### What this PR does
Before this PR:
- `file_entry` table used `trashed_at` for the soft-delete timestamp,
diverging from every other soft-deletable table in the schema (`agent`,
`assistant`, `message`, `topic`), which all use `deleted_at`.
After this PR:
- `file_entry.deleted_at` (and BO field `deletedAt`) — naming is
consistent across the entire schema.
- Renamed identifiers:
- Schema field: `trashedAt` → `deletedAt`
- SQL column: `trashed_at` → `deleted_at`
- Index: `fe_trashed_at_idx` → `fe_deleted_at_idx`
- CHECK constraint: `fe_external_no_trash` → `fe_external_no_delete`
- Updated all source files, tests, and architecture docs (including
`v2-refactor-temp/docs/file-manager/`).
- **Intentionally NOT renamed** (out of scope — these are API surface /
concept names, not the column name): `moveToTrash`, `restoreFromTrash`,
`inTrash` (query flag), `isTrashed`, `batchTrash`, `internalTrash`, and
"Trash" as a concept in comments/docs.
Fixes #
### Why we need it and why it was done in this way
The following tradeoffs were made:
- **Scope discipline**: kept the rename strictly at the
column-identifier layer (4 identifiers). Did not change API names or
concept words — switching the "Trash" concept to "Delete" is a larger
semantic change that deserves its own PR.
- **Migration 0026 contains a manual SQL patch.**
drizzle-orm/drizzle-kit issue
[#3653](https://github.com/drizzle-team/drizzle-orm/issues/3653) causes
the SQLite rebuild-table path to drop the leading `ALTER TABLE … RENAME
COLUMN` statement. The generated `INSERT … SELECT "deleted_at" FROM
file_entry` would fail because the source table still has `trashed_at`.
The migration manually prepends an explicit `ALTER TABLE file_entry
RENAME COLUMN trashed_at TO deleted_at;` before the rebuild. Upstream
fix landed in `drizzle-kit@1.0.0-beta`/`rc` but is not backported to the
`0.31.x` stable line we depend on.
- **Why keeping the manual patch is acceptable**: per `CLAUDE.md` § v2
Refactoring, `migrations/sqlite-drizzle/` is throwaway during v2 — it
will be wiped and regenerated as a single clean initial migration from
the final schemas before release. Mid-development DB drift is explicitly
acceptable, and the manual SQL only needs to survive until that
regeneration.
The following alternatives were considered:
- Selecting `create column` in `drizzle-kit generate` instead of `rename
column`: also produces invalid SQL (same root cause — the rebuild path
puts the new column name in the `SELECT` list regardless of the rename
mapping). Rejected.
- Skipping the `0026` migration entirely and relying on `db:push` / DB
reset during dev: pollutes `_journal.json` divergence and makes the next
schema change confusing. Rejected.
- Upgrading to `drizzle-kit@1.0.0-beta`/`rc` to get the fix: v1 is a
major rewrite with significant breaking changes (alternation engine
rewrite, ORM type system rewrite, migration folder layout change). Out
of scope for this PR. Rejected.
Links to places where the discussion took place: N/A
### Breaking changes
None. Dev-only DB column rename during v2 refactor. No user-visible
behavior change. No public API surface change. v1 data never reaches
this branch except through migrators in `src/main/data/migration/v2/`.
### Special notes for your reviewer
- The single manual edit to drizzle-generated SQL is in
`migrations/sqlite-drizzle/0026_sturdy_aqueduct.sql` — look for the
`MANUAL PATCH` comment block at the top. Without it the migration will
fail to apply.
- "Trash" concept words still appear throughout the file-manager
codebase by design (function names, comments, docs section headings). If
we later want to migrate the whole concept to "Delete", that should be a
follow-up PR.
### Checklist
This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.
- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others
### Release note
```release-note
NONE
```
---------
Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate all JobManager state writes to the new serialized write channel so
they share a single libsql safety boundary, replacing the removed
JobManager.globalDispatchMutex.
Two-form DAO pattern in JobService / JobScheduleService:
- *Tx methods take a DbOrTx and compose inside withWriteTx.
- Non-Tx methods become thin wrappers — callsite shape unchanged so
consumers do not need to know about withWriteTx.
JobManager changes:
- Delete globalDispatchMutex; dispatch claim now runs inside withWriteTx,
with queue.mutex preserved as Layer 1 per-queue tick serialization
(lock acquisition order: Layer 1 first, Layer 0 via withWriteTx).
- All remaining writes (scheduleRetry, finalizeJob, patchMetadata, cancel,
cancelMany) route through withWriteTx.
- scheduleRetry now degrades to finalizeJob('failed', retryable=true) on
persistent non-BUSY tx failure. BUSY is already handled by withWriteTx;
this fallback defends against SQLITE_CORRUPT / FULL / driver bugs that
would otherwise leave a row stuck in 'running'. finalizeJob's existing
synthesizeFailedSnapshot still releases the in-memory slot when its own
tx fails.
- spawnExecute's fire-and-forget IIFE gets a two-stage .catch chain so
any leaked exception (logger errors, fallback finalize failures) cannot
surface as UnhandledPromiseRejection.
Stranded `running` rows from any double-failure path are reclaimed by
runStartupRecovery on the next process start.
Updates concurrency-and-locks.md to reflect Layer 0's new home in
DbService. Extends JobManager.integration.test.ts with two regression
tests: scheduleRetry fallback degrade and spawnExecute outer-catch
swallow.
libsql client-ts upstream issue #288 makes PRAGMA busy_timeout ineffective
for async transactions, so concurrent db.transaction() calls reliably surface
SQLITE_BUSY. Introduce DbService.withWriteTx as a serialized write helper:
- Process-wide FIFO mutex (async-mutex) serializes write transactions.
- libsql client's default BEGIN IMMEDIATE protects against read-then-write
tx upgrade failures (no override needed at the drizzle layer).
- Single 50ms BUSY retry guards against transient external locks.
Reads do NOT need this — WAL gives readers snapshot isolation that is never
blocked by writers.
Includes unit tests (FIFO ordering, finally release on throw, single BUSY
retry, persistent BUSY rethrow, non-BUSY passthrough) plus a real-libsql
integration test. Updates the DbService test mock with a passthrough
withWriteTx so dependent services do not throw "is not a function" in
tests. Documents the API in database-patterns.md and points
CLAUDE.md / data-api-overview.md at the new pattern.
`LifecycleManager.allReady()` was holding `await Promise.allSettled(_doAllReady)`,
making every `onAllReady` body a synchronous bootstrap dependency. This
contradicted the hook's JSDoc ("post-bootstrap supplement, not a critical
initialization gate") and gave any in-hook deferred work an oversize blast
radius — a 60 s wait inside one service stalled `ALL_SERVICES_READY` and
delayed bootstrap completion.
Align the implementation with the JSDoc:
- `allReady()` becomes `void`. It synchronously invokes every initialized
service's `_doAllReady()`, attaches an async `.catch` that re-emits
`SERVICE_ERROR`, then emits `ALL_SERVICES_READY` immediately.
- `Application.bootstrap()` drops its `await` on `allReady()`.
- `LifecycleManager` tests adjusted: drop redundant `await`s, rewrite
`resolves.toBeUndefined()` as `not.toThrow()`, drain microtasks before
asserting on the now-async `SERVICE_ERROR` emit, and add a test
exercising the fire-and-forget contract with a never-resolving hook.
`ALL_SERVICES_READY` now fires when hooks are *invoked*, not when they
complete. Docs reflect the contract change: a "Hook vs Event" comparison
in `lifecycle-overview.md`, two new Common Mistakes in
`lifecycle-decision-guide.md`, and an `onAllReady` business-work pattern
template in `lifecycle-usage.md` showing the \`setTimeout\` + signal +
\`onStop\` join model used by JobManager.
`onAllReady` is a post-bootstrap supplement, but JobManager was holding
a 60 s `await setTimeout` plus four IO steps inside it. The hook became
a 60 s blocking call and left `onStop` without a join point for the
deferred recovery.
Split the two concerns:
- `onAllReady` is now a synchronous `void` that schedules a `setTimeout`
(cleared via `registerDisposable`) and returns.
- The recovery body moves to a private `runStartupRecoveryFlow` whose
every IO step re-checks `_isShuttingDown`.
- A new `_recoveryDone: Promise<void> | undefined` lets `onStop` join
the flow before tearing down dependent resources.
- `_onAllReadyStopRequested` is renamed `_isShuttingDown` to reflect
its expanded role.
- `detectAndDispatchOverdue` gains a per-loop shutdown check so a slow
`onMissed` cannot starve `onStop`.
Test fixtures (smoke / schedule / integration) now access `_recoveryDone`
after fake-timer advancement instead of awaiting the lifecycle hook.
Docs synced: new "Startup Recovery" section in
`job-and-scheduler/overview.md`; "Registration timing" + recovery
internals + timeout-sentinel notes in `handler-authoring.md`; `once` /
`interval` trigger lifetime in `scheduler-usage.md`; lock acquisition
order in `concurrency-and-locks.md`. Module READMEs add pointers.
JobManager.onAllReady now walks distinct (queue, type) pairs for all
non-terminal rows and calls ensureQueue() for each. Previously
dispatchAll() iterated an empty this.queues Map on cold start, so rows
reset by runStartupRecovery stayed pending until the next enqueue
incidentally ensured the right queue. This bridges that gap so recovered
jobs dispatch on the same tick recovery finishes.
Additional changes bundled in:
- JobService.getDistinctActiveQueues: SQL groupBy(queue, type) over
pending/delayed/running, returned shape aligned with ensureQueue input
- Integration tests: retry/singleton cases extended to assert the full
recovery -> resurrect -> dispatch -> completed chain; new case for
resurrection of a pre-existing pending row; bootstrapManager drains
the dispatch chain under fake timers before switching back to real
timers so handler internal setTimeouts are not cancelled mid-flight
- useJob: add sibling useJobProgress hook (no DataApi fallback; reads
only the shared cache that JobManager publishes via reportProgress)
- handler-authoring docs: add organization convention (handlers live in
<module>/tasks/, file naming *JobHandler.ts)
- JobManager.makeError: replace repeated (err as Error & {...})
assignments with Object.assign for equivalent behaviour in one expr
Four backbone changes that close known gaps in the schedule subsystem before the next round of business handler work:
* DB-enforced singleton: drop the app-layer Mutex in JobScheduleService and let UNIQUE(type, name) enforce "one singleton per type" via a '' sentinel. rowToSnapshot maps '' back to null so the external snapshot contract stays `string | null`.
* updateJobSchedule public API: writes the DB row and re-arms the in-process cron entry when trigger or enabled changes. Field-presence check avoids JSON.stringify brittleness; the one-turn race is an accepted last-writer-wins limitation.
* Startup recovery gating: move runStartupRecovery + armSchedule from onReady to a new onAllReady behind a 60s delay so business services have a window to register handlers before recovery runs. onStop flips a stop flag for clean teardown; per-step try/catch so one failure does not zero the session.
* Schedule control API tests: cover pause/resume/triggerNow/unregister × by-id/by-name plus updateJobSchedule branch matrix; JobScheduleService gets its own unit suite for singleton/sentinel behavior.
Side effects: add JOB_SCHEDULE_NAME_INVALID code; fix pre-existing arg order on DataApiErrorFactory.conflict at unique handlers (message, then resource); tighten getByTypeAndName signature to (type, name: string); listNamesForType filters the singleton sentinel and returns string[]; sync existing integration/smoke fixtures to await _doAllReady() with fake timers; add "Schedule identity: (type, name) model" section in handler-authoring.md.
Documentation pass that wraps up Phase 1 by surfacing how to find and use
the Job/Scheduler system, plus bringing the public-API JSDoc in line with
the project convention (summary + @param/@returns/@throws tags).
- Add `src/main/core/job/README.md` and `src/main/core/scheduler/README.md`
as thin pointers into `docs/references/job-and-scheduler/`, matching the
Lifecycle/Application README style (file structure + Quick Links table,
no duplicated content).
- Add `docs/references/job-and-scheduler/scheduler-usage.md` documenting
the decision tree for `SchedulerService` vs `BaseService.registerInterval`
vs raw `setInterval`, the three judgment questions, common mistakes,
and reserved id prefixes used internally by JobManager.
- Tighten `overview.md` so the dispatch lock acquisition order matches the
code: Layer 1 (per-queue) first, then Layer 0 (global libsql tx). Make
the fixed order explicit to prevent future deadlock-introducing refactors.
- Upgrade public-API JSDoc on JobManager (registerHandler / enqueue / cancel
/ cancelMany / get / list / dispatch + full schedule registry: by-id and
by-name variants) with @param / @returns / @throws including error codes
(JOB_UNKNOWN_TYPE / JOB_PAYLOAD_TOO_LARGE / JOB_CANCEL_REASON_TOO_LONG /
JOB_SCHEDULE_NAME_REQUIRED / JOB_SCHEDULE_NOT_FOUND_BY_NAME /
JOB_SCHEDULE_NAME_CONFLICT / JOB_SCHEDULE_SINGLETON_EXISTS).
- Upgrade SchedulerService public-API JSDoc on registerSchedule / pause /
resume / unregister / triggerNow / getNextRun / has with @param /
@returns; tighten the class-level doc to describe `protect: true`
semantics and the chained-setTimeout interval implementation.
- Add descriptive block comments to private state-machine transition
helpers (finalizeJob / scheduleRetry / armSchedule / detectAndDispatch-
Overdue / runGC / handleFor / publishState / computeBackoff / dispatchAll)
to document non-obvious design decisions, and inline-document the fixed
lock acquisition order inside `dispatch` to prevent accidental reversal.
No runtime behavior change. lint / format / docs:check-links all clean.
cherry-studio v2 had 6+ ad-hoc queue/scheduler implementations (Knowledge,
FileProcessing, agent SchedulerService, TopicQueue, NotificationQueue,
protocol heartbeats) with no shared registry, inconsistent cancel and
progress semantics, and no cross-restart recovery outside agent_task.
This commit lands the unified replacement: JobManager owns Job lifecycle
and SchedulerService owns time-only scheduling, both reusable independently.
Phase 1 ships the backbone only: jobTable + jobScheduleTable, entity
services, 6-state machine with per-handler recovery (abandon/retry/
singleton), catch-up policy (skip-missed/after-startup), retry backoff,
GC, idempotencyKey dedup, useJob renderer hook, and 4 reference docs.
Four-layer lock model addresses libsql client-ts issue #288 (Layer 0
global dispatch mutex serializes all transactions).
croner@^10 is introduced for cron expressions (zero-dep, Electron-friendly).
Application.SHUTDOWN_TIMEOUT_MS is promoted to public for JobManager reuse.
cron-parser stays until Phase 2 agent task migration.
No business migrations in this commit — Knowledge/FileProcessing/agent
SchedulerService remain untouched and migrate in Phase 2-4 per
docs/references/job-and-scheduler/migration-checklist.md.
Follow-ups for Phase 1 completion: DataApi GET /jobs handler, dummy.echo
smoke test, integration tests, migration feasibility report.
The rule that schema files in packages/shared/data/api/schemas/ are
organized by the entity's domain (not URL prefix) was followed in code
but never written down, so readers could reasonably misinfer "the URL
parent decides the file" from routes like /topics/:topicId/messages
living in messages.ts.
Add a Schema File Organization section to api-types.md, with a small
table comparing routes whose URL parent and returned entity disagree,
and a cross-reference note from api-design-guidelines.md so path-design
readers land on the right page.
- data-api-in-main: add Registry Sub-Resource Endpoints section -
GET-only for stateless reads, AIP-136 colon notation for derived
views, registry packages are main-only (bundle waste + merge
already in main)
- best-practice-layered-preset-pattern: preset-only static fields
must merge in rowToEntity rather than via parallel endpoint;
document acceptable exceptions for catalog and specialised
surfaces
- data-ordering-guide section 2: drop user_provider.isEnabled from
the Live partition list - the table is whole-table ordered
(already correct in section 7)
- database-patterns: flag boolean columns without .notNull() as a
common R3 offender, with concrete wrong/right example
All service methods accepting a Drizzle transaction now follow:
- tx is the first parameter
- method name ends with Tx
Renamed:
- pinService.purgeForEntity / purgeForEntities -> *Tx
- tagService.purgeForEntity / purgeForEntities -> *Tx
- TagService#assertTagsExist (private) -> assertTagsExistTx
- PromptService#assertPromptsExist (private) -> assertPromptsExistTx
- AssistantService#syncRelations (private) -> syncRelationsTx
- AgentService#syncSettingsToSessions (private) -> syncSettingsToSessionsTx
The convention is documented in docs/references/data/data-api-in-main.md
under the new "Transaction Method Naming" section. Behavior is unchanged.
Wraps setInterval with auto-unref, exception isolation, and cleanup
via the existing registerDisposable channel. Returns a Disposable.
Replaces 5 ad-hoc setInterval patterns (CacheService, PreferenceService,
WindowManager, ProxyManager, FileProcessingTaskService) — unifies
three pre-existing cleanup styles and fixes a missing unref() in
ProxyManager.
WindowManager: warmupGcTimer cleanup moves from onDestroy to onStop
(via registerDisposable) — acceptable for this singleton.
Skipped (YAGNI): registerTimeout, immediate/unref options,
activation-scoped variant. QuickAssistantService keeps bare
setInterval — its lifecycle is finer than activation.
### What this PR does
Migrates the MiniApp feature from v1 (Redux + sidecar
`custom-minapps.json`) to the v2 data architecture (DataApi + Preference
+ Cache), and integrates it into the v2 AppShell tab system.
**Before this PR**
- App lists lived in three Redux arrays (`enabled` / `disabled` /
`pinned`); custom-app logos were stripped before persistence and
recovered at runtime from `{userData}/Data/Files/custom-minapps.json`.
- Settings (`region`, `max_keep_alive`, `open_link_external`,
`show_opened_in_sidebar`) lived in legacy redux/electron-store.
- Runtime keep-alive used a module-level `lru-cache` singleton, mirrored
into v2 cache via `onInsert` / `disposeAfter` (two sources of truth —
already a known race).
- Routes were `/app/minapp/*`; sidebar icon literal was `'minapp'`.
- Sidebar mode used the legacy popup container; top-navbar mode was
non-functional.
**After this PR**
- A single `mini_app` SQLite table owns every row (preset + custom).
Preset rows are seeded by `MiniAppSeeder` from `PRESETS_MINI_APPS` on
every boot; custom rows come in via `POST /mini-apps`. The seeder uses
`setWhere isNotNull(presetMiniappId)` so refreshing preset display
fields can never overwrite a custom row whose `appId` happens to collide
with a preset.
- `MiniAppMigrator` imports v1 Redux state and reads
`custom-minapps.json` (path resolved through
`MigrationPaths.customMiniAppsFile`) to recover stripped logos.
- Settings live under typed Preference keys
(`feature.mini_app.{region,max_keep_alive,open_link_external}`); sidebar
icon literal renamed `'minapp'` → `'mini_app'` with a complex preference
transform that rewrites existing user arrays in-place.
- API: `GET/POST/PATCH/DELETE /mini-apps` + `POST
/mini-apps/order:batch`, Zod-validated, fractional-indexing ordering
scoped by `status` (cross-status batches are rejected with
`VALIDATION_ERROR` per the data-ordering-guide contract). Status
transitions reassign `orderKey` to the tail of the target partition
inside a transaction.
- Renderer hook `useMiniApps` exposes **command-style** writes only:
`updateAppStatus(id, status)` and `setAppStatusBulk([{id, status}])`.
The legacy declarative `updateMiniApps(list)` /
`updateDisabledMiniApps(list)` / `updatePinnedMiniApps(list)` are gone —
they took region-filtered subsets and silently disabled rows the caller
never saw.
- Keep-alive list is stored solely in
`useCache('mini_app.opened_keep_alive')`. Cap eviction respects AppShell
pin status: `useMiniAppPopup` reads pinned mini-app routes from
`useTabs` and skips them in eviction. `MiniAppTabsPool` renders webviews
in a stable `appId`-sorted order so LRU reorders never move `<webview>`
DOM nodes (Electron `<webview>` loses its guest WebContents on
detach/reattach).
- **Unified launch path**: clicking any miniapp (from the launcher grid
or a top tab bar entry) calls `openTab('/app/mini-app/<id>', { title,
icon: app.logo })`. A globally-mounted `<MiniAppTabsPool>` in `AppShell`
keeps a `<webview>` alive per opened app, regardless of sidebar vs
top-navbar layout.
- Settings UI rewritten as a `PageSidePanel` drawer composed of
`MiniAppListPair` (visible / hidden columns with drag-drop) and
`MiniAppDisplaySettings` (region / cache slider). New custom-app form is
a separate `NewMiniAppPanel` drawer.
- Sidebar's running-mini-apps strip removed — opened apps live
exclusively in the top tab bar (per #3198804265). Companion preference
`feature.mini_app.show_opened_in_sidebar` deleted from the schema.
### Why we need it and why it was done in this way
Part of the broader v2 data-layer migration (Redux/Dexie/ElectronStore →
DataApi + Preference + Cache).
**Architecture**
- DataApi for entity rows (preserves user content); Preference for
atomic settings; Cache (Memory tier) for runtime ephemera.
- Layered preset pattern (`best-practice-layered-preset-pattern.md`):
preset and custom rows share the same table, discriminated by
`presetMiniappId`. Seeder refreshes preset display fields on re-run;
custom rows are immutable to the seeder.
- Region filtering is a **view-only** concern (read path); the write
path is command-style and never references region. This eliminated a
class of bugs where editing the visible (filtered) list caused
region-hidden rows to drift.
- AppShell tab pinning is the canonical "keep this loaded" signal. The
keep-alive cap respects it; pinned mini-app tabs never get evicted
regardless of cap. Render-order independence in `MiniAppTabsPool`
ensures LRU touches don't move `<webview>` nodes around.
- Per-app icon resolution: `app.logo` is a `CompoundIcon` id (e.g.
`"Moonshot"`) for presets and a URL for custom apps. UI consumers (tab
bar, sidebar entry, settings list) call `getMiniAppsLogo` to resolve the
id to a `CompoundIcon` before rendering, with `<img>` fallback for URL
strings.
- Per-entity tab icons are cleared on internal navigation, sidebar
reuse, and the top-bar settings button — three call sites that all flip
the active tab's URL now consistently reset `icon: undefined` so a
mini-app logo never sticks onto an unrelated route.
**Tradeoffs**
- `useMiniApps` still exposes `miniapps` (region-filtered
enabled+pinned) and `disabled` (region-filtered). These are display-only
views. Renamed/typed wrappers were considered but deferred — the
refactor to command-style writes already eliminated the bug class that
motivated the rename.
- The `applyReorderedList` integration test for
`reorderMiniAppsByStatus` was dropped — `MockUseDataApiUtils` doesn't
fill the SWR cache that `useReorder.readCurrent` reads. Splice logic is
straightforward and the server-side `applyScopedMoves` test covers the
contract.
- Sidebar primitives in `@cherrystudio/ui`-adjacent layout still accept
`miniAppTabs` / `onMiniAppTabClick` props (defensive defaults — render
nothing without a producer). Removing these from the primitive's API is
a separate refactor not in scope.
### Breaking changes
User-visible changes are auto-migrated by the v2 migration framework —
no manual user action required:
- Sidebar icon literal `'minapp'` → `'mini_app'` (rewritten by the
`sidebar_icons_rename` complex preference transform)
- Preference key rename `feature.minapp.*` → `feature.mini_app.*`
(auto-migrated via `classification.json`)
- Custom-app logos stripped from v1 Redux are recovered from
`custom-minapps.json` during migration
One product-shape change is documented under
`v2-refactor-temp/docs/breaking-changes/`:
- `2026-05-07-miniapp-sidebar-running-list-removed.md` — the sidebar no
longer surfaces opened mini-apps under the mini-app entry. Open apps are
accessed exclusively via the top tab bar; pin a tab to keep its state
across switches.
The legacy v1 preference `showOpenedMinappsInSidebar` is reclassified as
`status: deleted` in the migration pipeline; v1 values are dropped
during v1→v2 migration with no v2 destination.
### Special notes for your reviewer
**Verified end-to-end on a real dev profile**: v1 Redux state +
`custom-minapps.json` → v2 SQLite, including pinned-app cross-group
dedup (a v1 pinned app appears in both `pinned` and `enabled` Redux
arrays; the migrator counts duplicates as skipped so the engine's
`targetCount >= sourceCount - skippedCount` invariant holds — without
this, any user with pinned miniapps was blocked from migrating).
**Drizzle migrations** are throwaway in dev per `CLAUDE.md`.
`migrations/sqlite-drizzle/0020_even_hulk.sql` is the single regenerated
migration; it will be wiped to a clean initial migration before release.
**Review history**: 28 line-comments across multiple formal review
rounds. All resolved. The most consequential fixes:
- `applyScopedMoves` in `MiniAppService.reorder` — rejects cross-status
batches with `VALIDATION_ERROR` instead of silently splitting them.
- `update()` reassigns `orderKey` to a fresh tail in the target
partition on status change.
- Empty-string substitution in migrator mappings is now caught by the
post-transform validity check; bad rows are skipped + warned, never
inserted.
- Migrator validation switched from `limit(5)` sample to full `count(*)
WHERE empty-fields` — bad rows can no longer pass validation by virtue
of being beyond the sample window.
- Keep-alive cap exempts pinned tabs (#3198809321 + the kangfenmao
keepalive review); render order in `MiniAppTabsPool` is `appId`-stable
so LRU touches don't move `<webview>` nodes (this was the root cause of
"switching tabs reloads the webview").
**Out of scope**:
- The remaining `@renderer/store/tabs` import in
`PaintingsRoutePage.tsx` is pre-existing v1 residual (not introduced or
touched by this PR).
### Checklist
- [x] PR: description rewritten to reflect the final architecture +
integration with the AppShell tab system
- [x] Code: command-style writes (`updateAppStatus` /
`setAppStatusBulk`); see `useMiniApps`, `MiniAppService`,
`MiniAppMigrator`, `MiniAppTabsPool`, `useMiniAppPopup` for the main
entry points
- [x] Refactor: ~1500 lines of dead/legacy code removed
(`Tab/TabContainer`, `TabsService`, `MiniAppPopupContainer`,
`TopViewMiniAppContainer`, legacy LRU singleton, `PinnedMiniApps`, dead
`userOverrides` / `MiniAppRegistryService`, unused `Signal.ts`)
- [x] Upgrade: v1 → v2 migration verified end-to-end on a real dev
instance
- [x] Documentation: architecture covered by `docs/references/data/`;
one user-visible behavior change documented in
`v2-refactor-temp/docs/breaking-changes/`
- [x] Self-review: multi-agent review via `/gh-pr-review` (twice); all
28 review comments resolved
### Release note
```release-note
NONE - Internal v2 data refactor. User-facing renames (route, sidebar icon, preference keys) are auto-migrated. The sidebar no longer shows a running-mini-apps strip; opened apps live in the top tab bar.
```
---------
Signed-off-by: suyao <sy20010504@gmail.com>
Signed-off-by: chengcheng84 <hello_world0000@outlook.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
The "DB defaults are near-permanent" guidance previously claimed
drizzle-kit cannot auto-generate the SQLite table rebuild for DEFAULT
changes. That's incorrect: drizzle-kit emits the full PRAGMA / CREATE
__new_xxx / INSERT SELECT / DROP / RENAME / re-create-indexes sequence
automatically.
Rewrite the supporting argument from "tooling can't do it" to:
- every change forces a full-table rebuild at runtime (schema lock,
~2x temporary disk, slow on multi-GB tables);
- DEFAULT changes never touch existing rows;
- legacy NULL backfill must be hand-written into the rebuild's
INSERT SELECT line via COALESCE - drizzle-kit will not synthesize
that.
Conclusion (near-permanent) and Safe bias remain unchanged - only the
underlying mechanics are corrected.
Drop the drizzle-orm#2489 reference and the "drizzle-kit generate
--custom" workaround it implied.
Codify what was implicit: putting a value into a DB column DEFAULT for the first time costs nothing, but changing it later in SQLite is expensive and asymmetric (no ALTER COLUMN SET DEFAULT; drizzle-kit emits only an explanatory comment without naming the affected column — drizzle-orm#2489). So the first write is effectively the final one, and the placement bias should flip from "DB by default" to "service unless certain".
Verified via drizzle-orm maintainer (Andrii Sherman) Medium article, drizzle-orm GitHub issues #2489 / #5360 / #1313, and Drizzle docs via context7. Empirical: this repo's 7 existing migrations are all ALTER TABLE ADD COLUMN — zero ALTER COLUMN — confirming the team has so far avoided the manual rebuild path organically.
Spec changes in best-practice-default-values-and-nullability.md:
- New section "DB defaults are near-permanent" between DM2 and Quick chooser
- DM2 → DB DEFAULT row note tightened, links to new section
- Quick chooser flipped: "unsure" now → Service ?? (was "try DB first")
- Standard Layered Design: emoji moved from DB DEFAULT to service ?? (product-chosen value)
- Anti-patterns: emoji-mask row's Correct column updated; new row for "speculative DB DEFAULT thinking I can tune later"
- Case Study A's Fix description aligned with new bias
- Related References: drizzle-orm#2489 added
Companion update in database-patterns.md: same precision for the DB .default('X') cell in "Where the default value lives".
Establish team standards for placing default values across the data stack (DB DEFAULT / Drizzle $defaultFn / Zod .default() / service ??) and judging column nullability.
Originating context: PR #14689 fixed a PATCH leakage bug rooted in defaults living in three places at once (DB, Zod Create schema, row mapper) for the assistant entity. The follow-up discussion recovered general principles that other entities (agent, message) also violate; this doc captures them as a reference for future schema/service work.
- New: best-practice-default-values-and-nullability.md — Five rules (R1-R5), decision matrices for nullability and default placement, standard layered design example, anti-patterns table, case studies (assistant, modelId, agent.accessiblePaths)
- api-design-guidelines.md: refine Rule C Update derivation guidance; add Rule E discouraging Zod .default() on entity / Create / Update schemas
- data-api-in-main.md: upgrade row-mapper ?? fallbacks from "needs hand-write" tolerance to anti-pattern; add Write-path defaults section codifying R4
- database-patterns.md: add Column Nullability and Defaults section; add R3 no-fabricated-fallbacks bullet to Row → Entity Mapping
- README.md: index entry under Reference Guides
No code changes. Implementation follow-up will land in separate PRs that apply these rules entity by entity.
Mirror the existing single-row purgeForEntity on PinService and TagService
with a purgeForEntities(tx, entityType, ids[]) variant. Empty input is a
no-op; non-empty input collapses N round trips into one and emits a single
aggregated log line with a count, removing the "loop and log per id" smell
when consumers later cascade deletes for many entities of the same type.
Document a Cross-Service Table Access rule in data-api-in-main.md:
- Writes to a foreign table must go through the owner (pass tx into its
method) — owners are the single source of truth for invariants and the
emitter of mutation logs; foreign writes split that knowledge.
- Reads that inline-JOIN a foreign table are allowed when JOIN is the
simpler path (e.g. AssistantService.list pulling per-row tags).
The new bulk methods are intentionally landed without a consumer so that
the "add a bulk method on the owner instead of writing raw SQL from the
caller" path described by the rule is available the moment a cascading
delete appears, rather than nudging the first author to bypass the owner.
Fixes#14593 (mutate type collapses to CursorPaginationResponse, erasing
subtype fields like BranchMessagesResponse.activeNodeId) and #14594
(default flatMap bakes in a "page-load order == display order" assumption
that breaks on branch-walk + column-reverse layouts). Also closes a dual
silent-failure where useInfiniteQuery silently accepted offset paths
(stuck at page 1) and usePaginatedQuery silently accepted cursor paths
(total stays 0).
useInfiniteQuery now exposes raw pages: TResponse[] (no items field),
preserving subtype fields and typing mutate as SWRInfiniteKeyedMutator
correctly. The new useInfiniteFlatItems hook derives the flat list with
explicit reversePages / reverseItems switches, so flattening is no longer
hidden behind an implicit page-load ordering. Path generics on both hooks
are gated via CursorPaginatedPath / OffsetPaginatedPath, built on
InferPaginationMode so the optional nextCursor field cannot collapse
offset paths into the cursor guard structurally.
DEFAULT_SWR_OPTIONS realigned for IPC (not HTTP) semantics: DataApiService
is the single retry decision point (shouldRetryOnError: false), reconnect
revalidation disabled, keepPreviousData enabled to suppress search/
pagination flicker. loadNext drops its isValidating guard (SWR's
dedupingInterval is the right dedup site); usePaginatedQuery
reset-on-query-change uses unstable_serialize to be key-order independent.
useInfiniteQuery had zero consumers when rewritten — the breaking removal
of the items field carries no migration cost. Comprehensive test coverage
for type contracts, flat-items behavior, infinite integration, and
paginated reset; renderer data docs synced.
Extend the pool warmup state machine to support singleton windows via an
optional `singletonConfig` (eager pre-warm + retentionTime-based close→hide
with delayed destroy). Generalize shared symbols (PoolState→WarmupState,
lastOpenAt→lastActivityAt, pool*→warmup* on non-pool-specific methods) and
fix a half-bug where pool inactivity only counted since last open, not
since last open or close.
Op naming convention: lifecycle-specific ops carry `pool-*` / `singleton-*`
prefixes; ops shared by both (create-idle, release-skip, inactivity-trim,
warmup) are unprefixed so log greps stay precise.
Main is intentionally NOT migrated — its close handler reads tray
preferences, quits the app on Win/Linux, guards on isFullScreen, and
toggles Dock visibility; none of that fits the declarative `retentionTime`
contract. Registry entry now documents this decision inline.
All 14 per-module handler files used the same 7-line mapped type.
Extract it into a single HandlersFor<Schemas> helper in apiTypes and
migrate each file to a one-line annotation (net -119 lines).
The helper is defined as Pick<ApiImplementation, Extract<keyof Schemas,
keyof ApiImplementation>> rather than re-applying ApiHandler<Path, Method>
over generic parameters -- the latter triggers TS2590 (union too complex)
because ApiHandler nests several conditional types that must resolve
symbolically when Path is a free type variable. Indexing the
already-evaluated ApiImplementation sidesteps that.
Also elevate the annotation rule to a dedicated "Handler Type Annotation"
subsection in the data-api-in-main doc and add a type-level regression
matrix under handlers/__tests__.
Align cache-overview.md and cache-usage.md with the template-key and
subscribe* APIs (sharedCasual was dropped in 3fbc52e05). Extract the
"adding keys" content into a new cache-schema-guide.md aligned with
preference and boot-config schema guides. Lift non-obvious invariants
(isEqual short-circuit, TTL-with-hooks warning, Persist has no delete,
Main-wins convergence, template placeholder rules) into a first-class
Design Invariants section in the overview.
Fix two code-contradicted claims:
- useCache does not accept a TTL options argument (hook signature is
(key, initValue?)).
- Persist is renderer-authoritative; Main only relays IPC and does
not store (CacheService.ts:477-479 is "Reserved, not implemented").
Update peripheral references in the same pass so cross-references stay
coherent: v2-renderer skill, CLAUDE.md, architecture-overview.md,
api-design-guidelines.md (Cache vs DataApi matcher contrast), and the
package READMEs.
Net change: +249 / -579.
Enables main-process services to react to cache changes without writer-side
wiring — unblocks the web-search/OCR provider rotation use case where new
providers would otherwise require manual hook-ups at every write site.
Also unifies equality across main and renderer on lodash.isEqual (fixing
redundant cross-window broadcasts when Record/Array values are rebuilt on
every write) and extracts template utilities to the shared package so both
processes use one implementation.
Align SharedCache type system with Memory (UseCache) template support and
remove the sharedCasual escape hatch that existed only because SharedCache
could not type-check dynamic keys:
- Introduce InferSharedCacheValue and expand SharedCacheKey through
ProcessKey so template schema entries like
'web_search.provider.last_used_key.${providerId}' match concrete keys
with precise value types on both Main and Renderer.
- Extend useSharedCache hook with findMatchingSharedCacheSchemaKey /
getSharedCacheDefaultValue, mirroring useCache's template-aware default
resolution.
- Remove getSharedCasual / setSharedCasual / hasSharedCasual /
deleteSharedCasual / hasSharedTTLCasual from the Renderer CacheService
and all test mocks; Main CacheService never had them.
- Migrate BaseWebSearchProvider and OcrBaseApiClient rotation from
sharedCasual to type-safe getShared/setShared. Rename keys to conform
to schema naming rules (ESLint data-schema-key/valid-key):
web-search-provider:${id}:last_used_key
-> web_search.provider.last_used_key.${providerId}
ocr_provider:${id}:last_used_key
-> ocr.provider.last_used_key.${providerId}
Old values under legacy key names become orphans after rollout; this
is acceptable because rotation state is transient and consumers
reinitialize from keys[0] on a miss.
- Add type-level assertions for SharedCacheKey and InferSharedCacheValue
in useCache.types.test.ts to lock the contract in CI.
- Update cache-overview.md, cache-usage.md, and tests/__mocks__/README.md
to describe SharedCache's template support and reflect that casual
methods now exist only on the Memory tier.
Expose pushInitData(windowId, data) and pushInitDataToType(type, data)
as main-process public methods on WindowManager. Both reuse the existing
initDataStore plus WindowManager_Reused IPC channel, so the renderer's
useWindowInitData hook picks up the new payload in-place without any
remount or DOM churn. This closes the gap between cold-start and reuse
paths: previously, updating an already-visible window required close()
plus open(), which lost interaction state on singletons.
Signatures forbid undefined to keep "push nothing" from silently
no-opping; unlike the reuse path, undefined has no meaningful semantics
here. pushInitDataToType does not filter by visibility — idle pooled
windows still receive the payload so they have the latest data when
taken out of the pool next.
- Add "Zod Schema & DTO Conventions" section to api-design-guidelines.md
covering the four rules applied across the prior refactor commits:
A. type (not interface) for XxxSchemas route tables
B. XxxSchema for Zod constants, XxxDto for TypeScript type names
C. EntitySchema.pick({...}) whitelist derivation with field atoms
and z.strictObject (guarded against overposting)
D. handwritten Zod everywhere; no drizzle-zod; no pure TS interface
DTOs (responses stay as interface — opposite direction of the
IPC trust boundary)
- Include a decision rule for when to extract an XXX_MUTABLE_FIELDS
constant: both Create/Update share the pick set AND fields >= 5
- Sync example code in api-types.md to the new conventions
- Flip the TopicSchemas example in data-api-in-main.md from interface
to type
Previously the hook hard-coded a `{ items: Array<...> }` cache contract,
so the two resources actually shipping OrderEndpoints — /pins and
/groups — would have thrown `length mismatch` on first drag because
their GET responses are flat arrays (Pin[], Group[]).
Auto-detect flat arrays and `.items`-bearing objects; expose paired
selectItems/updateItems accessors for nested shapes (grouped views,
GraphQL-style connections, envelopes with a different field name).
Half-configured accessors throw at hook construction.
Degradation splits into two tiers: "cache not loaded" (all paths no-op
and warn per call) vs "shape unrecognized" — move/applyBatch let the
PATCH through since id + anchor are self-contained, while
applyReorderedList refuses since blind whole-list reorder without a
client baseline is unsafe. Unrecognized-shape warnings are
de-duplicated per hook instance.
Paginated shapes (OffsetPaginationResponse / CursorPaginationResponse)
now preserve non-items metadata (total, page, nextCursor) on optimistic
overlays.
Update the guide to match what's now landed in code:
- §2: drop the `reserved groupId = '__pinned__'` bucket teaching — pin
is a separate table, not an overloaded scope value. Point readers
to the schema/service JSDoc for resource-design details.
- §3: add applyScopedMoves to the helper table.
- §3.1 (new): document the scoped-reorder contract (service-side
scope inference, multi-scope batch rejection, missing-id semantics)
in one short section.
- §9 "Group Ordering" upgraded from future-extension placeholder to
spec, trimmed to ordering-relevant points only.
- §10 "Pin Ordering" added, also ordering-only. Polymorphic shape,
purgeForEntity contract, idempotent pin semantics, and hard-delete
rationale are deliberately NOT duplicated here — they live in
pin.ts / PinService.ts JSDoc where the code is.
- Partition-dimensions list and migration checklist updated so
`group.entityType` / `pin.entityType` show as live rather than
hypothetical.
Add `.notNull()` to `createdAt` / `updatedAt` in the shared
`createUpdateTimestamps` helper so Drizzle `$inferSelect` produces
`number` instead of the misleading `number | null`. `deletedAt` in
`createUpdateDeleteTimestamps` stays nullable (soft-delete semantics).
Generated migration 0013 rebuilds 26 affected tables via the standard
SQLite table-recreation pattern; FK / CHECK / INDEX constraints are
preserved across rebuild. No backfill is added (project is in the
development phase; null pre-existing rows are accepted as a "wipe DB"
signal rather than engineered around).
Fix upstream in `KnowledgeMappings.toTimestamp` so it returns a
`Date.now()` fallback instead of `undefined` — otherwise future Dexie
-> v2 migrator runs would try to insert undefined into NOT NULL
columns. Three test assertions updated from `undefined` to
`expect.any(Number)`.
Sweep 18 downstream call sites across 9 functions that were carrying a
dead `?? new Date().toISOString()` fallback:
- AssistantService, KnowledgeBaseService, KnowledgeItemService,
MessageService, TopicService, TranslateHistoryService,
TranslateLanguageService (the original Pattern A set)
- McpServerService and MiniAppService.rowToMiniApp (reclassified from
Pattern B: the domain types stay `optional` to accommodate builtin
literals in the renderer, but `string` assigns legally into
`string | undefined`, so the switch is safe)
Keep `MiniAppService.builtinToMiniApp` on `timestampToISOOrUndefined`
— its `dbRow?: MiniAppSelect` semantics ("the preference row may not
exist at all") is genuinely optional, not a disguised "nullable column".
Also remove a Pattern C that neither the plan nor the grep audit
caught: `TagService.ensureTagTimestamp` was a self-rolled defense
layer that threw INTERNAL_SERVER_ERROR on null timestamps. The DB
now refuses to produce such rows, so the defense — and the test
named "should surface timestamp anomalies instead of masking them" —
are dead code. Removed both.
Update three docs to reflect the new defaults:
- `services/utils/README.md` — drop the "DB still nullable" table row
and the predictive paragraph; reframe Pattern B around
"whole row may not exist"
- `services/utils/rowMappers.ts` JSDoc — same reframing
- `docs/references/data/data-api-in-main.md` — delete the fallback
code samples and simplify Convention §3
Introduce shared `services/utils/rowMappers.ts` exporting three helpers:
`nullsToUndefined` (renamed from the previous `stripNulls`, with a
corrected type signature that preserves `notNull()` columns unchanged),
`timestampToISO` for guaranteed-present timestamps, and
`timestampToISOOrUndefined` for nullable ones.
Migrate 9 services off hand-rolled null/date handling:
- Eliminate the `stripNulls` duplicate in MiniAppService
- Replace 18 repetitive createdAt/updatedAt ternaries with helper calls
- Fix McpServerService misuse where an optional domain field was being
forced to a synthesized "now" value; restore honest undefined semantics
- Simplify KnowledgeBaseService.rowToKnowledgeBase via the spread idiom,
bypassing `clean` for the `T | null` embeddingModelId field
Document the paradigm in docs/references/data/data-api-in-main.md with
standard and advanced skeletons, plus an explicit "when NOT to spread"
list covering services with field renaming, custom fallbacks, computed
fields, or sensitive-data sanitization. Per-helper design decisions
(shallow vs. recursive, rejected alternatives) live in
services/utils/README.md.
Extend the renderer data hooks to cover three mutation shapes that
previously had to drop to imperative dataApiService calls:
- Template paths (e.g. `/providers/:providerId`) with a runtime `params`
option on useQuery / useMutation / useInfiniteQuery / usePaginatedQuery,
so a single hook instance can operate on ids chosen at call time.
ParamsForPath derives types directly from the schema's existing
`params: {...}` declarations (no template-string parsing).
- Function-form `refresh: ({ args, result }) => ConcreteApiPaths[]` for
invalidation keys that depend on trigger input or server response. Args
are closure-captured at trigger entry to avoid races between concurrent
calls.
- Explicit `/*` suffix for path-segment prefix matching on refresh and
invalidate patterns, preserving the trailing slash so `/providers/*`
doesn't match siblings like `/providers-archived`.
A single `resolveTemplate` function is the canonical path-replacement
point, so `useQuery('/providers/:id', { params: { id: 'abc' } })` and
`useQuery('/providers/abc')` produce byte-for-byte identical cache keys.
Dev-mode assertions flag invalid `/*` patterns (bare wildcard or missing
trailing slash) and warn on concurrent triggers against the same template
hook instance, which would share SWR mutation state.
Fully backward compatible: existing `refresh: ['/topics']` and
concrete-path hook calls compile and behave identically.
The mocks README described what was mocked but never stated that
tests/__mocks__/main/ is intentionally limited to cross-cutting
infrastructure. Without that boundary in writing, feature-specific
lifecycle services were getting registered into defaultServiceInstances,
bloating the global default surface without serving any other test.
Adds an explicit Scope section with a pattern-selection table, documents
the canonical local-stub pattern for feature-specific lifecycle services
(vi.mock on @application + MockBaseService stand-in), and a Common
Assertions table covering phase, dependencies, IPC handlers, and
disposables. Adds the missing Main PreferenceService section to match the
other three infrastructure service docs. Links the lifecycle README to
the new testing guidance.
Move runtime overrides (setHideOnBlur / setAlwaysOnTop /
setMacShowInDockByType) off the flat WindowManager API onto a
`wm.behavior` sub-namespace backed by a new BehaviorController class
in behavior.ts. The controller owns the hideOnBlur override map and
the per-type macShowInDock override map; WindowManager keeps only the
Dock commit-state flag and the visibility predicate.
Surfacing the three-layer declarative split (windowOptions / behavior
/ quirks) at the API level lets consumers tell at a glance which
layer a runtime setter belongs to, and gives future behavior setters
a natural home without inflating the flat WindowManager namespace.
`setTitleBarOverlay` and `setInitData` stay on the flat namespace
since they do not target the behavior layer.
Hard-cut migration: 5 production call sites (SelectionService,
MainWindowService) and the window / MainWindowService test suites
updated in place; no deprecation alias kept. Documentation across
the window-manager reference set rewritten to the new API shape.
Drive-by: add missing `setIsPinned` dep to HomeWindow.tsx
`baseFooterProps` useMemo to silence a pre-existing
react-hooks/exhaustive-deps warning surfaced while verifying lint.
Add Python_ExecutionRequest and Python_ExecutionResponse to the
IpcChannel enum and replace the bare string literals used by
PythonService (main) and PyodideService (renderer) so the push-style
request/response channels are governed the same way as Python_Execute.
Wire values are normalized to the repository's 'namespace:action-dashed'
convention (python:execution-request / python:execution-response) since
both ends are updated in the same change.
- Rename WindowService -> MainWindowService (git mv, preserves blame/log)
- Register WindowType.Main in windowRegistry (singleton, showMode: manual);
MainWindowService drives construction via wm.open and retains business
logic only: IPC handlers, tray-aware close, crash recovery, show/toggle
- 50 call sites renamed (@DependsOn, application.get keys, imports)
WM Dock visibility refactored from visibility-based to existence-based:
- updateDockVisibility predicate drops isVisible/isMinimized check — a
hidden main window must not remove the Dock icon (Cmd+W semantics)
- Add wm.setMacShowInDockByType(type, value) for tray-mode transitions;
keyed by type so services can suppress Dock before the first instance
exists (tray-on-launch path)
- Reduce triggers to window creation, destruction, and type-override
changes; show/hide/minimize/restore no longer affect Dock state
- dockShouldBeVisible initializes true to match Electron's default
MainWindowService uses setMacShowInDockByType for tray-on-launch, close-
to-tray, and reopen-from-tray paths, replacing manual app.dock?.hide()
calls that raced against WM.
Docs updated across window-manager/{README, overview, platform,
api-reference, migration-guide}. New MainWindowService unit test covers
the close matrix (isQuitting, win/linux tray on/off, mac default, mac
tray on_close, fullscreen edge, Dock override assertions) and crash
recovery (first reload vs second-within-60s forceExit).
WindowTypeMetadata now declares per-type config across three orthogonal
layers, chosen by what goes wrong if misconfigured:
- windowOptions: BrowserWindow constructor parameters (Electron-native).
- behavior: cross-platform declarative WM behavior that the constructor
cannot express — hideOnBlur, alwaysOnTop level/relativeLevel,
visibleOnAllWorkspaces options, macShowInDock.
- quirks: OS-specific monkey-patches around hide/show/close.
`macReapplyAlwaysOnTop` is now a pure boolean — the level it re-applies
reads from `behavior.alwaysOnTop`, eliminating the previous mixing of
hack flag and semantic value.
Field renames in the registry:
- defaultConfig → windowOptions
- show → showMode (also gains 'immediate' | 'manual' string variants
in place of true/false; 'auto' unchanged)
- showInDock (top-level) → behavior.macShowInDock
- preload now accepts a plain filename ('index.js' / 'simplest.js'),
mirroring htmlPath; empty string disables, omit defaults to
'index.js'. Removes the variant→file mapping switch.
- mergeWindowConfig → mergeWindowOptions
Two runtime setters added on WindowManager:
- setHideOnBlur(id, enabled): runtime override on behavior.hideOnBlur.
Override map is cleared on destroy and on releaseToPool, so pool
consumers re-applying after open() see a clean default.
- setAlwaysOnTop(id, enabled): toggles using level/relativeLevel from
behavior — registry is the single source of truth.
setVisibleOnAllWorkspaces intentionally has no WM setter: its options
differ per call in real usage (SelectionAction's full-screen show
sequence) and WM has no state to maintain.
Types derived from Electron's signatures so they stay in sync with
@types/electron:
- AlwaysOnTopLevel = NonNullable<Parameters<BW['setAlwaysOnTop']>[1]>
- VisibleOnAllWorkspacesOptions imported directly
applyWindowQuirks is split into applyWindowBehavior (new, behavior.ts)
+ applyWindowQuirks. Behavior runs first so monkey-patches wrap any
subsequent show/hide rather than the initial setter calls. Behavior
takes a closure for reading the override map, avoiding a reverse
WindowManager dependency.
Consumer changes:
- SelectionToolbar: declares behavior.hideOnBlur; mouse-key hook
lifecycle moves from showToolbarAtPosition/hideToolbar call sites
to window 'show'/'hide' event listeners, so any hide path (WM-
driven blur included) triggers cleanup symmetrically.
- SelectionAction: pinActionWindow routes through wm.setAlwaysOnTop
via getWindowId. Show-sequence setVisibleOnAllWorkspaces calls
stay direct (true/false options differ per call). Hardcoded
'floating' level dropped from the show sequence; Electron's
default is identical.
- QuickAssistant: removes the inline setVisibleOnAllWorkspaces and
setAlwaysOnTop calls (now declared in registry behavior). The
blur handler stays in the service because hideQuickAssistant() is
a platform-specific business flow (Windows minimize+setOpacity,
macOS<26 app.hide()) that a generic window.hide() cannot express.
QuickAssistant pin/blur on macOS NSPanel — separate concern, addressed
in the same change because the diagnosis surfaced during refactor:
Electron's blur tracker can get stuck after (outside-click → intra-
panel pin/unpin click → outside-click). Cocoa fires
windowDidResignKey: for the second outside-click but Electron filters
it — its tracker thinks the window was already blurred (the intra-
panel click silently re-keyed the panel without firing
windowDidBecomeKey:). Upstream marked wontfix (electron/electron#3222).
Workaround: after un-pinning, poll window.isFocused() at 200ms,
capped at 30s. isFocused() reads Cocoa state directly, bypassing
the broken Electron tracker. Triggered only when hasBlurredSinceShow
is true (the failure precondition), so the common open→pin→unpin
path runs no timer.
Renderer side: HomeWindow's pin sync moves from useEffect (deferred
by one render cycle) to a setter wrapper, so IPC fires synchronously
inside the click handler — closes a small race where a fast outside
click could see a stale main flag.
Tests: 15 new specs covering behavior layer (declarative + runtime
override, pool recycle reset, level derivation, initial
setVisibleOnAllWorkspaces application, macReapplyAlwaysOnTop reading
from behavior). Existing fixtures mechanically renamed.
Docs: 6 window-manager docs synchronized; README adds sections on
configuration layers, "WM does not know pin", behavior/quirks API,
when to provide a runtime setter, type derivation convention, and
Electron edge cases.
Unify the feature under its canonical name "Quick Assistant":
- Rename miniWindow.html → quickAssistant.html and
windows/mini/ → windows/quickAssistant/ (via git mv to preserve history).
The bounds file miniWindow-state.json is not migrated; users see default
bounds once after upgrade.
- Collapse half-renamed QuickWindow/MiniWindow identifiers in
QuickAssistantService and its callers (ShortcutService, TrayService,
ShortcutService.test) onto QuickAssistant.
- Rename the Inputbar scope literal 'mini-window' → 'quick-assistant'.
The Redux DEFAULT_TOOL_ORDER_BY_SCOPE keeps the legacy key behind a
transitional Record<InputbarScope | 'mini-window', ToolOrder> annotation;
both entries will be collapsed when Redux is removed.
- Rename i18n keys miniwindow.*, mini_window, show_mini_window to
quickAssistant.*, quick_assistant, show_quick_assistant across all
locales; synced via i18n:sync.
- Update lingering "mini window" wording in docs and comments.
Legacy 'mini_window' literals intentionally retained in the v2
ShortcutMappings migrator (identifies Redux-persisted old keys) and in
v1 Redux store files (to be removed with Redux).
Add thin wrapper methods on WindowManager that subscribe to a specific
WindowType, eliminating the boilerplate `if (managed.type !== X) return`
guard at every consumer call site. The underlying `onWindowCreated` /
`onWindowDestroyed` events remain available for the rare "observe all
windows" use case.
The new variants are additive and non-breaking — same Disposable return
contract, same ManagedWindow callback payload, just with an up-front type
filter baked in.
Documentation updates landing in the same commit:
- window-manager-usage.md: recommend the byType variants as the canonical
single-type subscription pattern; add a "Callback styles" section covering
destructuring vs `mw` shorthand for accessing ManagedWindow fields.
- window-manager-api-reference.md: list the byType variants in the Events
table; update `create`'s rationale to point at byType.
- window-manager-migration-guide.md: Step 3 example uses byType +
destructuring in the After block.
- docs README and window-manager-overview.md: anti-pattern and feature
rows point to the byType variants as the consumer-facing primary form.
Move the 756-line src/main/core/window/README.md into a dedicated
docs/references/window-manager/ directory organized by concern
(overview, usage, pool mechanics, platform, API reference, migration),
following the docs/references/lifecycle/ pattern. The in-source README
shrinks to a 14-line pointer.
Along the way:
- Reframe onWindowCreated + open()/close() as the canonical consumer
pattern, and demote create()/destroy() to internal primitives with an
explicit anti-pattern section for direct-ID attachment at call sites.
- Add a previously-undocumented "Renderer IPC Surface" section covering
WindowManager_Open/Close/Show/Hide/etc., with target-resolution rules
(bare sender vs singleton type).
- Make the pool-recycle-does-not-re-fire-onWindowCreated consequence
explicit in the Event Timing Contract guarantees.
- Update @see links in WindowManager.ts and types.ts to point at the new
doc locations directly instead of bouncing through the in-source README.
Phase ordering already guarantees BeforeReady services (PreferenceService,
DbService, CacheService, DataApiService) are ready before WhenReady starts,
so declaring @DependsOn across phases adds noise and misleads readers about
same-phase coupling. The rule existed in lifecycle-overview but was buried
in a bullet; reinforce it in the docs AI assistants scan first.
- CLAUDE.md: add a bullet under the Lifecycle section stating @DependsOn
is for same-phase deps only
- lifecycle/README.md: new "Cross-Phase Dependencies Are Automatic"
callout section and a matching Anti-patterns row
- lifecycle/lifecycle-overview.md: promote the line-87 note to a blockquote
callout and annotate the Dependency Rules table
- lifecycle/lifecycle-decision-guide.md: add Common Mistake #5 with
bad/good code examples
Extend the DataApi router's `extractPathParams` to recognize `:name*` segments
as greedy captures that consume one or more path segments joined with `/`.
Greedy params may appear as the last segment or in the middle anchored by
trailing static / plain-param segments; at most one greedy is allowed per
pattern. No decoding is performed, consistent with the existing raw-string
policy.
This unblocks routing for composite identifiers whose values contain `/`
(e.g. OpenRouter-style `qwen/qwen3-vl`, Fireworks-style
`accounts/fireworks/models/deepseek-v3p2`) without resorting to URL encoding,
which the project does not use.
- Add 21 unit tests covering plain params, tail greedy, middle greedy,
multi-greedy rejection, and edge cases.
- Document the feature under "Greedy Path Parameters" in the API design
guidelines with tail/middle/mixed examples.