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:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -82,7 +82,7 @@ Use `package.json` scripts as the command source of truth.
|
||||
| Executable source | Focused tests plus package-scoped type-check and lint |
|
||||
| Cross-workspace/shared contract | Workspace-wide type-check and lint plus affected builds/tests |
|
||||
| Added/deleted/renamed source file, export/type/entrypoint/import shape | `bun run dead-code` in addition to relevant checks |
|
||||
| Persisted or external contract | Compatibility and round-trip tests; conversion/malformed-old-data tests when old data needs migration; failed-write/migration rollback tests |
|
||||
| Persisted or external contract | Compatibility and round-trip tests plus the applicable failure/ordering cases: missing-versus-empty, malformed data, stale reads versus newer mutations, out-of-order writes, lifecycle handling for debounced writes, conversion, and failed-write/migration rollback |
|
||||
| Dependency or lockfile | Workspace-wide checks and affected builds |
|
||||
| Generated asset | Regeneration check plus consumer build/test |
|
||||
| Docs-only or isolated config | Narrow syntax/schema/link validation; do not run unrelated full suites |
|
||||
|
||||
@@ -37,6 +37,8 @@ Do not optimize against a toy fixture when the report provides production scale.
|
||||
|
||||
Do not infer a bottleneck from code appearance when a trace or counter can identify it.
|
||||
|
||||
Profiling identifies where time is spent; it does not prove behavioral equivalence. Separately verify the applicable state, identity, layout, and lifecycle transitions for every structural optimization.
|
||||
|
||||
### 2. Write The Cost Equation
|
||||
|
||||
Name every multiplying dimension:
|
||||
@@ -109,6 +111,9 @@ Prefer indexes keyed by stable IDs. Keep high-frequency runtime state out of met
|
||||
- Preserve references for unaffected entities and buckets.
|
||||
- Keep streaming state out of broadly consumed stores.
|
||||
- Never rely on `React.memo`, `useMemo`, or Zustand equality to prevent selector execution upstream.
|
||||
- Treat every custom memo/equality comparator as a correctness boundary. Inventory every render-relevant value that comparator gates and observe its canonical identity or an explicit semantic version covering the same semantics.
|
||||
- Do not compare a proxy, aggregate, fallback, or differently resolved identity when the gated render path uses another source. Stable entity IDs do not imply stable rendered content; changes to comparator-gated semantics under the same ID must invalidate affected consumers, while semantically equivalent replacements may remain stable.
|
||||
- Prefer leaf subscriptions for isolated high-frequency state over threading broad state through custom comparators. Keep comparator work bounded so render fanout is not merely replaced by recursive comparison fanout.
|
||||
- Do not sort structural lists from token/delta-frequency fields.
|
||||
- Coalesce repeated same-entity events and skip no-op reducer updates.
|
||||
- Ensure hidden or disabled surfaces perform no ongoing work.
|
||||
@@ -117,6 +122,20 @@ Prefer indexes keyed by stable IDs. Keep high-frequency runtime state out of met
|
||||
- Avoid textarea auto-size shrink/expand cycles when content only grows.
|
||||
- Freeze structural ordering during high-frequency updates and reorder at an explicit lifecycle edge.
|
||||
|
||||
## Virtualization Contracts
|
||||
|
||||
Virtualization changes layout, mounting, measurement, focus, and scroll semantics. It is not behaviorally equivalent merely because steady-state visible rows look the same.
|
||||
|
||||
Before virtualizing a collection, define:
|
||||
|
||||
- the actual scrolling element and whether it directly contains the virtualizer or is an ancestor;
|
||||
- how total virtual height and the final item remain reachable from that scroller;
|
||||
- estimated versus measured sizes, including expanded, nested, and dynamically resized items;
|
||||
- initialization, remount, and activation-threshold behavior;
|
||||
- interactions that depend on mounted DOM, including incremental reveal, focus, selection, drag-and-drop, menus, and accessibility traversal.
|
||||
|
||||
When activation is threshold-based, test threshold minus one, threshold, and threshold plus one. Also test applicable collapsed/expanded, hidden/visible, filtered/unfiltered, and short/long transitions. If the current DOM or scroll topology cannot expose the virtual tail reliably, correct that topology or retain normal rendering rather than virtualizing solely by item count.
|
||||
|
||||
## Caching Rules
|
||||
|
||||
Add a cache only when all are explicit:
|
||||
@@ -141,6 +160,9 @@ Require both correctness and performance guards:
|
||||
- repeated-event test for streaming/polling paths;
|
||||
- no-op and unrelated-entity update tests;
|
||||
- reference-stability test for unaffected buckets;
|
||||
- when custom comparators change, tests proving both directions: unrelated or semantically equivalent updates preserve the boundary, while changes to comparator-gated identity, membership, content, and source semantics invalidate it;
|
||||
- when memoized tree/list consumers change, same-ID replacements and rebuilt-container fixtures covering both semantic change and semantic equivalence;
|
||||
- when virtualization changes, tests using the real scrolling ancestor that prove final-item/control reachability and stable scroll, focus, and interactions; include activation-boundary cases when such a boundary exists;
|
||||
- failure, partial-data, empty-success, and stale-async-completion tests;
|
||||
- memory/cache growth check for long-running paths;
|
||||
- production build or equivalent runtime profile for UI interactions.
|
||||
@@ -181,4 +203,6 @@ If the interaction remains above budget, do not call the mitigation the complete
|
||||
- [ ] Partial failure cannot trigger destructive cleanup.
|
||||
- [ ] Representative benchmark meets the stated budget.
|
||||
- [ ] Operation-count or repeated-event regression test prevents recurrence.
|
||||
- [ ] Structural optimizations have transition-focused correctness coverage independent of performance measurements.
|
||||
- [ ] When mount topology or activation boundaries change, instrumentation distinguishes those transitions from steady state.
|
||||
- [ ] Correctness, type, lint, and relevant runtime validations pass.
|
||||
|
||||
@@ -35,6 +35,14 @@ Never swallow an SDK/API error into `[]`, `{}`, or another valid empty success.
|
||||
|
||||
Track completeness at the smallest entity/scope. One failed project or directory blocks destructive work for itself, not for unrelated complete scopes.
|
||||
|
||||
Inferring destructive cleanup from disappearance between snapshots requires an established authoritative baseline. This is separate from applying a complete snapshot whose contract explicitly authorizes first-load replacement.
|
||||
|
||||
- Never infer a disappearance event from the first snapshot, startup-empty state, filtered/visible subsets, or partially loaded scopes.
|
||||
- Compare two complete authoritative snapshots from the same runtime and logical scope before treating disappearance as removal.
|
||||
- Key disappearance by stable entity identity. Owner, directory, grouping, category, or presentation moves are not deletion unless the authoritative contract says so.
|
||||
- Reset the baseline when runtime identity or authoritative scope changes.
|
||||
- Prefer explicit deletion events; snapshot-difference cleanup is a fallback that requires completeness guarantees.
|
||||
|
||||
## Live And Historical State
|
||||
|
||||
- Use historical state to restore context, not to infer ongoing execution.
|
||||
@@ -61,6 +69,10 @@ For streaming-frequency work, also load `performance-engineering`.
|
||||
- Treat startup 502/503 as transient with bounded retry/recovery.
|
||||
- A retry loop requires a real failure signal; swallowed errors disable retries.
|
||||
- Preserve previous authoritative state during transient bootstrap/reconnect failures.
|
||||
- Distinguish stale-scope rejection from same-scope mutation reconciliation. A generation token rejects obsolete owners but does not protect mutations made while a still-valid request is in flight.
|
||||
- Capture a mutation revision when an authoritative load starts. At commit time, read current state and preserve or overlay entity mutations newer than that revision.
|
||||
- Record removals as mutations even when the entity is already absent, so an in-flight response cannot resurrect it.
|
||||
- Return committed reconciled state, not the raw fetched snapshot, when callers depend on the result.
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
@@ -85,6 +97,18 @@ For streaming-frequency work, also load `performance-engineering`.
|
||||
- Key runtime-scoped caches by runtime identity when IDs or paths can collide.
|
||||
- Clean optimistic and local cache state after partial failures.
|
||||
|
||||
## Persisted Snapshot Ordering
|
||||
|
||||
When state exists in memory and one or more persistent stores, define an explicit authority and ordering protocol:
|
||||
|
||||
- Distinguish a missing snapshot from authoritative empty data, malformed data, and read failure.
|
||||
- Preserve mutation order independently per owner by serializing writes or attaching monotonic revisions and rejecting stale writes. Do not rely on uncontrolled wall-clock timestamps.
|
||||
- Capture runtime/owner identity with every debounced or asynchronous operation and verify it again before commit.
|
||||
- Pending writes must complete against their captured owner, drain before an owner switch, or be canceled only under an explicit durability/data-loss contract. Apply the strongest available guarantee at page hide/freeze and shutdown boundaries.
|
||||
- During hydration, capture the local mutation revision and do not replace state after newer local mutations.
|
||||
- Validate persisted payload shape before granting authority. Malformed data is failure, not empty success.
|
||||
- Define retention explicitly; never silently evict older owner namespaces unless bounded retention and resulting data loss are intentional contracts.
|
||||
|
||||
## Verification
|
||||
|
||||
Cover the relevant lifecycle, not only static state:
|
||||
@@ -97,6 +121,10 @@ Cover the relevant lifecycle, not only static state:
|
||||
- create, stream, abort, permission, archive/delete, and revisit when session behavior changes;
|
||||
- partial multi-directory/project failure;
|
||||
- runtime or worktree switch with dynamic directory resolution.
|
||||
- snapshot-difference cleanup establishing its first authoritative baseline without deletion, then cleaning a later authoritative disappearance exactly once;
|
||||
- identity-preserving moves/category changes and runtime/scope changes resetting cleanup baselines;
|
||||
- create, update, move, archive, and delete mutations surviving responses started before those mutations;
|
||||
- missing versus empty persistence, malformed payloads, out-of-order writes, hydration races, and lifecycle durability behavior.
|
||||
|
||||
## Red Flags
|
||||
|
||||
@@ -107,3 +135,6 @@ Cover the relevant lifecycle, not only static state:
|
||||
- Queue reads current model/agent at send time.
|
||||
- New session lookup assumes SSE already indexed it.
|
||||
- Optimistic data has no shadow entry or rollback.
|
||||
- Snapshot-difference cleanup treats its first startup snapshot as a disappearance event.
|
||||
- Missing or malformed persistence becomes authoritative empty state.
|
||||
- Debounced writes are canceled on owner/lifecycle change without completing against the captured owner or an explicit durability/data-loss contract.
|
||||
|
||||
@@ -68,3 +68,4 @@ workspaces/
|
||||
*.pid
|
||||
.worktrees/
|
||||
test-results/
|
||||
artifacts/browser-profile-*/
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
},
|
||||
"packages/electron": {
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.16.1",
|
||||
"version": "1.16.2",
|
||||
"dependencies": {
|
||||
"@openchamber/web": "workspace:*",
|
||||
"electron-context-menu": "^4.1.2",
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.16.1",
|
||||
"version": "1.16.2",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@base-ui/react": "^1.4.0",
|
||||
@@ -237,7 +237,7 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.16.1",
|
||||
"version": "1.16.2",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.3",
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.16.1",
|
||||
"version": "1.16.2",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
@@ -347,6 +347,9 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch",
|
||||
},
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"docs:validate": "node scripts/docs/validate-docs.mjs",
|
||||
"dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates",
|
||||
"doctor": "node scripts/react-doctor.mjs",
|
||||
"profile:browser": "node scripts/profile-browser.mjs",
|
||||
"icons:sprite": "node scripts/generate-file-type-sprite.mjs",
|
||||
"icons:generate": "bun run scripts/generate-icon-sprite.mjs",
|
||||
"themes:port:opencode": "tsx scripts/port-opencode-theme.ts",
|
||||
|
||||
@@ -96,9 +96,12 @@ Packaged Desktop builds include the official OpenCode CLI that matches the pinne
|
||||
|
||||
Managed local Desktop startup prefers OpenCode binaries in this order:
|
||||
|
||||
1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`.
|
||||
2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`.
|
||||
3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations.
|
||||
1. `settings.opencodeBinary`.
|
||||
2. Environment overrides: `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`.
|
||||
3. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`.
|
||||
4. System installs discovered from PATH.
|
||||
5. Known npm/Bun/Homebrew/Scoop/Chocolatey and other standard install locations.
|
||||
6. Platform discovery through `where opencode` on Windows or a login shell on macOS/Linux.
|
||||
|
||||
Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable.
|
||||
|
||||
|
||||
@@ -490,12 +490,16 @@ const readJsonFile = (filePath) => {
|
||||
};
|
||||
|
||||
const writeJsonFile = async (filePath, data) => {
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const directory = path.dirname(filePath);
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') await fsp.chmod(directory, 0o700);
|
||||
// Atomic: write to a temp file then rename. Readers never see a partial
|
||||
// JSON file that could parse-error and get coerced to {}.
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(data, null, 2));
|
||||
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
|
||||
await fsp.rename(tmp, filePath);
|
||||
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
|
||||
};
|
||||
|
||||
const readSettingsRoot = () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return }
|
||||
|
||||
var snapshot: [String: Any] = [
|
||||
"runtimeKey": request.content.userInfo["runtimeKey"] as? String ?? "",
|
||||
"attentionCount": 0,
|
||||
"recentSessions": [],
|
||||
]
|
||||
@@ -46,6 +47,16 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
snapshot = stored
|
||||
}
|
||||
|
||||
if let pushRuntimeKey = request.content.userInfo["runtimeKey"] as? String,
|
||||
!pushRuntimeKey.isEmpty,
|
||||
snapshot["runtimeKey"] as? String != pushRuntimeKey {
|
||||
snapshot = [
|
||||
"runtimeKey": pushRuntimeKey,
|
||||
"attentionCount": 0,
|
||||
"recentSessions": [],
|
||||
]
|
||||
}
|
||||
|
||||
// Attention count: authoritative server value carried in aps.badge.
|
||||
if let badge = request.content.badge as? Int {
|
||||
snapshot["attentionCount"] = badge
|
||||
|
||||
@@ -17,10 +17,11 @@ struct WidgetSession: Codable, Identifiable, Hashable {
|
||||
/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the
|
||||
/// JSON the app stores decodes directly.
|
||||
struct WidgetSnapshot: Codable {
|
||||
var runtimeKey: String?
|
||||
let attentionCount: Int
|
||||
let recentSessions: [WidgetSession]
|
||||
|
||||
static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: [])
|
||||
static let empty = WidgetSnapshot(runtimeKey: nil, attentionCount: 0, recentSessions: [])
|
||||
}
|
||||
|
||||
enum WidgetStore {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
@@ -202,6 +203,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
setDiffLoadError(null);
|
||||
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
||||
.then((response) => {
|
||||
@@ -210,7 +212,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
});
|
||||
}, runtimeKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
import { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -40,6 +40,18 @@ const testRelay: MobileRelayConfig = {
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('removes inline tokens only after each secure migration succeeds', async () => {
|
||||
const result = await migrateLegacyInlineTokenRecords([
|
||||
{ id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' },
|
||||
{ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' },
|
||||
], async (url) => url.includes('ok.example'));
|
||||
|
||||
expect(result.migrated).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.records[0]).toEqual({ id: 'ok', url: 'http://ok.example', hasToken: true });
|
||||
expect(result.records[1]).toEqual({ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' });
|
||||
});
|
||||
|
||||
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
@@ -691,6 +691,30 @@ const deleteSecureToken = async (key: string): Promise<void> => {
|
||||
|
||||
// One-time migration: a legacy localStorage record on native might still carry an
|
||||
// inline `clientToken`. Move it into the secure store and strip the metadata.
|
||||
export const migrateLegacyInlineTokenRecords = async (
|
||||
records: unknown[],
|
||||
migrateToken: (url: string, token: string) => Promise<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> => {
|
||||
if (typeof window === 'undefined' || !isCapacitorApp()) return;
|
||||
let parsed: unknown;
|
||||
@@ -707,11 +731,20 @@ const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
&& Boolean((item as { clientToken: string }).clientToken.trim()));
|
||||
if (legacy.length === 0) return;
|
||||
logStorage('secure:migrate-start', { count: legacy.length });
|
||||
for (const { url, clientToken } of legacy) {
|
||||
await writeSecureToken(getConnectionStorageKey(url), clientToken);
|
||||
const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => {
|
||||
const key = getConnectionStorageKey(url);
|
||||
if (!await writeSecureToken(key, token)) return false;
|
||||
return await readSecureToken(key) === token;
|
||||
});
|
||||
if (result.migrated > 0) {
|
||||
try {
|
||||
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records));
|
||||
} catch (error) {
|
||||
console.warn('[mobile-storage] failed to finalize secure token migration', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeConnections(readConnections());
|
||||
logStorage('secure:migrate-done', { count: legacy.length });
|
||||
logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed });
|
||||
};
|
||||
|
||||
export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* Builds the lightweight session overview the native iOS widgets render (home medium,
|
||||
@@ -26,6 +27,8 @@ export interface MobileWidgetSession {
|
||||
}
|
||||
|
||||
export interface MobileWidgetSnapshot {
|
||||
/** Runtime instance that owns all session IDs and paths in this snapshot. */
|
||||
runtimeKey: string;
|
||||
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
|
||||
attentionCount: number;
|
||||
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */
|
||||
@@ -97,7 +100,7 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.map(({ id, title, unread, project }) => ({ id, title, unread, project }));
|
||||
|
||||
return { attentionCount, recentSessions };
|
||||
return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
|
||||
};
|
||||
|
||||
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
|
||||
|
||||
@@ -7,9 +7,15 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||
@@ -47,7 +53,13 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||
usePermissionStore.getState().reset();
|
||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
|
||||
@@ -36,16 +36,15 @@ import { useStreamingStore } from '@/sync/streaming';
|
||||
import {
|
||||
useSessionMessageCount,
|
||||
useSessionMessageRecords,
|
||||
useSessionMessageLoadState,
|
||||
useSyncDirectory,
|
||||
useDirectorySync,
|
||||
useSessionRenderable,
|
||||
useSessionStatus,
|
||||
useScopedBlockingPermissions,
|
||||
useScopedBlockingQuestions,
|
||||
useParentSession,
|
||||
} from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
@@ -215,6 +214,11 @@ const ChatViewport = React.memo(({
|
||||
// Shell-mode prompts show their extracted command; cache by message id so
|
||||
// the parts array reference is stable while the command is unchanged.
|
||||
const shellPreviewCache = React.useRef(new Map<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 next = new Map<string, Part[]>();
|
||||
for (let index = 0; index < renderedMessages.length; index += 1) {
|
||||
@@ -339,6 +343,7 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionId}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
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 = {
|
||||
active?: boolean;
|
||||
autoOpenDraft?: 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();
|
||||
// Session UI state
|
||||
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 setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
|
||||
// Sync actions
|
||||
const sync = useSync();
|
||||
const syncDirectory = useSyncDirectory();
|
||||
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
|
||||
const ensureSessionRenderable = React.useCallback(
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
|
||||
[sync],
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
const loadMoreMessages = React.useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -542,31 +575,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
),
|
||||
);
|
||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
const hasRenderableSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
// Messages from sync system
|
||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
||||
enabled: active,
|
||||
suspendPartUpdates: Boolean(streamingMessageId),
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const sessionPrefetchInfo = React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(notify) => currentSessionId
|
||||
? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify)
|
||||
: () => undefined,
|
||||
[currentSessionId, effectiveSessionDirectory],
|
||||
),
|
||||
React.useCallback(
|
||||
() => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined,
|
||||
[currentSessionId, effectiveSessionDirectory],
|
||||
),
|
||||
React.useCallback(() => undefined, []),
|
||||
const sessionMessageLoadState = useSessionMessageLoadState(
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
|
||||
// Plan detection - watches messages for plan creation and signals store
|
||||
@@ -643,20 +662,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// History metadata — use sync's hasMore/isLoading
|
||||
const historyMeta = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
// Sync's meta is authoritative once a fetch has confirmed the history
|
||||
// is fully loaded — a stale prefetch-cache entry (cursor recorded at
|
||||
// the initial page) must not keep the "load older" affordance alive
|
||||
// after the user has already reached the top.
|
||||
const syncComplete = sync.isComplete(currentSessionId);
|
||||
const prefetchHasMore = !syncComplete
|
||||
&& Boolean(sessionPrefetchInfo?.cursor)
|
||||
&& sessionPrefetchInfo?.complete !== true;
|
||||
return {
|
||||
limit: sessionMessages.length,
|
||||
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
|
||||
loading: sync.isLoading(currentSessionId),
|
||||
complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor,
|
||||
loading: sessionMessageLoadState.status === 'loading',
|
||||
};
|
||||
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
|
||||
}, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
@@ -668,17 +679,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const isDesktopExpandedInput = isExpandedInput;
|
||||
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
|
||||
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);
|
||||
|
||||
// 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 =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionRef.current === currentSessionId) return;
|
||||
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
@@ -963,14 +967,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (!active || !currentSessionId) return;
|
||||
if (hasRenderableSessionSnapshot) return;
|
||||
if (effectiveSessionDirectory !== syncDirectory) return;
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]);
|
||||
}, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
||||
@@ -993,22 +996,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
||||
<div className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<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}
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
'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 (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 (
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
@@ -1124,7 +1134,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<ChatViewport
|
||||
key={currentSessionId}
|
||||
currentSessionId={currentSessionId}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
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 { useSessionUIStore } from '@/sync/session-ui-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 * as sessionActions from '@/sync/session-actions';
|
||||
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 { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
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 { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import ToolOutputDialog from './message/ToolOutputDialog';
|
||||
@@ -111,6 +120,9 @@ const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
||||
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 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 VS_CODE_DROP_DATA_TYPES = [
|
||||
'CodeFiles',
|
||||
@@ -918,81 +930,35 @@ type AutocompleteOverlayPosition = {
|
||||
maxHeight: number;
|
||||
};
|
||||
|
||||
// Per-session draft key — preserves in-progress messages across project switches
|
||||
const getDraftKey = (sessionId: string | null): string =>
|
||||
`openchamber_chat_input_draft_${sessionId ?? 'new'}`;
|
||||
|
||||
// Helper to safely read from localStorage for a given session
|
||||
const getStoredDraft = (sessionId: string | null): string => {
|
||||
try {
|
||||
return localStorage.getItem(getDraftKey(sessionId)) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to safely write/clear a per-session draft
|
||||
const saveStoredDraft = (sessionId: string | null, draft: string): void => {
|
||||
try {
|
||||
if (draft) {
|
||||
localStorage.setItem(getDraftKey(sessionId), draft);
|
||||
} else {
|
||||
localStorage.removeItem(getDraftKey(sessionId));
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
};
|
||||
|
||||
// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text
|
||||
const getConfirmedMentionsKey = (sessionId: string | null): string =>
|
||||
`openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`;
|
||||
|
||||
const saveConfirmedMentions = (sessionId: string | null, mentions: Set<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 resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
||||
const sessionState = useSessionUIStore.getState();
|
||||
const newSessionDirectory = sessionState.newSessionDraft?.open
|
||||
? sessionState.newSessionDraft.bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride
|
||||
: null;
|
||||
const directory = sessionId
|
||||
? sessionState.getDirectoryForSession(sessionId) ?? sessionState.currentSessionDirectory
|
||||
: newSessionDirectory ?? useDirectoryStore.getState().currentDirectory;
|
||||
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
|
||||
};
|
||||
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
const { t } = useI18n();
|
||||
// Track if we restored a draft on mount (for text selection)
|
||||
const initialDraftRef = React.useRef<string | null>(null);
|
||||
// Track initial session ID (captured at mount time for draft restoration)
|
||||
const initialSessionIdRef = React.useRef<string | null>(null);
|
||||
const initialDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(null);
|
||||
const initialDraftSnapshotRef = React.useRef<ChatDraftSnapshot>({ text: '', confirmedMentions: new Set() });
|
||||
const [message, setMessage] = React.useState(() => {
|
||||
// Read per-session draft at mount time using the current session from the store
|
||||
const sessionId = useSessionUIStore.getState().currentSessionId;
|
||||
initialSessionIdRef.current = sessionId;
|
||||
const draft = getStoredDraft(sessionId);
|
||||
if (draft) {
|
||||
initialDraftRef.current = draft;
|
||||
const identity = resolveChatDraftIdentity(sessionId);
|
||||
const snapshot = readChatDraft(identity);
|
||||
initialDraftIdentityRef.current = identity;
|
||||
initialDraftSnapshotRef.current = snapshot;
|
||||
if (snapshot.text) {
|
||||
initialDraftRef.current = snapshot.text;
|
||||
}
|
||||
return draft;
|
||||
return snapshot.text;
|
||||
});
|
||||
// Restore confirmed mentions from localStorage on mount
|
||||
const confirmedMentionsRef = React.useRef<Set<string>>(loadConfirmedMentions(initialSessionIdRef.current));
|
||||
const confirmedMentionsRef = React.useRef<Set<string>>(initialDraftSnapshotRef.current.confirmedMentions);
|
||||
// Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
|
||||
const isConfirmedFilePath = (text: string): boolean =>
|
||||
text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text);
|
||||
@@ -1070,7 +1036,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipNextDraftPersistRef = React.useRef(false);
|
||||
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());
|
||||
|
||||
// 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(
|
||||
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
|
||||
);
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
const chatDraftIdentity = React.useMemo(
|
||||
() => createChatDraftIdentity(
|
||||
activeRuntimeKey,
|
||||
currentSessionDirectoryForSync ?? currentDirectory,
|
||||
currentSessionId,
|
||||
),
|
||||
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
|
||||
);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
||||
const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => (
|
||||
@@ -1487,14 +1462,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
} | null>(null);
|
||||
|
||||
// Message queue
|
||||
const messageQueueTarget = currentSessionId
|
||||
? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory)
|
||||
: null;
|
||||
const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null;
|
||||
const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior);
|
||||
const queuedMessages = useMessageQueueStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!currentSessionId) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||
if (!messageQueueKey) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[messageQueueKey] ?? EMPTY_QUEUE;
|
||||
},
|
||||
[currentSessionId]
|
||||
[messageQueueKey]
|
||||
)
|
||||
);
|
||||
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
||||
@@ -1502,21 +1481,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
|
||||
// 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(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return 0;
|
||||
return (state.drafts[sessionKey] ?? []).length;
|
||||
},
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
(state) => inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []).length : 0,
|
||||
[inlineDraftKey]
|
||||
)
|
||||
);
|
||||
const draftSourceKey = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : [];
|
||||
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
@@ -1529,7 +1514,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`;
|
||||
},
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
[inlineDraftKey]
|
||||
)
|
||||
);
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
@@ -1537,29 +1522,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const terminalContextDrafts = terminalContextCount > 0
|
||||
? (useInlineCommentDraftStore.getState().drafts[currentSessionId ?? (newSessionDraftOpen ? 'draft' : '')] ?? []).filter((draft) => draft.source === 'terminal')
|
||||
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
|
||||
: [];
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === source) {
|
||||
removeInlineCommentDraft(sessionKey, draft.id);
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
|
||||
}, [inlineDraftTarget, removeInlineCommentDraft]);
|
||||
// Review comments are the inline-comment drafts that aren't preview sources.
|
||||
const removeReviewDrafts = React.useCallback(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') {
|
||||
removeInlineCommentDraft(sessionKey, draft.id);
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
|
||||
}, [inlineDraftTarget, removeInlineCommentDraft]);
|
||||
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
@@ -1571,17 +1554,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}, [message]);
|
||||
|
||||
React.useEffect(() => {
|
||||
currentSessionIdForDraftRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
currentChatDraftIdentityRef.current = chatDraftIdentity;
|
||||
}, [chatDraftIdentity]);
|
||||
|
||||
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => {
|
||||
const key = getDraftKey(sessionId);
|
||||
const lastPersisted = lastPersistedDraftRef.current.get(key);
|
||||
if (lastPersisted === draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveStoredDraft(sessionId, draft);
|
||||
const persistDraftImmediately = React.useCallback((identity: ChatDraftIdentity | null, draft: string) => {
|
||||
if (!identity) return;
|
||||
const key = getChatDraftIdentityKey(identity);
|
||||
// Only persist confirmed mentions that are actually present in the draft text
|
||||
const activeMentions = new Set<string>();
|
||||
for (const mention of confirmedMentionsRef.current) {
|
||||
@@ -1590,8 +1568,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}
|
||||
confirmedMentionsRef.current = activeMentions;
|
||||
saveConfirmedMentions(sessionId, activeMentions);
|
||||
lastPersistedDraftRef.current.set(key, draft);
|
||||
const signature = getChatDraftSnapshotSignature(draft, activeMentions);
|
||||
const lastPersisted = lastPersistedDraftRef.current.get(key);
|
||||
if (lastPersisted === signature) {
|
||||
return;
|
||||
}
|
||||
writeChatDraft(identity, draft, activeMentions);
|
||||
lastPersistedDraftRef.current.set(key, signature);
|
||||
}, []);
|
||||
|
||||
const clearPendingDraftPersist = React.useCallback(() => {
|
||||
@@ -1614,11 +1597,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!persistChatDraft) {
|
||||
// Setting disabled - clear the restored draft
|
||||
setMessage('');
|
||||
try {
|
||||
localStorage.removeItem(getDraftKey(initialSessionIdRef.current));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
writeChatDraft(initialDraftIdentityRef.current, '', []);
|
||||
} else {
|
||||
// Setting enabled - select all text
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1627,24 +1606,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}, [persistChatDraft]);
|
||||
|
||||
// Handle session switching: save draft for old session, restore draft for new session
|
||||
const prevSessionIdRef = React.useRef(currentSessionId);
|
||||
// Handle identity switching: save the old draft and restore the new runtime/directory/session draft.
|
||||
const prevChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
|
||||
React.useEffect(() => {
|
||||
if (prevSessionIdRef.current !== currentSessionId) {
|
||||
const oldSessionId = prevSessionIdRef.current;
|
||||
prevSessionIdRef.current = currentSessionId;
|
||||
const previousIdentity = prevChatDraftIdentityRef.current;
|
||||
const previousKey = previousIdentity ? getChatDraftIdentityKey(previousIdentity) : null;
|
||||
const currentKey = chatDraftIdentity ? getChatDraftIdentityKey(chatDraftIdentity) : null;
|
||||
if (previousKey !== currentKey) {
|
||||
prevChatDraftIdentityRef.current = chatDraftIdentity;
|
||||
setInputMode('normal');
|
||||
clearPendingDraftPersist();
|
||||
skipNextDraftPersistRef.current = true;
|
||||
|
||||
if (persistChatDraft) {
|
||||
// Save current draft for the session we're leaving
|
||||
persistDraftImmediately(oldSessionId, messageRef.current);
|
||||
// Restore draft for the session we're entering
|
||||
const newDraft = getStoredDraft(currentSessionId);
|
||||
setMessage(newDraft);
|
||||
confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId);
|
||||
if (newDraft) {
|
||||
persistDraftImmediately(previousIdentity, messageRef.current);
|
||||
const nextSnapshot = readChatDraft(chatDraftIdentity);
|
||||
setMessage(nextSnapshot.text);
|
||||
confirmedMentionsRef.current = nextSnapshot.confirmedMentions;
|
||||
if (nextSnapshot.text) {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.select();
|
||||
});
|
||||
@@ -1655,7 +1634,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
confirmedMentionsRef.current = new Set();
|
||||
}
|
||||
}
|
||||
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
|
||||
}, [chatDraftIdentity, clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
React.useEffect(() => subscribeChatDraftDeletion((deletedIdentity) => {
|
||||
const deletedKey = getChatDraftIdentityKey(deletedIdentity);
|
||||
lastPersistedDraftRef.current.set(deletedKey, getChatDraftSnapshotSignature('', []));
|
||||
const currentIdentity = currentChatDraftIdentityRef.current;
|
||||
if (!currentIdentity || getChatDraftIdentityKey(currentIdentity) !== deletedKey) return;
|
||||
clearPendingDraftPersist();
|
||||
skipNextDraftPersistRef.current = true;
|
||||
messageRef.current = '';
|
||||
confirmedMentionsRef.current = new Set();
|
||||
setMessage('');
|
||||
}), [clearPendingDraftPersist]);
|
||||
|
||||
// Focus textarea when new session draft is opened
|
||||
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
||||
@@ -1678,7 +1669,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
React.useEffect(() => {
|
||||
if (!persistChatDraft) {
|
||||
clearPendingDraftPersist();
|
||||
persistDraftImmediately(currentSessionId, '');
|
||||
persistDraftImmediately(chatDraftIdentity, '');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1689,24 +1680,37 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
clearPendingDraftPersist();
|
||||
const draftSnapshot = message;
|
||||
const sessionSnapshot = currentSessionId;
|
||||
const identitySnapshot = chatDraftIdentity;
|
||||
draftPersistTimerRef.current = setTimeout(() => {
|
||||
draftPersistTimerRef.current = null;
|
||||
persistDraftImmediately(sessionSnapshot, draftSnapshot);
|
||||
persistDraftImmediately(identitySnapshot, draftSnapshot);
|
||||
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearPendingDraftPersist();
|
||||
};
|
||||
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]);
|
||||
}, [chatDraftIdentity, clearPendingDraftPersist, message, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
const flushCurrentDraft = () => {
|
||||
clearPendingDraftPersist();
|
||||
if (persistChatDraft) {
|
||||
persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current);
|
||||
persistDraftImmediately(currentChatDraftIdentityRef.current, messageRef.current);
|
||||
}
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') flushCurrentDraft();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.addEventListener('freeze', flushCurrentDraft);
|
||||
window.addEventListener('pagehide', flushCurrentDraft);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.removeEventListener('freeze', flushCurrentDraft);
|
||||
window.removeEventListener('pagehide', flushCurrentDraft);
|
||||
flushCurrentDraft();
|
||||
};
|
||||
}, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
// Session activity for queue availability and controls
|
||||
@@ -1782,9 +1786,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
// Add message to queue instead of sending
|
||||
const handleQueueMessage = React.useCallback(() => {
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId) return;
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
const drafts = consumeDrafts(currentSessionId);
|
||||
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
|
||||
|
||||
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
if (drafts.length > 0) {
|
||||
@@ -1792,7 +1796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
||||
|
||||
addToQueue(currentSessionId, {
|
||||
addToQueue(messageQueueTarget, {
|
||||
content: messageToQueue,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
@@ -1815,7 +1819,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!isMobile) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -1974,10 +1978,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
let drafts: InlineCommentDraft[] = [];
|
||||
if (!queuedOnly && sessionKey) {
|
||||
drafts = consumeDrafts(sessionKey);
|
||||
if (!queuedOnly && consumedDraftTarget) {
|
||||
drafts = consumeDrafts(consumedDraftTarget);
|
||||
}
|
||||
|
||||
if (drafts.length > 0) {
|
||||
@@ -2032,17 +2036,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return;
|
||||
|
||||
// Clear queue and input
|
||||
if (currentSessionId && queuedMessageId) {
|
||||
removeFromQueue(currentSessionId, queuedMessageId);
|
||||
} else if (currentSessionId && hasQueuedMessages) {
|
||||
clearQueue(currentSessionId);
|
||||
if (messageQueueTarget && queuedMessageId) {
|
||||
removeFromQueue(messageQueueTarget, queuedMessageId);
|
||||
} else if (messageQueueTarget && hasQueuedMessages) {
|
||||
clearQueue(messageQueueTarget);
|
||||
}
|
||||
if (!queuedOnly) {
|
||||
setMessage('');
|
||||
confirmedMentionsRef.current.clear();
|
||||
// Clear per-session draft on submit
|
||||
saveStoredDraft(currentSessionId, '');
|
||||
saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current);
|
||||
persistDraftImmediately(chatDraftIdentity, '');
|
||||
// Reset message history navigation state
|
||||
setHistoryIndex(-1);
|
||||
setDraftMessage('');
|
||||
@@ -2333,8 +2336,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
sendMessageOptions,
|
||||
);
|
||||
const restoreConsumedDrafts = () => {
|
||||
if (sessionKey && drafts.length > 0) {
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts);
|
||||
if (consumedDraftTarget && drafts.length > 0) {
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2369,7 +2372,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const currentInput = textareaRef.current?.value ?? messageRef.current;
|
||||
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
|
||||
setMessage(inputSnapshot.message);
|
||||
saveStoredDraft(null, inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
}
|
||||
|
||||
const isSoftNetworkError =
|
||||
@@ -4667,7 +4670,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
|
||||
{t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })}
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -163,12 +163,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
|
||||
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
|
||||
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
|
||||
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
|
||||
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
|
||||
|
||||
streamPerfCount('ui.chat_message.render');
|
||||
if (isInActiveTurn) {
|
||||
@@ -186,12 +182,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}))
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
|
||||
const [copiedMessage, setCopiedMessage] = React.useState(false);
|
||||
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
|
||||
@@ -580,8 +570,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const shouldAnimateMessage = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
const freshnessDetector = MessageFreshnessDetector.getInstance();
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
|
||||
}, [message.info, currentSessionId, isUser]);
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID);
|
||||
}, [message.info, isUser]);
|
||||
|
||||
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
|
||||
|
||||
@@ -794,14 +784,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const handleRevert = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
revertToMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id, revertToMessage]);
|
||||
useSessionUIStore.getState().revertToMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id]);
|
||||
|
||||
// NEW: Fork handler
|
||||
const handleFork = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
forkFromMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id, forkFromMessage]);
|
||||
useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id]);
|
||||
|
||||
const handleToggleTool = React.useCallback((toolId: string) => {
|
||||
const isDefaultOpen = defaultOpenToolIds.has(toolId);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -754,69 +755,6 @@ const useMermaidInlineInteractions = ({
|
||||
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Single tuning knob: the streaming reveal cadence. Lower = smoother but more
|
||||
// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are
|
||||
// auto-scaled from this so reveal throughput (chars/sec) stays constant no
|
||||
// matter the cadence — text always keeps up with the incoming stream.
|
||||
const TEXT_PACE_MS = 64;
|
||||
const PACE_BASELINE_MS = 24;
|
||||
const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS;
|
||||
const TEXT_SNAP = /[\s.,!?;:)\]]/;
|
||||
|
||||
const paceStep = (remaining: number): number => {
|
||||
const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8));
|
||||
return Math.max(1, Math.round(base * PACE_RATIO));
|
||||
};
|
||||
|
||||
const nextRevealIndex = (text: string, start: number): number => {
|
||||
const end = Math.min(text.length, start + paceStep(text.length - start));
|
||||
for (let i = end; i < Math.min(text.length, end + 8); i += 1) {
|
||||
if (TEXT_SNAP.test(text[i] ?? '')) return i + 1;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
// Granular streaming reveal. Cheap because each step only re-runs the
|
||||
// marked->morphdom pipeline (patching changed DOM nodes), with no React tree
|
||||
// reconciliation of the markdown body.
|
||||
const usePacedText = (content: string, streaming: boolean): string => {
|
||||
const [shown, setShown] = React.useState<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
|
||||
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source
|
||||
// so a stable diagram is laid out once and served from cache thereafter.
|
||||
@@ -1094,6 +1032,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
onShowPopup,
|
||||
enableFileReferences = true,
|
||||
}) => {
|
||||
streamPerfCount('ui.markdown_renderer.render');
|
||||
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
||||
streamPerfObserve('ui.markdown_renderer.content_len', content.length);
|
||||
const currentTheme = useCurrentMermaidTheme();
|
||||
const { editor, runtime } = useRuntimeAPIs();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1106,7 +1047,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
}, [effectiveDirectory, openContextPreview]);
|
||||
|
||||
const live = isStreaming && !disableStreamAnimation;
|
||||
const pacedText = usePacedText(content, live);
|
||||
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
@@ -1127,7 +1067,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
|
||||
const markdownContent = (
|
||||
<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 { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionParts } from '@/sync/sync-context';
|
||||
@@ -885,6 +885,7 @@ const MessageListEntry = React.memo(({
|
||||
activeStreamingPhase,
|
||||
reviewTransferDirection,
|
||||
}: MessageListEntryProps) => {
|
||||
streamPerfCount('ui.message_list_entry.render');
|
||||
if (entry.kind === 'ungrouped') {
|
||||
return (
|
||||
<UngroupedMessageRow
|
||||
@@ -1264,6 +1265,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
scrollRef,
|
||||
directory,
|
||||
}, ref) => {
|
||||
streamPerfMark('react.message_list_render');
|
||||
streamPerfCount('ui.message_list.render');
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
|
||||
@@ -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 {
|
||||
_statusType?: 'busy' | 'retry' | 'idle';
|
||||
_hasRunningChildren?: boolean;
|
||||
_runningChildrenCount?: number;
|
||||
_childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
||||
}
|
||||
|
||||
// Cross-project session source. Mirrors the dedicated MobileSessionsSheet:
|
||||
@@ -84,12 +82,14 @@ function useSessionGrouping(
|
||||
const map = new Map<string, Session[]>();
|
||||
const allIds = new Set(sessions.map((s) => s.id));
|
||||
|
||||
sessions.forEach((session) => {
|
||||
for (const session of sessions) {
|
||||
const parentID = (session as { parentID?: string }).parentID;
|
||||
if (parentID && allIds.has(parentID)) {
|
||||
map.set(parentID, [...(map.get(parentID) || []), session]);
|
||||
const children = map.get(parentID);
|
||||
if (children) children.push(session);
|
||||
else map.set(parentID, [session]);
|
||||
}
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [sessions]);
|
||||
|
||||
@@ -99,24 +99,6 @@ function useSessionGrouping(
|
||||
return 'idle';
|
||||
}, [sessionStatus]);
|
||||
|
||||
const hasRunningChildren = React.useCallback((sessionId: string): boolean => {
|
||||
const children = parentChildMap.get(sessionId) || [];
|
||||
return children.some((child) => getStatusType(child.id) !== 'idle');
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const getRunningChildrenCount = React.useCallback((sessionId: string): number => {
|
||||
const children = parentChildMap.get(sessionId) || [];
|
||||
return children.filter((child) => getStatusType(child.id) !== 'idle').length;
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => {
|
||||
const children = parentChildMap.get(sessionId) || [];
|
||||
return children
|
||||
.filter((child) => getStatusType(child.id) !== 'idle')
|
||||
.map((child) => ({ session: child, isRunning: true }))
|
||||
.slice(0, 3);
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const processedSessions = React.useMemo(() => {
|
||||
const sessionIds = new Set(sessions.map((s) => s.id));
|
||||
const topLevel = sessions.filter((session) => {
|
||||
@@ -129,20 +111,18 @@ function useSessionGrouping(
|
||||
|
||||
topLevel.forEach((session) => {
|
||||
const statusType = getStatusType(session.id);
|
||||
const hasRunning = hasRunningChildren(session.id);
|
||||
const runningChildrenCount = (parentChildMap.get(session.id) ?? [])
|
||||
.filter((child) => getStatusType(child.id) !== 'idle')
|
||||
.length;
|
||||
const attention = (unseenCounts[session.id] ?? 0) > 0;
|
||||
|
||||
const enriched: SessionWithStatus = {
|
||||
...session,
|
||||
_statusType: statusType,
|
||||
_hasRunningChildren: hasRunning,
|
||||
_runningChildrenCount: getRunningChildrenCount(session.id),
|
||||
_childIndicators: getChildIndicators(session.id),
|
||||
_runningChildrenCount: runningChildrenCount,
|
||||
};
|
||||
|
||||
if (statusType !== 'idle' || hasRunning) {
|
||||
running.push(enriched);
|
||||
} else if (attention) {
|
||||
if (statusType !== 'idle' || runningChildrenCount > 0 || attention) {
|
||||
running.push(enriched);
|
||||
} else {
|
||||
viewed.push(enriched);
|
||||
@@ -159,7 +139,7 @@ function useSessionGrouping(
|
||||
viewed.sort(sortByUpdated);
|
||||
|
||||
return [...running, ...viewed];
|
||||
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
|
||||
}, [sessions, getStatusType, parentChildMap, unseenCounts]);
|
||||
|
||||
const totalRunning = processedSessions.reduce((sum, s) => {
|
||||
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
||||
@@ -188,7 +168,6 @@ function useSessionHelpers() {
|
||||
|
||||
// Per-project status indicators (running / unread) for the filter chips.
|
||||
function useProjectStatus(
|
||||
sessions: Session[],
|
||||
sessionStatus: Record<string, { type: string }> | undefined,
|
||||
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,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const sessions = useAllProjectSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionStatus = useAllSessionStatuses();
|
||||
@@ -469,7 +447,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
|
||||
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
|
||||
const { getSessionTitle, needsAttention } = useSessionHelpers();
|
||||
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
||||
const getProjectStatus = useProjectStatus(sessionStatus, currentSessionId);
|
||||
const resolveProjectRoots = useProjectRootsResolver();
|
||||
|
||||
// Project filter, persisted in the UI store so the choice survives closing and
|
||||
@@ -605,10 +583,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
</div>
|
||||
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
@@ -639,3 +613,11 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
</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 { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
|
||||
import { useSessionMessages, useSessionRenderable } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
@@ -646,11 +645,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||
const hasRenderableCurrentSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
const hasRenderableCurrentSessionSnapshot = useSessionRenderable(
|
||||
currentSessionId ?? '',
|
||||
currentSessionDirectory ?? undefined,
|
||||
);
|
||||
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -24,12 +24,12 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface QueuedMessageChipProps {
|
||||
message: QueuedMessage;
|
||||
sessionId: string;
|
||||
target: MessageQueueTarget;
|
||||
onEdit: (message: QueuedMessage) => void;
|
||||
onSend: (message: QueuedMessage) => void;
|
||||
}
|
||||
|
||||
const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMessageChipProps) => {
|
||||
const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessageChipProps) => {
|
||||
const { t } = useI18n();
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id });
|
||||
@@ -89,7 +89,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMe
|
||||
</Button>
|
||||
<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"
|
||||
aria-label={t('chat.queuedMessage.removeAria')}
|
||||
>
|
||||
@@ -111,13 +111,16 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
|
||||
const { t } = useI18n();
|
||||
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(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!currentSessionId) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||
if (!queueKey) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[queueKey] ?? EMPTY_QUEUE;
|
||||
},
|
||||
[currentSessionId]
|
||||
[queueKey]
|
||||
)
|
||||
);
|
||||
const popToInput = useMessageQueueStore((state) => state.popToInput);
|
||||
@@ -132,14 +135,14 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !currentSessionId) return;
|
||||
reorderQueue(currentSessionId, String(active.id), String(over.id));
|
||||
}, [currentSessionId, reorderQueue]);
|
||||
if (!over || active.id === over.id || !target) return;
|
||||
reorderQueue(target, String(active.id), String(over.id));
|
||||
}, [target, reorderQueue]);
|
||||
|
||||
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.attachments && popped.attachments.length > 0) {
|
||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||
@@ -147,13 +150,13 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
}
|
||||
}, [currentSessionId, popToInput, onEditMessage]);
|
||||
}, [target, popToInput, onEditMessage]);
|
||||
|
||||
const handleSend = React.useCallback((message: QueuedMessage) => {
|
||||
onSendMessage(message.id);
|
||||
}, [onSendMessage]);
|
||||
|
||||
if (queuedMessages.length === 0 || !currentSessionId) {
|
||||
if (queuedMessages.length === 0 || !target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -180,7 +183,7 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
target={target}
|
||||
onEdit={handleEdit}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
|
||||
@@ -159,6 +159,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
@@ -170,8 +176,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId ? state.sessions[currentSessionId]?.todos : undefined),
|
||||
[currentSessionId, showTodos],
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
|
||||
@@ -53,13 +53,14 @@ export const useTurnRecords = (
|
||||
const projection = React.useMemo(() => {
|
||||
const sessionKey = options.sessionKey ?? '';
|
||||
const mergeKey = options.planModeEnabled ? 'merge:plan' : 'merge';
|
||||
const cached = getCachedProjection(
|
||||
const cacheKey = buildProjectionCacheKey(
|
||||
sessionKey,
|
||||
messages,
|
||||
options.showTextJustificationActivity,
|
||||
options.showTurnChangedFiles,
|
||||
mergeKey,
|
||||
);
|
||||
const cached = getCachedProjection(cacheKey);
|
||||
if (cached) {
|
||||
previousProjectionRef.current = cached;
|
||||
return cached;
|
||||
@@ -74,13 +75,6 @@ export const useTurnRecords = (
|
||||
});
|
||||
previousProjectionRef.current = nextProjection;
|
||||
|
||||
const cacheKey = buildProjectionCacheKey(
|
||||
sessionKey,
|
||||
messages,
|
||||
options.showTextJustificationActivity,
|
||||
options.showTurnChangedFiles,
|
||||
mergeKey,
|
||||
);
|
||||
setCachedProjection(cacheKey, nextProjection);
|
||||
|
||||
return nextProjection;
|
||||
|
||||
@@ -56,14 +56,7 @@ export const buildProjectionCacheKey = (
|
||||
].join('|');
|
||||
};
|
||||
|
||||
export const getCachedProjection = (
|
||||
sessionKey: string,
|
||||
messages: ChatMessageEntry[],
|
||||
showTextJustificationActivity: boolean,
|
||||
showTurnChangedFiles: boolean,
|
||||
mergeHiddenUserTurnsKey: string,
|
||||
): TurnProjectionResult | undefined => {
|
||||
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles, mergeHiddenUserTurnsKey);
|
||||
export const getCachedProjection = (key: string): TurnProjectionResult | undefined => {
|
||||
const cached = projectionCache.get(key);
|
||||
if (cached) {
|
||||
// LRU re-order: move hit to the end (most recent) so it survives
|
||||
|
||||
@@ -623,7 +623,7 @@ const StaticToolRowInner: React.FC<{
|
||||
return entries;
|
||||
}, [activities, currentDirectory, isReadGroup]);
|
||||
|
||||
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
|
||||
const handleFileClick = React.useCallback((filePath: string, offset?: number) => {
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
if (!absolutePath) {
|
||||
return;
|
||||
@@ -656,14 +656,6 @@ const StaticToolRowInner: React.FC<{
|
||||
uiStore.openContextFile(contextDirectory, absolutePath);
|
||||
}, [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 isSearchGroup = normalizedToolName === 'grep'
|
||||
|| normalizedToolName === 'search'
|
||||
@@ -699,7 +691,7 @@ const StaticToolRowInner: React.FC<{
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
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)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
@@ -751,7 +743,7 @@ const StaticToolRowInner: React.FC<{
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
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)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import React from 'react';
|
||||
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 { useI18n } from '@/lib/i18n';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type LineRangeBase = {
|
||||
start: number;
|
||||
@@ -52,25 +60,42 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
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 updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||
|
||||
const [selection, setSelection] = React.useState<TRange | null>(null);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
|
||||
const sessionKey = React.useMemo(() => {
|
||||
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
}, [currentSessionId, newSessionDraftOpen]);
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
const draftDirectory = sessionDirectory ?? effectiveDirectory;
|
||||
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(() => {
|
||||
if (!sessionKey || !fileLabel) return [];
|
||||
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
||||
if (!target || !fileLabel) return [];
|
||||
return sessionDrafts.filter((draft) => draft.source === source && draft.fileLabel === fileLabel);
|
||||
}, [allDrafts, fileLabel, sessionKey, source]);
|
||||
}, [fileLabel, sessionDrafts, source, target]);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
@@ -90,18 +115,19 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
}, [fromDraftRange]);
|
||||
|
||||
const deleteDraft = React.useCallback((draft: InlineCommentDraft) => {
|
||||
removeDraft(draft.sessionKey, draft.id);
|
||||
if (!target) return;
|
||||
removeDraft(target, draft.id);
|
||||
if (editingDraftId === draft.id) {
|
||||
reset();
|
||||
}
|
||||
}, [editingDraftId, removeDraft, reset]);
|
||||
}, [editingDraftId, removeDraft, reset, target]);
|
||||
|
||||
const saveComment = React.useCallback((textToSave: string, rangeOverride?: TRange) => {
|
||||
const targetRange = rangeOverride ?? selection;
|
||||
const trimmedText = textToSave.trim();
|
||||
if (!targetRange || !trimmedText || !fileLabel) return;
|
||||
|
||||
if (!sessionKey) {
|
||||
if (!target) {
|
||||
toast.error(t('inlineComment.toast.selectSessionToSave'));
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +137,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
const code = getCodeForRange(normalizedRange);
|
||||
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
updateDraft(target, editingDraftId, {
|
||||
fileLabel,
|
||||
startLine: normalizedStoreRange.startLine,
|
||||
endLine: normalizedStoreRange.endLine,
|
||||
@@ -121,8 +147,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
text: trimmedText,
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
addDraft(target, {
|
||||
source,
|
||||
fileLabel,
|
||||
startLine: normalizedStoreRange.startLine,
|
||||
@@ -135,7 +160,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
}
|
||||
|
||||
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 {
|
||||
sessionKey,
|
||||
|
||||
@@ -542,6 +542,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||
|
||||
@@ -688,7 +689,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
|
||||
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!sessionKey) {
|
||||
if (!sessionKey || !effectiveDirectory) {
|
||||
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
|
||||
return;
|
||||
}
|
||||
@@ -712,8 +713,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
attachedScreenshot = false;
|
||||
}
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: pageUrl || 'preview',
|
||||
startLine: 1,
|
||||
@@ -731,7 +731,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
});
|
||||
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||
})();
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setBridgeReady(false);
|
||||
@@ -921,7 +921,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
|
||||
const attachConsoleEvents = React.useCallback(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!sessionKey) {
|
||||
if (!sessionKey || !effectiveDirectory) {
|
||||
toast.error(t('contextPanel.preview.console.attachNoSession'));
|
||||
return;
|
||||
}
|
||||
@@ -937,8 +937,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
return `[${timestamp}] [${event.level}] ${event.message}${details}`;
|
||||
}).join('\n');
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'preview-console',
|
||||
fileLabel: rawUrl || effectiveSrc || 'preview',
|
||||
startLine: 1,
|
||||
@@ -948,7 +947,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
text: t('contextPanel.preview.console.attachAnnotation'),
|
||||
});
|
||||
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,
|
||||
// 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);
|
||||
}
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: currentUrl || 'browser',
|
||||
startLine: 1,
|
||||
@@ -1610,7 +1608,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
||||
text: '',
|
||||
});
|
||||
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 iframe = iframeRef.current;
|
||||
@@ -1998,8 +1996,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
|
||||
await addAttachedFile(file);
|
||||
}
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: currentUrl || 'browser',
|
||||
startLine: 1,
|
||||
@@ -2018,7 +2015,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
|
||||
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||
})
|
||||
.catch(() => setIsInspecting(false));
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, isInspecting, newSessionDraftOpen, t]);
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, isInspecting, newSessionDraftOpen, t]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
|
||||
@@ -21,12 +21,12 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
||||
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
|
||||
import { useAllLiveSessions, useSession, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { useGitBranchLabel } from '@/stores/useGitStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
@@ -74,7 +74,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type HeaderSessionSnapshot = {
|
||||
title: string | null;
|
||||
directory: string | null;
|
||||
created: number | null;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
onToggleLeftDrawer,
|
||||
onToggleRightDrawer,
|
||||
leftDrawerOpen,
|
||||
rightDrawerOpen,
|
||||
}) => {
|
||||
streamPerfCount('ui.header.render');
|
||||
const { t } = useI18n();
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
@@ -720,7 +728,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const openContextBrowser = useUIStore((state) => state.openContextBrowser);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory);
|
||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
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 currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const currentSyncedSession = useSession(currentSessionId ?? null);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback(
|
||||
(state): HeaderSessionSnapshot | null => {
|
||||
if (!currentSessionId) return null;
|
||||
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) {
|
||||
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(() => {
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
@@ -1130,18 +1150,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
});
|
||||
}, [fetchAllQuotas, isUsageRefreshSpinning]);
|
||||
|
||||
const currentSessionLive = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
return liveSessions.find((s) => s.id === currentSessionId)
|
||||
?? globalActiveSessions.find((s) => s.id === currentSessionId)
|
||||
?? currentSyncedSession
|
||||
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
|
||||
?? null;
|
||||
}, [currentSessionId, currentSyncedSession, globalActiveSessions, liveSessions]);
|
||||
const currentSessionSnapshot = currentSessionId
|
||||
? currentGlobalSession ?? null
|
||||
: null;
|
||||
|
||||
const lastResolvedSessionRef = React.useRef<{
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
session: HeaderSessionSnapshot;
|
||||
expiresAt: number;
|
||||
} | null>(null);
|
||||
const [sessionFallbackVersion, setSessionFallbackVersion] = React.useState(0);
|
||||
@@ -1155,10 +1170,10 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSessionLive) {
|
||||
if (currentSessionSnapshot) {
|
||||
lastResolvedSessionRef.current = {
|
||||
sessionId: currentSessionId,
|
||||
session: currentSessionLive,
|
||||
session: currentSessionSnapshot,
|
||||
expiresAt: Date.now() + 2000,
|
||||
};
|
||||
return;
|
||||
@@ -1186,12 +1201,12 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [currentSessionId, currentSessionLive]);
|
||||
}, [currentSessionId, currentSessionSnapshot]);
|
||||
|
||||
void sessionFallbackVersion;
|
||||
const currentSession = (() => {
|
||||
if (currentSessionLive) {
|
||||
return currentSessionLive;
|
||||
if (currentSessionSnapshot) {
|
||||
return currentSessionSnapshot;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
@@ -1256,6 +1271,10 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const openDirectory = React.useMemo(() => {
|
||||
return worktreeDirectory || sessionDirectory || draftDirectory;
|
||||
}, [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 candidateDirectory = normalize(worktreeDirectory || sessionDirectory || '');
|
||||
@@ -1336,7 +1355,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
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) {
|
||||
lastPlanSessionKeyRef.current = sessionKey;
|
||||
}
|
||||
@@ -1349,7 +1368,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
planModeEnabled,
|
||||
planTabAvailable,
|
||||
currentSession?.slug,
|
||||
currentSession?.time?.created,
|
||||
currentSession?.created,
|
||||
currentSessionId,
|
||||
sessionDirectory,
|
||||
]);
|
||||
@@ -1449,23 +1468,16 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'context') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextOverview(directory);
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextOverview, openDirectory]);
|
||||
}, [closeContextPanel, openContextOverview, openDirectory]);
|
||||
|
||||
const isContextPanelActive = React.useMemo(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return getActiveContextMode(panelState) === 'context';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
const isContextPanelActive = activeContextMode === 'context';
|
||||
|
||||
const handleOpenContextPlan = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
@@ -1473,14 +1485,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'plan') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextPlan(directory);
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextPlan, openDirectory]);
|
||||
}, [closeContextPanel, openContextPlan, openDirectory]);
|
||||
|
||||
const handleOpenContextChanges = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
@@ -1488,14 +1500,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'diff') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextPanelTab(directory, { mode: 'diff', stagedDiff: false });
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextPanelTab, openDirectory]);
|
||||
}, [closeContextPanel, openContextPanelTab, openDirectory]);
|
||||
|
||||
const handleOpenContextBrowser = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
@@ -1503,41 +1515,18 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'browser') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextBrowser(directory);
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextBrowser, openDirectory]);
|
||||
}, [closeContextPanel, openContextBrowser, openDirectory]);
|
||||
|
||||
const isContextPlanActive = React.useMemo(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
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 isContextPlanActive = activeContextMode === 'plan';
|
||||
const isContextChangesActive = activeContextMode === 'diff';
|
||||
const isContextBrowserActive = activeContextMode === 'browser';
|
||||
|
||||
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
|
||||
const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS;
|
||||
|
||||
@@ -72,7 +72,6 @@ export const MainLayout: React.FC = () => {
|
||||
setMobileLeftDrawerOpen(open);
|
||||
useUIStore.getState().setSessionSwitcherOpen(open);
|
||||
}, []);
|
||||
const mobileRightDrawerOpenRef = React.useRef(false);
|
||||
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
|
||||
|
||||
// Left drawer motion value
|
||||
@@ -114,7 +113,6 @@ export const MainLayout: React.FC = () => {
|
||||
setMobileRightDrawerVisible(false);
|
||||
return;
|
||||
}
|
||||
mobileRightDrawerOpenRef.current = mobileRightSidebarOpen;
|
||||
if (mobileRightSidebarOpen) {
|
||||
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">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
@@ -480,7 +478,7 @@ export const MainLayout: React.FC = () => {
|
||||
aria-hidden={!mobileLeftDrawerOpen}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<SessionSidebar mobileVariant />
|
||||
<SessionSidebar mobileVariant isVisible={mobileLeftDrawerVisible} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
{mobileRightDrawerVisible && (
|
||||
@@ -520,7 +518,7 @@ export const MainLayout: React.FC = () => {
|
||||
className="border-border/50"
|
||||
topBar={<SidebarTopBar />}
|
||||
>
|
||||
<SessionSidebar />
|
||||
<SessionSidebar isVisible={isSidebarOpen} />
|
||||
</Sidebar>
|
||||
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
|
||||
<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">
|
||||
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
@@ -121,20 +122,23 @@ type FileTreeCache = {
|
||||
};
|
||||
const FILE_TREE_CACHE_MAX_ROOTS = 8;
|
||||
const fileTreeCacheByRoot = new Map<string, FileTreeCache>();
|
||||
const fileTreeCacheKey = (root: string): string => JSON.stringify([getRuntimeKey(), root]);
|
||||
|
||||
const touchCache = (root: string): FileTreeCache | null => {
|
||||
const entry = fileTreeCacheByRoot.get(root);
|
||||
const key = fileTreeCacheKey(root);
|
||||
const entry = fileTreeCacheByRoot.get(key);
|
||||
if (!entry) return null;
|
||||
entry.touchedAt = Date.now();
|
||||
// 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.
|
||||
fileTreeCacheByRoot.delete(root);
|
||||
fileTreeCacheByRoot.set(root, entry);
|
||||
fileTreeCacheByRoot.delete(key);
|
||||
fileTreeCacheByRoot.set(key, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const getOrCreateCache = (root: string): FileTreeCache => {
|
||||
const existing = fileTreeCacheByRoot.get(root);
|
||||
const key = fileTreeCacheKey(root);
|
||||
const existing = fileTreeCacheByRoot.get(key);
|
||||
if (existing) {
|
||||
existing.touchedAt = Date.now();
|
||||
return existing;
|
||||
@@ -151,12 +155,12 @@ const getOrCreateCache = (root: string): FileTreeCache => {
|
||||
loadedDirs: new Set(),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
fileTreeCacheByRoot.set(root, created);
|
||||
fileTreeCacheByRoot.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const dropCacheForRoot = (root: string): void => {
|
||||
fileTreeCacheByRoot.delete(root);
|
||||
fileTreeCacheByRoot.delete(fileTreeCacheKey(root));
|
||||
};
|
||||
|
||||
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
|
||||
|
||||
@@ -251,6 +251,10 @@ export const VSCodeLayout: React.FC = () => {
|
||||
setCurrentView('sessions');
|
||||
}, []);
|
||||
|
||||
const handleSessionSelected = React.useCallback(() => {
|
||||
setCurrentView('chat');
|
||||
}, []);
|
||||
|
||||
const isSessionInActiveWorkspace = React.useCallback((session: Session): boolean => {
|
||||
if (!activeWorkspacePath) {
|
||||
return false;
|
||||
@@ -590,7 +594,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
<ChatView active={currentView === 'chat'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,7 +613,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
<SessionSidebar
|
||||
mobileVariant
|
||||
allowReselect
|
||||
onSessionSelected={() => setCurrentView('chat')}
|
||||
onSessionSelected={handleSessionSelected}
|
||||
hideDirectoryControls
|
||||
/>
|
||||
</div>
|
||||
@@ -628,7 +632,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
<ChatView active={currentView === 'chat'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,10 @@ const mainLayoutSource = readFileSync(
|
||||
join(__dirname, '..', 'MainLayout.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const sessionSidebarSource = readFileSync(
|
||||
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
|
||||
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(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', () => {
|
||||
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar />');
|
||||
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
|
||||
expect(desktopSidebarIndex).toBeGreaterThan(-1);
|
||||
|
||||
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(/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 { formatDirectoryName, cn } from '@/lib/utils';
|
||||
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 { useSync } from '@/sync/use-sync';
|
||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||
import { SessionPrefetchEffect } from './sidebar/hooks/useSessionPrefetch';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
@@ -22,7 +23,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
|
||||
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 { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
||||
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
||||
@@ -30,7 +31,7 @@ import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
||||
import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
|
||||
import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
|
||||
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
|
||||
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
|
||||
import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
|
||||
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
|
||||
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
@@ -69,30 +70,32 @@ import {
|
||||
compareSessionsByPinnedAndTime,
|
||||
formatProjectLabel,
|
||||
normalizePath,
|
||||
selectExpandedParentKeysForContext,
|
||||
toggleExpandedParentKey,
|
||||
} from './sidebar/utils';
|
||||
import {
|
||||
mergeLiveSessionWithGlobalSession,
|
||||
refreshGlobalSessions,
|
||||
refreshGlobalSessionsForDirectories,
|
||||
getSessionStructuralSignature,
|
||||
resolveGlobalSessionDirectory,
|
||||
useGlobalSessionsStore,
|
||||
} from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
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 GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
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"
|
||||
// and a project's root) has independent expand state. v1 held bare session
|
||||
// ids; useSidebarPersistence migrates v1 data on first read by fanning each
|
||||
// id into all four context combinations.
|
||||
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';
|
||||
// and a project's root) has independent expand state. Older expansion state
|
||||
// mixed contexts and is intentionally not migrated.
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
|
||||
|
||||
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
|
||||
@@ -157,6 +160,7 @@ const isKnownActiveSessionDirectory = (
|
||||
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||
|
||||
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 handlerRef = React.useRef(handler);
|
||||
@@ -165,6 +169,7 @@ const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...ar
|
||||
};
|
||||
|
||||
interface SessionSidebarProps {
|
||||
isVisible?: boolean;
|
||||
mobileVariant?: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
allowReselect?: boolean;
|
||||
@@ -172,13 +177,65 @@ interface SessionSidebarProps {
|
||||
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,
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = 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 [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
|
||||
@@ -204,7 +261,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
|
||||
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds);
|
||||
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||
try {
|
||||
@@ -236,7 +292,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return new Map();
|
||||
}
|
||||
});
|
||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => {
|
||||
const initialActiveSessionByProject = React.useMemo<Map<string, string>>(() => {
|
||||
try {
|
||||
const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
@@ -253,7 +309,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
} catch {
|
||||
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 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 currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
@@ -302,26 +362,51 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
|
||||
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
|
||||
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
|
||||
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
|
||||
|
||||
useSessionSearchEffects({
|
||||
enabled: isVisible,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
});
|
||||
|
||||
const gitBranches = useGitAllBranches();
|
||||
const gitBranches = useGitAllBranches(isVisible);
|
||||
|
||||
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 hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const activeSessionStructure = useGlobalSessionsStore(useShallow(
|
||||
(state) => state.activeSessions.map(getSessionStructuralSignature).sort(),
|
||||
));
|
||||
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 updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||
@@ -359,14 +444,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
|
||||
const sessions = React.useMemo(() => {
|
||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => {
|
||||
const liveSession = liveById.get(session.id);
|
||||
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
|
||||
});
|
||||
const merged = [...globalActiveSessions];
|
||||
const seenIds = new Set(merged.map((session) => session.id));
|
||||
|
||||
liveSessions.forEach((session) => {
|
||||
liveFallbackSessions.forEach((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
@@ -377,43 +458,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
allowUnknownDirectory: !isVSCode,
|
||||
allowEmptyDirectorySet: !isVSCode,
|
||||
}));
|
||||
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveSessions]);
|
||||
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
|
||||
|
||||
const persistenceSessions = React.useMemo(
|
||||
() => [...globalActiveSessions, ...archivedSessions],
|
||||
[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);
|
||||
React.useEffect(() => {
|
||||
syncSessionsSnapshotRef.current = liveSessions;
|
||||
}, [syncSessionStructureSignature, 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],
|
||||
);
|
||||
}, [liveSessions]);
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const projectWorktreeDiscoveryKey = React.useMemo(
|
||||
() => projects
|
||||
() => `${runtimeKey}|${projects
|
||||
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
|
||||
.join('|'),
|
||||
[projects],
|
||||
.join('|')}`,
|
||||
[projects, runtimeKey],
|
||||
);
|
||||
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
|
||||
isVSCode ? projectWorktreeDiscoveryKey : null,
|
||||
@@ -434,6 +496,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
let cancelled = false;
|
||||
|
||||
const discoverWorktrees = async () => {
|
||||
const discoveryRuntimeKey = runtimeKey;
|
||||
const projectEntries = useProjectsStore.getState().projects;
|
||||
if (projectEntries.length === 0 || isVSCode) {
|
||||
if (!cancelled) {
|
||||
@@ -488,7 +551,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
if (cancelled) return;
|
||||
if (cancelled || getRuntimeKey() !== discoveryRuntimeKey) return;
|
||||
|
||||
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
|
||||
for (const projectPath of worktreesByProject.keys()) {
|
||||
@@ -514,7 +577,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVSCode, projectWorktreeDiscoveryKey]);
|
||||
}, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -556,22 +619,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
|
||||
isVSCode,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
safeStorage,
|
||||
keys: {
|
||||
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
|
||||
sessionExpandedLegacy: LEGACY_SESSION_EXPANDED_STORAGE_KEY,
|
||||
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
|
||||
sessionPinned: SESSION_PINNED_STORAGE_KEY,
|
||||
groupOrder: GROUP_ORDER_STORAGE_KEY,
|
||||
projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY,
|
||||
groupCollapse: GROUP_COLLAPSE_STORAGE_KEY,
|
||||
},
|
||||
sessions: persistenceSessions,
|
||||
pinnedSessionIds,
|
||||
setPinnedSessionIds,
|
||||
groupOrderByProject,
|
||||
activeSessionByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
@@ -619,12 +674,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return map;
|
||||
}, [sortedSessions, pinnedSessionIds]);
|
||||
|
||||
const emptyState = (
|
||||
const emptyState = React.useMemo(() => (
|
||||
<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-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p>
|
||||
</div>
|
||||
);
|
||||
), [t]);
|
||||
|
||||
const editingProject = React.useMemo(
|
||||
() => projects.find((project) => project.id === editingProjectDialogId) ?? null,
|
||||
@@ -703,9 +758,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
handleDeleteSession,
|
||||
confirmDeleteSession,
|
||||
} = useSessionActions({
|
||||
activeProjectId,
|
||||
currentDirectory,
|
||||
currentSessionId,
|
||||
mobileVariant,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
@@ -713,8 +765,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
setActiveProjectIdOnly,
|
||||
setDirectory,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setCurrentSession,
|
||||
@@ -746,40 +796,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
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) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(expansionKey)) {
|
||||
next.delete(expansionKey);
|
||||
} else {
|
||||
next.add(expansionKey);
|
||||
}
|
||||
setExpandedParents((previous) => {
|
||||
const next = toggleExpandedParentKey(previous, expansionKey);
|
||||
try {
|
||||
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||
} catch { /* ignored */ }
|
||||
@@ -964,12 +983,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
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 setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||
|
||||
useProjectRepoStatus({
|
||||
enabled: isVisible,
|
||||
normalizedProjects,
|
||||
gitRepoStatus,
|
||||
setProjectRepoStatus,
|
||||
@@ -981,15 +1001,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
|
||||
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
|
||||
);
|
||||
useSessionFolderCleanup({
|
||||
isSessionsLoading,
|
||||
useAuthoritativeSessionCleanup({
|
||||
enabled: isVisible,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
normalizedProjects,
|
||||
ownership: sessionOwnership,
|
||||
availableWorktreesByProject,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
cleanupSessions,
|
||||
sessions: persistenceSessions,
|
||||
});
|
||||
|
||||
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
|
||||
@@ -997,6 +1012,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
|
||||
useArchivedAutoFolders({
|
||||
enabled: isVisible,
|
||||
normalizedProjects,
|
||||
ownership: sessionOwnership,
|
||||
isSessionsLoading,
|
||||
@@ -1006,7 +1022,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
});
|
||||
|
||||
// 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 projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||
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 list = [...normalizedProjects];
|
||||
@@ -1079,26 +1177,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
foldersMap,
|
||||
});
|
||||
|
||||
const searchEmptyState = (
|
||||
const searchEmptyState = React.useMemo(() => (
|
||||
<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-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
useProjectSessionSelection({
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
});
|
||||
), [t]);
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
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(() => {
|
||||
return showArchivedSessions
|
||||
? sectionsForRender
|
||||
@@ -1254,6 +1325,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}, [sectionsForRender, showArchivedSessions]);
|
||||
|
||||
const prLookupKeys = React.useMemo(() => {
|
||||
if (!isVisible) return EMPTY_STRING_ARRAY;
|
||||
const keys = new Set<string>();
|
||||
sectionsForSidebarRender.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
@@ -1266,16 +1338,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
});
|
||||
return [...keys];
|
||||
}, [gitBranches, sectionsForSidebarRender]);
|
||||
}, [gitBranches, isVisible, sectionsForSidebarRender]);
|
||||
|
||||
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
||||
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
|
||||
const targetsByKey = new Map<string, { directory: string; branch: string }>();
|
||||
const now = Date.now();
|
||||
|
||||
sectionsForSidebarRender.forEach((section) => {
|
||||
@@ -1307,29 +1379,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
if (shouldRetryNoPr) {
|
||||
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;
|
||||
}
|
||||
|
||||
const uniqueTargets = new Map<string, { directory: string; branch: string; remoteName?: string | null }>();
|
||||
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) => {
|
||||
targetsByKey.forEach((target, key) => {
|
||||
ensurePrStatusEntry(key);
|
||||
setPrStatusParams(key, {
|
||||
directory: target.directory,
|
||||
branch: target.branch,
|
||||
remoteName: target.remoteName ?? null,
|
||||
remoteName: null,
|
||||
canShow: true,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
@@ -1337,7 +1403,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
void refreshPrStatusTargets([...uniqueTargets.values()], {
|
||||
void refreshPrStatusTargets([...targetsByKey.values()], {
|
||||
silent: true,
|
||||
markInitialResolved: true,
|
||||
});
|
||||
@@ -1347,6 +1413,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubAuthStatus?.connected,
|
||||
isVisible,
|
||||
gitBranches,
|
||||
refreshPrStatusTargets,
|
||||
sectionsForSidebarRender,
|
||||
@@ -1360,6 +1427,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
|
||||
const headerActionIconClass = 'h-4.5 w-4.5';
|
||||
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||
enabled: isVisible,
|
||||
isDesktopShellRuntime,
|
||||
projectSections,
|
||||
projectHeaderSentinelRefs,
|
||||
@@ -1382,9 +1450,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
groupDirectory={groupDirectory}
|
||||
projectId={projectId}
|
||||
archivedBucket={archivedBucket}
|
||||
currentSessionId={currentSessionId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
expandedParents={renderContext === 'recent' ? recentExpandedParents : projectExpandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
@@ -1417,12 +1484,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renderSessionNode={renderSessionNode}
|
||||
secondaryMeta={secondaryMeta}
|
||||
renderContext={renderContext}
|
||||
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
|
||||
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
|
||||
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
|
||||
liveSessionById={liveSessionById}
|
||||
/>
|
||||
),
|
||||
);
|
||||
@@ -1505,13 +1570,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setRenameFolderDraft={setRenameFolderDraft}
|
||||
setRenamingFolderId={setRenamingFolderId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
expandedParents={projectExpandedParents}
|
||||
sessionOrderIndex={sessionOrderIndex}
|
||||
currentSessionId={currentSessionId}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
liveSessionById={liveSessionById}
|
||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1546,28 +1609,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
projectExpandedParents,
|
||||
sessionOrderIndex,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
editTitle,
|
||||
openSidebarMenuKey,
|
||||
liveSessionById,
|
||||
prVisualStateByDirectoryBranch,
|
||||
toggleCollapsedGroup,
|
||||
],
|
||||
);
|
||||
|
||||
const topContent = (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
currentSessionId={currentSessionId}
|
||||
editingId={editingId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
variant="section"
|
||||
/>
|
||||
) : null;
|
||||
const topContent = React.useMemo(
|
||||
() => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
editingId={editingId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
expansionState={recentExpandedParents}
|
||||
variant="section"
|
||||
/>
|
||||
) : null,
|
||||
[activitySections, editingId, hasSessionSearchQuery, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection],
|
||||
);
|
||||
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
|
||||
|
||||
const {
|
||||
@@ -1620,6 +1684,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
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
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
showRecentControls={!isVSCode}
|
||||
@@ -1643,7 +1733,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onToggleSelectionMode={handleToggleSelectionMode}
|
||||
/>
|
||||
|
||||
<SidebarProjectsList
|
||||
{isVisible ? <SidebarProjectsList
|
||||
topContent={topContent}
|
||||
hasSharedSessions={hasActivitySectionItems}
|
||||
sectionsForRender={sectionsForSidebarRender}
|
||||
@@ -1678,7 +1768,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
isInlineEditing={isInlineEditing}
|
||||
/>
|
||||
/> : null}
|
||||
|
||||
{selectionModeEnabled && hasSelection ? (
|
||||
<BulkActionBar
|
||||
@@ -1770,3 +1860,5 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</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.
|
||||
- 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.
|
||||
- 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:
|
||||
- project session list builders
|
||||
- folder cleanup sync
|
||||
- authoritative deletion cleanup
|
||||
- sticky project header observer
|
||||
|
||||
## VS Code grouping
|
||||
@@ -29,8 +30,8 @@
|
||||
- `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.
|
||||
- `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.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children.
|
||||
- `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. Rows do not initiate directory bootstrap on mount.
|
||||
- `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.
|
||||
- `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/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/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.
|
||||
@@ -49,11 +50,29 @@
|
||||
- `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/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`.
|
||||
|
||||
### Types and utilities
|
||||
|
||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
||||
- `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).
|
||||
|
||||
## 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
|
||||
// when we cross this row count so the DOM stays bounded.
|
||||
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
|
||||
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
||||
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
||||
@@ -26,8 +21,10 @@ import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePa
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
nodeHasPinnedMembershipChange,
|
||||
nodeContainsSessionId,
|
||||
resolveMenuOpenSessionId,
|
||||
selectFolderRootNodes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
@@ -36,6 +33,7 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
|
||||
type DeleteFolderConfirm = {
|
||||
scopeKey: string;
|
||||
@@ -92,11 +90,9 @@ type Props = {
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
currentSessionId: string | null;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
openSidebarMenuKey: string | null;
|
||||
liveSessionById: Map<string, Session>;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
@@ -142,12 +138,14 @@ const groupHasPinnedMembershipChange = (
|
||||
prevPinnedSessionIds: Set<string>,
|
||||
nextPinnedSessionIds: Set<string>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const sessionId = node.session.id;
|
||||
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
return group.sessions.some((node) => nodeHasPinnedMembershipChange(
|
||||
node,
|
||||
node,
|
||||
prevPinnedSessionIds,
|
||||
nextPinnedSessionIds,
|
||||
group.directory,
|
||||
group.directory,
|
||||
));
|
||||
};
|
||||
|
||||
const groupHasSessionOrderChange = (
|
||||
@@ -177,21 +175,6 @@ const groupHasExpansionMembershipChange = (
|
||||
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 => {
|
||||
if (!props.projectId) return undefined;
|
||||
return props.projectRepoStatus.has(props.projectId)
|
||||
@@ -236,11 +219,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
||||
return false;
|
||||
@@ -257,11 +235,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
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
|
||||
// during bootstrap but a single group's value is usually stable, so we
|
||||
// compare only the value this group actually consumes instead of the
|
||||
@@ -352,7 +325,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
sessionOrderIndex,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
prVisualStateByDirectoryBranch,
|
||||
@@ -379,6 +351,19 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
// VS Code always uses the expanded layout (see SessionNodeItem).
|
||||
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
|
||||
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 nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
|
||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||
@@ -409,10 +394,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [sourceGroupNodes]);
|
||||
|
||||
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
|
||||
const nodes = folder.sessionIds
|
||||
.map((sid) => nodeBySessionId.get(sid))
|
||||
.filter((n): n is SessionNode => Boolean(n))
|
||||
.sort(compareSessionNodes);
|
||||
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
|
||||
return { folder, nodes };
|
||||
}), [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 rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
||||
|
||||
// Precompute per-row "subtree contains active session" and "subtree contains
|
||||
// editing session" lookups once per render. The previous design walked the
|
||||
// Precompute the per-row "subtree contains editing session" lookup once per
|
||||
// render. The previous design walked the
|
||||
// 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
|
||||
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
|
||||
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 set = new Set<string>();
|
||||
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
|
||||
@@ -539,11 +512,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
|
||||
|
||||
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(child),
|
||||
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
|
||||
}), [subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
|
||||
|
||||
const totalSessions = ungroupedSessions.length;
|
||||
const visibleSessions = group.isArchivedBucket
|
||||
@@ -554,21 +526,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const remainingCount = totalSessions - visibleSessions.length;
|
||||
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
|
||||
|
||||
// Virtualize large groups. Archived buckets grow into the hundreds or
|
||||
// thousands of rows; active/worktree groups can also hit 80+ sessions
|
||||
// when a single worktree accumulates over time. Both paths share the
|
||||
// same virtua Virtualizer; the threshold just controls when we mount
|
||||
// it. The visible behavior is identical because virtua uses overscan
|
||||
// (8) for the buffer zone. All hooks below MUST stay above the
|
||||
// search-empty early-return so they fire in the same order every
|
||||
// render — rules-of-hooks.
|
||||
const shouldVirtualizeArchived = group.isArchivedBucket === true
|
||||
// Virtualize archived buckets, which can grow into the thousands. Active
|
||||
// groups retain normal flow because their incremental Show more control and
|
||||
// the shared ancestor scroller cannot expose an unmounted virtual tail.
|
||||
// Hooks below MUST stay above the search-empty early-return so they fire in
|
||||
// the same order every render — rules-of-hooks.
|
||||
const shouldVirtualize = group.isArchivedBucket === true
|
||||
&& !hasSessionSearchQuery
|
||||
&& 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
|
||||
// children inline, making them much taller than the fixed estimate.
|
||||
@@ -864,7 +829,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
renderSessionNode={renderSessionNode}
|
||||
getRenderExtras={resolveNodeStructureKey
|
||||
? (node) => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -941,7 +905,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
// meanwhile keeps the container's height real so the scroller
|
||||
// never collapses/clamps during the flip.
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -980,7 +943,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}}
|
||||
>
|
||||
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -994,7 +956,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</div>
|
||||
) : (
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -1005,6 +966,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
{group.isArchivedBucket
|
||||
? 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')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||
@@ -25,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId } from './sessionNodeItemUtils';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
@@ -43,6 +44,8 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
|
||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
@@ -57,7 +60,6 @@ type Props = {
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
currentSessionId: string | null;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
@@ -70,9 +72,9 @@ type Props = {
|
||||
handleSaveEdit: (titleOverride?: string) => void;
|
||||
handleCancelEdit: () => void;
|
||||
toggleParent: (expansionKey: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
togglePinnedSession: (target: SessionPinnedTarget) => void;
|
||||
handleShareSession: (session: Session) => void;
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
@@ -101,16 +103,9 @@ type Props = {
|
||||
) => React.ReactNode;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the current
|
||||
* active session. Computed once per SessionGroupSection render (when
|
||||
* currentSessionId changes) instead of being recomputed in every row's
|
||||
* React.memo comparator.
|
||||
*/
|
||||
subtreeContainsActive: Set<string>;
|
||||
/**
|
||||
* 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>;
|
||||
/**
|
||||
@@ -132,15 +127,52 @@ type Props = {
|
||||
* to fetch the right key for each child it produces.
|
||||
*/
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
/**
|
||||
* Batched index of live session objects keyed by id. The previous
|
||||
* implementation called `useSession(session.id)` per row, which used
|
||||
* `findLiveSession` to iterate every child-store on every SSE event.
|
||||
* With M visible rows that's M×child-stores per event; the batched
|
||||
* map turns it into a single Map.get per row. The parent falls back
|
||||
* to `useSession` only when this map returns undefined.
|
||||
*/
|
||||
liveSessionById: Map<string, Session>;
|
||||
};
|
||||
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const row = target.closest<HTMLElement>('[data-session-row]');
|
||||
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||
if (!row || !container) return;
|
||||
|
||||
cancelScrollAnchorByContainer.get(container)?.();
|
||||
|
||||
const initialTop = row.getBoundingClientRect().top;
|
||||
let remainingFrames = 3;
|
||||
let cancelled = false;
|
||||
let frameId: number | null = null;
|
||||
const cancel = () => {
|
||||
cancelled = true;
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
cancelScrollAnchorByContainer.delete(container);
|
||||
container.removeEventListener('wheel', cancel);
|
||||
container.removeEventListener('touchstart', cancel);
|
||||
};
|
||||
const restore = () => {
|
||||
if (cancelled || !row.isConnected || !container.isConnected) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const delta = row.getBoundingClientRect().top - initialTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
streamPerfCount('ui.sidebar.selection_scroll_anchor_adjustment');
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
if (remainingFrames <= 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
frameId = window.requestAnimationFrame(restore);
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', cancel, { passive: true });
|
||||
container.addEventListener('touchstart', cancel, { passive: true });
|
||||
cancelScrollAnchorByContainer.set(container, cancel);
|
||||
frameId = window.requestAnimationFrame(restore);
|
||||
};
|
||||
|
||||
type QuickSessionActionProps = {
|
||||
@@ -206,6 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
});
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_session_node.render');
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
node,
|
||||
@@ -213,7 +246,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
groupDirectory,
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
@@ -248,11 +280,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
liveSessionById,
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
@@ -307,25 +337,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const session = node.session;
|
||||
// Batched live-session lookup. `liveSessionById` is built once per
|
||||
// Sidebar render from the same `useAllLiveSessions` selector that
|
||||
// `useSession` would have iterated per child-store, so a Map.get
|
||||
// here is equivalent in observed state but O(1) per row instead of
|
||||
// O(child-stores). Falls back to the row session when the live map
|
||||
// hasn't seen this id yet (sub-render latency between when a session
|
||||
// is created and when the SSE-driven aggregate picks it up).
|
||||
const resolvedSession = liveSessionById.get(session.id) ?? session;
|
||||
const resolvedSession = session;
|
||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
// Archived rows are historical and never need live state, yet they point at
|
||||
// dozens of (often deleted) worktrees — bootstrapping each from the sidebar
|
||||
// triggers a pointless session-list fetch + 6×2s empty-retry storm on startup.
|
||||
// Skip bootstrap for archived rows; the store ref is only read on-demand via
|
||||
// getState() in the export handlers (never subscribed). Active rows keep
|
||||
// bootstrapping so live cross-directory session/status still aggregates.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket });
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sync = useSync();
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
@@ -368,7 +388,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
<span
|
||||
@@ -379,10 +399,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} />
|
||||
</span>
|
||||
) : null;
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isPinnedSession = isSessionPinned(pinnedSessionIds, sessionDirectory, session.id);
|
||||
// Per-render-context expansion key: the same session can appear in both
|
||||
// the project's root and the "Recent" list, and expanding one should not
|
||||
// expand the other. Matches the format of menuInstanceKey.
|
||||
@@ -409,7 +428,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
let skipped = 0;
|
||||
for (const child of children) {
|
||||
try {
|
||||
await sync.ensureSessionRenderable(child.session.id);
|
||||
await sync.ensureSessionRenderable(child.session.id, false, sessionDirectory ?? undefined);
|
||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||
@@ -426,7 +445,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return { children: results, skipped };
|
||||
}, [collectNodeDescendantIds, directoryStore, sync, t]);
|
||||
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
|
||||
|
||||
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||||
if (count <= 0) return;
|
||||
@@ -441,7 +460,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
await sync.ensureSessionRenderable(session.id);
|
||||
await sync.ensureSessionRenderable(session.id, false, sessionDirectory);
|
||||
|
||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||
if (records.length === 0) {
|
||||
@@ -775,7 +794,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
||||
return;
|
||||
}
|
||||
handleSessionSelect(session.id, sessionDirectory, projectId);
|
||||
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
||||
handleSessionSelect(session.id, sessionDirectory);
|
||||
};
|
||||
|
||||
// The selection/active highlight covers the WHOLE row box (gutter, edge
|
||||
@@ -832,7 +852,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.rename')}
|
||||
</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 ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
|
||||
</Item>
|
||||
@@ -1267,7 +1287,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
||||
? childRenderExtrasFor(child)
|
||||
: {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: '',
|
||||
@@ -1387,26 +1406,6 @@ const hasSetMembershipChangeInNode = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasResolvedSessionChangeInNode = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevLiveSessionById: Map<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 => {
|
||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||
@@ -1428,6 +1427,7 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prev.node.session.id !== next.node.session.id) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
@@ -1442,13 +1442,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
|
||||
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
|
||||
|
||||
if (prev.liveSessionById !== next.liveSessionById
|
||||
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
|
||||
&& nodeHasPinnedMembershipChange(
|
||||
prev.node,
|
||||
next.node,
|
||||
prev.pinnedSessionIds,
|
||||
next.pinnedSessionIds,
|
||||
prev.groupDirectory,
|
||||
next.groupDirectory,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1456,14 +1458,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|
||||
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|
||||
|
||||
@@ -38,9 +38,9 @@ type Props = {
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
currentSessionId: string | null;
|
||||
editingId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
expansionState?: ReadonlySet<string>;
|
||||
variant?: 'section' | 'flat';
|
||||
initialVisibleCount?: number;
|
||||
batchSize?: number;
|
||||
@@ -50,16 +50,16 @@ type RenderExtras = SessionNodeRenderExtras;
|
||||
|
||||
const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
|
||||
export function SidebarActivitySections({
|
||||
sections,
|
||||
renderSessionNode,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
}: Props): React.ReactNode {
|
||||
export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
const {
|
||||
sections,
|
||||
renderSessionNode,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
@@ -101,8 +101,6 @@ export function SidebarActivitySections({
|
||||
}, [batchSize]);
|
||||
|
||||
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
||||
const subtreeContainsActive = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
|
||||
const subtreeContainsEditing = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
|
||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
|
||||
@@ -114,7 +112,6 @@ export function SidebarActivitySections({
|
||||
nodes.forEach(visit);
|
||||
|
||||
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
|
||||
@@ -122,13 +119,12 @@ export function SidebarActivitySections({
|
||||
});
|
||||
|
||||
return (node: SessionNode): RenderExtras => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
||||
childRenderExtrasFor,
|
||||
});
|
||||
}, [currentSessionId, editingId, openSidebarMenuKey]);
|
||||
}, [editingId, openSidebarMenuKey]);
|
||||
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0);
|
||||
if (visibleSections.length === 0) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatProjectLabel } from './utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
|
||||
type ProjectSection = {
|
||||
project: {
|
||||
@@ -78,7 +79,8 @@ type Props = {
|
||||
isInlineEditing: boolean;
|
||||
};
|
||||
|
||||
export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_projects_list.render');
|
||||
const { t } = useI18n();
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
@@ -311,3 +313,5 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
</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 = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
isSessionsLoading: boolean;
|
||||
@@ -26,12 +27,12 @@ type Args = {
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
@@ -40,11 +41,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
|
||||
|
||||
@@ -72,11 +70,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
addSessionToFolder(scopeKey, folder.id, session.id);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
enabled,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
@@ -85,6 +82,5 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
]);
|
||||
};
|
||||
|
||||
+44
@@ -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';
|
||||
|
||||
type Project = { id: string; path: string; normalizedPath: string };
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: Project[];
|
||||
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
|
||||
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
|
||||
@@ -16,6 +18,7 @@ type Args = {
|
||||
export const useProjectRepoStatus = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
gitRepoStatus,
|
||||
setProjectRepoStatus,
|
||||
setProjectRootBranches,
|
||||
@@ -26,7 +29,7 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
|
||||
// Derive repo status from centralized Git store
|
||||
React.useEffect(() => {
|
||||
if (!git || normalizedProjects.length === 0) {
|
||||
if (!enabled || !git || normalizedProjects.length === 0) {
|
||||
setProjectRepoStatus(new Map());
|
||||
return;
|
||||
}
|
||||
@@ -35,16 +38,17 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
normalizedProjects.forEach((project) => {
|
||||
void ensureStatus(project.normalizedPath, git);
|
||||
});
|
||||
}, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
}, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
|
||||
// Read isGitRepo from the store-populated state
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const next = new Map<string, boolean | null>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
|
||||
});
|
||||
setProjectRepoStatus(next);
|
||||
}, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
}, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
|
||||
const projectGitBranchesKey = React.useMemo(() => {
|
||||
return normalizedProjects
|
||||
@@ -69,9 +73,9 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
// background updates and only re-resolve on cold start or actual
|
||||
// branch changes (those still invalidate via the input-key check).
|
||||
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Debounce so the initial burst of per-project `ensureStatus` updates
|
||||
@@ -164,9 +168,5 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not
|
||||
// in the deps array since it never changes during the component
|
||||
// lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
}, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
@@ -16,7 +17,7 @@ type Args = {
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
@@ -148,7 +149,7 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
||||
handleSessionSelect(targetSessionId, targetDirectory, activeProjectId);
|
||||
handleSessionSelect(targetSessionId, targetDirectory);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
@@ -183,3 +184,34 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
};
|
||||
|
||||
type ProjectSessionSelectionEffectProps = Omit<
|
||||
Args,
|
||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
|
||||
> & {
|
||||
initialActiveSessionByProject: Map<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 { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
||||
session: Session;
|
||||
@@ -20,9 +22,6 @@ type DeleteSessionSource = {
|
||||
};
|
||||
|
||||
type Args = {
|
||||
activeProjectId: string | null;
|
||||
currentDirectory: string | null;
|
||||
currentSessionId: string | null;
|
||||
mobileVariant: boolean;
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
@@ -30,8 +29,6 @@ type Args = {
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
|
||||
@@ -66,7 +63,8 @@ export const useSessionActions = (args: Args) => {
|
||||
}, []);
|
||||
|
||||
const handleSessionSelect = React.useCallback(
|
||||
(sessionId: string, sessionDirectory?: string | null, projectId?: string | null) => {
|
||||
(sessionId: string, sessionDirectory?: string | null) => {
|
||||
streamPerfMark('navigation.session_select');
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
@@ -75,26 +73,19 @@ export const useSessionActions = (args: Args) => {
|
||||
args.setIsSessionSearchOpen(false);
|
||||
};
|
||||
|
||||
if (projectId && projectId !== args.activeProjectId) {
|
||||
args.setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
|
||||
if (sessionDirectory && sessionDirectory !== args.currentDirectory) {
|
||||
args.setDirectory(sessionDirectory, { showOverlay: false });
|
||||
}
|
||||
|
||||
if (args.mobileVariant) {
|
||||
args.setActiveMainTab('chat');
|
||||
args.setSessionSwitcherOpen(false);
|
||||
}
|
||||
|
||||
if (sessionId === args.currentSessionId) {
|
||||
if (sessionId === useSessionUIStore.getState().currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
args.onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
return;
|
||||
}
|
||||
streamPerfMark('navigation.session_state_set');
|
||||
args.setCurrentSession(sessionId, sessionDirectory ?? null);
|
||||
args.onSessionSelected?.(sessionId);
|
||||
resetSessionSearch();
|
||||
|
||||
@@ -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;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessionIds?: string[];
|
||||
ensureSessionRenderable: (sessionId: string) => Promise<unknown>;
|
||||
recentSessions?: Session[];
|
||||
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 sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
const generationRef = React.useRef(0);
|
||||
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(() => {
|
||||
if (prefetchDisabled || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
||||
const nextSessionId = sessionPrefetchQueueRef.current.shift();
|
||||
if (!nextSessionId) {
|
||||
const request = sessionPrefetchQueueRef.current.shift();
|
||||
if (!request) {
|
||||
break;
|
||||
}
|
||||
if (request.generation !== generationRef.current) continue;
|
||||
|
||||
const state = useSessionUIStore.getState();
|
||||
if (state.currentSessionId === nextSessionId) {
|
||||
if (state.currentSessionId === request.sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the session is already renderable in the sync child store.
|
||||
if (getSyncSessionMaterializationStatus(nextSessionId).renderable) {
|
||||
if (getSyncSessionMaterializationStatus(request.sessionId, request.directory).renderable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||
void ensureSessionRenderable(nextSessionId)
|
||||
const key = requestKey(request);
|
||||
sessionPrefetchInFlightRef.current.add(key);
|
||||
void prefetchSession(request.sessionId, request.directory)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||
sessionPrefetchInFlightRef.current.delete(key);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [ensureSessionRenderable, prefetchDisabled]);
|
||||
}, [enabled, prefetchDisabled, prefetchSession, requestKey]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||
if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
||||
const sessionId = session?.id;
|
||||
const directory = sessionDirectory(session);
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const request = { sessionId, directory, generation: generationRef.current };
|
||||
const key = requestKey(request);
|
||||
|
||||
// Already renderable in sync
|
||||
if (getSyncSessionMaterializationStatus(sessionId).renderable) {
|
||||
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchInFlightRef.current.has(sessionId)) {
|
||||
if (sessionPrefetchInFlightRef.current.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.includes(sessionId)) {
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
sessionPrefetchQueueRef.current.shift();
|
||||
}
|
||||
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(key);
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
sessionPrefetchTimersRef.current.delete(sessionId);
|
||||
sessionPrefetchQueueRef.current.push(sessionId);
|
||||
sessionPrefetchTimersRef.current.delete(key);
|
||||
if (request.generation !== generationRef.current) return;
|
||||
const queue = sessionPrefetchQueueRef.current;
|
||||
if (queue.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
queue.shift();
|
||||
}
|
||||
queue.push(request);
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||
}, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]);
|
||||
sessionPrefetchTimersRef.current.set(key, timer);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearPendingPrefetches();
|
||||
}, [clearPendingPrefetches, currentSessionId, enabled, prefetchDisabled]);
|
||||
|
||||
// Wait for the active session to finish loading before prefetching neighbors.
|
||||
// On rapid session switches the timer resets, so only the final session triggers prefetch.
|
||||
React.useEffect(() => {
|
||||
if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
||||
if (!enabled || prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) return;
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
|
||||
}, SESSION_PREFETCH_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) {
|
||||
if (!enabled || prefetchDisabled || !currentSessionId || recentSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const currentIndex = recentSessionIds.indexOf(currentSessionId);
|
||||
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) return;
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
|
||||
scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
|
||||
}, SESSION_PREFETCH_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||
return () => {
|
||||
prefetchTimers.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
prefetchTimers.clear();
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => clearPendingPrefetches, [clearPendingPrefetches]);
|
||||
};
|
||||
|
||||
export const SessionPrefetchEffect: React.FC<Omit<Args, 'currentSessionId'>> = (args) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
useSessionPrefetch({ ...args, currentSessionId });
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
@@ -8,13 +9,14 @@ type Args = {
|
||||
};
|
||||
|
||||
export const useSessionSearchEffects = ({
|
||||
enabled = true,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
}: Args): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof window === 'undefined') {
|
||||
if (!enabled || !isSessionSearchOpen || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
@@ -22,10 +24,10 @@ export const useSessionSearchEffects = ({
|
||||
sessionSearchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isSessionSearchOpen, sessionSearchInputRef]);
|
||||
}, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof document === 'undefined') {
|
||||
if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
@@ -38,5 +40,5 @@ export const useSessionSearchEffects = ({
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
}, [enabled, isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
@@ -21,6 +22,19 @@ type ProjectSection = {
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type ProjectSectionCacheEntry = {
|
||||
project: ProjectItem;
|
||||
activeSessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
rootBranch: string | null;
|
||||
isRepo: boolean;
|
||||
buildGroupedSessions: Args['buildGroupedSessions'];
|
||||
section: ProjectSection;
|
||||
};
|
||||
|
||||
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
getSessionsForProject: (projectId: string) => Session[];
|
||||
@@ -59,26 +73,67 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
} = args;
|
||||
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
|
||||
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = dedupeSessionsById([
|
||||
...getSessionsForProject(project.id),
|
||||
...getArchivedSessionsForProject(project.id),
|
||||
]);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const previousCache = projectSectionCacheRef.current;
|
||||
const nextCache = new Map<string, ProjectSectionCacheEntry>();
|
||||
let reusedSections = 0;
|
||||
let rebuiltSections = 0;
|
||||
const sameSessions = (left: Session[], right: Session[]): boolean => (
|
||||
left.length === right.length && left.every((session, index) => session === right[index])
|
||||
);
|
||||
|
||||
const sections = normalizedProjects.map((project) => {
|
||||
const activeSessions = getSessionsForProject(project.id);
|
||||
const archivedSessions = getArchivedSessionsForProject(project.id);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? EMPTY_WORKTREES;
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatus;
|
||||
const rootBranch = projectRootBranches.get(project.id) ?? null;
|
||||
const cached = previousCache.get(project.id);
|
||||
if (
|
||||
cached
|
||||
&& cached.project === project
|
||||
&& sameSessions(cached.activeSessions, activeSessions)
|
||||
&& sameSessions(cached.archivedSessions, archivedSessions)
|
||||
&& cached.availableWorktrees === worktreesForProject
|
||||
&& cached.rootBranch === rootBranch
|
||||
&& cached.isRepo === isRepo
|
||||
&& cached.buildGroupedSessions === buildGroupedSessions
|
||||
) {
|
||||
reusedSections += 1;
|
||||
nextCache.set(project.id, cached);
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
rebuiltSections += 1;
|
||||
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
);
|
||||
return { project, groups };
|
||||
const section = { project, groups };
|
||||
nextCache.set(project.id, {
|
||||
project,
|
||||
activeSessions,
|
||||
archivedSessions,
|
||||
availableWorktrees: worktreesForProject,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
buildGroupedSessions,
|
||||
section,
|
||||
});
|
||||
return section;
|
||||
});
|
||||
projectSectionCacheRef.current = nextCache;
|
||||
if (reusedSections > 0) streamPerfCount('ui.sidebar.project_section.reused', reusedSections);
|
||||
if (rebuiltSections > 0) streamPerfCount('ui.sidebar.project_section.rebuilt', rebuiltSections);
|
||||
return sections;
|
||||
}, [
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
|
||||
@@ -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 type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { prunePinnedSessionIds } from './pinnedSessionCleanup';
|
||||
|
||||
type SafeStorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem?: (key: string) => void;
|
||||
};
|
||||
|
||||
type Keys = {
|
||||
sessionExpanded: string;
|
||||
// v1 key, still on disk for users upgrading from pre-per-context expansion.
|
||||
// When present, its bare-session-id entries are fanned out to all four
|
||||
// (project|recent) × (active|archived) context combinations and rewritten
|
||||
// under `sessionExpanded`. After migration the v1 key is removed.
|
||||
sessionExpandedLegacy: string;
|
||||
projectCollapse: string;
|
||||
sessionPinned: string;
|
||||
groupOrder: string;
|
||||
projectActiveSession: string;
|
||||
groupCollapse: string;
|
||||
};
|
||||
|
||||
const LEGACY_EXPANSION_CONTEXT_PREFIXES = [
|
||||
'project:active:',
|
||||
'project:archived:',
|
||||
'recent:active:',
|
||||
'recent:archived:',
|
||||
];
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
safeStorage: SafeStorageLike;
|
||||
keys: Keys;
|
||||
sessions: Session[];
|
||||
pinnedSessionIds: Set<string>;
|
||||
setPinnedSessionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
groupOrderByProject: Map<string, string[]>;
|
||||
activeSessionByProject: Map<string, string>;
|
||||
collapsedGroups: Set<string>;
|
||||
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
@@ -49,13 +27,9 @@ type Args = {
|
||||
export const useSidebarPersistence = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
safeStorage,
|
||||
keys,
|
||||
sessions,
|
||||
setPinnedSessionIds,
|
||||
groupOrderByProject,
|
||||
activeSessionByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
@@ -115,28 +89,6 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
if (Array.isArray(parsed)) {
|
||||
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
} else {
|
||||
// No v2 data — migrate from v1 (bare session ids) if present.
|
||||
const legacyRaw = safeStorage.getItem(keys.sessionExpandedLegacy);
|
||||
if (legacyRaw) {
|
||||
try {
|
||||
const parsedLegacy = JSON.parse(legacyRaw);
|
||||
if (Array.isArray(parsedLegacy)) {
|
||||
const migrated = new Set<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);
|
||||
if (storedProjects) {
|
||||
@@ -148,17 +100,7 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasAuthoritativeGlobalSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPinnedSessionIds((prev) => {
|
||||
return prunePinnedSessionIds(sessions, prev);
|
||||
});
|
||||
}, [hasAuthoritativeGlobalSessions, sessions, setPinnedSessionIds]);
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
@@ -169,15 +111,6 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
}
|
||||
}, [groupOrderByProject, keys.groupOrder, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const serialized = Object.fromEntries(activeSessionByProject.entries());
|
||||
safeStorage.setItem(keys.projectActiveSession, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [activeSessionByProject, keys.projectActiveSession, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
projectSections: unknown[];
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
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());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellRuntime) {
|
||||
if (!enabled || !isDesktopShellRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,12 +25,16 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
}
|
||||
|
||||
setStuckProjectHeaders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!entry.isIntersecting) {
|
||||
if (prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(projectId);
|
||||
} else {
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
@@ -44,7 +49,7 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
}, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -10,7 +12,6 @@ import type { SessionNode } from './types';
|
||||
* each child's extras object.
|
||||
*/
|
||||
export type SessionNodeChildRenderExtras = {
|
||||
subtreeContainsActive: Set<string>;
|
||||
subtreeContainsEditing: Set<string>;
|
||||
menuOpenSessionId: string | null;
|
||||
nodeStructureKey: string;
|
||||
@@ -24,7 +25,7 @@ export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRende
|
||||
* Walk `nodes` and add `node.session.id` to `result` for every node
|
||||
* whose subtree contains `targetId`. This is used to precompute, once
|
||||
* per SessionGroupSection render, which rows need to update when
|
||||
* `currentSessionId` or `editingId` changes. With M visible rows, this
|
||||
* `editingId` changes. With M visible rows, this
|
||||
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
|
||||
* into a single O(M) `Set.has` per row.
|
||||
*/
|
||||
@@ -69,12 +70,45 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
|
||||
return false;
|
||||
};
|
||||
|
||||
export const selectFolderRootNodes = (
|
||||
sessionIds: string[],
|
||||
nodeBySessionId: ReadonlyMap<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
|
||||
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
|
||||
* rebuild of the tree (which happens on every `buildGroupedSessions`
|
||||
* pass) can be detected with a single string compare instead of a
|
||||
* recursive walk per row.
|
||||
* Build a key encoding descendant IDs and session object versions. This lets
|
||||
* row memoization detect one changed descendant without recursively comparing
|
||||
* every subtree after a reference-only grouping rebuild.
|
||||
*/
|
||||
export const computeNodeStructureKey = (node: SessionNode): string => {
|
||||
if (node.children.length === 0) {
|
||||
@@ -82,15 +116,49 @@ export const computeNodeStructureKey = (node: SessionNode): string => {
|
||||
}
|
||||
|
||||
const childKeys = node.children.map((child) => {
|
||||
const childVersion = getSessionObjectVersion(child.session);
|
||||
if (child.children.length === 0) {
|
||||
return child.session.id;
|
||||
return `${child.session.id}@${childVersion}`;
|
||||
}
|
||||
return `${child.session.id}:${computeNodeStructureKey(child)}`;
|
||||
return `${child.session.id}@${childVersion}:${computeNodeStructureKey(child)}`;
|
||||
});
|
||||
|
||||
return childKeys.join('|');
|
||||
};
|
||||
|
||||
export const nodeHasPinnedMembershipChange = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevPinnedSessionIds: Set<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
|
||||
* 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 { isPathWithinProject } from './utils';
|
||||
import {
|
||||
isPathWithinProject,
|
||||
selectExpandedParentKeysForContext,
|
||||
toggleExpandedParentKey,
|
||||
} from './utils';
|
||||
|
||||
describe('isPathWithinProject', () => {
|
||||
test('matches child directories for root projects', () => {
|
||||
@@ -26,3 +30,48 @@ describe('isPathWithinProject', () => {
|
||||
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectExpandedParentKeysForContext', () => {
|
||||
test('keeps project and recent expansion state isolated', () => {
|
||||
const expanded = new Set([
|
||||
'project:active:parent-a',
|
||||
'project:archived:parent-b',
|
||||
'recent:active:parent-a',
|
||||
]);
|
||||
|
||||
expect(selectExpandedParentKeysForContext(new Set(), expanded, 'project')).toEqual(new Set([
|
||||
'project:active:parent-a',
|
||||
'project:archived:parent-b',
|
||||
]));
|
||||
expect(selectExpandedParentKeysForContext(new Set(), expanded, 'recent')).toEqual(new Set([
|
||||
'recent:active:parent-a',
|
||||
]));
|
||||
});
|
||||
|
||||
test('preserves a context projection when only another context changes', () => {
|
||||
const recent = new Set(['recent:active:parent-a']);
|
||||
const expanded = new Set(['recent:active:parent-a', 'project:active:parent-a']);
|
||||
|
||||
expect(selectExpandedParentKeysForContext(recent, expanded, 'recent')).toBe(recent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent expansion state', () => {
|
||||
const recentKey = 'recent:active:parent-a';
|
||||
const projectKey = 'project:active:parent-a';
|
||||
|
||||
test('manually expands and collapses a parent', () => {
|
||||
const expanded = toggleExpandedParentKey(new Set(), recentKey);
|
||||
expect(expanded).toEqual(new Set([recentKey]));
|
||||
expect(toggleExpandedParentKey(expanded, recentKey)).toEqual(new Set());
|
||||
});
|
||||
|
||||
test('does not change the other render context', () => {
|
||||
const recentExpanded = new Set([recentKey]);
|
||||
const bothExpanded = toggleExpandedParentKey(recentExpanded, projectKey);
|
||||
const projectCollapsed = toggleExpandedParentKey(bothExpanded, projectKey);
|
||||
|
||||
expect(selectExpandedParentKeysForContext(new Set(), bothExpanded, 'recent')).toEqual(new Set([recentKey]));
|
||||
expect(selectExpandedParentKeysForContext(new Set(), projectCollapsed, 'recent')).toEqual(new Set([recentKey]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
export { normalizePath };
|
||||
|
||||
export const selectExpandedParentKeysForContext = (
|
||||
previous: Set<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]) =>
|
||||
formatMessage(useI18nStore.getState().dictionary, key, params);
|
||||
|
||||
@@ -132,8 +157,8 @@ export const compareSessionsByPinnedAndTime = (
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
const aPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(a), a.id);
|
||||
const bPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(b), b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type CommandEntry = {
|
||||
};
|
||||
|
||||
type FileHit = { path: string; name: string; relativePath: string };
|
||||
const EMPTY_SESSIONS: Session[] = [];
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -85,7 +86,10 @@ export const CommandPalette: React.FC = () => {
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
|
||||
const activeSessions = useGlobalSessionsStore((s) => s.activeSessions);
|
||||
const activeSessions = useGlobalSessionsStore(React.useCallback(
|
||||
(state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS,
|
||||
[isCommandPaletteOpen],
|
||||
));
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const activeProject = useProjectsStore((s) => s.getActiveProject());
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
|
||||
@@ -4,15 +4,16 @@ import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ChatViewProps = {
|
||||
active?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({ readOnly = false }) => {
|
||||
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false }) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
|
||||
return (
|
||||
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
||||
<ChatContainer readOnly={readOnly} />
|
||||
<ChatContainer active={active} readOnly={readOnly} />
|
||||
</ChatErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import {
|
||||
@@ -667,6 +668,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
setIsLoading(true);
|
||||
|
||||
let cancelled = false;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES;
|
||||
const fetchPromise = isImageFile(file.path)
|
||||
? git.getGitFileDiff(directory, { path: file.path, staged })
|
||||
@@ -697,7 +699,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
if (staged) {
|
||||
setStagedDiffData(nextDiff);
|
||||
} else {
|
||||
setDiff(directory, file.path, nextDiff);
|
||||
setDiff(directory, file.path, nextDiff, runtimeKey);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
@@ -1494,6 +1496,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
|
||||
setOpeningEditorFilePath(filePath);
|
||||
const runtimeKey = getRuntimeKey();
|
||||
try {
|
||||
let targetLine: number | null = null;
|
||||
|
||||
@@ -1525,7 +1528,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isBinary: response.isBinary,
|
||||
};
|
||||
if (!activeDiffStaged) {
|
||||
setDiff(effectiveDirectory, filePath, diffForNavigation);
|
||||
setDiff(effectiveDirectory, filePath, diffForNavigation, runtimeKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
|
||||
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 removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
|
||||
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 lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||
const activeFileLoadIdRef = React.useRef(0);
|
||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
|
||||
@@ -1952,7 +1952,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
if (root) {
|
||||
setSelectedPath(root, node.path);
|
||||
addOpenPath(root, node.path);
|
||||
void ensurePathVisible(node.path, false);
|
||||
}
|
||||
|
||||
@@ -1966,7 +1965,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
if (isMobile) {
|
||||
setShowMobilePageContent(true);
|
||||
}
|
||||
}, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
|
||||
}, [ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedFile?.path) {
|
||||
@@ -1979,16 +1978,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
React.useEffect(() => {
|
||||
if (!selectedFile) {
|
||||
activeFileLoadIdRef.current += 1;
|
||||
loadingFilePathRef.current = null;
|
||||
setFileLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedFilePath === selectedFile.path) {
|
||||
if (loadedFilePath === selectedFile.path || loadingFilePathRef.current === selectedFile.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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 selection = terminalControllerRef.current?.getSelection();
|
||||
const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null);
|
||||
if (!selection || !sessionKey || !activeTab) return;
|
||||
addContextDraft({
|
||||
sessionKey,
|
||||
if (!selection || !sessionKey || !activeTab || !effectiveDirectory) return;
|
||||
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'terminal',
|
||||
fileLabel: activeTab.label,
|
||||
startLine: selection.startLine,
|
||||
@@ -626,7 +625,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
language: activeTab.terminalSessionId ?? activeTab.id,
|
||||
text: '',
|
||||
});
|
||||
}, [activeTab, addContextDraft, currentSessionId, newSessionDraft?.open]);
|
||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||
|
||||
const handleSelectTab = React.useCallback(
|
||||
(tabId: string) => {
|
||||
|
||||
@@ -382,8 +382,8 @@ export const PullRequestSection: React.FC<{
|
||||
const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork));
|
||||
|
||||
const prStatusKey = React.useMemo(
|
||||
() => getGitHubPrStatusKey(directory, branch),
|
||||
[directory, branch],
|
||||
() => getGitHubPrStatusKey(directory, branch, selectedRemote?.name ?? null),
|
||||
[directory, branch, selectedRemote?.name],
|
||||
);
|
||||
const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]);
|
||||
|
||||
|
||||
@@ -1,161 +1,17 @@
|
||||
import React, { type JSX, type ReactNode } from 'react';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
import {
|
||||
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,
|
||||
};
|
||||
}
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { createContentCachedFiles } from '@/contexts/content-cache-owner';
|
||||
|
||||
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>(
|
||||
() => ({
|
||||
...apis,
|
||||
files: withContentCache(apis.files),
|
||||
files: cachedFiles.files,
|
||||
}),
|
||||
[apis],
|
||||
[apis, cachedFiles],
|
||||
);
|
||||
return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
@@ -24,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getInitialSystemPreference, readEmbeddedThemeSearchParams } from './theme-embedded-bootstrap';
|
||||
import { isValidTheme } from './theme-validation';
|
||||
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
|
||||
type ThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
@@ -172,8 +174,8 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return existing || null;
|
||||
});
|
||||
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
|
||||
const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []);
|
||||
const isDesktopShell = useMemo(() => detectDesktopShell(), []);
|
||||
const customThemesRequestRef = useRef(0);
|
||||
const receivesParentThemeSync = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -249,11 +251,13 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const request = ++customThemesRequestRef.current;
|
||||
setCustomThemesLoading(true);
|
||||
try {
|
||||
const res = await runtimeFetch('/api/config/themes', {
|
||||
method: 'GET',
|
||||
credentials: isLocalDesktopOrigin ? 'omit' : 'include',
|
||||
credentials: isDesktopLocalOriginActive() ? 'omit' : 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
@@ -269,20 +273,31 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
if (request !== customThemesRequestRef.current || runtimeKey !== getRuntimeKey()) return;
|
||||
const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
|
||||
const normalized = incoming.filter(isValidTheme);
|
||||
setCustomThemes(normalized);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setCustomThemesLoading(false);
|
||||
if (request === customThemesRequestRef.current && runtimeKey === getRuntimeKey()) {
|
||||
setCustomThemesLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isLocalDesktopOrigin, isVSCode]);
|
||||
}, [isVSCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadCustomThemes();
|
||||
}, [reloadCustomThemes]);
|
||||
|
||||
useEffect(() => subscribeRuntimeEndpointChanged((detail) => {
|
||||
if (detail.runtimeKey === detail.previousRuntimeKey || isVSCode) return;
|
||||
customThemesRequestRef.current += 1;
|
||||
setCustomThemes([]);
|
||||
setCustomThemesLoading(false);
|
||||
void reloadCustomThemes();
|
||||
}), [isVSCode, reloadCustomThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVSCode) {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export const usePwaManifestSync = () => {
|
||||
}, [currentSessionId, sessions]);
|
||||
|
||||
const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]);
|
||||
const hasRecentShortcuts = recentShortcuts.length > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !isWebRuntime()) {
|
||||
@@ -76,7 +77,7 @@ export const usePwaManifestSync = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (recentShortcuts.length === 0) {
|
||||
if (!hasRecentShortcuts) {
|
||||
localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature);
|
||||
@@ -87,5 +88,5 @@ export const usePwaManifestSync = () => {
|
||||
|
||||
const win = window as ManifestSyncWindow;
|
||||
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
|
||||
}, [recentShortcuts, signature]);
|
||||
}, [hasRecentShortcuts, signature]);
|
||||
};
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
]);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
await sendQueuedAutoSendPayload('session-original', payload!, {
|
||||
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
|
||||
providerID: 'provider-1',
|
||||
modelID: 'model-1',
|
||||
agent: 'agent-1',
|
||||
@@ -185,7 +185,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
undefined,
|
||||
'variant-1',
|
||||
'normal',
|
||||
{ sessionId: 'session-original' },
|
||||
{ sessionId: 'session-original', directory: '/repo' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
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 { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
type SessionStatusType = 'idle' | 'busy' | 'retry';
|
||||
|
||||
@@ -67,6 +69,7 @@ type ResolvedQueuedSendConfig = {
|
||||
|
||||
export const sendQueuedAutoSendPayload = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
payload: QueuedAutoSendPayload,
|
||||
resolved: ResolvedQueuedSendConfig,
|
||||
) => {
|
||||
@@ -80,7 +83,7 @@ export const sendQueuedAutoSendPayload = (
|
||||
undefined,
|
||||
resolved.variant,
|
||||
'normal',
|
||||
{ sessionId },
|
||||
{ sessionId, directory },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -140,6 +143,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
||||
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
|
||||
@@ -151,11 +155,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (inFlightSessionsRef.current.has(sessionId)) {
|
||||
if (inFlightSessionsRef.current.has(targetKey)) {
|
||||
return;
|
||||
}
|
||||
if (hasRecentAbort(sessionId)) {
|
||||
@@ -166,7 +172,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle';
|
||||
const currentStatus = getDirectoryState(target.directory)?.session_status?.[sessionId]?.type ?? 'idle';
|
||||
if (currentStatus !== 'idle') {
|
||||
return;
|
||||
}
|
||||
@@ -176,9 +182,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = sendFailuresRef.current.get(sessionId);
|
||||
const failure = sendFailuresRef.current.get(targetKey);
|
||||
if (failure && failure.messageId !== payload.queuedMessageId) {
|
||||
sendFailuresRef.current.delete(sessionId);
|
||||
sendFailuresRef.current.delete(targetKey);
|
||||
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
|
||||
return;
|
||||
}
|
||||
@@ -192,28 +198,28 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
inFlightSessionsRef.current.add(sessionId);
|
||||
inFlightSessionsRef.current.add(targetKey);
|
||||
|
||||
try {
|
||||
await sendQueuedAutoSendPayload(sessionId, payload, {
|
||||
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.modelID,
|
||||
agent: resolved.agent,
|
||||
variant: resolved.variant,
|
||||
});
|
||||
useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId);
|
||||
sendFailuresRef.current.delete(sessionId);
|
||||
useMessageQueueStore.getState().removeFromQueue(target, payload.queuedMessageId);
|
||||
sendFailuresRef.current.delete(targetKey);
|
||||
} catch (error) {
|
||||
console.warn('[queue] queued auto-send failed:', error);
|
||||
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
|
||||
const failures = priorFailures + 1;
|
||||
sendFailuresRef.current.set(sessionId, {
|
||||
sendFailuresRef.current.set(targetKey, {
|
||||
messageId: payload.queuedMessageId,
|
||||
failures,
|
||||
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
|
||||
});
|
||||
} finally {
|
||||
inFlightSessionsRef.current.delete(sessionId);
|
||||
inFlightSessionsRef.current.delete(targetKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,7 +232,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
}
|
||||
|
||||
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 previousStatusType = previousStatusRef.current.get(sessionId);
|
||||
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
|
||||
@@ -241,12 +250,12 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType, queue.length > 0)
|
||||
|| (wasAutoReviewBlocked && !isAutoReviewRunning && currentStatusType === 'idle')
|
||||
)) {
|
||||
void dispatchSessionQueue(sessionId, queue);
|
||||
void dispatchSessionQueue(target, queue);
|
||||
}
|
||||
|
||||
nextStatusMap.set(sessionId, currentStatusType);
|
||||
});
|
||||
|
||||
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 AUTO_DELETE_KEEP_RECENT = 5;
|
||||
const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const EMPTY_SESSIONS: Session[] = [];
|
||||
|
||||
const getSessionLastActivity = (session: Session): number => {
|
||||
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 isLoading = useSessionUIStore((state) => state.isLoading);
|
||||
const globalSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
|
||||
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
||||
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
||||
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
|
||||
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
|
||||
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 runningRef = React.useRef(false);
|
||||
|
||||
@@ -359,7 +359,7 @@ const buildSnapshot = (instanceName: string): TraySnapshot => {
|
||||
const resolveStatus = (id: string): TraySessionStatus => {
|
||||
const fromStores = live.statusById.get(id);
|
||||
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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import type {
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
@@ -37,6 +35,7 @@ import type {
|
||||
} from './api/types';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { getRuntimeKey } from './runtime-switch';
|
||||
|
||||
const API_BASE = '/api/git';
|
||||
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 normalizeDirectoryKey = (directory: string): string => directory.trim();
|
||||
const getStatusCacheKey = (directory: string, mode?: 'light'): string =>
|
||||
mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
|
||||
const getDirectoryCacheKey = (runtimeKey: string, directory: string): string =>
|
||||
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 =>
|
||||
gitStatusCacheVersions.get(normalizeDirectoryKey(directory)) ?? 0;
|
||||
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
|
||||
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
|
||||
|
||||
const invalidateGitStatusCache = (directory: string): void => {
|
||||
const key = normalizeDirectoryKey(directory);
|
||||
gitStatusCacheVersions.set(key, getStatusCacheVersion(directory) + 1);
|
||||
for (const cacheKey of Array.from(gitStatusCache.keys())) {
|
||||
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
|
||||
gitStatusCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
for (const cacheKey of Array.from(gitStatusInFlight.keys())) {
|
||||
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
|
||||
gitStatusInFlight.delete(cacheKey);
|
||||
}
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const key = getDirectoryCacheKey(runtimeKey, directory);
|
||||
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
|
||||
for (const mode of [undefined, 'light'] as const) {
|
||||
const statusKey = getStatusCacheKey(runtimeKey, directory, mode);
|
||||
gitStatusCache.delete(statusKey);
|
||||
gitStatusInFlight.delete(statusKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,7 +78,7 @@ function buildUrl(
|
||||
}
|
||||
|
||||
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
||||
const key = normalizeDirectoryKey(directory);
|
||||
const key = getDirectoryCacheKey(getRuntimeKey(), directory);
|
||||
const now = Date.now();
|
||||
const cached = gitRepoCache.get(key);
|
||||
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> {
|
||||
const mode = options?.mode;
|
||||
const key = getStatusCacheKey(directory, mode);
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const key = getStatusCacheKey(runtimeKey, directory, mode);
|
||||
const now = Date.now();
|
||||
const cached = gitStatusCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
@@ -132,13 +130,13 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const cacheVersion = getStatusCacheVersion(directory);
|
||||
const cacheVersion = getStatusCacheVersion(runtimeKey, directory);
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git status: ${response.statusText}`);
|
||||
}
|
||||
const payload = await response.json() as GitStatus;
|
||||
if (getStatusCacheVersion(directory) === cacheVersion) {
|
||||
if (getStatusCacheVersion(runtimeKey, directory) === cacheVersion) {
|
||||
gitStatusCache.set(key, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
|
||||
|
||||
@@ -1860,6 +1860,12 @@ export const dict = {
|
||||
'chat.container.returnToParent.title': 'Return to parent session',
|
||||
'chat.container.returnToParent.label': 'Parent',
|
||||
'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.model.title': 'Model',
|
||||
'chat.unifiedControls.model.noRecent': 'No recent models',
|
||||
|
||||
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.container.returnToParent.title": "Volver a la sesión principal",
|
||||
"chat.container.returnToParent.label": "Principal",
|
||||
"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.model.title": "Modelo",
|
||||
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
|
||||
|
||||
@@ -1656,6 +1656,12 @@ export const dict = {
|
||||
'chat.container.returnToParent.title': 'Retour à la session parents',
|
||||
'chat.container.returnToParent.label': 'Mère',
|
||||
'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.model.title': 'Modèle',
|
||||
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
|
||||
|
||||
@@ -1856,6 +1856,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '親セッションに戻る',
|
||||
'chat.container.returnToParent.label': '親',
|
||||
'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.model.title': 'モデル',
|
||||
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
|
||||
|
||||
@@ -1862,6 +1862,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
|
||||
'chat.container.returnToParent.label': '상위',
|
||||
'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.model.title': '모델',
|
||||
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
|
||||
|
||||
@@ -749,6 +749,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
|
||||
'chat.container.returnToParent.label': 'Nadrzędna',
|
||||
'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.model.title': 'Model',
|
||||
'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.label": "Principal",
|
||||
"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.model.title": "Modelo",
|
||||
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
|
||||
|
||||
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.container.returnToParent.title": "Повернутися до батьківської сесії",
|
||||
"chat.container.returnToParent.label": "Батьківська",
|
||||
"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.model.title": "Модель",
|
||||
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
|
||||
|
||||
@@ -1826,6 +1826,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '返回父会话',
|
||||
'chat.container.returnToParent.label': '父级',
|
||||
'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.model.title': '模型',
|
||||
'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
|
||||
|
||||
@@ -1830,6 +1830,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '返回父會話',
|
||||
'chat.container.returnToParent.label': '父級',
|
||||
'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.model.title': '模型',
|
||||
'chat.unifiedControls.model.noRecent': '沒有最近使用的模型',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string };
|
||||
type ModelPrefsPayload = {
|
||||
@@ -75,9 +76,13 @@ export const startModelPrefsAutoSave = () => {
|
||||
let timer: number | null = null;
|
||||
let lastSent: ModelPrefsPayload | null = null;
|
||||
let didSkipInitial = false;
|
||||
let scheduledRuntimeKey: string | null = null;
|
||||
|
||||
const flush = () => {
|
||||
timer = null;
|
||||
const runtimeKey = scheduledRuntimeKey;
|
||||
scheduledRuntimeKey = null;
|
||||
if (!runtimeKey || runtimeKey !== getRuntimeKey()) return;
|
||||
const payload = snapshotModelPrefs();
|
||||
|
||||
if (lastSent && modelPrefsEqual(lastSent, payload)) {
|
||||
@@ -97,9 +102,17 @@ export const startModelPrefsAutoSave = () => {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
scheduledRuntimeKey = getRuntimeKey();
|
||||
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 next = {
|
||||
favoriteModels: state.favoriteModels,
|
||||
@@ -125,6 +138,7 @@ export const startModelPrefsAutoSave = () => {
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeRuntime();
|
||||
if (timer !== null) {
|
||||
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.
|
||||
*/
|
||||
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch'
|
||||
|
||||
const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3
|
||||
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000
|
||||
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 PROVIDER_EVICTION_TTL_MS = 60 * 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])
|
||||
|
||||
@@ -27,15 +30,14 @@ type ProviderState = {
|
||||
}
|
||||
|
||||
const providers = new Map<string, ProviderState>()
|
||||
const providerKey = (providerID: string): string => JSON.stringify([getRuntimeKey(), providerID])
|
||||
|
||||
function evictStaleProviders(): void {
|
||||
const now = Date.now()
|
||||
for (const [providerID, state] of providers) {
|
||||
if (
|
||||
state.consecutiveErrors === 0 &&
|
||||
now - state.lastErrorAt > PROVIDER_EVICTION_TTL_MS
|
||||
) {
|
||||
providers.delete(providerID)
|
||||
for (const [key, state] of providers) {
|
||||
const lastActivityAt = Math.max(state.lastErrorAt, state.circuitOpenAt)
|
||||
if (now - lastActivityAt > PROVIDER_EVICTION_TTL_MS) {
|
||||
providers.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +48,8 @@ if (typeof setInterval !== 'undefined') {
|
||||
}
|
||||
|
||||
function getOrCreateProvider(providerID: string): ProviderState {
|
||||
let state = providers.get(providerID)
|
||||
const key = providerKey(providerID)
|
||||
let state = providers.get(key)
|
||||
if (!state) {
|
||||
state = {
|
||||
consecutiveErrors: 0,
|
||||
@@ -55,17 +58,19 @@ function getOrCreateProvider(providerID: string): ProviderState {
|
||||
circuitOpenAt: 0,
|
||||
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
|
||||
}
|
||||
|
||||
export function recordProviderSuccess(providerID: string): void {
|
||||
if (!providerID) return
|
||||
const state = providers.get(providerID)
|
||||
if (!state) return
|
||||
state.consecutiveErrors = 0
|
||||
state.lastErrorAt = 0
|
||||
providers.delete(providerKey(providerID))
|
||||
}
|
||||
|
||||
export function recordProviderError(providerID: string, status?: number): void {
|
||||
@@ -91,7 +96,7 @@ function isCircuitBreakerStatus(status?: number): boolean {
|
||||
}
|
||||
|
||||
function isCircuitOpen(providerID: string): boolean {
|
||||
const state = providers.get(providerID)
|
||||
const state = providers.get(providerKey(providerID))
|
||||
if (!state?.circuitOpen) return false
|
||||
|
||||
const elapsed = Date.now() - state.circuitOpenAt
|
||||
|
||||
@@ -5,8 +5,10 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import {
|
||||
applyPersistedHomeDirectoryToWindow,
|
||||
getRuntimeSettingsMirrorStorageKey,
|
||||
getSettingsSaveState,
|
||||
invalidateSettingsCache,
|
||||
subscribeToSettingsSaveState,
|
||||
@@ -315,6 +317,88 @@ describe('updateDesktopSettings', () => {
|
||||
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 () => {
|
||||
getWindow();
|
||||
const settings = {
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
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 { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
||||
@@ -12,6 +18,8 @@ import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isTerminalShell } from '@/lib/terminalShell';
|
||||
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 => {
|
||||
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) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.themeId) {
|
||||
localStorage.setItem('selectedThemeId', settings.themeId);
|
||||
}
|
||||
if (settings.themeVariant) {
|
||||
localStorage.setItem('selectedThemeVariant', settings.themeVariant);
|
||||
}
|
||||
if (settings.lightThemeId) {
|
||||
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);
|
||||
}
|
||||
persistRuntimeSettingsMirror(settings, getRuntimeKey());
|
||||
setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
|
||||
setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
|
||||
setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
|
||||
setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
|
||||
setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
|
||||
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
|
||||
if (settings.homeDirectory) {
|
||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||
applyPersistedHomeDirectoryToWindow(settings.homeDirectory);
|
||||
} else {
|
||||
localStorage.removeItem('homeDirectory');
|
||||
}
|
||||
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
|
||||
localStorage.setItem('projects', JSON.stringify(settings.projects));
|
||||
@@ -81,6 +137,8 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
localStorage.removeItem('oc.sessions.projectCollapse');
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('oc.sessions.projectCollapse');
|
||||
}
|
||||
if (typeof settings.gitmojiEnabled === 'boolean') {
|
||||
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
|
||||
@@ -89,13 +147,15 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
}
|
||||
if (typeof settings.directoryShowHidden === 'boolean') {
|
||||
localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false');
|
||||
} else {
|
||||
localStorage.removeItem('directoryTreeShowHidden');
|
||||
}
|
||||
if (typeof settings.filesViewShowGitignored === 'boolean') {
|
||||
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
|
||||
} else {
|
||||
localStorage.removeItem('filesViewShowGitignored');
|
||||
}
|
||||
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
|
||||
localStorage.setItem('openInAppId', settings.openInAppId);
|
||||
}
|
||||
setOrRemoveLocalStorage('openInAppId', typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0 ? settings.openInAppId : null);
|
||||
if (typeof settings.pwaAppName === 'string') {
|
||||
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||
if (normalized.length > 0) {
|
||||
@@ -103,10 +163,10 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
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') {
|
||||
const version = settings.openCodeUpdateToastDismissedVersion.trim();
|
||||
if (version) {
|
||||
@@ -114,25 +174,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
localStorage.removeItem('opencode-update-toast-dismissed-version');
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('opencode-update-toast-dismissed-version');
|
||||
}
|
||||
if (typeof settings.dictationEnabled === 'boolean') {
|
||||
localStorage.setItem('dictationEnabled', String(settings.dictationEnabled));
|
||||
} else {
|
||||
localStorage.removeItem('dictationEnabled');
|
||||
}
|
||||
if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') {
|
||||
localStorage.setItem('sttProvider', settings.sttProvider);
|
||||
} else {
|
||||
localStorage.removeItem('sttProvider');
|
||||
}
|
||||
if (typeof settings.sttServerUrl === 'string') {
|
||||
localStorage.setItem('sttServerUrl', settings.sttServerUrl);
|
||||
}
|
||||
if (typeof settings.sttModel === 'string') {
|
||||
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);
|
||||
}
|
||||
setOrRemoveLocalStorage('sttServerUrl', typeof settings.sttServerUrl === 'string' ? settings.sttServerUrl : null);
|
||||
setOrRemoveLocalStorage('sttModel', typeof settings.sttModel === 'string' ? settings.sttModel : null);
|
||||
setOrRemoveLocalStorage('sttLocalModel', typeof settings.sttLocalModel === 'string' ? settings.sttLocalModel : null);
|
||||
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
|
||||
};
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
|
||||
@@ -457,6 +515,91 @@ const getPersistApi = (): PersistApi | undefined => {
|
||||
|
||||
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 store = useUIStore.getState();
|
||||
const configStore = typeof window !== 'undefined'
|
||||
@@ -1215,6 +1358,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
|
||||
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
|
||||
}
|
||||
if (typeof candidate.expandedEditorToolbar === 'boolean') {
|
||||
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
|
||||
}
|
||||
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
|
||||
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
|
||||
}
|
||||
@@ -1524,6 +1670,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
const applySettings = async (settings: DesktopSettings) => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true;
|
||||
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
|
||||
try {
|
||||
persistToLocalStorage(settings);
|
||||
} catch (error) {
|
||||
@@ -1531,20 +1678,23 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (settings.draftStarters === undefined) {
|
||||
useUIStore.setState({ globalDraftStarters: null });
|
||||
}
|
||||
try {
|
||||
applyDesktopUiPreferences(settings);
|
||||
applyDesktopUiPreferences(authoritativeSettings);
|
||||
} catch (error) {
|
||||
console.warn('applyDesktopUiPreferences failed:', error);
|
||||
}
|
||||
if (shouldPersistCraftGoalMigration) {
|
||||
await updateDesktopSettings({
|
||||
...(settings.draftStarters ? { draftStarters: settings.draftStarters } : {}),
|
||||
...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}),
|
||||
draftStartersCraftGoalAdded: true,
|
||||
});
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
}
|
||||
|
||||
dispatchSettingsSynced(settings);
|
||||
dispatchSettingsSynced(authoritativeSettings);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1579,7 +1729,6 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
_settingsCache = null;
|
||||
@@ -1613,7 +1762,6 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
dispatchSettingsSaveState('saved');
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
clearRuntimeUrlAuthToken,
|
||||
getRuntimeBearerTokenSync,
|
||||
refreshRuntimeUrlAuthToken,
|
||||
refreshLocalRuntimeUrlAuthToken,
|
||||
getLocalRuntimeUrlAuthTokenSync,
|
||||
setRuntimeAuthCredentialProvider,
|
||||
setRuntimeBearerToken,
|
||||
setRuntimeExtraHeaders,
|
||||
@@ -145,4 +147,53 @@ describe('runtime auth headers', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,10 @@ let runtimeUrlAuthTokenExpiresAt = 0;
|
||||
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
||||
let localRuntimeUrlAuthToken = '';
|
||||
let localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
let localRuntimeUrlAuthOrigin = '';
|
||||
let localRuntimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
||||
let localRuntimeUrlAuthRefreshOrigin = '';
|
||||
let localRuntimeUrlAuthGeneration = 0;
|
||||
let runtimeAuthGeneration = 0;
|
||||
|
||||
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 => {
|
||||
runtimeUrlAuthToken = '';
|
||||
runtimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
clearLocalRuntimeUrlAuthToken();
|
||||
};
|
||||
|
||||
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);
|
||||
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
const origin = typeof localOrigin === 'string' ? normalizeOrigin(localOrigin) : '';
|
||||
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || !origin) {
|
||||
clearLocalRuntimeUrlAuthToken();
|
||||
return;
|
||||
}
|
||||
localRuntimeUrlAuthToken = normalized;
|
||||
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
|
||||
localRuntimeUrlAuthOrigin = origin;
|
||||
};
|
||||
|
||||
const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||
@@ -154,10 +178,13 @@ const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||
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) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthOrigin = '';
|
||||
return '';
|
||||
}
|
||||
return localRuntimeUrlAuthToken;
|
||||
@@ -172,7 +199,7 @@ export const getRuntimeUrlAuthTokenSync = (): string => {
|
||||
};
|
||||
|
||||
export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => {
|
||||
const token = readValidLocalRuntimeUrlAuthTokenSync();
|
||||
const token = localOrigin ? readValidLocalRuntimeUrlAuthTokenSync(localOrigin) : '';
|
||||
if (!token && localOrigin && typeof window !== 'undefined') {
|
||||
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
|
||||
}
|
||||
@@ -242,15 +269,23 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): 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 response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), {
|
||||
const response = await fetch(buildAuthUrl(origin, '/auth/url-token'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
if (generation === localRuntimeUrlAuthGeneration && origin === localRuntimeUrlAuthRefreshOrigin) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthOrigin = '';
|
||||
}
|
||||
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;
|
||||
@@ -259,16 +294,22 @@ const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
|
||||
if (!token || !Number.isFinite(expiresAt)) {
|
||||
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;
|
||||
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
|
||||
localRuntimeUrlAuthOrigin = origin;
|
||||
return token;
|
||||
})();
|
||||
const trackedPromise = refreshPromise.finally(() => {
|
||||
if (localRuntimeUrlAuthRefreshPromise === trackedPromise) {
|
||||
localRuntimeUrlAuthRefreshPromise = null;
|
||||
localRuntimeUrlAuthRefreshOrigin = '';
|
||||
}
|
||||
});
|
||||
localRuntimeUrlAuthRefreshPromise = trackedPromise;
|
||||
localRuntimeUrlAuthRefreshOrigin = origin;
|
||||
return localRuntimeUrlAuthRefreshPromise;
|
||||
};
|
||||
|
||||
@@ -281,9 +322,17 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
|
||||
};
|
||||
|
||||
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;
|
||||
return mintLocalRuntimeUrlAuthToken(localOrigin);
|
||||
if (
|
||||
(localRuntimeUrlAuthOrigin && localRuntimeUrlAuthOrigin !== origin)
|
||||
|| (localRuntimeUrlAuthRefreshOrigin && localRuntimeUrlAuthRefreshOrigin !== origin)
|
||||
) {
|
||||
clearLocalRuntimeUrlAuthToken();
|
||||
}
|
||||
return mintLocalRuntimeUrlAuthToken(origin);
|
||||
};
|
||||
|
||||
// ── Proactive URL auth token refresh ──────────────────────────────────────
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('createRuntimeUrlResolver', () => {
|
||||
test('adds local URL auth token to desktop realtime proxy URL', () => {
|
||||
setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' });
|
||||
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 {
|
||||
withWindow({
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,11 @@ function withStableStringId<T extends object>(value: T, id: string): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
const MAX_RESOLVED_THEME_CACHE_ENTRIES = 40;
|
||||
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 bgRaw = raw.colors?.['editor.background'];
|
||||
@@ -68,24 +71,33 @@ const buildTextMateTheme = (theme: Theme): VSCodeTextMateTheme => {
|
||||
};
|
||||
|
||||
export const getResolvedShikiTheme = (theme: Theme): ShikiThemeRegistrationResolvedLike => {
|
||||
const cached = resolvedThemeCache.get(theme.metadata.id);
|
||||
const signature = getThemeContentSignature(theme);
|
||||
const cached = resolvedThemeCache.get(signature);
|
||||
if (cached) {
|
||||
resolvedThemeCache.delete(signature);
|
||||
resolvedThemeCache.set(signature, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const raw = buildTextMateTheme(theme);
|
||||
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;
|
||||
};
|
||||
|
||||
export const ensurePierreThemeRegistered = (theme: Theme): void => {
|
||||
const id = theme.metadata.id;
|
||||
if (registeredPierreThemes.has(id)) {
|
||||
const signature = getThemeContentSignature(theme);
|
||||
if (registeredPierreThemeSignatures.get(id) === signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = getResolvedShikiTheme(theme);
|
||||
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
Reference in New Issue
Block a user