From 85400459e980272d2f3fe7846b10db841df6b646 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 21 Jul 2026 20:52:20 +0300 Subject: [PATCH] perf: overhaul session loading, caching, and runtime isolation (#2360) Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants --- .../openchamber-change-discipline/SKILL.md | 2 +- .../skills/performance-engineering/SKILL.md | 24 + .agents/skills/sync-state-invariants/SKILL.md | 31 + .gitignore | 1 + bun.lock | 11 +- package.json | 1 + packages/electron/README.md | 9 +- packages/electron/main.mjs | 8 +- .../NotificationService.swift | 11 + .../App/OpenChamberWidget/WidgetShared.swift | 3 +- packages/ui/src/apps/MobileChangesSurface.tsx | 4 +- .../ui/src/apps/mobileConnections.test.ts | 14 +- packages/ui/src/apps/mobileConnections.ts | 41 +- packages/ui/src/apps/mobileWidgetSnapshot.ts | 5 +- packages/ui/src/apps/runtimeEndpointReset.ts | 12 + .../ui/src/components/chat/ChatContainer.tsx | 151 ++-- packages/ui/src/components/chat/ChatInput.tsx | 291 +++---- .../ui/src/components/chat/ChatMessage.tsx | 22 +- .../components/chat/MarkdownRendererImpl.tsx | 70 +- .../ui/src/components/chat/MessageList.tsx | 4 +- .../chat/MobileSessionStatusBar.test.ts | 18 + .../chat/MobileSessionStatusBar.tsx | 60 +- .../ui/src/components/chat/ModelControls.tsx | 10 +- .../components/chat/QueuedMessageChips.tsx | 33 +- packages/ui/src/components/chat/StatusRow.tsx | 12 +- .../components/chat/hooks/useTurnRecords.ts | 10 +- .../chat/lib/turns/turnProjectionCache.ts | 9 +- .../chat/message/parts/ProgressiveGroup.tsx | 14 +- .../comments/useInlineCommentController.ts | 55 +- .../ui/src/components/layout/ContextPanel.tsx | 25 +- packages/ui/src/components/layout/Header.tsx | 125 ++- .../ui/src/components/layout/MainLayout.tsx | 10 +- .../components/layout/SidebarFilesTree.tsx | 16 +- .../ui/src/components/layout/VSCodeLayout.tsx | 10 +- .../mainLayoutMobileSidebarMount.test.ts | 15 +- .../src/components/session/SessionSidebar.tsx | 456 ++++++----- .../session/sidebar/DOCUMENTATION.md | 29 +- .../session/sidebar/SessionGroupSection.tsx | 132 ++-- .../session/sidebar/SessionNodeItem.tsx | 158 ++-- .../sidebar/SidebarActivitySections.tsx | 28 +- .../session/sidebar/SidebarProjectsList.tsx | 6 +- .../sidebar/authoritativeSessionCleanup.ts | 31 + .../sidebar/hooks/pinnedSessionCleanup.ts | 20 - .../sidebar/hooks/useArchivedAutoFolders.ts | 12 +- .../useAuthoritativeSessionCleanup.test.ts | 44 ++ .../hooks/useAuthoritativeSessionCleanup.ts | 35 + .../sidebar/hooks/useProjectRepoStatus.ts | 18 +- .../hooks/useProjectSessionSelection.ts | 36 +- .../sidebar/hooks/useSessionActions.ts | 21 +- .../sidebar/hooks/useSessionFolderCleanup.ts | 80 -- .../sidebar/hooks/useSessionPrefetch.ts | 124 +-- .../sidebar/hooks/useSessionSearchEffects.ts | 10 +- .../hooks/useSessionSidebarSections.ts | 71 +- .../hooks/useSidebarPersistence.test.ts | 26 - .../sidebar/hooks/useSidebarPersistence.ts | 69 +- .../sidebar/hooks/useStickyProjectHeaders.ts | 17 +- .../sidebar/sessionBootstrapDemands.test.ts | 47 ++ .../sidebar/sessionBootstrapDemands.ts | 77 ++ .../sidebar/sessionNodeItemUtils.test.ts | 125 +++ .../session/sidebar/sessionNodeItemUtils.ts | 86 +- .../components/session/sidebar/utils.test.ts | 51 +- .../src/components/session/sidebar/utils.tsx | 29 +- .../ui/src/components/ui/CommandPalette.tsx | 6 +- packages/ui/src/components/views/ChatView.tsx | 5 +- packages/ui/src/components/views/DiffView.tsx | 7 +- .../ui/src/components/views/FilesView.tsx | 26 +- .../ui/src/components/views/TerminalView.tsx | 7 +- .../views/git/PullRequestSection.tsx | 4 +- .../ui/src/contexts/RuntimeAPIProvider.tsx | 156 +--- .../ui/src/contexts/ThemeSystemContext.tsx | 23 +- .../src/contexts/content-cache-owner.test.ts | 74 ++ .../ui/src/contexts/content-cache-owner.ts | 134 ++++ packages/ui/src/hooks/usePwaManifestSync.ts | 5 +- .../hooks/useQueuedMessageAutoSend.test.ts | 4 +- .../ui/src/hooks/useQueuedMessageAutoSend.ts | 43 +- .../ui/src/hooks/useSessionAutoCleanup.ts | 10 +- packages/ui/src/hooks/useTraySync.ts | 2 +- .../ui/src/lib/chatDraftPersistence.test.ts | 92 +++ packages/ui/src/lib/chatDraftPersistence.ts | 125 +++ packages/ui/src/lib/gitApiHttp.ts | 40 +- packages/ui/src/lib/i18n/messages/en.ts | 6 + packages/ui/src/lib/i18n/messages/es.ts | 6 + packages/ui/src/lib/i18n/messages/fr.ts | 6 + packages/ui/src/lib/i18n/messages/ja.ts | 6 + packages/ui/src/lib/i18n/messages/ko.ts | 6 + packages/ui/src/lib/i18n/messages/pl.ts | 6 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 + packages/ui/src/lib/i18n/messages/uk.ts | 6 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 + packages/ui/src/lib/modelPrefsAutoSave.ts | 14 + .../src/lib/opencode/provider-tracker.test.ts | 18 + .../ui/src/lib/opencode/provider-tracker.ts | 31 +- packages/ui/src/lib/persistence.test.ts | 84 ++ packages/ui/src/lib/persistence.ts | 232 +++++- packages/ui/src/lib/runtime-auth.test.ts | 51 ++ packages/ui/src/lib/runtime-auth.ts | 77 +- packages/ui/src/lib/runtime-url.test.ts | 2 +- .../ui/src/lib/shiki/appThemeRegistry.test.ts | 33 + packages/ui/src/lib/shiki/appThemeRegistry.ts | 22 +- packages/ui/src/stores/DOCUMENTATION.md | 43 +- packages/ui/src/stores/globalSessions.test.ts | 25 + packages/ui/src/stores/globalSessions.ts | 52 +- .../ui/src/stores/messageQueueStore.test.ts | 51 ++ packages/ui/src/stores/messageQueueStore.ts | 127 ++- .../ui/src/stores/permissionStore.test.ts | 54 +- packages/ui/src/stores/permissionStore.ts | 122 ++- packages/ui/src/stores/useConfigStore.ts | 61 +- .../ui/src/stores/useFileSearchStore.test.ts | 19 + packages/ui/src/stores/useFileSearchStore.ts | 13 +- .../src/stores/useFilesViewTabsStore.test.ts | 14 +- .../ui/src/stores/useFilesViewTabsStore.ts | 92 ++- .../src/stores/useGitHubPrStatusStore.test.ts | 139 ++++ .../ui/src/stores/useGitHubPrStatusStore.ts | 132 +++- packages/ui/src/stores/useGitStore.test.ts | 72 +- packages/ui/src/stores/useGitStore.ts | 243 +++++- .../useGlobalSessionsStore-races.test.ts | 126 +++ .../src/stores/useGlobalSessionsStore.test.ts | 94 ++- .../ui/src/stores/useGlobalSessionsStore.ts | 260 ++++-- ...seInlineCommentDraftStore.terminal.test.ts | 73 +- .../src/stores/useInlineCommentDraftStore.ts | 398 +++++----- .../ui/src/stores/useProjectsStore.test.ts | 21 + packages/ui/src/stores/useProjectsStore.ts | 19 - .../src/stores/useSessionFoldersStore.test.ts | 58 +- .../ui/src/stores/useSessionFoldersStore.ts | 278 ++++--- .../src/stores/useSessionPinnedStore.test.ts | 46 ++ .../ui/src/stores/useSessionPinnedStore.ts | 135 +++- .../src/stores/useTodosPersistStore.test.ts | 51 ++ .../ui/src/stores/useTodosPersistStore.ts | 45 +- .../ui/src/stores/utils/safeStorage.test.ts | 60 ++ packages/ui/src/stores/utils/safeStorage.ts | 221 ++---- packages/ui/src/stores/utils/streamDebug.ts | 35 +- packages/ui/src/sync/DOCUMENTATION.md | 96 ++- .../sync/__tests__/materialization.test.ts | 58 +- .../__tests__/session-prefetch-cache.test.ts | 32 - .../sync-context-session-events.test.ts | 73 ++ packages/ui/src/sync/bootstrap.test.ts | 111 +++ packages/ui/src/sync/bootstrap.ts | 65 +- packages/ui/src/sync/child-store.test.ts | 362 ++++++++- packages/ui/src/sync/child-store.ts | 515 +++++++++++- packages/ui/src/sync/content-cache.ts | 76 -- packages/ui/src/sync/event-pipeline.test.ts | 79 ++ packages/ui/src/sync/event-pipeline.ts | 49 +- packages/ui/src/sync/event-reducer.ts | 30 +- .../ui/src/sync/global-session-status.test.ts | 45 ++ packages/ui/src/sync/global-session-status.ts | 37 +- .../sync/live-aggregate-performance.test.ts | 29 + packages/ui/src/sync/live-aggregate.ts | 15 +- packages/ui/src/sync/materialization.ts | 40 +- .../ui/src/sync/performance-diagnostics.ts | 126 +++ packages/ui/src/sync/persist-cache.test.ts | 179 +++++ packages/ui/src/sync/persist-cache.ts | 169 +++- .../ui/src/sync/reconnect-recovery.test.ts | 46 ++ packages/ui/src/sync/reconnect-recovery.ts | 67 +- packages/ui/src/sync/session-actions.test.ts | 92 +++ packages/ui/src/sync/session-actions.ts | 212 +++-- .../src/sync/session-deletion-cleanup.test.ts | 78 ++ .../ui/src/sync/session-deletion-cleanup.ts | 24 + packages/ui/src/sync/session-event-router.ts | 60 +- .../ui/src/sync/session-load-performance.ts | 62 ++ .../src/sync/session-message-loader.test.ts | 175 +++++ .../ui/src/sync/session-message-loader.ts | 619 +++++++++++++++ .../src/sync/session-message-records.test.ts | 33 + .../src/sync/session-prefetch-cache.test.ts | 50 ++ .../ui/src/sync/session-prefetch-cache.ts | 97 +-- packages/ui/src/sync/session-ui-store.test.js | 18 + packages/ui/src/sync/session-ui-store.ts | 67 +- packages/ui/src/sync/streaming.test.ts | 85 +- packages/ui/src/sync/streaming.ts | 146 +++- packages/ui/src/sync/sync-context.tsx | 737 +++++++++++++----- packages/ui/src/sync/types.ts | 8 + packages/ui/src/sync/use-sync.ts | 556 +++---------- .../src/sync/worktree-topology-cache.test.ts | 47 ++ .../ui/src/sync/worktree-topology-cache.ts | 99 +++ packages/vscode/src/DOCUMENTATION.md | 2 +- ...dge-permission-auto-accept-runtime.test.ts | 28 +- .../bridge-permission-auto-accept-runtime.ts | 45 +- packages/vscode/src/opencodeAuth.ts | 7 +- packages/vscode/webview/main.tsx | 8 +- packages/web/server/index.js | 5 +- .../web/server/lib/github/DOCUMENTATION.md | 1 + .../web/server/lib/opencode/DOCUMENTATION.md | 4 + .../web/server/lib/opencode/env-runtime.js | 10 +- .../server/lib/opencode/env-runtime.test.js | 10 +- .../server/lib/opencode/session-runtime.js | 63 +- .../lib/opencode/session-runtime.test.js | 80 ++ .../server/lib/opencode/settings-helpers.js | 4 + .../lib/opencode/settings-helpers.test.js | 1 + .../server/lib/opencode/settings-runtime.js | 8 +- .../lib/opencode/settings-runtime.test.js | 12 + .../lib/permission-auto-accept/runtime.js | 5 +- .../permission-auto-accept/runtime.test.js | 11 +- .../web/server/lib/session-folders/routes.js | 106 ++- .../server/lib/session-folders/routes.test.js | 143 +++- packages/web/src/api/settings.ts | 8 +- scripts/profile-browser.md | 54 ++ scripts/profile-browser.mjs | 515 ++++++++++++ 197 files changed, 10835 insertions(+), 3400 deletions(-) create mode 100644 packages/ui/src/components/chat/MobileSessionStatusBar.test.ts create mode 100644 packages/ui/src/components/session/sidebar/authoritativeSessionCleanup.ts delete mode 100644 packages/ui/src/components/session/sidebar/hooks/pinnedSessionCleanup.ts create mode 100644 packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.test.ts create mode 100644 packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.ts delete mode 100644 packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts delete mode 100644 packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.test.ts create mode 100644 packages/ui/src/components/session/sidebar/sessionBootstrapDemands.test.ts create mode 100644 packages/ui/src/components/session/sidebar/sessionBootstrapDemands.ts create mode 100644 packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts create mode 100644 packages/ui/src/contexts/content-cache-owner.test.ts create mode 100644 packages/ui/src/contexts/content-cache-owner.ts create mode 100644 packages/ui/src/lib/chatDraftPersistence.test.ts create mode 100644 packages/ui/src/lib/chatDraftPersistence.ts create mode 100644 packages/ui/src/lib/opencode/provider-tracker.test.ts create mode 100644 packages/ui/src/lib/shiki/appThemeRegistry.test.ts create mode 100644 packages/ui/src/stores/messageQueueStore.test.ts create mode 100644 packages/ui/src/stores/useGitHubPrStatusStore.test.ts create mode 100644 packages/ui/src/stores/useGlobalSessionsStore-races.test.ts create mode 100644 packages/ui/src/stores/useProjectsStore.test.ts create mode 100644 packages/ui/src/stores/useSessionPinnedStore.test.ts create mode 100644 packages/ui/src/stores/useTodosPersistStore.test.ts delete mode 100644 packages/ui/src/sync/__tests__/session-prefetch-cache.test.ts create mode 100644 packages/ui/src/sync/bootstrap.test.ts delete mode 100644 packages/ui/src/sync/content-cache.ts create mode 100644 packages/ui/src/sync/global-session-status.test.ts create mode 100644 packages/ui/src/sync/live-aggregate-performance.test.ts create mode 100644 packages/ui/src/sync/performance-diagnostics.ts create mode 100644 packages/ui/src/sync/persist-cache.test.ts create mode 100644 packages/ui/src/sync/session-deletion-cleanup.test.ts create mode 100644 packages/ui/src/sync/session-deletion-cleanup.ts create mode 100644 packages/ui/src/sync/session-load-performance.ts create mode 100644 packages/ui/src/sync/session-message-loader.test.ts create mode 100644 packages/ui/src/sync/session-message-loader.ts create mode 100644 packages/ui/src/sync/session-prefetch-cache.test.ts create mode 100644 packages/ui/src/sync/worktree-topology-cache.test.ts create mode 100644 packages/ui/src/sync/worktree-topology-cache.ts create mode 100644 scripts/profile-browser.md create mode 100644 scripts/profile-browser.mjs diff --git a/.agents/skills/openchamber-change-discipline/SKILL.md b/.agents/skills/openchamber-change-discipline/SKILL.md index aac28f1b..7f3adc37 100644 --- a/.agents/skills/openchamber-change-discipline/SKILL.md +++ b/.agents/skills/openchamber-change-discipline/SKILL.md @@ -82,7 +82,7 @@ Use `package.json` scripts as the command source of truth. | Executable source | Focused tests plus package-scoped type-check and lint | | Cross-workspace/shared contract | Workspace-wide type-check and lint plus affected builds/tests | | Added/deleted/renamed source file, export/type/entrypoint/import shape | `bun run dead-code` in addition to relevant checks | -| Persisted or external contract | Compatibility and round-trip tests; conversion/malformed-old-data tests when old data needs migration; failed-write/migration rollback tests | +| Persisted or external contract | Compatibility and round-trip tests plus the applicable failure/ordering cases: missing-versus-empty, malformed data, stale reads versus newer mutations, out-of-order writes, lifecycle handling for debounced writes, conversion, and failed-write/migration rollback | | Dependency or lockfile | Workspace-wide checks and affected builds | | Generated asset | Regeneration check plus consumer build/test | | Docs-only or isolated config | Narrow syntax/schema/link validation; do not run unrelated full suites | diff --git a/.agents/skills/performance-engineering/SKILL.md b/.agents/skills/performance-engineering/SKILL.md index ef992029..6bcd7123 100644 --- a/.agents/skills/performance-engineering/SKILL.md +++ b/.agents/skills/performance-engineering/SKILL.md @@ -37,6 +37,8 @@ Do not optimize against a toy fixture when the report provides production scale. Do not infer a bottleneck from code appearance when a trace or counter can identify it. +Profiling identifies where time is spent; it does not prove behavioral equivalence. Separately verify the applicable state, identity, layout, and lifecycle transitions for every structural optimization. + ### 2. Write The Cost Equation Name every multiplying dimension: @@ -109,6 +111,9 @@ Prefer indexes keyed by stable IDs. Keep high-frequency runtime state out of met - Preserve references for unaffected entities and buckets. - Keep streaming state out of broadly consumed stores. - Never rely on `React.memo`, `useMemo`, or Zustand equality to prevent selector execution upstream. +- Treat every custom memo/equality comparator as a correctness boundary. Inventory every render-relevant value that comparator gates and observe its canonical identity or an explicit semantic version covering the same semantics. +- Do not compare a proxy, aggregate, fallback, or differently resolved identity when the gated render path uses another source. Stable entity IDs do not imply stable rendered content; changes to comparator-gated semantics under the same ID must invalidate affected consumers, while semantically equivalent replacements may remain stable. +- Prefer leaf subscriptions for isolated high-frequency state over threading broad state through custom comparators. Keep comparator work bounded so render fanout is not merely replaced by recursive comparison fanout. - Do not sort structural lists from token/delta-frequency fields. - Coalesce repeated same-entity events and skip no-op reducer updates. - Ensure hidden or disabled surfaces perform no ongoing work. @@ -117,6 +122,20 @@ Prefer indexes keyed by stable IDs. Keep high-frequency runtime state out of met - Avoid textarea auto-size shrink/expand cycles when content only grows. - Freeze structural ordering during high-frequency updates and reorder at an explicit lifecycle edge. +## Virtualization Contracts + +Virtualization changes layout, mounting, measurement, focus, and scroll semantics. It is not behaviorally equivalent merely because steady-state visible rows look the same. + +Before virtualizing a collection, define: + +- the actual scrolling element and whether it directly contains the virtualizer or is an ancestor; +- how total virtual height and the final item remain reachable from that scroller; +- estimated versus measured sizes, including expanded, nested, and dynamically resized items; +- initialization, remount, and activation-threshold behavior; +- interactions that depend on mounted DOM, including incremental reveal, focus, selection, drag-and-drop, menus, and accessibility traversal. + +When activation is threshold-based, test threshold minus one, threshold, and threshold plus one. Also test applicable collapsed/expanded, hidden/visible, filtered/unfiltered, and short/long transitions. If the current DOM or scroll topology cannot expose the virtual tail reliably, correct that topology or retain normal rendering rather than virtualizing solely by item count. + ## Caching Rules Add a cache only when all are explicit: @@ -141,6 +160,9 @@ Require both correctness and performance guards: - repeated-event test for streaming/polling paths; - no-op and unrelated-entity update tests; - reference-stability test for unaffected buckets; +- when custom comparators change, tests proving both directions: unrelated or semantically equivalent updates preserve the boundary, while changes to comparator-gated identity, membership, content, and source semantics invalidate it; +- when memoized tree/list consumers change, same-ID replacements and rebuilt-container fixtures covering both semantic change and semantic equivalence; +- when virtualization changes, tests using the real scrolling ancestor that prove final-item/control reachability and stable scroll, focus, and interactions; include activation-boundary cases when such a boundary exists; - failure, partial-data, empty-success, and stale-async-completion tests; - memory/cache growth check for long-running paths; - production build or equivalent runtime profile for UI interactions. @@ -181,4 +203,6 @@ If the interaction remains above budget, do not call the mitigation the complete - [ ] Partial failure cannot trigger destructive cleanup. - [ ] Representative benchmark meets the stated budget. - [ ] Operation-count or repeated-event regression test prevents recurrence. +- [ ] Structural optimizations have transition-focused correctness coverage independent of performance measurements. +- [ ] When mount topology or activation boundaries change, instrumentation distinguishes those transitions from steady state. - [ ] Correctness, type, lint, and relevant runtime validations pass. diff --git a/.agents/skills/sync-state-invariants/SKILL.md b/.agents/skills/sync-state-invariants/SKILL.md index 58f574fb..9aa3fec0 100644 --- a/.agents/skills/sync-state-invariants/SKILL.md +++ b/.agents/skills/sync-state-invariants/SKILL.md @@ -35,6 +35,14 @@ Never swallow an SDK/API error into `[]`, `{}`, or another valid empty success. Track completeness at the smallest entity/scope. One failed project or directory blocks destructive work for itself, not for unrelated complete scopes. +Inferring destructive cleanup from disappearance between snapshots requires an established authoritative baseline. This is separate from applying a complete snapshot whose contract explicitly authorizes first-load replacement. + +- Never infer a disappearance event from the first snapshot, startup-empty state, filtered/visible subsets, or partially loaded scopes. +- Compare two complete authoritative snapshots from the same runtime and logical scope before treating disappearance as removal. +- Key disappearance by stable entity identity. Owner, directory, grouping, category, or presentation moves are not deletion unless the authoritative contract says so. +- Reset the baseline when runtime identity or authoritative scope changes. +- Prefer explicit deletion events; snapshot-difference cleanup is a fallback that requires completeness guarantees. + ## Live And Historical State - Use historical state to restore context, not to infer ongoing execution. @@ -61,6 +69,10 @@ For streaming-frequency work, also load `performance-engineering`. - Treat startup 502/503 as transient with bounded retry/recovery. - A retry loop requires a real failure signal; swallowed errors disable retries. - Preserve previous authoritative state during transient bootstrap/reconnect failures. +- Distinguish stale-scope rejection from same-scope mutation reconciliation. A generation token rejects obsolete owners but does not protect mutations made while a still-valid request is in flight. +- Capture a mutation revision when an authoritative load starts. At commit time, read current state and preserve or overlay entity mutations newer than that revision. +- Record removals as mutations even when the entity is already absent, so an in-flight response cannot resurrect it. +- Return committed reconciled state, not the raw fetched snapshot, when callers depend on the result. ## Optimistic Updates @@ -85,6 +97,18 @@ For streaming-frequency work, also load `performance-engineering`. - Key runtime-scoped caches by runtime identity when IDs or paths can collide. - Clean optimistic and local cache state after partial failures. +## Persisted Snapshot Ordering + +When state exists in memory and one or more persistent stores, define an explicit authority and ordering protocol: + +- Distinguish a missing snapshot from authoritative empty data, malformed data, and read failure. +- Preserve mutation order independently per owner by serializing writes or attaching monotonic revisions and rejecting stale writes. Do not rely on uncontrolled wall-clock timestamps. +- Capture runtime/owner identity with every debounced or asynchronous operation and verify it again before commit. +- Pending writes must complete against their captured owner, drain before an owner switch, or be canceled only under an explicit durability/data-loss contract. Apply the strongest available guarantee at page hide/freeze and shutdown boundaries. +- During hydration, capture the local mutation revision and do not replace state after newer local mutations. +- Validate persisted payload shape before granting authority. Malformed data is failure, not empty success. +- Define retention explicitly; never silently evict older owner namespaces unless bounded retention and resulting data loss are intentional contracts. + ## Verification Cover the relevant lifecycle, not only static state: @@ -97,6 +121,10 @@ Cover the relevant lifecycle, not only static state: - create, stream, abort, permission, archive/delete, and revisit when session behavior changes; - partial multi-directory/project failure; - runtime or worktree switch with dynamic directory resolution. +- snapshot-difference cleanup establishing its first authoritative baseline without deletion, then cleaning a later authoritative disappearance exactly once; +- identity-preserving moves/category changes and runtime/scope changes resetting cleanup baselines; +- create, update, move, archive, and delete mutations surviving responses started before those mutations; +- missing versus empty persistence, malformed payloads, out-of-order writes, hydration races, and lifecycle durability behavior. ## Red Flags @@ -107,3 +135,6 @@ Cover the relevant lifecycle, not only static state: - Queue reads current model/agent at send time. - New session lookup assumes SSE already indexed it. - Optimistic data has no shadow entry or rollback. +- Snapshot-difference cleanup treats its first startup snapshot as a disappearance event. +- Missing or malformed persistence becomes authoritative empty state. +- Debounced writes are canceled on owner/lifecycle change without completing against the captured owner or an explicit durability/data-loss contract. diff --git a/.gitignore b/.gitignore index 8a344a6b..525d0eff 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ workspaces/ *.pid .worktrees/ test-results/ +artifacts/browser-profile-*/ diff --git a/bun.lock b/bun.lock index 2537858a..0debc2ed 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.16.1", + "version": "1.16.2", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -133,7 +133,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.16.1", + "version": "1.16.2", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -237,7 +237,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.16.1", + "version": "1.16.2", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.3", @@ -260,7 +260,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.16.1", + "version": "1.16.2", "bin": { "openchamber": "./bin/cli.js", }, @@ -347,6 +347,9 @@ }, }, }, + "trustedDependencies": [ + "electron", + ], "patchedDependencies": { "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", }, diff --git a/package.json b/package.json index 77e712da..46e8813b 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "docs:validate": "node scripts/docs/validate-docs.mjs", "dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates", "doctor": "node scripts/react-doctor.mjs", + "profile:browser": "node scripts/profile-browser.mjs", "icons:sprite": "node scripts/generate-file-type-sprite.mjs", "icons:generate": "bun run scripts/generate-icon-sprite.mjs", "themes:port:opencode": "tsx scripts/port-opencode-theme.ts", diff --git a/packages/electron/README.md b/packages/electron/README.md index 9d42437b..afccf5d5 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -96,9 +96,12 @@ Packaged Desktop builds include the official OpenCode CLI that matches the pinne Managed local Desktop startup prefers OpenCode binaries in this order: -1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`. -2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`. -3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations. +1. `settings.opencodeBinary`. +2. Environment overrides: `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`. +3. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`. +4. System installs discovered from PATH. +5. Known npm/Bun/Homebrew/Scoop/Chocolatey and other standard install locations. +6. Platform discovery through `where opencode` on Windows or a login shell on macOS/Linux. Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 100532e0..cfb83696 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -490,12 +490,16 @@ const readJsonFile = (filePath) => { }; const writeJsonFile = async (filePath, data) => { - await fsp.mkdir(path.dirname(filePath), { recursive: true }); + const directory = path.dirname(filePath); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await fsp.chmod(directory, 0o700); // Atomic: write to a temp file then rename. Readers never see a partial // JSON file that could parse-error and get coerced to {}. const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - await fsp.writeFile(tmp, JSON.stringify(data, null, 2)); + await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); + if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600); await fsp.rename(tmp, filePath); + if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600); }; const readSettingsRoot = () => { diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift index ed29076a..8489b66c 100644 --- a/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift +++ b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift @@ -37,6 +37,7 @@ class NotificationService: UNNotificationServiceExtension { guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return } var snapshot: [String: Any] = [ + "runtimeKey": request.content.userInfo["runtimeKey"] as? String ?? "", "attentionCount": 0, "recentSessions": [], ] @@ -46,6 +47,16 @@ class NotificationService: UNNotificationServiceExtension { snapshot = stored } + if let pushRuntimeKey = request.content.userInfo["runtimeKey"] as? String, + !pushRuntimeKey.isEmpty, + snapshot["runtimeKey"] as? String != pushRuntimeKey { + snapshot = [ + "runtimeKey": pushRuntimeKey, + "attentionCount": 0, + "recentSessions": [], + ] + } + // Attention count: authoritative server value carried in aps.badge. if let badge = request.content.badge as? Int { snapshot["attentionCount"] = badge diff --git a/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift index e98e08e5..b32caf6a 100644 --- a/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift +++ b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift @@ -17,10 +17,11 @@ struct WidgetSession: Codable, Identifiable, Hashable { /// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the /// JSON the app stores decodes directly. struct WidgetSnapshot: Codable { + var runtimeKey: String? let attentionCount: Int let recentSessions: [WidgetSession] - static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: []) + static let empty = WidgetSnapshot(runtimeKey: nil, attentionCount: 0, recentSessions: []) } enum WidgetStore { diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 6818fec3..9c861bfe 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -21,6 +21,7 @@ import { useIsGitRepo, useGitLoadingStatus, } from '@/stores/useGitStore'; +import { getRuntimeKey } from '@/lib/runtime-switch'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type CommitAction = 'commit' | 'commitAndPush' | null; @@ -202,6 +203,7 @@ export const MobileChangesSurface: React.FC = ({ onCl } let cancelled = false; + const runtimeKey = getRuntimeKey(); setDiffLoadError(null); void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined }) .then((response) => { @@ -210,7 +212,7 @@ export const MobileChangesSurface: React.FC = ({ onCl original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary, - }); + }, runtimeKey); }) .catch((error) => { if (cancelled) return; diff --git a/packages/ui/src/apps/mobileConnections.test.ts b/packages/ui/src/apps/mobileConnections.test.ts index 0d918711..d5ba3ecb 100644 --- a/packages/ui/src/apps/mobileConnections.test.ts +++ b/packages/ui/src/apps/mobileConnections.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from 'bun:test'; -import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections'; +import { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections'; const originalFetch = globalThis.fetch; const originalWindow = globalThis.window; @@ -40,6 +40,18 @@ const testRelay: MobileRelayConfig = { }; describe('mobile connection storage', () => { + test('removes inline tokens only after each secure migration succeeds', async () => { + const result = await migrateLegacyInlineTokenRecords([ + { id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' }, + { id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' }, + ], async (url) => url.includes('ok.example')); + + expect(result.migrated).toBe(1); + expect(result.failed).toBe(1); + expect(result.records[0]).toEqual({ id: 'ok', url: 'http://ok.example', hasToken: true }); + expect(result.records[1]).toEqual({ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' }); + }); + test('entries persisted before candidates migrate to a single direct candidate', async () => { try { installTestWindow(); diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 697acf9a..eb7654d7 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -691,6 +691,30 @@ const deleteSecureToken = async (key: string): Promise => { // One-time migration: a legacy localStorage record on native might still carry an // inline `clientToken`. Move it into the secure store and strip the metadata. +export const migrateLegacyInlineTokenRecords = async ( + records: unknown[], + migrateToken: (url: string, token: string) => Promise, +): Promise<{ records: unknown[]; migrated: number; failed: number }> => { + let migrated = 0; + let failed = 0; + const next = await Promise.all(records.map(async (item) => { + if (!item || typeof item !== 'object') return item; + const record = item as Record; + const url = typeof record.url === 'string' ? record.url : null; + const token = typeof record.clientToken === 'string' ? record.clientToken.trim() : ''; + if (!url || !token) return item; + if (!await migrateToken(url, token)) { + failed += 1; + return item; + } + migrated += 1; + const { clientToken: _removed, ...metadata } = record; + void _removed; + return { ...metadata, hasToken: true }; + })); + return { records: next, migrated, failed }; +}; + const migrateLegacyInlineTokens = async (): Promise => { if (typeof window === 'undefined' || !isCapacitorApp()) return; let parsed: unknown; @@ -707,11 +731,20 @@ const migrateLegacyInlineTokens = async (): Promise => { && Boolean((item as { clientToken: string }).clientToken.trim())); if (legacy.length === 0) return; logStorage('secure:migrate-start', { count: legacy.length }); - for (const { url, clientToken } of legacy) { - await writeSecureToken(getConnectionStorageKey(url), clientToken); + const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => { + const key = getConnectionStorageKey(url); + if (!await writeSecureToken(key, token)) return false; + return await readSecureToken(key) === token; + }); + if (result.migrated > 0) { + try { + window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records)); + } catch (error) { + console.warn('[mobile-storage] failed to finalize secure token migration', error); + return; + } } - writeConnections(readConnections()); - logStorage('secure:migrate-done', { count: legacy.length }); + logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed }); }; export const loadMobileConnections = async (): Promise => { diff --git a/packages/ui/src/apps/mobileWidgetSnapshot.ts b/packages/ui/src/apps/mobileWidgetSnapshot.ts index 4ea1406e..eed0e318 100644 --- a/packages/ui/src/apps/mobileWidgetSnapshot.ts +++ b/packages/ui/src/apps/mobileWidgetSnapshot.ts @@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useNotificationStore } from '@/sync/notification-store'; +import { getRuntimeKey } from '@/lib/runtime-switch'; /** * Builds the lightweight session overview the native iOS widgets render (home medium, @@ -26,6 +27,8 @@ export interface MobileWidgetSession { } export interface MobileWidgetSnapshot { + /** Runtime instance that owns all session IDs and paths in this snapshot. */ + runtimeKey: string; /** Count of sessions needing attention — same signal that drives the app-icon badge. */ attentionCount: number; /** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */ @@ -97,7 +100,7 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => { .slice(0, RECENT_LIMIT) .map(({ id, title, unread, project }) => ({ id, title, unread, project })); - return { attentionCount, recentSessions }; + return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions }; }; const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__'; diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index 9a9ebe86..e66e52f5 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -7,9 +7,15 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useUIStore } from '@/stores/useUIStore'; import { usePermissionStore } from '@/stores/permissionStore'; +import { useFileSearchStore } from '@/stores/useFileSearchStore'; +import { useGitStore } from '@/stores/useGitStore'; +import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; +import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; +import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; +import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; import { syncDesktopSettings } from '@/lib/persistence'; // Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK @@ -47,7 +53,13 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD // Cross-project session list (mobile sessions sheet & co) belongs to the // previous instance — drop it so stale sessions can't linger after a switch. useGlobalSessionsStore.getState().resetForRuntimeSwitch(); + useGlobalSessionStatusStore.setState({ statusById: new Map() }); usePermissionStore.getState().reset(); + useFileSearchStore.getState().resetForRuntimeSwitch(); + useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey); + useGitHubPrStatusStore.getState().resetForRuntimeSwitch(); + useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey); + useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); resetStreamingState(); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 034e5b13..7b4469be 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -36,16 +36,15 @@ import { useStreamingStore } from '@/sync/streaming'; import { useSessionMessageCount, useSessionMessageRecords, + useSessionMessageLoadState, useSyncDirectory, - useDirectorySync, + useSessionRenderable, useSessionStatus, useScopedBlockingPermissions, useScopedBlockingQuestions, useParentSession, } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; -import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache'; -import { getSessionMaterializationStatus } from '@/sync/materialization'; import { usePlanDetection } from '@/hooks/usePlanDetection'; import { useI18n } from '@/lib/i18n'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; @@ -215,6 +214,11 @@ const ChatViewport = React.memo(({ // Shell-mode prompts show their extracted command; cache by message id so // the parts array reference is stable while the command is unchanged. const shellPreviewCache = React.useRef(new Map()); + const shellPreviewSessionRef = React.useRef(currentSessionId); + if (shellPreviewSessionRef.current !== currentSessionId) { + shellPreviewSessionRef.current = currentSessionId; + shellPreviewCache.current.clear(); + } const promptPreviewsByTurnId = React.useMemo(() => { const next = new Map(); for (let index = 0; index < renderedMessages.length; index += 1) { @@ -339,6 +343,7 @@ const ChatViewport = React.memo(({ )} { + const { t } = useI18n(); + const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); + const projectLabel = useProjectsStore(React.useCallback((state) => { + const projectId = selectedProjectId ?? state.activeProjectId; + const project = (projectId + ? state.projects.find((candidate) => candidate.id === projectId) + : null) ?? state.projects[0] ?? null; + return project ? getProjectDisplayLabel(project) : null; + }, [selectedProjectId])); + + return ( +
+

+ {renderDraftTitle( + projectLabel + ? t('chat.emptyState.draftTitleWithProject', { project: projectLabel }) + : t('chat.emptyState.draftTitle'), + projectLabel, + )} +

+ useInputStore.getState().requestPresetSubmit(text)} + className="oc-draft-starters mt-8 max-w-md" + /> +
+ ); +}; + type ChatContainerProps = { + active?: boolean; autoOpenDraft?: boolean; readOnly?: boolean; }; -export const ChatContainer: React.FC = ({ autoOpenDraft = true, readOnly = false }) => { +export const ChatContainer: React.FC = ({ active = true, autoOpenDraft = true, readOnly = false }) => { const { t } = useI18n(); // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -500,16 +535,14 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); - const projects = useProjectsStore((s) => s.projects); - const activeProjectId = useProjectsStore((s) => s.activeProjectId); // Sync actions const sync = useSync(); const syncDirectory = useSyncDirectory(); const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory; const ensureSessionRenderable = React.useCallback( - (sessionId: string) => sync.ensureSessionRenderable(sessionId), - [sync], + (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), + [effectiveSessionDirectory, sync], ); const loadMoreMessages = React.useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -542,31 +575,17 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr ), ); const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory); - const hasRenderableSessionSnapshot = useDirectorySync( - React.useCallback( - (state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false), - [currentSessionId], - ), - effectiveSessionDirectory, - ); + const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory); // Messages from sync system const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, { + enabled: active, suspendPartUpdates: Boolean(streamingMessageId), suspendPartUpdatesForMessageId: streamingMessageId, }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; - const sessionPrefetchInfo = React.useSyncExternalStore( - React.useCallback( - (notify) => currentSessionId - ? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify) - : () => undefined, - [currentSessionId, effectiveSessionDirectory], - ), - React.useCallback( - () => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined, - [currentSessionId, effectiveSessionDirectory], - ), - React.useCallback(() => undefined, []), + const sessionMessageLoadState = useSessionMessageLoadState( + currentSessionId ?? '', + effectiveSessionDirectory, ); // Plan detection - watches messages for plan creation and signals store @@ -643,20 +662,12 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr // History metadata — use sync's hasMore/isLoading const historyMeta = React.useMemo(() => { if (!currentSessionId) return null; - // Sync's meta is authoritative once a fetch has confirmed the history - // is fully loaded — a stale prefetch-cache entry (cursor recorded at - // the initial page) must not keep the "load older" affordance alive - // after the user has already reached the top. - const syncComplete = sync.isComplete(currentSessionId); - const prefetchHasMore = !syncComplete - && Boolean(sessionPrefetchInfo?.cursor) - && sessionPrefetchInfo?.complete !== true; return { limit: sessionMessages.length, - complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore), - loading: sync.isLoading(currentSessionId), + complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor, + loading: sessionMessageLoadState.status === 'loading', }; - }, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]); + }, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]); const { isMobile } = useDeviceInfo(); const isVSCode = isVSCodeRuntime(); @@ -668,17 +679,6 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr const isDesktopExpandedInput = isExpandedInput; const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat'; const messageListRef = React.useRef(null); - const draftProjectLabel = React.useMemo(() => { - const selectedProject = newSessionDraft?.selectedProjectId - ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null - : null; - const activeProject = activeProjectId - ? projects.find((project) => project.id === activeProjectId) ?? null - : null; - const project = selectedProject ?? activeProject ?? projects[0] ?? null; - return project ? getProjectDisplayLabel(project) : null; - }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); - const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); // In the embedded session-chat iframe, hide "Return to parent" when @@ -942,9 +942,13 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr const isSessionHydrating = Boolean(currentSessionId) && !hasRenderableSessionSnapshot; + const retrySessionLoad = React.useCallback(() => { + if (!active || !currentSessionId) return; + void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); + }, [active, currentSessionId, effectiveSessionDirectory, sync]); React.useEffect(() => { - if (!currentSessionId) return; + if (!active || !currentSessionId) return; if (lastScrolledSessionRef.current === currentSessionId) return; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; @@ -963,14 +967,13 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr } else { window.requestAnimationFrame(run); } - }, [currentSessionId, releaseAutoFollow, restoreSnapshot]); + }, [active, currentSessionId, releaseAutoFollow, restoreSnapshot]); React.useEffect(() => { - if (!currentSessionId) return; + if (!active || !currentSessionId) return; if (hasRenderableSessionSnapshot) return; - if (effectiveSessionDirectory !== syncDirectory) return; void ensureSessionRenderable(currentSessionId); - }, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]); + }, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]); if (!currentSessionId && !draftOpen) { // With auto-open, the draft welcome opens on the next tick (effect below), @@ -993,22 +996,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr // the fullscreen composer's position:fixed visual-viewport pinning in // mobile browsers (see ChatInput's composerFormRef effect).
- {useCompactDraftLayout && !isDesktopExpandedInput ? ( -
-

- {renderDraftTitle( - draftProjectLabel - ? t('chat.emptyState.draftTitleWithProject', { project: draftProjectLabel }) - : t('chat.emptyState.draftTitle'), - draftProjectLabel, - )} -

- useInputStore.getState().requestPresetSubmit(text)} - className="oc-draft-starters mt-8 max-w-md" - /> -
- ) : null} + {useCompactDraftLayout && !isDesktopExpandedInput ? : null}
= ({ autoOpenDraft = tr } if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { + if (sessionMessageLoadState.status === 'error') { + return ( +
+ {returnToParentButton} +
+
+
+ +
+

{t('chat.container.sessionLoadError.title')}

+

{t('chat.container.sessionLoadError.description')}

+ +
+
+
+ {promptReadOnly ? : } +
+
+ ); + } return (
{returnToParentButton} @@ -1124,7 +1134,6 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
{returnToParentButton} ): string => ( + `${text}\u0000${[...confirmedMentions].sort().join('\u0000')}` +); const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const VS_CODE_DROP_DATA_TYPES = [ 'CodeFiles', @@ -918,81 +930,35 @@ type AutocompleteOverlayPosition = { maxHeight: number; }; -// Per-session draft key — preserves in-progress messages across project switches -const getDraftKey = (sessionId: string | null): string => - `openchamber_chat_input_draft_${sessionId ?? 'new'}`; - -// Helper to safely read from localStorage for a given session -const getStoredDraft = (sessionId: string | null): string => { - try { - return localStorage.getItem(getDraftKey(sessionId)) ?? ''; - } catch { - return ''; - } -}; - -// Helper to safely write/clear a per-session draft -const saveStoredDraft = (sessionId: string | null, draft: string): void => { - try { - if (draft) { - localStorage.setItem(getDraftKey(sessionId), draft); - } else { - localStorage.removeItem(getDraftKey(sessionId)); - } - } catch { - // Ignore localStorage errors - } -}; - -// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text -const getConfirmedMentionsKey = (sessionId: string | null): string => - `openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`; - -const saveConfirmedMentions = (sessionId: string | null, mentions: Set): void => { - try { - if (mentions.size > 0) { - localStorage.setItem(getConfirmedMentionsKey(sessionId), JSON.stringify([...mentions])); - } else { - localStorage.removeItem(getConfirmedMentionsKey(sessionId)); - } - } catch { - // Ignore localStorage errors - } -}; - -const loadConfirmedMentions = (sessionId: string | null): Set => { - try { - const raw = localStorage.getItem(getConfirmedMentionsKey(sessionId)); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return new Set(parsed.filter((v): v is string => typeof v === 'string')); - } - } - } catch { - // Ignore localStorage errors - } - return new Set(); +const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { + const sessionState = useSessionUIStore.getState(); + const newSessionDirectory = sessionState.newSessionDraft?.open + ? sessionState.newSessionDraft.bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride + : null; + const directory = sessionId + ? sessionState.getDirectoryForSession(sessionId) ?? sessionState.currentSessionDirectory + : newSessionDirectory ?? useDirectoryStore.getState().currentDirectory; + return createChatDraftIdentity(getRuntimeKey(), directory, sessionId); }; const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom }) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); - // Track initial session ID (captured at mount time for draft restoration) - const initialSessionIdRef = React.useRef(null); + const initialDraftIdentityRef = React.useRef(null); + const initialDraftSnapshotRef = React.useRef({ text: '', confirmedMentions: new Set() }); const [message, setMessage] = React.useState(() => { - // Read per-session draft at mount time using the current session from the store const sessionId = useSessionUIStore.getState().currentSessionId; - initialSessionIdRef.current = sessionId; - const draft = getStoredDraft(sessionId); - if (draft) { - initialDraftRef.current = draft; + const identity = resolveChatDraftIdentity(sessionId); + const snapshot = readChatDraft(identity); + initialDraftIdentityRef.current = identity; + initialDraftSnapshotRef.current = snapshot; + if (snapshot.text) { + initialDraftRef.current = snapshot.text; } - return draft; + return snapshot.text; }); - // Restore confirmed mentions from localStorage on mount - const confirmedMentionsRef = React.useRef>(loadConfirmedMentions(initialSessionIdRef.current)); + const confirmedMentionsRef = React.useRef>(initialDraftSnapshotRef.current.confirmedMentions); // Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed) const isConfirmedFilePath = (text: string): boolean => text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text); @@ -1070,7 +1036,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const draftPersistTimerRef = React.useRef | null>(null); const skipNextDraftPersistRef = React.useRef(false); const lastPersistedDraftRef = React.useRef>(new Map()); - const currentSessionIdForDraftRef = React.useRef(null); + const currentChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); const pendingPastedAttachmentFilenamesRef = React.useRef>(new Set()); // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) @@ -1084,6 +1050,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const currentSessionDirectoryForSync = useSessionUIStore( React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), ); + const activeRuntimeKey = getRuntimeKey(); + const chatDraftIdentity = React.useMemo( + () => createChatDraftIdentity( + activeRuntimeKey, + currentSessionDirectoryForSync ?? currentDirectory, + currentSessionId, + ), + [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], + ); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => ( @@ -1487,14 +1462,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } | null>(null); // Message queue + const messageQueueTarget = currentSessionId + ? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory) + : null; + const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null; const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { - if (!currentSessionId) return EMPTY_QUEUE; - return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE; + if (!messageQueueKey) return EMPTY_QUEUE; + return state.queuedMessages[messageQueueKey] ?? EMPTY_QUEUE; }, - [currentSessionId] + [messageQueueKey] ) ); const addToQueue = useMessageQueueStore((state) => state.addToQueue); @@ -1502,21 +1481,27 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); // Inline comment drafts + const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory; + const inlineDraftTarget = React.useMemo( + () => inlineDraftSessionKey && inlineDraftDirectory + ? { directory: inlineDraftDirectory, sessionKey: inlineDraftSessionKey } + : null, + [inlineDraftDirectory, inlineDraftSessionKey], + ); + const inlineDraftKey = inlineDraftTarget + ? getInlineCommentDraftKey(activeRuntimeKey, inlineDraftTarget.directory, inlineDraftTarget.sessionKey) + : null; const draftCount = useInlineCommentDraftStore( React.useCallback( - (state) => { - const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); - if (!sessionKey) return 0; - return (state.drafts[sessionKey] ?? []).length; - }, - [currentSessionId, newSessionDraftOpen] + (state) => inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []).length : 0, + [inlineDraftKey] ) ); const draftSourceKey = useInlineCommentDraftStore( React.useCallback( (state) => { - const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); - const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : []; + const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : []; let previewConsole = 0; let previewAnnotation = 0; let review = 0; @@ -1529,7 +1514,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`; }, - [currentSessionId, newSessionDraftOpen] + [inlineDraftKey] ) ); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); @@ -1537,29 +1522,27 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const hasDrafts = draftCount > 0; const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const terminalContextDrafts = terminalContextCount > 0 - ? (useInlineCommentDraftStore.getState().drafts[currentSessionId ?? (newSessionDraftOpen ? 'draft' : '')] ?? []).filter((draft) => draft.source === 'terminal') + ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') : []; const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => { - const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); - if (!sessionKey) return; - const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? []; + if (!inlineDraftTarget) return; + const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { if (draft.source === source) { - removeInlineCommentDraft(sessionKey, draft.id); + removeInlineCommentDraft(inlineDraftTarget, draft.id); } } - }, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]); + }, [inlineDraftTarget, removeInlineCommentDraft]); // Review comments are the inline-comment drafts that aren't preview sources. const removeReviewDrafts = React.useCallback(() => { - const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); - if (!sessionKey) return; - const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? []; + if (!inlineDraftTarget) return; + const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') { - removeInlineCommentDraft(sessionKey, draft.id); + removeInlineCommentDraft(inlineDraftTarget, draft.id); } } - }, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]); + }, [inlineDraftTarget, removeInlineCommentDraft]); // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. @@ -1571,17 +1554,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, [message]); React.useEffect(() => { - currentSessionIdForDraftRef.current = currentSessionId; - }, [currentSessionId]); + currentChatDraftIdentityRef.current = chatDraftIdentity; + }, [chatDraftIdentity]); - const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => { - const key = getDraftKey(sessionId); - const lastPersisted = lastPersistedDraftRef.current.get(key); - if (lastPersisted === draft) { - return; - } - - saveStoredDraft(sessionId, draft); + const persistDraftImmediately = React.useCallback((identity: ChatDraftIdentity | null, draft: string) => { + if (!identity) return; + const key = getChatDraftIdentityKey(identity); // Only persist confirmed mentions that are actually present in the draft text const activeMentions = new Set(); for (const mention of confirmedMentionsRef.current) { @@ -1590,8 +1568,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } confirmedMentionsRef.current = activeMentions; - saveConfirmedMentions(sessionId, activeMentions); - lastPersistedDraftRef.current.set(key, draft); + const signature = getChatDraftSnapshotSignature(draft, activeMentions); + const lastPersisted = lastPersistedDraftRef.current.get(key); + if (lastPersisted === signature) { + return; + } + writeChatDraft(identity, draft, activeMentions); + lastPersistedDraftRef.current.set(key, signature); }, []); const clearPendingDraftPersist = React.useCallback(() => { @@ -1614,11 +1597,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!persistChatDraft) { // Setting disabled - clear the restored draft setMessage(''); - try { - localStorage.removeItem(getDraftKey(initialSessionIdRef.current)); - } catch { - // Ignore - } + writeChatDraft(initialDraftIdentityRef.current, '', []); } else { // Setting enabled - select all text requestAnimationFrame(() => { @@ -1627,24 +1606,24 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }, [persistChatDraft]); - // Handle session switching: save draft for old session, restore draft for new session - const prevSessionIdRef = React.useRef(currentSessionId); + // Handle identity switching: save the old draft and restore the new runtime/directory/session draft. + const prevChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); React.useEffect(() => { - if (prevSessionIdRef.current !== currentSessionId) { - const oldSessionId = prevSessionIdRef.current; - prevSessionIdRef.current = currentSessionId; + const previousIdentity = prevChatDraftIdentityRef.current; + const previousKey = previousIdentity ? getChatDraftIdentityKey(previousIdentity) : null; + const currentKey = chatDraftIdentity ? getChatDraftIdentityKey(chatDraftIdentity) : null; + if (previousKey !== currentKey) { + prevChatDraftIdentityRef.current = chatDraftIdentity; setInputMode('normal'); clearPendingDraftPersist(); skipNextDraftPersistRef.current = true; if (persistChatDraft) { - // Save current draft for the session we're leaving - persistDraftImmediately(oldSessionId, messageRef.current); - // Restore draft for the session we're entering - const newDraft = getStoredDraft(currentSessionId); - setMessage(newDraft); - confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId); - if (newDraft) { + persistDraftImmediately(previousIdentity, messageRef.current); + const nextSnapshot = readChatDraft(chatDraftIdentity); + setMessage(nextSnapshot.text); + confirmedMentionsRef.current = nextSnapshot.confirmedMentions; + if (nextSnapshot.text) { requestAnimationFrame(() => { textareaRef.current?.select(); }); @@ -1655,7 +1634,19 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo confirmedMentionsRef.current = new Set(); } } - }, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]); + }, [chatDraftIdentity, clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); + + React.useEffect(() => subscribeChatDraftDeletion((deletedIdentity) => { + const deletedKey = getChatDraftIdentityKey(deletedIdentity); + lastPersistedDraftRef.current.set(deletedKey, getChatDraftSnapshotSignature('', [])); + const currentIdentity = currentChatDraftIdentityRef.current; + if (!currentIdentity || getChatDraftIdentityKey(currentIdentity) !== deletedKey) return; + clearPendingDraftPersist(); + skipNextDraftPersistRef.current = true; + messageRef.current = ''; + confirmedMentionsRef.current = new Set(); + setMessage(''); + }), [clearPendingDraftPersist]); // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); @@ -1678,7 +1669,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { if (!persistChatDraft) { clearPendingDraftPersist(); - persistDraftImmediately(currentSessionId, ''); + persistDraftImmediately(chatDraftIdentity, ''); return; } @@ -1689,24 +1680,37 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo clearPendingDraftPersist(); const draftSnapshot = message; - const sessionSnapshot = currentSessionId; + const identitySnapshot = chatDraftIdentity; draftPersistTimerRef.current = setTimeout(() => { draftPersistTimerRef.current = null; - persistDraftImmediately(sessionSnapshot, draftSnapshot); + persistDraftImmediately(identitySnapshot, draftSnapshot); }, CHAT_DRAFT_PERSIST_DEBOUNCE_MS); return () => { clearPendingDraftPersist(); }; - }, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]); + }, [chatDraftIdentity, clearPendingDraftPersist, message, persistChatDraft, persistDraftImmediately]); React.useEffect(() => { - return () => { + const flushCurrentDraft = () => { clearPendingDraftPersist(); if (persistChatDraft) { - persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current); + persistDraftImmediately(currentChatDraftIdentityRef.current, messageRef.current); } }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') flushCurrentDraft(); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + document.addEventListener('freeze', flushCurrentDraft); + window.addEventListener('pagehide', flushCurrentDraft); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + document.removeEventListener('freeze', flushCurrentDraft); + window.removeEventListener('pagehide', flushCurrentDraft); + flushCurrentDraft(); + }; }, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); // Session activity for queue availability and controls @@ -1782,9 +1786,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); - if (!inputSnapshot.hasContent || !currentSessionId) return; + if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; - const drafts = consumeDrafts(currentSessionId); + const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : []; let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); if (drafts.length > 0) { @@ -1792,7 +1796,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles); - addToQueue(currentSessionId, { + addToQueue(messageQueueTarget, { content: messageToQueue, attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, sendConfig: currentProviderId && currentModelId ? { @@ -1815,7 +1819,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!isMobile) { textareaRef.current?.focus(); } - }, [getCurrentInputSnapshot, currentSessionId, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); @@ -1974,10 +1978,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + const consumedDraftTarget = inlineDraftTarget; let drafts: InlineCommentDraft[] = []; - if (!queuedOnly && sessionKey) { - drafts = consumeDrafts(sessionKey); + if (!queuedOnly && consumedDraftTarget) { + drafts = consumeDrafts(consumedDraftTarget); } if (drafts.length > 0) { @@ -2032,17 +2036,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return; // Clear queue and input - if (currentSessionId && queuedMessageId) { - removeFromQueue(currentSessionId, queuedMessageId); - } else if (currentSessionId && hasQueuedMessages) { - clearQueue(currentSessionId); + if (messageQueueTarget && queuedMessageId) { + removeFromQueue(messageQueueTarget, queuedMessageId); + } else if (messageQueueTarget && hasQueuedMessages) { + clearQueue(messageQueueTarget); } if (!queuedOnly) { setMessage(''); confirmedMentionsRef.current.clear(); // Clear per-session draft on submit - saveStoredDraft(currentSessionId, ''); - saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current); + persistDraftImmediately(chatDraftIdentity, ''); // Reset message history navigation state setHistoryIndex(-1); setDraftMessage(''); @@ -2333,8 +2336,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo sendMessageOptions, ); const restoreConsumedDrafts = () => { - if (sessionKey && drafts.length > 0) { - useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts); + if (consumedDraftTarget && drafts.length > 0) { + useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts); } }; @@ -2369,7 +2372,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const currentInput = textareaRef.current?.value ?? messageRef.current; if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { setMessage(inputSnapshot.message); - saveStoredDraft(null, inputSnapshot.message); + writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); } const isSoftNetworkError = @@ -4667,7 +4670,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo {t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })} -
diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 476e7457..a499a805 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -163,12 +163,8 @@ const ChatMessage: React.FC = ({ const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); - const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession); const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection); - const revertToMessage = useSessionUIStore((s) => s.revertToMessage); - const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage); streamPerfCount('ui.chat_message.render'); if (isInActiveTurn) { @@ -186,12 +182,6 @@ const ChatMessage: React.FC = ({ })) ); - React.useEffect(() => { - if (currentSessionId) { - MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); - } - }, [currentSessionId]); - const [copiedCode, setCopiedCode] = React.useState(null); const [copiedMessage, setCopiedMessage] = React.useState(false); const [expandedTools, setExpandedTools] = React.useState>(() => readExpandedToolsCache(message.info.id)); @@ -580,8 +570,8 @@ const ChatMessage: React.FC = ({ const shouldAnimateMessage = React.useMemo(() => { if (isUser) return false; const freshnessDetector = MessageFreshnessDetector.getInstance(); - return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID); - }, [message.info, currentSessionId, isUser]); + return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID); + }, [message.info, isUser]); const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false); @@ -794,14 +784,14 @@ const ChatMessage: React.FC = ({ const handleRevert = React.useCallback(() => { if (!sessionId || !message.info.id) return; - revertToMessage(sessionId, message.info.id); - }, [sessionId, message.info.id, revertToMessage]); + useSessionUIStore.getState().revertToMessage(sessionId, message.info.id); + }, [sessionId, message.info.id]); // NEW: Fork handler const handleFork = React.useCallback(() => { if (!sessionId || !message.info.id) return; - forkFromMessage(sessionId, message.info.id); - }, [sessionId, message.info.id, forkFromMessage]); + useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id); + }, [sessionId, message.info.id]); const handleToggleTool = React.useCallback((toolId: string) => { const isDefaultOpen = defaultOpenToolIds.has(toolId); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index f88450cb..77606013 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -40,6 +40,7 @@ import { parseFileReference, type ParsedFileReference, } from './fileReferenceParser'; +import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; const useCurrentMermaidTheme = () => { const themeSystem = useOptionalThemeSystem(); @@ -754,69 +755,6 @@ const useMermaidInlineInteractions = ({ // Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom // --------------------------------------------------------------------------- -// Single tuning knob: the streaming reveal cadence. Lower = smoother but more -// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are -// auto-scaled from this so reveal throughput (chars/sec) stays constant no -// matter the cadence — text always keeps up with the incoming stream. -const TEXT_PACE_MS = 64; -const PACE_BASELINE_MS = 24; -const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS; -const TEXT_SNAP = /[\s.,!?;:)\]]/; - -const paceStep = (remaining: number): number => { - const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8)); - return Math.max(1, Math.round(base * PACE_RATIO)); -}; - -const nextRevealIndex = (text: string, start: number): number => { - const end = Math.min(text.length, start + paceStep(text.length - start)); - for (let i = end; i < Math.min(text.length, end + 8); i += 1) { - if (TEXT_SNAP.test(text[i] ?? '')) return i + 1; - } - return end; -}; - -// Granular streaming reveal. Cheap because each step only re-runs the -// marked->morphdom pipeline (patching changed DOM nodes), with no React tree -// reconciliation of the markdown body. -const usePacedText = (content: string, streaming: boolean): string => { - const [shown, setShown] = React.useState(() => (streaming ? 0 : content.length)); - const shownRef = React.useRef(shown); - shownRef.current = shown; - - React.useEffect(() => { - if (!streaming || typeof window === 'undefined') { - setShown(content.length); - return; - } - if (shownRef.current > content.length) { - setShown(content.length); - } - - let timer: number | null = null; - const tick = () => { - const current = Math.min(shownRef.current, content.length); - if (current >= content.length) { - timer = null; - return; - } - setShown(nextRevealIndex(content, current)); - timer = window.setTimeout(tick, TEXT_PACE_MS); - }; - - if (shownRef.current < content.length) { - timer = window.setTimeout(tick, TEXT_PACE_MS); - } - - return () => { - if (timer !== null) window.clearTimeout(timer); - }; - }, [content, streaming]); - - if (!streaming) return content; - return content.slice(0, Math.min(shown, content.length)); -}; - // Mermaid layout is expensive; `decorate` would otherwise re-render every // diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source // so a stable diagram is laid out once and served from cache thereafter. @@ -1094,6 +1032,9 @@ const MarkdownRendererImpl: React.FC = ({ onShowPopup, enableFileReferences = true, }) => { + streamPerfCount('ui.markdown_renderer.render'); + if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming'); + streamPerfObserve('ui.markdown_renderer.content_len', content.length); const currentTheme = useCurrentMermaidTheme(); const { editor, runtime } = useRuntimeAPIs(); const containerRef = React.useRef(null); @@ -1106,7 +1047,6 @@ const MarkdownRendererImpl: React.FC = ({ }, [effectiveDirectory, openContextPreview]); const live = isStreaming && !disableStreamAnimation; - const pacedText = usePacedText(content, live); useMermaidInlineInteractions({ containerRef, @@ -1127,7 +1067,7 @@ const MarkdownRendererImpl: React.FC = ({ const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; - useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx }); + useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx }); const markdownContent = (
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 6e43b76d..2ce4442e 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -16,7 +16,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { isHiddenUserMessage } from './message/hiddenUserMessage'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; -import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; +import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug'; import type { StreamPhase } from './message/types'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useSessionParts } from '@/sync/sync-context'; @@ -885,6 +885,7 @@ const MessageListEntry = React.memo(({ activeStreamingPhase, reviewTransferDirection, }: MessageListEntryProps) => { + streamPerfCount('ui.message_list_entry.render'); if (entry.kind === 'ungrouped') { return ( (({ scrollRef, directory, }, ref) => { + streamPerfMark('react.message_list_render'); streamPerfCount('ui.message_list.render'); const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const chatRenderMode = useUIStore((state) => state.chatRenderMode); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.test.ts b/packages/ui/src/components/chat/MobileSessionStatusBar.test.ts new file mode 100644 index 00000000..a0814fbd --- /dev/null +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./MobileSessionStatusBar.tsx', import.meta.url), 'utf8'); + +describe('MobileSessionStatusBar hidden work', () => { + test('does not mount session grouping and project derivation while the panel is closed', () => { + const wrapperStart = source.indexOf('export const MobileSessionStatusBar'); + const openPanelStart = source.indexOf('const MobileSessionStatusOpenPanel'); + const closedGuard = source.indexOf('if (!isMobile || !open) return null;', wrapperStart); + const openPanelMount = source.indexOf('; } // Cross-project session source. Mirrors the dedicated MobileSessionsSheet: @@ -84,12 +82,14 @@ function useSessionGrouping( const map = new Map(); const allIds = new Set(sessions.map((s) => s.id)); - sessions.forEach((session) => { + for (const session of sessions) { const parentID = (session as { parentID?: string }).parentID; if (parentID && allIds.has(parentID)) { - map.set(parentID, [...(map.get(parentID) || []), session]); + const children = map.get(parentID); + if (children) children.push(session); + else map.set(parentID, [session]); } - }); + } return map; }, [sessions]); @@ -99,24 +99,6 @@ function useSessionGrouping( return 'idle'; }, [sessionStatus]); - const hasRunningChildren = React.useCallback((sessionId: string): boolean => { - const children = parentChildMap.get(sessionId) || []; - return children.some((child) => getStatusType(child.id) !== 'idle'); - }, [parentChildMap, getStatusType]); - - const getRunningChildrenCount = React.useCallback((sessionId: string): number => { - const children = parentChildMap.get(sessionId) || []; - return children.filter((child) => getStatusType(child.id) !== 'idle').length; - }, [parentChildMap, getStatusType]); - - const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => { - const children = parentChildMap.get(sessionId) || []; - return children - .filter((child) => getStatusType(child.id) !== 'idle') - .map((child) => ({ session: child, isRunning: true })) - .slice(0, 3); - }, [parentChildMap, getStatusType]); - const processedSessions = React.useMemo(() => { const sessionIds = new Set(sessions.map((s) => s.id)); const topLevel = sessions.filter((session) => { @@ -129,20 +111,18 @@ function useSessionGrouping( topLevel.forEach((session) => { const statusType = getStatusType(session.id); - const hasRunning = hasRunningChildren(session.id); + const runningChildrenCount = (parentChildMap.get(session.id) ?? []) + .filter((child) => getStatusType(child.id) !== 'idle') + .length; const attention = (unseenCounts[session.id] ?? 0) > 0; const enriched: SessionWithStatus = { ...session, _statusType: statusType, - _hasRunningChildren: hasRunning, - _runningChildrenCount: getRunningChildrenCount(session.id), - _childIndicators: getChildIndicators(session.id), + _runningChildrenCount: runningChildrenCount, }; - if (statusType !== 'idle' || hasRunning) { - running.push(enriched); - } else if (attention) { + if (statusType !== 'idle' || runningChildrenCount > 0 || attention) { running.push(enriched); } else { viewed.push(enriched); @@ -159,7 +139,7 @@ function useSessionGrouping( viewed.sort(sortByUpdated); return [...running, ...viewed]; - }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]); + }, [sessions, getStatusType, parentChildMap, unseenCounts]); const totalRunning = processedSessions.reduce((sum, s) => { const selfRunning = s._statusType !== 'idle' ? 1 : 0; @@ -188,7 +168,6 @@ function useSessionHelpers() { // Per-project status indicators (running / unread) for the filter chips. function useProjectStatus( - sessions: Session[], sessionStatus: Record | undefined, currentSessionId: string | null ) { @@ -450,12 +429,11 @@ export const MobileSessionPanelTrigger: React.FC ); }; -export const MobileSessionStatusBar: React.FC = ({ +const MobileSessionStatusOpenPanel: React.FC = ({ onSessionSwitch, }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); - const isMobile = useUIStore((state) => state.isMobile); const sessions = useAllProjectSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionStatus = useAllSessionStatuses(); @@ -469,7 +447,7 @@ export const MobileSessionStatusBar: React.FC = ({ const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus); const { getSessionTitle, needsAttention } = useSessionHelpers(); - const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); + const getProjectStatus = useProjectStatus(sessionStatus, currentSessionId); const resolveProjectRoots = useProjectRootsResolver(); // Project filter, persisted in the UI store so the choice survives closing and @@ -605,10 +583,6 @@ export const MobileSessionStatusBar: React.FC = ({
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]); - if (!isMobile) { - return null; - } - return ( = ({ ); }; + +export const MobileSessionStatusBar: React.FC = (props) => { + const isMobile = useUIStore((state) => state.isMobile); + const open = useUIStore((state) => state.mobileSessionPanelOpen); + + if (!isMobile || !open) return null; + return ; +}; diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index efafb04d..a325b05d 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -29,9 +29,8 @@ import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; -import { useDirectorySync, useSessionMessages } from '@/sync/sync-context'; +import { useSessionMessages, useSessionRenderable } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; -import { getSessionMaterializationStatus } from '@/sync/materialization'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; @@ -646,11 +645,8 @@ export const ModelControls: React.FC = ({ const latestLoadedUserChoiceRestoreRef = React.useRef(null); const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; - const hasRenderableCurrentSessionSnapshot = useDirectorySync( - React.useCallback( - (state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false), - [currentSessionId], - ), + const hasRenderableCurrentSessionSnapshot = useSessionRenderable( + currentSessionId ?? '', currentSessionDirectory ?? undefined, ); const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index e1605281..d49b1ae5 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -14,7 +14,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; +import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; import { useI18n } from '@/lib/i18n'; @@ -24,12 +24,12 @@ import { cn } from '@/lib/utils'; interface QueuedMessageChipProps { message: QueuedMessage; - sessionId: string; + target: MessageQueueTarget; onEdit: (message: QueuedMessage) => void; onSend: (message: QueuedMessage) => void; } -const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMessageChipProps) => { +const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessageChipProps) => { const { t } = useI18n(); const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id }); @@ -89,7 +89,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMe + + ) : t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
) : null} diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 88b33aff..c6a058bb 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -18,6 +18,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/ import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore'; import { Icon } from "@/components/icon/Icon"; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession'; @@ -25,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession import { useSync } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from './sessionFolderDnd'; -import { nodeContainsSessionId } from './sessionNodeItemUtils'; +import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils'; import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNode } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils'; @@ -43,6 +44,8 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; +import { streamPerfCount } from '@/stores/utils/streamDebug'; +import { useSessionUIStore } from '@/sync/session-ui-store'; type Folder = { id: string; name: string; sessionIds: string[] }; @@ -57,7 +60,6 @@ type Props = { groupDirectory?: string | null; projectId?: string | null; archivedBucket?: boolean; - currentSessionId: string | null; pinnedSessionIds: Set; expandedParents: Set; hasSessionSearchQuery: boolean; @@ -70,9 +72,9 @@ type Props = { handleSaveEdit: (titleOverride?: string) => void; handleCancelEdit: () => void; toggleParent: (expansionKey: string) => void; - handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void; + handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void; handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void; - togglePinnedSession: (sessionId: string) => void; + togglePinnedSession: (target: SessionPinnedTarget) => void; handleShareSession: (session: Session) => void; copiedSessionId: string | null; handleCopyShareUrl: (url: string, sessionId: string) => void; @@ -101,16 +103,9 @@ type Props = { ) => React.ReactNode; secondaryMeta?: SecondaryMeta | null; renderContext?: 'project' | 'recent'; - /** - * Precomputed set of session IDs whose subtree contains the current - * active session. Computed once per SessionGroupSection render (when - * currentSessionId changes) instead of being recomputed in every row's - * React.memo comparator. - */ - subtreeContainsActive: Set; /** * Precomputed set of session IDs whose subtree contains the session - * currently being edited. Same rationale as subtreeContainsActive. + * currently being edited. Precomputed once per group render. */ subtreeContainsEditing: Set; /** @@ -132,15 +127,52 @@ type Props = { * to fetch the right key for each child it produces. */ childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras; - /** - * Batched index of live session objects keyed by id. The previous - * implementation called `useSession(session.id)` per row, which used - * `findLiveSession` to iterate every child-store on every SSE event. - * With M visible rows that's M×child-stores per event; the batched - * map turns it into a single Map.get per row. The parent falls back - * to `useSession` only when this map returns undefined. - */ - liveSessionById: Map; +}; + +const cancelScrollAnchorByContainer = new WeakMap void>(); + +const holdSessionRowPosition = (target: HTMLElement): void => { + if (typeof window === 'undefined') return; + const row = target.closest('[data-session-row]'); + const container = row?.closest('.overlay-scrollbar-container'); + if (!row || !container) return; + + cancelScrollAnchorByContainer.get(container)?.(); + + const initialTop = row.getBoundingClientRect().top; + let remainingFrames = 3; + let cancelled = false; + let frameId: number | null = null; + const cancel = () => { + cancelled = true; + if (frameId !== null) window.cancelAnimationFrame(frameId); + frameId = null; + cancelScrollAnchorByContainer.delete(container); + container.removeEventListener('wheel', cancel); + container.removeEventListener('touchstart', cancel); + }; + const restore = () => { + if (cancelled || !row.isConnected || !container.isConnected) { + cancel(); + return; + } + const delta = row.getBoundingClientRect().top - initialTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + streamPerfCount('ui.sidebar.selection_scroll_anchor_adjustment'); + } + remainingFrames -= 1; + if (remainingFrames <= 0) { + cancel(); + return; + } + frameId = window.requestAnimationFrame(restore); + }; + + container.addEventListener('wheel', cancel, { passive: true }); + container.addEventListener('touchstart', cancel, { passive: true }); + cancelScrollAnchorByContainer.set(container, cancel); + frameId = window.requestAnimationFrame(restore); }; type QuickSessionActionProps = { @@ -206,6 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({ }); function SessionNodeItemComponent(props: Props): React.ReactNode { + streamPerfCount('ui.sidebar_session_node.render'); const { t } = useI18n(); const { node, @@ -213,7 +246,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { groupDirectory, projectId, archivedBucket = false, - currentSessionId, pinnedSessionIds, expandedParents, hasSessionSearchQuery, @@ -248,11 +280,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { renderSessionNode, secondaryMeta, renderContext = 'project', - subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, childRenderExtrasFor, - liveSessionById, } = props; const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel); const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel); @@ -307,25 +337,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const formRef = React.useRef(null); const session = node.session; - // Batched live-session lookup. `liveSessionById` is built once per - // Sidebar render from the same `useAllLiveSessions` selector that - // `useSession` would have iterated per child-store, so a Map.get - // here is equivalent in observed state but O(1) per row instead of - // O(child-stores). Falls back to the row session when the live map - // hasn't seen this id yet (sub-render latency between when a session - // is created and when the SSE-driven aggregate picks it up). - const resolvedSession = liveSessionById.get(session.id) ?? session; + const resolvedSession = session; + const isActive = useSessionUIStore((state) => state.currentSessionId === session.id); const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); - // Archived rows are historical and never need live state, yet they point at - // dozens of (often deleted) worktrees — bootstrapping each from the sidebar - // triggers a pointless session-list fetch + 6×2s empty-retry storm on startup. - // Skip bootstrap for archived rows; the store ref is only read on-demand via - // getState() in the export handlers (never subscribed). Active rows keep - // bootstrapping so live cross-directory session/status still aggregates. - const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket }); + // Directory bootstrap is scheduled once at sidebar level. A row only needs + // the lightweight store reference for scoped state and export actions. + const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false }); const sync = useSync(); const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); @@ -368,7 +388,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { ); const sessionStatus = useGlobalSessionStatus(session.id); const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id); - const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); + const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false }); const sessionGoal = getSessionGoal(resolvedSession); const sessionGoalGlyph = sessionGoal ? ( ) : null; - const isActive = currentSessionId === session.id; const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled'); const hasChildren = node.children.length > 0; - const isPinnedSession = pinnedSessionIds.has(session.id); + const isPinnedSession = isSessionPinned(pinnedSessionIds, sessionDirectory, session.id); // Per-render-context expansion key: the same session can appear in both // the project's root and the "Recent" list, and expanding one should not // expand the other. Matches the format of menuInstanceKey. @@ -409,7 +428,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { let skipped = 0; for (const child of children) { try { - await sync.ensureSessionRenderable(child.session.id); + await sync.ensureSessionRenderable(child.session.id, false, sessionDirectory ?? undefined); const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list; const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent'); const childAgent = (child.session as Session & { agent?: string }).agent; @@ -426,7 +445,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { } } return { children: results, skipped }; - }, [collectNodeDescendantIds, directoryStore, sync, t]); + }, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]); const showSkippedSubtasksWarning = React.useCallback((count: number) => { if (count <= 0) return; @@ -441,7 +460,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { return; } - await sync.ensureSessionRenderable(session.id); + await sync.ensureSessionRenderable(session.id, false, sessionDirectory); const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list; if (records.length === 0) { @@ -775,7 +794,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node)); return; } - handleSessionSelect(session.id, sessionDirectory, projectId); + if (event?.currentTarget) holdSessionRowPosition(event.currentTarget); + handleSessionSelect(session.id, sessionDirectory); }; // The selection/active highlight covers the WHOLE row box (gutter, edge @@ -832,7 +852,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { {t('sessions.sidebar.session.menu.rename')} - togglePinnedSession(session.id)} className="[&>svg]:mr-1"> + sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1"> {isPinnedSession ? : } {isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')} @@ -1267,7 +1287,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor ? childRenderExtrasFor(child) : { - subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: '', @@ -1387,26 +1406,6 @@ const hasSetMembershipChangeInNode = ( return false; }; -const hasResolvedSessionChangeInNode = ( - prevNode: SessionNode, - nextNode: SessionNode, - prevLiveSessionById: Map, - nextLiveSessionById: Map, -): boolean => { - if (prevNode.session.id !== nextNode.session.id) return true; - const sessionId = prevNode.session.id; - if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) { - return true; - } - if (prevNode.children.length !== nextNode.children.length) return true; - for (let i = 0; i < prevNode.children.length; i += 1) { - if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) { - return true; - } - } - return false; -}; - const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => { if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false; const prevBucketTag = prev.archivedBucket ? 'archived' : 'active'; @@ -1428,6 +1427,7 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => { const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => { if (prev.node.session.id !== next.node.session.id) return false; + if (prev.node.session !== next.node.session) return false; if (prev.depth !== next.depth) return false; if (prev.groupDirectory !== next.groupDirectory) return false; if (prev.projectId !== next.projectId) return false; @@ -1442,13 +1442,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => { if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false; if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false; - if (prev.liveSessionById !== next.liveSessionById - && hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) { - return false; - } - if (prev.pinnedSessionIds !== next.pinnedSessionIds - && hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) { + && nodeHasPinnedMembershipChange( + prev.node, + next.node, + prev.pinnedSessionIds, + next.pinnedSessionIds, + prev.groupDirectory, + next.groupDirectory, + )) { return false; } @@ -1456,14 +1458,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => { return false; } - if (prev.currentSessionId !== next.currentSessionId - && ( - subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive) - || subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive) - )) { - return false; - } - if (prev.editingId !== next.editingId && ( subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index 8f053344..8f97bc53 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -38,9 +38,9 @@ type Props = { renderContext?: 'project' | 'recent', renderExtras?: SessionNodeRenderExtras, ) => React.ReactNode; - currentSessionId: string | null; editingId: string | null; openSidebarMenuKey: string | null; + expansionState?: ReadonlySet; variant?: 'section' | 'flat'; initialVisibleCount?: number; batchSize?: number; @@ -50,16 +50,16 @@ type RenderExtras = SessionNodeRenderExtras; const MAX_VISIBLE_RECENT_SESSIONS = 7; -export function SidebarActivitySections({ - sections, - renderSessionNode, - currentSessionId, - editingId, - openSidebarMenuKey, - variant = 'section', - initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, - batchSize = MAX_VISIBLE_RECENT_SESSIONS, -}: Props): React.ReactNode { +export function SidebarActivitySections(props: Props): React.ReactNode { + const { + sections, + renderSessionNode, + editingId, + openSidebarMenuKey, + variant = 'section', + initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, + batchSize = MAX_VISIBLE_RECENT_SESSIONS, + } = props; const { t } = useI18n(); const [collapsed, setCollapsed] = React.useState>(new Set()); const [visibleCountBySection, setVisibleCountBySection] = React.useState>(new Map()); @@ -101,8 +101,6 @@ export function SidebarActivitySections({ }, [batchSize]); const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => { - const subtreeContainsActive = new Set(); - collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive); const subtreeContainsEditing = new Set(); collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing); const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false); @@ -114,7 +112,6 @@ export function SidebarActivitySections({ nodes.forEach(visit); const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({ - subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '', @@ -122,13 +119,12 @@ export function SidebarActivitySections({ }); return (node: SessionNode): RenderExtras => ({ - subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '', childRenderExtrasFor, }); - }, [currentSessionId, editingId, openSidebarMenuKey]); + }, [editingId, openSidebarMenuKey]); const visibleSections = sections.filter((section) => section.items.length > 0); if (visibleSections.length === 0) { diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index 9efac151..7498cec4 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -18,6 +18,7 @@ import { formatProjectLabel } from './utils'; import { useI18n } from '@/lib/i18n'; import type { MainTab } from '@/stores/useUIStore'; import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; +import { streamPerfCount } from '@/stores/utils/streamDebug'; type ProjectSection = { project: { @@ -78,7 +79,8 @@ type Props = { isInlineEditing: boolean; }; -export function SidebarProjectsList(props: Props): React.ReactNode { +function SidebarProjectsListComponent(props: Props): React.ReactNode { + streamPerfCount('ui.sidebar_projects_list.render'); const { t } = useI18n(); const projectSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -311,3 +313,5 @@ export function SidebarProjectsList(props: Props): React.ReactNode { ); } + +export const SidebarProjectsList = React.memo(SidebarProjectsListComponent); diff --git a/packages/ui/src/components/session/sidebar/authoritativeSessionCleanup.ts b/packages/ui/src/components/session/sidebar/authoritativeSessionCleanup.ts new file mode 100644 index 00000000..a58bb766 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/authoritativeSessionCleanup.ts @@ -0,0 +1,31 @@ +import type { Session } from '@opencode-ai/sdk/v2'; +import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; + +type AuthoritativeSessionIdentity = { + directory: string; + sessionId: string; +}; + +export const buildAuthoritativeSessionIdentityMap = ( + sessions: Session[], +): Map => { + const identities = new Map(); + for (const session of sessions) { + const directory = resolveGlobalSessionDirectory(session); + if (!directory) continue; + identities.set(session.id, { directory, sessionId: session.id }); + } + return identities; +}; + +export const findRemovedAuthoritativeSessions = ( + previous: ReadonlyMap | null, + current: ReadonlyMap, +): AuthoritativeSessionIdentity[] => { + if (!previous) return []; + const removed: AuthoritativeSessionIdentity[] = []; + previous.forEach((identity, key) => { + if (!current.has(key)) removed.push(identity); + }); + return removed; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/pinnedSessionCleanup.ts b/packages/ui/src/components/session/sidebar/hooks/pinnedSessionCleanup.ts deleted file mode 100644 index f2381896..00000000 --- a/packages/ui/src/components/session/sidebar/hooks/pinnedSessionCleanup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Session } from '@opencode-ai/sdk/v2'; - -export const prunePinnedSessionIds = ( - sessions: Array>, - pinnedSessionIds: Set, -): Set => { - const existingSessionIds = new Set(sessions.map((session) => session.id)); - let changed = false; - const next = new Set(); - - pinnedSessionIds.forEach((id) => { - if (existingSessionIds.has(id)) { - next.add(id); - return; - } - changed = true; - }); - - return changed ? next : pinnedSessionIds; -}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts index 5bb33e8d..0031b36d 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts @@ -17,6 +17,7 @@ type FolderEntry = { }; type Args = { + enabled?: boolean; normalizedProjects: ProjectForArchivedFolders[]; ownership: SessionOwnershipIndex; isSessionsLoading: boolean; @@ -26,12 +27,12 @@ type Args = { foldersMap: Record; createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; - cleanupSessions: (scopeKey: string, existingSessionIds: Set) => void; }; export const useArchivedAutoFolders = (args: Args): void => { const { normalizedProjects, + enabled = true, ownership, isSessionsLoading, hasAuthoritativeGlobalSessions, @@ -40,11 +41,10 @@ export const useArchivedAutoFolders = (args: Args): void => { foldersMap, createFolder, addSessionToFolder, - cleanupSessions, } = args; React.useEffect(() => { - if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) { + if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) { return; } @@ -54,8 +54,6 @@ export const useArchivedAutoFolders = (args: Args): void => { } const scopeKey = getArchivedScopeKey(project.normalizedPath); const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? []; - const sessionIds = new Set(projectArchivedSessions.map((session) => session.id)); - const existingFolders = foldersMap[scopeKey] ?? []; const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder])); @@ -72,11 +70,10 @@ export const useArchivedAutoFolders = (args: Args): void => { addSessionToFolder(scopeKey, folder.id, session.id); } }); - - cleanupSessions(scopeKey, sessionIds); }); }, [ normalizedProjects, + enabled, ownership, isSessionsLoading, hasAuthoritativeGlobalSessions, @@ -85,6 +82,5 @@ export const useArchivedAutoFolders = (args: Args): void => { foldersMap, createFolder, addSessionToFolder, - cleanupSessions, ]); }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.test.ts b/packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.test.ts new file mode 100644 index 00000000..d33089e5 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + buildAuthoritativeSessionIdentityMap, + findRemovedAuthoritativeSessions, +} from '../authoritativeSessionCleanup'; + +const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session; + +describe('authoritative session cleanup', () => { + test('does not infer deletion from the first authoritative startup snapshot', () => { + const current = buildAuthoritativeSessionIdentityMap([]); + + expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]); + }); + + test('finds sessions omitted after an established authoritative baseline', () => { + const previous = buildAuthoritativeSessionIdentityMap([ + session('deleted'), + session('retained'), + ]); + const current = buildAuthoritativeSessionIdentityMap([session('retained')]); + + expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([ + { directory: '/repo', sessionId: 'deleted' }, + ]); + }); + + test('treats archive membership as retained authority', () => { + const previous = buildAuthoritativeSessionIdentityMap([session('archived')]); + const current = buildAuthoritativeSessionIdentityMap([ + { ...session('archived'), time: { archived: 10 } } as Session, + ]); + + expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]); + }); + + test('does not treat a directory move as session deletion', () => { + const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]); + const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]); + + expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.ts b/packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.ts new file mode 100644 index 00000000..a86cc08b --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useAuthoritativeSessionCleanup.ts @@ -0,0 +1,35 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { cleanupPersistedSessionState } from '@/sync/session-deletion-cleanup'; +import { + buildAuthoritativeSessionIdentityMap, + findRemovedAuthoritativeSessions, +} from '../authoritativeSessionCleanup'; + +export const useAuthoritativeSessionCleanup = (args: { + enabled?: boolean; + hasAuthoritativeGlobalSessions: boolean; + sessions: Session[]; +}): void => { + const { enabled = true, hasAuthoritativeGlobalSessions, sessions } = args; + const baselineRef = React.useRef<{ + runtimeKey: string; + identities: ReturnType; + } | null>(null); + + React.useEffect(() => { + if (!enabled || !hasAuthoritativeGlobalSessions) return; + + const runtimeKey = getRuntimeKey(); + const current = buildAuthoritativeSessionIdentityMap(sessions); + const previous = baselineRef.current?.runtimeKey === runtimeKey + ? baselineRef.current.identities + : null; + + for (const identity of findRemovedAuthoritativeSessions(previous, current)) { + cleanupPersistedSessionState({ runtimeKey, ...identity }); + } + baselineRef.current = { runtimeKey, identities: current }; + }, [enabled, hasAuthoritativeGlobalSessions, sessions]); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts index 44b72f34..2d3cb253 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts @@ -5,8 +5,10 @@ import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; type Project = { id: string; path: string; normalizedPath: string }; +const ROOT_BRANCH_TTL_MS = 5 * 60_000; type Args = { + enabled?: boolean; normalizedProjects: Project[]; gitRepoStatus: Map; setProjectRepoStatus: React.Dispatch>>; @@ -16,6 +18,7 @@ type Args = { export const useProjectRepoStatus = (args: Args): void => { const { normalizedProjects, + enabled = true, gitRepoStatus, setProjectRepoStatus, setProjectRootBranches, @@ -26,7 +29,7 @@ export const useProjectRepoStatus = (args: Args): void => { // Derive repo status from centralized Git store React.useEffect(() => { - if (!git || normalizedProjects.length === 0) { + if (!enabled || !git || normalizedProjects.length === 0) { setProjectRepoStatus(new Map()); return; } @@ -35,16 +38,17 @@ export const useProjectRepoStatus = (args: Args): void => { normalizedProjects.forEach((project) => { void ensureStatus(project.normalizedPath, git); }); - }, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]); + }, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]); // Read isGitRepo from the store-populated state React.useEffect(() => { + if (!enabled) return; const next = new Map(); normalizedProjects.forEach((project) => { next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null); }); setProjectRepoStatus(next); - }, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]); + }, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]); const projectGitBranchesKey = React.useMemo(() => { return normalizedProjects @@ -69,9 +73,9 @@ export const useProjectRepoStatus = (args: Args): void => { // background updates and only re-resolve on cold start or actual // branch changes (those still invalidate via the input-key check). const rootBranchCacheRef = React.useRef>(new Map()); - const ROOT_BRANCH_TTL_MS = 5 * 60_000; React.useEffect(() => { + if (!enabled) return; let cancelled = false; // Debounce so the initial burst of per-project `ensureStatus` updates @@ -164,9 +168,5 @@ export const useProjectRepoStatus = (args: Args): void => { cancelled = true; clearTimeout(timer); }; - // ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not - // in the deps array since it never changes during the component - // lifetime. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]); + }, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]); }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts index 421dfdff..9d099dc1 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts @@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode } from '../types'; import { normalizePath } from '../utils'; import type { MainTab } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; type ProjectSection = { project: { id: string; normalizedPath: string }; @@ -16,7 +17,7 @@ type Args = { activeSessionByProject: Map; setActiveSessionByProject: React.Dispatch>>; currentSessionId: string | null; - handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void; + handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void; newSessionDraftOpen: boolean; mobileVariant: boolean; openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void; @@ -148,7 +149,7 @@ export const useProjectSessionSelection = (args: Args): void => { return; } const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null; - handleSessionSelect(targetSessionId, targetDirectory, activeProjectId); + handleSessionSelect(targetSessionId, targetDirectory); }, [ activeProjectId, activeSessionByProject, @@ -183,3 +184,34 @@ export const useProjectSessionSelection = (args: Args): void => { }, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]); }; + +type ProjectSessionSelectionEffectProps = Omit< + Args, + 'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' +> & { + initialActiveSessionByProject: Map; + persistActiveSessionByProject: (value: Map) => void; +}; + +export const ProjectSessionSelectionEffect: React.FC = ({ + initialActiveSessionByProject, + persistActiveSessionByProject, + ...args +}) => { + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const [activeSessionByProject, setActiveSessionByProject] = React.useState( + () => new Map(initialActiveSessionByProject), + ); + useProjectSessionSelection({ + ...args, + activeSessionByProject, + setActiveSessionByProject, + currentSessionId, + newSessionDraftOpen, + }); + React.useEffect(() => { + persistActiveSessionByProject(activeSessionByProject); + }, [activeSessionByProject, persistActiveSessionByProject]); + return null; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts index 7338be53..d3ab4123 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -4,6 +4,8 @@ import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; import type { MainTab } from '@/stores/useUIStore'; +import { streamPerfMark } from '@/stores/utils/streamDebug'; +import { useSessionUIStore } from '@/sync/session-ui-store'; type DeleteSessionConfirmSetter = React.Dispatch void; @@ -30,8 +29,6 @@ type Args = { sessionSearchQuery: string; setSessionSearchQuery: (value: string) => void; setIsSessionSearchOpen: (open: boolean) => void; - setActiveProjectIdOnly: (id: string) => void; - setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void; setActiveMainTab: (tab: MainTab) => void; setSessionSwitcherOpen: (open: boolean) => void; setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void; @@ -66,7 +63,8 @@ export const useSessionActions = (args: Args) => { }, []); const handleSessionSelect = React.useCallback( - (sessionId: string, sessionDirectory?: string | null, projectId?: string | null) => { + (sessionId: string, sessionDirectory?: string | null) => { + streamPerfMark('navigation.session_select'); const resetSessionSearch = () => { if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) { return; @@ -75,26 +73,19 @@ export const useSessionActions = (args: Args) => { args.setIsSessionSearchOpen(false); }; - if (projectId && projectId !== args.activeProjectId) { - args.setActiveProjectIdOnly(projectId); - } - - if (sessionDirectory && sessionDirectory !== args.currentDirectory) { - args.setDirectory(sessionDirectory, { showOverlay: false }); - } - if (args.mobileVariant) { args.setActiveMainTab('chat'); args.setSessionSwitcherOpen(false); } - if (sessionId === args.currentSessionId) { + if (sessionId === useSessionUIStore.getState().currentSessionId) { if (args.allowReselect) { args.onSessionSelected?.(sessionId); } resetSessionSearch(); return; } + streamPerfMark('navigation.session_state_set'); args.setCurrentSession(sessionId, sessionDirectory ?? null); args.onSessionSelected?.(sessionId); resetSessionSearch(); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts deleted file mode 100644 index 103c0698..00000000 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { getArchivedScopeKey, normalizePath } from '../utils'; -import type { SessionOwnershipIndex } from '../sessionOwnership'; - -type WorktreeMeta = { path: string }; - -type NormalizedProject = { - id: string; - normalizedPath: string; -}; - -type Args = { - isSessionsLoading: boolean; - hasAuthoritativeGlobalSessions: boolean; - isWorktreeTopologyLoading: boolean; - normalizedProjects: NormalizedProject[]; - ownership: SessionOwnershipIndex; - availableWorktreesByProject: Map; - unresolvedWorktreeProjectPaths: ReadonlySet; - cleanupSessions: (scopeKey: string, validSessionIds: Set) => void; -}; - -export const useSessionFolderCleanup = (args: Args): void => { - const { - isSessionsLoading, - hasAuthoritativeGlobalSessions, - isWorktreeTopologyLoading, - normalizedProjects, - ownership, - availableWorktreesByProject, - unresolvedWorktreeProjectPaths, - cleanupSessions, - } = args; - - React.useEffect(() => { - if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) { - return; - } - - if (ownership.bySessionId.size === 0) { - return; - } - - const idsByScope = new Map>(); - ownership.sessionsByScope.forEach((sessionIds, scopeDirectory) => { - idsByScope.set(scopeDirectory, new Set(sessionIds)); - }); - - normalizedProjects.forEach((project) => { - if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) { - return; - } - const scopeKey = getArchivedScopeKey(project.normalizedPath); - const archivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? []; - idsByScope.set(scopeKey, new Set(archivedSessions.map((session) => session.id))); - if (!idsByScope.has(project.normalizedPath)) { - idsByScope.set(project.normalizedPath, new Set()); - } - for (const worktree of availableWorktreesByProject.get(project.normalizedPath) ?? []) { - const worktreePath = normalizePath(worktree.path); - if (worktreePath && !idsByScope.has(worktreePath)) { - idsByScope.set(worktreePath, new Set()); - } - } - }); - - idsByScope.forEach((sessionIds, scopeKey) => { - cleanupSessions(scopeKey, sessionIds); - }); - }, [ - availableWorktreesByProject, - cleanupSessions, - hasAuthoritativeGlobalSessions, - isWorktreeTopologyLoading, - isSessionsLoading, - normalizedProjects, - ownership, - unresolvedWorktreeProjectPaths, - ]); -}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts index 8d00bc84..66bdda18 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts @@ -10,120 +10,152 @@ const SESSION_PREFETCH_CONCURRENCY = 1; const SESSION_PREFETCH_PENDING_LIMIT = 6; type Args = { + enabled?: boolean; currentSessionId: string | null; sortedSessions: Session[]; - recentSessionIds?: string[]; - ensureSessionRenderable: (sessionId: string) => Promise; + recentSessions?: Session[]; + prefetchSession: (sessionId: string, directory: string) => Promise; }; -export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], ensureSessionRenderable }: Args): void => { +type PrefetchRequest = { + sessionId: string; + directory: string; + generation: number; +}; + +const sessionDirectory = (session: Session | null | undefined): string | null => { + const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory; + return typeof directory === 'string' && directory.trim() ? directory : null; +}; + +export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => { const sessionPrefetchTimersRef = React.useRef>(new Map()); - const sessionPrefetchQueueRef = React.useRef([]); + const sessionPrefetchQueueRef = React.useRef([]); const sessionPrefetchInFlightRef = React.useRef>(new Set()); + const generationRef = React.useRef(0); const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []); + const requestKey = React.useCallback((request: Pick) => ( + `${request.directory}\n${request.sessionId}` + ), []); + + const clearPendingPrefetches = React.useCallback(() => { + generationRef.current += 1; + sessionPrefetchQueueRef.current = []; + sessionPrefetchTimersRef.current.forEach((timer) => window.clearTimeout(timer)); + sessionPrefetchTimersRef.current.clear(); + }, []); + const pumpSessionPrefetchQueue = React.useCallback(() => { - if (prefetchDisabled || typeof window === 'undefined') { + if (!enabled || prefetchDisabled || typeof window === 'undefined') { return; } while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) { - const nextSessionId = sessionPrefetchQueueRef.current.shift(); - if (!nextSessionId) { + const request = sessionPrefetchQueueRef.current.shift(); + if (!request) { break; } + if (request.generation !== generationRef.current) continue; const state = useSessionUIStore.getState(); - if (state.currentSessionId === nextSessionId) { + if (state.currentSessionId === request.sessionId) { continue; } // Check if the session is already renderable in the sync child store. - if (getSyncSessionMaterializationStatus(nextSessionId).renderable) { + if (getSyncSessionMaterializationStatus(request.sessionId, request.directory).renderable) { continue; } - sessionPrefetchInFlightRef.current.add(nextSessionId); - void ensureSessionRenderable(nextSessionId) + const key = requestKey(request); + sessionPrefetchInFlightRef.current.add(key); + void prefetchSession(request.sessionId, request.directory) .catch(() => undefined) .finally(() => { - sessionPrefetchInFlightRef.current.delete(nextSessionId); + sessionPrefetchInFlightRef.current.delete(key); pumpSessionPrefetchQueue(); }); } - }, [ensureSessionRenderable, prefetchDisabled]); + }, [enabled, prefetchDisabled, prefetchSession, requestKey]); - const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => { - if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') { + const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => { + const sessionId = session?.id; + const directory = sessionDirectory(session); + if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') { return; } + const request = { sessionId, directory, generation: generationRef.current }; + const key = requestKey(request); // Already renderable in sync - if (getSyncSessionMaterializationStatus(sessionId).renderable) { + if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) { return; } - if (sessionPrefetchInFlightRef.current.has(sessionId)) { + if (sessionPrefetchInFlightRef.current.has(key)) { return; } - if (sessionPrefetchQueueRef.current.includes(sessionId)) { + if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) { return; } - if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) { - sessionPrefetchQueueRef.current.shift(); - } - - const existingTimer = sessionPrefetchTimersRef.current.get(sessionId); + const existingTimer = sessionPrefetchTimersRef.current.get(key); if (existingTimer !== undefined) { window.clearTimeout(existingTimer); } const timer = window.setTimeout(() => { - sessionPrefetchTimersRef.current.delete(sessionId); - sessionPrefetchQueueRef.current.push(sessionId); + sessionPrefetchTimersRef.current.delete(key); + if (request.generation !== generationRef.current) return; + const queue = sessionPrefetchQueueRef.current; + if (queue.length >= SESSION_PREFETCH_PENDING_LIMIT) { + queue.shift(); + } + queue.push(request); pumpSessionPrefetchQueue(); }, SESSION_PREFETCH_HOVER_DELAY_MS); - sessionPrefetchTimersRef.current.set(sessionId, timer); - }, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]); + sessionPrefetchTimersRef.current.set(key, timer); + }, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]); + + React.useEffect(() => { + clearPendingPrefetches(); + }, [clearPendingPrefetches, currentSessionId, enabled, prefetchDisabled]); // Wait for the active session to finish loading before prefetching neighbors. // On rapid session switches the timer resets, so only the final session triggers prefetch. React.useEffect(() => { - if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) { + if (!enabled || prefetchDisabled || !currentSessionId || sortedSessions.length === 0) { return; } const timer = window.setTimeout(() => { const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); if (currentIndex < 0) return; - scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); - scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); + scheduleSessionPrefetch(sortedSessions[currentIndex - 1]); + scheduleSessionPrefetch(sortedSessions[currentIndex + 1]); }, SESSION_PREFETCH_SETTLE_MS); return () => window.clearTimeout(timer); - }, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]); + }, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]); React.useEffect(() => { - if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) { + if (!enabled || prefetchDisabled || !currentSessionId || recentSessions.length === 0) { return; } const timer = window.setTimeout(() => { - const currentIndex = recentSessionIds.indexOf(currentSessionId); + const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId); if (currentIndex < 0) return; - scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]); - scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); + scheduleSessionPrefetch(recentSessions[currentIndex - 1]); + scheduleSessionPrefetch(recentSessions[currentIndex + 1]); }, SESSION_PREFETCH_SETTLE_MS); return () => window.clearTimeout(timer); - }, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]); + }, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]); - React.useEffect(() => { - const prefetchTimers = sessionPrefetchTimersRef.current; - return () => { - prefetchTimers.forEach((timer) => { - clearTimeout(timer); - }); - prefetchTimers.clear(); - sessionPrefetchQueueRef.current = []; - }; - }, []); + React.useEffect(() => clearPendingPrefetches, [clearPendingPrefetches]); +}; + +export const SessionPrefetchEffect: React.FC> = (args) => { + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + useSessionPrefetch({ ...args, currentSessionId }); + return null; }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts index bb0a760c..a4543458 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts @@ -1,6 +1,7 @@ import React from 'react'; type Args = { + enabled?: boolean; isSessionSearchOpen: boolean; setIsSessionSearchOpen: (open: boolean) => void; sessionSearchInputRef: React.RefObject; @@ -8,13 +9,14 @@ type Args = { }; export const useSessionSearchEffects = ({ + enabled = true, isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchInputRef, sessionSearchContainerRef, }: Args): void => { React.useEffect(() => { - if (!isSessionSearchOpen || typeof window === 'undefined') { + if (!enabled || !isSessionSearchOpen || typeof window === 'undefined') { return; } const raf = window.requestAnimationFrame(() => { @@ -22,10 +24,10 @@ export const useSessionSearchEffects = ({ sessionSearchInputRef.current?.select(); }); return () => window.cancelAnimationFrame(raf); - }, [isSessionSearchOpen, sessionSearchInputRef]); + }, [enabled, isSessionSearchOpen, sessionSearchInputRef]); React.useEffect(() => { - if (!isSessionSearchOpen || typeof document === 'undefined') { + if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') { return; } const handlePointerDown = (event: MouseEvent) => { @@ -38,5 +40,5 @@ export const useSessionSearchEffects = ({ }; document.addEventListener('mousedown', handlePointerDown); return () => document.removeEventListener('mousedown', handlePointerDown); - }, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]); + }, [enabled, isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]); }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts index e0567af4..c1a02d58 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts @@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode, GroupSearchData } from '../types'; import { dedupeSessionsById, normalizePath } from '../utils'; import type { WorktreeMetadata } from '@/types/worktree'; import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore'; +import { streamPerfCount } from '@/stores/utils/streamDebug'; type ProjectItem = { id: string; @@ -21,6 +22,19 @@ type ProjectSection = { groups: SessionGroup[]; }; +type ProjectSectionCacheEntry = { + project: ProjectItem; + activeSessions: Session[]; + archivedSessions: Session[]; + availableWorktrees: WorktreeMetadata[]; + rootBranch: string | null; + isRepo: boolean; + buildGroupedSessions: Args['buildGroupedSessions']; + section: ProjectSection; +}; + +const EMPTY_WORKTREES: WorktreeMetadata[] = []; + type Args = { normalizedProjects: ProjectItem[]; getSessionsForProject: (projectId: string) => Session[]; @@ -59,26 +73,67 @@ export const useSessionSidebarSections = (args: Args) => { buildGroupSearchText, foldersMap, } = args; + const projectSectionCacheRef = React.useRef>(new Map()); const projectSections = React.useMemo(() => { - return normalizedProjects.map((project) => { - const projectSessions = dedupeSessionsById([ - ...getSessionsForProject(project.id), - ...getArchivedSessionsForProject(project.id), - ]); - const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; + const previousCache = projectSectionCacheRef.current; + const nextCache = new Map(); + let reusedSections = 0; + let rebuiltSections = 0; + const sameSessions = (left: Session[], right: Session[]): boolean => ( + left.length === right.length && left.every((session, index) => session === right[index]) + ); + + const sections = normalizedProjects.map((project) => { + const activeSessions = getSessionsForProject(project.id); + const archivedSessions = getArchivedSessionsForProject(project.id); + const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? EMPTY_WORKTREES; const isRepo = projectRepoStatus.has(project.id) ? Boolean(projectRepoStatus.get(project.id)) : lastRepoStatus; + const rootBranch = projectRootBranches.get(project.id) ?? null; + const cached = previousCache.get(project.id); + if ( + cached + && cached.project === project + && sameSessions(cached.activeSessions, activeSessions) + && sameSessions(cached.archivedSessions, archivedSessions) + && cached.availableWorktrees === worktreesForProject + && cached.rootBranch === rootBranch + && cached.isRepo === isRepo + && cached.buildGroupedSessions === buildGroupedSessions + ) { + reusedSections += 1; + nextCache.set(project.id, cached); + return cached.section; + } + + rebuiltSections += 1; + const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]); const groups = buildGroupedSessions( projectSessions, project.normalizedPath, worktreesForProject, - projectRootBranches.get(project.id) ?? null, + rootBranch, isRepo, ); - return { project, groups }; + const section = { project, groups }; + nextCache.set(project.id, { + project, + activeSessions, + archivedSessions, + availableWorktrees: worktreesForProject, + rootBranch, + isRepo, + buildGroupedSessions, + section, + }); + return section; }); + projectSectionCacheRef.current = nextCache; + if (reusedSections > 0) streamPerfCount('ui.sidebar.project_section.reused', reusedSections); + if (rebuiltSections > 0) streamPerfCount('ui.sidebar.project_section.rebuilt', rebuiltSections); + return sections; }, [ normalizedProjects, getSessionsForProject, diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.test.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.test.ts deleted file mode 100644 index 2d0b8a29..00000000 --- a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import type { Session } from '@opencode-ai/sdk/v2'; -import { prunePinnedSessionIds } from './pinnedSessionCleanup'; - -const makeSession = (id: string): Pick => ({ id }); - -describe('prunePinnedSessionIds', () => { - test('keeps pinned ids that still exist in the authoritative session list', () => { - const sessions = [makeSession('visible-session'), makeSession('hidden-session')]; - const pinnedSessionIds = new Set(['hidden-session', 'missing-session']); - - const next = prunePinnedSessionIds(sessions, pinnedSessionIds); - - expect([...next]).toEqual(['hidden-session']); - expect(next).not.toBe(pinnedSessionIds); - }); - - test('returns the original set when nothing needs pruning', () => { - const sessions = [makeSession('visible-session'), makeSession('hidden-session')]; - const pinnedSessionIds = new Set(['visible-session', 'hidden-session']); - - const next = prunePinnedSessionIds(sessions, pinnedSessionIds); - - expect(next).toBe(pinnedSessionIds); - }); -}); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts index 3a4a2146..d2e8604e 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts @@ -1,46 +1,24 @@ import React from 'react'; -import type { Session } from '@opencode-ai/sdk/v2'; import { updateDesktopSettings } from '@/lib/persistence'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { prunePinnedSessionIds } from './pinnedSessionCleanup'; type SafeStorageLike = { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void; - removeItem?: (key: string) => void; }; type Keys = { sessionExpanded: string; - // v1 key, still on disk for users upgrading from pre-per-context expansion. - // When present, its bare-session-id entries are fanned out to all four - // (project|recent) × (active|archived) context combinations and rewritten - // under `sessionExpanded`. After migration the v1 key is removed. - sessionExpandedLegacy: string; projectCollapse: string; - sessionPinned: string; groupOrder: string; - projectActiveSession: string; groupCollapse: string; }; -const LEGACY_EXPANSION_CONTEXT_PREFIXES = [ - 'project:active:', - 'project:archived:', - 'recent:active:', - 'recent:archived:', -]; - type Args = { isVSCode: boolean; - hasAuthoritativeGlobalSessions: boolean; safeStorage: SafeStorageLike; keys: Keys; - sessions: Session[]; - pinnedSessionIds: Set; - setPinnedSessionIds: React.Dispatch>>; groupOrderByProject: Map; - activeSessionByProject: Map; collapsedGroups: Set; setExpandedParents: React.Dispatch>>; setCollapsedProjects: React.Dispatch>>; @@ -49,13 +27,9 @@ type Args = { export const useSidebarPersistence = (args: Args) => { const { isVSCode, - hasAuthoritativeGlobalSessions, safeStorage, keys, - sessions, - setPinnedSessionIds, groupOrderByProject, - activeSessionByProject, collapsedGroups, setExpandedParents, setCollapsedProjects, @@ -115,28 +89,6 @@ export const useSidebarPersistence = (args: Args) => { if (Array.isArray(parsed)) { setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string'))); } - } else { - // No v2 data — migrate from v1 (bare session ids) if present. - const legacyRaw = safeStorage.getItem(keys.sessionExpandedLegacy); - if (legacyRaw) { - try { - const parsedLegacy = JSON.parse(legacyRaw); - if (Array.isArray(parsedLegacy)) { - const migrated = new Set(); - parsedLegacy.forEach((item) => { - if (typeof item !== 'string' || item.length === 0) return; - LEGACY_EXPANSION_CONTEXT_PREFIXES.forEach((prefix) => migrated.add(`${prefix}${item}`)); - }); - if (migrated.size > 0) { - setExpandedParents(migrated); - try { safeStorage.setItem(keys.sessionExpanded, JSON.stringify(Array.from(migrated))); } catch { /* ignored */ } - } - } - } catch { - // legacy data was malformed; ignore and let it expire - } - try { safeStorage.removeItem?.(keys.sessionExpandedLegacy); } catch { /* ignored */ } - } } const storedProjects = safeStorage.getItem(keys.projectCollapse); if (storedProjects) { @@ -148,17 +100,7 @@ export const useSidebarPersistence = (args: Args) => { } catch { // ignored } - }, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]); - - React.useEffect(() => { - if (!hasAuthoritativeGlobalSessions) { - return; - } - - setPinnedSessionIds((prev) => { - return prunePinnedSessionIds(sessions, prev); - }); - }, [hasAuthoritativeGlobalSessions, sessions, setPinnedSessionIds]); + }, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]); React.useEffect(() => { try { @@ -169,15 +111,6 @@ export const useSidebarPersistence = (args: Args) => { } }, [groupOrderByProject, keys.groupOrder, safeStorage]); - React.useEffect(() => { - try { - const serialized = Object.fromEntries(activeSessionByProject.entries()); - safeStorage.setItem(keys.projectActiveSession, JSON.stringify(serialized)); - } catch { - // ignored - } - }, [activeSessionByProject, keys.projectActiveSession, safeStorage]); - React.useEffect(() => { try { safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups))); diff --git a/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts b/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts index 9a48e4bf..e3bfe0e7 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts @@ -1,17 +1,18 @@ import React from 'react'; type Args = { + enabled?: boolean; isDesktopShellRuntime: boolean; projectSections: unknown[]; projectHeaderSentinelRefs: React.MutableRefObject>; }; export const useStickyProjectHeaders = (args: Args): Set => { - const { isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args; + const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args; const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); React.useEffect(() => { - if (!isDesktopShellRuntime) { + if (!enabled || !isDesktopShellRuntime) { return; } @@ -24,12 +25,16 @@ export const useStickyProjectHeaders = (args: Args): Set => { } setStuckProjectHeaders((prev) => { - const next = new Set(prev); if (!entry.isIntersecting) { + if (prev.has(projectId)) return prev; + const next = new Set(prev); next.add(projectId); - } else { - next.delete(projectId); + return next; } + + if (!prev.has(projectId)) return prev; + const next = new Set(prev); + next.delete(projectId); return next; }); }); @@ -44,7 +49,7 @@ export const useStickyProjectHeaders = (args: Args): Set => { }); return () => observer.disconnect(); - }, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]); + }, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]); return stuckProjectHeaders; }; diff --git a/packages/ui/src/components/session/sidebar/sessionBootstrapDemands.test.ts b/packages/ui/src/components/session/sidebar/sessionBootstrapDemands.test.ts new file mode 100644 index 00000000..302b46cd --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionBootstrapDemands.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { buildSessionBootstrapDemands } from "./sessionBootstrapDemands" + +const sections = [{ + project: { id: "project-a", normalizedPath: "/repo" }, + groups: [ + { id: "root", directory: "/repo", isMain: true }, + { id: "worktree:/repo/wt-a", directory: "/repo/wt-a", isMain: false }, + { id: "worktree:/repo/wt-b", directory: "/repo/wt-b", isMain: false }, + ], +}] + +describe("buildSessionBootstrapDemands", () => { + test("keeps collapsed worktrees eligible at background priority", () => { + const demands = buildSessionBootstrapDemands({ + projectSections: sections, + activeProjectId: null, + collapsedProjects: new Set(["project-a"]), + collapsedGroups: new Set(), + currentDirectory: null, + currentSessionDirectory: null, + }) + + expect(demands.map(({ directory, priority }) => [directory, priority])).toEqual([ + ["/repo", "background"], + ["/repo/wt-a", "background"], + ["/repo/wt-b", "background"], + ]) + }) + + test("promotes expansion and selected session without duplicate directories", () => { + const demands = buildSessionBootstrapDemands({ + projectSections: sections, + activeProjectId: "project-a", + collapsedProjects: new Set(), + collapsedGroups: new Set(["project-a:worktree:/repo/wt-b"]), + currentDirectory: "/repo", + currentSessionDirectory: "/repo/wt-b", + }) + const byDirectory = new Map(demands.map((demand) => [demand.directory, demand])) + + expect(demands.length).toBe(3) + expect(byDirectory.get("/repo")?.priority).toBe("selected") + expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded") + expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected") + }) +}) diff --git a/packages/ui/src/components/session/sidebar/sessionBootstrapDemands.ts b/packages/ui/src/components/session/sidebar/sessionBootstrapDemands.ts new file mode 100644 index 00000000..a8ff2357 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionBootstrapDemands.ts @@ -0,0 +1,77 @@ +import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store" +import { normalizePath } from "./utils" + +type BootstrapProjectSection = { + project: { id: string; normalizedPath: string } + groups: Array<{ + id: string + directory: string | null + isArchivedBucket?: boolean + isMain: boolean + }> +} + +const PRIORITY_RANK: Record = { + selected: 0, + "active-project": 1, + expanded: 2, + visible: 3, + background: 4, +} + +export function buildSessionBootstrapDemands(input: { + projectSections: BootstrapProjectSection[] + activeProjectId: string | null + collapsedProjects: ReadonlySet + collapsedGroups: ReadonlySet + currentDirectory: string | null + currentSessionDirectory: string | null +}): DirectoryBootstrapDemand[] { + const byDirectory = new Map() + const add = ( + directory: string | null | undefined, + priority: DirectoryBootstrapPriority, + reason: DirectoryBootstrapDemand["reason"], + ) => { + const normalizedDirectory = normalizePath(directory ?? null) + if (!normalizedDirectory) return + const existing = byDirectory.get(normalizedDirectory) + if (existing && PRIORITY_RANK[existing.priority] <= PRIORITY_RANK[priority]) return + byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason }) + } + + for (const section of input.projectSections) { + const projectExpanded = !input.collapsedProjects.has(section.project.id) + let projectPriority: DirectoryBootstrapPriority = "background" + if (section.project.id === input.activeProjectId) { + projectPriority = "active-project" + } else if (projectExpanded) { + projectPriority = "expanded" + } + add( + section.project.normalizedPath, + projectPriority, + projectExpanded ? "project-expanded" : "known-project", + ) + + for (const group of section.groups) { + if (!group.directory || group.isArchivedBucket || group.isMain) continue + const groupExpanded = projectExpanded && !input.collapsedGroups.has(`${section.project.id}:${group.id}`) + let groupPriority: DirectoryBootstrapPriority = "background" + if (groupExpanded) { + groupPriority = "expanded" + } else if (projectExpanded) { + groupPriority = "visible" + } + add( + group.directory, + groupPriority, + groupExpanded ? "worktree-expanded" : "known-worktree", + ) + } + } + + add(input.currentDirectory, "selected", "current-directory") + add(input.currentSessionDirectory, "selected", "selected-session") + return [...byDirectory.values()] +} diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts new file mode 100644 index 00000000..873796bb --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; +import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils'; +import type { SessionNode } from './types'; + +const session = (id: string, title: string): Session => ({ + id, + title, + time: { created: 1, updated: 1 }, +} as Session); + +const rootWithChild = (childSession: Session): SessionNode => ({ + session: session('root', 'Root'), + children: [{ session: childSession, children: [], worktree: null }], + worktree: null, +}); + +describe('computeNodeStructureKey', () => { + test('stays stable across grouping rebuilds that reuse session objects', () => { + const child = session('child', 'Child'); + + expect(computeNodeStructureKey(rootWithChild(child))).toBe(computeNodeStructureKey(rootWithChild(child))); + }); + + test('changes when a descendant session object changes', () => { + const previous = session('child', 'Before'); + const next = { ...previous, title: 'After' }; + + expect(computeNodeStructureKey(rootWithChild(previous))).not.toBe(computeNodeStructureKey(rootWithChild(next))); + }); +}); + +describe('nodeHasPinnedMembershipChange', () => { + test('detects composite pin changes using the group directory fallback', () => { + const node: SessionNode = { + session: session('root', 'Root'), + children: [], + worktree: null, + }; + const pinnedKey = getPinnedSessionKey(getRuntimeKey(), '/repo', 'root'); + + expect(pinnedKey).not.toBeNull(); + expect(nodeHasPinnedMembershipChange( + node, + node, + new Set(), + new Set([pinnedKey!]), + '/repo', + '/repo', + )).toBe(true); + }); + + test('ignores pin changes for the same session id in another directory', () => { + const node: SessionNode = { + session: session('root', 'Root'), + children: [], + worktree: null, + }; + const pinnedKey = getPinnedSessionKey(getRuntimeKey(), '/other-repo', 'root'); + + expect(pinnedKey).not.toBeNull(); + expect(nodeHasPinnedMembershipChange( + node, + node, + new Set(), + new Set([pinnedKey!]), + '/repo', + '/repo', + )).toBe(false); + }); +}); + +describe('selectFolderRootNodes', () => { + test('does not render assigned descendants again beside their assigned parent tree', () => { + const grandchild: SessionNode = { + session: { ...session('grandchild', 'Grandchild'), parentID: 'child' } as Session, + children: [], + worktree: null, + }; + const child: SessionNode = { + session: { ...session('child', 'Child'), parentID: 'root' } as Session, + children: [grandchild], + worktree: null, + }; + const root: SessionNode = { + session: session('root', 'Root'), + children: [child], + worktree: null, + }; + const nodes = new Map([ + ['root', root], + ['child', child], + ['grandchild', grandchild], + ]); + + expect(selectFolderRootNodes(['root', 'child', 'grandchild'], nodes)).toEqual([root]); + }); + + test('keeps a child as a folder root when none of its ancestors are assigned', () => { + const child: SessionNode = { + session: { ...session('child', 'Child'), parentID: 'root' } as Session, + children: [], + worktree: null, + }; + const root: SessionNode = { + session: session('root', 'Root'), + children: [child], + worktree: null, + }; + + expect(selectFolderRootNodes(['child'], new Map([['root', root], ['child', child]]))).toEqual([child]); + }); + + test('keeps a child when an assigned ancestor is not available in the group', () => { + const child: SessionNode = { + session: { ...session('child', 'Child'), parentID: 'missing-root' } as Session, + children: [], + worktree: null, + }; + + expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts index ba8b4b53..8bb67ce2 100644 --- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts @@ -1,3 +1,5 @@ +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; import type { SessionNode } from './types'; /** @@ -10,7 +12,6 @@ import type { SessionNode } from './types'; * each child's extras object. */ export type SessionNodeChildRenderExtras = { - subtreeContainsActive: Set; subtreeContainsEditing: Set; menuOpenSessionId: string | null; nodeStructureKey: string; @@ -24,7 +25,7 @@ export type SessionNodeRenderExtras = SessionNodeChildRende * Walk `nodes` and add `node.session.id` to `result` for every node * whose subtree contains `targetId`. This is used to precompute, once * per SessionGroupSection render, which rows need to update when - * `currentSessionId` or `editingId` changes. With M visible rows, this + * `editingId` changes. With M visible rows, this * turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual` * into a single O(M) `Set.has` per row. */ @@ -69,12 +70,45 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul return false; }; +export const selectFolderRootNodes = ( + sessionIds: string[], + nodeBySessionId: ReadonlyMap, +): SessionNode[] => { + const assignedSessionIds = new Set(sessionIds); + + return sessionIds + .map((sessionId) => nodeBySessionId.get(sessionId)) + .filter((node): node is SessionNode => { + if (!node) return false; + + const visited = new Set(); + let parentID = (node.session as SessionNode['session'] & { parentID?: string | null }).parentID ?? null; + while (parentID && !visited.has(parentID)) { + if (assignedSessionIds.has(parentID) && nodeBySessionId.has(parentID)) return false; + visited.add(parentID); + const parentNode = nodeBySessionId.get(parentID); + parentID = (parentNode?.session as (SessionNode['session'] & { parentID?: string | null }) | undefined)?.parentID ?? null; + } + return true; + }); +}; + +const sessionObjectVersions = new WeakMap(); +let nextSessionObjectVersion = 1; + +const getSessionObjectVersion = (session: object): number => { + const existing = sessionObjectVersions.get(session); + if (existing !== undefined) return existing; + const version = nextSessionObjectVersion; + nextSessionObjectVersion += 1; + sessionObjectVersions.set(session, version); + return version; +}; + /** - * Build a structural key for `node` that encodes the IDs of all - * descendants. Used by `SessionNodeItem.areEqual` so a reference-only - * rebuild of the tree (which happens on every `buildGroupedSessions` - * pass) can be detected with a single string compare instead of a - * recursive walk per row. + * Build a key encoding descendant IDs and session object versions. This lets + * row memoization detect one changed descendant without recursively comparing + * every subtree after a reference-only grouping rebuild. */ export const computeNodeStructureKey = (node: SessionNode): string => { if (node.children.length === 0) { @@ -82,15 +116,49 @@ export const computeNodeStructureKey = (node: SessionNode): string => { } const childKeys = node.children.map((child) => { + const childVersion = getSessionObjectVersion(child.session); if (child.children.length === 0) { - return child.session.id; + return `${child.session.id}@${childVersion}`; } - return `${child.session.id}:${computeNodeStructureKey(child)}`; + return `${child.session.id}@${childVersion}:${computeNodeStructureKey(child)}`; }); return childKeys.join('|'); }; +export const nodeHasPinnedMembershipChange = ( + prevNode: SessionNode, + nextNode: SessionNode, + prevPinnedSessionIds: Set, + nextPinnedSessionIds: Set, + prevGroupDirectory?: string | null, + nextGroupDirectory?: string | null, +): boolean => { + const runtimeKey = getRuntimeKey(); + const visit = (previous: SessionNode, current: SessionNode): boolean => { + if (previous.session.id !== current.session.id || previous.children.length !== current.children.length) { + return true; + } + + const prevDirectory = (previous.session as SessionNode['session'] & { directory?: string | null }).directory + ?? prevGroupDirectory; + const nextDirectory = (current.session as SessionNode['session'] & { directory?: string | null }).directory + ?? nextGroupDirectory; + const prevKey = getPinnedSessionKey(runtimeKey, prevDirectory ?? '', previous.session.id); + const nextKey = getPinnedSessionKey(runtimeKey, nextDirectory ?? '', current.session.id); + if ( + (prevKey ? prevPinnedSessionIds.has(prevKey) : false) + !== (nextKey ? nextPinnedSessionIds.has(nextKey) : false) + ) { + return true; + } + + return previous.children.some((child, index) => visit(child, current.children[index])); + }; + + return visit(prevNode, nextNode); +}; + /** * Resolve the session id whose sidebar menu is open, or null if no * menu is open. Only one row can have its menu open at a time. diff --git a/packages/ui/src/components/session/sidebar/utils.test.ts b/packages/ui/src/components/session/sidebar/utils.test.ts index d9135d05..fc164281 100644 --- a/packages/ui/src/components/session/sidebar/utils.test.ts +++ b/packages/ui/src/components/session/sidebar/utils.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { isPathWithinProject } from './utils'; +import { + isPathWithinProject, + selectExpandedParentKeysForContext, + toggleExpandedParentKey, +} from './utils'; describe('isPathWithinProject', () => { test('matches child directories for root projects', () => { @@ -26,3 +30,48 @@ describe('isPathWithinProject', () => { expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true); }); }); + +describe('selectExpandedParentKeysForContext', () => { + test('keeps project and recent expansion state isolated', () => { + const expanded = new Set([ + 'project:active:parent-a', + 'project:archived:parent-b', + 'recent:active:parent-a', + ]); + + expect(selectExpandedParentKeysForContext(new Set(), expanded, 'project')).toEqual(new Set([ + 'project:active:parent-a', + 'project:archived:parent-b', + ])); + expect(selectExpandedParentKeysForContext(new Set(), expanded, 'recent')).toEqual(new Set([ + 'recent:active:parent-a', + ])); + }); + + test('preserves a context projection when only another context changes', () => { + const recent = new Set(['recent:active:parent-a']); + const expanded = new Set(['recent:active:parent-a', 'project:active:parent-a']); + + expect(selectExpandedParentKeysForContext(recent, expanded, 'recent')).toBe(recent); + }); +}); + +describe('parent expansion state', () => { + const recentKey = 'recent:active:parent-a'; + const projectKey = 'project:active:parent-a'; + + test('manually expands and collapses a parent', () => { + const expanded = toggleExpandedParentKey(new Set(), recentKey); + expect(expanded).toEqual(new Set([recentKey])); + expect(toggleExpandedParentKey(expanded, recentKey)).toEqual(new Set()); + }); + + test('does not change the other render context', () => { + const recentExpanded = new Set([recentKey]); + const bothExpanded = toggleExpandedParentKey(recentExpanded, projectKey); + const projectCollapsed = toggleExpandedParentKey(bothExpanded, projectKey); + + expect(selectExpandedParentKeysForContext(new Set(), bothExpanded, 'recent')).toEqual(new Set([recentKey])); + expect(selectExpandedParentKeysForContext(new Set(), projectCollapsed, 'recent')).toEqual(new Set([recentKey])); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index c24ef19e..2af0e05e 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -1,11 +1,36 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; +import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { isSessionPinned } from '@/stores/useSessionPinnedStore'; import { getCurrentIntlLocale } from '@/lib/i18n'; import { formatMessage, useI18nStore } from '@/lib/i18n/store'; import { normalizePath } from '@/lib/pathNormalization'; export { normalizePath }; +export const selectExpandedParentKeysForContext = ( + previous: Set, + expanded: ReadonlySet, + context: 'project' | 'recent', +): Set => { + const prefix = `${context}:`; + const next = new Set([...expanded].filter((key) => key.startsWith(prefix))); + if (previous.size === next.size && [...next].every((key) => previous.has(key))) { + return previous; + } + return next; +}; + +export const toggleExpandedParentKey = ( + expanded: Set, + key: string, +): Set => { + const next = new Set(expanded); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; +}; + const t = (key: Parameters[1], params?: Parameters[2]) => formatMessage(useI18nStore.getState().dictionary, key, params); @@ -132,8 +157,8 @@ export const compareSessionsByPinnedAndTime = ( b: Session, pinnedSessionIds: Set, ): number => { - const aPinned = pinnedSessionIds.has(a.id); - const bPinned = pinnedSessionIds.has(b.id); + const aPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(a), a.id); + const bPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(b), b.id); if (aPinned !== bPinned) { return aPinned ? -1 : 1; } diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index e7e8fc75..77bccafd 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -53,6 +53,7 @@ type CommandEntry = { }; type FileHit = { path: string; name: string; relativePath: string }; +const EMPTY_SESSIONS: Session[] = []; const normalizePath = (value: string): string => { if (!value) return ''; @@ -85,7 +86,10 @@ export const CommandPalette: React.FC = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); - const activeSessions = useGlobalSessionsStore((s) => s.activeSessions); + const activeSessions = useGlobalSessionsStore(React.useCallback( + (state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS, + [isCommandPaletteOpen], + )); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const activeProject = useProjectsStore((s) => s.getActiveProject()); const projects = useProjectsStore((s) => s.projects); diff --git a/packages/ui/src/components/views/ChatView.tsx b/packages/ui/src/components/views/ChatView.tsx index f1e5b21b..152998f4 100644 --- a/packages/ui/src/components/views/ChatView.tsx +++ b/packages/ui/src/components/views/ChatView.tsx @@ -4,15 +4,16 @@ import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; import { useSessionUIStore } from '@/sync/session-ui-store'; type ChatViewProps = { + active?: boolean; readOnly?: boolean; }; -export const ChatView: React.FC = ({ readOnly = false }) => { +export const ChatView: React.FC = ({ active = true, readOnly = false }) => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return ( - + ); }; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 473711d4..95d83840 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; +import { getRuntimeKey } from '@/lib/runtime-switch'; import { cn } from '@/lib/utils'; import type { GitStatus } from '@/lib/api/types'; import { @@ -667,6 +668,7 @@ const MultiFileDiffEntry = React.memo(({ setIsLoading(true); let cancelled = false; + const runtimeKey = getRuntimeKey(); const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES; const fetchPromise = isImageFile(file.path) ? git.getGitFileDiff(directory, { path: file.path, staged }) @@ -697,7 +699,7 @@ const MultiFileDiffEntry = React.memo(({ if (staged) { setStagedDiffData(nextDiff); } else { - setDiff(directory, file.path, nextDiff); + setDiff(directory, file.path, nextDiff, runtimeKey); } } setIsLoading(false); @@ -1494,6 +1496,7 @@ export const DiffView: React.FC = ({ } setOpeningEditorFilePath(filePath); + const runtimeKey = getRuntimeKey(); try { let targetLine: number | null = null; @@ -1525,7 +1528,7 @@ export const DiffView: React.FC = ({ isBinary: response.isBinary, }; if (!activeDiffStaged) { - setDiff(effectiveDirectory, filePath, diffForNavigation); + setDiff(effectiveDirectory, filePath, diffForNavigation, runtimeKey); } } diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 163fa58e..5c7191bb 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -567,9 +567,9 @@ const FileRow: React.FC = ({ onOpenChange={(open) => setContextMenuPath(open ? node.path : null)} > -