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
This commit is contained in:
Bohdan Triapitsyn
2026-07-21 20:52:20 +03:00
committed by GitHub
parent 485efc7117
commit 85400459e9
197 changed files with 10835 additions and 3400 deletions
@@ -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 | | 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 | | 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 | | 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 | | Dependency or lockfile | Workspace-wide checks and affected builds |
| Generated asset | Regeneration check plus consumer build/test | | Generated asset | Regeneration check plus consumer build/test |
| Docs-only or isolated config | Narrow syntax/schema/link validation; do not run unrelated full suites | | Docs-only or isolated config | Narrow syntax/schema/link validation; do not run unrelated full suites |
@@ -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. 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 ### 2. Write The Cost Equation
Name every multiplying dimension: 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. - Preserve references for unaffected entities and buckets.
- Keep streaming state out of broadly consumed stores. - Keep streaming state out of broadly consumed stores.
- Never rely on `React.memo`, `useMemo`, or Zustand equality to prevent selector execution upstream. - 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. - Do not sort structural lists from token/delta-frequency fields.
- Coalesce repeated same-entity events and skip no-op reducer updates. - Coalesce repeated same-entity events and skip no-op reducer updates.
- Ensure hidden or disabled surfaces perform no ongoing work. - 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. - 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. - 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 ## Caching Rules
Add a cache only when all are explicit: 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; - repeated-event test for streaming/polling paths;
- no-op and unrelated-entity update tests; - no-op and unrelated-entity update tests;
- reference-stability test for unaffected buckets; - 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; - failure, partial-data, empty-success, and stale-async-completion tests;
- memory/cache growth check for long-running paths; - memory/cache growth check for long-running paths;
- production build or equivalent runtime profile for UI interactions. - 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. - [ ] Partial failure cannot trigger destructive cleanup.
- [ ] Representative benchmark meets the stated budget. - [ ] Representative benchmark meets the stated budget.
- [ ] Operation-count or repeated-event regression test prevents recurrence. - [ ] 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. - [ ] Correctness, type, lint, and relevant runtime validations pass.
@@ -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. 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 ## Live And Historical State
- Use historical state to restore context, not to infer ongoing execution. - 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. - Treat startup 502/503 as transient with bounded retry/recovery.
- A retry loop requires a real failure signal; swallowed errors disable retries. - A retry loop requires a real failure signal; swallowed errors disable retries.
- Preserve previous authoritative state during transient bootstrap/reconnect failures. - 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 ## 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. - Key runtime-scoped caches by runtime identity when IDs or paths can collide.
- Clean optimistic and local cache state after partial failures. - 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 ## Verification
Cover the relevant lifecycle, not only static state: 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; - create, stream, abort, permission, archive/delete, and revisit when session behavior changes;
- partial multi-directory/project failure; - partial multi-directory/project failure;
- runtime or worktree switch with dynamic directory resolution. - 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 ## Red Flags
@@ -107,3 +135,6 @@ Cover the relevant lifecycle, not only static state:
- Queue reads current model/agent at send time. - Queue reads current model/agent at send time.
- New session lookup assumes SSE already indexed it. - New session lookup assumes SSE already indexed it.
- Optimistic data has no shadow entry or rollback. - 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.
+1
View File
@@ -68,3 +68,4 @@ workspaces/
*.pid *.pid
.worktrees/ .worktrees/
test-results/ test-results/
artifacts/browser-profile-*/
+7 -4
View File
@@ -97,7 +97,7 @@
}, },
"packages/electron": { "packages/electron": {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.16.1", "version": "1.16.2",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
@@ -133,7 +133,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.16.1", "version": "1.16.2",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
@@ -237,7 +237,7 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.16.1", "version": "1.16.2",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.3", "@opencode-ai/sdk": "1.18.3",
@@ -260,7 +260,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.16.1", "version": "1.16.2",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
@@ -347,6 +347,9 @@
}, },
}, },
}, },
"trustedDependencies": [
"electron",
],
"patchedDependencies": { "patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch",
}, },
+1
View File
@@ -72,6 +72,7 @@
"docs:validate": "node scripts/docs/validate-docs.mjs", "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", "dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates",
"doctor": "node scripts/react-doctor.mjs", "doctor": "node scripts/react-doctor.mjs",
"profile:browser": "node scripts/profile-browser.mjs",
"icons:sprite": "node scripts/generate-file-type-sprite.mjs", "icons:sprite": "node scripts/generate-file-type-sprite.mjs",
"icons:generate": "bun run scripts/generate-icon-sprite.mjs", "icons:generate": "bun run scripts/generate-icon-sprite.mjs",
"themes:port:opencode": "tsx scripts/port-opencode-theme.ts", "themes:port:opencode": "tsx scripts/port-opencode-theme.ts",
+6 -3
View File
@@ -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: 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`. 1. `settings.opencodeBinary`.
2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`. 2. Environment overrides: `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`.
3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations. 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. 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.
+6 -2
View File
@@ -490,12 +490,16 @@ const readJsonFile = (filePath) => {
}; };
const writeJsonFile = async (filePath, data) => { 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 // Atomic: write to a temp file then rename. Readers never see a partial
// JSON file that could parse-error and get coerced to {}. // 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)}`; 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); await fsp.rename(tmp, filePath);
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
}; };
const readSettingsRoot = () => { const readSettingsRoot = () => {
@@ -37,6 +37,7 @@ class NotificationService: UNNotificationServiceExtension {
guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return } guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return }
var snapshot: [String: Any] = [ var snapshot: [String: Any] = [
"runtimeKey": request.content.userInfo["runtimeKey"] as? String ?? "",
"attentionCount": 0, "attentionCount": 0,
"recentSessions": [], "recentSessions": [],
] ]
@@ -46,6 +47,16 @@ class NotificationService: UNNotificationServiceExtension {
snapshot = stored 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. // Attention count: authoritative server value carried in aps.badge.
if let badge = request.content.badge as? Int { if let badge = request.content.badge as? Int {
snapshot["attentionCount"] = badge snapshot["attentionCount"] = badge
@@ -17,10 +17,11 @@ struct WidgetSession: Codable, Identifiable, Hashable {
/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the /// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the
/// JSON the app stores decodes directly. /// JSON the app stores decodes directly.
struct WidgetSnapshot: Codable { struct WidgetSnapshot: Codable {
var runtimeKey: String?
let attentionCount: Int let attentionCount: Int
let recentSessions: [WidgetSession] let recentSessions: [WidgetSession]
static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: []) static let empty = WidgetSnapshot(runtimeKey: nil, attentionCount: 0, recentSessions: [])
} }
enum WidgetStore { enum WidgetStore {
@@ -21,6 +21,7 @@ import {
useIsGitRepo, useIsGitRepo,
useGitLoadingStatus, useGitLoadingStatus,
} from '@/stores/useGitStore'; } from '@/stores/useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
type CommitAction = 'commit' | 'commitAndPush' | null; type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -202,6 +203,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
} }
let cancelled = false; let cancelled = false;
const runtimeKey = getRuntimeKey();
setDiffLoadError(null); setDiffLoadError(null);
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined }) void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
.then((response) => { .then((response) => {
@@ -210,7 +212,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
original: response.original ?? '', original: response.original ?? '',
modified: response.modified ?? '', modified: response.modified ?? '',
isBinary: response.isBinary, isBinary: response.isBinary,
}); }, runtimeKey);
}) })
.catch((error) => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
+13 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from 'bun:test'; 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 originalFetch = globalThis.fetch;
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
@@ -40,6 +40,18 @@ const testRelay: MobileRelayConfig = {
}; };
describe('mobile connection storage', () => { 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 () => { test('entries persisted before candidates migrate to a single direct candidate', async () => {
try { try {
installTestWindow(); installTestWindow();
+37 -4
View File
@@ -691,6 +691,30 @@ const deleteSecureToken = async (key: string): Promise<void> => {
// One-time migration: a legacy localStorage record on native might still carry an // 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. // inline `clientToken`. Move it into the secure store and strip the metadata.
export const migrateLegacyInlineTokenRecords = async (
records: unknown[],
migrateToken: (url: string, token: string) => Promise<boolean>,
): 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<string, unknown>;
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<void> => { const migrateLegacyInlineTokens = async (): Promise<void> => {
if (typeof window === 'undefined' || !isCapacitorApp()) return; if (typeof window === 'undefined' || !isCapacitorApp()) return;
let parsed: unknown; let parsed: unknown;
@@ -707,11 +731,20 @@ const migrateLegacyInlineTokens = async (): Promise<void> => {
&& Boolean((item as { clientToken: string }).clientToken.trim())); && Boolean((item as { clientToken: string }).clientToken.trim()));
if (legacy.length === 0) return; if (legacy.length === 0) return;
logStorage('secure:migrate-start', { count: legacy.length }); logStorage('secure:migrate-start', { count: legacy.length });
for (const { url, clientToken } of legacy) { const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => {
await writeSecureToken(getConnectionStorageKey(url), clientToken); 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<MobileSavedConnection[]> => { export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
+4 -1
View File
@@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useNotificationStore } from '@/sync/notification-store'; import { useNotificationStore } from '@/sync/notification-store';
import { getRuntimeKey } from '@/lib/runtime-switch';
/** /**
* Builds the lightweight session overview the native iOS widgets render (home medium, * Builds the lightweight session overview the native iOS widgets render (home medium,
@@ -26,6 +27,8 @@ export interface MobileWidgetSession {
} }
export interface MobileWidgetSnapshot { 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. */ /** Count of sessions needing attention — same signal that drives the app-icon badge. */
attentionCount: number; attentionCount: number;
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */ /** 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) .slice(0, RECENT_LIMIT)
.map(({ id, title, unread, project }) => ({ id, title, unread, project })); .map(({ id, title, unread, project }) => ({ id, title, unread, project }));
return { attentionCount, recentSessions }; return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
}; };
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__'; const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
@@ -7,9 +7,15 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { usePermissionStore } from '@/stores/permissionStore'; 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 { useTerminalStore } from '@/stores/useTerminalStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming'; import { resetStreamingState } from '@/sync/streaming';
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
import { syncDesktopSettings } from '@/lib/persistence'; import { syncDesktopSettings } from '@/lib/persistence';
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK // 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 // Cross-project session list (mobile sessions sheet & co) belongs to the
// previous instance — drop it so stale sessions can't linger after a switch. // previous instance — drop it so stale sessions can't linger after a switch.
useGlobalSessionsStore.getState().resetForRuntimeSwitch(); useGlobalSessionsStore.getState().resetForRuntimeSwitch();
useGlobalSessionStatusStore.setState({ statusById: new Map() });
usePermissionStore.getState().reset(); 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); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState(); resetStreamingState();
@@ -36,16 +36,15 @@ import { useStreamingStore } from '@/sync/streaming';
import { import {
useSessionMessageCount, useSessionMessageCount,
useSessionMessageRecords, useSessionMessageRecords,
useSessionMessageLoadState,
useSyncDirectory, useSyncDirectory,
useDirectorySync, useSessionRenderable,
useSessionStatus, useSessionStatus,
useScopedBlockingPermissions, useScopedBlockingPermissions,
useScopedBlockingQuestions, useScopedBlockingQuestions,
useParentSession, useParentSession,
} from '@/sync/sync-context'; } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync'; 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 { usePlanDetection } from '@/hooks/usePlanDetection';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; 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 // Shell-mode prompts show their extracted command; cache by message id so
// the parts array reference is stable while the command is unchanged. // the parts array reference is stable while the command is unchanged.
const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>()); const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>());
const shellPreviewSessionRef = React.useRef(currentSessionId);
if (shellPreviewSessionRef.current !== currentSessionId) {
shellPreviewSessionRef.current = currentSessionId;
shellPreviewCache.current.clear();
}
const promptPreviewsByTurnId = React.useMemo(() => { const promptPreviewsByTurnId = React.useMemo(() => {
const next = new Map<string, Part[]>(); const next = new Map<string, Part[]>();
for (let index = 0; index < renderedMessages.length; index += 1) { for (let index = 0; index < renderedMessages.length; index += 1) {
@@ -339,6 +343,7 @@ const ChatViewport = React.memo(({
</div> </div>
)} )}
<MessageList <MessageList
key={currentSessionId}
ref={messageListRef} ref={messageListRef}
sessionKey={currentSessionId} sessionKey={currentSessionId}
disableStaging={pendingRevealWork} disableStaging={pendingRevealWork}
@@ -487,12 +492,42 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
); );
}; };
const DraftWelcome: React.FC = () => {
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 (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
projectLabel
? t('chat.emptyState.draftTitleWithProject', { project: projectLabel })
: t('chat.emptyState.draftTitle'),
projectLabel,
)}
</h1>
<DraftPresetChips
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
className="oc-draft-starters mt-8 max-w-md"
/>
</div>
);
};
type ChatContainerProps = { type ChatContainerProps = {
active?: boolean;
autoOpenDraft?: boolean; autoOpenDraft?: boolean;
readOnly?: boolean; readOnly?: boolean;
}; };
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => { export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
const { t } = useI18n(); const { t } = useI18n();
// Session UI state // Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -500,16 +535,14 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
// Sync actions // Sync actions
const sync = useSync(); const sync = useSync();
const syncDirectory = useSyncDirectory(); const syncDirectory = useSyncDirectory();
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory; const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
const ensureSessionRenderable = React.useCallback( const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId), (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
[sync], [effectiveSessionDirectory, sync],
); );
const loadMoreMessages = React.useCallback( const loadMoreMessages = React.useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -542,31 +575,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
), ),
); );
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory); const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
const hasRenderableSessionSnapshot = useDirectorySync( const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
React.useCallback(
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
effectiveSessionDirectory,
);
// Messages from sync system // Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, { const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
enabled: active,
suspendPartUpdates: Boolean(streamingMessageId), suspendPartUpdates: Boolean(streamingMessageId),
suspendPartUpdatesForMessageId: streamingMessageId, suspendPartUpdatesForMessageId: streamingMessageId,
}); });
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const sessionPrefetchInfo = React.useSyncExternalStore( const sessionMessageLoadState = useSessionMessageLoadState(
React.useCallback( currentSessionId ?? '',
(notify) => currentSessionId effectiveSessionDirectory,
? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify)
: () => undefined,
[currentSessionId, effectiveSessionDirectory],
),
React.useCallback(
() => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined,
[currentSessionId, effectiveSessionDirectory],
),
React.useCallback(() => undefined, []),
); );
// Plan detection - watches messages for plan creation and signals store // Plan detection - watches messages for plan creation and signals store
@@ -643,20 +662,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// History metadata — use sync's hasMore/isLoading // History metadata — use sync's hasMore/isLoading
const historyMeta = React.useMemo(() => { const historyMeta = React.useMemo(() => {
if (!currentSessionId) return null; 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 { return {
limit: sessionMessages.length, limit: sessionMessages.length,
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore), complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor,
loading: sync.isLoading(currentSessionId), loading: sessionMessageLoadState.status === 'loading',
}; };
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]); }, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
@@ -668,17 +679,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const isDesktopExpandedInput = isExpandedInput; const isDesktopExpandedInput = isExpandedInput;
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat'; const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
const messageListRef = React.useRef<MessageListHandle | null>(null); const messageListRef = React.useRef<MessageListHandle | null>(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); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
// In the embedded session-chat iframe, hide "Return to parent" when // In the embedded session-chat iframe, hide "Return to parent" when
@@ -942,9 +942,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const isSessionHydrating = const isSessionHydrating =
Boolean(currentSessionId) Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot; && !hasRenderableSessionSnapshot;
const retrySessionLoad = React.useCallback(() => {
if (!active || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
React.useEffect(() => { React.useEffect(() => {
if (!currentSessionId) return; if (!active || !currentSessionId) return;
if (lastScrolledSessionRef.current === currentSessionId) return; if (lastScrolledSessionRef.current === currentSessionId) return;
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
@@ -963,14 +967,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
} else { } else {
window.requestAnimationFrame(run); window.requestAnimationFrame(run);
} }
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]); }, [active, currentSessionId, releaseAutoFollow, restoreSnapshot]);
React.useEffect(() => { React.useEffect(() => {
if (!currentSessionId) return; if (!active || !currentSessionId) return;
if (hasRenderableSessionSnapshot) return; if (hasRenderableSessionSnapshot) return;
if (effectiveSessionDirectory !== syncDirectory) return;
void ensureSessionRenderable(currentSessionId); void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]); }, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
if (!currentSessionId && !draftOpen) { if (!currentSessionId && !draftOpen) {
// With auto-open, the draft welcome opens on the next tick (effect below), // With auto-open, the draft welcome opens on the next tick (effect below),
@@ -993,22 +996,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// the fullscreen composer's position:fixed visual-viewport pinning in // the fullscreen composer's position:fixed visual-viewport pinning in
// mobile browsers (see ChatInput's composerFormRef effect). // mobile browsers (see ChatInput's composerFormRef effect).
<div className="relative flex h-full flex-col bg-background"> <div className="relative flex h-full flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? ( {useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
draftProjectLabel
? t('chat.emptyState.draftTitleWithProject', { project: draftProjectLabel })
: t('chat.emptyState.draftTitle'),
draftProjectLabel,
)}
</h1>
<DraftPresetChips
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
className="oc-draft-starters mt-8 max-w-md"
/>
</div>
) : null}
<div <div
className={cn( className={cn(
'relative z-10 flex min-h-0', 'relative z-10 flex min-h-0',
@@ -1030,6 +1018,28 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
} }
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="relative flex h-full flex-col bg-background">
{returnToParentButton}
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return ( return (
<div className="relative flex flex-col h-full bg-background"> <div className="relative flex flex-col h-full bg-background">
{returnToParentButton} {returnToParentButton}
@@ -1124,7 +1134,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
<div className="relative flex flex-col h-full bg-background"> <div className="relative flex flex-col h-full bg-background">
{returnToParentButton} {returnToParentButton}
<ChatViewport <ChatViewport
key={currentSessionId}
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput} isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile} isMobile={isMobile}
+147 -144
View File
@@ -6,7 +6,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
// sessionStore removed — currentSessionId comes from useSessionUIStore // sessionStore removed — currentSessionId comes from useSessionUIStore
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
@@ -14,12 +14,21 @@ import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context'; import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments'; import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts'; import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow'; import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
import {
createChatDraftIdentity,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
type ChatDraftIdentity,
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import ToolOutputDialog from './message/ToolOutputDialog'; import ToolOutputDialog from './message/ToolOutputDialog';
@@ -111,6 +120,9 @@ const FILE_MENTION_TOKEN = /^@[^\s]+$/;
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g; const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500; const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const getChatDraftSnapshotSignature = (text: string, confirmedMentions: Iterable<string>): string => (
`${text}\u0000${[...confirmedMentions].sort().join('\u0000')}`
);
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const VS_CODE_DROP_DATA_TYPES = [ const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles', 'CodeFiles',
@@ -918,81 +930,35 @@ type AutocompleteOverlayPosition = {
maxHeight: number; maxHeight: number;
}; };
// Per-session draft key — preserves in-progress messages across project switches const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
const getDraftKey = (sessionId: string | null): string => const sessionState = useSessionUIStore.getState();
`openchamber_chat_input_draft_${sessionId ?? 'new'}`; const newSessionDirectory = sessionState.newSessionDraft?.open
? sessionState.newSessionDraft.bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride
// Helper to safely read from localStorage for a given session : null;
const getStoredDraft = (sessionId: string | null): string => { const directory = sessionId
try { ? sessionState.getDirectoryForSession(sessionId) ?? sessionState.currentSessionDirectory
return localStorage.getItem(getDraftKey(sessionId)) ?? ''; : newSessionDirectory ?? useDirectoryStore.getState().currentDirectory;
} catch { return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
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<string>): 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<string> => {
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 ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => { const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const { t } = useI18n(); const { t } = useI18n();
// Track if we restored a draft on mount (for text selection) // Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null); const initialDraftRef = React.useRef<string | null>(null);
// Track initial session ID (captured at mount time for draft restoration) const initialDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(null);
const initialSessionIdRef = React.useRef<string | null>(null); const initialDraftSnapshotRef = React.useRef<ChatDraftSnapshot>({ text: '', confirmedMentions: new Set() });
const [message, setMessage] = React.useState(() => { const [message, setMessage] = React.useState(() => {
// Read per-session draft at mount time using the current session from the store
const sessionId = useSessionUIStore.getState().currentSessionId; const sessionId = useSessionUIStore.getState().currentSessionId;
initialSessionIdRef.current = sessionId; const identity = resolveChatDraftIdentity(sessionId);
const draft = getStoredDraft(sessionId); const snapshot = readChatDraft(identity);
if (draft) { initialDraftIdentityRef.current = identity;
initialDraftRef.current = draft; 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<Set<string>>(initialDraftSnapshotRef.current.confirmedMentions);
const confirmedMentionsRef = React.useRef<Set<string>>(loadConfirmedMentions(initialSessionIdRef.current));
// Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed) // Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
const isConfirmedFilePath = (text: string): boolean => const isConfirmedFilePath = (text: string): boolean =>
text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text); text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text);
@@ -1070,7 +1036,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const skipNextDraftPersistRef = React.useRef(false); const skipNextDraftPersistRef = React.useRef(false);
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map()); const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
const currentSessionIdForDraftRef = React.useRef<string | null>(null); const currentChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set()); const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
@@ -1084,6 +1050,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentSessionDirectoryForSync = useSessionUIStore( const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), 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 newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const newSessionDraftOpen = Boolean(newSessionDraft?.open); const newSessionDraftOpen = Boolean(newSessionDraft?.open);
const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => ( const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => (
@@ -1487,14 +1462,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} | null>(null); } | null>(null);
// Message queue // Message queue
const messageQueueTarget = currentSessionId
? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory)
: null;
const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null;
const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior); const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior);
const queuedMessages = useMessageQueueStore( const queuedMessages = useMessageQueueStore(
React.useCallback( React.useCallback(
(state) => { (state) => {
if (!currentSessionId) return EMPTY_QUEUE; if (!messageQueueKey) return EMPTY_QUEUE;
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE; return state.queuedMessages[messageQueueKey] ?? EMPTY_QUEUE;
}, },
[currentSessionId] [messageQueueKey]
) )
); );
const addToQueue = useMessageQueueStore((state) => state.addToQueue); const addToQueue = useMessageQueueStore((state) => state.addToQueue);
@@ -1502,21 +1481,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Inline comment drafts // Inline comment drafts
const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory;
const inlineDraftTarget = React.useMemo<InlineCommentDraftTarget | null>(
() => inlineDraftSessionKey && inlineDraftDirectory
? { directory: inlineDraftDirectory, sessionKey: inlineDraftSessionKey }
: null,
[inlineDraftDirectory, inlineDraftSessionKey],
);
const inlineDraftKey = inlineDraftTarget
? getInlineCommentDraftKey(activeRuntimeKey, inlineDraftTarget.directory, inlineDraftTarget.sessionKey)
: null;
const draftCount = useInlineCommentDraftStore( const draftCount = useInlineCommentDraftStore(
React.useCallback( React.useCallback(
(state) => { (state) => inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []).length : 0,
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); [inlineDraftKey]
if (!sessionKey) return 0;
return (state.drafts[sessionKey] ?? []).length;
},
[currentSessionId, newSessionDraftOpen]
) )
); );
const draftSourceKey = useInlineCommentDraftStore( const draftSourceKey = useInlineCommentDraftStore(
React.useCallback( React.useCallback(
(state) => { (state) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : [];
let previewConsole = 0; let previewConsole = 0;
let previewAnnotation = 0; let previewAnnotation = 0;
let review = 0; let review = 0;
@@ -1529,7 +1514,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`; return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`;
}, },
[currentSessionId, newSessionDraftOpen] [inlineDraftKey]
) )
); );
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
@@ -1537,29 +1522,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const hasDrafts = draftCount > 0; const hasDrafts = draftCount > 0;
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const terminalContextDrafts = terminalContextCount > 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 removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); if (!inlineDraftTarget) return;
if (!sessionKey) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
for (const draft of drafts) { for (const draft of drafts) {
if (draft.source === source) { 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. // Review comments are the inline-comment drafts that aren't preview sources.
const removeReviewDrafts = React.useCallback(() => { const removeReviewDrafts = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); if (!inlineDraftTarget) return;
if (!sessionKey) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
for (const draft of drafts) { for (const draft of drafts) {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') { 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. // User message history for up/down arrow navigation.
// Keep this on a narrow hook instead of full session message records. // Keep this on a narrow hook instead of full session message records.
@@ -1571,17 +1554,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, [message]); }, [message]);
React.useEffect(() => { React.useEffect(() => {
currentSessionIdForDraftRef.current = currentSessionId; currentChatDraftIdentityRef.current = chatDraftIdentity;
}, [currentSessionId]); }, [chatDraftIdentity]);
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => { const persistDraftImmediately = React.useCallback((identity: ChatDraftIdentity | null, draft: string) => {
const key = getDraftKey(sessionId); if (!identity) return;
const lastPersisted = lastPersistedDraftRef.current.get(key); const key = getChatDraftIdentityKey(identity);
if (lastPersisted === draft) {
return;
}
saveStoredDraft(sessionId, draft);
// Only persist confirmed mentions that are actually present in the draft text // Only persist confirmed mentions that are actually present in the draft text
const activeMentions = new Set<string>(); const activeMentions = new Set<string>();
for (const mention of confirmedMentionsRef.current) { for (const mention of confirmedMentionsRef.current) {
@@ -1590,8 +1568,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
} }
confirmedMentionsRef.current = activeMentions; confirmedMentionsRef.current = activeMentions;
saveConfirmedMentions(sessionId, activeMentions); const signature = getChatDraftSnapshotSignature(draft, activeMentions);
lastPersistedDraftRef.current.set(key, draft); const lastPersisted = lastPersistedDraftRef.current.get(key);
if (lastPersisted === signature) {
return;
}
writeChatDraft(identity, draft, activeMentions);
lastPersistedDraftRef.current.set(key, signature);
}, []); }, []);
const clearPendingDraftPersist = React.useCallback(() => { const clearPendingDraftPersist = React.useCallback(() => {
@@ -1614,11 +1597,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!persistChatDraft) { if (!persistChatDraft) {
// Setting disabled - clear the restored draft // Setting disabled - clear the restored draft
setMessage(''); setMessage('');
try { writeChatDraft(initialDraftIdentityRef.current, '', []);
localStorage.removeItem(getDraftKey(initialSessionIdRef.current));
} catch {
// Ignore
}
} else { } else {
// Setting enabled - select all text // Setting enabled - select all text
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1627,24 +1606,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
}, [persistChatDraft]); }, [persistChatDraft]);
// Handle session switching: save draft for old session, restore draft for new session // Handle identity switching: save the old draft and restore the new runtime/directory/session draft.
const prevSessionIdRef = React.useRef(currentSessionId); const prevChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
React.useEffect(() => { React.useEffect(() => {
if (prevSessionIdRef.current !== currentSessionId) { const previousIdentity = prevChatDraftIdentityRef.current;
const oldSessionId = prevSessionIdRef.current; const previousKey = previousIdentity ? getChatDraftIdentityKey(previousIdentity) : null;
prevSessionIdRef.current = currentSessionId; const currentKey = chatDraftIdentity ? getChatDraftIdentityKey(chatDraftIdentity) : null;
if (previousKey !== currentKey) {
prevChatDraftIdentityRef.current = chatDraftIdentity;
setInputMode('normal'); setInputMode('normal');
clearPendingDraftPersist(); clearPendingDraftPersist();
skipNextDraftPersistRef.current = true; skipNextDraftPersistRef.current = true;
if (persistChatDraft) { if (persistChatDraft) {
// Save current draft for the session we're leaving persistDraftImmediately(previousIdentity, messageRef.current);
persistDraftImmediately(oldSessionId, messageRef.current); const nextSnapshot = readChatDraft(chatDraftIdentity);
// Restore draft for the session we're entering setMessage(nextSnapshot.text);
const newDraft = getStoredDraft(currentSessionId); confirmedMentionsRef.current = nextSnapshot.confirmedMentions;
setMessage(newDraft); if (nextSnapshot.text) {
confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId);
if (newDraft) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
textareaRef.current?.select(); textareaRef.current?.select();
}); });
@@ -1655,7 +1634,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
confirmedMentionsRef.current = new Set(); 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 // Focus textarea when new session draft is opened
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
@@ -1678,7 +1669,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => { React.useEffect(() => {
if (!persistChatDraft) { if (!persistChatDraft) {
clearPendingDraftPersist(); clearPendingDraftPersist();
persistDraftImmediately(currentSessionId, ''); persistDraftImmediately(chatDraftIdentity, '');
return; return;
} }
@@ -1689,24 +1680,37 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
clearPendingDraftPersist(); clearPendingDraftPersist();
const draftSnapshot = message; const draftSnapshot = message;
const sessionSnapshot = currentSessionId; const identitySnapshot = chatDraftIdentity;
draftPersistTimerRef.current = setTimeout(() => { draftPersistTimerRef.current = setTimeout(() => {
draftPersistTimerRef.current = null; draftPersistTimerRef.current = null;
persistDraftImmediately(sessionSnapshot, draftSnapshot); persistDraftImmediately(identitySnapshot, draftSnapshot);
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS); }, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
return () => { return () => {
clearPendingDraftPersist(); clearPendingDraftPersist();
}; };
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]); }, [chatDraftIdentity, clearPendingDraftPersist, message, persistChatDraft, persistDraftImmediately]);
React.useEffect(() => { React.useEffect(() => {
return () => { const flushCurrentDraft = () => {
clearPendingDraftPersist(); clearPendingDraftPersist();
if (persistChatDraft) { 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]); }, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
// Session activity for queue availability and controls // Session activity for queue availability and controls
@@ -1782,9 +1786,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Add message to queue instead of sending // Add message to queue instead of sending
const handleQueueMessage = React.useCallback(() => { const handleQueueMessage = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot(); 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, ''); let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
if (drafts.length > 0) { if (drafts.length > 0) {
@@ -1792,7 +1796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles); const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
addToQueue(currentSessionId, { addToQueue(messageQueueTarget, {
content: messageToQueue, content: messageToQueue,
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
sendConfig: currentProviderId && currentModelId ? { sendConfig: currentProviderId && currentModelId ? {
@@ -1815,7 +1819,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!isMobile) { if (!isMobile) {
textareaRef.current?.focus(); 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) => { const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content); setMessage(content);
@@ -1974,10 +1978,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
} }
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); const consumedDraftTarget = inlineDraftTarget;
let drafts: InlineCommentDraft[] = []; let drafts: InlineCommentDraft[] = [];
if (!queuedOnly && sessionKey) { if (!queuedOnly && consumedDraftTarget) {
drafts = consumeDrafts(sessionKey); drafts = consumeDrafts(consumedDraftTarget);
} }
if (drafts.length > 0) { if (drafts.length > 0) {
@@ -2032,17 +2036,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return; if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return;
// Clear queue and input // Clear queue and input
if (currentSessionId && queuedMessageId) { if (messageQueueTarget && queuedMessageId) {
removeFromQueue(currentSessionId, queuedMessageId); removeFromQueue(messageQueueTarget, queuedMessageId);
} else if (currentSessionId && hasQueuedMessages) { } else if (messageQueueTarget && hasQueuedMessages) {
clearQueue(currentSessionId); clearQueue(messageQueueTarget);
} }
if (!queuedOnly) { if (!queuedOnly) {
setMessage(''); setMessage('');
confirmedMentionsRef.current.clear(); confirmedMentionsRef.current.clear();
// Clear per-session draft on submit // Clear per-session draft on submit
saveStoredDraft(currentSessionId, ''); persistDraftImmediately(chatDraftIdentity, '');
saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current);
// Reset message history navigation state // Reset message history navigation state
setHistoryIndex(-1); setHistoryIndex(-1);
setDraftMessage(''); setDraftMessage('');
@@ -2333,8 +2336,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
sendMessageOptions, sendMessageOptions,
); );
const restoreConsumedDrafts = () => { const restoreConsumedDrafts = () => {
if (sessionKey && drafts.length > 0) { if (consumedDraftTarget && drafts.length > 0) {
useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts); useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts);
} }
}; };
@@ -2369,7 +2372,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentInput = textareaRef.current?.value ?? messageRef.current; const currentInput = textareaRef.current?.value ?? messageRef.current;
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
setMessage(inputSnapshot.message); setMessage(inputSnapshot.message);
saveStoredDraft(null, inputSnapshot.message); writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
} }
const isSoftNetworkError = const isSoftNetworkError =
@@ -4667,7 +4670,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]"> <span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })} {t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })}
</span> </span>
<button type="button" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" onClick={() => removeInlineCommentDraft(draft.sessionKey, draft.id)} aria-label={t('chat.chatInput.terminalContextRemove')} title={t('chat.chatInput.terminalContextRemove')}> <button type="button" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" onClick={() => inlineDraftTarget && removeInlineCommentDraft(inlineDraftTarget, draft.id)} aria-label={t('chat.chatInput.terminalContextRemove')} title={t('chat.chatInput.terminalContextRemove')}>
<Icon name="close" className="h-3 w-3" /> <Icon name="close" className="h-3 w-3" />
</button> </button>
</div> </div>
@@ -163,12 +163,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const { currentTheme } = useThemeSystem(); const { currentTheme } = useThemeSystem();
const messageContainerRef = React.useRef<HTMLDivElement | null>(null); const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession); const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection); const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
streamPerfCount('ui.chat_message.render'); streamPerfCount('ui.chat_message.render');
if (isInActiveTurn) { if (isInActiveTurn) {
@@ -186,12 +182,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
})) }))
); );
React.useEffect(() => {
if (currentSessionId) {
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
}
}, [currentSessionId]);
const [copiedCode, setCopiedCode] = React.useState<string | null>(null); const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
const [copiedMessage, setCopiedMessage] = React.useState(false); const [copiedMessage, setCopiedMessage] = React.useState(false);
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id)); const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
@@ -580,8 +570,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const shouldAnimateMessage = React.useMemo(() => { const shouldAnimateMessage = React.useMemo(() => {
if (isUser) return false; if (isUser) return false;
const freshnessDetector = MessageFreshnessDetector.getInstance(); const freshnessDetector = MessageFreshnessDetector.getInstance();
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID); return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID);
}, [message.info, currentSessionId, isUser]); }, [message.info, isUser]);
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false); const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
@@ -794,14 +784,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const handleRevert = React.useCallback(() => { const handleRevert = React.useCallback(() => {
if (!sessionId || !message.info.id) return; if (!sessionId || !message.info.id) return;
revertToMessage(sessionId, message.info.id); useSessionUIStore.getState().revertToMessage(sessionId, message.info.id);
}, [sessionId, message.info.id, revertToMessage]); }, [sessionId, message.info.id]);
// NEW: Fork handler // NEW: Fork handler
const handleFork = React.useCallback(() => { const handleFork = React.useCallback(() => {
if (!sessionId || !message.info.id) return; if (!sessionId || !message.info.id) return;
forkFromMessage(sessionId, message.info.id); useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id);
}, [sessionId, message.info.id, forkFromMessage]); }, [sessionId, message.info.id]);
const handleToggleTool = React.useCallback((toolId: string) => { const handleToggleTool = React.useCallback((toolId: string) => {
const isDefaultOpen = defaultOpenToolIds.has(toolId); const isDefaultOpen = defaultOpenToolIds.has(toolId);
@@ -40,6 +40,7 @@ import {
parseFileReference, parseFileReference,
type ParsedFileReference, type ParsedFileReference,
} from './fileReferenceParser'; } from './fileReferenceParser';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
const useCurrentMermaidTheme = () => { const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem(); const themeSystem = useOptionalThemeSystem();
@@ -754,69 +755,6 @@ const useMermaidInlineInteractions = ({
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom // 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<number>(() => (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 // Mermaid layout is expensive; `decorate` would otherwise re-render every
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source // 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. // so a stable diagram is laid out once and served from cache thereafter.
@@ -1094,6 +1032,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
onShowPopup, onShowPopup,
enableFileReferences = true, 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 currentTheme = useCurrentMermaidTheme();
const { editor, runtime } = useRuntimeAPIs(); const { editor, runtime } = useRuntimeAPIs();
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
@@ -1106,7 +1047,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
}, [effectiveDirectory, openContextPreview]); }, [effectiveDirectory, openContextPreview]);
const live = isStreaming && !disableStreamAnimation; const live = isStreaming && !disableStreamAnimation;
const pacedText = usePacedText(content, live);
useMermaidInlineInteractions({ useMermaidInlineInteractions({
containerRef, containerRef,
@@ -1127,7 +1067,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; 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 = ( const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}> <div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
@@ -16,7 +16,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { isHiddenUserMessage } from './message/hiddenUserMessage'; import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; 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 type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionParts } from '@/sync/sync-context'; import { useSessionParts } from '@/sync/sync-context';
@@ -885,6 +885,7 @@ const MessageListEntry = React.memo(({
activeStreamingPhase, activeStreamingPhase,
reviewTransferDirection, reviewTransferDirection,
}: MessageListEntryProps) => { }: MessageListEntryProps) => {
streamPerfCount('ui.message_list_entry.render');
if (entry.kind === 'ungrouped') { if (entry.kind === 'ungrouped') {
return ( return (
<UngroupedMessageRow <UngroupedMessageRow
@@ -1264,6 +1265,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
scrollRef, scrollRef,
directory, directory,
}, ref) => { }, ref) => {
streamPerfMark('react.message_list_render');
streamPerfCount('ui.message_list.render'); streamPerfCount('ui.message_list.render');
const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -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('<MobileSessionStatusOpenPanel', wrapperStart);
expect(openPanelStart).toBeGreaterThan(-1);
expect(closedGuard).toBeGreaterThan(wrapperStart);
expect(openPanelMount).toBeGreaterThan(closedGuard);
expect(source.indexOf('useSessionGrouping(', openPanelStart)).toBeLessThan(wrapperStart);
});
});
@@ -21,9 +21,7 @@ interface MobileSessionStatusBarProps {
interface SessionWithStatus extends Session { interface SessionWithStatus extends Session {
_statusType?: 'busy' | 'retry' | 'idle'; _statusType?: 'busy' | 'retry' | 'idle';
_hasRunningChildren?: boolean;
_runningChildrenCount?: number; _runningChildrenCount?: number;
_childIndicators?: Array<{ session: Session; isRunning: boolean }>;
} }
// Cross-project session source. Mirrors the dedicated MobileSessionsSheet: // Cross-project session source. Mirrors the dedicated MobileSessionsSheet:
@@ -84,12 +82,14 @@ function useSessionGrouping(
const map = new Map<string, Session[]>(); const map = new Map<string, Session[]>();
const allIds = new Set(sessions.map((s) => s.id)); const allIds = new Set(sessions.map((s) => s.id));
sessions.forEach((session) => { for (const session of sessions) {
const parentID = (session as { parentID?: string }).parentID; const parentID = (session as { parentID?: string }).parentID;
if (parentID && allIds.has(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; return map;
}, [sessions]); }, [sessions]);
@@ -99,24 +99,6 @@ function useSessionGrouping(
return 'idle'; return 'idle';
}, [sessionStatus]); }, [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 processedSessions = React.useMemo(() => {
const sessionIds = new Set(sessions.map((s) => s.id)); const sessionIds = new Set(sessions.map((s) => s.id));
const topLevel = sessions.filter((session) => { const topLevel = sessions.filter((session) => {
@@ -129,20 +111,18 @@ function useSessionGrouping(
topLevel.forEach((session) => { topLevel.forEach((session) => {
const statusType = getStatusType(session.id); 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 attention = (unseenCounts[session.id] ?? 0) > 0;
const enriched: SessionWithStatus = { const enriched: SessionWithStatus = {
...session, ...session,
_statusType: statusType, _statusType: statusType,
_hasRunningChildren: hasRunning, _runningChildrenCount: runningChildrenCount,
_runningChildrenCount: getRunningChildrenCount(session.id),
_childIndicators: getChildIndicators(session.id),
}; };
if (statusType !== 'idle' || hasRunning) { if (statusType !== 'idle' || runningChildrenCount > 0 || attention) {
running.push(enriched);
} else if (attention) {
running.push(enriched); running.push(enriched);
} else { } else {
viewed.push(enriched); viewed.push(enriched);
@@ -159,7 +139,7 @@ function useSessionGrouping(
viewed.sort(sortByUpdated); viewed.sort(sortByUpdated);
return [...running, ...viewed]; return [...running, ...viewed];
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]); }, [sessions, getStatusType, parentChildMap, unseenCounts]);
const totalRunning = processedSessions.reduce((sum, s) => { const totalRunning = processedSessions.reduce((sum, s) => {
const selfRunning = s._statusType !== 'idle' ? 1 : 0; const selfRunning = s._statusType !== 'idle' ? 1 : 0;
@@ -188,7 +168,6 @@ function useSessionHelpers() {
// Per-project status indicators (running / unread) for the filter chips. // Per-project status indicators (running / unread) for the filter chips.
function useProjectStatus( function useProjectStatus(
sessions: Session[],
sessionStatus: Record<string, { type: string }> | undefined, sessionStatus: Record<string, { type: string }> | undefined,
currentSessionId: string | null currentSessionId: string | null
) { ) {
@@ -450,12 +429,11 @@ export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps>
); );
}; };
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({ const MobileSessionStatusOpenPanel: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch, onSessionSwitch,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const { currentTheme } = useThemeSystem(); const { currentTheme } = useThemeSystem();
const isMobile = useUIStore((state) => state.isMobile);
const sessions = useAllProjectSessions(); const sessions = useAllProjectSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionStatus = useAllSessionStatuses(); const sessionStatus = useAllSessionStatuses();
@@ -469,7 +447,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus); const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
const { getSessionTitle, needsAttention } = useSessionHelpers(); const { getSessionTitle, needsAttention } = useSessionHelpers();
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); const getProjectStatus = useProjectStatus(sessionStatus, currentSessionId);
const resolveProjectRoots = useProjectRootsResolver(); const resolveProjectRoots = useProjectRootsResolver();
// Project filter, persisted in the UI store so the choice survives closing and // Project filter, persisted in the UI store so the choice survives closing and
@@ -605,10 +583,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
</div> </div>
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]); ), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
if (!isMobile) {
return null;
}
return ( return (
<MobileOverlayPanel <MobileOverlayPanel
open={open} open={open}
@@ -639,3 +613,11 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
</MobileOverlayPanel> </MobileOverlayPanel>
); );
}; };
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = (props) => {
const isMobile = useUIStore((state) => state.isMobile);
const open = useUIStore((state) => state.mobileSessionPanelOpen);
if (!isMobile || !open) return null;
return <MobileSessionStatusOpenPanel {...props} />;
};
@@ -29,9 +29,8 @@ import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-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 { useSync } from '@/sync/use-sync';
import { getSessionMaterializationStatus } from '@/sync/materialization';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists'; import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
@@ -646,11 +645,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null); const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
const hasRenderableCurrentSessionSnapshot = useDirectorySync( const hasRenderableCurrentSessionSnapshot = useSessionRenderable(
React.useCallback( currentSessionId ?? '',
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
currentSessionDirectory ?? undefined, currentSessionDirectory ?? undefined,
); );
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; 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 { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -24,12 +24,12 @@ import { cn } from '@/lib/utils';
interface QueuedMessageChipProps { interface QueuedMessageChipProps {
message: QueuedMessage; message: QueuedMessage;
sessionId: string; target: MessageQueueTarget;
onEdit: (message: QueuedMessage) => void; onEdit: (message: QueuedMessage) => void;
onSend: (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 { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id });
@@ -89,7 +89,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMe
</Button> </Button>
<button <button
type="button" type="button"
onClick={() => removeFromQueue(sessionId, message.id)} onClick={() => removeFromQueue(target, message.id)}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors" className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label={t('chat.queuedMessage.removeAria')} aria-label={t('chat.queuedMessage.removeAria')}
> >
@@ -111,13 +111,16 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => { export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
const { t } = useI18n(); const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
const queueKey = target ? getMessageQueueKey(target) : null;
const queuedMessages = useMessageQueueStore( const queuedMessages = useMessageQueueStore(
React.useCallback( React.useCallback(
(state) => { (state) => {
if (!currentSessionId) return EMPTY_QUEUE; if (!queueKey) return EMPTY_QUEUE;
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE; return state.queuedMessages[queueKey] ?? EMPTY_QUEUE;
}, },
[currentSessionId] [queueKey]
) )
); );
const popToInput = useMessageQueueStore((state) => state.popToInput); const popToInput = useMessageQueueStore((state) => state.popToInput);
@@ -132,14 +135,14 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
const handleDragEnd = React.useCallback((event: DragEndEvent) => { const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id || !currentSessionId) return; if (!over || active.id === over.id || !target) return;
reorderQueue(currentSessionId, String(active.id), String(over.id)); reorderQueue(target, String(active.id), String(over.id));
}, [currentSessionId, reorderQueue]); }, [target, reorderQueue]);
const handleEdit = React.useCallback((message: QueuedMessage) => { const handleEdit = React.useCallback((message: QueuedMessage) => {
if (!currentSessionId) return; if (!target) return;
const popped = popToInput(currentSessionId, message.id); const popped = popToInput(target, message.id);
if (popped) { if (popped) {
if (popped.attachments && popped.attachments.length > 0) { if (popped.attachments && popped.attachments.length > 0) {
const currentAttachments = useInputStore.getState().attachedFiles; const currentAttachments = useInputStore.getState().attachedFiles;
@@ -147,13 +150,13 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
} }
onEditMessage(popped.content, popped.attachments); onEditMessage(popped.content, popped.attachments);
} }
}, [currentSessionId, popToInput, onEditMessage]); }, [target, popToInput, onEditMessage]);
const handleSend = React.useCallback((message: QueuedMessage) => { const handleSend = React.useCallback((message: QueuedMessage) => {
onSendMessage(message.id); onSendMessage(message.id);
}, [onSendMessage]); }, [onSendMessage]);
if (queuedMessages.length === 0 || !currentSessionId) { if (queuedMessages.length === 0 || !target) {
return null; return null;
} }
@@ -180,7 +183,7 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
<QueuedMessageChip <QueuedMessageChip
key={message.id} key={message.id}
message={message} message={message}
sessionId={currentSessionId} target={target}
onEdit={handleEdit} onEdit={handleEdit}
onSend={handleSend} onSend={handleSend}
/> />
+10 -2
View File
@@ -159,6 +159,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync( const liveTodos = useDirectorySync(
React.useCallback( React.useCallback(
(state) => { (state) => {
@@ -170,8 +176,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
); );
const persistedSessionTodos = useTodosPersistStore( const persistedSessionTodos = useTodosPersistStore(
React.useCallback( React.useCallback(
(state) => (showTodos && currentSessionId ? state.sessions[currentSessionId]?.todos : undefined), (state) => (showTodos && currentSessionId && currentSessionDirectory
[currentSessionId, showTodos], ? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
), ),
); );
const todos: TodoItem[] = React.useMemo(() => { const todos: TodoItem[] = React.useMemo(() => {
@@ -53,13 +53,14 @@ export const useTurnRecords = (
const projection = React.useMemo(() => { const projection = React.useMemo(() => {
const sessionKey = options.sessionKey ?? ''; const sessionKey = options.sessionKey ?? '';
const mergeKey = options.planModeEnabled ? 'merge:plan' : 'merge'; const mergeKey = options.planModeEnabled ? 'merge:plan' : 'merge';
const cached = getCachedProjection( const cacheKey = buildProjectionCacheKey(
sessionKey, sessionKey,
messages, messages,
options.showTextJustificationActivity, options.showTextJustificationActivity,
options.showTurnChangedFiles, options.showTurnChangedFiles,
mergeKey, mergeKey,
); );
const cached = getCachedProjection(cacheKey);
if (cached) { if (cached) {
previousProjectionRef.current = cached; previousProjectionRef.current = cached;
return cached; return cached;
@@ -74,13 +75,6 @@ export const useTurnRecords = (
}); });
previousProjectionRef.current = nextProjection; previousProjectionRef.current = nextProjection;
const cacheKey = buildProjectionCacheKey(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
mergeKey,
);
setCachedProjection(cacheKey, nextProjection); setCachedProjection(cacheKey, nextProjection);
return nextProjection; return nextProjection;
@@ -56,14 +56,7 @@ export const buildProjectionCacheKey = (
].join('|'); ].join('|');
}; };
export const getCachedProjection = ( export const getCachedProjection = (key: string): TurnProjectionResult | undefined => {
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
mergeHiddenUserTurnsKey: string,
): TurnProjectionResult | undefined => {
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles, mergeHiddenUserTurnsKey);
const cached = projectionCache.get(key); const cached = projectionCache.get(key);
if (cached) { if (cached) {
// LRU re-order: move hit to the end (most recent) so it survives // LRU re-order: move hit to the end (most recent) so it survives
@@ -623,7 +623,7 @@ const StaticToolRowInner: React.FC<{
return entries; return entries;
}, [activities, currentDirectory, isReadGroup]); }, [activities, currentDirectory, isReadGroup]);
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => { const handleFileClick = React.useCallback((filePath: string, offset?: number) => {
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
if (!absolutePath) { if (!absolutePath) {
return; return;
@@ -656,14 +656,6 @@ const StaticToolRowInner: React.FC<{
uiStore.openContextFile(contextDirectory, absolutePath); uiStore.openContextFile(contextDirectory, absolutePath);
}, [currentDirectory, runtime]); }, [currentDirectory, runtime]);
const handleSkillClick = React.useCallback((skillPath: string) => {
if (!skillPath) {
return;
}
const uiStore = useUIStore.getState();
uiStore.openContextFile(currentDirectory || getDirectoryForFilePath('', skillPath), skillPath);
}, [currentDirectory]);
const normalizedToolName = toolName.toLowerCase(); const normalizedToolName = toolName.toLowerCase();
const isSearchGroup = normalizedToolName === 'grep' const isSearchGroup = normalizedToolName === 'grep'
|| normalizedToolName === 'search' || normalizedToolName === 'search'
@@ -699,7 +691,7 @@ const StaticToolRowInner: React.FC<{
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleReadFileClick(entry.path, entry.offset); handleFileClick(entry.path, entry.offset);
}} }}
className={cn('inline-flex !min-h-0 items-center justify-start gap-1 min-w-0 flex-1 text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)} className={cn('inline-flex !min-h-0 items-center justify-start gap-1 min-w-0 flex-1 text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)}
style={{ color: 'var(--tools-description)' }} style={{ color: 'var(--tools-description)' }}
@@ -751,7 +743,7 @@ const StaticToolRowInner: React.FC<{
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleSkillClick(entry.path); handleFileClick(entry.path);
}} }}
className={cn('!min-h-0 min-w-0 flex-1 truncate whitespace-nowrap text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)} className={cn('!min-h-0 min-w-0 flex-1 truncate whitespace-nowrap text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)}
style={{ color: 'var(--tools-description)' }} style={{ color: 'var(--tools-description)' }}
@@ -1,8 +1,16 @@
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore'; import {
EMPTY_INLINE_COMMENT_DRAFTS,
getInlineCommentDraftKey,
useInlineCommentDraftStore,
type InlineCommentDraft,
type InlineCommentSource,
} from '@/stores/useInlineCommentDraftStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getRuntimeKey } from '@/lib/runtime-switch';
type LineRangeBase = { type LineRangeBase = {
start: number; start: number;
@@ -52,25 +60,42 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const effectiveDirectory = useEffectiveDirectory();
const sessionDirectory = useSessionUIStore(
React.useCallback(
(state) => currentSessionId ? state.getDirectoryForSession(currentSessionId) : null,
[currentSessionId],
),
);
const addDraft = useInlineCommentDraftStore((state) => state.addDraft); const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft); const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft); const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
const [selection, setSelection] = React.useState<TRange | null>(null); const [selection, setSelection] = React.useState<TRange | null>(null);
const [commentText, setCommentText] = React.useState(''); const [commentText, setCommentText] = React.useState('');
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null); const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const sessionKey = React.useMemo(() => { const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); const draftDirectory = sessionDirectory ?? effectiveDirectory;
}, [currentSessionId, newSessionDraftOpen]); const target = React.useMemo(() => {
if (!sessionKey || !draftDirectory) return null;
return { directory: draftDirectory, sessionKey };
}, [draftDirectory, sessionKey]);
const targetKey = target
? getInlineCommentDraftKey(getRuntimeKey(), target.directory, target.sessionKey)
: null;
const sessionDrafts = useInlineCommentDraftStore(
React.useCallback(
(state) => targetKey ? state.drafts[targetKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS,
[targetKey],
),
);
const drafts = React.useMemo(() => { const drafts = React.useMemo(() => {
if (!sessionKey || !fileLabel) return []; if (!target || !fileLabel) return [];
const sessionDrafts = allDrafts[sessionKey] ?? [];
return sessionDrafts.filter((draft) => draft.source === source && draft.fileLabel === fileLabel); return sessionDrafts.filter((draft) => draft.source === source && draft.fileLabel === fileLabel);
}, [allDrafts, fileLabel, sessionKey, source]); }, [fileLabel, sessionDrafts, source, target]);
const reset = React.useCallback(() => { const reset = React.useCallback(() => {
setSelection(null); setSelection(null);
@@ -90,18 +115,19 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
}, [fromDraftRange]); }, [fromDraftRange]);
const deleteDraft = React.useCallback((draft: InlineCommentDraft) => { const deleteDraft = React.useCallback((draft: InlineCommentDraft) => {
removeDraft(draft.sessionKey, draft.id); if (!target) return;
removeDraft(target, draft.id);
if (editingDraftId === draft.id) { if (editingDraftId === draft.id) {
reset(); reset();
} }
}, [editingDraftId, removeDraft, reset]); }, [editingDraftId, removeDraft, reset, target]);
const saveComment = React.useCallback((textToSave: string, rangeOverride?: TRange) => { const saveComment = React.useCallback((textToSave: string, rangeOverride?: TRange) => {
const targetRange = rangeOverride ?? selection; const targetRange = rangeOverride ?? selection;
const trimmedText = textToSave.trim(); const trimmedText = textToSave.trim();
if (!targetRange || !trimmedText || !fileLabel) return; if (!targetRange || !trimmedText || !fileLabel) return;
if (!sessionKey) { if (!target) {
toast.error(t('inlineComment.toast.selectSessionToSave')); toast.error(t('inlineComment.toast.selectSessionToSave'));
return; return;
} }
@@ -111,7 +137,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
const code = getCodeForRange(normalizedRange); const code = getCodeForRange(normalizedRange);
if (editingDraftId) { if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, { updateDraft(target, editingDraftId, {
fileLabel, fileLabel,
startLine: normalizedStoreRange.startLine, startLine: normalizedStoreRange.startLine,
endLine: normalizedStoreRange.endLine, endLine: normalizedStoreRange.endLine,
@@ -121,8 +147,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
text: trimmedText, text: trimmedText,
}); });
} else { } else {
addDraft({ addDraft(target, {
sessionKey,
source, source,
fileLabel, fileLabel,
startLine: normalizedStoreRange.startLine, startLine: normalizedStoreRange.startLine,
@@ -135,7 +160,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
} }
reset(); reset();
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, t, toStoreRange, updateDraft]); }, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, source, t, target, toStoreRange, updateDraft]);
return { return {
sessionKey, sessionKey,
@@ -542,6 +542,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null); const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const effectiveDirectory = useEffectiveDirectory();
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft); const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
const addAttachedFile = useInputStore((state) => state.addAttachedFile); const addAttachedFile = useInputStore((state) => state.addAttachedFile);
@@ -688,7 +689,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => { const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) { if (!sessionKey || !effectiveDirectory) {
toast.error(t('contextPanel.preview.inspect.attachNoSession')); toast.error(t('contextPanel.preview.inspect.attachNoSession'));
return; return;
} }
@@ -712,8 +713,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
attachedScreenshot = false; attachedScreenshot = false;
} }
addInlineCommentDraft({ addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
sessionKey,
source: 'preview-annotation', source: 'preview-annotation',
fileLabel: pageUrl || 'preview', fileLabel: pageUrl || 'preview',
startLine: 1, startLine: 1,
@@ -731,7 +731,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
}); });
toast.success(t('contextPanel.preview.inspect.attached')); toast.success(t('contextPanel.preview.inspect.attached'));
})(); })();
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]); }, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
React.useEffect(() => { React.useEffect(() => {
setBridgeReady(false); setBridgeReady(false);
@@ -921,7 +921,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const attachConsoleEvents = React.useCallback(() => { const attachConsoleEvents = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) { if (!sessionKey || !effectiveDirectory) {
toast.error(t('contextPanel.preview.console.attachNoSession')); toast.error(t('contextPanel.preview.console.attachNoSession'));
return; return;
} }
@@ -937,8 +937,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
return `[${timestamp}] [${event.level}] ${event.message}${details}`; return `[${timestamp}] [${event.level}] ${event.message}${details}`;
}).join('\n'); }).join('\n');
addInlineCommentDraft({ addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
sessionKey,
source: 'preview-console', source: 'preview-console',
fileLabel: rawUrl || effectiveSrc || 'preview', fileLabel: rawUrl || effectiveSrc || 'preview',
startLine: 1, startLine: 1,
@@ -948,7 +947,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
text: t('contextPanel.preview.console.attachAnnotation'), text: t('contextPanel.preview.console.attachAnnotation'),
}); });
toast.success(t('contextPanel.preview.console.attached')); toast.success(t('contextPanel.preview.console.attached'));
}, [addInlineCommentDraft, consoleEvents, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]); }, [addInlineCommentDraft, consoleEvents, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
// Out-of-band upstream probe: iframes don't expose HTTP status to the parent, // Out-of-band upstream probe: iframes don't expose HTTP status to the parent,
// so when the proxy returns a 502 (upstream dev server is offline) the iframe // so when the proxy returns a 502 (upstream dev server is offline) the iframe
@@ -1590,8 +1589,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
await addAttachedFile(file); await addAttachedFile(file);
} }
addInlineCommentDraft({ addInlineCommentDraft({ directory, sessionKey }, {
sessionKey,
source: 'preview-annotation', source: 'preview-annotation',
fileLabel: currentUrl || 'browser', fileLabel: currentUrl || 'browser',
startLine: 1, startLine: 1,
@@ -1610,7 +1608,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
text: '', text: '',
}); });
toast.success(t('contextPanel.preview.inspect.attached')); toast.success(t('contextPanel.preview.inspect.attached'));
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, newSessionDraftOpen, t]); }, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, newSessionDraftOpen, t]);
const cancelInspect = React.useCallback(() => { const cancelInspect = React.useCallback(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
@@ -1998,8 +1996,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
await addAttachedFile(file); await addAttachedFile(file);
} }
addInlineCommentDraft({ addInlineCommentDraft({ directory, sessionKey }, {
sessionKey,
source: 'preview-annotation', source: 'preview-annotation',
fileLabel: currentUrl || 'browser', fileLabel: currentUrl || 'browser',
startLine: 1, startLine: 1,
@@ -2018,7 +2015,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
toast.success(t('contextPanel.preview.inspect.attached')); toast.success(t('contextPanel.preview.inspect.attached'));
}) })
.catch(() => setIsInspecting(false)); .catch(() => setIsInspecting(false));
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, isInspecting, newSessionDraftOpen, t]); }, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, isInspecting, newSessionDraftOpen, t]);
return ( return (
<div className="absolute inset-0 flex flex-col bg-background"> <div className="absolute inset-0 flex flex-col bg-background">
+57 -68
View File
@@ -21,12 +21,12 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract'; import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
import { useAllLiveSessions, useSession, useSessionMessagesResolved } from '@/sync/sync-context'; import { useSessionMessagesResolved } from '@/sync/sync-context';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useGitBranchLabel } from '@/stores/useGitStore'; import { useGitBranchLabel } from '@/stores/useGitStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
@@ -74,7 +74,7 @@ import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth'; import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import type { Session } from '@opencode-ai/sdk/v2/client'; import { useShallow } from 'zustand/react/shallow';
import type { IconName } from "@/components/icon/icons"; import type { IconName } from "@/components/icon/icons";
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
@@ -703,12 +703,20 @@ interface HeaderProps {
rightDrawerOpen?: boolean; rightDrawerOpen?: boolean;
} }
type HeaderSessionSnapshot = {
title: string | null;
directory: string | null;
created: number | null;
slug: string | null;
};
export const Header: React.FC<HeaderProps> = ({ export const Header: React.FC<HeaderProps> = ({
onToggleLeftDrawer, onToggleLeftDrawer,
onToggleRightDrawer, onToggleRightDrawer,
leftDrawerOpen, leftDrawerOpen,
rightDrawerOpen, rightDrawerOpen,
}) => { }) => {
streamPerfCount('ui.header.render');
const { t } = useI18n(); const { t } = useI18n();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleSidebar = useUIStore((state) => state.toggleSidebar);
@@ -720,7 +728,6 @@ export const Header: React.FC<HeaderProps> = ({
const openContextBrowser = useUIStore((state) => state.openContextBrowser); const openContextBrowser = useUIStore((state) => state.openContextBrowser);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const closeContextPanel = useUIStore((state) => state.closeContextPanel); const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory);
const activeMainTab = useUIStore((state) => state.activeMainTab); const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
@@ -734,15 +741,28 @@ export const Header: React.FC<HeaderProps> = ({
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? ''); const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
const currentSyncedSession = useSession(currentSessionId ?? null); const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback(
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); (state): HeaderSessionSnapshot | null => {
const liveSessions = useAllLiveSessions(); if (!currentSessionId) return null;
const activeProject = useProjectsStore((state) => { const session = state.activeSessions.find((candidate) => candidate.id === currentSessionId);
if (!session) return null;
const record = session as typeof session & { directory?: string | null; slug?: string | null };
return {
title: session.title ?? null,
directory: record.directory ?? null,
created: session.time?.created ?? null,
slug: record.slug ?? null,
};
},
[currentSessionId],
)));
const activeProject = useProjectsStore(useShallow((state) => {
if (!state.activeProjectId) { if (!state.activeProjectId) {
return null; return null;
} }
return state.projects.find((project) => project.id === state.activeProjectId) ?? null; const project = state.projects.find((candidate) => candidate.id === state.activeProjectId);
}); return project ? { id: project.id, path: project.path, label: project.label } : null;
}));
const activeProjectLabel = React.useMemo(() => { const activeProjectLabel = React.useMemo(() => {
if (!activeProject) { if (!activeProject) {
return null; return null;
@@ -1130,18 +1150,13 @@ export const Header: React.FC<HeaderProps> = ({
}); });
}, [fetchAllQuotas, isUsageRefreshSpinning]); }, [fetchAllQuotas, isUsageRefreshSpinning]);
const currentSessionLive = React.useMemo(() => { const currentSessionSnapshot = currentSessionId
if (!currentSessionId) return null; ? currentGlobalSession ?? null
return liveSessions.find((s) => s.id === currentSessionId) : null;
?? globalActiveSessions.find((s) => s.id === currentSessionId)
?? currentSyncedSession
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
?? null;
}, [currentSessionId, currentSyncedSession, globalActiveSessions, liveSessions]);
const lastResolvedSessionRef = React.useRef<{ const lastResolvedSessionRef = React.useRef<{
sessionId: string; sessionId: string;
session: Session; session: HeaderSessionSnapshot;
expiresAt: number; expiresAt: number;
} | null>(null); } | null>(null);
const [sessionFallbackVersion, setSessionFallbackVersion] = React.useState(0); const [sessionFallbackVersion, setSessionFallbackVersion] = React.useState(0);
@@ -1155,10 +1170,10 @@ export const Header: React.FC<HeaderProps> = ({
return; return;
} }
if (currentSessionLive) { if (currentSessionSnapshot) {
lastResolvedSessionRef.current = { lastResolvedSessionRef.current = {
sessionId: currentSessionId, sessionId: currentSessionId,
session: currentSessionLive, session: currentSessionSnapshot,
expiresAt: Date.now() + 2000, expiresAt: Date.now() + 2000,
}; };
return; return;
@@ -1186,12 +1201,12 @@ export const Header: React.FC<HeaderProps> = ({
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [currentSessionId, currentSessionLive]); }, [currentSessionId, currentSessionSnapshot]);
void sessionFallbackVersion; void sessionFallbackVersion;
const currentSession = (() => { const currentSession = (() => {
if (currentSessionLive) { if (currentSessionSnapshot) {
return currentSessionLive; return currentSessionSnapshot;
} }
if (!currentSessionId) { if (!currentSessionId) {
@@ -1256,6 +1271,10 @@ export const Header: React.FC<HeaderProps> = ({
const openDirectory = React.useMemo(() => { const openDirectory = React.useMemo(() => {
return worktreeDirectory || sessionDirectory || draftDirectory; return worktreeDirectory || sessionDirectory || draftDirectory;
}, [draftDirectory, sessionDirectory, worktreeDirectory]); }, [draftDirectory, sessionDirectory, worktreeDirectory]);
const activeContextMode = useUIStore(React.useCallback((state) => {
const directory = normalize(openDirectory || '');
return directory ? getActiveContextMode(state.contextPanelByDirectory[directory]) : null;
}, [openDirectory]));
const catalogWorktreeBranch = useSessionUIStore((state) => { const catalogWorktreeBranch = useSessionUIStore((state) => {
const candidateDirectory = normalize(worktreeDirectory || sessionDirectory || ''); const candidateDirectory = normalize(worktreeDirectory || sessionDirectory || '');
@@ -1336,7 +1355,7 @@ export const Header: React.FC<HeaderProps> = ({
if (!currentSessionId) return; if (!currentSessionId) return;
const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.time?.created || 0}:${currentSession?.slug || 'none'}`; const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.created || 0}:${currentSession?.slug || 'none'}`;
if (lastPlanSessionKeyRef.current !== sessionKey) { if (lastPlanSessionKeyRef.current !== sessionKey) {
lastPlanSessionKeyRef.current = sessionKey; lastPlanSessionKeyRef.current = sessionKey;
} }
@@ -1349,7 +1368,7 @@ export const Header: React.FC<HeaderProps> = ({
planModeEnabled, planModeEnabled,
planTabAvailable, planTabAvailable,
currentSession?.slug, currentSession?.slug,
currentSession?.time?.created, currentSession?.created,
currentSessionId, currentSessionId,
sessionDirectory, sessionDirectory,
]); ]);
@@ -1449,23 +1468,16 @@ export const Header: React.FC<HeaderProps> = ({
return; return;
} }
const panelState = contextPanelByDirectory[directory]; const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'context') { if (getActiveContextMode(panelState) === 'context') {
closeContextPanel(directory); closeContextPanel(directory);
return; return;
} }
openContextOverview(directory); openContextOverview(directory);
}, [closeContextPanel, contextPanelByDirectory, openContextOverview, openDirectory]); }, [closeContextPanel, openContextOverview, openDirectory]);
const isContextPanelActive = React.useMemo(() => { const isContextPanelActive = activeContextMode === 'context';
const directory = normalize(openDirectory || '');
if (!directory) {
return false;
}
const panelState = contextPanelByDirectory[directory];
return getActiveContextMode(panelState) === 'context';
}, [contextPanelByDirectory, openDirectory]);
const handleOpenContextPlan = React.useCallback(() => { const handleOpenContextPlan = React.useCallback(() => {
const directory = normalize(openDirectory || ''); const directory = normalize(openDirectory || '');
@@ -1473,14 +1485,14 @@ export const Header: React.FC<HeaderProps> = ({
return; return;
} }
const panelState = contextPanelByDirectory[directory]; const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'plan') { if (getActiveContextMode(panelState) === 'plan') {
closeContextPanel(directory); closeContextPanel(directory);
return; return;
} }
openContextPlan(directory); openContextPlan(directory);
}, [closeContextPanel, contextPanelByDirectory, openContextPlan, openDirectory]); }, [closeContextPanel, openContextPlan, openDirectory]);
const handleOpenContextChanges = React.useCallback(() => { const handleOpenContextChanges = React.useCallback(() => {
const directory = normalize(openDirectory || ''); const directory = normalize(openDirectory || '');
@@ -1488,14 +1500,14 @@ export const Header: React.FC<HeaderProps> = ({
return; return;
} }
const panelState = contextPanelByDirectory[directory]; const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'diff') { if (getActiveContextMode(panelState) === 'diff') {
closeContextPanel(directory); closeContextPanel(directory);
return; return;
} }
openContextPanelTab(directory, { mode: 'diff', stagedDiff: false }); openContextPanelTab(directory, { mode: 'diff', stagedDiff: false });
}, [closeContextPanel, contextPanelByDirectory, openContextPanelTab, openDirectory]); }, [closeContextPanel, openContextPanelTab, openDirectory]);
const handleOpenContextBrowser = React.useCallback(() => { const handleOpenContextBrowser = React.useCallback(() => {
const directory = normalize(openDirectory || ''); const directory = normalize(openDirectory || '');
@@ -1503,41 +1515,18 @@ export const Header: React.FC<HeaderProps> = ({
return; return;
} }
const panelState = contextPanelByDirectory[directory]; const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'browser') { if (getActiveContextMode(panelState) === 'browser') {
closeContextPanel(directory); closeContextPanel(directory);
return; return;
} }
openContextBrowser(directory); openContextBrowser(directory);
}, [closeContextPanel, contextPanelByDirectory, openContextBrowser, openDirectory]); }, [closeContextPanel, openContextBrowser, openDirectory]);
const isContextPlanActive = React.useMemo(() => { const isContextPlanActive = activeContextMode === 'plan';
const directory = normalize(openDirectory || ''); const isContextChangesActive = activeContextMode === 'diff';
if (!directory) { const isContextBrowserActive = activeContextMode === 'browser';
return false;
}
const panelState = contextPanelByDirectory[directory];
return getActiveContextMode(panelState) === 'plan';
}, [contextPanelByDirectory, openDirectory]);
const isContextChangesActive = React.useMemo(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return false;
}
const panelState = contextPanelByDirectory[directory];
return getActiveContextMode(panelState) === 'diff';
}, [contextPanelByDirectory, openDirectory]);
const isContextBrowserActive = React.useMemo(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return false;
}
const panelState = contextPanelByDirectory[directory];
return getActiveContextMode(panelState) === 'browser';
}, [contextPanelByDirectory, openDirectory]);
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS; const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS;
@@ -72,7 +72,6 @@ export const MainLayout: React.FC = () => {
setMobileLeftDrawerOpen(open); setMobileLeftDrawerOpen(open);
useUIStore.getState().setSessionSwitcherOpen(open); useUIStore.getState().setSessionSwitcherOpen(open);
}, []); }, []);
const mobileRightDrawerOpenRef = React.useRef(false);
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth); const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
// Left drawer motion value // Left drawer motion value
@@ -114,7 +113,6 @@ export const MainLayout: React.FC = () => {
setMobileRightDrawerVisible(false); setMobileRightDrawerVisible(false);
return; return;
} }
mobileRightDrawerOpenRef.current = mobileRightSidebarOpen;
if (mobileRightSidebarOpen) { if (mobileRightSidebarOpen) {
setMobileRightDrawerVisible(true); setMobileRightDrawerVisible(true);
} }
@@ -441,7 +439,7 @@ export const MainLayout: React.FC = () => {
> >
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true"> <main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}> <div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary> <ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
</div> </div>
{secondaryView && ( {secondaryView && (
<div className="absolute inset-0"> <div className="absolute inset-0">
@@ -480,7 +478,7 @@ export const MainLayout: React.FC = () => {
aria-hidden={!mobileLeftDrawerOpen} aria-hidden={!mobileLeftDrawerOpen}
> >
<ErrorBoundary> <ErrorBoundary>
<SessionSidebar mobileVariant /> <SessionSidebar mobileVariant isVisible={mobileLeftDrawerVisible} />
</ErrorBoundary> </ErrorBoundary>
</motion.div> </motion.div>
{mobileRightDrawerVisible && ( {mobileRightDrawerVisible && (
@@ -520,7 +518,7 @@ export const MainLayout: React.FC = () => {
className="border-border/50" className="border-border/50"
topBar={<SidebarTopBar />} topBar={<SidebarTopBar />}
> >
<SessionSidebar /> <SessionSidebar isVisible={isSidebarOpen} />
</Sidebar> </Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true"> <div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header /> <Header />
@@ -530,7 +528,7 @@ export const MainLayout: React.FC = () => {
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true"> <div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true"> <main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}> <div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary> <ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
</div> </div>
{secondaryView && ( {secondaryView && (
<div className="absolute inset-0"> <div className="absolute inset-0">
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { import {
@@ -121,20 +122,23 @@ type FileTreeCache = {
}; };
const FILE_TREE_CACHE_MAX_ROOTS = 8; const FILE_TREE_CACHE_MAX_ROOTS = 8;
const fileTreeCacheByRoot = new Map<string, FileTreeCache>(); const fileTreeCacheByRoot = new Map<string, FileTreeCache>();
const fileTreeCacheKey = (root: string): string => JSON.stringify([getRuntimeKey(), root]);
const touchCache = (root: string): FileTreeCache | null => { const touchCache = (root: string): FileTreeCache | null => {
const entry = fileTreeCacheByRoot.get(root); const key = fileTreeCacheKey(root);
const entry = fileTreeCacheByRoot.get(key);
if (!entry) return null; if (!entry) return null;
entry.touchedAt = Date.now(); entry.touchedAt = Date.now();
// Touch on read promotes the key to the end of the Map's iteration order, // Touch on read promotes the key to the end of the Map's iteration order,
// so the oldest (front) entry is the next eviction candidate. // so the oldest (front) entry is the next eviction candidate.
fileTreeCacheByRoot.delete(root); fileTreeCacheByRoot.delete(key);
fileTreeCacheByRoot.set(root, entry); fileTreeCacheByRoot.set(key, entry);
return entry; return entry;
}; };
const getOrCreateCache = (root: string): FileTreeCache => { const getOrCreateCache = (root: string): FileTreeCache => {
const existing = fileTreeCacheByRoot.get(root); const key = fileTreeCacheKey(root);
const existing = fileTreeCacheByRoot.get(key);
if (existing) { if (existing) {
existing.touchedAt = Date.now(); existing.touchedAt = Date.now();
return existing; return existing;
@@ -151,12 +155,12 @@ const getOrCreateCache = (root: string): FileTreeCache => {
loadedDirs: new Set(), loadedDirs: new Set(),
touchedAt: Date.now(), touchedAt: Date.now(),
}; };
fileTreeCacheByRoot.set(root, created); fileTreeCacheByRoot.set(key, created);
return created; return created;
}; };
const dropCacheForRoot = (root: string): void => { const dropCacheForRoot = (root: string): void => {
fileTreeCacheByRoot.delete(root); fileTreeCacheByRoot.delete(fileTreeCacheKey(root));
}; };
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => { const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
@@ -251,6 +251,10 @@ export const VSCodeLayout: React.FC = () => {
setCurrentView('sessions'); setCurrentView('sessions');
}, []); }, []);
const handleSessionSelected = React.useCallback(() => {
setCurrentView('chat');
}, []);
const isSessionInActiveWorkspace = React.useCallback((session: Session): boolean => { const isSessionInActiveWorkspace = React.useCallback((session: Session): boolean => {
if (!activeWorkspacePath) { if (!activeWorkspacePath) {
return false; return false;
@@ -590,7 +594,7 @@ export const VSCodeLayout: React.FC = () => {
/> />
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ErrorBoundary> <ErrorBoundary>
<ChatView /> <ChatView active={currentView === 'chat'} />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div> </div>
@@ -609,7 +613,7 @@ export const VSCodeLayout: React.FC = () => {
<SessionSidebar <SessionSidebar
mobileVariant mobileVariant
allowReselect allowReselect
onSessionSelected={() => setCurrentView('chat')} onSessionSelected={handleSessionSelected}
hideDirectoryControls hideDirectoryControls
/> />
</div> </div>
@@ -628,7 +632,7 @@ export const VSCodeLayout: React.FC = () => {
/> />
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ErrorBoundary> <ErrorBoundary>
<ChatView /> <ChatView active={currentView === 'chat'} />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div> </div>
@@ -8,6 +8,10 @@ const mainLayoutSource = readFileSync(
join(__dirname, '..', 'MainLayout.tsx'), join(__dirname, '..', 'MainLayout.tsx'),
'utf-8', 'utf-8',
); );
const sessionSidebarSource = readFileSync(
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
'utf-8',
);
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => { describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => { test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
@@ -20,10 +24,11 @@ describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)'
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false); expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
expect(precedingWindow.includes('pointer-events-none')).toBe(true); expect(precedingWindow.includes('pointer-events-none')).toBe(true);
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
}); });
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => { test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar />'); const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
expect(desktopSidebarIndex).toBeGreaterThan(-1); expect(desktopSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, desktopSidebarIndex - 300); const windowStart = Math.max(0, desktopSidebarIndex - 300);
@@ -32,4 +37,12 @@ describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)'
expect(precedingWindow).toContain('<Sidebar'); expect(precedingWindow).toContain('<Sidebar');
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false); expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
}); });
test('hidden sidebars disable render-only subscriptions and effects', () => {
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
});
}); });
@@ -7,10 +7,11 @@ import { isDesktopShell } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils'; import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllLiveSessions } from '@/sync/sync-context'; import { useChildStoreManager } from '@/sync/sync-context';
import { getAllSyncSessionMap } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSync } from '@/sync/use-sync'; import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { SessionPrefetchEffect } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
@@ -22,7 +23,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders'; import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections'; import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSelection'; import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering'; import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping'; import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects'; import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
@@ -30,7 +31,7 @@ import { useSessionActions } from './sidebar/hooks/useSessionActions';
import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence'; import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus'; import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists'; import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup'; import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership'; import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders'; import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
@@ -69,30 +70,32 @@ import {
compareSessionsByPinnedAndTime, compareSessionsByPinnedAndTime,
formatProjectLabel, formatProjectLabel,
normalizePath, normalizePath,
selectExpandedParentKeysForContext,
toggleExpandedParentKey,
} from './sidebar/utils'; } from './sidebar/utils';
import { import {
mergeLiveSessionWithGlobalSession,
refreshGlobalSessions, refreshGlobalSessions,
refreshGlobalSessionsForDirectories, refreshGlobalSessionsForDirectories,
getSessionStructuralSignature,
resolveGlobalSessionDirectory, resolveGlobalSessionDirectory,
useGlobalSessionsStore, useGlobalSessionsStore,
} from '@/stores/useGlobalSessionsStore'; } from '@/stores/useGlobalSessionsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
// v2 key holds composite "${renderContext}:${active|archived}:${sessionId}" // v3 holds composite "${renderContext}:${active|archived}:${sessionId}"
// entries so the same session in different render contexts (e.g. "Recent" // entries so the same session in different render contexts (e.g. "Recent"
// and a project's root) has independent expand state. v1 held bare session // and a project's root) has independent expand state. Older expansion state
// ids; useSidebarPersistence migrates v1 data on first read by fanning each // mixed contexts and is intentionally not migrated.
// id into all four context combinations. const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v2';
const LEGACY_SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed'; type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
@@ -157,6 +160,7 @@ const isKnownActiveSessionDirectory = (
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000; const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
const EMPTY_SUBTREE_SET: Set<string> = new Set(); const EMPTY_SUBTREE_SET: Set<string> = new Set();
const EMPTY_STRING_ARRAY: string[] = [];
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => { const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
const handlerRef = React.useRef(handler); const handlerRef = React.useRef(handler);
@@ -165,6 +169,7 @@ const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...ar
}; };
interface SessionSidebarProps { interface SessionSidebarProps {
isVisible?: boolean;
mobileVariant?: boolean; mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void; onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean; allowReselect?: boolean;
@@ -172,13 +177,65 @@ interface SessionSidebarProps {
showOnlyMainWorkspace?: boolean; showOnlyMainWorkspace?: boolean;
} }
export const SessionSidebar: React.FC<SessionSidebarProps> = ({ const SidebarBootstrapDemandEffect: React.FC<{
owner: string;
childStores: ReturnType<typeof useChildStoreManager>;
projectSections: Parameters<typeof buildSessionBootstrapDemands>[0]['projectSections'];
activeProjectId: string | null;
collapsedProjects: ReadonlySet<string>;
collapsedGroups: ReadonlySet<string>;
currentDirectory: string | null;
}> = ({
owner,
childStores,
projectSections,
activeProjectId,
collapsedProjects,
collapsedGroups,
currentDirectory,
}) => {
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
React.useEffect(() => {
childStores.setBootstrapDemand(owner, buildSessionBootstrapDemands({
projectSections,
activeProjectId,
collapsedProjects,
collapsedGroups,
currentDirectory,
currentSessionDirectory,
}));
}, [
activeProjectId,
childStores,
collapsedGroups,
collapsedProjects,
currentDirectory,
currentSessionDirectory,
owner,
projectSections,
]);
React.useEffect(
() => () => childStores.clearBootstrapDemand(owner),
[childStores, owner],
);
return null;
};
const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
isVisible = true,
mobileVariant = false, mobileVariant = false,
onSessionSelected, onSessionSelected,
allowReselect = false, allowReselect = false,
hideDirectoryControls = false, hideDirectoryControls = false,
showOnlyMainWorkspace = false, showOnlyMainWorkspace = false,
}) => { }) => {
streamPerfMark('react.session_sidebar_render');
streamPerfCount('ui.session_sidebar.render');
streamPerfCount(`ui.session_sidebar.render.${mobileVariant ? 'mobile' : 'desktop'}`);
streamPerfCount(`ui.session_sidebar.render.${isVisible ? 'visible' : 'hidden'}`);
const { t } = useI18n(); const { t } = useI18n();
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false); const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = React.useState(''); const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
@@ -204,7 +261,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null); const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null); const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds);
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => { const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try { try {
@@ -236,7 +292,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return new Map(); return new Map();
} }
}); });
const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => { const initialActiveSessionByProject = React.useMemo<Map<string, string>>(() => {
try { try {
const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) { if (!raw) {
@@ -253,7 +309,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
} catch { } catch {
return new Map(); return new Map();
} }
}); }, []);
const persistActiveSessionByProject = React.useCallback((value: Map<string, string>) => {
try {
safeStorage.setItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(Object.fromEntries(value.entries())));
} catch { /* ignored */ }
}, [safeStorage]);
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map()); const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map()); const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
@@ -261,7 +322,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId); const activeProjectId = useProjectsStore((state) => state.activeProjectId);
@@ -302,26 +362,51 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder); const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders); const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse); const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
useSessionSearchEffects({ useSessionSearchEffects({
enabled: isVisible,
isSessionSearchOpen, isSessionSearchOpen,
setIsSessionSearchOpen, setIsSessionSearchOpen,
sessionSearchInputRef, sessionSearchInputRef,
sessionSearchContainerRef, sessionSearchContainerRef,
}); });
const gitBranches = useGitAllBranches(); const gitBranches = useGitAllBranches(isVisible);
const sync = useSync(); const sync = useSync();
const liveSessions = useAllLiveSessions(); const childStores = useChildStoreManager();
const bootstrapDemandOwner = `session-sidebar:${React.useId()}`;
const liveSessionIndex = getAllSyncSessionMap();
const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const activeSessionStructure = useGlobalSessionsStore(useShallow(
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); (state) => state.activeSessions.map(getSessionStructuralSignature).sort(),
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); ));
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const archivedSessionStructure = useGlobalSessionsStore(useShallow(
(state) => state.archivedSessions.map(getSessionStructuralSignature).sort(),
));
const globalSessionSnapshot = useGlobalSessionsStore.getState();
const globalActiveSessions = globalSessionSnapshot.activeSessions;
const archivedSessions = globalSessionSnapshot.archivedSessions;
const liveFallbackCacheRef = React.useRef<{ signature: string; sessions: Session[] }>({
signature: '',
sessions: [],
});
const globalActiveSessionIds = React.useMemo(
() => new Set(globalActiveSessions.map((session) => session.id)),
[globalActiveSessions],
);
const liveFallbackSessions = (() => {
const candidates = liveSessions.filter((session) => !globalActiveSessionIds.has(session.id));
const signature = candidates.map(getSessionStructuralSignature).sort().join('\n');
if (liveFallbackCacheRef.current.signature === signature) {
return liveFallbackCacheRef.current.sessions;
}
liveFallbackCacheRef.current = { signature, sessions: candidates };
return candidates;
})();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
const shareSession = useSessionUIStore((state) => state.shareSession); const shareSession = useSessionUIStore((state) => state.shareSession);
@@ -359,14 +444,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
); );
const sessions = React.useMemo(() => { const sessions = React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session])); const merged = [...globalActiveSessions];
const merged = globalActiveSessions.map((session) => {
const liveSession = liveById.get(session.id);
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
});
const seenIds = new Set(merged.map((session) => session.id)); const seenIds = new Set(merged.map((session) => session.id));
liveSessions.forEach((session) => { liveFallbackSessions.forEach((session) => {
if (seenIds.has(session.id)) { if (seenIds.has(session.id)) {
return; return;
} }
@@ -377,43 +458,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
allowUnknownDirectory: !isVSCode, allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode, allowEmptyDirectorySet: !isVSCode,
})); }));
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveSessions]); }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
const persistenceSessions = React.useMemo( const persistenceSessions = React.useMemo(
() => [...globalActiveSessions, ...archivedSessions], () => [...globalActiveSessions, ...archivedSessions],
[archivedSessions, globalActiveSessions], [archivedSessions, globalActiveSessions],
); );
const syncSessionStructureSignature = React.useMemo(
() => liveSessions
.map((session) => {
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
})
.join('|'),
[liveSessions],
);
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions); const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
React.useEffect(() => { React.useEffect(() => {
syncSessionsSnapshotRef.current = liveSessions; syncSessionsSnapshotRef.current = liveSessions;
}, [syncSessionStructureSignature, liveSessions]); }, [liveSessions]);
// Batched live-session index. Building this here turns the per-row
// `useSession(session.id)` reads in SessionNodeItem (each of which
// iterates all child-stores via `findLiveSession`) into a single
// Map lookup. With M visible rows, that changes an O(M × child-stores)
// work to O(child-stores) once per Sidebar render.
const liveSessionById = React.useMemo(
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
[liveSessions],
);
const runtimeKey = getRuntimeKey();
const projectWorktreeDiscoveryKey = React.useMemo( const projectWorktreeDiscoveryKey = React.useMemo(
() => projects () => `${runtimeKey}|${projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`) .map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
.join('|'), .join('|')}`,
[projects], [projects, runtimeKey],
); );
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>( const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
isVSCode ? projectWorktreeDiscoveryKey : null, isVSCode ? projectWorktreeDiscoveryKey : null,
@@ -434,6 +496,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
let cancelled = false; let cancelled = false;
const discoverWorktrees = async () => { const discoverWorktrees = async () => {
const discoveryRuntimeKey = runtimeKey;
const projectEntries = useProjectsStore.getState().projects; const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0 || isVSCode) { if (projectEntries.length === 0 || isVSCode) {
if (!cancelled) { if (!cancelled) {
@@ -488,7 +551,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
await Promise.all(workers); await Promise.all(workers);
if (cancelled) return; if (cancelled || getRuntimeKey() !== discoveryRuntimeKey) return;
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean)); const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
for (const projectPath of worktreesByProject.keys()) { for (const projectPath of worktreesByProject.keys()) {
@@ -514,7 +577,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isVSCode, projectWorktreeDiscoveryKey]); }, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey]);
React.useEffect(() => { React.useEffect(() => {
let refreshTimeout: ReturnType<typeof setTimeout> | null = null; let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -556,22 +619,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({ const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
isVSCode, isVSCode,
hasAuthoritativeGlobalSessions,
safeStorage, safeStorage,
keys: { keys: {
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY, sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
sessionExpandedLegacy: LEGACY_SESSION_EXPANDED_STORAGE_KEY,
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY, projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
sessionPinned: SESSION_PINNED_STORAGE_KEY,
groupOrder: GROUP_ORDER_STORAGE_KEY, groupOrder: GROUP_ORDER_STORAGE_KEY,
projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY,
groupCollapse: GROUP_COLLAPSE_STORAGE_KEY, groupCollapse: GROUP_COLLAPSE_STORAGE_KEY,
}, },
sessions: persistenceSessions,
pinnedSessionIds,
setPinnedSessionIds,
groupOrderByProject, groupOrderByProject,
activeSessionByProject,
collapsedGroups, collapsedGroups,
setExpandedParents, setExpandedParents,
setCollapsedProjects, setCollapsedProjects,
@@ -619,12 +674,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return map; return map;
}, [sortedSessions, pinnedSessionIds]); }, [sortedSessions, pinnedSessionIds]);
const emptyState = ( const emptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground"> <div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noSessions.title')}</p> <p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noSessions.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p> <p className="typography-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p>
</div> </div>
); ), [t]);
const editingProject = React.useMemo( const editingProject = React.useMemo(
() => projects.find((project) => project.id === editingProjectDialogId) ?? null, () => projects.find((project) => project.id === editingProjectDialogId) ?? null,
@@ -703,9 +758,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
handleDeleteSession, handleDeleteSession,
confirmDeleteSession, confirmDeleteSession,
} = useSessionActions({ } = useSessionActions({
activeProjectId,
currentDirectory,
currentSessionId,
mobileVariant, mobileVariant,
allowReselect, allowReselect,
onSessionSelected, onSessionSelected,
@@ -713,8 +765,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionSearchQuery, sessionSearchQuery,
setSessionSearchQuery, setSessionSearchQuery,
setIsSessionSearchOpen, setIsSessionSearchOpen,
setActiveProjectIdOnly,
setDirectory,
setActiveMainTab, setActiveMainTab,
setSessionSwitcherOpen, setSessionSwitcherOpen,
setCurrentSession, setCurrentSession,
@@ -746,40 +796,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionEvents.requestDirectoryDialog(); sessionEvents.requestDirectoryDialog();
}, []); }, []);
// Auto-expand parent session when navigating to a subagent (child) session.
// We don't know which render context the user will look at the parent in
// (Recent, project root, archived bucket, ...), so fan out across all
// four combinations to ensure it's expanded wherever it appears.
React.useEffect(() => {
if (!currentSessionId) return;
const current = sessions.find((s) => s.id === currentSessionId);
const parentID = (current as Session & { parentID?: string | null })?.parentID;
if (!parentID) return;
const keysToAdd = [
`project:active:${parentID}`,
`project:archived:${parentID}`,
`recent:active:${parentID}`,
`recent:archived:${parentID}`,
];
setExpandedParents((prev) => {
if (keysToAdd.every((k) => prev.has(k))) return prev;
const next = new Set(prev);
keysToAdd.forEach((k) => next.add(k));
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
return next;
});
}, [currentSessionId, sessions, safeStorage]);
const toggleParent = React.useCallback((expansionKey: string) => { const toggleParent = React.useCallback((expansionKey: string) => {
setExpandedParents((prev) => { setExpandedParents((previous) => {
const next = new Set(prev); const next = toggleExpandedParentKey(previous, expansionKey);
if (next.has(expansionKey)) {
next.delete(expansionKey);
} else {
next.add(expansionKey);
}
try { try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next))); safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ } } catch { /* ignored */ }
@@ -964,12 +983,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const { github } = useRuntimeAPIs(); const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths); const gitRepoStatus = useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY);
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets); const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
useProjectRepoStatus({ useProjectRepoStatus({
enabled: isVisible,
normalizedProjects, normalizedProjects,
gitRepoStatus, gitRepoStatus,
setProjectRepoStatus, setProjectRepoStatus,
@@ -981,15 +1001,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions), () => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions], [archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
); );
useSessionFolderCleanup({ useAuthoritativeSessionCleanup({
isSessionsLoading, enabled: isVisible,
hasAuthoritativeGlobalSessions, hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading, sessions: persistenceSessions,
normalizedProjects,
ownership: sessionOwnership,
availableWorktreesByProject,
unresolvedWorktreeProjectPaths,
cleanupSessions,
}); });
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
@@ -997,6 +1012,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
useArchivedAutoFolders({ useArchivedAutoFolders({
enabled: isVisible,
normalizedProjects, normalizedProjects,
ownership: sessionOwnership, ownership: sessionOwnership,
isSessionsLoading, isSessionsLoading,
@@ -1006,7 +1022,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
foldersMap, foldersMap,
createFolder, createFolder,
addSessionToFolder, addSessionToFolder,
cleanupSessions,
}); });
// Keep last-known repo status to avoid UI jiggling during project switch // Keep last-known repo status to avoid UI jiggling during project switch
@@ -1019,6 +1034,89 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder); const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
const projectExpandedParentsRef = React.useRef<Set<string>>(new Set());
const recentExpandedParentsRef = React.useRef<Set<string>>(new Set());
const projectExpandedParents = selectExpandedParentKeysForContext(
projectExpandedParentsRef.current,
expandedParents,
'project',
);
const recentExpandedParents = selectExpandedParentKeysForContext(
recentExpandedParentsRef.current,
expandedParents,
'recent',
);
projectExpandedParentsRef.current = projectExpandedParents;
recentExpandedParentsRef.current = recentExpandedParents;
const sidebarRenderSources = {
isVisible,
mobileVariant,
onSessionSelected,
allowReselect,
hideDirectoryControls,
showOnlyMainWorkspace,
t,
isTablet,
liveSessions,
activeSessionStructure,
archivedSessionStructure,
globalActiveSessions,
archivedSessions,
projects,
activeProjectId,
manualProjectOrder,
currentDirectory,
worktreeMetadata,
availableWorktreesByProject,
pinnedSessionIds,
foldersMap,
collapsedFolderIds,
gitBranches,
gitRepoStatus,
githubAuthStatus,
githubAuthChecked,
updateStore,
showRecentSection,
showArchivedSessions,
projectSortOrder,
projectRepoStatus,
projectRootBranches,
resolvedWorktreeTopologyKey,
unresolvedWorktreeProjectPaths,
isSessionSearchOpen,
sessionSearchQuery,
editingId,
editTitle,
editingProjectDialogId,
expandedParents,
collapsedProjects,
visibleSessionCountByGroup,
updateDialogOpen,
openSidebarMenuKey,
renamingFolderId,
renameFolderDraft,
deleteSessionConfirm,
deleteFolderConfirm,
bulkDeleteConfirm,
collapsedGroups,
groupOrderByProject,
};
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
if (previousSidebarRenderSources) {
let attributed = false;
for (const source of Object.keys(sidebarRenderSources) as Array<keyof typeof sidebarRenderSources>) {
if (!Object.is(previousSidebarRenderSources[source], sidebarRenderSources[source])) {
streamPerfCount(`ui.session_sidebar.source.${source}`);
attributed = true;
}
}
if (!attributed) {
streamPerfCount('ui.session_sidebar.source.parent_or_context');
}
}
previousSidebarRenderSourcesRef.current = sidebarRenderSources;
const sortedProjects = React.useMemo(() => { const sortedProjects = React.useMemo(() => {
const list = [...normalizedProjects]; const list = [...normalizedProjects];
@@ -1079,26 +1177,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
foldersMap, foldersMap,
}); });
const searchEmptyState = ( const searchEmptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground"> <div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p> <p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p> <p className="typography-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p>
</div> </div>
); ), [t]);
useProjectSessionSelection({
projectSections,
activeProjectId,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
});
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject); const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
const hasInitializedArchivedCollapseRef = React.useRef(false); const hasInitializedArchivedCollapseRef = React.useRef(false);
@@ -1231,19 +1315,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
); );
const recentSessionIds = React.useMemo(() => {
return new Set(activeNowSessions.map((session) => session.id));
}, [activeNowSessions]);
const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]);
useSessionPrefetch({
currentSessionId,
sortedSessions,
recentSessionIds: recentSessionIdsList,
ensureSessionRenderable: sync.ensureSessionRenderable,
});
const sectionsForSidebarRender = React.useMemo(() => { const sectionsForSidebarRender = React.useMemo(() => {
return showArchivedSessions return showArchivedSessions
? sectionsForRender ? sectionsForRender
@@ -1254,6 +1325,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [sectionsForRender, showArchivedSessions]); }, [sectionsForRender, showArchivedSessions]);
const prLookupKeys = React.useMemo(() => { const prLookupKeys = React.useMemo(() => {
if (!isVisible) return EMPTY_STRING_ARRAY;
const keys = new Set<string>(); const keys = new Set<string>();
sectionsForSidebarRender.forEach((section) => { sectionsForSidebarRender.forEach((section) => {
section.groups.forEach((group) => { section.groups.forEach((group) => {
@@ -1266,16 +1338,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
}); });
return [...keys]; return [...keys];
}, [gitBranches, sectionsForSidebarRender]); }, [gitBranches, isVisible, sectionsForSidebarRender]);
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys); const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys);
React.useEffect(() => { React.useEffect(() => {
if (!githubAuthChecked || !githubAuthStatus?.connected || !github) { if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
return; return;
} }
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = []; const targetsByKey = new Map<string, { directory: string; branch: string }>();
const now = Date.now(); const now = Date.now();
sectionsForSidebarRender.forEach((section) => { sectionsForSidebarRender.forEach((section) => {
@@ -1307,29 +1379,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (shouldRetryNoPr) { if (shouldRetryNoPr) {
retriedNoPrStatusKeysRef.current.add(retryKey); retriedNoPrStatusKeysRef.current.add(retryKey);
} }
missingTargets.push({ directory, branch }); if (!targetsByKey.has(key)) {
targetsByKey.set(key, { directory, branch });
}
} }
}); });
}); });
if (missingTargets.length === 0) { if (targetsByKey.size === 0) {
return; return;
} }
const uniqueTargets = new Map<string, { directory: string; branch: string; remoteName?: string | null }>(); targetsByKey.forEach((target, key) => {
missingTargets.forEach((target) => {
const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null);
if (!uniqueTargets.has(key)) {
uniqueTargets.set(key, target);
}
});
uniqueTargets.forEach((target, key) => {
ensurePrStatusEntry(key); ensurePrStatusEntry(key);
setPrStatusParams(key, { setPrStatusParams(key, {
directory: target.directory, directory: target.directory,
branch: target.branch, branch: target.branch,
remoteName: target.remoteName ?? null, remoteName: null,
canShow: true, canShow: true,
github, github,
githubAuthChecked, githubAuthChecked,
@@ -1337,7 +1403,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
}); });
void refreshPrStatusTargets([...uniqueTargets.values()], { void refreshPrStatusTargets([...targetsByKey.values()], {
silent: true, silent: true,
markInitialResolved: true, markInitialResolved: true,
}); });
@@ -1347,6 +1413,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
github, github,
githubAuthChecked, githubAuthChecked,
githubAuthStatus?.connected, githubAuthStatus?.connected,
isVisible,
gitBranches, gitBranches,
refreshPrStatusTargets, refreshPrStatusTargets,
sectionsForSidebarRender, sectionsForSidebarRender,
@@ -1360,6 +1427,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass; const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
const headerActionIconClass = 'h-4.5 w-4.5'; const headerActionIconClass = 'h-4.5 w-4.5';
const stuckProjectHeaders = useStickyProjectHeaders({ const stuckProjectHeaders = useStickyProjectHeaders({
enabled: isVisible,
isDesktopShellRuntime, isDesktopShellRuntime,
projectSections, projectSections,
projectHeaderSentinelRefs, projectHeaderSentinelRefs,
@@ -1382,9 +1450,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
groupDirectory={groupDirectory} groupDirectory={groupDirectory}
projectId={projectId} projectId={projectId}
archivedBucket={archivedBucket} archivedBucket={archivedBucket}
currentSessionId={currentSessionId}
pinnedSessionIds={pinnedSessionIds} pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents} expandedParents={renderContext === 'recent' ? recentExpandedParents : projectExpandedParents}
hasSessionSearchQuery={hasSessionSearchQuery} hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery} normalizedSessionSearchQuery={normalizedSessionSearchQuery}
notifyOnSubtasks={notifyOnSubtasks} notifyOnSubtasks={notifyOnSubtasks}
@@ -1417,12 +1484,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta} secondaryMeta={secondaryMeta}
renderContext={renderContext} renderContext={renderContext}
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET} subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null} menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''} nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
childRenderExtrasFor={renderExtras?.childRenderExtrasFor} childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
liveSessionById={liveSessionById}
/> />
), ),
); );
@@ -1505,13 +1570,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setRenameFolderDraft={setRenameFolderDraft} setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId} setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds} pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents} expandedParents={projectExpandedParents}
sessionOrderIndex={sessionOrderIndex} sessionOrderIndex={sessionOrderIndex}
currentSessionId={currentSessionId}
editingId={editingId} editingId={editingId}
editTitle={editTitle} editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey} openSidebarMenuKey={openSidebarMenuKey}
liveSessionById={liveSessionById}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch} prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup} onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
@@ -1546,28 +1609,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
renamingFolderId, renamingFolderId,
renameFolderDraft, renameFolderDraft,
pinnedSessionIds, pinnedSessionIds,
expandedParents, projectExpandedParents,
sessionOrderIndex, sessionOrderIndex,
currentSessionId,
editingId, editingId,
editTitle, editTitle,
openSidebarMenuKey, openSidebarMenuKey,
liveSessionById,
prVisualStateByDirectoryBranch, prVisualStateByDirectoryBranch,
toggleCollapsedGroup, toggleCollapsedGroup,
], ],
); );
const topContent = (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? ( const topContent = React.useMemo(
() => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
<SidebarActivitySections <SidebarActivitySections
sections={activitySections} sections={activitySections}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
currentSessionId={currentSessionId}
editingId={editingId} editingId={editingId}
openSidebarMenuKey={openSidebarMenuKey} openSidebarMenuKey={openSidebarMenuKey}
expansionState={recentExpandedParents}
variant="section" variant="section"
/> />
) : null; ) : null,
[activitySections, editingId, hasSessionSearchQuery, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection],
);
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const { const {
@@ -1620,6 +1684,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
mobileVariant ? '' : 'bg-transparent', mobileVariant ? '' : 'bg-transparent',
)} )}
> >
<SidebarBootstrapDemandEffect
owner={bootstrapDemandOwner}
childStores={childStores}
projectSections={projectSections}
activeProjectId={activeProjectId}
collapsedProjects={collapsedProjects}
collapsedGroups={collapsedGroups}
currentDirectory={currentDirectory}
/>
<ProjectSessionSelectionEffect
projectSections={projectSections}
activeProjectId={activeProjectId}
initialActiveSessionByProject={initialActiveSessionByProject}
persistActiveSessionByProject={persistActiveSessionByProject}
handleSessionSelect={stableHandleSessionSelect}
mobileVariant={mobileVariant}
openNewSessionDraft={openNewSessionDraft}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
/>
<SessionPrefetchEffect
enabled={isVisible}
sortedSessions={sortedSessions}
recentSessions={activeNowSessions}
prefetchSession={sync.prefetchSession}
/>
<SidebarHeader <SidebarHeader
hideDirectoryControls={hideDirectoryControls} hideDirectoryControls={hideDirectoryControls}
showRecentControls={!isVSCode} showRecentControls={!isVSCode}
@@ -1643,7 +1733,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
onToggleSelectionMode={handleToggleSelectionMode} onToggleSelectionMode={handleToggleSelectionMode}
/> />
<SidebarProjectsList {isVisible ? <SidebarProjectsList
topContent={topContent} topContent={topContent}
hasSharedSessions={hasActivitySectionItems} hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender} sectionsForRender={sectionsForSidebarRender}
@@ -1678,7 +1768,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
openSidebarMenuKey={openSidebarMenuKey} openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey} setOpenSidebarMenuKey={setOpenSidebarMenuKey}
isInlineEditing={isInlineEditing} isInlineEditing={isInlineEditing}
/> /> : null}
{selectionModeEnabled && hasSelection ? ( {selectionModeEnabled && hasSelection ? (
<BulkActionBar <BulkActionBar
@@ -1770,3 +1860,5 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div> </div>
); );
}; };
export const SessionSidebar = React.memo(SessionSidebarComponent);
@@ -10,9 +10,10 @@
- Archived groups are collapsed by default and support bulk deletion at group/folder level. - Archived groups are collapsed by default and support bulk deletion at group/folder level.
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode. - Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle. - Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
- New extractions in latest pass reduced local effect/callback bulk further: - New extractions in latest pass reduced local effect/callback bulk further:
- project session list builders - project session list builders
- folder cleanup sync - authoritative deletion cleanup
- sticky project header observer - sticky project header observer
## VS Code grouping ## VS Code grouping
@@ -29,8 +30,8 @@
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only. - `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states. - `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, and group-level controls. - `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, group-level controls, and explicit loading/error/retry state for empty groups.
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children. - `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children. Rows do not initiate directory bootstrap on mount.
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows. - `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances. - `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders. - `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
@@ -40,7 +41,7 @@
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations). - `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior. - `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
- `hooks/useSessionPrefetch.ts`: Prefetches messages for nearby/active sessions to improve perceived load speed. - `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers. - `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering. - `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context. - `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
@@ -49,11 +50,29 @@
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings. - `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata. - `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index. - `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
- `hooks/useSessionFolderCleanup.ts`: Cleans stale folder session IDs by reconciling known sessions/archived scopes. - `hooks/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`. - `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
### Types and utilities ### Types and utilities
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata). - `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list. - `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting). - `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting).
## Loading rules
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
- Current directory and selected-session directory are `selected` demand and therefore run first.
- Expanded projects/worktrees outrank merely visible and background groups.
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes are read from the authoritative snapshot on the next sidebar render rather than triggering a full tree rebuild themselves.
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
@@ -5,11 +5,6 @@ import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize // Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded. // when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50; const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Active/worktree groups can also grow large (a single worktree with 80+
// sessions), and unlike the archive they're interactive from the start.
// Virtualize eagerly for non-archived groups to keep the rendered row
// count bounded. With overscan ~8 the visible behavior is identical.
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
// Compact rows in the archived bucket without nested subagents render // Compact rows in the archived bucket without nested subagents render
// around 24-32px; virtua measures mounted rows and uses this as the initial hint. // around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28; const ARCHIVED_ROW_ESTIMATE_PX = 28;
@@ -26,8 +21,10 @@ import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePa
import { import {
collectSubtreeContainingId, collectSubtreeContainingId,
computeNodeStructureKey, computeNodeStructureKey,
nodeHasPinnedMembershipChange,
nodeContainsSessionId, nodeContainsSessionId,
resolveMenuOpenSessionId, resolveMenuOpenSessionId,
selectFolderRootNodes,
} from './sessionNodeItemUtils'; } from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import type { SessionFolder } from '@/stores/useSessionFoldersStore';
@@ -36,6 +33,7 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { openExternalUrl } from '@/lib/url'; import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useChildStoreManager } from '@/sync/sync-context';
type DeleteFolderConfirm = { type DeleteFolderConfirm = {
scopeKey: string; scopeKey: string;
@@ -92,11 +90,9 @@ type Props = {
pinnedSessionIds: Set<string>; pinnedSessionIds: Set<string>;
expandedParents: Set<string>; expandedParents: Set<string>;
sessionOrderIndex: Map<string, number>; sessionOrderIndex: Map<string, number>;
currentSessionId: string | null;
editingId: string | null; editingId: string | null;
editTitle: string; editTitle: string;
openSidebarMenuKey: string | null; openSidebarMenuKey: string | null;
liveSessionById: Map<string, Session>;
prVisualStateByDirectoryBranch: Map<string, { prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed'; visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number; number: number;
@@ -142,12 +138,14 @@ const groupHasPinnedMembershipChange = (
prevPinnedSessionIds: Set<string>, prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>, nextPinnedSessionIds: Set<string>,
): boolean => { ): boolean => {
const visit = (node: SessionNode): boolean => { return group.sessions.some((node) => nodeHasPinnedMembershipChange(
const sessionId = node.session.id; node,
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true; node,
return node.children.some(visit); prevPinnedSessionIds,
}; nextPinnedSessionIds,
return group.sessions.some(visit); group.directory,
group.directory,
));
}; };
const groupHasSessionOrderChange = ( const groupHasSessionOrderChange = (
@@ -177,21 +175,6 @@ const groupHasExpansionMembershipChange = (
return group.sessions.some(visit); return group.sessions.some(visit);
}; };
const groupHasResolvedSessionChange = (
group: SessionGroup,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
return true;
}
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => { const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
if (!props.projectId) return undefined; if (!props.projectId) return undefined;
return props.projectRepoStatus.has(props.projectId) return props.projectRepoStatus.has(props.projectId)
@@ -236,11 +219,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
return false; return false;
} }
if (prev.currentSessionId !== next.currentSessionId
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
return false;
}
if (prev.editingId !== next.editingId if (prev.editingId !== next.editingId
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) { && (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false; return false;
@@ -257,11 +235,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
if (prevMenuSessionId || nextMenuSessionId) return false; if (prevMenuSessionId || nextMenuSessionId) return false;
} }
if (prev.liveSessionById !== next.liveSessionById
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
return false;
}
// Per-row / per-state props. The PR-visual-state map flips frequently // Per-row / per-state props. The PR-visual-state map flips frequently
// during bootstrap but a single group's value is usually stable, so we // during bootstrap but a single group's value is usually stable, so we
// compare only the value this group actually consumes instead of the // compare only the value this group actually consumes instead of the
@@ -352,7 +325,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
pinnedSessionIds, pinnedSessionIds,
expandedParents, expandedParents,
sessionOrderIndex, sessionOrderIndex,
currentSessionId,
editingId, editingId,
openSidebarMenuKey, openSidebarMenuKey,
prVisualStateByDirectoryBranch, prVisualStateByDirectoryBranch,
@@ -379,6 +351,19 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
// VS Code always uses the expanded layout (see SessionNodeItem). // VS Code always uses the expanded layout (see SessionNodeItem).
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime(); const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
const childStores = useChildStoreManager();
const bootstrapDirectory = normalizePath(group.directory ?? null);
const bootstrapState = React.useSyncExternalStore(
React.useCallback(
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectory, childStores],
),
React.useCallback(
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
[bootstrapDirectory, childStores],
),
React.useCallback(() => undefined, []),
);
const maxVisible = hideDirectoryControls ? 10 : 5; const maxVisible = hideDirectoryControls ? 10 : 5;
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible); const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false; const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
@@ -409,10 +394,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
}, [sourceGroupNodes]); }, [sourceGroupNodes]);
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => { const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
const nodes = folder.sessionIds const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
.map((sid) => nodeBySessionId.get(sid))
.filter((n): n is SessionNode => Boolean(n))
.sort(compareSessionNodes);
return { folder, nodes }; return { folder, nodes };
}), [scopeFolders, nodeBySessionId, compareSessionNodes]); }), [scopeFolders, nodeBySessionId, compareSessionNodes]);
@@ -472,21 +454,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]); const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]); const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
// Precompute per-row "subtree contains active session" and "subtree contains // Precompute the per-row "subtree contains editing session" lookup once per
// editing session" lookups once per render. The previous design walked the // render. The previous design walked the
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2) // node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
// across the whole sidebar. These sets let areEqual answer with a single // across the whole sidebar. These sets let areEqual answer with a single
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render. // Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
const renderContextForGroup = 'project' as const; const renderContextForGroup = 'project' as const;
const subtreeContainsActive = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, currentSessionId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
const subtreeContainsEditing = React.useMemo(() => { const subtreeContainsEditing = React.useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, editingId, set); collectSubtreeContainingId(sourceGroupNodes, editingId, set);
@@ -539,11 +512,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]); }, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({ const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(child), nodeStructureKey: resolveNodeStructureKey(child),
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]); }), [subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
const totalSessions = ungroupedSessions.length; const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket const visibleSessions = group.isArchivedBucket
@@ -554,21 +526,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const remainingCount = totalSessions - visibleSessions.length; const remainingCount = totalSessions - visibleSessions.length;
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0; const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
// Virtualize large groups. Archived buckets grow into the hundreds or // Virtualize archived buckets, which can grow into the thousands. Active
// thousands of rows; active/worktree groups can also hit 80+ sessions // groups retain normal flow because their incremental Show more control and
// when a single worktree accumulates over time. Both paths share the // the shared ancestor scroller cannot expose an unmounted virtual tail.
// same virtua Virtualizer; the threshold just controls when we mount // Hooks below MUST stay above the search-empty early-return so they fire in
// it. The visible behavior is identical because virtua uses overscan // the same order every render — rules-of-hooks.
// (8) for the buffer zone. All hooks below MUST stay above the const shouldVirtualize = group.isArchivedBucket === true
// search-empty early-return so they fire in the same order every
// render — rules-of-hooks.
const shouldVirtualizeArchived = group.isArchivedBucket === true
&& !hasSessionSearchQuery && !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD; && visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const shouldVirtualizeActive = group.isArchivedBucket !== true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
// Check if any parent node is expanded - expanded parents render their // Check if any parent node is expanded - expanded parents render their
// children inline, making them much taller than the fixed estimate. // children inline, making them much taller than the fixed estimate.
@@ -864,7 +829,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
getRenderExtras={resolveNodeStructureKey getRenderExtras={resolveNodeStructureKey
? (node) => ({ ? (node) => ({
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node), nodeStructureKey: resolveNodeStructureKey(node),
@@ -941,7 +905,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
// meanwhile keeps the container's height real so the scroller // meanwhile keeps the container's height real so the scroller
// never collapses/clamps during the flip. // never collapses/clamps during the flip.
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node), nodeStructureKey: resolveNodeStructureKey(node),
@@ -980,7 +943,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
}} }}
> >
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { {renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node), nodeStructureKey: resolveNodeStructureKey(node),
@@ -994,7 +956,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
</div> </div>
) : ( ) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node), nodeStructureKey: resolveNodeStructureKey(node),
@@ -1005,6 +966,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
<div className="py-1 text-left typography-micro text-muted-foreground"> <div className="py-1 text-left typography-micro text-muted-foreground">
{group.isArchivedBucket {group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions') ? t('sessions.sidebar.group.empty.noArchivedSessions')
: bootstrapState === 'queued' || bootstrapState === 'running'
? (
<span className="inline-flex items-center gap-1.5">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('sessions.sidebar.group.empty.loadingSessions')}
</span>
)
: bootstrapState === 'failed' && bootstrapDirectory
? (
<span className="inline-flex items-center gap-1.5">
{t('sessions.sidebar.group.empty.loadFailed')}
<button
type="button"
className="text-foreground hover:underline"
onClick={() => childStores.requestBootstrap({
directory: bootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
})}
>
{t('sessions.sidebar.group.empty.retry')}
</button>
</span>
)
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')} : t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div> </div>
) : null} ) : null}
@@ -18,6 +18,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; 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 { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession';
@@ -25,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession
import { useSync } from '@/sync/use-sync'; import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd'; import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId } from './sessionNodeItemUtils'; import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionNode } from './types'; import type { SessionNode } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
@@ -43,6 +44,8 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
import { FusionIcon } from '@/components/icons/FusionIcon'; import { FusionIcon } from '@/components/icons/FusionIcon';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; 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[] }; type Folder = { id: string; name: string; sessionIds: string[] };
@@ -57,7 +60,6 @@ type Props = {
groupDirectory?: string | null; groupDirectory?: string | null;
projectId?: string | null; projectId?: string | null;
archivedBucket?: boolean; archivedBucket?: boolean;
currentSessionId: string | null;
pinnedSessionIds: Set<string>; pinnedSessionIds: Set<string>;
expandedParents: Set<string>; expandedParents: Set<string>;
hasSessionSearchQuery: boolean; hasSessionSearchQuery: boolean;
@@ -70,9 +72,9 @@ type Props = {
handleSaveEdit: (titleOverride?: string) => void; handleSaveEdit: (titleOverride?: string) => void;
handleCancelEdit: () => void; handleCancelEdit: () => void;
toggleParent: (expansionKey: string) => 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; handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
togglePinnedSession: (sessionId: string) => void; togglePinnedSession: (target: SessionPinnedTarget) => void;
handleShareSession: (session: Session) => void; handleShareSession: (session: Session) => void;
copiedSessionId: string | null; copiedSessionId: string | null;
handleCopyShareUrl: (url: string, sessionId: string) => void; handleCopyShareUrl: (url: string, sessionId: string) => void;
@@ -101,16 +103,9 @@ type Props = {
) => React.ReactNode; ) => React.ReactNode;
secondaryMeta?: SecondaryMeta | null; secondaryMeta?: SecondaryMeta | null;
renderContext?: 'project' | 'recent'; 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<string>;
/** /**
* Precomputed set of session IDs whose subtree contains the session * 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<string>; subtreeContainsEditing: Set<string>;
/** /**
@@ -132,15 +127,52 @@ type Props = {
* to fetch the right key for each child it produces. * to fetch the right key for each child it produces.
*/ */
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras; childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
/** };
* Batched index of live session objects keyed by id. The previous
* implementation called `useSession(session.id)` per row, which used const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
* `findLiveSession` to iterate every child-store on every SSE event.
* With M visible rows that's M×child-stores per event; the batched const holdSessionRowPosition = (target: HTMLElement): void => {
* map turns it into a single Map.get per row. The parent falls back if (typeof window === 'undefined') return;
* to `useSession` only when this map returns undefined. const row = target.closest<HTMLElement>('[data-session-row]');
*/ const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
liveSessionById: Map<string, Session>; 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 = { type QuickSessionActionProps = {
@@ -206,6 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
}); });
function SessionNodeItemComponent(props: Props): React.ReactNode { function SessionNodeItemComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_session_node.render');
const { t } = useI18n(); const { t } = useI18n();
const { const {
node, node,
@@ -213,7 +246,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
groupDirectory, groupDirectory,
projectId, projectId,
archivedBucket = false, archivedBucket = false,
currentSessionId,
pinnedSessionIds, pinnedSessionIds,
expandedParents, expandedParents,
hasSessionSearchQuery, hasSessionSearchQuery,
@@ -248,11 +280,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
renderSessionNode, renderSessionNode,
secondaryMeta, secondaryMeta,
renderContext = 'project', renderContext = 'project',
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
childRenderExtrasFor, childRenderExtrasFor,
liveSessionById,
} = props; } = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel); const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel); const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
@@ -307,25 +337,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const formRef = React.useRef<HTMLFormElement>(null); const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session; const session = node.session;
// Batched live-session lookup. `liveSessionById` is built once per const resolvedSession = session;
// Sidebar render from the same `useAllLiveSessions` selector that const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
// `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 sessionDirectory = const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null) normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(groupDirectory ?? null); ?? normalizePath(groupDirectory ?? null);
// Archived rows are historical and never need live state, yet they point at // Directory bootstrap is scheduled once at sidebar level. A row only needs
// dozens of (often deleted) worktrees — bootstrapping each from the sidebar // the lightweight store reference for scoped state and export actions.
// triggers a pointless session-list fetch + 6×2s empty-retry storm on startup. const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
// 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 });
const sync = useSync(); const sync = useSync();
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
@@ -368,7 +388,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
); );
const sessionStatus = useGlobalSessionStatus(session.id); const sessionStatus = useGlobalSessionStatus(session.id);
const isMovingToWorktree = useIsSessionWorktreeMovePending(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 sessionGoal = getSessionGoal(resolvedSession);
const sessionGoalGlyph = sessionGoal ? ( const sessionGoalGlyph = sessionGoal ? (
<span <span
@@ -379,10 +399,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} /> <Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} />
</span> </span>
) : null; ) : null;
const isActive = currentSessionId === session.id;
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled'); const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
const hasChildren = node.children.length > 0; 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 // 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 // the project's root and the "Recent" list, and expanding one should not
// expand the other. Matches the format of menuInstanceKey. // expand the other. Matches the format of menuInstanceKey.
@@ -409,7 +428,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
let skipped = 0; let skipped = 0;
for (const child of children) { for (const child of children) {
try { 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 childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent'); const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
const childAgent = (child.session as Session & { agent?: string }).agent; const childAgent = (child.session as Session & { agent?: string }).agent;
@@ -426,7 +445,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
} }
} }
return { children: results, skipped }; return { children: results, skipped };
}, [collectNodeDescendantIds, directoryStore, sync, t]); }, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
const showSkippedSubtasksWarning = React.useCallback((count: number) => { const showSkippedSubtasksWarning = React.useCallback((count: number) => {
if (count <= 0) return; if (count <= 0) return;
@@ -441,7 +460,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
return; return;
} }
await sync.ensureSessionRenderable(session.id); await sync.ensureSessionRenderable(session.id, false, sessionDirectory);
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list; const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) { if (records.length === 0) {
@@ -775,7 +794,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node)); toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
return; 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 // The selection/active highlight covers the WHOLE row box (gutter, edge
@@ -832,7 +852,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="pencil-ai" className="mr-1 h-4 w-4" /> <Icon name="pencil-ai" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')} {t('sessions.sidebar.session.menu.rename')}
</Item> </Item>
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1"> <Item onClick={() => sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1">
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />} {isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')} {isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
</Item> </Item>
@@ -1267,7 +1287,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
? childRenderExtrasFor(child) ? childRenderExtrasFor(child)
: { : {
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: '', nodeStructureKey: '',
@@ -1387,26 +1406,6 @@ const hasSetMembershipChangeInNode = (
return false; return false;
}; };
const hasResolvedSessionChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): 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 => { const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false; if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active'; const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
@@ -1428,6 +1427,7 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
const areSessionNodeItemPropsEqual = (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.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.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false; if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) 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 (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) 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 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; return false;
} }
@@ -1456,14 +1458,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
return false; 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 if (prev.editingId !== next.editingId
&& ( && (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing) subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
@@ -38,9 +38,9 @@ type Props = {
renderContext?: 'project' | 'recent', renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras, renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode; ) => React.ReactNode;
currentSessionId: string | null;
editingId: string | null; editingId: string | null;
openSidebarMenuKey: string | null; openSidebarMenuKey: string | null;
expansionState?: ReadonlySet<string>;
variant?: 'section' | 'flat'; variant?: 'section' | 'flat';
initialVisibleCount?: number; initialVisibleCount?: number;
batchSize?: number; batchSize?: number;
@@ -50,16 +50,16 @@ type RenderExtras = SessionNodeRenderExtras;
const MAX_VISIBLE_RECENT_SESSIONS = 7; const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({ export function SidebarActivitySections(props: Props): React.ReactNode {
const {
sections, sections,
renderSessionNode, renderSessionNode,
currentSessionId,
editingId, editingId,
openSidebarMenuKey, openSidebarMenuKey,
variant = 'section', variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS, batchSize = MAX_VISIBLE_RECENT_SESSIONS,
}: Props): React.ReactNode { } = props;
const { t } = useI18n(); const { t } = useI18n();
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set()); const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map()); const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
@@ -101,8 +101,6 @@ export function SidebarActivitySections({
}, [batchSize]); }, [batchSize]);
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => { const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsActive = new Set<string>();
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
const subtreeContainsEditing = new Set<string>(); const subtreeContainsEditing = new Set<string>();
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing); collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false); const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
@@ -114,7 +112,6 @@ export function SidebarActivitySections({
nodes.forEach(visit); nodes.forEach(visit);
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({ const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '', nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
@@ -122,13 +119,12 @@ export function SidebarActivitySections({
}); });
return (node: SessionNode): RenderExtras => ({ return (node: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing, subtreeContainsEditing,
menuOpenSessionId, menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '', nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
childRenderExtrasFor, childRenderExtrasFor,
}); });
}, [currentSessionId, editingId, openSidebarMenuKey]); }, [editingId, openSidebarMenuKey]);
const visibleSections = sections.filter((section) => section.items.length > 0); const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) { if (visibleSections.length === 0) {
@@ -18,6 +18,7 @@ import { formatProjectLabel } from './utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
type ProjectSection = { type ProjectSection = {
project: { project: {
@@ -78,7 +79,8 @@ type Props = {
isInlineEditing: boolean; 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 { t } = useI18n();
const projectSensors = useSensors( const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -311,3 +313,5 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
</ScrollableOverlay> </ScrollableOverlay>
); );
} }
export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
@@ -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<string, AuthoritativeSessionIdentity> => {
const identities = new Map<string, AuthoritativeSessionIdentity>();
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<string, AuthoritativeSessionIdentity> | null,
current: ReadonlyMap<string, AuthoritativeSessionIdentity>,
): AuthoritativeSessionIdentity[] => {
if (!previous) return [];
const removed: AuthoritativeSessionIdentity[] = [];
previous.forEach((identity, key) => {
if (!current.has(key)) removed.push(identity);
});
return removed;
};
@@ -1,20 +0,0 @@
import type { Session } from '@opencode-ai/sdk/v2';
export const prunePinnedSessionIds = (
sessions: Array<Pick<Session, 'id'>>,
pinnedSessionIds: Set<string>,
): Set<string> => {
const existingSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
const next = new Set<string>();
pinnedSessionIds.forEach((id) => {
if (existingSessionIds.has(id)) {
next.add(id);
return;
}
changed = true;
});
return changed ? next : pinnedSessionIds;
};
@@ -17,6 +17,7 @@ type FolderEntry = {
}; };
type Args = { type Args = {
enabled?: boolean;
normalizedProjects: ProjectForArchivedFolders[]; normalizedProjects: ProjectForArchivedFolders[];
ownership: SessionOwnershipIndex; ownership: SessionOwnershipIndex;
isSessionsLoading: boolean; isSessionsLoading: boolean;
@@ -26,12 +27,12 @@ type Args = {
foldersMap: Record<string, FolderEntry[]>; foldersMap: Record<string, FolderEntry[]>;
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry; createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
}; };
export const useArchivedAutoFolders = (args: Args): void => { export const useArchivedAutoFolders = (args: Args): void => {
const { const {
normalizedProjects, normalizedProjects,
enabled = true,
ownership, ownership,
isSessionsLoading, isSessionsLoading,
hasAuthoritativeGlobalSessions, hasAuthoritativeGlobalSessions,
@@ -40,11 +41,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
foldersMap, foldersMap,
createFolder, createFolder,
addSessionToFolder, addSessionToFolder,
cleanupSessions,
} = args; } = args;
React.useEffect(() => { React.useEffect(() => {
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) { if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
return; return;
} }
@@ -54,8 +54,6 @@ export const useArchivedAutoFolders = (args: Args): void => {
} }
const scopeKey = getArchivedScopeKey(project.normalizedPath); const scopeKey = getArchivedScopeKey(project.normalizedPath);
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? []; const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
const existingFolders = foldersMap[scopeKey] ?? []; const existingFolders = foldersMap[scopeKey] ?? [];
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder])); 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); addSessionToFolder(scopeKey, folder.id, session.id);
} }
}); });
cleanupSessions(scopeKey, sessionIds);
}); });
}, [ }, [
normalizedProjects, normalizedProjects,
enabled,
ownership, ownership,
isSessionsLoading, isSessionsLoading,
hasAuthoritativeGlobalSessions, hasAuthoritativeGlobalSessions,
@@ -85,6 +82,5 @@ export const useArchivedAutoFolders = (args: Args): void => {
foldersMap, foldersMap,
createFolder, createFolder,
addSessionToFolder, addSessionToFolder,
cleanupSessions,
]); ]);
}; };
@@ -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([]);
});
});
@@ -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<typeof buildAuthoritativeSessionIdentityMap>;
} | 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]);
};
@@ -5,8 +5,10 @@ import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
type Project = { id: string; path: string; normalizedPath: string }; type Project = { id: string; path: string; normalizedPath: string };
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
type Args = { type Args = {
enabled?: boolean;
normalizedProjects: Project[]; normalizedProjects: Project[];
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>; gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>; setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
@@ -16,6 +18,7 @@ type Args = {
export const useProjectRepoStatus = (args: Args): void => { export const useProjectRepoStatus = (args: Args): void => {
const { const {
normalizedProjects, normalizedProjects,
enabled = true,
gitRepoStatus, gitRepoStatus,
setProjectRepoStatus, setProjectRepoStatus,
setProjectRootBranches, setProjectRootBranches,
@@ -26,7 +29,7 @@ export const useProjectRepoStatus = (args: Args): void => {
// Derive repo status from centralized Git store // Derive repo status from centralized Git store
React.useEffect(() => { React.useEffect(() => {
if (!git || normalizedProjects.length === 0) { if (!enabled || !git || normalizedProjects.length === 0) {
setProjectRepoStatus(new Map()); setProjectRepoStatus(new Map());
return; return;
} }
@@ -35,16 +38,17 @@ export const useProjectRepoStatus = (args: Args): void => {
normalizedProjects.forEach((project) => { normalizedProjects.forEach((project) => {
void ensureStatus(project.normalizedPath, git); void ensureStatus(project.normalizedPath, git);
}); });
}, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]); }, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
// Read isGitRepo from the store-populated state // Read isGitRepo from the store-populated state
React.useEffect(() => { React.useEffect(() => {
if (!enabled) return;
const next = new Map<string, boolean | null>(); const next = new Map<string, boolean | null>();
normalizedProjects.forEach((project) => { normalizedProjects.forEach((project) => {
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null); next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
}); });
setProjectRepoStatus(next); setProjectRepoStatus(next);
}, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]); }, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
const projectGitBranchesKey = React.useMemo(() => { const projectGitBranchesKey = React.useMemo(() => {
return normalizedProjects return normalizedProjects
@@ -69,9 +73,9 @@ export const useProjectRepoStatus = (args: Args): void => {
// background updates and only re-resolve on cold start or actual // background updates and only re-resolve on cold start or actual
// branch changes (those still invalidate via the input-key check). // branch changes (those still invalidate via the input-key check).
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map()); const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
React.useEffect(() => { React.useEffect(() => {
if (!enabled) return;
let cancelled = false; let cancelled = false;
// Debounce so the initial burst of per-project `ensureStatus` updates // Debounce so the initial burst of per-project `ensureStatus` updates
@@ -164,9 +168,5 @@ export const useProjectRepoStatus = (args: Args): void => {
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not }, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
// in the deps array since it never changes during the component
// lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
}; };
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode } from '../types';
import { normalizePath } from '../utils'; import { normalizePath } from '../utils';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
type ProjectSection = { type ProjectSection = {
project: { id: string; normalizedPath: string }; project: { id: string; normalizedPath: string };
@@ -16,7 +17,7 @@ type Args = {
activeSessionByProject: Map<string, string>; activeSessionByProject: Map<string, string>;
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>; setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
currentSessionId: string | null; currentSessionId: string | null;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void; handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
newSessionDraftOpen: boolean; newSessionDraftOpen: boolean;
mobileVariant: boolean; mobileVariant: boolean;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void; openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
@@ -148,7 +149,7 @@ export const useProjectSessionSelection = (args: Args): void => {
return; return;
} }
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null; const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
handleSessionSelect(targetSessionId, targetDirectory, activeProjectId); handleSessionSelect(targetSessionId, targetDirectory);
}, [ }, [
activeProjectId, activeProjectId,
activeSessionByProject, activeSessionByProject,
@@ -183,3 +184,34 @@ export const useProjectSessionSelection = (args: Args): void => {
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]); }, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
}; };
type ProjectSessionSelectionEffectProps = Omit<
Args,
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
> & {
initialActiveSessionByProject: Map<string, string>;
persistActiveSessionByProject: (value: Map<string, string>) => void;
};
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
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;
};
@@ -4,6 +4,8 @@ import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import { streamPerfMark } from '@/stores/utils/streamDebug';
import { useSessionUIStore } from '@/sync/session-ui-store';
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{ type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
session: Session; session: Session;
@@ -20,9 +22,6 @@ type DeleteSessionSource = {
}; };
type Args = { type Args = {
activeProjectId: string | null;
currentDirectory: string | null;
currentSessionId: string | null;
mobileVariant: boolean; mobileVariant: boolean;
allowReselect: boolean; allowReselect: boolean;
onSessionSelected?: (sessionId: string) => void; onSessionSelected?: (sessionId: string) => void;
@@ -30,8 +29,6 @@ type Args = {
sessionSearchQuery: string; sessionSearchQuery: string;
setSessionSearchQuery: (value: string) => void; setSessionSearchQuery: (value: string) => void;
setIsSessionSearchOpen: (open: boolean) => void; setIsSessionSearchOpen: (open: boolean) => void;
setActiveProjectIdOnly: (id: string) => void;
setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void;
setActiveMainTab: (tab: MainTab) => void; setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void; setSessionSwitcherOpen: (open: boolean) => void;
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void; setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
@@ -66,7 +63,8 @@ export const useSessionActions = (args: Args) => {
}, []); }, []);
const handleSessionSelect = React.useCallback( const handleSessionSelect = React.useCallback(
(sessionId: string, sessionDirectory?: string | null, projectId?: string | null) => { (sessionId: string, sessionDirectory?: string | null) => {
streamPerfMark('navigation.session_select');
const resetSessionSearch = () => { const resetSessionSearch = () => {
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) { if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
return; return;
@@ -75,26 +73,19 @@ export const useSessionActions = (args: Args) => {
args.setIsSessionSearchOpen(false); args.setIsSessionSearchOpen(false);
}; };
if (projectId && projectId !== args.activeProjectId) {
args.setActiveProjectIdOnly(projectId);
}
if (sessionDirectory && sessionDirectory !== args.currentDirectory) {
args.setDirectory(sessionDirectory, { showOverlay: false });
}
if (args.mobileVariant) { if (args.mobileVariant) {
args.setActiveMainTab('chat'); args.setActiveMainTab('chat');
args.setSessionSwitcherOpen(false); args.setSessionSwitcherOpen(false);
} }
if (sessionId === args.currentSessionId) { if (sessionId === useSessionUIStore.getState().currentSessionId) {
if (args.allowReselect) { if (args.allowReselect) {
args.onSessionSelected?.(sessionId); args.onSessionSelected?.(sessionId);
} }
resetSessionSearch(); resetSessionSearch();
return; return;
} }
streamPerfMark('navigation.session_state_set');
args.setCurrentSession(sessionId, sessionDirectory ?? null); args.setCurrentSession(sessionId, sessionDirectory ?? null);
args.onSessionSelected?.(sessionId); args.onSessionSelected?.(sessionId);
resetSessionSearch(); resetSessionSearch();
@@ -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<string, WorktreeMeta[]>;
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => 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<string, Set<string>>();
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,
]);
};
@@ -10,120 +10,152 @@ const SESSION_PREFETCH_CONCURRENCY = 1;
const SESSION_PREFETCH_PENDING_LIMIT = 6; const SESSION_PREFETCH_PENDING_LIMIT = 6;
type Args = { type Args = {
enabled?: boolean;
currentSessionId: string | null; currentSessionId: string | null;
sortedSessions: Session[]; sortedSessions: Session[];
recentSessionIds?: string[]; recentSessions?: Session[];
ensureSessionRenderable: (sessionId: string) => Promise<unknown>; prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
}; };
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<Map<string, number>>(new Map()); const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<string[]>([]); const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set()); const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
const generationRef = React.useRef(0);
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []); const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
const requestKey = React.useCallback((request: Pick<PrefetchRequest, 'directory' | 'sessionId'>) => (
`${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(() => { const pumpSessionPrefetchQueue = React.useCallback(() => {
if (prefetchDisabled || typeof window === 'undefined') { if (!enabled || prefetchDisabled || typeof window === 'undefined') {
return; return;
} }
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) { while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
const nextSessionId = sessionPrefetchQueueRef.current.shift(); const request = sessionPrefetchQueueRef.current.shift();
if (!nextSessionId) { if (!request) {
break; break;
} }
if (request.generation !== generationRef.current) continue;
const state = useSessionUIStore.getState(); const state = useSessionUIStore.getState();
if (state.currentSessionId === nextSessionId) { if (state.currentSessionId === request.sessionId) {
continue; continue;
} }
// Check if the session is already renderable in the sync child store. // Check if the session is already renderable in the sync child store.
if (getSyncSessionMaterializationStatus(nextSessionId).renderable) { if (getSyncSessionMaterializationStatus(request.sessionId, request.directory).renderable) {
continue; continue;
} }
sessionPrefetchInFlightRef.current.add(nextSessionId); const key = requestKey(request);
void ensureSessionRenderable(nextSessionId) sessionPrefetchInFlightRef.current.add(key);
void prefetchSession(request.sessionId, request.directory)
.catch(() => undefined) .catch(() => undefined)
.finally(() => { .finally(() => {
sessionPrefetchInFlightRef.current.delete(nextSessionId); sessionPrefetchInFlightRef.current.delete(key);
pumpSessionPrefetchQueue(); pumpSessionPrefetchQueue();
}); });
} }
}, [ensureSessionRenderable, prefetchDisabled]); }, [enabled, prefetchDisabled, prefetchSession, requestKey]);
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => { const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') { const sessionId = session?.id;
const directory = sessionDirectory(session);
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
return; return;
} }
const request = { sessionId, directory, generation: generationRef.current };
const key = requestKey(request);
// Already renderable in sync // Already renderable in sync
if (getSyncSessionMaterializationStatus(sessionId).renderable) { if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
return; return;
} }
if (sessionPrefetchInFlightRef.current.has(sessionId)) { if (sessionPrefetchInFlightRef.current.has(key)) {
return; return;
} }
if (sessionPrefetchQueueRef.current.includes(sessionId)) { if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
return; return;
} }
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) { const existingTimer = sessionPrefetchTimersRef.current.get(key);
sessionPrefetchQueueRef.current.shift();
}
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
if (existingTimer !== undefined) { if (existingTimer !== undefined) {
window.clearTimeout(existingTimer); window.clearTimeout(existingTimer);
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
sessionPrefetchTimersRef.current.delete(sessionId); sessionPrefetchTimersRef.current.delete(key);
sessionPrefetchQueueRef.current.push(sessionId); if (request.generation !== generationRef.current) return;
const queue = sessionPrefetchQueueRef.current;
if (queue.length >= SESSION_PREFETCH_PENDING_LIMIT) {
queue.shift();
}
queue.push(request);
pumpSessionPrefetchQueue(); pumpSessionPrefetchQueue();
}, SESSION_PREFETCH_HOVER_DELAY_MS); }, SESSION_PREFETCH_HOVER_DELAY_MS);
sessionPrefetchTimersRef.current.set(sessionId, timer); sessionPrefetchTimersRef.current.set(key, timer);
}, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]); }, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
React.useEffect(() => {
clearPendingPrefetches();
}, [clearPendingPrefetches, currentSessionId, enabled, prefetchDisabled]);
// Wait for the active session to finish loading before prefetching neighbors. // 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. // On rapid session switches the timer resets, so only the final session triggers prefetch.
React.useEffect(() => { React.useEffect(() => {
if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) { if (!enabled || prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return; if (currentIndex < 0) return;
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS); }, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]); }, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
React.useEffect(() => { React.useEffect(() => {
if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) { if (!enabled || prefetchDisabled || !currentSessionId || recentSessions.length === 0) {
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const currentIndex = recentSessionIds.indexOf(currentSessionId); const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return; if (currentIndex < 0) return;
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]); scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS); }, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]); }, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
React.useEffect(() => { React.useEffect(() => clearPendingPrefetches, [clearPendingPrefetches]);
const prefetchTimers = sessionPrefetchTimersRef.current; };
return () => {
prefetchTimers.forEach((timer) => { export const SessionPrefetchEffect: React.FC<Omit<Args, 'currentSessionId'>> = (args) => {
clearTimeout(timer); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
}); useSessionPrefetch({ ...args, currentSessionId });
prefetchTimers.clear(); return null;
sessionPrefetchQueueRef.current = [];
};
}, []);
}; };
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
type Args = { type Args = {
enabled?: boolean;
isSessionSearchOpen: boolean; isSessionSearchOpen: boolean;
setIsSessionSearchOpen: (open: boolean) => void; setIsSessionSearchOpen: (open: boolean) => void;
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>; sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
@@ -8,13 +9,14 @@ type Args = {
}; };
export const useSessionSearchEffects = ({ export const useSessionSearchEffects = ({
enabled = true,
isSessionSearchOpen, isSessionSearchOpen,
setIsSessionSearchOpen, setIsSessionSearchOpen,
sessionSearchInputRef, sessionSearchInputRef,
sessionSearchContainerRef, sessionSearchContainerRef,
}: Args): void => { }: Args): void => {
React.useEffect(() => { React.useEffect(() => {
if (!isSessionSearchOpen || typeof window === 'undefined') { if (!enabled || !isSessionSearchOpen || typeof window === 'undefined') {
return; return;
} }
const raf = window.requestAnimationFrame(() => { const raf = window.requestAnimationFrame(() => {
@@ -22,10 +24,10 @@ export const useSessionSearchEffects = ({
sessionSearchInputRef.current?.select(); sessionSearchInputRef.current?.select();
}); });
return () => window.cancelAnimationFrame(raf); return () => window.cancelAnimationFrame(raf);
}, [isSessionSearchOpen, sessionSearchInputRef]); }, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
React.useEffect(() => { React.useEffect(() => {
if (!isSessionSearchOpen || typeof document === 'undefined') { if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
return; return;
} }
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
@@ -38,5 +40,5 @@ export const useSessionSearchEffects = ({
}; };
document.addEventListener('mousedown', handlePointerDown); document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown); return () => document.removeEventListener('mousedown', handlePointerDown);
}, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]); }, [enabled, isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
}; };
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
import { dedupeSessionsById, normalizePath } from '../utils'; import { dedupeSessionsById, normalizePath } from '../utils';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore'; import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
type ProjectItem = { type ProjectItem = {
id: string; id: string;
@@ -21,6 +22,19 @@ type ProjectSection = {
groups: SessionGroup[]; 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 = { type Args = {
normalizedProjects: ProjectItem[]; normalizedProjects: ProjectItem[];
getSessionsForProject: (projectId: string) => Session[]; getSessionsForProject: (projectId: string) => Session[];
@@ -59,26 +73,67 @@ export const useSessionSidebarSections = (args: Args) => {
buildGroupSearchText, buildGroupSearchText,
foldersMap, foldersMap,
} = args; } = args;
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
const projectSections = React.useMemo<ProjectSection[]>(() => { const projectSections = React.useMemo<ProjectSection[]>(() => {
return normalizedProjects.map((project) => { const previousCache = projectSectionCacheRef.current;
const projectSessions = dedupeSessionsById([ const nextCache = new Map<string, ProjectSectionCacheEntry>();
...getSessionsForProject(project.id), let reusedSections = 0;
...getArchivedSessionsForProject(project.id), let rebuiltSections = 0;
]); const sameSessions = (left: Session[], right: Session[]): boolean => (
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; 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) const isRepo = projectRepoStatus.has(project.id)
? Boolean(projectRepoStatus.get(project.id)) ? Boolean(projectRepoStatus.get(project.id))
: lastRepoStatus; : 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( const groups = buildGroupedSessions(
projectSessions, projectSessions,
project.normalizedPath, project.normalizedPath,
worktreesForProject, worktreesForProject,
projectRootBranches.get(project.id) ?? null, rootBranch,
isRepo, 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, normalizedProjects,
getSessionsForProject, getSessionsForProject,
@@ -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<Session, 'id'> => ({ 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);
});
});
@@ -1,46 +1,24 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { prunePinnedSessionIds } from './pinnedSessionCleanup';
type SafeStorageLike = { type SafeStorageLike = {
getItem: (key: string) => string | null; getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void; setItem: (key: string, value: string) => void;
removeItem?: (key: string) => void;
}; };
type Keys = { type Keys = {
sessionExpanded: string; 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; projectCollapse: string;
sessionPinned: string;
groupOrder: string; groupOrder: string;
projectActiveSession: string;
groupCollapse: string; groupCollapse: string;
}; };
const LEGACY_EXPANSION_CONTEXT_PREFIXES = [
'project:active:',
'project:archived:',
'recent:active:',
'recent:archived:',
];
type Args = { type Args = {
isVSCode: boolean; isVSCode: boolean;
hasAuthoritativeGlobalSessions: boolean;
safeStorage: SafeStorageLike; safeStorage: SafeStorageLike;
keys: Keys; keys: Keys;
sessions: Session[];
pinnedSessionIds: Set<string>;
setPinnedSessionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
groupOrderByProject: Map<string, string[]>; groupOrderByProject: Map<string, string[]>;
activeSessionByProject: Map<string, string>;
collapsedGroups: Set<string>; collapsedGroups: Set<string>;
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>; setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>; setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
@@ -49,13 +27,9 @@ type Args = {
export const useSidebarPersistence = (args: Args) => { export const useSidebarPersistence = (args: Args) => {
const { const {
isVSCode, isVSCode,
hasAuthoritativeGlobalSessions,
safeStorage, safeStorage,
keys, keys,
sessions,
setPinnedSessionIds,
groupOrderByProject, groupOrderByProject,
activeSessionByProject,
collapsedGroups, collapsedGroups,
setExpandedParents, setExpandedParents,
setCollapsedProjects, setCollapsedProjects,
@@ -115,28 +89,6 @@ export const useSidebarPersistence = (args: Args) => {
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string'))); 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<string>();
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); const storedProjects = safeStorage.getItem(keys.projectCollapse);
if (storedProjects) { if (storedProjects) {
@@ -148,17 +100,7 @@ export const useSidebarPersistence = (args: Args) => {
} catch { } catch {
// ignored // ignored
} }
}, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]); }, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
React.useEffect(() => {
if (!hasAuthoritativeGlobalSessions) {
return;
}
setPinnedSessionIds((prev) => {
return prunePinnedSessionIds(sessions, prev);
});
}, [hasAuthoritativeGlobalSessions, sessions, setPinnedSessionIds]);
React.useEffect(() => { React.useEffect(() => {
try { try {
@@ -169,15 +111,6 @@ export const useSidebarPersistence = (args: Args) => {
} }
}, [groupOrderByProject, keys.groupOrder, safeStorage]); }, [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(() => { React.useEffect(() => {
try { try {
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups))); safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
@@ -1,17 +1,18 @@
import React from 'react'; import React from 'react';
type Args = { type Args = {
enabled?: boolean;
isDesktopShellRuntime: boolean; isDesktopShellRuntime: boolean;
projectSections: unknown[]; projectSections: unknown[];
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>; projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
}; };
export const useStickyProjectHeaders = (args: Args): Set<string> => { export const useStickyProjectHeaders = (args: Args): Set<string> => {
const { isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args; const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set()); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
React.useEffect(() => { React.useEffect(() => {
if (!isDesktopShellRuntime) { if (!enabled || !isDesktopShellRuntime) {
return; return;
} }
@@ -24,12 +25,16 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
} }
setStuckProjectHeaders((prev) => { setStuckProjectHeaders((prev) => {
const next = new Set(prev);
if (!entry.isIntersecting) { if (!entry.isIntersecting) {
if (prev.has(projectId)) return prev;
const next = new Set(prev);
next.add(projectId); next.add(projectId);
} else { return next;
next.delete(projectId);
} }
if (!prev.has(projectId)) return prev;
const next = new Set(prev);
next.delete(projectId);
return next; return next;
}); });
}); });
@@ -44,7 +49,7 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
}); });
return () => observer.disconnect(); return () => observer.disconnect();
}, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]); }, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
return stuckProjectHeaders; return stuckProjectHeaders;
}; };
@@ -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")
})
})
@@ -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<DirectoryBootstrapPriority, number> = {
selected: 0,
"active-project": 1,
expanded: 2,
visible: 3,
background: 4,
}
export function buildSessionBootstrapDemands(input: {
projectSections: BootstrapProjectSection[]
activeProjectId: string | null
collapsedProjects: ReadonlySet<string>
collapsedGroups: ReadonlySet<string>
currentDirectory: string | null
currentSessionDirectory: string | null
}): DirectoryBootstrapDemand[] {
const byDirectory = new Map<string, DirectoryBootstrapDemand>()
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()]
}
@@ -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]);
});
});
@@ -1,3 +1,5 @@
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import type { SessionNode } from './types'; import type { SessionNode } from './types';
/** /**
@@ -10,7 +12,6 @@ import type { SessionNode } from './types';
* each child's extras object. * each child's extras object.
*/ */
export type SessionNodeChildRenderExtras = { export type SessionNodeChildRenderExtras = {
subtreeContainsActive: Set<string>;
subtreeContainsEditing: Set<string>; subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null; menuOpenSessionId: string | null;
nodeStructureKey: string; nodeStructureKey: string;
@@ -24,7 +25,7 @@ export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRende
* Walk `nodes` and add `node.session.id` to `result` for every node * Walk `nodes` and add `node.session.id` to `result` for every node
* whose subtree contains `targetId`. This is used to precompute, once * whose subtree contains `targetId`. This is used to precompute, once
* per SessionGroupSection render, which rows need to update when * 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` * turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
* into a single O(M) `Set.has` per row. * into a single O(M) `Set.has` per row.
*/ */
@@ -69,12 +70,45 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
return false; return false;
}; };
export const selectFolderRootNodes = (
sessionIds: string[],
nodeBySessionId: ReadonlyMap<string, SessionNode>,
): 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<string>();
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<object, number>();
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 * Build a key encoding descendant IDs and session object versions. This lets
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only * row memoization detect one changed descendant without recursively comparing
* rebuild of the tree (which happens on every `buildGroupedSessions` * every subtree after a reference-only grouping rebuild.
* pass) can be detected with a single string compare instead of a
* recursive walk per row.
*/ */
export const computeNodeStructureKey = (node: SessionNode): string => { export const computeNodeStructureKey = (node: SessionNode): string => {
if (node.children.length === 0) { if (node.children.length === 0) {
@@ -82,15 +116,49 @@ export const computeNodeStructureKey = (node: SessionNode): string => {
} }
const childKeys = node.children.map((child) => { const childKeys = node.children.map((child) => {
const childVersion = getSessionObjectVersion(child.session);
if (child.children.length === 0) { 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('|'); return childKeys.join('|');
}; };
export const nodeHasPinnedMembershipChange = (
prevNode: SessionNode,
nextNode: SessionNode,
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
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 * 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. * menu is open. Only one row can have its menu open at a time.
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { isPathWithinProject } from './utils'; import {
isPathWithinProject,
selectExpandedParentKeysForContext,
toggleExpandedParentKey,
} from './utils';
describe('isPathWithinProject', () => { describe('isPathWithinProject', () => {
test('matches child directories for root projects', () => { test('matches child directories for root projects', () => {
@@ -26,3 +30,48 @@ describe('isPathWithinProject', () => {
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true); 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]));
});
});
@@ -1,11 +1,36 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
import { getCurrentIntlLocale } from '@/lib/i18n'; import { getCurrentIntlLocale } from '@/lib/i18n';
import { formatMessage, useI18nStore } from '@/lib/i18n/store'; import { formatMessage, useI18nStore } from '@/lib/i18n/store';
import { normalizePath } from '@/lib/pathNormalization'; import { normalizePath } from '@/lib/pathNormalization';
export { normalizePath }; export { normalizePath };
export const selectExpandedParentKeysForContext = (
previous: Set<string>,
expanded: ReadonlySet<string>,
context: 'project' | 'recent',
): Set<string> => {
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<string>,
key: string,
): Set<string> => {
const next = new Set(expanded);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
};
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) => const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
formatMessage(useI18nStore.getState().dictionary, key, params); formatMessage(useI18nStore.getState().dictionary, key, params);
@@ -132,8 +157,8 @@ export const compareSessionsByPinnedAndTime = (
b: Session, b: Session,
pinnedSessionIds: Set<string>, pinnedSessionIds: Set<string>,
): number => { ): number => {
const aPinned = pinnedSessionIds.has(a.id); const aPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(a), a.id);
const bPinned = pinnedSessionIds.has(b.id); const bPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(b), b.id);
if (aPinned !== bPinned) { if (aPinned !== bPinned) {
return aPinned ? -1 : 1; return aPinned ? -1 : 1;
} }
@@ -53,6 +53,7 @@ type CommandEntry = {
}; };
type FileHit = { path: string; name: string; relativePath: string }; type FileHit = { path: string; name: string; relativePath: string };
const EMPTY_SESSIONS: Session[] = [];
const normalizePath = (value: string): string => { const normalizePath = (value: string): string => {
if (!value) return ''; if (!value) return '';
@@ -85,7 +86,10 @@ export const CommandPalette: React.FC = () => {
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); 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 currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const activeProject = useProjectsStore((s) => s.getActiveProject()); const activeProject = useProjectsStore((s) => s.getActiveProject());
const projects = useProjectsStore((s) => s.projects); const projects = useProjectsStore((s) => s.projects);
@@ -4,15 +4,16 @@ import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
type ChatViewProps = { type ChatViewProps = {
active?: boolean;
readOnly?: boolean; readOnly?: boolean;
}; };
export const ChatView: React.FC<ChatViewProps> = ({ readOnly = false }) => { export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
return ( return (
<ChatErrorBoundary sessionId={currentSessionId || undefined}> <ChatErrorBoundary sessionId={currentSessionId || undefined}>
<ChatContainer readOnly={readOnly} /> <ChatContainer active={active} readOnly={readOnly} />
</ChatErrorBoundary> </ChatErrorBoundary>
); );
}; };
@@ -3,6 +3,7 @@ import React from 'react';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GitStatus } from '@/lib/api/types'; import type { GitStatus } from '@/lib/api/types';
import { import {
@@ -667,6 +668,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setIsLoading(true); setIsLoading(true);
let cancelled = false; let cancelled = false;
const runtimeKey = getRuntimeKey();
const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES; const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES;
const fetchPromise = isImageFile(file.path) const fetchPromise = isImageFile(file.path)
? git.getGitFileDiff(directory, { path: file.path, staged }) ? git.getGitFileDiff(directory, { path: file.path, staged })
@@ -697,7 +699,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
if (staged) { if (staged) {
setStagedDiffData(nextDiff); setStagedDiffData(nextDiff);
} else { } else {
setDiff(directory, file.path, nextDiff); setDiff(directory, file.path, nextDiff, runtimeKey);
} }
} }
setIsLoading(false); setIsLoading(false);
@@ -1494,6 +1496,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
} }
setOpeningEditorFilePath(filePath); setOpeningEditorFilePath(filePath);
const runtimeKey = getRuntimeKey();
try { try {
let targetLine: number | null = null; let targetLine: number | null = null;
@@ -1525,7 +1528,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
isBinary: response.isBinary, isBinary: response.isBinary,
}; };
if (!activeDiffStaged) { if (!activeDiffStaged) {
setDiff(effectiveDirectory, filePath, diffForNavigation); setDiff(effectiveDirectory, filePath, diffForNavigation, runtimeKey);
} }
} }
+11 -5
View File
@@ -810,7 +810,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath);
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix); const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix);
@@ -922,6 +921,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const diagramEditorRef = React.useRef<React.ComponentRef<typeof DiagramEditor>>(null); const diagramEditorRef = React.useRef<React.ComponentRef<typeof DiagramEditor>>(null);
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null); const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
const activeFileLoadIdRef = React.useRef(0); const activeFileLoadIdRef = React.useRef(0);
const loadingFilePathRef = React.useRef<string | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
const [diagramSaved, setDiagramSaved] = React.useState(false); const [diagramSaved, setDiagramSaved] = React.useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled); const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
@@ -1952,7 +1952,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (root) { if (root) {
setSelectedPath(root, node.path); setSelectedPath(root, node.path);
addOpenPath(root, node.path);
void ensurePathVisible(node.path, false); void ensurePathVisible(node.path, false);
} }
@@ -1966,7 +1965,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (isMobile) { if (isMobile) {
setShowMobilePageContent(true); setShowMobilePageContent(true);
} }
}, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]); }, [ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
React.useEffect(() => { React.useEffect(() => {
if (!selectedFile?.path) { if (!selectedFile?.path) {
@@ -1979,16 +1978,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
React.useEffect(() => { React.useEffect(() => {
if (!selectedFile) { if (!selectedFile) {
activeFileLoadIdRef.current += 1; activeFileLoadIdRef.current += 1;
loadingFilePathRef.current = null;
setFileLoading(false); setFileLoading(false);
return; return;
} }
if (loadedFilePath === selectedFile.path) { if (loadedFilePath === selectedFile.path || loadingFilePathRef.current === selectedFile.path) {
return; return;
} }
// Selection changes are guarded; this effect is also what restores persisted tabs on mount. // Selection changes are guarded; this effect is also what restores persisted tabs on mount.
void loadSelectedFile(selectedFile); const loadingPath = selectedFile.path;
loadingFilePathRef.current = loadingPath;
void loadSelectedFile(selectedFile).finally(() => {
if (loadingFilePathRef.current === loadingPath) {
loadingFilePathRef.current = null;
}
});
}, [loadSelectedFile, loadedFilePath, selectedFile]); }, [loadSelectedFile, loadedFilePath, selectedFile]);
// Sync isDirty to a ref so the polling interval can read the latest value // Sync isDirty to a ref so the polling interval can read the latest value
@@ -615,9 +615,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const handleAttachSelection = React.useCallback(() => { const handleAttachSelection = React.useCallback(() => {
const selection = terminalControllerRef.current?.getSelection(); const selection = terminalControllerRef.current?.getSelection();
const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null); const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null);
if (!selection || !sessionKey || !activeTab) return; if (!selection || !sessionKey || !activeTab || !effectiveDirectory) return;
addContextDraft({ addContextDraft({ directory: effectiveDirectory, sessionKey }, {
sessionKey,
source: 'terminal', source: 'terminal',
fileLabel: activeTab.label, fileLabel: activeTab.label,
startLine: selection.startLine, startLine: selection.startLine,
@@ -626,7 +625,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
language: activeTab.terminalSessionId ?? activeTab.id, language: activeTab.terminalSessionId ?? activeTab.id,
text: '', text: '',
}); });
}, [activeTab, addContextDraft, currentSessionId, newSessionDraft?.open]); }, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
const handleSelectTab = React.useCallback( const handleSelectTab = React.useCallback(
(tabId: string) => { (tabId: string) => {
@@ -382,8 +382,8 @@ export const PullRequestSection: React.FC<{
const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork)); const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork));
const prStatusKey = React.useMemo( const prStatusKey = React.useMemo(
() => getGitHubPrStatusKey(directory, branch), () => getGitHubPrStatusKey(directory, branch, selectedRemote?.name ?? null),
[directory, branch], [directory, branch, selectedRemote?.name],
); );
const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]); const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]);
+6 -150
View File
@@ -1,161 +1,17 @@
import React, { type JSX, type ReactNode } from 'react'; import React, { type JSX, type ReactNode } from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types'; import type { RuntimeAPIs } from '@/lib/api/types';
import { import { createContentCachedFiles } from '@/contexts/content-cache-owner';
approxStringBytes,
evictContentLru,
setContentBytes,
touchContent as touchContentLru,
removeContentBytes,
} from '@/sync/content-cache';
/** Wrap a FilesAPI with an in-memory LRU content cache. */
function withContentCache(files: FilesAPI): FilesAPI {
const cache = new Map<string, { content: string; path: string; size?: number; mtimeMs?: number }>();
const removeCacheEntry = (path: string) => {
cache.delete(path);
removeContentBytes(path);
};
const removeCacheEntriesByPrefix = (path: string) => {
const prefix = path.endsWith('/') ? path : `${path}/`;
for (const key of cache.keys()) {
if (key === path || key.startsWith(prefix)) {
removeCacheEntry(key);
}
}
};
/** Whether cached metadata still matches the file on disk. */
const statMatches = (
cached: { size?: number; mtimeMs?: number },
latest: { isFile: boolean; size: number; mtimeMs?: number },
): boolean => {
if (!latest.isFile) return false;
// If mtimeMs is available on both sides, it is the strongest signal.
if (cached.mtimeMs !== undefined && latest.mtimeMs !== undefined) {
return cached.mtimeMs === latest.mtimeMs && cached.size === latest.size;
}
return cached.size === latest.size;
};
const syncCacheEntry = (
path: string,
result: { content: string; path: string },
stat?: { isFile: boolean; size: number; mtimeMs?: number } | null,
): { content: string; path: string } => {
const bytes = approxStringBytes(result.content);
cache.set(path, {
...result,
size: stat?.isFile ? stat.size : undefined,
mtimeMs: stat?.isFile ? stat.mtimeMs : undefined,
});
setContentBytes(path, bytes);
const keep = new Set<string>();
evictContentLru(keep, (evictPath) => {
cache.delete(evictPath);
});
return result;
};
const readFreshFile = async (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]): Promise<{ content: string; path: string }> => {
// stat → read → stat to avoid TOCTOU:
// if the file changes between read and either stat, metadata won't match and we retry.
const statBefore = await files.statFile?.(path, options).catch(() => null);
const result = await files.readFile!(path, options);
const statAfter = await files.statFile?.(path, options).catch(() => null);
// If both stats are available and agree, the read was atomic with respect to file changes.
if (statBefore && statAfter && statBefore.isFile && statAfter.isFile) {
if (statBefore.size === statAfter.size && statBefore.mtimeMs === statAfter.mtimeMs) {
return syncCacheEntry(path, result, statAfter);
}
// File changed during read — discard and re-read once.
const retryStatBefore = await files.statFile?.(path, options).catch(() => null);
const retry = await files.readFile!(path, options);
const retryStat = await files.statFile?.(path, options).catch(() => null);
// Accept retry only if file was stable across the read.
if (retryStatBefore && retryStat && retryStatBefore.isFile && retryStat.isFile
&& retryStatBefore.size === retryStat.size && retryStatBefore.mtimeMs === retryStat.mtimeMs) {
return syncCacheEntry(path, retry, retryStat);
}
// Best-effort: file was still changing, cache what we got. Next hit will re-validate.
return syncCacheEntry(path, retry, retryStat);
}
return syncCacheEntry(path, result, statAfter ?? statBefore);
};
const cachedReadFile: FilesAPI['readFile'] = files.readFile
? async (path: string, options) => {
if (options?.allowOutsideWorkspace) {
return readFreshFile(path, options);
}
const hit = cache.get(path);
if (hit) {
// Validate cached entry is still fresh
if (files.statFile) {
const latest = await files.statFile(path, options).catch(() => {
removeCacheEntry(path);
return null;
});
if (!latest || !statMatches(hit, latest)) {
removeCacheEntry(path);
return readFreshFile(path, options);
}
}
touchContentLru(path);
return { content: hit.content, path: hit.path };
}
return readFreshFile(path, options);
}
: undefined;
// Invalidate cache on writes, deletes, renames
const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile
? async (path, content) => {
removeCacheEntry(path);
return files.writeFile!(path, content);
}
: undefined;
const cachedDelete: FilesAPI['delete'] = files.delete
? async (path) => {
removeCacheEntriesByPrefix(path);
return files.delete!(path);
}
: undefined;
const cachedRename: FilesAPI['rename'] = files.rename
? async (oldPath, newPath) => {
removeCacheEntriesByPrefix(oldPath);
removeCacheEntriesByPrefix(newPath);
return files.rename!(oldPath, newPath);
}
: undefined;
return {
...files,
readFile: cachedReadFile,
writeFile: cachedWriteFile,
delete: cachedDelete,
rename: cachedRename,
};
}
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element { export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
const cachedFiles = React.useMemo(() => createContentCachedFiles(apis.files), [apis.files]);
React.useEffect(() => () => cachedFiles.dispose(), [cachedFiles]);
const cachedApis = React.useMemo<RuntimeAPIs>( const cachedApis = React.useMemo<RuntimeAPIs>(
() => ({ () => ({
...apis, ...apis,
files: withContentCache(apis.files), files: cachedFiles.files,
}), }),
[apis], [apis, cachedFiles],
); );
return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>; return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>;
} }
@@ -2,6 +2,7 @@ import React, {
useEffect, useEffect,
useMemo, useMemo,
useCallback, useCallback,
useRef,
useState, useState,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
@@ -24,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { getInitialSystemPreference, readEmbeddedThemeSearchParams } from './theme-embedded-bootstrap'; import { getInitialSystemPreference, readEmbeddedThemeSearchParams } from './theme-embedded-bootstrap';
import { isValidTheme } from './theme-validation'; import { isValidTheme } from './theme-validation';
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
type ThemePreferences = { type ThemePreferences = {
themeMode: ThemeMode; themeMode: ThemeMode;
@@ -172,8 +174,8 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return existing || null; return existing || null;
}); });
const isVSCode = useMemo(() => isVSCodeRuntime(), []); const isVSCode = useMemo(() => isVSCodeRuntime(), []);
const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []);
const isDesktopShell = useMemo(() => detectDesktopShell(), []); const isDesktopShell = useMemo(() => detectDesktopShell(), []);
const customThemesRequestRef = useRef(0);
const receivesParentThemeSync = useMemo(() => { const receivesParentThemeSync = useMemo(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return false; return false;
@@ -249,11 +251,13 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return; return;
} }
const runtimeKey = getRuntimeKey();
const request = ++customThemesRequestRef.current;
setCustomThemesLoading(true); setCustomThemesLoading(true);
try { try {
const res = await runtimeFetch('/api/config/themes', { const res = await runtimeFetch('/api/config/themes', {
method: 'GET', method: 'GET',
credentials: isLocalDesktopOrigin ? 'omit' : 'include', credentials: isDesktopLocalOriginActive() ? 'omit' : 'include',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
}, },
@@ -269,20 +273,31 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
} }
const payload = await res.json(); const payload = await res.json();
if (request !== customThemesRequestRef.current || runtimeKey !== getRuntimeKey()) return;
const incoming = Array.isArray(payload?.themes) ? payload.themes : []; const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
const normalized = incoming.filter(isValidTheme); const normalized = incoming.filter(isValidTheme);
setCustomThemes(normalized); setCustomThemes(normalized);
} catch { } catch {
// ignore // ignore
} finally { } finally {
if (request === customThemesRequestRef.current && runtimeKey === getRuntimeKey()) {
setCustomThemesLoading(false); setCustomThemesLoading(false);
} }
}, [isLocalDesktopOrigin, isVSCode]); }
}, [isVSCode]);
useEffect(() => { useEffect(() => {
void reloadCustomThemes(); void reloadCustomThemes();
}, [reloadCustomThemes]); }, [reloadCustomThemes]);
useEffect(() => subscribeRuntimeEndpointChanged((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey || isVSCode) return;
customThemesRequestRef.current += 1;
setCustomThemes([]);
setCustomThemesLoading(false);
void reloadCustomThemes();
}), [isVSCode, reloadCustomThemes]);
useEffect(() => { useEffect(() => {
if (!isVSCode) { if (!isVSCode) {
return; return;
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test"
import type { FilesAPI } from "@/lib/api/types"
import { createContentCachedFiles } from "./content-cache-owner"
const deferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => { resolve = res })
return { promise, resolve }
}
describe("content cache owner", () => {
test("reuses only strongly validated content", async () => {
let reads = 0
const files = {
readFile: async (path: string) => ({ path, content: `value-${++reads}` }),
statFile: async () => ({ isFile: true, isDirectory: false, size: 7, mtimeMs: 1 }),
} as unknown as FilesAPI
const owner = createContentCachedFiles(files)
expect((await owner.files.readFile!("file.ts")).content).toBe("value-1")
expect((await owner.files.readFile!("file.ts")).content).toBe("value-1")
expect(reads).toBe(1)
owner.dispose()
})
test("does not retain size-only reads without mtime", async () => {
let reads = 0
const owner = createContentCachedFiles({
readFile: async (path: string) => ({ path, content: `value-${++reads}` }),
statFile: async () => ({ isFile: true, isDirectory: false, size: 7 }),
} as unknown as FilesAPI)
await owner.files.readFile!("file.ts")
await owner.files.readFile!("file.ts")
expect(reads).toBe(2)
owner.dispose()
})
test("retries a read that overlaps a write", async () => {
const firstRead = deferred<{ path: string; content: string }>()
let content = "old"
let reads = 0
const owner = createContentCachedFiles({
readFile: async (path: string) => {
reads += 1
return reads === 1 ? firstRead.promise : { path, content }
},
statFile: async () => ({ isFile: true, isDirectory: false, size: content.length, mtimeMs: content === "old" ? 1 : 2 }),
writeFile: async (_path: string, next: string) => { content = next },
} as unknown as FilesAPI)
const reading = owner.files.readFile!("file.ts")
await owner.files.writeFile!("file.ts", "new")
firstRead.resolve({ path: "file.ts", content: "old" })
expect((await reading).content).toBe("new")
expect(reads).toBe(2)
owner.dispose()
})
test("separates identical paths by directory scope", async () => {
let reads = 0
const owner = createContentCachedFiles({
readFile: async (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]) => ({ path, content: `${options?.directory}-${++reads}` }),
statFile: async () => ({ isFile: true, isDirectory: false, size: 1, mtimeMs: 1 }),
} as unknown as FilesAPI)
const first = await owner.files.readFile!("file.ts", { directory: "/a" })
const second = await owner.files.readFile!("file.ts", { directory: "/b" })
expect(first.content).toBe("/a-1")
expect(second.content).toBe("/b-2")
owner.dispose()
})
})
@@ -0,0 +1,134 @@
import type { FilesAPI } from '@/lib/api/types';
import { subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
const MAX_ENTRIES = 40;
const MAX_BYTES = 20 * 1024 * 1024;
type Entry = { content: string; path: string; sourcePath: string; size: number; mtimeMs: number; bytes: number };
export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; dispose: () => void } {
const cache = new Map<string, Entry>();
let totalBytes = 0;
let generation = 0;
let active = true;
let mutationBarrier = Promise.resolve();
const cacheKey = (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]) =>
JSON.stringify([options?.directory ?? '', path]);
const contentBytes = (content: string) => new TextEncoder().encode(content).byteLength;
const removeEntry = (key: string) => {
const entry = cache.get(key);
if (entry) totalBytes = Math.max(0, totalBytes - entry.bytes);
cache.delete(key);
};
const removePrefix = (path: string) => {
const prefix = path.endsWith('/') ? path : `${path}/`;
for (const [key, entry] of cache) {
if (entry.sourcePath === path || entry.sourcePath.startsWith(prefix)) removeEntry(key);
}
};
const metadataMatches = (cached: Entry, latest: { isFile: boolean; size: number; mtimeMs?: number }) => (
latest.isFile
&& latest.mtimeMs !== undefined
&& cached.mtimeMs === latest.mtimeMs
&& cached.size === latest.size
);
const cacheResult = (
key: string,
sourcePath: string,
result: { content: string; path: string },
stat: { isFile: boolean; size: number; mtimeMs?: number },
) => {
if (!active || !stat.isFile || stat.mtimeMs === undefined) return result;
const bytes = contentBytes(result.content);
if (bytes > MAX_BYTES) return result;
removeEntry(key);
cache.set(key, { ...result, sourcePath, size: stat.size, mtimeMs: stat.mtimeMs, bytes });
totalBytes += bytes;
while (cache.size > MAX_ENTRIES || totalBytes > MAX_BYTES) {
const oldest = cache.keys().next().value;
if (!oldest) break;
removeEntry(oldest);
}
return result;
};
const readFresh = async (
key: string,
path: string,
options: Parameters<NonNullable<FilesAPI['readFile']>>[1] | undefined,
capturedGeneration: number,
): Promise<{ content: string; path: string }> => {
const before = await files.statFile?.(path, options).catch(() => null);
const result = await files.readFile!(path, options);
const after = await files.statFile?.(path, options).catch(() => null);
if (!active) throw new Error('File read invalidated by runtime change');
if (capturedGeneration !== generation) return cachedReadFile!(path, options);
const stable = before && after && before.isFile && after.isFile
&& before.mtimeMs !== undefined && after.mtimeMs !== undefined
&& before.size === after.size && before.mtimeMs === after.mtimeMs;
return stable ? cacheResult(key, path, result, after) : result;
};
const cachedReadFile: FilesAPI['readFile'] = files.readFile
? async (path, options) => {
await mutationBarrier;
if (!active) throw new Error('File cache owner disposed');
const capturedGeneration = generation;
if (options?.allowOutsideWorkspace) return files.readFile!(path, options);
const key = cacheKey(path, options);
const hit = cache.get(key);
if (!hit) return readFresh(key, path, options, capturedGeneration);
const latest = await files.statFile?.(path, options).catch(() => null);
if (!active) throw new Error('File read invalidated by runtime change');
if (capturedGeneration !== generation) return cachedReadFile!(path, options);
if (!latest || !metadataMatches(hit, latest)) {
removeEntry(key);
return readFresh(key, path, options, capturedGeneration);
}
cache.delete(key);
cache.set(key, hit);
return { content: hit.content, path: hit.path };
}
: undefined;
const mutate = async <T>(paths: string[], operation: () => Promise<T>): Promise<T> => {
const previous = mutationBarrier;
let release!: () => void;
mutationBarrier = new Promise<void>((resolve) => { release = resolve; });
await previous;
generation += 1;
paths.forEach(removePrefix);
try {
return await operation();
} finally {
paths.forEach(removePrefix);
generation += 1;
release();
}
};
const cachedFiles: FilesAPI = {
...files,
readFile: cachedReadFile,
writeFile: files.writeFile ? (path, content) => mutate([path], () => files.writeFile!(path, content)) : undefined,
delete: files.delete ? (path) => mutate([path], () => files.delete!(path)) : undefined,
rename: files.rename ? (oldPath, newPath) => mutate([oldPath, newPath], () => files.rename!(oldPath, newPath)) : undefined,
};
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
active = false;
generation += 1;
cache.clear();
totalBytes = 0;
});
return {
files: cachedFiles,
dispose: () => {
active = false;
generation += 1;
cache.clear();
totalBytes = 0;
unsubscribeRuntime();
},
};
}
+3 -2
View File
@@ -69,6 +69,7 @@ export const usePwaManifestSync = () => {
}, [currentSessionId, sessions]); }, [currentSessionId, sessions]);
const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]); const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]);
const hasRecentShortcuts = recentShortcuts.length > 0;
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined' || !isWebRuntime()) { if (typeof window === 'undefined' || !isWebRuntime()) {
@@ -76,7 +77,7 @@ export const usePwaManifestSync = () => {
} }
try { try {
if (recentShortcuts.length === 0) { if (!hasRecentShortcuts) {
localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY); localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY);
} else { } else {
localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature); localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature);
@@ -87,5 +88,5 @@ export const usePwaManifestSync = () => {
const win = window as ManifestSyncWindow; const win = window as ManifestSyncWindow;
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.(); win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
}, [recentShortcuts, signature]); }, [hasRecentShortcuts, signature]);
}; };
@@ -167,7 +167,7 @@ describe('buildQueuedAutoSendPayload', () => {
]); ]);
expect(payload).not.toBeNull(); expect(payload).not.toBeNull();
await sendQueuedAutoSendPayload('session-original', payload!, { await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
providerID: 'provider-1', providerID: 'provider-1',
modelID: 'model-1', modelID: 'model-1',
agent: 'agent-1', agent: 'agent-1',
@@ -185,7 +185,7 @@ describe('buildQueuedAutoSendPayload', () => {
undefined, undefined,
'variant-1', 'variant-1',
'normal', 'normal',
{ sessionId: 'session-original' }, { sessionId: 'session-original', directory: '/repo' },
]); ]);
}); });
}); });
@@ -1,13 +1,15 @@
import React from 'react'; import React from 'react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import { getMessageQueueKey, parseMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore'; import { useContextStore } from '@/stores/contextStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { getSyncSessionStatus } from '@/sync/sync-refs'; import { getDirectoryState } from '@/sync/sync-refs';
import { useDirectorySync } from '@/sync/sync-context'; import { useDirectorySync } from '@/sync/sync-context';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
type SessionStatusType = 'idle' | 'busy' | 'retry'; type SessionStatusType = 'idle' | 'busy' | 'retry';
@@ -67,6 +69,7 @@ type ResolvedQueuedSendConfig = {
export const sendQueuedAutoSendPayload = ( export const sendQueuedAutoSendPayload = (
sessionId: string, sessionId: string,
directory: string,
payload: QueuedAutoSendPayload, payload: QueuedAutoSendPayload,
resolved: ResolvedQueuedSendConfig, resolved: ResolvedQueuedSendConfig,
) => { ) => {
@@ -80,7 +83,7 @@ export const sendQueuedAutoSendPayload = (
undefined, undefined,
resolved.variant, resolved.variant,
'normal', 'normal',
{ sessionId }, { sessionId, directory },
); );
}; };
@@ -140,6 +143,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages); const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID); const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
const sessionStatusRecord = useDirectorySync((state) => state.session_status); const sessionStatusRecord = useDirectorySync((state) => state.session_status);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const inFlightSessionsRef = React.useRef<Set<string>>(new Set()); const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map()); const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
@@ -151,11 +155,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return; return;
} }
const dispatchSessionQueue = async (sessionId: string, queueSnapshot: QueuedMessage[]) => { const dispatchSessionQueue = async (target: MessageQueueTarget, queueSnapshot: QueuedMessage[]) => {
const { sessionId } = target;
const targetKey = getMessageQueueKey(target);
if (queueSnapshot.length === 0) { if (queueSnapshot.length === 0) {
return; return;
} }
if (inFlightSessionsRef.current.has(sessionId)) { if (inFlightSessionsRef.current.has(targetKey)) {
return; return;
} }
if (hasRecentAbort(sessionId)) { if (hasRecentAbort(sessionId)) {
@@ -166,7 +172,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return; return;
} }
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle'; const currentStatus = getDirectoryState(target.directory)?.session_status?.[sessionId]?.type ?? 'idle';
if (currentStatus !== 'idle') { if (currentStatus !== 'idle') {
return; return;
} }
@@ -176,9 +182,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return; return;
} }
const failure = sendFailuresRef.current.get(sessionId); const failure = sendFailuresRef.current.get(targetKey);
if (failure && failure.messageId !== payload.queuedMessageId) { if (failure && failure.messageId !== payload.queuedMessageId) {
sendFailuresRef.current.delete(sessionId); sendFailuresRef.current.delete(targetKey);
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) { } else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
return; return;
} }
@@ -192,28 +198,28 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return; return;
} }
inFlightSessionsRef.current.add(sessionId); inFlightSessionsRef.current.add(targetKey);
try { try {
await sendQueuedAutoSendPayload(sessionId, payload, { await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
providerID: resolved.providerID, providerID: resolved.providerID,
modelID: resolved.modelID, modelID: resolved.modelID,
agent: resolved.agent, agent: resolved.agent,
variant: resolved.variant, variant: resolved.variant,
}); });
useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId); useMessageQueueStore.getState().removeFromQueue(target, payload.queuedMessageId);
sendFailuresRef.current.delete(sessionId); sendFailuresRef.current.delete(targetKey);
} catch (error) { } catch (error) {
console.warn('[queue] queued auto-send failed:', error); console.warn('[queue] queued auto-send failed:', error);
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0; const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
const failures = priorFailures + 1; const failures = priorFailures + 1;
sendFailuresRef.current.set(sessionId, { sendFailuresRef.current.set(targetKey, {
messageId: payload.queuedMessageId, messageId: payload.queuedMessageId,
failures, failures,
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures), nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
}); });
} finally { } finally {
inFlightSessionsRef.current.delete(sessionId); inFlightSessionsRef.current.delete(targetKey);
} }
}; };
@@ -226,7 +232,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
} }
const queueEntries = Object.entries(queuedMessages); const queueEntries = Object.entries(queuedMessages);
queueEntries.forEach(([sessionId, queue]) => { queueEntries.forEach(([key, queue]) => {
const target = parseMessageQueueKey(key);
if (!target || target.runtimeKey !== getRuntimeKey() || target.directory !== currentDirectory) return;
const { sessionId } = target;
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType; const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
const previousStatusType = previousStatusRef.current.get(sessionId); const previousStatusType = previousStatusRef.current.get(sessionId);
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId); const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
@@ -241,12 +250,12 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType, queue.length > 0) shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType, queue.length > 0)
|| (wasAutoReviewBlocked && !isAutoReviewRunning && currentStatusType === 'idle') || (wasAutoReviewBlocked && !isAutoReviewRunning && currentStatusType === 'idle')
)) { )) {
void dispatchSessionQueue(sessionId, queue); void dispatchSessionQueue(target, queue);
} }
nextStatusMap.set(sessionId, currentStatusType); nextStatusMap.set(sessionId, currentStatusType);
}); });
previousStatusRef.current = nextStatusMap; previousStatusRef.current = nextStatusMap;
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns]); }, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]);
} }
@@ -9,6 +9,7 @@ import { useUIStore } from '@/stores/useUIStore';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const AUTO_DELETE_KEEP_RECENT = 5; const AUTO_DELETE_KEEP_RECENT = 5;
const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000; const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const EMPTY_SESSIONS: Session[] = [];
const getSessionLastActivity = (session: Session): number => { const getSessionLastActivity = (session: Session): number => {
return session.time?.updated ?? session.time?.created ?? 0; return session.time?.updated ?? session.time?.created ?? 0;
@@ -71,14 +72,17 @@ export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOption
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isLoading = useSessionUIStore((state) => state.isLoading); const isLoading = useSessionUIStore((state) => state.isLoading);
const globalSessions = useGlobalSessionsStore((state) => state.activeSessions);
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction); const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt); const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt); const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt);
const needsGlobalSessions = enabled && (!autoRun || autoDeleteEnabled);
const globalSessions = useGlobalSessionsStore(React.useCallback(
(state) => needsGlobalSessions ? state.activeSessions : EMPTY_SESSIONS,
[needsGlobalSessions],
));
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const [isRunning, setIsRunning] = React.useState(false); const [isRunning, setIsRunning] = React.useState(false);
const runningRef = React.useRef(false); const runningRef = React.useRef(false);
+1 -1
View File
@@ -359,7 +359,7 @@ const buildSnapshot = (instanceName: string): TraySnapshot => {
const resolveStatus = (id: string): TraySessionStatus => { const resolveStatus = (id: string): TraySessionStatus => {
const fromStores = live.statusById.get(id); const fromStores = live.statusById.get(id);
if (fromStores && fromStores !== 'idle') return fromStores; if (fromStores && fromStores !== 'idle') return fromStores;
return globalStatusById.get(id)?.status ?? fromStores ?? 'idle'; return globalStatusById.get(id)?.status.type ?? fromStores ?? 'idle';
}; };
const rollupStatus = (family: string[]): TraySessionStatus => { const rollupStatus = (family: string[]): TraySessionStatus => {
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import {
clearChatDraft,
createChatDraftIdentity,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
} from './chatDraftPersistence';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
const storage = getDeferredSafeStorage();
describe('chatDraftPersistence', () => {
beforeEach(() => {
storage.removeItem('openchamber.chatDrafts.v2');
});
test('isolates drafts by runtime, directory, and session', () => {
const first = createChatDraftIdentity('runtime-a', '/repo-a/', 'session-1')!;
const second = createChatDraftIdentity('runtime-b', '/repo-a', 'session-1')!;
const third = createChatDraftIdentity('runtime-a', '/repo-b', 'session-1')!;
writeChatDraft(first, 'first', ['file.ts']);
writeChatDraft(second, 'second', []);
writeChatDraft(third, 'third', []);
expect(readChatDraft(first)).toEqual({ text: 'first', confirmedMentions: new Set(['file.ts']) });
expect(readChatDraft(second).text).toBe('second');
expect(readChatDraft(third).text).toBe('third');
});
test('keeps new-session drafts separate from similarly named sessions', () => {
const newSession = createChatDraftIdentity('runtime-a', '/repo', null)!;
const namedSession = createChatDraftIdentity('runtime-a', '/repo', '__new__')!;
writeChatDraft(newSession, 'new session', []);
writeChatDraft(namedSession, 'named session', []);
expect(readChatDraft(newSession).text).toBe('new session');
expect(readChatDraft(namedSession).text).toBe('named session');
});
test('clears only the matching identity and notifies active composers', () => {
const deleted = createChatDraftIdentity('runtime-a', '/repo-a', 'session-1')!;
const retained = createChatDraftIdentity('runtime-a', '/repo-b', 'session-1')!;
const notifications: string[] = [];
const unsubscribe = subscribeChatDraftDeletion((identity) => notifications.push(identity.directory));
writeChatDraft(deleted, 'delete', []);
writeChatDraft(retained, 'retain', []);
clearChatDraft(deleted, true);
unsubscribe();
expect(readChatDraft(deleted).text).toBe('');
expect(readChatDraft(retained).text).toBe('retain');
expect(notifications).toEqual(['/repo-a']);
});
test('bounds persisted drafts by recency', () => {
for (let index = 0; index < 55; index += 1) {
const identity = createChatDraftIdentity('runtime-a', '/repo', `session-${index}`)!;
writeChatDraft(identity, `draft-${index}`, []);
}
const envelope = JSON.parse(storage.getItem('openchamber.chatDrafts.v2') ?? '{}') as { drafts?: object };
expect(Object.keys(envelope.drafts ?? {})).toHaveLength(50);
});
test('reuses a parsed envelope while the stored value is unchanged', () => {
const identity = createChatDraftIdentity('runtime-cache', '/repo', 'session-1')!;
const key = getChatDraftIdentityKey(identity);
storage.setItem('openchamber.chatDrafts.v2', JSON.stringify({
version: 2,
drafts: { [key]: { text: 'cached', confirmedMentions: [], touchedAt: 1 } },
}));
const originalParse = JSON.parse;
let parseCalls = 0;
JSON.parse = ((...args: Parameters<typeof JSON.parse>) => {
parseCalls += 1;
return originalParse(...args);
}) as typeof JSON.parse;
try {
expect(readChatDraft(identity).text).toBe('cached');
expect(readChatDraft(identity).text).toBe('cached');
expect(parseCalls).toBe(1);
} finally {
JSON.parse = originalParse;
}
});
});
+125
View File
@@ -0,0 +1,125 @@
import { normalizePath } from '@/lib/pathNormalization';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { countSyncPersistenceSerialization } from '@/sync/performance-diagnostics';
export type ChatDraftIdentity = {
runtimeKey: string;
directory: string;
sessionId: string | null;
};
export type ChatDraftSnapshot = {
text: string;
confirmedMentions: Set<string>;
};
type PersistedChatDraft = {
text: string;
confirmedMentions: string[];
touchedAt: number;
};
type PersistedChatDraftEnvelope = {
version: 2;
drafts: Record<string, PersistedChatDraft>;
};
const STORAGE_KEY = 'openchamber.chatDrafts.v2';
const MAX_DRAFTS = 50;
const storage = getDeferredSafeStorage();
const deletionListeners = new Set<(identity: ChatDraftIdentity) => void>();
let cachedRawEnvelope: string | null | undefined;
let cachedEnvelope: PersistedChatDraftEnvelope | undefined;
export const createChatDraftIdentity = (
runtimeKey: string,
directory: string | null | undefined,
sessionId: string | null,
): ChatDraftIdentity | null => {
const normalizedDirectory = normalizePath(directory);
if (!runtimeKey || !normalizedDirectory) return null;
return { runtimeKey, directory: normalizedDirectory, sessionId };
};
export const getChatDraftIdentityKey = (identity: ChatDraftIdentity): string =>
JSON.stringify([identity.runtimeKey, identity.directory, identity.sessionId]);
const readEnvelope = (): PersistedChatDraftEnvelope => {
const raw = storage.getItem(STORAGE_KEY);
if (raw === cachedRawEnvelope && cachedEnvelope) return cachedEnvelope;
try {
const parsed = JSON.parse(raw ?? '') as Partial<PersistedChatDraftEnvelope>;
if (parsed.version !== 2 || !parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
cachedRawEnvelope = raw;
cachedEnvelope = { version: 2, drafts: {} };
return cachedEnvelope;
}
const drafts: Record<string, PersistedChatDraft> = {};
for (const [key, value] of Object.entries(parsed.drafts)) {
if (!value || typeof value !== 'object') continue;
const draft = value as Partial<PersistedChatDraft>;
if (typeof draft.text !== 'string' || !Array.isArray(draft.confirmedMentions) || typeof draft.touchedAt !== 'number') continue;
drafts[key] = {
text: draft.text,
confirmedMentions: draft.confirmedMentions.filter((mention): mention is string => typeof mention === 'string'),
touchedAt: draft.touchedAt,
};
}
cachedRawEnvelope = raw;
cachedEnvelope = { version: 2, drafts };
return cachedEnvelope;
} catch {
storage.removeItem(STORAGE_KEY);
cachedRawEnvelope = null;
cachedEnvelope = { version: 2, drafts: {} };
return cachedEnvelope;
}
};
const writeEnvelope = (envelope: PersistedChatDraftEnvelope): void => {
const serialized = JSON.stringify(envelope);
cachedRawEnvelope = serialized;
cachedEnvelope = envelope;
countSyncPersistenceSerialization(serialized);
storage.setItem(STORAGE_KEY, serialized);
};
export const readChatDraft = (identity: ChatDraftIdentity | null): ChatDraftSnapshot => {
if (!identity) return { text: '', confirmedMentions: new Set() };
const persisted = readEnvelope().drafts[getChatDraftIdentityKey(identity)];
return persisted
? { text: persisted.text, confirmedMentions: new Set(persisted.confirmedMentions) }
: { text: '', confirmedMentions: new Set() };
};
export const writeChatDraft = (
identity: ChatDraftIdentity | null,
text: string,
confirmedMentions: Iterable<string>,
): void => {
if (!identity) return;
const envelope = readEnvelope();
const key = getChatDraftIdentityKey(identity);
const mentions = Array.from(new Set(confirmedMentions));
if (!text && mentions.length === 0) {
if (!(key in envelope.drafts)) return;
delete envelope.drafts[key];
} else {
envelope.drafts[key] = { text, confirmedMentions: mentions, touchedAt: Date.now() };
}
const retained = Object.entries(envelope.drafts)
.sort((left, right) => right[1].touchedAt - left[1].touchedAt)
.slice(0, MAX_DRAFTS);
writeEnvelope({ version: 2, drafts: Object.fromEntries(retained) });
};
export const clearChatDraft = (identity: ChatDraftIdentity, notify = false): void => {
writeChatDraft(identity, '', []);
if (notify) deletionListeners.forEach((listener) => listener(identity));
};
export const subscribeChatDraftDeletion = (listener: (identity: ChatDraftIdentity) => void): (() => void) => {
deletionListeners.add(listener);
return () => deletionListeners.delete(listener);
};
+19 -21
View File
@@ -1,5 +1,3 @@
import type { import type {
GitStatus, GitStatus,
GitDiffResponse, GitDiffResponse,
@@ -37,6 +35,7 @@ import type {
} from './api/types'; } from './api/types';
import { runtimeFetch } from './runtime-fetch'; import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
const API_BASE = '/api/git'; const API_BASE = '/api/git';
const GIT_STATUS_CACHE_TTL_MS = 1200; const GIT_STATUS_CACHE_TTL_MS = 1200;
@@ -48,24 +47,22 @@ const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
const gitRepoInFlight = new Map<string, Promise<boolean>>(); const gitRepoInFlight = new Map<string, Promise<boolean>>();
const normalizeDirectoryKey = (directory: string): string => directory.trim(); const normalizeDirectoryKey = (directory: string): string => directory.trim();
const getStatusCacheKey = (directory: string, mode?: 'light'): string => const getDirectoryCacheKey = (runtimeKey: string, directory: string): string =>
mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory); JSON.stringify([runtimeKey, normalizeDirectoryKey(directory)]);
const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'): string =>
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory), mode ?? 'full']);
const getStatusCacheVersion = (directory: string): number => const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
gitStatusCacheVersions.get(normalizeDirectoryKey(directory)) ?? 0; gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
const invalidateGitStatusCache = (directory: string): void => { const invalidateGitStatusCache = (directory: string): void => {
const key = normalizeDirectoryKey(directory); const runtimeKey = getRuntimeKey();
gitStatusCacheVersions.set(key, getStatusCacheVersion(directory) + 1); const key = getDirectoryCacheKey(runtimeKey, directory);
for (const cacheKey of Array.from(gitStatusCache.keys())) { gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) { for (const mode of [undefined, 'light'] as const) {
gitStatusCache.delete(cacheKey); const statusKey = getStatusCacheKey(runtimeKey, directory, mode);
} gitStatusCache.delete(statusKey);
} gitStatusInFlight.delete(statusKey);
for (const cacheKey of Array.from(gitStatusInFlight.keys())) {
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
gitStatusInFlight.delete(cacheKey);
}
} }
}; };
@@ -81,7 +78,7 @@ function buildUrl(
} }
export async function checkIsGitRepository(directory: string): Promise<boolean> { export async function checkIsGitRepository(directory: string): Promise<boolean> {
const key = normalizeDirectoryKey(directory); const key = getDirectoryCacheKey(getRuntimeKey(), directory);
const now = Date.now(); const now = Date.now();
const cached = gitRepoCache.get(key); const cached = gitRepoCache.get(key);
if (cached && cached.expiresAt > now) { if (cached && cached.expiresAt > now) {
@@ -119,7 +116,8 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> { export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
const mode = options?.mode; const mode = options?.mode;
const key = getStatusCacheKey(directory, mode); const runtimeKey = getRuntimeKey();
const key = getStatusCacheKey(runtimeKey, directory, mode);
const now = Date.now(); const now = Date.now();
const cached = gitStatusCache.get(key); const cached = gitStatusCache.get(key);
if (cached && cached.expiresAt > now) { if (cached && cached.expiresAt > now) {
@@ -132,13 +130,13 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
} }
const task = (async () => { const task = (async () => {
const cacheVersion = getStatusCacheVersion(directory); const cacheVersion = getStatusCacheVersion(runtimeKey, directory);
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined)); const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to get git status: ${response.statusText}`); throw new Error(`Failed to get git status: ${response.statusText}`);
} }
const payload = await response.json() as GitStatus; const payload = await response.json() as GitStatus;
if (getStatusCacheVersion(directory) === cacheVersion) { if (getStatusCacheVersion(runtimeKey, directory) === cacheVersion) {
gitStatusCache.set(key, { gitStatusCache.set(key, {
value: payload, value: payload,
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
+6
View File
@@ -1860,6 +1860,12 @@ export const dict = {
'chat.container.returnToParent.title': 'Return to parent session', 'chat.container.returnToParent.title': 'Return to parent session',
'chat.container.returnToParent.label': 'Parent', 'chat.container.returnToParent.label': 'Parent',
'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.', 'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.',
'chat.container.sessionLoadError.title': 'Session could not be loaded',
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
'chat.container.sessionLoadError.retry': 'Try again',
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
'sessions.sidebar.group.empty.retry': 'Try again',
'chat.unifiedControls.title': 'Controls', 'chat.unifiedControls.title': 'Controls',
'chat.unifiedControls.model.title': 'Model', 'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'No recent models', 'chat.unifiedControls.model.noRecent': 'No recent models',
+6
View File
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.title": "Volver a la sesión principal", "chat.container.returnToParent.title": "Volver a la sesión principal",
"chat.container.returnToParent.label": "Principal", "chat.container.returnToParent.label": "Principal",
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.", "chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
"chat.container.sessionLoadError.retry": "Reintentar",
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
"sessions.sidebar.group.empty.retry": "Reintentar",
"chat.unifiedControls.title": "Controles", "chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo", "chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "No hay modelos recientes", "chat.unifiedControls.model.noRecent": "No hay modelos recientes",
+6
View File
@@ -1656,6 +1656,12 @@ export const dict = {
'chat.container.returnToParent.title': 'Retour à la session parents', 'chat.container.returnToParent.title': 'Retour à la session parents',
'chat.container.returnToParent.label': 'Mère', 'chat.container.returnToParent.label': 'Mère',
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.', 'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
'chat.container.sessionLoadError.retry': 'Réessayer',
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.',
'sessions.sidebar.group.empty.retry': 'Réessayer',
'chat.unifiedControls.title': 'Contrôles', 'chat.unifiedControls.title': 'Contrôles',
'chat.unifiedControls.model.title': 'Modèle', 'chat.unifiedControls.model.title': 'Modèle',
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent', 'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
+6
View File
@@ -1856,6 +1856,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '親セッションに戻る', 'chat.container.returnToParent.title': '親セッションに戻る',
'chat.container.returnToParent.label': '親', 'chat.container.returnToParent.label': '親',
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。', 'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
'chat.container.sessionLoadError.retry': '再試行',
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
'sessions.sidebar.group.empty.retry': '再試行',
'chat.unifiedControls.title': 'コントロール', 'chat.unifiedControls.title': 'コントロール',
'chat.unifiedControls.model.title': 'モデル', 'chat.unifiedControls.model.title': 'モデル',
'chat.unifiedControls.model.noRecent': '最近のモデルはありません', 'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
+6
View File
@@ -1862,6 +1862,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '상위 세션으로 돌아가기', 'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
'chat.container.returnToParent.label': '상위', 'chat.container.returnToParent.label': '상위',
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.', 'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
'chat.container.sessionLoadError.retry': '다시 시도',
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
'sessions.sidebar.group.empty.retry': '다시 시도',
'chat.unifiedControls.title': '컨트롤', 'chat.unifiedControls.title': '컨트롤',
'chat.unifiedControls.model.title': '모델', 'chat.unifiedControls.model.title': '모델',
'chat.unifiedControls.model.noRecent': '최근 모델 없음', 'chat.unifiedControls.model.noRecent': '최근 모델 없음',
+6
View File
@@ -749,6 +749,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
'chat.container.returnToParent.label': 'Nadrzędna', 'chat.container.returnToParent.label': 'Nadrzędna',
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.', 'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
'chat.container.sessionLoadError.retry': 'Spróbuj ponownie',
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
'chat.unifiedControls.title': 'Kontrolki', 'chat.unifiedControls.title': 'Kontrolki',
'chat.unifiedControls.model.title': 'Model', 'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli', 'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.title": "Voltar para a sessão principal", "chat.container.returnToParent.title": "Voltar para a sessão principal",
"chat.container.returnToParent.label": "Principal", "chat.container.returnToParent.label": "Principal",
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
"chat.container.sessionLoadError.retry": "Tentar novamente",
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
"sessions.sidebar.group.empty.retry": "Tentar novamente",
"chat.unifiedControls.title": "Controles", "chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo", "chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "Não há modelos recentes", "chat.unifiedControls.model.noRecent": "Não há modelos recentes",
+6
View File
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.title": "Повернутися до батьківської сесії", "chat.container.returnToParent.title": "Повернутися до батьківської сесії",
"chat.container.returnToParent.label": "Батьківська", "chat.container.returnToParent.label": "Батьківська",
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.", "chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
"chat.container.sessionLoadError.retry": "Спробувати знову",
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
"sessions.sidebar.group.empty.retry": "Спробувати знову",
"chat.unifiedControls.title": "Елементи керування", "chat.unifiedControls.title": "Елементи керування",
"chat.unifiedControls.model.title": "Модель", "chat.unifiedControls.model.title": "Модель",
"chat.unifiedControls.model.noRecent": "Немає останніх моделей", "chat.unifiedControls.model.noRecent": "Немає останніх моделей",
@@ -1826,6 +1826,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '返回父会话', 'chat.container.returnToParent.title': '返回父会话',
'chat.container.returnToParent.label': '父级', 'chat.container.returnToParent.label': '父级',
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。', 'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
'chat.container.sessionLoadError.title': '无法加载会话',
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
'chat.container.sessionLoadError.retry': '重试',
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
'sessions.sidebar.group.empty.retry': '重试',
'chat.unifiedControls.title': '控制', 'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型', 'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '没有最近使用的模型', 'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
@@ -1830,6 +1830,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '返回父會話', 'chat.container.returnToParent.title': '返回父會話',
'chat.container.returnToParent.label': '父級', 'chat.container.returnToParent.label': '父級',
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。', 'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
'chat.container.sessionLoadError.title': '無法載入工作階段',
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
'chat.container.sessionLoadError.retry': '再試一次',
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
'sessions.sidebar.group.empty.retry': '再試一次',
'chat.unifiedControls.title': '控制', 'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型', 'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '沒有最近使用的模型', 'chat.unifiedControls.model.noRecent': '沒有最近使用的模型',
+14
View File
@@ -1,5 +1,6 @@
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
type ModelRef = { providerID: string; modelID: string }; type ModelRef = { providerID: string; modelID: string };
type ModelPrefsPayload = { type ModelPrefsPayload = {
@@ -75,9 +76,13 @@ export const startModelPrefsAutoSave = () => {
let timer: number | null = null; let timer: number | null = null;
let lastSent: ModelPrefsPayload | null = null; let lastSent: ModelPrefsPayload | null = null;
let didSkipInitial = false; let didSkipInitial = false;
let scheduledRuntimeKey: string | null = null;
const flush = () => { const flush = () => {
timer = null; timer = null;
const runtimeKey = scheduledRuntimeKey;
scheduledRuntimeKey = null;
if (!runtimeKey || runtimeKey !== getRuntimeKey()) return;
const payload = snapshotModelPrefs(); const payload = snapshotModelPrefs();
if (lastSent && modelPrefsEqual(lastSent, payload)) { if (lastSent && modelPrefsEqual(lastSent, payload)) {
@@ -97,9 +102,17 @@ export const startModelPrefsAutoSave = () => {
if (timer !== null) { if (timer !== null) {
window.clearTimeout(timer); window.clearTimeout(timer);
} }
scheduledRuntimeKey = getRuntimeKey();
timer = window.setTimeout(flush, 1200); timer = window.setTimeout(flush, 1200);
}; };
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange(() => {
if (timer !== null) window.clearTimeout(timer);
timer = null;
scheduledRuntimeKey = null;
lastSent = null;
});
const unsubscribe = useUIStore.subscribe((state, prevState) => { const unsubscribe = useUIStore.subscribe((state, prevState) => {
const next = { const next = {
favoriteModels: state.favoriteModels, favoriteModels: state.favoriteModels,
@@ -125,6 +138,7 @@ export const startModelPrefsAutoSave = () => {
return () => { return () => {
unsubscribe(); unsubscribe();
unsubscribeRuntime();
if (timer !== null) { if (timer !== null) {
window.clearTimeout(timer); window.clearTimeout(timer);
} }
@@ -0,0 +1,18 @@
import { expect, mock, test } from "bun:test"
let runtimeKey = "runtime-a"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { assertProviderCircuitClosed, recordProviderError, recordProviderSuccess } = await import("./provider-tracker")
test("isolates provider circuit state by runtime", () => {
for (let attempt = 0; attempt < 3; attempt += 1) recordProviderError("provider", 503)
expect(() => assertProviderCircuitClosed("provider")).toThrow()
runtimeKey = "runtime-b"
assertProviderCircuitClosed("provider")
runtimeKey = "runtime-a"
recordProviderSuccess("provider")
assertProviderCircuitClosed("provider")
})
@@ -8,6 +8,8 @@
* Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives. * Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives.
*/ */
import { getRuntimeKey } from '@/lib/runtime-switch'
const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3 const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000 const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000
const DEFAULT_RETRY_BASE_DELAY_MS = 1000 const DEFAULT_RETRY_BASE_DELAY_MS = 1000
@@ -15,6 +17,7 @@ const DEFAULT_RETRY_MAX_DELAY_MS = 32_000
const DEFAULT_RETRY_MAX_ATTEMPTS = 3 const DEFAULT_RETRY_MAX_ATTEMPTS = 3
const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000 const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000
const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000 const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000
const PROVIDER_MAX_ENTRIES = 200
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]) const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504])
@@ -27,15 +30,14 @@ type ProviderState = {
} }
const providers = new Map<string, ProviderState>() const providers = new Map<string, ProviderState>()
const providerKey = (providerID: string): string => JSON.stringify([getRuntimeKey(), providerID])
function evictStaleProviders(): void { function evictStaleProviders(): void {
const now = Date.now() const now = Date.now()
for (const [providerID, state] of providers) { for (const [key, state] of providers) {
if ( const lastActivityAt = Math.max(state.lastErrorAt, state.circuitOpenAt)
state.consecutiveErrors === 0 && if (now - lastActivityAt > PROVIDER_EVICTION_TTL_MS) {
now - state.lastErrorAt > PROVIDER_EVICTION_TTL_MS providers.delete(key)
) {
providers.delete(providerID)
} }
} }
} }
@@ -46,7 +48,8 @@ if (typeof setInterval !== 'undefined') {
} }
function getOrCreateProvider(providerID: string): ProviderState { function getOrCreateProvider(providerID: string): ProviderState {
let state = providers.get(providerID) const key = providerKey(providerID)
let state = providers.get(key)
if (!state) { if (!state) {
state = { state = {
consecutiveErrors: 0, consecutiveErrors: 0,
@@ -55,17 +58,19 @@ function getOrCreateProvider(providerID: string): ProviderState {
circuitOpenAt: 0, circuitOpenAt: 0,
circuitCooldownMs: DEFAULT_CIRCUIT_COOLDOWN_MS, circuitCooldownMs: DEFAULT_CIRCUIT_COOLDOWN_MS,
} }
providers.set(providerID, state) providers.set(key, state)
while (providers.size > PROVIDER_MAX_ENTRIES) {
const oldest = providers.keys().next().value
if (!oldest) break
providers.delete(oldest)
}
} }
return state return state
} }
export function recordProviderSuccess(providerID: string): void { export function recordProviderSuccess(providerID: string): void {
if (!providerID) return if (!providerID) return
const state = providers.get(providerID) providers.delete(providerKey(providerID))
if (!state) return
state.consecutiveErrors = 0
state.lastErrorAt = 0
} }
export function recordProviderError(providerID: string, status?: number): void { export function recordProviderError(providerID: string, status?: number): void {
@@ -91,7 +96,7 @@ function isCircuitBreakerStatus(status?: number): boolean {
} }
function isCircuitOpen(providerID: string): boolean { function isCircuitOpen(providerID: string): boolean {
const state = providers.get(providerID) const state = providers.get(providerKey(providerID))
if (!state?.circuitOpen) return false if (!state?.circuitOpen) return false
const elapsed = Date.now() - state.circuitOpenAt const elapsed = Date.now() - state.circuitOpenAt
+84
View File
@@ -5,8 +5,10 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave'; import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { import {
applyPersistedHomeDirectoryToWindow, applyPersistedHomeDirectoryToWindow,
getRuntimeSettingsMirrorStorageKey,
getSettingsSaveState, getSettingsSaveState,
invalidateSettingsCache, invalidateSettingsCache,
subscribeToSettingsSaveState, subscribeToSettingsSaveState,
@@ -315,6 +317,88 @@ describe('updateDesktopSettings', () => {
expect(useUIStore.getState().terminalShell).toBe('bash'); expect(useUIStore.getState().terminalShell).toBe('bash');
}); });
test('isolates local settings mirrors and removes values omitted by the next runtime', async () => {
getWindow();
localStorage.clear();
switchRuntimeEndpoint({ apiBaseUrl: 'https://mirror-a.example', runtimeKey: 'mirror-a' });
registerSettingsApi(async () => ({}), async () => ({
settings: {
themeId: 'theme-a',
directoryShowHidden: true,
sttModel: 'model-a',
draftStartersCraftGoalAdded: true,
},
source: 'web',
}));
await syncDesktopSettings();
switchRuntimeEndpoint({ apiBaseUrl: 'https://mirror-b.example', runtimeKey: 'mirror-b' });
registerSettingsApi(async () => ({}), async () => ({
settings: { draftStartersCraftGoalAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(localStorage.getItem('selectedThemeId')).toBeNull();
expect(localStorage.getItem('directoryTreeShowHidden')).toBeNull();
expect(localStorage.getItem('sttModel')).toBeNull();
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-a')) ?? '{}')).toEqual({
themeId: 'theme-a',
directoryShowHidden: true,
sttModel: 'model-a',
});
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({});
});
test('resets in-memory preferences omitted by an authoritative runtime snapshot', async () => {
getWindow();
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-a.example', runtimeKey: 'preferences-a' });
registerSettingsApi(async () => ({}), async () => ({
settings: {
showReasoningTraces: false,
terminalShell: 'fish',
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
followUpBehavior: 'steer',
draftStarters: [{ type: 'command', name: 'runtime-a' }],
draftStartersCraftGoalAdded: true,
},
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().showReasoningTraces).toBe(false);
expect(useUIStore.getState().terminalShell).toBe('fish');
expect(useUIStore.getState().favoriteModels).toHaveLength(1);
expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]);
expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer');
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-b.example', runtimeKey: 'preferences-b' });
registerSettingsApi(async () => ({}), async () => ({
settings: { draftStartersCraftGoalAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().showReasoningTraces).toBe(true);
expect(useUIStore.getState().terminalShell).toBe('auto');
expect(useUIStore.getState().favoriteModels).toEqual([]);
expect(useUIStore.getState().globalDraftStarters).toBeNull();
expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue');
});
test('treats settings save responses as partial patches', async () => {
getWindow();
localStorage.setItem('selectedThemeId', 'existing-theme');
useUIStore.getState().setTerminalShell('fish');
registerSettingsSave(async () => ({ showReasoningTraces: false }));
await updateDesktopSettings({ showReasoningTraces: false });
expect(useUIStore.getState().showReasoningTraces).toBe(false);
expect(useUIStore.getState().terminalShell).toBe('fish');
expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme');
});
test('applies model selector settings from server settings', async () => { test('applies model selector settings from server settings', async () => {
getWindow(); getWindow();
const settings = { const settings = {
+189 -41
View File
@@ -2,7 +2,13 @@ import type { DesktopSettings } from '@/lib/desktop';
import { createProjectIdFromPath } from '@/lib/projectId'; import { createProjectIdFromPath } from '@/lib/projectId';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions'; import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import {
DEFAULT_FOLLOW_UP_BEHAVIOR,
isFollowUpBehavior,
normalizeFollowUpBehavior,
useMessageQueueStore,
type FollowUpBehavior,
} from '@/stores/messageQueueStore';
import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
@@ -12,6 +18,8 @@ import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { isTerminalShell } from '@/lib/terminalShell'; import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => { export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -28,32 +36,80 @@ export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void
} }
}; };
const SETTINGS_MIRROR_INDEX_KEY = 'openchamber.settingsMirror.v2.index';
const SETTINGS_MIRROR_KEY_PREFIX = 'openchamber.settingsMirror.v2:';
const MAX_SETTINGS_MIRROR_RUNTIMES = 5;
export const getRuntimeSettingsMirrorStorageKey = (runtimeKey: string): string =>
`${SETTINGS_MIRROR_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
const setOrRemoveLocalStorage = (key: string, value: string | null): void => {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
};
const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: string): void => {
const mirror = {
themeId: settings.themeId,
themeVariant: settings.themeVariant,
lightThemeId: settings.lightThemeId,
darkThemeId: settings.darkThemeId,
useSystemTheme: settings.useSystemTheme,
lastDirectory: settings.lastDirectory,
homeDirectory: settings.homeDirectory,
projects: settings.projects,
activeProjectId: settings.activeProjectId,
pinnedDirectories: settings.pinnedDirectories,
gitmojiEnabled: settings.gitmojiEnabled,
directoryShowHidden: settings.directoryShowHidden,
filesViewShowGitignored: settings.filesViewShowGitignored,
openInAppId: settings.openInAppId,
pwaAppName: settings.pwaAppName,
mobileKeyboardMode: settings.mobileKeyboardMode,
openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion,
dictationEnabled: settings.dictationEnabled,
sttProvider: settings.sttProvider,
sttServerUrl: settings.sttServerUrl,
sttModel: settings.sttModel,
sttLocalModel: settings.sttLocalModel,
sttLanguage: settings.sttLanguage,
};
localStorage.setItem(getRuntimeSettingsMirrorStorageKey(runtimeKey), JSON.stringify(mirror));
let previous: string[] = [];
try {
const parsed = JSON.parse(localStorage.getItem(SETTINGS_MIRROR_INDEX_KEY) ?? '[]') as unknown;
if (Array.isArray(parsed)) previous = parsed.filter((entry): entry is string => typeof entry === 'string');
} catch {
previous = [];
}
const runtimes = [runtimeKey, ...previous.filter((entry) => entry !== runtimeKey)].slice(0, MAX_SETTINGS_MIRROR_RUNTIMES);
for (const staleRuntime of previous) {
if (!runtimes.includes(staleRuntime)) localStorage.removeItem(getRuntimeSettingsMirrorStorageKey(staleRuntime));
}
localStorage.setItem(SETTINGS_MIRROR_INDEX_KEY, JSON.stringify(runtimes));
};
const persistToLocalStorage = (settings: DesktopSettings) => { const persistToLocalStorage = (settings: DesktopSettings) => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
if (settings.themeId) { persistRuntimeSettingsMirror(settings, getRuntimeKey());
localStorage.setItem('selectedThemeId', settings.themeId); setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
} setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
if (settings.themeVariant) { setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
localStorage.setItem('selectedThemeVariant', settings.themeVariant); setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
} setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
if (settings.lightThemeId) { setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
localStorage.setItem('lightThemeId', settings.lightThemeId);
}
if (settings.darkThemeId) {
localStorage.setItem('darkThemeId', settings.darkThemeId);
}
if (typeof settings.useSystemTheme === 'boolean') {
localStorage.setItem('useSystemTheme', String(settings.useSystemTheme));
}
if (settings.lastDirectory) {
localStorage.setItem('lastDirectory', settings.lastDirectory);
}
if (settings.homeDirectory) { if (settings.homeDirectory) {
localStorage.setItem('homeDirectory', settings.homeDirectory); localStorage.setItem('homeDirectory', settings.homeDirectory);
applyPersistedHomeDirectoryToWindow(settings.homeDirectory); applyPersistedHomeDirectoryToWindow(settings.homeDirectory);
} else {
localStorage.removeItem('homeDirectory');
} }
if (Array.isArray(settings.projects) && settings.projects.length > 0) { if (Array.isArray(settings.projects) && settings.projects.length > 0) {
localStorage.setItem('projects', JSON.stringify(settings.projects)); localStorage.setItem('projects', JSON.stringify(settings.projects));
@@ -81,6 +137,8 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else { } else {
localStorage.removeItem('oc.sessions.projectCollapse'); localStorage.removeItem('oc.sessions.projectCollapse');
} }
} else {
localStorage.removeItem('oc.sessions.projectCollapse');
} }
if (typeof settings.gitmojiEnabled === 'boolean') { if (typeof settings.gitmojiEnabled === 'boolean') {
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled)); localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
@@ -89,13 +147,15 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} }
if (typeof settings.directoryShowHidden === 'boolean') { if (typeof settings.directoryShowHidden === 'boolean') {
localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false'); localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false');
} else {
localStorage.removeItem('directoryTreeShowHidden');
} }
if (typeof settings.filesViewShowGitignored === 'boolean') { if (typeof settings.filesViewShowGitignored === 'boolean') {
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false'); localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
} else {
localStorage.removeItem('filesViewShowGitignored');
} }
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) { setOrRemoveLocalStorage('openInAppId', typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0 ? settings.openInAppId : null);
localStorage.setItem('openInAppId', settings.openInAppId);
}
if (typeof settings.pwaAppName === 'string') { if (typeof settings.pwaAppName === 'string') {
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64); const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
if (normalized.length > 0) { if (normalized.length > 0) {
@@ -103,10 +163,10 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else { } else {
localStorage.removeItem('openchamber.pwaName'); localStorage.removeItem('openchamber.pwaName');
} }
} else {
localStorage.removeItem('openchamber.pwaName');
} }
if (typeof settings.mobileKeyboardMode === 'string') {
setStoredMobileKeyboardMode(settings.mobileKeyboardMode); setStoredMobileKeyboardMode(settings.mobileKeyboardMode);
}
if (typeof settings.openCodeUpdateToastDismissedVersion === 'string') { if (typeof settings.openCodeUpdateToastDismissedVersion === 'string') {
const version = settings.openCodeUpdateToastDismissedVersion.trim(); const version = settings.openCodeUpdateToastDismissedVersion.trim();
if (version) { if (version) {
@@ -114,25 +174,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else { } else {
localStorage.removeItem('opencode-update-toast-dismissed-version'); localStorage.removeItem('opencode-update-toast-dismissed-version');
} }
} else {
localStorage.removeItem('opencode-update-toast-dismissed-version');
} }
if (typeof settings.dictationEnabled === 'boolean') { if (typeof settings.dictationEnabled === 'boolean') {
localStorage.setItem('dictationEnabled', String(settings.dictationEnabled)); localStorage.setItem('dictationEnabled', String(settings.dictationEnabled));
} else {
localStorage.removeItem('dictationEnabled');
} }
if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') { if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') {
localStorage.setItem('sttProvider', settings.sttProvider); localStorage.setItem('sttProvider', settings.sttProvider);
} else {
localStorage.removeItem('sttProvider');
} }
if (typeof settings.sttServerUrl === 'string') { setOrRemoveLocalStorage('sttServerUrl', typeof settings.sttServerUrl === 'string' ? settings.sttServerUrl : null);
localStorage.setItem('sttServerUrl', settings.sttServerUrl); setOrRemoveLocalStorage('sttModel', typeof settings.sttModel === 'string' ? settings.sttModel : null);
} setOrRemoveLocalStorage('sttLocalModel', typeof settings.sttLocalModel === 'string' ? settings.sttLocalModel : null);
if (typeof settings.sttModel === 'string') { setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
localStorage.setItem('sttModel', settings.sttModel);
}
if (typeof settings.sttLocalModel === 'string') {
localStorage.setItem('sttLocalModel', settings.sttLocalModel);
}
if (typeof settings.sttLanguage === 'string') {
localStorage.setItem('sttLanguage', settings.sttLanguage);
}
}; };
const dispatchSettingsSynced = (settings: DesktopSettings): void => { const dispatchSettingsSynced = (settings: DesktopSettings): void => {
@@ -457,6 +515,91 @@ const getPersistApi = (): PersistApi | undefined => {
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null; const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopSettings => {
const defaults = useUIStore.getInitialState();
return {
useSystemTheme: true,
lightThemeId: DEFAULT_LIGHT_THEME_ID,
darkThemeId: DEFAULT_DARK_THEME_ID,
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
sessionRecapEnabled: defaults.sessionRecapEnabled,
sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
sessionGoalEnabled: defaults.sessionGoalEnabled,
sessionGoalDefaultBudgetEnabled: defaults.sessionGoalDefaultBudgetEnabled,
sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
autoDeleteEnabled: defaults.autoDeleteEnabled,
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
sessionRetentionAction: defaults.sessionRetentionAction,
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
showDeletionDialog: defaults.showDeletionDialog,
nativeNotificationsEnabled: defaults.nativeNotificationsEnabled,
notificationMode: defaults.notificationMode,
notifyOnSubtasks: defaults.notifyOnSubtasks,
notifyOnCompletion: defaults.notifyOnCompletion,
notifyOnError: defaults.notifyOnError,
notifyOnQuestion: defaults.notifyOnQuestion,
notificationTemplates: defaults.notificationTemplates,
summarizeLastMessage: defaults.summarizeLastMessage,
summaryThreshold: defaults.summaryThreshold,
summaryLength: defaults.summaryLength,
maxLastMessageLength: defaults.maxLastMessageLength,
inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
showToolFileIcons: defaults.showToolFileIcons,
codeBlockLineWrap: defaults.codeBlockLineWrap,
showTurnChangedFiles: defaults.showTurnChangedFiles,
showExpandedBashTools: defaults.showExpandedBashTools,
showExpandedEditTools: defaults.showExpandedEditTools,
timeFormatPreference: defaults.timeFormatPreference,
weekStartPreference: defaults.weekStartPreference,
desktopWindowControlsPosition: defaults.desktopWindowControlsPosition,
chatRenderMode: defaults.chatRenderMode,
activityRenderMode: defaults.activityRenderMode,
mermaidRenderingMode: defaults.mermaidRenderingMode,
userMessageRenderingMode: defaults.userMessageRenderingMode,
collapsibleUserMessages: defaults.collapsibleUserMessages,
messageStreamTransport: 'auto',
stickyUserHeader: defaults.stickyUserHeader,
promptNavigatorEnabled: defaults.promptNavigatorEnabled,
expandedEditorToolbar: defaults.expandedEditorToolbar,
wideChatLayoutEnabled: defaults.wideChatLayoutEnabled,
showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions,
reportUsage: defaults.reportUsage,
fontSize: defaults.fontSize,
terminalFontSize: defaults.terminalFontSize,
terminalShell: defaults.terminalShell,
terminalLoginShells: defaults.terminalLoginShells,
editorFontSize: defaults.editorFontSize,
uiFont: defaults.uiFont,
monoFont: defaults.monoFont,
padding: defaults.padding,
cornerRadius: defaults.cornerRadius,
inputBarOffset: defaults.inputBarOffset,
shortcutOverrides: defaults.shortcutOverrides,
mobileKeyboardMode: 'resize-content',
favoriteModels: defaults.favoriteModels,
hiddenModels: defaults.hiddenModels,
collapsedModelProviders: defaults.collapsedModelProviders,
recentModels: defaults.recentModels,
recentAgents: defaults.recentAgents,
recentEfforts: defaults.recentEfforts,
diffLayoutPreference: defaults.diffLayoutPreference,
gitChangesViewMode: defaults.gitChangesViewMode,
directoryShowHidden: true,
filesViewShowGitignored: false,
dictationEnabled: true,
sttProvider: 'local',
sttServerUrl: 'http://localhost:8001/v1',
sttModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
sttLocalModel: 'parakeet-tdt-0.6b-v2-int8',
sttLanguage: '',
...settings,
};
};
const applyDesktopUiPreferences = (settings: DesktopSettings) => { const applyDesktopUiPreferences = (settings: DesktopSettings) => {
const store = useUIStore.getState(); const store = useUIStore.getState();
const configStore = typeof window !== 'undefined' const configStore = typeof window !== 'undefined'
@@ -1215,6 +1358,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.promptNavigatorEnabled === 'boolean') { if (typeof candidate.promptNavigatorEnabled === 'boolean') {
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled; result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
} }
if (typeof candidate.expandedEditorToolbar === 'boolean') {
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
}
if (typeof candidate.wideChatLayoutEnabled === 'boolean') { if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled; result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
} }
@@ -1524,6 +1670,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
const applySettings = async (settings: DesktopSettings) => { const applySettings = async (settings: DesktopSettings) => {
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true; const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true;
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
try { try {
persistToLocalStorage(settings); persistToLocalStorage(settings);
} catch (error) { } catch (error) {
@@ -1531,20 +1678,23 @@ export const syncDesktopSettings = async (): Promise<void> => {
} }
await waitForHydration(); await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
if (settings.draftStarters === undefined) {
useUIStore.setState({ globalDraftStarters: null });
}
try { try {
applyDesktopUiPreferences(settings); applyDesktopUiPreferences(authoritativeSettings);
} catch (error) { } catch (error) {
console.warn('applyDesktopUiPreferences failed:', error); console.warn('applyDesktopUiPreferences failed:', error);
} }
if (shouldPersistCraftGoalMigration) { if (shouldPersistCraftGoalMigration) {
await updateDesktopSettings({ await updateDesktopSettings({
...(settings.draftStarters ? { draftStarters: settings.draftStarters } : {}), ...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}),
draftStartersCraftGoalAdded: true, draftStartersCraftGoalAdded: true,
}); });
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
} }
dispatchSettingsSynced(settings); dispatchSettingsSynced(authoritativeSettings);
}; };
try { try {
@@ -1579,7 +1729,6 @@ async function _flushSettingsUpdate(): Promise<void> {
const updated = await runtimeSettings.save(changes); const updated = await runtimeSettings.save(changes);
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) { if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated); applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated); dispatchSettingsSynced(updated);
_settingsCache = null; _settingsCache = null;
@@ -1613,7 +1762,6 @@ async function _flushSettingsUpdate(): Promise<void> {
const updated = (await response.json().catch(() => null)) as DesktopSettings | null; const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) { if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated); applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated); dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved'); dispatchSettingsSaveState('saved');
+51
View File
@@ -5,6 +5,8 @@ import {
clearRuntimeUrlAuthToken, clearRuntimeUrlAuthToken,
getRuntimeBearerTokenSync, getRuntimeBearerTokenSync,
refreshRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken,
refreshLocalRuntimeUrlAuthToken,
getLocalRuntimeUrlAuthTokenSync,
setRuntimeAuthCredentialProvider, setRuntimeAuthCredentialProvider,
setRuntimeBearerToken, setRuntimeBearerToken,
setRuntimeExtraHeaders, setRuntimeExtraHeaders,
@@ -145,4 +147,53 @@ describe('runtime auth headers', () => {
clearRuntimeAuthCredentialProvider(); clearRuntimeAuthCredentialProvider();
} }
}); });
test('never reuses a local URL token for another origin', async () => {
const previousFetch = globalThis.fetch;
let fetchCount = 0;
try {
clearRuntimeUrlAuthToken();
globalThis.fetch = (async (input: RequestInfo | URL) => {
fetchCount += 1;
const origin = new URL(String(input)).origin;
return Response.json({ token: `${origin}-token`, expiresAt: Date.now() + 60_000 });
}) as typeof fetch;
const a = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3001');
const b = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3002');
expect(a).toBe('http://127.0.0.1:3001-token');
expect(b).toBe('http://127.0.0.1:3002-token');
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3001')).toBe('');
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3002')).toBe(b);
expect(fetchCount).toBe(2);
} finally {
globalThis.fetch = previousFetch;
clearRuntimeUrlAuthToken();
}
});
test('rejects a local mint that completes after switching origins', async () => {
const previousFetch = globalThis.fetch;
let resolveA!: (response: Response) => void;
try {
clearRuntimeUrlAuthToken();
globalThis.fetch = ((input: RequestInfo | URL) => {
const origin = new URL(String(input)).origin;
if (origin.endsWith(':3001')) return new Promise<Response>((resolve) => { resolveA = resolve; });
return Promise.resolve(Response.json({ token: 'token-b', expiresAt: Date.now() + 60_000 }));
}) as typeof fetch;
const requestA = refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3001');
const tokenB = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3002');
resolveA(Response.json({ token: 'token-a', expiresAt: Date.now() + 60_000 }));
expect(tokenB).toBe('token-b');
await expect(requestA).rejects.toThrow('stale');
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3002')).toBe('token-b');
} finally {
globalThis.fetch = previousFetch;
clearRuntimeUrlAuthToken();
}
});
}); });
+61 -12
View File
@@ -14,7 +14,10 @@ let runtimeUrlAuthTokenExpiresAt = 0;
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null; let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
let localRuntimeUrlAuthToken = ''; let localRuntimeUrlAuthToken = '';
let localRuntimeUrlAuthTokenExpiresAt = 0; let localRuntimeUrlAuthTokenExpiresAt = 0;
let localRuntimeUrlAuthOrigin = '';
let localRuntimeUrlAuthRefreshPromise: Promise<string> | null = null; let localRuntimeUrlAuthRefreshPromise: Promise<string> | null = null;
let localRuntimeUrlAuthRefreshOrigin = '';
let localRuntimeUrlAuthGeneration = 0;
let runtimeAuthGeneration = 0; let runtimeAuthGeneration = 0;
const URL_AUTH_REFRESH_SKEW_MS = 10_000; const URL_AUTH_REFRESH_SKEW_MS = 10_000;
@@ -66,11 +69,27 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri
} }
}; };
const normalizeOrigin = (value: string): string => {
try {
return new URL(value).origin;
} catch {
return '';
}
};
const clearLocalRuntimeUrlAuthToken = (): void => {
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthOrigin = '';
localRuntimeUrlAuthRefreshPromise = null;
localRuntimeUrlAuthRefreshOrigin = '';
localRuntimeUrlAuthGeneration += 1;
};
export const clearRuntimeUrlAuthToken = (): void => { export const clearRuntimeUrlAuthToken = (): void => {
runtimeUrlAuthToken = ''; runtimeUrlAuthToken = '';
runtimeUrlAuthTokenExpiresAt = 0; runtimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthToken = ''; clearLocalRuntimeUrlAuthToken();
localRuntimeUrlAuthTokenExpiresAt = 0;
}; };
const resetRuntimeAuthGeneration = (): void => { const resetRuntimeAuthGeneration = (): void => {
@@ -135,15 +154,20 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires
} }
}; };
export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { export const setLocalRuntimeUrlAuthToken = (
token: string | null | undefined,
expiresAt: number | null | undefined,
localOrigin?: string | null,
): void => {
const normalized = normalizeBearerToken(token); const normalized = normalizeBearerToken(token);
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { const origin = typeof localOrigin === 'string' ? normalizeOrigin(localOrigin) : '';
localRuntimeUrlAuthToken = ''; if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || !origin) {
localRuntimeUrlAuthTokenExpiresAt = 0; clearLocalRuntimeUrlAuthToken();
return; return;
} }
localRuntimeUrlAuthToken = normalized; localRuntimeUrlAuthToken = normalized;
localRuntimeUrlAuthTokenExpiresAt = expiresAt; localRuntimeUrlAuthTokenExpiresAt = expiresAt;
localRuntimeUrlAuthOrigin = origin;
}; };
const readValidRuntimeUrlAuthTokenSync = (): string => { const readValidRuntimeUrlAuthTokenSync = (): string => {
@@ -154,10 +178,13 @@ const readValidRuntimeUrlAuthTokenSync = (): string => {
return runtimeUrlAuthToken; return runtimeUrlAuthToken;
}; };
const readValidLocalRuntimeUrlAuthTokenSync = (): string => { const readValidLocalRuntimeUrlAuthTokenSync = (localOrigin: string): string => {
const origin = normalizeOrigin(localOrigin);
if (!origin || localRuntimeUrlAuthOrigin !== origin) return '';
if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) {
localRuntimeUrlAuthToken = ''; localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0; localRuntimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthOrigin = '';
return ''; return '';
} }
return localRuntimeUrlAuthToken; return localRuntimeUrlAuthToken;
@@ -172,7 +199,7 @@ export const getRuntimeUrlAuthTokenSync = (): string => {
}; };
export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => { export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => {
const token = readValidLocalRuntimeUrlAuthTokenSync(); const token = localOrigin ? readValidLocalRuntimeUrlAuthTokenSync(localOrigin) : '';
if (!token && localOrigin && typeof window !== 'undefined') { if (!token && localOrigin && typeof window !== 'undefined') {
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
} }
@@ -242,15 +269,23 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
}; };
const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => { const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise; const origin = normalizeOrigin(localOrigin);
if (!origin) return Promise.reject(new Error('Local runtime URL auth origin was invalid'));
if (localRuntimeUrlAuthRefreshPromise && localRuntimeUrlAuthRefreshOrigin === origin) {
return localRuntimeUrlAuthRefreshPromise;
}
const generation = localRuntimeUrlAuthGeneration;
const refreshPromise = (async () => { const refreshPromise = (async () => {
const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), { const response = await fetch(buildAuthUrl(origin, '/auth/url-token'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}); });
if (!response.ok) { if (!response.ok) {
if (generation === localRuntimeUrlAuthGeneration && origin === localRuntimeUrlAuthRefreshOrigin) {
localRuntimeUrlAuthToken = ''; localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0; localRuntimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthOrigin = '';
}
throw new Error(`Failed to mint local runtime URL auth token (${response.status})`); throw new Error(`Failed to mint local runtime URL auth token (${response.status})`);
} }
const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null; const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null;
@@ -259,16 +294,22 @@ const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
if (!token || !Number.isFinite(expiresAt)) { if (!token || !Number.isFinite(expiresAt)) {
throw new Error('Local runtime URL auth token response was invalid'); throw new Error('Local runtime URL auth token response was invalid');
} }
if (generation !== localRuntimeUrlAuthGeneration || origin !== localRuntimeUrlAuthRefreshOrigin) {
throw new Error('Local runtime URL auth token response is stale');
}
localRuntimeUrlAuthToken = token; localRuntimeUrlAuthToken = token;
localRuntimeUrlAuthTokenExpiresAt = expiresAt; localRuntimeUrlAuthTokenExpiresAt = expiresAt;
localRuntimeUrlAuthOrigin = origin;
return token; return token;
})(); })();
const trackedPromise = refreshPromise.finally(() => { const trackedPromise = refreshPromise.finally(() => {
if (localRuntimeUrlAuthRefreshPromise === trackedPromise) { if (localRuntimeUrlAuthRefreshPromise === trackedPromise) {
localRuntimeUrlAuthRefreshPromise = null; localRuntimeUrlAuthRefreshPromise = null;
localRuntimeUrlAuthRefreshOrigin = '';
} }
}); });
localRuntimeUrlAuthRefreshPromise = trackedPromise; localRuntimeUrlAuthRefreshPromise = trackedPromise;
localRuntimeUrlAuthRefreshOrigin = origin;
return localRuntimeUrlAuthRefreshPromise; return localRuntimeUrlAuthRefreshPromise;
}; };
@@ -281,9 +322,17 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
}; };
export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise<string> => { export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise<string> => {
const existing = readValidLocalRuntimeUrlAuthTokenSync(); const origin = normalizeOrigin(localOrigin);
if (!origin) throw new Error('Local runtime URL auth origin was invalid');
const existing = readValidLocalRuntimeUrlAuthTokenSync(origin);
if (existing) return existing; if (existing) return existing;
return mintLocalRuntimeUrlAuthToken(localOrigin); if (
(localRuntimeUrlAuthOrigin && localRuntimeUrlAuthOrigin !== origin)
|| (localRuntimeUrlAuthRefreshOrigin && localRuntimeUrlAuthRefreshOrigin !== origin)
) {
clearLocalRuntimeUrlAuthToken();
}
return mintLocalRuntimeUrlAuthToken(origin);
}; };
// ── Proactive URL auth token refresh ────────────────────────────────────── // ── Proactive URL auth token refresh ──────────────────────────────────────
+1 -1
View File
@@ -103,7 +103,7 @@ describe('createRuntimeUrlResolver', () => {
test('adds local URL auth token to desktop realtime proxy URL', () => { test('adds local URL auth token to desktop realtime proxy URL', () => {
setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' });
setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000); setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000);
setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000); setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000, 'http://127.0.0.1:57123');
try { try {
withWindow({ withWindow({
location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' },
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test';
import { themes } from '@/lib/theme/themes';
import { getResolvedShikiTheme, getThemeContentSignature } from './appThemeRegistry';
describe('appThemeRegistry', () => {
test('invalidates resolved themes when content changes under the same ID', () => {
const original = themes[0];
const changed = {
...original,
colors: {
...original.colors,
syntax: {
...original.colors.syntax,
base: {
...original.colors.syntax.base,
keyword: original.colors.syntax.base.string,
},
},
},
};
expect(getThemeContentSignature(changed)).not.toBe(getThemeContentSignature(original));
expect(getResolvedShikiTheme(changed)).not.toBe(getResolvedShikiTheme(original));
});
test('reuses resolved themes for identical content', () => {
const original = themes[0];
const clone = JSON.parse(JSON.stringify(original));
expect(getResolvedShikiTheme(clone)).toBe(getResolvedShikiTheme(original));
});
});
+17 -5
View File
@@ -35,8 +35,11 @@ function withStableStringId<T extends object>(value: T, id: string): T {
return value; return value;
} }
const MAX_RESOLVED_THEME_CACHE_ENTRIES = 40;
const resolvedThemeCache = new Map<string, ShikiThemeRegistrationResolvedLike>(); const resolvedThemeCache = new Map<string, ShikiThemeRegistrationResolvedLike>();
const registeredPierreThemes = new Set<string>(); const registeredPierreThemeSignatures = new Map<string, string>();
export const getThemeContentSignature = (theme: Theme): string => JSON.stringify(theme);
const toResolvedTheme = (raw: VSCodeTextMateTheme, id: string): ShikiThemeRegistrationResolvedLike => { const toResolvedTheme = (raw: VSCodeTextMateTheme, id: string): ShikiThemeRegistrationResolvedLike => {
const bgRaw = raw.colors?.['editor.background']; const bgRaw = raw.colors?.['editor.background'];
@@ -68,24 +71,33 @@ const buildTextMateTheme = (theme: Theme): VSCodeTextMateTheme => {
}; };
export const getResolvedShikiTheme = (theme: Theme): ShikiThemeRegistrationResolvedLike => { export const getResolvedShikiTheme = (theme: Theme): ShikiThemeRegistrationResolvedLike => {
const cached = resolvedThemeCache.get(theme.metadata.id); const signature = getThemeContentSignature(theme);
const cached = resolvedThemeCache.get(signature);
if (cached) { if (cached) {
resolvedThemeCache.delete(signature);
resolvedThemeCache.set(signature, cached);
return cached; return cached;
} }
const raw = buildTextMateTheme(theme); const raw = buildTextMateTheme(theme);
const resolved = toResolvedTheme(raw, theme.metadata.id); const resolved = toResolvedTheme(raw, theme.metadata.id);
resolvedThemeCache.set(theme.metadata.id, resolved); resolvedThemeCache.set(signature, resolved);
while (resolvedThemeCache.size > MAX_RESOLVED_THEME_CACHE_ENTRIES) {
const oldest = resolvedThemeCache.keys().next().value;
if (oldest === undefined) break;
resolvedThemeCache.delete(oldest);
}
return resolved; return resolved;
}; };
export const ensurePierreThemeRegistered = (theme: Theme): void => { export const ensurePierreThemeRegistered = (theme: Theme): void => {
const id = theme.metadata.id; const id = theme.metadata.id;
if (registeredPierreThemes.has(id)) { const signature = getThemeContentSignature(theme);
if (registeredPierreThemeSignatures.get(id) === signature) {
return; return;
} }
const resolved = getResolvedShikiTheme(theme); const resolved = getResolvedShikiTheme(theme);
registerCustomTheme(id, async () => resolved); registerCustomTheme(id, async () => resolved);
registeredPierreThemes.add(id); registeredPierreThemeSignatures.set(id, signature);
}; };

Some files were not shown because too many files have changed in this diff Show More