Merge main

This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:25:37 +03:00
728 changed files with 42191 additions and 15617 deletions
+44 -3
View File
@@ -38,7 +38,7 @@ Examples:
- `useFeatureFlagsStore.ts`
- `useUpdateStore.ts`
These stores coordinate visible app state, navigation, selected tabs, dialogs, and lightweight feature flags.
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection.
Context-panel session chats mount only the active chat iframe. After installing
its message listener, the iframe requests its authoritative visibility from the
@@ -64,9 +64,9 @@ These stores coordinate persistent project/session metadata across multiple view
`useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn.
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message.
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same.
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage. Its entity map and active root, parent/child, and directory indexes are maintained in the same transaction as the compatibility arrays and `sessionsByDirectory`. Full authoritative snapshots may rebuild those indexes once; direct create, update, move, archive, and delete mutations update only affected hierarchy and directory buckets. Metadata-only updates preserve the structure reference. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
User-visible session ordering is also not owned by the global cache array order. `sync/session-ordering.ts` combines lifecycle rank with timestamp fallbacks, and session surfaces must use that shared comparator instead of independently sorting global sessions by `time.updated`.
@@ -88,6 +88,8 @@ Project and UI settings use successful settings synchronization as authority. Om
Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode.
Session display persistence keeps a hydrated local cache for the independent all-projects/single-project mode, session grouping, project sort, and Recent preference; successful server settings snapshots are authoritative and the UI seeds missing server fields once from that cache for upgrades. The last confirmed or manually selected project and sticky-header preference stay local to the device. Draft target changes do not write the picker selection; materialized session navigation updates it from the resolved project directory.
Session folders persist in runtime-specific v2 browser keys without silently evicting older runtime namespaces. Runtime switch, page hide, app freeze, and unload synchronously flush the pending browser snapshot before lifecycle suspension or namespace replacement. A runtime switch then cancels stale old-runtime disk work and starts generation-owned disk hydration. Missing or malformed server files are not authoritative empty snapshots; disk data may replace browser state only when it carries a real revision and no newer local folder mutation occurred. Server writes are serialized and reject non-newer revisions so delayed or duplicate requests cannot overwrite the current state. File-search cache and in-flight keys include runtime plus directory and are cleared on endpoint reset.
Persisted session todos use a bounded composite key of runtime, normalized directory, and session ID. Ambiguous legacy todo entries are discarded rather than claimed by whichever runtime starts first. Authoritative deletion uses an explicit runtime identity, and session-folder deletion scans every scope in the active runtime so archived assignments cannot survive after their session is gone.
@@ -196,6 +198,45 @@ These rules are important. Breaking them tends to reintroduce idle CPU churn, st
9. Global session refresh must remain bounded and failure-isolated per directory.
10. Global session cache must not drive live activity indicators or message-loading state.
### Configuration stores and the Settings directory
`useAgentsStore`, `useCommandsStore`, `useSkillsStore`, `useMcpConfigStore` and
the provider half of `useConfigStore` describe **one project's configuration**.
Two surfaces read them at once: the app (chat, autocompletes, pickers), which
wants the active project, and Settings, whose own project selector may point
somewhere else.
Each of them therefore keeps two things:
- a per-directory map (`agentsByDirectory`, `commandsByDirectory`,
`skillsByDirectory`, `serversByDirectory`, `directoryScoped`);
- a flat mirror (`agents`, `commands`, `skills`, `mcpServers`, `providers`) that
tracks the **active** project only.
Thinking variants keep the effective value in `currentVariant` so existing send
paths capture a stable configuration. The transient `currentVariantSelection`
distinguishes automatic initialization from a picker or shortcut choosing an
explicit override or `Default`; returning to `Default` restores its inherited
effective value. Only explicit overrides are stored in the per-session
selection store.
Every loader and mutation takes an explicit directory; omitting it means the
active project, which is what non-Settings callers pass. A load for another
directory writes the map and leaves the mirror alone, so browsing another
project in Settings cannot change what chat sees. Components select through
`selectAgentsForDirectory` / `selectCommandsForDirectory` /
`selectSkillsForDirectory` / `selectMcpServersForDirectory` /
`selectProvidersForDirectory`, which return stored arrays.
Settings resolves its directory through `useSettingsDirectory`, backed by
`useUIStore.settingsProjectPath`. That selection is Settings-local and not
persisted: it follows the active project until the user picks another one. The
Settings project selector must never call `setActiveProject` — that relocates
the chat, the session list and the file tree.
Failure is still not empty: a failed load restores that directory's previous
list rather than clearing it.
## Selector Rules
Use leaf selectors.
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore, MAX_TRUSTED_SCHEMES } from './appLinkTrustStore';
describe('app link trust store', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
});
test('trusts a scheme with case and whitespace normalization', () => {
const store = useAppLinkTrustStore.getState();
store.trustScheme(' Obsidian ');
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian']);
expect(useAppLinkTrustStore.getState().isSchemeTrusted('OBSIDIAN')).toBe(true);
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(false);
});
test('re-trusting moves the scheme to the front without duplicates', () => {
const store = useAppLinkTrustStore.getState();
store.trustScheme('obsidian');
store.trustScheme('linear');
store.trustScheme('obsidian');
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian', 'linear']);
});
test('removes a trusted scheme', () => {
const store = useAppLinkTrustStore.getState();
store.trustScheme('obsidian');
store.trustScheme('linear');
useAppLinkTrustStore.getState().removeTrustedScheme('obsidian');
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['linear']);
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(false);
});
test('caps the stored scheme list', () => {
const store = useAppLinkTrustStore.getState();
for (let index = 0; index < MAX_TRUSTED_SCHEMES + 5; index += 1) {
store.trustScheme(`scheme${index}`);
}
const schemes = useAppLinkTrustStore.getState().trustedSchemes;
expect(schemes).toHaveLength(MAX_TRUSTED_SCHEMES);
expect(schemes[0]).toBe(`scheme${MAX_TRUSTED_SCHEMES + 4}`);
});
});
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage';
export const MAX_TRUSTED_SCHEMES = 64;
interface AppLinkTrustState {
/** Application deep-link schemes (obsidian, vscode, ...) the user chose to always allow. */
trustedSchemes: string[];
trustScheme: (scheme: string) => void;
removeTrustedScheme: (scheme: string) => void;
isSchemeTrusted: (scheme: string) => boolean;
}
const normalizeScheme = (scheme: string): string => scheme.trim().toLowerCase();
/**
* Per-device trust for application deep links rendered in chat. Security
* decisions do not roam, so this persists locally through the shared safe
* storage rather than server-synced settings.
*/
export const useAppLinkTrustStore = create<AppLinkTrustState>()(
persist(
(set, get) => ({
trustedSchemes: [],
trustScheme: (scheme) => {
const normalized = normalizeScheme(scheme);
if (!normalized) return;
set((state) => {
const next = [normalized, ...state.trustedSchemes.filter((entry) => entry !== normalized)];
return { trustedSchemes: next.slice(0, MAX_TRUSTED_SCHEMES) };
});
},
removeTrustedScheme: (scheme) => {
const normalized = normalizeScheme(scheme);
set((state) => ({ trustedSchemes: state.trustedSchemes.filter((entry) => entry !== normalized) }));
},
isSchemeTrusted: (scheme) => get().trustedSchemes.includes(normalizeScheme(scheme)),
}),
{
name: 'app-link-trust-store',
storage: createDeferredSafeJSONStorage(),
version: 1,
partialize: (state) => ({ trustedSchemes: state.trustedSchemes }),
},
),
);
@@ -0,0 +1,224 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { normalizePath } from '@/lib/pathNormalization';
export type GlobalSessionStructure = {
activeSessionIds: readonly string[];
activeRootIds: readonly string[];
activeChildrenByParentId: ReadonlyMap<string, readonly string[]>;
activeIdsByDirectory: ReadonlyMap<string, readonly string[]>;
};
export type GlobalSessionStructureMutation = {
sessionId: string;
previous: Session | null;
next: Session | null;
};
type SessionLocation = {
directory: string | null;
parentId: string | null;
};
type BucketChange = {
additions: Set<string>;
removals: Set<string>;
};
type SessionIndexFields = {
directory?: string | null;
parentID?: string | null;
project?: { worktree?: string | null } | null;
};
const indexFields = (session: Session): Session & SessionIndexFields => {
// SAFETY: OpenCode session payloads expose these stable fields even though the SDK base Session omits them.
return session as Session & SessionIndexFields;
};
const parentIdOf = (session: Session): string | null => (
indexFields(session).parentID ?? null
);
export const resolveGlobalSessionDirectory = (session: Session): string | null => {
const record = indexFields(session);
return normalizePath(record.directory ?? null)
?? normalizePath(record.project?.worktree ?? null);
};
export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Session | null): Session => {
if (!existing) return incoming;
const incomingRecord = indexFields(incoming);
const existingRecord = indexFields(existing);
const incomingDirectory = normalizePath(incomingRecord.directory ?? null);
const incomingWorktree = normalizePath(incomingRecord.project?.worktree ?? null);
const existingDirectory = normalizePath(existingRecord.directory ?? null);
const existingWorktree = normalizePath(existingRecord.project?.worktree ?? null);
let changed = false;
const next: typeof incomingRecord = { ...incomingRecord };
if (!incomingDirectory && existingDirectory) {
next.directory = existingRecord.directory;
changed = true;
}
if (!incomingWorktree && existingWorktree) {
next.project = {
...(existingRecord.project ?? {}),
...(incomingRecord.project ?? {}),
worktree: existingRecord.project?.worktree,
};
changed = true;
} else if (!incomingRecord.project && existingRecord.project) {
next.project = existingRecord.project;
changed = true;
}
return changed ? next : incoming;
};
const locationOf = (session: Session): SessionLocation | null => session.time?.archived ? null : ({
directory: resolveGlobalSessionDirectory(session),
parentId: parentIdOf(session),
});
const sameLocation = (left: SessionLocation, right: SessionLocation): boolean => (
left.directory === right.directory
&& left.parentId === right.parentId
);
const appendToBucket = (buckets: Map<string, string[]>, key: string, sessionId: string): void => {
const bucket = buckets.get(key);
if (bucket) bucket.push(sessionId);
else buckets.set(key, [sessionId]);
};
export const buildGlobalSessionStructure = (
activeSessions: readonly Session[],
): GlobalSessionStructure => {
const activeRootIds: string[] = [];
const activeChildrenByParentId = new Map<string, string[]>();
const activeIdsByDirectory = new Map<string, string[]>();
const index = (
session: Session,
roots: string[],
children: Map<string, string[]>,
directories: Map<string, string[]>,
): void => {
const location = locationOf(session);
if (!location) return;
if (location.parentId) appendToBucket(children, location.parentId, session.id);
else roots.push(session.id);
if (location.directory) appendToBucket(directories, location.directory, session.id);
};
for (const session of activeSessions) index(session, activeRootIds, activeChildrenByParentId, activeIdsByDirectory);
return {
activeSessionIds: activeSessions.map((session) => session.id),
activeRootIds,
activeChildrenByParentId,
activeIdsByDirectory,
};
};
const recordBucketChange = (
changes: Map<string, BucketChange>,
key: string,
sessionId: string,
operation: 'add' | 'remove',
): void => {
const change = changes.get(key) ?? { additions: new Set<string>(), removals: new Set<string>() };
if (operation === 'add') {
change.removals.delete(sessionId);
change.additions.delete(sessionId);
change.additions.add(sessionId);
} else {
change.additions.delete(sessionId);
change.removals.add(sessionId);
}
changes.set(key, change);
};
const applyListChange = (
source: readonly string[],
change: BucketChange,
): readonly string[] => {
if (change.additions.size === 0 && change.removals.size === 0) return source;
const additions = [...change.additions].reverse();
const added = new Set(additions);
const retained = source.filter((id) => !change.removals.has(id) && !added.has(id));
const next = [...additions, ...retained];
if (next.length === source.length && next.every((id, index) => id === source[index])) return source;
return next;
};
const applyBucketChanges = (
source: ReadonlyMap<string, readonly string[]>,
changes: Map<string, BucketChange>,
): ReadonlyMap<string, readonly string[]> => {
if (changes.size === 0) return source;
let next: Map<string, readonly string[]> | null = null;
for (const [key, change] of changes) {
const previous = source.get(key) ?? [];
const bucket = applyListChange(previous, change);
if (bucket === previous) continue;
next ??= new Map(source);
if (bucket.length === 0) next.delete(key);
else next.set(key, bucket);
}
return next ?? source;
};
export const applyGlobalSessionStructureMutations = (
structure: GlobalSessionStructure,
mutations: readonly GlobalSessionStructureMutation[],
): GlobalSessionStructure => {
const activeRoots: BucketChange = { additions: new Set(), removals: new Set() };
const activeSessions: BucketChange = { additions: new Set(), removals: new Set() };
const activeChildren = new Map<string, BucketChange>();
const activeDirectories = new Map<string, BucketChange>();
const record = (location: SessionLocation, sessionId: string, operation: 'add' | 'remove'): void => {
if (operation === 'add') {
activeSessions.removals.delete(sessionId);
activeSessions.additions.delete(sessionId);
activeSessions.additions.add(sessionId);
} else {
activeSessions.additions.delete(sessionId);
activeSessions.removals.add(sessionId);
}
if (location.parentId) recordBucketChange(activeChildren, location.parentId, sessionId, operation);
else if (operation === 'add') {
activeRoots.removals.delete(sessionId);
activeRoots.additions.delete(sessionId);
activeRoots.additions.add(sessionId);
} else {
activeRoots.additions.delete(sessionId);
activeRoots.removals.add(sessionId);
}
if (location.directory) recordBucketChange(activeDirectories, location.directory, sessionId, operation);
};
for (const mutation of mutations) {
const previousLocation = mutation.previous ? locationOf(mutation.previous) : null;
const nextLocation = mutation.next ? locationOf(mutation.next) : null;
if (previousLocation && nextLocation && sameLocation(previousLocation, nextLocation)) continue;
if (previousLocation) record(previousLocation, mutation.sessionId, 'remove');
if (nextLocation) record(nextLocation, mutation.sessionId, 'add');
}
const next: GlobalSessionStructure = {
activeSessionIds: applyListChange(structure.activeSessionIds, activeSessions),
activeRootIds: applyListChange(structure.activeRootIds, activeRoots),
activeChildrenByParentId: applyBucketChanges(structure.activeChildrenByParentId, activeChildren),
activeIdsByDirectory: applyBucketChanges(structure.activeIdsByDirectory, activeDirectories),
};
return next.activeSessionIds === structure.activeSessionIds
&& next.activeRootIds === structure.activeRootIds
&& next.activeChildrenByParentId === structure.activeChildrenByParentId
&& next.activeIdsByDirectory === structure.activeIdsByDirectory
? structure
: next;
};
+24 -2
View File
@@ -1,7 +1,29 @@
import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
describe('managed Chats runtime visibility', () => {
const session = (id: string, directory: string): Session => ({
id,
slug: id,
projectID: 'project',
directory,
title: id,
version: '1',
time: { created: 1, updated: 1 },
})
const chat = session('chat', '/home/user/.config/openchamber/chats/2026-08-21/session-a')
const project = session('project', '/workspace/project')
test('VS Code rejects managed Chats before they enter global state', () => {
expect(filterManagedChatsForRuntime([chat, project], true)).toEqual([project])
})
test('other runtimes retain managed Chats', () => {
expect(filterManagedChatsForRuntime([chat, project], false)).toEqual([chat, project])
})
})
describe('listGlobalSessionPages', () => {
test('sanitizes session list records before returning them', async () => {
+7
View File
@@ -3,6 +3,7 @@ import { runBackgroundNetworkTask } from '@/lib/background-network';
import { retry } from "@/sync/retry";
import { stripSessionListDetails } from "@/sync/sanitize";
import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance";
import { isChatDirectoryPath } from '@/lib/chatDirectories';
export type GlobalSessionRecord = Session & {
project?: {
@@ -12,6 +13,12 @@ export type GlobalSessionRecord = Session & {
} | null;
};
export const filterManagedChatsForRuntime = (sessions: Session[], vscode: boolean): Session[] => (
vscode
? sessions.filter((session) => !isChatDirectoryPath(session.directory))
: sessions
);
const toNumber = (value: string | null): number | null => {
if (!value) {
return null;
@@ -6,6 +6,9 @@ const skill = (name: string, path: string) => ({ name, path });
const AGENTS = (name: string) => skill(name, `/repo/.agents/skills/${name}/SKILL.md`);
const CLAUDE = (name: string) => skill(name, `/repo/.claude/skills/${name}/SKILL.md`);
const OPENCODE = (name: string) => skill(name, `/home/u/.config/opencode/skill/${name}/SKILL.md`);
const WIN_AGENTS = (name: string) => skill(name, String.raw`C:\Users\u\.agents\skills\${name}\SKILL.md`);
const WIN_CLAUDE = (name: string) => skill(name, String.raw`C:\Users\u\.claude\skills\${name}\SKILL.md`);
const WIN_OPENCODE = (name: string) => skill(name, String.raw`C:\Users\u\.config\opencode\skill\${name}\SKILL.md`);
const ENABLED = { claudeDisabled: false, allDisabled: false };
@@ -20,6 +23,13 @@ describe('resolveSkillRoot', () => {
test('does not match a directory that merely contains the name', () => {
expect(resolveSkillRoot('/repo/my.claude.backup/skills/a/SKILL.md')).toBe('opencode');
});
test('classifies Windows backslash paths', () => {
expect(resolveSkillRoot(WIN_CLAUDE('a').path)).toBe('claude');
expect(resolveSkillRoot(WIN_AGENTS('a').path)).toBe('agents');
expect(resolveSkillRoot(WIN_OPENCODE('a').path)).toBe('opencode');
expect(resolveSkillRoot(String.raw`C:\repo\my.claude.backup\skills\a\SKILL.md`)).toBe('opencode');
});
});
describe('filterSkillsByRuntimeFlags', () => {
@@ -72,4 +82,22 @@ describe('filterSkillsByRuntimeFlags', () => {
const result = filterSkillsByRuntimeFlags([CLAUDE('only-claude'), AGENTS('other')], ENABLED);
expect(result.map((s) => s.name).sort()).toEqual(['only-claude', 'other']);
});
test('drops Windows .agents and .claude skills when external skills are disabled', () => {
const skills = [WIN_AGENTS('a'), WIN_CLAUDE('b'), WIN_OPENCODE('c')];
const result = filterSkillsByRuntimeFlags(skills, { claudeDisabled: false, allDisabled: true });
expect(result.map((s) => s.name)).toEqual(['c']);
});
test('drops only Windows .claude skills when claude skills are disabled', () => {
const skills = [WIN_AGENTS('a'), WIN_CLAUDE('b'), WIN_OPENCODE('c')];
const result = filterSkillsByRuntimeFlags(skills, { claudeDisabled: true, allDisabled: false });
expect(result.map((s) => s.name).sort()).toEqual(['a', 'c']);
});
test('prefers the .agents copy for a duplicated name on Windows', () => {
const result = filterSkillsByRuntimeFlags([WIN_CLAUDE('dup'), WIN_AGENTS('dup')], ENABLED);
expect(result).toHaveLength(1);
expect(result[0].path).toContain('.agents');
});
});
+6 -2
View File
@@ -35,8 +35,12 @@ const AGENTS_ROOT = /(^|\/)\.agents\//;
type SkillRoot = 'claude' | 'agents' | 'opencode';
export const resolveSkillRoot = (skillPath: string): SkillRoot => {
if (CLAUDE_ROOT.test(skillPath)) return 'claude';
if (AGENTS_ROOT.test(skillPath)) return 'agents';
// Server discovery joins paths with the platform separator, so Windows
// skill paths arrive with backslashes. Normalize before matching the
// root regexes, which are expressed with forward slashes.
const normalized = skillPath.replace(/\\/g, '/');
if (CLAUDE_ROOT.test(normalized)) return 'claude';
if (AGENTS_ROOT.test(normalized)) return 'agents';
return 'opencode';
};
@@ -21,6 +21,10 @@ interface MemoryReadResult {
projectFailed: boolean;
}
interface PendingMemoryRead {
resolve?: (result: MemoryReadResult) => void;
}
/**
* Swappable implementations rather than mock helpers: each test states the one
* behaviour it needs.
@@ -45,7 +49,7 @@ mock.module('@/lib/agentMemoryApi', () => ({
},
}));
const { useAgentMemoryStore } = await import('./useAgentMemoryStore');
const { selectProjectMemoryForPath, useAgentMemoryStore } = await import('./useAgentMemoryStore');
beforeEach(() => {
useAgentMemoryStore.getState().reset();
@@ -86,6 +90,38 @@ describe('load', () => {
expect(state.error).toBe('offline');
});
test("does not expose the previous project's memories under the Chats owner", async () => {
await useAgentMemoryStore.getState().load('/workspace/openchamber');
const pending: PendingMemoryRead = {};
readImpl = () => new Promise((resolve) => {
pending.resolve = resolve;
});
const chatsPath = '/Users/test/.config/openchamber/chats';
const loadingChats = useAgentMemoryStore.getState().load(chatsPath);
const switched = useAgentMemoryStore.getState();
expect(selectProjectMemoryForPath(switched, chatsPath)).toEqual([]);
expect(switched.projectPath).toBe(chatsPath);
pending.resolve?.({ global: [entry({ id: 'g1' })], project: [], globalFailed: false, projectFailed: false });
await loadingChats;
expect(selectProjectMemoryForPath(useAgentMemoryStore.getState(), chatsPath)).toEqual([]);
});
test('a failed load for a new owner stays distinct from an empty project', async () => {
await useAgentMemoryStore.getState().load('/workspace/openchamber');
readImpl = async () => { throw new Error('offline'); };
await useAgentMemoryStore.getState().load('/Users/test/.config/openchamber/chats');
const state = useAgentMemoryStore.getState();
expect(state.project).toEqual([]);
expect(state.projectFailed).toBe(true);
expect(state.error).toBe('offline');
});
test('a disabled feature clears the lists rather than reporting an error', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
readImpl = async () => { throw new AgentMemoryDisabledError(); };
+24 -5
View File
@@ -57,6 +57,14 @@ const EMPTY_STATE = {
error: null as string | null,
};
const EMPTY_MEMORY: AgentMemoryEntry[] = [];
/** Never expose one owner's project entries under another owner's heading. */
export const selectProjectMemoryForPath = (
state: AgentMemoryState,
projectPath: string | null,
): AgentMemoryEntry[] => state.projectPath === projectPath ? state.project : EMPTY_MEMORY;
/**
* Only the newest load may write to the store. Turning the feature back on
* fires a load before the setting has finished being written, so an older
@@ -93,13 +101,20 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
load: async (projectPath) => {
const requestId = ++loadSequence;
set({ loading: true, projectPath });
const previous = get();
const ownerChanged = previous.projectPath !== projectPath;
if (ownerChanged) {
set({ loading: true, projectPath, project: [], projectFailed: false });
} else {
set({ loading: true, projectPath });
}
try {
const snapshot = await fetchAgentMemory(projectPath);
if (requestId !== loadSequence) return;
const current = get();
set({
global: snapshot.global,
project: snapshot.project,
global: snapshot.globalFailed ? current.global : snapshot.global,
project: snapshot.projectFailed ? current.project : snapshot.project,
projectPath,
globalFailed: snapshot.globalFailed,
projectFailed: snapshot.projectFailed,
@@ -119,7 +134,12 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
return;
}
// Whatever was loaded before stays. Only the error is new.
set({ loading: false, error: errorMessage(error, 'Failed to load agent memory') });
set({
loading: false,
globalFailed: true,
projectFailed: true,
error: errorMessage(error, 'Failed to load agent memory'),
});
}
},
@@ -156,4 +176,3 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
set({ ...EMPTY_STATE });
},
}));
+65 -26
View File
@@ -40,6 +40,19 @@ const getCurrentDirectory = (): string | null => {
return null;
};
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the project
* the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
return getConfigDirectory();
};
export const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
@@ -257,19 +270,22 @@ export interface AgentDraft {
interface AgentsStore {
selectedAgentName: string | null;
/** Agents of the project the app is on. Chat and pickers read this one. */
agents: Agent[];
/** Every directory loaded so far, including the ambient one. */
agentsByDirectory: Record<string, Agent[]>;
isLoading: boolean;
agentDraft: AgentDraft | null;
setSelectedAgent: (name: string | null) => void;
setAgentDraft: (draft: AgentDraft | null) => void;
loadAgents: () => Promise<boolean>;
createAgent: (config: AgentConfig) => Promise<AgentMutationResult>;
updateAgent: (name: string, config: Partial<AgentConfig>) => Promise<AgentMutationResult>;
deleteAgent: (name: string, scope?: AgentScope) => Promise<AgentMutationResult>;
getAgentByName: (name: string) => Agent | undefined;
loadAgents: (directory?: string | null) => Promise<boolean>;
createAgent: (config: AgentConfig, directory?: string | null) => Promise<AgentMutationResult>;
updateAgent: (name: string, config: Partial<AgentConfig>, directory?: string | null) => Promise<AgentMutationResult>;
deleteAgent: (name: string, scope?: AgentScope, directory?: string | null) => Promise<AgentMutationResult>;
getAgentByName: (name: string, directory?: string | null) => Agent | undefined;
// Returns only visible agents (excludes hidden internal agents)
getVisibleAgents: () => Agent[];
getVisibleAgents: (directory?: string | null) => Agent[];
}
declare global {
@@ -278,6 +294,20 @@ declare global {
}
}
const EMPTY_AGENTS: Agent[] = [];
/**
* Agents of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectAgentsForDirectory = (
state: Pick<AgentsStore, 'agentsByDirectory'>,
directory?: string | null,
): Agent[] => {
const cacheKey = getAgentsCacheKey(resolveDirectory(directory));
return state.agentsByDirectory[cacheKey] ?? EMPTY_AGENTS;
};
export const useAgentsStore = create<AgentsStore>()(
devtools(
persist(
@@ -285,6 +315,7 @@ export const useAgentsStore = create<AgentsStore>()(
selectedAgentName: null,
agents: [],
agentsByDirectory: {},
isLoading: false,
agentDraft: null,
@@ -296,12 +327,13 @@ export const useAgentsStore = create<AgentsStore>()(
set({ agentDraft: draft });
},
loadAgents: async () => {
const configDirectory = getConfigDirectory();
loadAgents: async (requestedDirectory?: string | null) => {
const configDirectory = resolveDirectory(requestedDirectory);
const cacheKey = getAgentsCacheKey(configDirectory);
const isAmbient = cacheKey === getAgentsCacheKey(getConfigDirectory());
const now = Date.now();
const loadedAt = agentsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedAgents = get().agents.length > 0;
const hasCachedAgents = (get().agentsByDirectory[cacheKey] ?? (isAmbient ? get().agents : [])).length > 0;
if (hasCachedAgents && now - loadedAt < AGENTS_LOAD_CACHE_TTL_MS) {
return true;
@@ -314,7 +346,9 @@ export const useAgentsStore = create<AgentsStore>()(
const request = (async () => {
set({ isLoading: true });
const previousAgents = get().agents;
// Failure must never look like an empty project. The mirror is the
// fallback so a directory loaded before this map existed still counts.
const previousAgents = get().agentsByDirectory[cacheKey] ?? (isAmbient ? get().agents : []);
const previousSignature = buildAgentsSignature(previousAgents);
for (let attempt = 0; attempt < 3; attempt++) {
@@ -372,7 +406,14 @@ export const useAgentsStore = create<AgentsStore>()(
const nextSignature = buildAgentsSignature(agentsWithScope);
if (previousSignature !== nextSignature) {
set({ agents: agentsWithScope, isLoading: false });
set((state) => {
const next: Partial<AgentsStore> = {
agentsByDirectory: { ...state.agentsByDirectory, [cacheKey]: agentsWithScope },
isLoading: false,
};
if (isAmbient) next.agents = agentsWithScope;
return next;
});
} else {
set({ isLoading: false });
}
@@ -395,7 +436,7 @@ export const useAgentsStore = create<AgentsStore>()(
}
},
createAgent: async (config: AgentConfig) => {
createAgent: async (config: AgentConfig, requestedDirectory?: string | null) => {
try {
console.log('[AgentsStore] Creating agent:', config.name);
@@ -415,7 +456,7 @@ export const useAgentsStore = create<AgentsStore>()(
console.log('[AgentsStore] Agent config to save:', agentConfig);
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(requestedDirectory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
@@ -458,7 +499,7 @@ export const useAgentsStore = create<AgentsStore>()(
return { ok: true };
}
const loaded = await get().loadAgents();
const loaded = await get().loadAgents(configDirectory);
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
@@ -471,7 +512,7 @@ export const useAgentsStore = create<AgentsStore>()(
}
},
updateAgent: async (name: string, config: Partial<AgentConfig>) => {
updateAgent: async (name: string, config: Partial<AgentConfig>, requestedDirectory?: string | null) => {
try {
const agentConfig: Record<string, unknown> = {};
@@ -485,7 +526,7 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.permission !== undefined) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(requestedDirectory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
@@ -528,7 +569,7 @@ export const useAgentsStore = create<AgentsStore>()(
return { ok: true };
}
const loaded = await get().loadAgents();
const loaded = await get().loadAgents(configDirectory);
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
@@ -541,9 +582,9 @@ export const useAgentsStore = create<AgentsStore>()(
}
},
deleteAgent: async (name: string, scope?: AgentScope) => {
deleteAgent: async (name: string, scope?: AgentScope, requestedDirectory?: string | null) => {
try {
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(requestedDirectory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
@@ -594,7 +635,7 @@ export const useAgentsStore = create<AgentsStore>()(
return { ok: true };
}
const loaded = await get().loadAgents();
const loaded = await get().loadAgents(configDirectory);
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
@@ -609,14 +650,12 @@ export const useAgentsStore = create<AgentsStore>()(
},
getAgentByName: (name: string) => {
const { agents } = get();
return agents.find((a) => a.name === name);
getAgentByName: (name: string, requestedDirectory?: string | null) => {
return selectAgentsForDirectory(get(), requestedDirectory).find((agent) => agent.name === name);
},
getVisibleAgents: () => {
const { agents } = get();
return filterVisibleAgents(agents);
getVisibleAgents: (requestedDirectory?: string | null) => {
return filterVisibleAgents(selectAgentsForDirectory(get(), requestedDirectory));
},
}),
{
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useBtwStore } from './useBtwStore';
describe('useBtwStore', () => {
beforeEach(() => {
useBtwStore.setState({ byParent: {} });
});
test('starts empty', () => {
expect(useBtwStore.getState().byParent).toEqual({});
});
test('setPanelState merges patches per parent', () => {
useBtwStore.getState().setPanelState('parent-1', { creating: true });
useBtwStore.getState().setPanelState('parent-1', { collapsed: true });
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ creating: true, collapsed: true });
});
test('parents are independent', () => {
useBtwStore.getState().setPanelState('parent-1', { collapsed: true });
useBtwStore.getState().setPanelState('parent-2', { destroying: true });
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ collapsed: true });
expect(useBtwStore.getState().byParent['parent-2']).toEqual({ destroying: true });
});
test('clearPanelState removes only its parent entry', () => {
useBtwStore.getState().setPanelState('parent-1', { collapsed: true });
useBtwStore.getState().setPanelState('parent-2', { collapsed: true });
useBtwStore.getState().clearPanelState('parent-1');
expect(useBtwStore.getState().byParent).toEqual({ 'parent-2': { collapsed: true } });
});
test('clearPanelState on an unknown parent is a no-op', () => {
const before = useBtwStore.getState().byParent;
useBtwStore.getState().clearPanelState('missing');
expect(useBtwStore.getState().byParent).toBe(before);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
/**
* UI-only state for the `/btw` peek panel.
*
* The panel's identity is NOT stored here: it is derived from session
* metadata (`openchamber.btwSessionID` on the parent — see
* `sessionBtwMetadata`), so the panel appears only in the session `/btw` was
* typed into and survives reloads. This store keeps only transient
* per-parent presentation state that has no authoritative home:
*
* - `collapsed`: the panel is minimized to the composer chip; the composer
* talks to the main session again until it is expanded.
* - `creating`: `/btw` is between submit and the parent-metadata link
* landing, so the panel can show its starting state immediately.
* - `destroying`: close was clicked; hides the panel optimistically while the
* unlink/delete round-trip completes.
*/
type BtwPanelUIState = {
collapsed?: boolean;
creating?: boolean;
destroying?: boolean;
};
type BtwStore = {
byParent: Record<string, BtwPanelUIState>;
setPanelState: (parentSessionId: string, patch: BtwPanelUIState) => void;
clearPanelState: (parentSessionId: string) => void;
};
export const useBtwStore = create<BtwStore>()((set) => ({
byParent: {},
setPanelState: (parentSessionId, patch) =>
set((state) => ({
byParent: {
...state.byParent,
[parentSessionId]: { ...state.byParent[parentSessionId], ...patch },
},
})),
clearPanelState: (parentSessionId) =>
set((state) => {
if (!(parentSessionId in state.byParent)) return state;
const byParent = { ...state.byParent };
delete byParent[parentSessionId];
return { byParent };
}),
}));
@@ -66,11 +66,38 @@ describe('useCommandsStore', () => {
useCommandsStore.setState({
selectedCommandName: null,
commands: [],
commandsByDirectory: {},
isLoading: false,
commandDraft: null,
});
});
test('loading another project leaves the active project\'s commands alone', async () => {
// Settings can browse a project the app is not on. Chat reads `commands`,
// so that list must keep describing the active project.
const activeCommands = [{
name: 'active-only',
description: 'Active project command',
template: 'run it',
scope: 'project' as const,
}];
useCommandsStore.setState({
commands: activeCommands,
commandsByDirectory: { [activeProjectPath]: activeCommands },
});
listCommandsWithDetailsImpl = async () => [
{ name: 'other-only', description: 'Other project command', template: 'run there' },
];
const result = await useCommandsStore.getState().loadCommands('/workspace/other');
expect(result).toBe(true);
const state = useCommandsStore.getState();
expect(state.commands).toEqual(activeCommands);
expect(state.commandsByDirectory['/workspace/other']?.map((command) => command.name)).toEqual(['other-only']);
expect(state.commandsByDirectory[activeProjectPath]).toEqual(activeCommands);
});
test('loadCommands preserves previous commands when the command list fails', async () => {
const previousCommands = [{
name: 'existing',
+105 -43
View File
@@ -67,12 +67,16 @@ const buildCommandsSignature = (commands: Command[]): string => {
};
const upsertCommandLocal = (
set: (state: Partial<CommandsStore>) => void,
set: (updater: (state: CommandsStore) => Partial<CommandsStore>) => void,
get: () => CommandsStore,
name: string,
config: Partial<CommandConfig>,
directory: string | null,
) => {
const existing = get().commands.find((command) => command.name === name);
const cacheKey = getCommandsCacheKey(directory);
const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory());
const current = get().commandsByDirectory[cacheKey] ?? [];
const existing = current.find((command) => command.name === name);
const nextCommand: Command = {
...existing,
name,
@@ -81,25 +85,49 @@ const upsertCommandLocal = (
scope: config.scope ?? existing?.scope,
isBuiltIn: existing?.isBuiltIn,
};
const commands = get().commands;
const nextCommands = commands.some((command) => command.name === name)
? commands.map((command) => (command.name === name ? nextCommand : command))
: [...commands, nextCommand];
set({ commands: nextCommands });
const nextCommands = current.some((command) => command.name === name)
? current.map((command) => (command.name === name ? nextCommand : command))
: [...current, nextCommand];
set((state) => {
const next: Partial<CommandsStore> = {
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands },
};
if (isAmbient) next.commands = nextCommands;
return next;
});
};
const removeCommandLocal = (
set: (state: Partial<CommandsStore>) => void,
set: (updater: (state: CommandsStore) => Partial<CommandsStore>) => void,
get: () => CommandsStore,
name: string,
directory: string | null,
) => {
const nextState: Partial<CommandsStore> = {
commands: get().commands.filter((command) => command.name !== name),
};
if (get().selectedCommandName === name) {
nextState.selectedCommandName = null;
const cacheKey = getCommandsCacheKey(directory);
const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory());
const nextCommands = (get().commandsByDirectory[cacheKey] ?? []).filter((command) => command.name !== name);
const clearSelection = get().selectedCommandName === name;
set((state) => {
const next: Partial<CommandsStore> = {
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands },
};
if (isAmbient) next.commands = nextCommands;
if (clearSelection) next.selectedCommandName = null;
return next;
});
};
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the project
* the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
set(nextState);
return getRequestDirectory();
};
const getRequestDirectory = (): string | null => {
@@ -143,17 +171,20 @@ export interface CommandDraft {
interface CommandsStore {
selectedCommandName: string | null;
/** Commands of the project the app is on. Chat and autocompletes read this one. */
commands: Command[];
/** Every directory loaded so far, including the ambient one. */
commandsByDirectory: Record<string, Command[]>;
isLoading: boolean;
commandDraft: CommandDraft | null;
setSelectedCommand: (name: string | null) => void;
setCommandDraft: (draft: CommandDraft | null) => void;
loadCommands: () => Promise<boolean>;
createCommand: (config: CommandConfig) => Promise<boolean>;
updateCommand: (name: string, config: Partial<CommandConfig>) => Promise<boolean>;
deleteCommand: (name: string) => Promise<boolean>;
getCommandByName: (name: string) => Command | undefined;
loadCommands: (directory?: string | null) => Promise<boolean>;
createCommand: (config: CommandConfig, directory?: string | null) => Promise<boolean>;
updateCommand: (name: string, config: Partial<CommandConfig>, directory?: string | null) => Promise<boolean>;
deleteCommand: (name: string, directory?: string | null) => Promise<boolean>;
getCommandByName: (name: string, directory?: string | null) => Command | undefined;
}
declare global {
@@ -162,6 +193,20 @@ declare global {
}
}
const EMPTY_COMMANDS: Command[] = [];
/**
* Commands of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectCommandsForDirectory = (
state: Pick<CommandsStore, 'commandsByDirectory'>,
directory?: string | null,
): Command[] => {
const cacheKey = getCommandsCacheKey(resolveDirectory(directory));
return state.commandsByDirectory[cacheKey] ?? EMPTY_COMMANDS;
};
export const useCommandsStore = create<CommandsStore>()(
devtools(
persist(
@@ -169,6 +214,7 @@ export const useCommandsStore = create<CommandsStore>()(
selectedCommandName: null,
commands: [],
commandsByDirectory: {},
isLoading: false,
commandDraft: null,
@@ -180,12 +226,13 @@ export const useCommandsStore = create<CommandsStore>()(
set({ commandDraft: draft });
},
loadCommands: async () => {
const directory = getRequestDirectory();
loadCommands: async (requestedDirectory?: string | null) => {
const directory = resolveDirectory(requestedDirectory);
const cacheKey = getCommandsCacheKey(directory);
const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory());
const now = Date.now();
const loadedAt = commandsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedCommands = get().commands.length > 0;
const hasCachedCommands = (get().commandsByDirectory[cacheKey] ?? (isAmbient ? get().commands : [])).length > 0;
if (hasCachedCommands && now - loadedAt < COMMANDS_LOAD_CACHE_TTL_MS) {
return true;
@@ -198,7 +245,9 @@ export const useCommandsStore = create<CommandsStore>()(
const request = (async () => {
set({ isLoading: true });
const previousCommands = get().commands;
// Failure must never look like an empty project. The mirror is the
// fallback so a directory loaded before this map existed still counts.
const previousCommands = get().commandsByDirectory[cacheKey] ?? (isAmbient ? get().commands : []);
const previousSignature = buildCommandsSignature(previousCommands);
let lastError: unknown = null;
@@ -255,7 +304,14 @@ export const useCommandsStore = create<CommandsStore>()(
const nextSignature = buildCommandsSignature(commandsWithScope);
if (previousSignature !== nextSignature) {
set({ commands: commandsWithScope, isLoading: false });
set((state) => {
const next: Partial<CommandsStore> = {
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: commandsWithScope },
isLoading: false,
};
if (isAmbient) next.commands = commandsWithScope;
return next;
});
} else {
set({ isLoading: false });
}
@@ -269,7 +325,14 @@ export const useCommandsStore = create<CommandsStore>()(
}
console.error("Failed to load commands:", lastError);
set({ commands: previousCommands, isLoading: false });
set((state) => {
const next: Partial<CommandsStore> = {
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: previousCommands },
isLoading: false,
};
if (isAmbient) next.commands = previousCommands;
return next;
});
return false;
})();
@@ -281,7 +344,7 @@ export const useCommandsStore = create<CommandsStore>()(
}
},
createCommand: async (config: CommandConfig) => {
createCommand: async (config: CommandConfig, requestedDirectory?: string | null) => {
try {
console.log('[CommandsStore] Creating command:', config.name);
@@ -296,7 +359,7 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to save:', commandConfig);
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
@@ -319,12 +382,12 @@ export const useCommandsStore = create<CommandsStore>()(
invalidateCommandsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertCommandLocal(set, get, config.name, config);
upsertCommandLocal(set, get, config.name, config, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'commands', { id: config.name })) {
upsertCommandLocal(set, get, config.name, config);
upsertCommandLocal(set, get, config.name, config, directory);
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
return true;
}
@@ -338,7 +401,7 @@ export const useCommandsStore = create<CommandsStore>()(
return true;
}
const loaded = await get().loadCommands();
const loaded = await get().loadCommands(directory);
if (loaded) {
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
}
@@ -349,7 +412,7 @@ export const useCommandsStore = create<CommandsStore>()(
}
},
updateCommand: async (name: string, config: Partial<CommandConfig>) => {
updateCommand: async (name: string, config: Partial<CommandConfig>, requestedDirectory?: string | null) => {
try {
console.log('[CommandsStore] Updating command:', name);
console.log('[CommandsStore] Config received:', config);
@@ -363,7 +426,7 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to update:', commandConfig);
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
@@ -386,12 +449,12 @@ export const useCommandsStore = create<CommandsStore>()(
invalidateCommandsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertCommandLocal(set, get, name, config);
upsertCommandLocal(set, get, name, config, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) {
upsertCommandLocal(set, get, name, config);
upsertCommandLocal(set, get, name, config, directory);
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
return true;
}
@@ -405,7 +468,7 @@ export const useCommandsStore = create<CommandsStore>()(
return true;
}
const loaded = await get().loadCommands();
const loaded = await get().loadCommands(directory);
if (loaded) {
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
}
@@ -416,10 +479,10 @@ export const useCommandsStore = create<CommandsStore>()(
}
},
deleteCommand: async (name: string) => {
deleteCommand: async (name: string, requestedDirectory?: string | null) => {
try {
// Use active project root for project-level command support
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
@@ -438,12 +501,12 @@ export const useCommandsStore = create<CommandsStore>()(
invalidateCommandsLoadCache(directory);
if (payload?.requiresManualRestart) {
removeCommandLocal(set, get, name);
removeCommandLocal(set, get, name, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) {
removeCommandLocal(set, get, name);
removeCommandLocal(set, get, name, directory);
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
return true;
}
@@ -457,7 +520,7 @@ export const useCommandsStore = create<CommandsStore>()(
return true;
}
const loaded = await get().loadCommands();
const loaded = await get().loadCommands(directory);
if (loaded) {
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
}
@@ -473,9 +536,8 @@ export const useCommandsStore = create<CommandsStore>()(
}
},
getCommandByName: (name: string) => {
const { commands } = get();
return commands.find((c) => c.name === name);
getCommandByName: (name: string, requestedDirectory?: string | null) => {
return selectCommandsForDirectory(get(), requestedDirectory).find((command) => command.name === name);
},
}),
{
@@ -268,6 +268,7 @@ describe('useConfigStore provider persistence', () => {
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
selectedProviderId: '',
currentAgentName: undefined,
agents: [],
@@ -405,6 +406,60 @@ describe('useConfigStore provider persistence', () => {
expect(state.currentVariant).toBe('fast');
});
test('the settings provider selection survives a refresh that no longer lists it', async () => {
// Plugin-registered providers vanish from the list while OpenCode restarts.
// A refresh in that window used to move the user to another provider while
// they were reading or editing the one they picked.
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
selectedProviderId: 'plugin-provider',
directoryScoped: {},
});
liveProviderId = 'live';
await useConfigStore.getState().loadProviders({ source: 'test:missing-selection' });
const state = useConfigStore.getState();
expect(state.providers.map((entry) => entry.id)).toEqual(['live']);
expect(state.selectedProviderId).toBe('plugin-provider');
expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('plugin-provider');
});
test('an empty settings provider selection is filled from the refreshed list', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: '',
currentModelId: '',
selectedProviderId: '',
directoryScoped: {},
});
liveProviderId = 'live';
await useConfigStore.getState().loadProviders({ source: 'test:empty-selection' });
expect(useConfigStore.getState().selectedProviderId).toBe('live');
});
test('changing the chat provider leaves the settings provider selection alone', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('anthropic'), provider('openai')],
currentProviderId: 'anthropic',
currentModelId: 'anthropic-model',
selectedProviderId: 'openai',
directoryScoped: {},
});
useConfigStore.getState().setProvider('anthropic');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('anthropic');
expect(state.selectedProviderId).toBe('openai');
expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('openai');
});
test('provider reload preserves the add-provider sentinel selection', async () => {
// The user has opened the "Add provider" form, which sets selectedProviderId
// to the sentinel. A background provider refresh must not navigate them away
@@ -471,6 +526,60 @@ describe('useConfigStore provider persistence', () => {
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('cycleCurrentVariant reaches Default, low, and medium from inherited high', () => {
useConfigStore.setState({
providers: [provider('openai', 'gpt-5.6-sol', { none: {}, low: {}, medium: {}, high: {}, xhigh: {}, max: {} })],
currentProviderId: 'openai',
currentModelId: 'gpt-5.6-sol',
currentVariant: 'high',
currentVariantSelection: { override: undefined, inherited: 'high' },
directoryScoped: {},
});
const expectedVariants = ['xhigh', 'max', undefined, 'none', 'low', 'medium', 'high'];
for (const expectedVariant of expectedVariants) {
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(expectedVariant);
expect(useConfigStore.getState().currentVariantSelection.override).toBe(expectedVariant ?? null);
}
useConfigStore.getState().setCurrentVariantOverride('max', 'high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('high');
expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'high' });
});
test('cycleCurrentVariant toggles a single variant with Default', () => {
useConfigStore.setState({
providers: [provider('openai', 'single', { high: {} })],
currentProviderId: 'openai',
currentModelId: 'single',
currentVariant: 'high',
currentVariantSelection: { override: null, inherited: 'high' },
directoryScoped: {},
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe('high');
expect(useConfigStore.getState().currentVariantSelection.override).toBe('high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
expect(useConfigStore.getState().currentVariant).toBe('high');
});
test('an unavailable explicit variant cycles back to Default', () => {
useConfigStore.setState({
providers: [provider('openai', 'changed', { low: {}, high: {} })],
currentProviderId: 'openai',
currentModelId: 'changed',
currentVariant: 'removed',
currentVariantSelection: { override: 'removed', inherited: 'low' },
directoryScoped: {},
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('low');
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
});
test('setAgent prefers saved and agent variants before settings default', () => {
const sessionId = 'ses_agent_saved_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
@@ -617,6 +726,80 @@ describe('useConfigStore provider persistence', () => {
expect(getConfigCalls).toBe(0);
});
test('a project default carries its own thinking level', async () => {
// The project pins a model plus the level to run it at. Before, the level
// was dropped and only the global settings variant was ever considered —
// and that one belongs to the global model, not this project's.
const projectProvider = provider('anthropic', 'claude-opus-5', { high: {}, low: {} });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [projectProvider],
agents: [testAgent('build')],
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
settingsDefaultModel: undefined,
settingsDefaultVariant: 'low',
selectionSource: 'auto',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: 'anthropic/claude-opus-5',
projectDefaultVariant: 'high',
});
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('anthropic');
expect(state.currentModelId).toBe('claude-opus-5');
expect(state.currentVariant).toBe('high');
});
test('a fresh session applies the settings thinking level instead of the previous override', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('build')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: 'low',
currentVariantSelection: { override: 'low', inherited: 'high' },
settingsDefaultModel: 'openai/gpt-5.5',
settingsDefaultVariant: 'high',
selectionSource: 'manual',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection();
const state = useConfigStore.getState();
expect(state.currentVariant).toBe('high');
expect(state.currentVariantSelection).toEqual({ override: 'high', inherited: 'high' });
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('a thinking level the project model does not offer is ignored', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('anthropic', 'claude-opus-5')],
agents: [testAgent('build')],
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
settingsDefaultModel: undefined,
settingsDefaultVariant: undefined,
selectionSource: 'auto',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: 'anthropic/claude-opus-5',
projectDefaultVariant: 'high',
});
expect(useConfigStore.getState().currentVariant).toBe(undefined);
});
test('manual selection survives an in-flight loadAgents refresh', async () => {
const pendingAgents = deferred<TestAgent[]>();
listAgentsImpl = async () => pendingAgents.promise;
@@ -931,6 +1114,8 @@ describe('useConfigStore provider persistence', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
selectionSource: 'manual',
currentVariant: 'high',
currentVariantSelection: { override: 'high', inherited: 'medium' },
opencodeDefaultAgent: 'active-default',
opencodeDefaultModel: 'active/model',
directoryScoped: {
@@ -952,6 +1137,7 @@ describe('useConfigStore provider persistence', () => {
agents: [testAgent('other-agent')],
currentProviderId: 'other',
currentModelId: 'other-model',
currentVariant: 'low',
currentAgentName: 'other-agent',
selectedProviderId: 'other',
agentModelSelections: {},
@@ -971,6 +1157,7 @@ describe('useConfigStore provider persistence', () => {
expect(state.selectionSource).toBe('auto');
expect(state.opencodeDefaultAgent).toBe('other-default');
expect(state.opencodeDefaultModel).toBe('other/model');
expect(state.currentVariantSelection).toEqual({ override: undefined, inherited: 'low' });
});
test('sync config without defaults clears stored OpenCode defaults without changing manual selection', () => {
+90 -45
View File
@@ -187,10 +187,6 @@ const sanitizePersistedSelectedProviderId = (providerId: string | undefined): st
providerId === ADD_PROVIDER_SENTINEL ? "" : (providerId ?? "")
);
const preserveAddProviderSelection = (currentSelectedProviderId: string | undefined, nextProviderId: string): string => (
currentSelectedProviderId === ADD_PROVIDER_SENTINEL ? ADD_PROVIDER_SENTINEL : nextProviderId
);
const normalizeOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
@@ -295,6 +291,7 @@ const resolveDefaultAgentModelSelection = ({
agents,
providers,
projectDefaultModel,
projectDefaultVariant,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
@@ -304,6 +301,7 @@ const resolveDefaultAgentModelSelection = ({
agents: Agent[];
providers: ProviderWithModelList[];
projectDefaultModel?: string;
projectDefaultVariant?: string;
settingsDefaultAgent?: string;
settingsDefaultModel?: string;
settingsDefaultVariant?: string;
@@ -359,7 +357,9 @@ const resolveDefaultAgentModelSelection = ({
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
providerId = parsed.providerId;
modelId = parsed.modelId;
variant = resolveVariant(providerId, modelId, projectDefaultModel ? undefined : settingsDefaultVariant);
// A project default carries its own variant; the settings variant
// belongs to the settings model and must not leak onto it.
variant = resolveVariant(providerId, modelId, projectDefaultModel ? projectDefaultVariant : settingsDefaultVariant);
}
}
@@ -885,6 +885,11 @@ interface DirectoryScopedConfig {
selectionSource?: "auto" | "manual";
}
type CurrentVariantSelection = {
override: string | null | undefined;
inherited: string | undefined;
};
/**
* Lift the active directory's cached provider/agent snapshot into the top-level
* fields the pickers read (`providers`, `agents`, selections), so a cold start
@@ -1006,6 +1011,7 @@ interface ConfigStore {
currentProviderId: string;
currentModelId: string;
currentVariant: string | undefined;
currentVariantSelection: CurrentVariantSelection;
currentAgentName: string | undefined;
selectedProviderId: string;
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
@@ -1098,10 +1104,11 @@ interface ConfigStore {
setProvider: (providerId: string) => void;
setModel: (modelId: string) => void;
setCurrentVariant: (variant: string | undefined) => void;
cycleCurrentVariant: () => void;
setCurrentVariantOverride: (override: string | null | undefined, inherited: string | undefined) => void;
cycleCurrentVariant: () => string | undefined;
getCurrentModelVariants: () => string[];
setAgent: (agentName: string | undefined) => void;
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string }) => void;
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string; projectDefaultVariant?: string }) => void;
applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void;
setSelectedProvider: (providerId: string) => void;
setSettingsDefaultModel: (model: string | undefined) => void;
@@ -1138,6 +1145,26 @@ const _inFlightProviders = new Map<string, Promise<void>>();
const _inFlightAgents = new Map<string, Promise<boolean>>();
let _initializeAppInFlight: Promise<void> | null = null;
/**
* Providers of one project. Returns a stored array, so components can select it
* directly and re-render only when that project's list is replaced.
*
* Settings pages browse a project the app is not on; everything else wants the
* active one, which is what an omitted directory resolves to.
*/
export const selectProvidersForDirectory = (
state: Pick<ConfigStore, "providers" | "directoryScoped" | "activeDirectoryKey">,
directory?: string | null,
): ProviderWithModelList[] => {
const directoryKey = toConfigDirectoryKey(directory);
if (directoryKey === state.activeDirectoryKey) {
return state.providers;
}
return state.directoryScoped[directoryKey]?.providers ?? EMPTY_PROVIDERS;
};
const EMPTY_PROVIDERS: ProviderWithModelList[] = [];
export const useConfigStore = create<ConfigStore>()(
devtools(
persist(
@@ -1151,6 +1178,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: "",
currentModelId: "",
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
@@ -1417,6 +1445,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: snapshot.currentProviderId,
currentModelId: snapshot.currentModelId,
currentVariant: snapshot.currentVariant,
currentVariantSelection: { override: undefined, inherited: snapshot.currentVariant },
currentAgentName: snapshot.currentAgentName,
selectedProviderId: snapshot.selectedProviderId,
agentModelSelections: snapshot.agentModelSelections,
@@ -1433,6 +1462,7 @@ export const useConfigStore = create<ConfigStore>()(
agents: [],
currentProviderId: "",
currentModelId: "",
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
@@ -1609,10 +1639,13 @@ export const useConfigStore = create<ConfigStore>()(
const currentSelectedProviderId = state.activeDirectoryKey === directoryKey
? state.selectedProviderId
: baseSnapshot.selectedProviderId;
// Preserve the add-provider sentinel so a background refresh does not
// navigate the user out of the in-progress add-provider form (issue #1765).
const selectedProviderId = currentSelectedProviderId === ADD_PROVIDER_SENTINEL
|| processedProviders.some((provider) => provider.id === currentSelectedProviderId)
// The Providers settings selection belongs to the user, not to this
// loader. A refresh may report a different provider set — an OpenCode
// restart drops plugin-registered providers until they re-register —
// and re-deriving a selection here yanked the open provider away
// mid-edit. Keep whatever is selected; only fill in an empty one.
// The add-provider sentinel is kept for the same reason (issue #1765).
const selectedProviderId = currentSelectedProviderId
? currentSelectedProviderId
: (resolvedModel?.providerId ?? processedProviders[0]?.id ?? "");
@@ -1723,12 +1756,17 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = parsed.providerId;
nextState.currentModelId = parsed.modelId;
nextState.currentVariant = currentVariant;
nextState.selectedProviderId = parsed.providerId;
nextSnapshot.currentProviderId = parsed.providerId;
nextSnapshot.currentModelId = parsed.modelId;
nextSnapshot.currentVariant = currentVariant;
nextSnapshot.selectedProviderId = parsed.providerId;
// Only adopt this as the settings selection when the user has
// none; a failed refresh must not move an existing one.
if (!state.selectedProviderId) {
nextState.selectedProviderId = parsed.providerId;
nextSnapshot.selectedProviderId = parsed.providerId;
}
}
}
}
@@ -1771,14 +1809,12 @@ export const useConfigStore = create<ConfigStore>()(
...baseSnapshot,
currentProviderId: providerId,
currentModelId: newModelId,
selectedProviderId: providerId,
selectionSource: "manual",
};
return {
currentProviderId: providerId,
currentModelId: newModelId,
selectedProviderId: providerId,
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
@@ -1821,13 +1857,22 @@ export const useConfigStore = create<ConfigStore>()(
},
setCurrentVariant: (variant: string | undefined) => {
get().setCurrentVariantOverride(undefined, variant);
},
setCurrentVariantOverride: (override, inherited) => {
set((state) => {
if (state.currentVariant === variant) {
const currentVariant = override ?? inherited;
if (
state.currentVariant === currentVariant
&& state.currentVariantSelection.override === override
&& state.currentVariantSelection.inherited === inherited
) {
return state;
}
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
const baseSnapshot = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
@@ -1839,18 +1884,17 @@ export const useConfigStore = create<ConfigStore>()(
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentVariant: variant,
selectionSource: "manual",
};
return {
currentVariant: variant,
currentVariant,
currentVariantSelection: { override, inherited },
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
[directoryKey]: {
...baseSnapshot,
currentVariant,
selectionSource: "manual",
},
},
};
});
@@ -1868,22 +1912,26 @@ export const useConfigStore = create<ConfigStore>()(
cycleCurrentVariant: () => {
const variantKeys = get().getCurrentModelVariants();
if (variantKeys.length === 0) {
return;
return undefined;
}
const current = get().currentVariant;
if (!current) {
get().setCurrentVariant(variantKeys[0]);
return;
const state = get();
const currentOverride = state.currentVariantSelection.override;
const inheritedVariant = state.currentVariantSelection.inherited ?? state.currentVariant;
const currentVariant = currentOverride === undefined
? state.currentVariant
: currentOverride;
let nextOverride: string | null;
if (currentVariant === null || currentVariant === undefined) {
nextOverride = variantKeys[0];
} else {
const index = variantKeys.indexOf(currentVariant);
nextOverride = index >= 0 ? (variantKeys[index + 1] ?? null) : null;
}
const index = variantKeys.indexOf(current);
if (index === -1 || index === variantKeys.length - 1) {
get().setCurrentVariant(undefined);
return;
}
get().setCurrentVariant(variantKeys[index + 1]);
get().setCurrentVariantOverride(nextOverride, inheritedVariant);
return nextOverride ?? undefined;
},
setSelectedProvider: (providerId: string) => {
@@ -2456,7 +2504,6 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: providerId,
currentModelId: modelId,
currentVariant: variant,
selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId),
selectionSource: "manual",
};
@@ -2464,7 +2511,6 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: providerId,
currentModelId: modelId,
currentVariant: variant,
selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId),
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
@@ -2583,6 +2629,7 @@ export const useConfigStore = create<ConfigStore>()(
agents,
providers,
projectDefaultModel: options?.projectDefaultModel,
projectDefaultVariant: options?.projectDefaultVariant,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
@@ -2616,7 +2663,6 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: resolvedProviderId,
currentModelId: resolvedModelId,
currentVariant: resolvedVariant,
selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId),
}
: {}),
selectionSource: "auto",
@@ -2635,7 +2681,10 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
nextState.currentVariant = resolvedVariant;
nextState.selectedProviderId = preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId);
nextState.currentVariantSelection = {
override: resolvedVariant,
inherited: resolvedVariant,
};
}
return nextState;
@@ -2717,7 +2766,6 @@ export const useConfigStore = create<ConfigStore>()(
const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId;
const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId;
const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant;
const currentSelectedProviderId = isActive ? state.selectedProviderId : baseSnapshot.selectedProviderId;
const nextSelection = resolveSelectionWithManualGuard({
agents,
providers,
@@ -2742,7 +2790,6 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: nextSelection.providerId,
currentModelId: nextSelection.modelId,
currentVariant: nextSelection.variant,
selectedProviderId: preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId),
}
: {}),
selectionSource: nextSelection.selectionSource,
@@ -2761,7 +2808,6 @@ export const useConfigStore = create<ConfigStore>()(
state.currentProviderId !== nextSelection.providerId
|| state.currentModelId !== nextSelection.modelId
|| state.currentVariant !== nextSelection.variant
|| state.selectedProviderId !== preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId)
))
));
@@ -2781,7 +2827,6 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = nextSelection.providerId;
nextState.currentModelId = nextSelection.modelId;
nextState.currentVariant = nextSelection.variant;
nextState.selectedProviderId = preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId);
}
}
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
let runtimeKey = "runtime-a"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { gitBaseBranchEntryKey, useGitBaseBranchStore } = await import("./useGitBaseBranchStore")
describe("git base branch overrides", () => {
beforeEach(() => {
runtimeKey = "runtime-a"
useGitBaseBranchStore.setState({ overrides: {} })
})
test("keys the same repository per branch and runtime", () => {
const featureA = gitBaseBranchEntryKey("/repo", "feature-a")
const featureB = gitBaseBranchEntryKey("/repo", "feature-b")
runtimeKey = "runtime-b"
const featureARemote = gitBaseBranchEntryKey("/repo", "feature-a")
expect(new Set([featureA, featureB, featureARemote]).size).toBe(3)
})
test("a base picked for one branch does not apply to another branch", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("/repo", "feature-a", "main")
expect(store.getOverride("/repo", "feature-a")).toBe("main")
// feature-b must fall back to its own detection, not feature-a's choice.
expect(store.getOverride("/repo", "feature-b")).toBeNull()
})
test("different branches of one repository keep independent bases", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("/repo", "feature-a", "main")
store.setOverride("/repo", "feature-b", "develop")
expect(store.getOverride("/repo", "feature-a")).toBe("main")
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
})
test("clearOverride removes only the targeted branch's choice", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("/repo", "feature-a", "main")
store.setOverride("/repo", "feature-b", "develop")
store.clearOverride("/repo", "feature-a")
expect(store.getOverride("/repo", "feature-a")).toBeNull()
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
})
test("rejects empty directory, branch, or base", () => {
const store = useGitBaseBranchStore.getState()
store.setOverride("", "feature-a", "main")
store.setOverride("/repo", "", "main")
store.setOverride("/repo", "feature-a", "")
store.clearOverride("", "feature-a")
expect(useGitBaseBranchStore.getState().overrides).toEqual({})
expect(store.getOverride("", "feature-a")).toBeNull()
expect(store.getOverride("/repo", "")).toBeNull()
})
})
@@ -0,0 +1,71 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
const GIT_BASE_BRANCH_STORAGE_KEY = 'openchamber.git-base-branch';
const MAX_BASE_BRANCH_ENTRIES = 100;
/**
* Build the persisted override key for one branch of one repository.
*
* The branch is part of the identity on purpose: a base picked for one feature
* branch is not an answer for a different branch of the same repository, and a
* directory-only key would silently shadow reflog detection after checkout.
* Keys include the runtime identity so a remote runtime's paths never shadow
* local ones.
*/
export const gitBaseBranchEntryKey = (directory: string, branch: string): string =>
JSON.stringify([getRuntimeKey(), directory, branch]);
type GitBaseBranchState = {
overrides: Record<string, string>;
getOverride: (directory: string, branch: string) => string | null;
setOverride: (directory: string, branch: string, base: string) => void;
clearOverride: (directory: string, branch: string) => void;
};
/**
* Explicit per-branch base choices for the "Branch" diff scope.
*
* Git does not record a parent branch for every branch (clones, detached
* starts). When no authoritative source exists, the user picks a base once and
* the choice is remembered for that branch.
*/
export const useGitBaseBranchStore = create<GitBaseBranchState>()(
persist(
(set, get) => ({
overrides: {},
getOverride: (directory, branch) => {
if (!directory || !branch) return null;
return get().overrides[gitBaseBranchEntryKey(directory, branch)] ?? null;
},
setOverride: (directory, branch, base) => {
if (!directory || !branch || !base) return;
set((state) => {
const key = gitBaseBranchEntryKey(directory, branch);
const entries = Object.entries({ ...state.overrides, [key]: base });
while (entries.length > MAX_BASE_BRANCH_ENTRIES) {
entries.shift();
}
return { overrides: Object.fromEntries(entries) };
});
},
clearOverride: (directory, branch) => {
if (!directory || !branch) return;
set((state) => {
const key = gitBaseBranchEntryKey(directory, branch);
if (!(key in state.overrides)) return state;
const next = { ...state.overrides };
delete next[key];
return { overrides: next };
});
},
}),
{
name: GIT_BASE_BRANCH_STORAGE_KEY,
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ overrides: state.overrides }),
}
)
);
@@ -4,7 +4,7 @@ import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
let runtimeKey = "runtime-a"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
const { getFreshestPrStatusForBranch, getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
const deferred = <T>() => {
let resolve!: (value: T) => void
@@ -38,6 +38,34 @@ describe("GitHub PR status cache ownership", () => {
expect(new Set([originA, upstreamA, originB]).size).toBe(3)
})
test("passive branch readers follow the freshest remote-keyed status", () => {
const automatic = getGitHubPrStatusKey("/repo", "feature")
const origin = getGitHubPrStatusKey("/repo", "feature", "origin")
useGitHubPrStatusStore.getState().ensureEntry(automatic)
useGitHubPrStatusStore.getState().ensureEntry(origin)
useGitHubPrStatusStore.getState().updateStatus(automatic, () => ({
connected: true,
pr: { number: 7, title: "old", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
checks: { state: "pending", total: 3, success: 1, failure: 0, pending: 2 },
}))
useGitHubPrStatusStore.getState().updateStatus(origin, () => ({
connected: true,
pr: { number: 7, title: "current", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
checks: { state: "success", total: 3, success: 3, failure: 0, pending: 0 },
}))
useGitHubPrStatusStore.setState((state) => ({
entries: {
...state.entries,
[automatic]: { ...state.entries[automatic], lastRefreshAt: 1 },
[origin]: { ...state.entries[origin], lastRefreshAt: 2 },
},
}))
const freshest = getFreshestPrStatusForBranch(useGitHubPrStatusStore.getState().entries, "/repo", "feature")
expect(freshest?.pr?.title).toBe("current")
expect(freshest?.checks?.pending).toBe(0)
})
test("rejects a response after params change", async () => {
const request = deferred<GitHubPullRequestStatus>()
const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"]
@@ -238,11 +238,11 @@ const findResolvedSiblingEntry = (
* instead of a single key: the entry being actively watched/refreshed may be
* keyed by a concrete remote while the 'auto' entry goes stale.
*/
export const getFreshestPrStatusForBranch = (
const getFreshestPrEntryForBranch = (
entries: Record<string, PrStatusEntry>,
directory: string,
branch: string,
): GitHubPullRequestStatus | null => {
): PrStatusEntry | null => {
const runtimeKey = getRuntimeKey();
let best: PrStatusEntry | null = null;
for (const [key, entry] of Object.entries(entries)) {
@@ -260,7 +260,15 @@ export const getFreshestPrStatusForBranch = (
best = entry;
}
}
return best?.status ?? null;
return best;
};
export const getFreshestPrStatusForBranch = (
entries: Record<string, PrStatusEntry>,
directory: string,
branch: string,
): GitHubPullRequestStatus | null => {
return getFreshestPrEntryForBranch(entries, directory, branch)?.status ?? null;
};
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
@@ -951,23 +959,39 @@ const summarySignature = (s: PrVisualSummary): string =>
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
const getCachedPrSummary = (cacheKey: string, entry: PrStatusEntry | null | undefined): PrVisualSummary | null => {
const summary = entry ? deriveSummary(entry) : null;
if (!summary) {
prSummaryCacheByKey.delete(cacheKey);
return null;
}
const sig = summarySignature(summary);
const cached = prSummaryCacheByKey.get(cacheKey);
if (cached?.sig === sig) return cached.summary;
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
const oldestKey = prSummaryCacheByKey.keys().next().value;
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
}
prSummaryCacheByKey.set(cacheKey, { sig, summary });
return summary;
};
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
return useGitHubPrStatusStore((state) => {
if (!key) return null;
const entry = state.entries[key];
const summary = entry ? deriveSummary(entry) : null;
if (!summary) {
prSummaryCacheByKey.delete(key);
return null;
}
const sig = summarySignature(summary);
const cached = prSummaryCacheByKey.get(key);
if (cached && cached.sig === sig) return cached.summary;
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
const oldestKey = prSummaryCacheByKey.keys().next().value;
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
}
prSummaryCacheByKey.set(key, { sig, summary });
return summary;
return getCachedPrSummary(key, state.entries[key]);
});
};
export const useFreshestPrVisualSummaryForBranch = (
directory: string | null,
branch: string | null,
): PrVisualSummary | null => {
const cacheKey = directory && branch ? JSON.stringify(['branch', getRuntimeKey(), directory, branch]) : null;
return useGitHubPrStatusStore((state) => {
if (!directory || !branch || !cacheKey) return null;
return getCachedPrSummary(cacheKey, getFreshestPrEntryForBranch(state.entries, directory, branch));
});
};
@@ -27,6 +27,13 @@ describe('useGlobalSessionsStore', () => {
activeSessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
entityById: new Map(),
structure: {
activeSessionIds: [],
activeRootIds: [],
activeChildrenByParentId: new Map(),
activeIdsByDirectory: new Map(),
},
hasLoaded: false,
status: 'idle',
});
@@ -114,11 +121,13 @@ describe('useGlobalSessionsStore', () => {
expect(useGlobalSessionsStore.getState().archivedSessions).toBe(archivedSessions);
const activeSessions = useGlobalSessionsStore.getState().activeSessions;
const structure = useGlobalSessionsStore.getState().structure;
useGlobalSessionsStore.getState().upsertSession({
...archived,
time: { created: 1, updated: 4, archived: 3 },
});
expect(useGlobalSessionsStore.getState().activeSessions).toBe(activeSessions);
expect(useGlobalSessionsStore.getState().structure).toBe(structure);
});
test('applies a batch of session upserts in one store publication', () => {
@@ -136,6 +145,90 @@ describe('useGlobalSessionsStore', () => {
expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['ses_2', 'ses_1']);
expect(publications).toBe(1);
});
test('indexes a large batch of subagents in one store publication', () => {
const parent = buildSession('https://share.example/parent', { id: 'ses_parent' });
const children = Array.from({ length: 1_000 }, (_, index) => buildSession(
`https://share.example/child-${index}`,
{ id: `ses_child_${index}`, parentID: parent.id },
));
let publications = 0;
const unsubscribe = useGlobalSessionsStore.subscribe(() => {
publications += 1;
});
useGlobalSessionsStore.getState().upsertSessions([parent, ...children]);
unsubscribe();
const state = useGlobalSessionsStore.getState();
expect(publications).toBe(1);
expect(state.structure.activeRootIds).toEqual([parent.id]);
expect(state.structure.activeChildrenByParentId.get(parent.id)?.length).toBe(1_000);
});
test('preserves hierarchy references for entity-only updates', () => {
const parent = buildSession('https://share.example/parent', { id: 'ses_parent', directory: '/repo' });
const child = buildSession('https://share.example/child', {
id: 'ses_child',
directory: '/repo',
parentID: parent.id,
});
useGlobalSessionsStore.getState().upsertSessions([parent, child]);
const previous = useGlobalSessionsStore.getState();
const previousChildren = previous.structure.activeChildrenByParentId.get(parent.id);
useGlobalSessionsStore.getState().upsertSession({
...child,
title: 'Renamed child',
time: { ...child.time, updated: 3 },
});
const next = useGlobalSessionsStore.getState();
expect(next.structure).toBe(previous.structure);
expect(next.structure.activeChildrenByParentId.get(parent.id)).toBe(previousChildren);
expect(next.entityById.get(child.id)?.title).toBe('Renamed child');
});
test('updates only affected hierarchy buckets when a session is reparented', () => {
const parentA = buildSession('https://share.example/a', { id: 'ses_parent_a' });
const parentB = buildSession('https://share.example/b', { id: 'ses_parent_b' });
const parentC = buildSession('https://share.example/c', { id: 'ses_parent_c' });
const child = buildSession('https://share.example/child', { id: 'ses_child', parentID: parentA.id });
const unrelatedChild = buildSession('https://share.example/other', { id: 'ses_other', parentID: parentC.id });
useGlobalSessionsStore.getState().upsertSessions([parentA, parentB, parentC, child, unrelatedChild]);
const previous = useGlobalSessionsStore.getState().structure;
const unrelatedBucket = previous.activeChildrenByParentId.get(parentC.id);
useGlobalSessionsStore.getState().upsertSession({ ...child, parentID: parentB.id });
const next = useGlobalSessionsStore.getState().structure;
expect(next).not.toBe(previous);
expect(next.activeChildrenByParentId.get(parentA.id)).toBe(undefined);
expect([...next.activeChildrenByParentId.get(parentB.id) ?? []]).toEqual([child.id]);
expect(next.activeChildrenByParentId.get(parentC.id)).toBe(unrelatedBucket);
});
test('applies ordered mixed mutations in one publication', () => {
const original = buildSession('https://share.example/original', { id: 'ses_original' });
useGlobalSessionsStore.getState().upsertSession(original);
let publications = 0;
const unsubscribe = useGlobalSessionsStore.subscribe(() => {
publications += 1;
});
useGlobalSessionsStore.getState().applySessionMutations([
{ type: 'upsert', session: buildSession('https://share.example/temporary', { id: 'ses_temporary' }) },
{ type: 'remove', sessionId: original.id },
{ type: 'remove', sessionId: 'ses_temporary' },
{ type: 'upsert', session: buildSession('https://share.example/final', { id: 'ses_final' }) },
]);
unsubscribe();
const state = useGlobalSessionsStore.getState();
expect(publications).toBe(1);
expect(state.activeSessions.map((session) => session.id)).toEqual(['ses_final']);
expect(state.structure.activeRootIds).toEqual(['ses_final']);
});
});
describe('mergeLiveSessionWithGlobalSession', () => {
+253 -134
View File
@@ -1,12 +1,25 @@
import { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization';
import { raiseSessionOrderingBaselines } from '@/sync/session-ordering';
import { mapWithConcurrency } from '@/lib/concurrency';
import { persistManagedChatSessions, readManagedChatSessions } from '@/sync/persist-cache';
import { isVSCodeRuntime } from '@/lib/desktop';
import { countSyncPerformance } from '@/sync/performance-diagnostics';
import {
applyGlobalSessionStructureMutations,
buildGlobalSessionStructure,
mergeSessionDirectoryMetadata,
resolveGlobalSessionDirectory,
type GlobalSessionStructure,
type GlobalSessionStructureMutation,
} from './globalSessionStructure';
export { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory } from './globalSessionStructure';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -15,9 +28,15 @@ type LoadResult = {
archivedSessions: Session[];
};
export type GlobalSessionMutation =
| { type: 'upsert'; session: Session }
| { type: 'remove'; sessionId: string };
type GlobalSessionsState = {
activeSessions: Session[];
archivedSessions: Session[];
entityById: ReadonlyMap<string, Session>;
structure: GlobalSessionStructure;
sessionsByDirectory: Map<string, Session[]>;
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
mutationRevision: number;
@@ -27,6 +46,7 @@ type GlobalSessionsState = {
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
refreshSessionsForDirectories: (directories: Iterable<string>, fallbackActive?: Session[]) => Promise<LoadResult>;
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
applySessionMutations: (mutations: readonly GlobalSessionMutation[]) => void;
upsertSession: (session: Session) => void;
upsertSessions: (sessions: Session[]) => void;
removeSessions: (ids: Iterable<string>) => void;
@@ -61,60 +81,6 @@ let inflightLoad: Promise<LoadResult> | null = null;
// not apply its (stale) snapshot after the reset.
let loadGeneration = 0;
export const resolveGlobalSessionDirectory = (session: Session): string | null => {
const record = session as Session & {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalizePath(record.directory ?? null)
?? normalizePath(record.project?.worktree ?? null);
};
export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Session | null): Session => {
if (!existing) {
return incoming;
}
const incomingRecord = incoming as Session & {
directory?: string | null;
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
};
const existingRecord = existing as Session & {
directory?: string | null;
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
};
const incomingDirectory = normalizePath(incomingRecord.directory ?? null);
const incomingWorktree = normalizePath(incomingRecord.project?.worktree ?? null);
const existingDirectory = normalizePath(existingRecord.directory ?? null);
const existingWorktree = normalizePath(existingRecord.project?.worktree ?? null);
let changed = false;
const next: typeof incomingRecord = { ...incomingRecord };
// Some live session updates omit stable raw directory metadata; keep the
// cached value so project grouping does not temporarily lose the session.
if (!incomingDirectory && existingDirectory) {
next.directory = existingRecord.directory;
changed = true;
}
if (!incomingWorktree && existingWorktree) {
next.project = {
...(existingRecord.project ?? {}),
...(incomingRecord.project ?? {}),
worktree: existingRecord.project?.worktree,
};
changed = true;
} else if (!incomingRecord.project && existingRecord.project) {
next.project = existingRecord.project;
changed = true;
}
return changed ? next : incoming;
};
export const mergeLiveSessionWithGlobalSession = (
liveSession: Session,
globalSession: Session,
@@ -144,9 +110,12 @@ const buildSessionsByDirectory = (sessions: Session[]): Map<string, Session[]> =
};
const getSessionSignature = (session: Session): string => {
const record = session as Session & { parentID?: string | null; slug?: string | null };
return [
session.id,
session.title ?? '',
record.parentID ?? '',
record.slug ?? '',
session.time?.created ?? 0,
session.time?.updated ?? 0,
session.time?.archived ?? 0,
@@ -156,7 +125,7 @@ const getSessionSignature = (session: Session): string => {
].join(':');
};
export const getSessionStructuralSignature = (session: Session): string => {
const getSessionStructuralSignature = (session: Session): string => {
const record = session as Session & { parentID?: string | null; slug?: string | null };
return [
session.id,
@@ -309,14 +278,6 @@ const upsertSessionIntoList = (sessions: Session[], session: Session): Session[]
return next;
};
const removeSessionFromList = (sessions: Session[], sessionId: string): Session[] => {
const index = sessions.findIndex((session) => session.id === sessionId);
if (index === -1) {
return sessions;
}
return [...sessions.slice(0, index), ...sessions.slice(index + 1)];
};
const mergeSessionLists = (existing: Session[], incoming?: Session[]): Session[] => {
if (!incoming || incoming.length === 0) {
return existing;
@@ -363,12 +324,24 @@ const applySnapshot = (
archivedSessions: Session[],
status: GlobalSessionsStatus,
): Partial<GlobalSessionsState> | GlobalSessionsState => {
if (isVSCodeRuntime()) {
activeSessions = filterManagedChatsForRuntime(activeSessions, true);
archivedSessions = filterManagedChatsForRuntime(archivedSessions, true);
}
const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions)
? state.activeSessions
: activeSessions;
const nextArchivedSessions = sameSessionList(state.archivedSessions, archivedSessions)
? state.archivedSessions
: archivedSessions;
const sessionsChanged = nextActiveSessions !== state.activeSessions
|| nextArchivedSessions !== state.archivedSessions;
const nextEntityById = sessionsChanged
? new Map([...nextActiveSessions, ...nextArchivedSessions].map((session) => [session.id, session]))
: state.entityById;
const nextStructure = nextActiveSessions !== state.activeSessions
? buildGlobalSessionStructure(nextActiveSessions)
: state.structure;
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions);
@@ -390,6 +363,8 @@ const applySnapshot = (
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
entityById: nextEntityById,
structure: nextStructure,
sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextReviewTransferMap,
hasLoaded: true,
@@ -429,38 +404,166 @@ const mutationRevisionPatch = (state: GlobalSessionsState, ids: Iterable<string>
return { mutationRevision, mutationRevisionBySessionId };
};
const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial<GlobalSessionsState> => {
const revisionPatch = mutationRevisionPatch(state, sessions.map((session) => session.id));
let nextActiveSessions = state.activeSessions;
let nextArchivedSessions = state.archivedSessions;
const materializeChangedSessionList = (
previous: readonly Session[],
memberIds: ReadonlySet<string>,
additions: ReadonlySet<string>,
entityById: ReadonlyMap<string, Session>,
): Session[] => {
const additionsInDisplayOrder = [...additions].reverse();
const addedIds = new Set(additionsInDisplayOrder);
const next = additionsInDisplayOrder.flatMap((sessionId) => {
const session = entityById.get(sessionId);
return session && memberIds.has(sessionId) ? [session] : [];
});
for (const previousSession of previous) {
if (!memberIds.has(previousSession.id) || addedIds.has(previousSession.id)) continue;
const session = entityById.get(previousSession.id);
if (session) next.push(session);
}
return next;
};
for (const session of sessions) {
const existingSession = nextActiveSessions.find((candidate) => candidate.id === session.id)
?? nextArchivedSessions.find((candidate) => candidate.id === session.id)
?? null;
const sessionWithMetadata = mergeSessionDirectoryMetadata(session, existingSession);
const updateSessionsByDirectory = (
previous: Map<string, Session[]>,
previousStructure: GlobalSessionStructure,
nextStructure: GlobalSessionStructure,
entityById: ReadonlyMap<string, Session>,
mutations: readonly GlobalSessionStructureMutation[],
): Map<string, Session[]> => {
const affectedDirectories = new Set<string>();
const entityChangedDirectories = new Set<string>();
for (const mutation of mutations) {
const previousDirectory = mutation.previous && !mutation.previous.time?.archived
? resolveGlobalSessionDirectory(mutation.previous)
: null;
const nextDirectory = mutation.next && !mutation.next.time?.archived
? resolveGlobalSessionDirectory(mutation.next)
: null;
if (previousDirectory) affectedDirectories.add(previousDirectory);
if (nextDirectory) {
affectedDirectories.add(nextDirectory);
entityChangedDirectories.add(nextDirectory);
}
}
if (affectedDirectories.size === 0) return previous;
let next: Map<string, Session[]> | null = null;
for (const directory of affectedDirectories) {
const previousIds = previousStructure.activeIdsByDirectory.get(directory);
const nextIds = nextStructure.activeIdsByDirectory.get(directory);
if (previousIds === nextIds && !entityChangedDirectories.has(directory)) continue;
next ??= new Map(previous);
if (!nextIds || nextIds.length === 0) {
next.delete(directory);
continue;
}
next.set(directory, nextIds.flatMap((sessionId) => {
const session = entityById.get(sessionId);
return session ? [session] : [];
}));
}
return next ?? previous;
};
const applySessionMutations = (
state: GlobalSessionsState,
requestedMutations: readonly GlobalSessionMutation[],
): Partial<GlobalSessionsState> => {
let mutations = requestedMutations;
if (isVSCodeRuntime()) {
mutations = requestedMutations.filter((mutation) => (
mutation.type === 'remove'
|| filterManagedChatsForRuntime([mutation.session], true).length > 0
));
if (mutations.length === 0) return state;
}
const revisionPatch = mutationRevisionPatch(state, mutations.map((mutation) => (
mutation.type === 'upsert' ? mutation.session.id : mutation.sessionId
)));
let nextEntityById: Map<string, Session> | null = null;
const activeIds = new Set(state.activeSessions.map((session) => session.id));
const archivedIds = new Set(state.archivedSessions.map((session) => session.id));
const activeAdditions = new Set<string>();
const archivedAdditions = new Set<string>();
const structureMutations: GlobalSessionStructureMutation[] = [];
let activeChanged = false;
let archivedChanged = false;
const addMember = (ids: Set<string>, additions: Set<string>, sessionId: string): void => {
if (ids.has(sessionId)) return;
ids.add(sessionId);
additions.delete(sessionId);
additions.add(sessionId);
};
const removeMember = (ids: Set<string>, additions: Set<string>, sessionId: string): void => {
ids.delete(sessionId);
additions.delete(sessionId);
};
for (const mutation of mutations) {
const sessionId = mutation.type === 'upsert' ? mutation.session.id : mutation.sessionId;
const existingSession = (nextEntityById ?? state.entityById).get(sessionId) ?? null;
if (mutation.type === 'remove') {
if (!existingSession) continue;
nextEntityById ??= new Map(state.entityById);
nextEntityById.delete(sessionId);
structureMutations.push({ sessionId, previous: existingSession, next: null });
if (existingSession.time?.archived) {
archivedChanged = true;
removeMember(archivedIds, archivedAdditions, sessionId);
} else {
activeChanged = true;
removeMember(activeIds, activeAdditions, sessionId);
}
continue;
}
const sessionWithMetadata = mergeSessionDirectoryMetadata(mutation.session, existingSession);
if (existingSession && getSessionSignature(existingSession) === getSessionSignature(sessionWithMetadata)) continue;
nextEntityById ??= new Map(state.entityById);
nextEntityById.set(sessionId, sessionWithMetadata);
structureMutations.push({ sessionId, previous: existingSession, next: sessionWithMetadata });
const isArchived = Boolean(sessionWithMetadata.time?.archived);
nextActiveSessions = isArchived
? removeSessionFromList(nextActiveSessions, session.id)
: upsertSessionIntoList(nextActiveSessions, sessionWithMetadata);
nextArchivedSessions = isArchived
? upsertSessionIntoList(nextArchivedSessions, sessionWithMetadata)
: removeSessionFromList(nextArchivedSessions, session.id);
const wasArchived = Boolean(existingSession?.time?.archived);
if (existingSession) {
if (wasArchived) archivedChanged = true;
else activeChanged = true;
}
if (isArchived) {
archivedChanged = true;
removeMember(activeIds, activeAdditions, sessionId);
addMember(archivedIds, archivedAdditions, sessionId);
} else {
activeChanged = true;
removeMember(archivedIds, archivedAdditions, sessionId);
addMember(activeIds, activeAdditions, sessionId);
}
}
if (
nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions
) {
if (!nextEntityById) {
return revisionPatch;
}
const nextActiveSessions = activeChanged
? materializeChangedSessionList(state.activeSessions, activeIds, activeAdditions, nextEntityById)
: state.activeSessions;
const nextArchivedSessions = archivedChanged
? materializeChangedSessionList(state.archivedSessions, archivedIds, archivedAdditions, nextEntityById)
: state.archivedSessions;
const nextStructure = applyGlobalSessionStructureMutations(state.structure, structureMutations);
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions),
entityById: nextEntityById,
structure: nextStructure,
sessionsByDirectory: updateSessionsByDirectory(
state.sessionsByDirectory,
state.structure,
nextStructure,
nextEntityById,
structureMutations,
),
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions),
@@ -483,11 +586,17 @@ const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransfer
return next
}
const initialManagedChatSessions = readManagedChatSessions();
const initialEntityById = new Map(initialManagedChatSessions.map((session) => [session.id, session]));
const initialStructure = buildGlobalSessionStructure(initialManagedChatSessions);
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
activeSessions: [],
activeSessions: initialManagedChatSessions,
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
entityById: initialEntityById,
structure: initialStructure,
sessionsByDirectory: buildSessionsByDirectory(initialManagedChatSessions),
reviewTransferBySessionId: buildReviewTransferMap(initialManagedChatSessions),
mutationRevision: 0,
mutationRevisionBySessionId: new Map(),
hasLoaded: false,
@@ -501,14 +610,23 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
},
applySessionMutations: (mutations) => {
if (mutations.length === 0) return;
set((state) => applySessionMutations(state, mutations));
},
resetForRuntimeSwitch: () => {
loadGeneration += 1;
inflightLoad = null;
const managedChatSessions = readManagedChatSessions();
const entityById = new Map(managedChatSessions.map((session) => [session.id, session]));
set({
activeSessions: [],
activeSessions: managedChatSessions,
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
entityById,
structure: buildGlobalSessionStructure(managedChatSessions),
sessionsByDirectory: buildSessionsByDirectory(managedChatSessions),
reviewTransferBySessionId: buildReviewTransferMap(managedChatSessions),
mutationRevision: 0,
mutationRevisionBySessionId: new Map(),
hasLoaded: false,
@@ -549,6 +667,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
});
const committed = get();
raiseSessionOrderingBaselines(committed.activeSessions);
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
} catch (error) {
if (generation !== loadGeneration) {
@@ -601,6 +720,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
}
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
const refreshedActiveIds = active.map((session) => session.id);
set((state) => {
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
@@ -621,10 +741,12 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions);
const activeChanged = nextActiveSessions !== state.activeSessions;
const archivedChanged = nextArchivedSessions !== state.archivedSessions;
if (
nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions
!activeChanged
&& !archivedChanged
&& nextSessionsByDirectory === state.sessionsByDirectory
) {
return state;
@@ -633,6 +755,8 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
entityById: new Map([...nextActiveSessions, ...nextArchivedSessions].map((session) => [session.id, session])),
structure: activeChanged ? buildGlobalSessionStructure(nextActiveSessions) : state.structure,
sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
@@ -641,16 +765,23 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
});
const state = get();
raiseSessionOrderingBaselines(refreshedActiveIds.flatMap((sessionId) => {
const session = state.entityById.get(sessionId);
return session && !session.time?.archived ? [session] : [];
}));
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
},
upsertSession: (session) => {
set((state) => applySessionUpserts(state, [session]));
set((state) => applySessionMutations(state, [{ type: 'upsert', session }]));
},
upsertSessions: (sessions) => {
if (sessions.length === 0) return;
set((state) => applySessionUpserts(state, sessions));
set((state) => applySessionMutations(
state,
sessions.map((session) => ({ type: 'upsert' as const, session })),
));
},
removeSessions: (ids) => {
@@ -659,26 +790,10 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
return;
}
set((state) => {
const revisionPatch = mutationRevisionPatch(state, idSet);
const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id));
const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
if (
nextActiveSessions.length === state.activeSessions.length
&& nextArchivedSessions.length === state.archivedSessions.length
) {
return revisionPatch;
}
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
...revisionPatch,
};
});
set((state) => applySessionMutations(
state,
[...idSet].map((sessionId) => ({ type: 'remove' as const, sessionId })),
));
},
archiveSessions: (ids, archivedAt = Date.now()) => {
@@ -688,13 +803,10 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
}
set((state) => {
const revisionPatch = mutationRevisionPatch(state, idSet);
const movedSessions: Session[] = [];
const nextActiveSessions = state.activeSessions.filter((session) => {
if (!idSet.has(session.id)) {
return true;
}
for (const sessionId of idSet) {
const session = state.entityById.get(sessionId);
if (!session || session.time?.archived) continue;
movedSessions.push({
...session,
time: {
@@ -702,26 +814,33 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
archived: archivedAt,
},
});
return false;
});
if (movedSessions.length === 0) {
return revisionPatch;
}
const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
if (movedSessions.length === 0) {
return mutationRevisionPatch(state, idSet);
}
const patch = applySessionMutations(
state,
movedSessions.map((session) => ({ type: 'upsert' as const, session })),
);
return {
activeSessions: nextActiveSessions,
archivedSessions: [...movedSessions, ...remainingArchivedSessions],
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
...revisionPatch,
...patch,
...mutationRevisionPatch(state, idSet),
};
});
},
}));
useGlobalSessionsStore.subscribe((state, previous) => {
countSyncPerformance('globalSessionPublications');
if (
state.activeSessions !== previous.activeSessions
&& (state.status !== 'idle' || state.activeSessions.length > 0)
) {
persistManagedChatSessions(state.activeSessions);
}
});
export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise<LoadResult> => {
const state = useGlobalSessionsStore.getState();
if (state.hasLoaded && state.status !== 'error') {
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
import { migratePersistedDrafts, persistedDraftEnvelopeSchema, useInlineCommentDraftStore } from './useInlineCommentDraftStore';
const selection = {
source: 'terminal' as const,
@@ -7,7 +7,8 @@ const selection = {
startLine: 4,
endLine: 5,
code: 'first\nsecond',
language: 'term-1',
language: '',
terminalId: 'term-1',
text: '',
};
const target = { directory: '/repo', sessionKey: 'session-1' };
@@ -79,3 +80,64 @@ describe('terminal context drafts', () => {
}
});
});
describe('persisted draft migration', () => {
type PersistedV2Draft = {
id: string;
sessionKey: string;
source: string;
fileLabel: string;
startLine: number;
endLine: number;
code: string;
language: string;
text: string;
createdAt: number;
};
const v2Draft = (overrides: Partial<PersistedV2Draft> = {}): PersistedV2Draft => ({
id: 'icd-1',
sessionKey: 'session-1',
source: 'diff',
fileLabel: 'src/app.ts',
startLine: 3,
endLine: 5,
code: 'const x = 1;',
language: 'ts',
text: 'fix this',
createdAt: 1000,
...overrides,
});
test('moves the terminal id out of the language field', () => {
const migrated = migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({
drafts: { key: [v2Draft({ source: 'terminal', language: 'term-9' })] },
touchedAt: { key: 1000 },
}), 2);
expect(migrated.drafts.key[0].terminalId).toBe('term-9');
expect(migrated.drafts.key[0].language).toBe('');
expect(migrated.touchedAt.key).toBe(1000);
});
test('drops preview-console drafts but keeps the rest of the bucket', () => {
const migrated = migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({
drafts: { key: [v2Draft({ source: 'preview-console' }), v2Draft({ id: 'icd-2' })] },
touchedAt: { key: 1000 },
}), 2);
expect(migrated.drafts.key.map((draft) => draft.id)).toEqual(['icd-2']);
});
test('malformed entries and unknown payload shapes reset safely', () => {
expect(migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse(null), 2)).toEqual({ drafts: {}, touchedAt: {} });
expect(migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({ drafts: 'nope' }), 2)).toEqual({ drafts: {}, touchedAt: {} });
const migrated = migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({
drafts: { key: [{ id: 42 }, v2Draft()] },
touchedAt: {},
}), 2);
expect(migrated.drafts.key).toHaveLength(1);
});
test('pre-v2 payloads reset entirely', () => {
expect(migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({ drafts: { key: [v2Draft()] }, touchedAt: {} }), 1))
.toEqual({ drafts: {}, touchedAt: {} });
});
});
@@ -1,10 +1,11 @@
import { create } from 'zustand';
import { z } from 'zod';
import { devtools, persist } from 'zustand/middleware';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { normalizePath } from '@/lib/pathNormalization';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check' | 'chat-quote' | 'file-quote';
export type InlineCommentDraftTarget = {
directory: string;
@@ -22,6 +23,8 @@ export interface InlineCommentDraft {
code: string;
language: string;
text: string;
/** Owning terminal session; set only for `source: 'terminal'`. */
terminalId?: string;
createdAt: number;
}
@@ -162,6 +165,63 @@ const boundState = (
return { drafts: retainedDrafts, touchedAt: retainedTouchedAt };
};
const EMPTY_PERSISTED_STATE: InlineCommentDraftState = { drafts: {}, touchedAt: {} };
const persistedDraftSchema = z.object({
id: z.string(),
sessionKey: z.string(),
source: z.enum(['diff', 'plan', 'file', 'preview-annotation', 'terminal', 'pr-comment', 'pr-check', 'chat-quote', 'file-quote']),
fileLabel: z.string(),
startLine: z.number(),
endLine: z.number(),
side: z.enum(['original', 'modified']).optional(),
code: z.string(),
language: z.string(),
text: z.string(),
terminalId: z.string().optional(),
createdAt: z.number(),
});
export const persistedDraftEnvelopeSchema = z.object({
drafts: z.record(z.string(), z.array(z.unknown())),
touchedAt: z.record(z.string(), z.number()).optional(),
});
type PersistedDraftEnvelopeResult = z.ZodSafeParseResult<z.infer<typeof persistedDraftEnvelopeSchema>>;
/**
* v2 v3: terminal drafts carried their terminal id in `language`; move it to
* the dedicated `terminalId` field. Drafts from the removed 'preview-console'
* source are dropped, as are malformed entries. Pre-v2 or unreadable payloads
* reset entirely (the pre-v3 behavior).
*/
export const migratePersistedDrafts = (envelope: PersistedDraftEnvelopeResult, version: number): InlineCommentDraftState => {
if (version < 2) return EMPTY_PERSISTED_STATE;
if (!envelope.success) return EMPTY_PERSISTED_STATE;
const drafts: Record<string, InlineCommentDraft[]> = {};
for (const [key, bucket] of Object.entries(envelope.data.drafts)) {
const migrated: InlineCommentDraft[] = [];
for (const entry of bucket) {
const parsed = persistedDraftSchema.safeParse(entry);
if (!parsed.success) continue;
const draft = parsed.data;
if (draft.source === 'terminal' && !draft.terminalId) {
migrated.push({ ...draft, terminalId: draft.language, language: '' });
} else {
migrated.push(draft);
}
}
if (migrated.length > 0) drafts[key] = migrated;
}
const touchedAt: Record<string, number> = {};
for (const [key, value] of Object.entries(envelope.data.touchedAt ?? {})) {
if (key in drafts) touchedAt[key] = value;
}
return { drafts, touchedAt };
};
const removeDraftKey = (state: InlineCommentDraftState, key: string): InlineCommentDraftState => {
if (!(key in state.drafts)) return state;
@@ -281,9 +341,9 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
{
name: 'openchamber-inline-comment-drafts',
storage: createDeferredSafeJSONStorage(),
version: 2,
version: 3,
partialize: (state) => ({ drafts: state.drafts, touchedAt: state.touchedAt }),
migrate: () => ({ drafts: {}, touchedAt: {} }),
migrate: (persisted, version) => migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse(persisted), version),
},
),
{ name: 'inline-comment-draft-store' },
+71 -27
View File
@@ -19,6 +19,19 @@ type McpMutationResult = {
restartDeferred?: boolean;
};
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the
* project the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
return getConfigDirectory();
};
const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
@@ -115,29 +128,48 @@ const getMcpCacheKey = (directory: string | null): string => {
// ============== STORE ==============
interface McpConfigStore {
/** Servers of the project the app is on. Chat and mobile read this one. */
mcpServers: McpServerWithScope[];
/** Every directory loaded so far, including the ambient one. */
serversByDirectory: Record<string, McpServerWithScope[]>;
selectedMcpName: string | null;
isLoading: boolean;
mcpDraft: McpDraft | null;
setSelectedMcp: (name: string | null) => void;
setMcpDraft: (draft: McpDraft | null) => void;
loadMcpConfigs: (options?: { force?: boolean }) => Promise<boolean>;
createMcp: (config: McpDraft) => Promise<McpMutationResult>;
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<McpMutationResult>;
deleteMcp: (name: string) => Promise<McpMutationResult>;
getMcpByName: (name: string) => McpServerWithScope | undefined;
loadMcpConfigs: (options?: { force?: boolean; directory?: string | null }) => Promise<boolean>;
createMcp: (config: McpDraft, directory?: string | null) => Promise<McpMutationResult>;
updateMcp: (name: string, config: Partial<McpDraft>, directory?: string | null) => Promise<McpMutationResult>;
deleteMcp: (name: string, directory?: string | null) => Promise<McpMutationResult>;
getMcpByName: (name: string, directory?: string | null) => McpServerWithScope | undefined;
getMcpServersForDirectory: (directory?: string | null) => McpServerWithScope[];
}
const invalidateMcpCache = (directory: string | null) => {
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
};
const EMPTY_MCP_SERVERS: McpServerWithScope[] = [];
/**
* Servers of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectMcpServersForDirectory = (
state: Pick<McpConfigStore, 'serversByDirectory'>,
directory?: string | null,
): McpServerWithScope[] => {
const cacheKey = getMcpCacheKey(resolveDirectory(directory));
return state.serversByDirectory[cacheKey] ?? EMPTY_MCP_SERVERS;
};
export const useMcpConfigStore = create<McpConfigStore>()(
devtools(
persist(
(set, get) => ({
mcpServers: [],
serversByDirectory: {},
selectedMcpName: null,
isLoading: false,
mcpDraft: null,
@@ -147,11 +179,12 @@ export const useMcpConfigStore = create<McpConfigStore>()(
setMcpDraft: (draft) => set({ mcpDraft: draft }),
loadMcpConfigs: async (options) => {
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(options?.directory);
const cacheKey = getMcpCacheKey(configDirectory);
const isAmbient = cacheKey === getMcpCacheKey(getConfigDirectory());
const now = Date.now();
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedConfigs = get().mcpServers.length > 0;
const hasCachedConfigs = (get().serversByDirectory[cacheKey] ?? (isAmbient ? get().mcpServers : [])).length > 0;
if (!options?.force && hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
return true;
@@ -173,7 +206,14 @@ export const useMcpConfigStore = create<McpConfigStore>()(
throw new Error('Failed to load MCP configs');
}
const data: McpServerWithScope[] = await response.json();
set({ mcpServers: data, isLoading: false });
set((state) => {
const next: Partial<McpConfigStore> = {
serversByDirectory: { ...state.serversByDirectory, [cacheKey]: data },
isLoading: false,
};
if (isAmbient) next.mcpServers = data;
return next;
});
mcpLastLoadedAt.set(cacheKey, Date.now());
return true;
} catch (error) {
@@ -191,10 +231,10 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
createMcp: async (config: McpDraft) => {
createMcp: async (config: McpDraft, directory?: string | null) => {
try {
const body = buildMcpBody(config);
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
@@ -213,7 +253,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
invalidateMcpCache(configDirectory);
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
@@ -224,7 +264,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: config.name })) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
@@ -241,7 +281,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -250,7 +290,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
};
}
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -263,10 +303,10 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
updateMcp: async (name: string, config: Partial<McpDraft>) => {
updateMcp: async (name: string, config: Partial<McpDraft>, directory?: string | null) => {
try {
const body = buildMcpBody(config);
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
@@ -285,7 +325,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
invalidateMcpCache(configDirectory);
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
@@ -296,7 +336,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
@@ -313,7 +353,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -322,7 +362,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
};
}
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -335,9 +375,9 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
deleteMcp: async (name: string) => {
deleteMcp: async (name: string, directory?: string | null) => {
try {
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE',
@@ -356,7 +396,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
@@ -367,7 +407,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
@@ -386,7 +426,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
});
}
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -399,8 +439,12 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
getMcpByName: (name: string) => {
return get().mcpServers.find((s) => s.name === name);
getMcpByName: (name: string, directory?: string | null) => {
return get().getMcpServersForDirectory(directory).find((s) => s.name === name);
},
getMcpServersForDirectory: (directory?: string | null) => {
return selectMcpServersForDirectory(get(), directory);
},
}),
{
@@ -226,4 +226,39 @@ describe('useMultiRunStore', () => {
'createSession:/repo-worktrees/fix-thing',
]);
});
test('accepts more than 5 models per group without a "maximum 5 models" error', async () => {
const models = Array.from({ length: 6 }, (_, i) => ({
providerID: 'anthropic',
modelID: `claude-sonnet-4-5-${i}`,
}));
const result = await useMultiRunStore.getState().createMultiRun({
name: 'Many models',
isolateRuns: false,
groups: [{ prompt: 'Fix it', models }],
});
expect(useMultiRunStore.getState().error).toBeNull();
expect(result?.sessionIds).toHaveLength(6);
});
test('accepts more than 5 models on the isolated (per-worktree) dispatch path', async () => {
isGitRepository = true;
const models = Array.from({ length: 6 }, (_, i) => ({
providerID: 'anthropic',
modelID: `claude-sonnet-4-5-${i}`,
}));
const result = await useMultiRunStore.getState().createMultiRun({
name: 'Many models',
isolateRuns: true,
groups: [{ prompt: 'Fix it', models }],
});
expect(useMultiRunStore.getState().error).toBeNull();
expect(result?.sessionIds).toHaveLength(6);
expect(worktreeCreateCalls.length).toBe(6);
});
});
@@ -138,10 +138,6 @@ export const useMultiRunStore = create<MultiRunStore>()(
set({ error: `Group ${gi + 1}: select at least 1 model` });
return null;
}
if (groups[gi].models.length > 5) {
set({ error: `Group ${gi + 1}: maximum 5 models allowed` });
return null;
}
}
set({ isLoading: true, error: null });
+3 -3
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand';
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type InstalledDesktopAppInfo } from '@/lib/desktop';
import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps';
import { updateDesktopSettings } from '@/lib/persistence';
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
export type OpenInAppOption = OpenInApp & {
iconDataUrl?: string;
@@ -160,7 +160,7 @@ export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
void loadInstalledApps();
const settingsHandler = (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings;
const nextId = detail
&& typeof detail.openInAppId === 'string'
&& detail.openInAppId.length > 0
@@ -18,4 +18,104 @@ describe("useProjectsStore settings synchronization", () => {
expect(useProjectsStore.getState().activeProjectId).toBe(null)
expect(useProjectsStore.getState().manualProjectOrder).toEqual([])
})
test("a reconcile sync never adopts another window's active project", () => {
// Ids are path-derived inside the store's sanitizer, so seed real ones by
// bootstrapping once and reading them back.
const raw = { projects: [{ path: "/repo-a" }, { path: "/repo-b" }] } as DesktopSettings
useProjectsStore.getState().synchronizeFromSettings(raw)
const [first, second] = useProjectsStore.getState().projects
useProjectsStore.setState({ activeProjectId: first.id })
// The shared settings document carries window B's pointer; outside a
// bootstrap this window keeps its own.
useProjectsStore.getState().synchronizeFromSettings(
{ ...raw, activeProjectId: second.id } as DesktopSettings,
{ adoptActiveProject: false },
)
expect(useProjectsStore.getState().activeProjectId).toBe(first.id)
// Unless its own project vanished from the list — then the incoming
// pointer is better than a dangling one.
useProjectsStore.getState().synchronizeFromSettings(
{ projects: [{ path: "/repo-b" }], activeProjectId: second.id } as DesktopSettings,
{ adoptActiveProject: false },
)
expect(useProjectsStore.getState().activeProjectId).toBe(second.id)
// A bootstrap sync adopts as before.
useProjectsStore.getState().synchronizeFromSettings(raw)
useProjectsStore.setState({ activeProjectId: first.id })
useProjectsStore.getState().synchronizeFromSettings(
{ ...raw, activeProjectId: second.id } as DesktopSettings,
)
expect(useProjectsStore.getState().activeProjectId).toBe(second.id)
})
})
describe("useProjectsStore selection identity", () => {
test("changes only the active project id", () => {
const first = { id: "project-a", path: "/repo-a", lastOpenedAt: 10 } as ProjectEntry
const second = { id: "project-b", path: "/repo-b", lastOpenedAt: 20 } as ProjectEntry
const projects = [first, second]
useProjectsStore.setState({
projects,
activeProjectId: first.id,
manualProjectOrder: projects.map((project) => project.id),
})
useProjectsStore.getState().setActiveProjectIdOnly(second.id)
const state = useProjectsStore.getState()
expect(state.activeProjectId).toBe(second.id)
expect(state.projects).toBe(projects)
expect(state.projects.map((project) => project.lastOpenedAt)).toEqual([10, 20])
})
})
describe("useProjectsStore default model and thinking level", () => {
const seed = (project: ProjectEntry) => {
useProjectsStore.setState({
projects: [project],
activeProjectId: project.id,
manualProjectOrder: [project.id],
})
}
test("keeps a thinking level next to the model it belongs to", () => {
seed({ id: "project-a", path: "/repo" } as ProjectEntry)
useProjectsStore.getState().updateProjectMeta("project-a", {
defaultModel: "anthropic/claude-opus-5",
defaultVariant: "high",
})
const project = useProjectsStore.getState().projects[0]
expect(project?.defaultModel).toBe("anthropic/claude-opus-5")
expect(project?.defaultVariant).toBe("high")
})
test("drops the thinking level when the model is cleared", () => {
seed({
id: "project-a",
path: "/repo",
defaultModel: "anthropic/claude-opus-5",
defaultVariant: "high",
} as ProjectEntry)
useProjectsStore.getState().updateProjectMeta("project-a", { defaultModel: null })
const project = useProjectsStore.getState().projects[0]
expect(project?.defaultModel).toBe(undefined)
expect(project?.defaultVariant).toBe(undefined)
})
test("ignores a thinking level that arrives without a model", () => {
useProjectsStore.getState().synchronizeFromSettings({
projects: [{ id: "project-a", path: "/repo", defaultVariant: "high" }],
} as DesktopSettings)
const project = useProjectsStore.getState().projects[0]
expect(project?.defaultVariant).toBe(undefined)
})
})
+62 -31
View File
@@ -4,7 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
import { createProjectIdFromPath } from '@/lib/projectId';
import { getDeferredSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
@@ -60,6 +60,7 @@ interface ProjectsStore {
color?: string | null;
iconBackground?: string | null;
defaultModel?: string | null;
defaultVariant?: string | null;
}) => void;
uploadProjectIcon: (id: string, file: File) => Promise<{ ok: boolean; error?: string }>;
removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>;
@@ -67,7 +68,7 @@ interface ProjectsStore {
reorderProjects: (fromIndex: number, toIndex: number) => void;
resetForRuntimeSwitch: () => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => void;
syncVSCodeWorkspaceFolders: (folders: VSCodeWorkspaceFolderConfig[], activePath?: string | null) => ProjectEntry | null;
getActiveProject: () => ProjectEntry | null;
}
@@ -298,6 +299,10 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
const defaultModel = normalizeDefaultModel(candidate.defaultModel);
if (defaultModel) {
project.defaultModel = defaultModel;
// A variant only means something next to the model it belongs to.
if (typeof candidate.defaultVariant === 'string' && candidate.defaultVariant.trim().length > 0) {
project.defaultVariant = candidate.defaultVariant.trim();
}
}
if (candidate.iconBackground === null) {
project.iconBackground = null;
@@ -359,13 +364,7 @@ const readPersistedActiveProjectId = (): string | null => {
return null;
};
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
try {
safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects));
} catch {
// ignored
}
const cacheActiveProjectId = (activeProjectId: string | null) => {
try {
const activeProjectStorageKey = getActiveProjectStorageKey();
if (activeProjectId) {
@@ -378,6 +377,15 @@ const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null)
}
};
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
try {
safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects));
} catch {
// ignored
}
cacheActiveProjectId(activeProjectId);
};
const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null, manualOrder?: string[]) => {
cacheProjects(projects, activeProjectId);
if (manualOrder) {
@@ -528,6 +536,7 @@ const vscodeWorkspaceProjectsEqual = (left: ProjectEntry[], right: ProjectEntry[
&& leftProject.color === rightProject.color
&& leftProject.iconBackground === rightProject.iconBackground
&& leftProject.defaultModel === rightProject.defaultModel
&& leftProject.defaultVariant === rightProject.defaultVariant
&& leftProject.addedAt === rightProject.addedAt
&& leftProject.lastOpenedAt === rightProject.lastOpenedAt
&& leftProject.sidebarCollapsed === rightProject.sidebarCollapsed
@@ -715,18 +724,13 @@ export const useProjectsStore = create<ProjectsStore>()(
if (activeProjectId === id) {
return;
}
const target = projects.find((project) => project.id === id);
if (!target) {
if (!projects.some((project) => project.id === id)) {
return;
}
const now = Date.now();
const nextProjects = projects.map((project) =>
project.id === id ? { ...project, lastOpenedAt: now } : project
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id, get().manualProjectOrder);
set({ activeProjectId: id });
cacheActiveProjectId(id);
void updateDesktopSettings({ activeProjectId: id });
},
renameProject: (id: string, label: string) => {
@@ -752,6 +756,7 @@ export const useProjectsStore = create<ProjectsStore>()(
color?: string | null;
iconBackground?: string | null;
defaultModel?: string | null;
defaultVariant?: string | null;
}) => {
if (isVSCodeProjectsRuntime) {
return;
@@ -777,6 +782,19 @@ export const useProjectsStore = create<ProjectsStore>()(
delete updated.defaultModel;
}
}
if (meta.defaultVariant !== undefined) {
const trimmed = meta.defaultVariant?.trim();
if (trimmed) {
updated.defaultVariant = trimmed;
} else {
delete updated.defaultVariant;
}
}
// A variant without its model is meaningless, and the model may have
// just been cleared in this same update.
if (!updated.defaultModel) {
delete updated.defaultVariant;
}
return updated;
});
set({ projects: nextProjects });
@@ -819,7 +837,7 @@ export const useProjectsStore = create<ProjectsStore>()(
const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null;
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return { ok: true };
} catch (error) {
@@ -848,7 +866,7 @@ export const useProjectsStore = create<ProjectsStore>()(
const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null;
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return { ok: true };
} catch (error) {
@@ -884,7 +902,7 @@ export const useProjectsStore = create<ProjectsStore>()(
}
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return {
@@ -934,32 +952,43 @@ export const useProjectsStore = create<ProjectsStore>()(
set({ projects, activeProjectId: nextActiveProjectId, manualProjectOrder: [] });
},
synchronizeFromSettings: (settings: DesktopSettings) => {
synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => {
if (isVSCodeProjectsRuntime) {
return;
}
const adoptActiveProject = options?.adoptActiveProject !== false;
const incomingProjects = sanitizeProjects(settings.projects ?? []);
const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim()
? settings.activeProjectId.trim()
: null;
const current = get();
const incomingIds = new Set(incomingProjects.map((p) => p.id));
// The settings document is shared by every window on this server, so
// outside a bootstrap sync the incoming active pointer is just another
// window's choice — the project LIST still reconciles, but this
// window's active project stays its own while it remains valid.
const nextActive = adoptActiveProject
? incomingActive
: (current.activeProjectId && incomingIds.has(current.activeProjectId)
? current.activeProjectId
: incomingActive);
const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
const activeChanged = current.activeProjectId !== incomingActive;
const activeChanged = current.activeProjectId !== nextActive;
if (!projectsChanged && !activeChanged) {
return;
}
const incomingIds = new Set(incomingProjects.map((p) => p.id));
const cleanedOrder = get().manualProjectOrder.filter((id) => incomingIds.has(id));
set({ projects: incomingProjects, activeProjectId: incomingActive, manualProjectOrder: cleanedOrder });
cacheProjects(incomingProjects, incomingActive);
set({ projects: incomingProjects, activeProjectId: nextActive, manualProjectOrder: cleanedOrder });
cacheProjects(incomingProjects, nextActive);
persistManualProjectOrder(cleanedOrder);
if (incomingActive) {
const activeProject = incomingProjects.find((project) => project.id === incomingActive);
if (activeChanged && nextActive) {
const activeProject = incomingProjects.find((project) => project.id === nextActive);
if (activeProject) {
opencodeClient.setDirectory(activeProject.path);
useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false });
@@ -1015,9 +1044,11 @@ export const useProjectsStore = create<ProjectsStore>()(
if (typeof window !== 'undefined') {
window.addEventListener('openchamber:settings-synced', (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
if (detail && typeof detail === 'object') {
useProjectsStore.getState().synchronizeFromSettings(detail);
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
if (detail && typeof detail === 'object' && detail.settings) {
useProjectsStore.getState().synchronizeFromSettings(detail.settings, {
adoptActiveProject: detail.adoptWorkspace,
});
}
});
}
@@ -32,3 +32,26 @@ describe('useSessionDisplayStore project sorting', () => {
expect(migrated.showArchivedSessions).toBe(true);
});
});
describe('useSessionDisplayStore project display', () => {
test('defaults to showing all projects without a selected single project', () => {
expect(useSessionDisplayStore.getState().projectDisplayMode).toBe('all');
expect(useSessionDisplayStore.getState().singleProjectId).toBeNull();
});
test('stores the single-project mode independently from session grouping', () => {
useSessionDisplayStore.getState().setProjectDisplayMode('single');
useSessionDisplayStore.getState().setSingleProjectId('project-alpha');
useSessionDisplayStore.getState().setSessionGroupingMode('flat');
expect(useSessionDisplayStore.getState().projectDisplayMode).toBe('single');
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha');
expect(useSessionDisplayStore.getState().sessionGroupingMode).toBe('flat');
useSessionDisplayStore.setState({
projectDisplayMode: 'all',
singleProjectId: null,
sessionGroupingMode: 'by-worktree',
});
});
});
@@ -6,8 +6,13 @@ type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
// 'by-worktree' keeps per-worktree sub-headers inside each project zone
// (parallel-work overview); 'flat' merges everything into one recency list.
type SessionGroupingMode = 'by-worktree' | 'flat';
type ProjectDisplayMode = 'all' | 'single';
type SessionDisplayStore = {
projectDisplayMode: ProjectDisplayMode;
singleProjectId: string | null;
setProjectDisplayMode: (mode: ProjectDisplayMode) => void;
setSingleProjectId: (projectId: string) => void;
sessionGroupingMode: SessionGroupingMode;
setSessionGroupingMode: (mode: SessionGroupingMode) => void;
/** Project/recent zone headers stick to the top while their zone scrolls. */
@@ -50,6 +55,10 @@ export const migrateSessionDisplayState = (
export const useSessionDisplayStore = create<SessionDisplayStore>()(
persist(
(set) => ({
projectDisplayMode: 'all',
singleProjectId: null,
setProjectDisplayMode: (mode) => set({ projectDisplayMode: mode }),
setSingleProjectId: (projectId) => set({ singleProjectId: projectId }),
sessionGroupingMode: 'by-worktree',
setSessionGroupingMode: (mode) => set({ sessionGroupingMode: mode }),
stickyZoneHeaders: true,
@@ -68,13 +77,14 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
}),
{
name: 'session-display-mode',
version: 4,
version: 5,
// v1→v2 adds projectSortOrder using the canonical manual ordering.
// v2→v3 replaces the previously shipped recent default with manual.
// v3→v4 removes displayMode (single sidebar row layout).
// v4→v5 adds the independent all-projects/single-project view mode.
migrate: migrateSessionDisplayState,
},
),
);
export type { ProjectSortOrder };
export type { ProjectDisplayMode, ProjectSortOrder };
@@ -84,6 +84,19 @@ describe('useSessionFoldersStore folder assignments', () => {
expect(storageSetCount).toBe(0);
});
test('bulk cross-scope move clears the former folder membership before assigning the target', () => {
const store = useSessionFoldersStore.getState();
const source = store.createFolder('/workspace/project', 'Source');
const target = store.createFolder('/workspace/project-worktree', 'Target');
store.addSessionsToFolder('/workspace/project', source.id, ['ses_1', 'ses_2']);
store.removeSessionsFromFolders('/workspace/project', ['ses_1', 'ses_2']);
store.addSessionsToFolder('/workspace/project-worktree', target.id, ['ses_1', 'ses_2']);
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project')[0]?.sessionIds).toEqual([]);
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project-worktree')[0]?.sessionIds).toEqual(['ses_1', 'ses_2']);
});
test('restores independent folder snapshots across runtime switches', async () => {
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Runtime A');
await waitForPersist();
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useSessionTabsStore } from './useSessionTabsStore';
describe('useSessionTabsStore', () => {
beforeEach(() => {
useSessionTabsStore.setState({ tabIds: [] });
});
test('ensureTab appends once and preserves order', () => {
const store = useSessionTabsStore.getState();
store.ensureTab('a');
store.ensureTab('b');
store.ensureTab('a');
expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'b']);
});
test('closeTab removes only the given id; closeOtherTabs keeps only it', () => {
useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] });
useSessionTabsStore.getState().closeTab('b');
expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'c']);
useSessionTabsStore.getState().closeOtherTabs('c');
expect(useSessionTabsStore.getState().tabIds).toEqual(['c']);
});
test('reorderTabs moves by id and ignores unknown ids', () => {
useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] });
useSessionTabsStore.getState().reorderTabs('c', 'a');
expect(useSessionTabsStore.getState().tabIds).toEqual(['c', 'a', 'b']);
const before = useSessionTabsStore.getState().tabIds;
useSessionTabsStore.getState().reorderTabs('x', 'a');
expect(useSessionTabsStore.getState().tabIds).toBe(before);
});
test('caps the working set at 10, evicting the oldest tab', () => {
useSessionTabsStore.setState({ tabIds: Array.from({ length: 10 }, (_, i) => `s${i}`) });
useSessionTabsStore.getState().ensureTab('s-new');
const ids = useSessionTabsStore.getState().tabIds;
expect(ids).toHaveLength(10);
expect(ids[0]).toBe('s1');
expect(ids.at(-1)).toBe('s-new');
});
test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => {
useSessionTabsStore.setState({ tabIds: ['a', 'b'] });
const before = useSessionTabsStore.getState().tabIds;
useSessionTabsStore.getState().removeTabs(['x']);
expect(useSessionTabsStore.getState().tabIds).toBe(before);
useSessionTabsStore.getState().removeTabs(['a']);
expect(useSessionTabsStore.getState().tabIds).toEqual(['b']);
});
});
@@ -0,0 +1,87 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage';
/**
* The header's working set of sessions, shown as tabs on web/desktop.
*
* Only session ids and their order are owned here titles, directories and
* liveness come from the session stores at render time. Tabs are a per-client
* projection: opening a session anywhere adds it once, closing a tab only
* removes it from the strip and never touches the session itself. Ids whose
* session is unknown are kept (a partially loaded global list must not
* destroy the working set) and simply do not render until the session loads.
*/
interface SessionTabsStore {
tabIds: string[];
ensureTab: (sessionId: string) => void;
closeTab: (sessionId: string) => void;
closeOtherTabs: (sessionId: string) => void;
reorderTabs: (activeId: string, overId: string) => void;
/** Drop ids the caller has authoritatively confirmed no longer exist. */
removeTabs: (sessionIds: readonly string[]) => void;
}
const MAX_SESSION_TABS = 10;
type PersistedSessionTabs = { tabIds: string[] };
export const useSessionTabsStore = create<SessionTabsStore>()(
devtools(
persist(
(set, get) => ({
tabIds: [],
ensureTab: (sessionId) => {
if (!sessionId) return;
const { tabIds } = get();
if (tabIds.includes(sessionId)) return;
// Soft cap: with auto-add the strip only ever grows, so past the cap
// the oldest tab (never the one being opened, which lands last)
// leaves the working set.
const next = [...tabIds, sessionId];
set({ tabIds: next.length > MAX_SESSION_TABS ? next.slice(next.length - MAX_SESSION_TABS) : next });
},
closeTab: (sessionId) => {
const { tabIds } = get();
if (!tabIds.includes(sessionId)) return;
set({ tabIds: tabIds.filter((id) => id !== sessionId) });
},
closeOtherTabs: (sessionId) => {
const { tabIds } = get();
if (!tabIds.includes(sessionId)) return;
if (tabIds.length === 1) return;
set({ tabIds: [sessionId] });
},
reorderTabs: (activeId, overId) => {
const { tabIds } = get();
const from = tabIds.indexOf(activeId);
const to = tabIds.indexOf(overId);
if (from < 0 || to < 0 || from === to) return;
const next = [...tabIds];
next.splice(to, 0, ...next.splice(from, 1));
set({ tabIds: next });
},
removeTabs: (sessionIds) => {
if (sessionIds.length === 0) return;
const gone = new Set(sessionIds);
const { tabIds } = get();
const next = tabIds.filter((id) => !gone.has(id));
if (next.length === tabIds.length) return;
set({ tabIds: next });
},
}),
{
name: 'session-tabs-store',
storage: createDeferredSafeJSONStorage<PersistedSessionTabs>(),
partialize: (state) => ({ tabIds: state.tabIds }),
},
),
),
);
@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => undefined,
},
}));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => null,
}),
},
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: async () => new Response('{}', { status: 500 }),
}));
mock.module('@/stores/useSkillsStore', () => ({
invalidateSkillsLoadCache: () => undefined,
refreshSkillsAfterOpenCodeRestart: async () => undefined,
useSkillsStore: {
getState: () => ({}),
},
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: () => undefined,
finishConfigUpdate: () => undefined,
updateConfigUpdateMessage: () => undefined,
}));
const { useSkillsCatalogStore } = await import('./useSkillsCatalogStore');
describe('skills catalog ClawHub label', () => {
beforeEach(() => {
useSkillsCatalogStore.setState({
sources: useSkillsCatalogStore.getState().sources,
});
});
test('fallback sources label ClawHub correctly', () => {
const clawhub = useSkillsCatalogStore.getState().sources.find((source) => source.id === 'clawdhub');
expect(clawhub).toBeDefined();
expect(clawhub?.label).toBe('ClawHub');
});
});
+85 -135
View File
@@ -30,11 +30,27 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [
sourceType: 'github',
},
{
id: 'clawdhub',
label: 'ClawHub',
description: 'Community skill registry with vector search',
source: 'clawdhub:registry',
sourceType: 'clawdhub',
id: 'openai',
label: 'OpenAI',
description: "OpenAI's curated skills",
source: 'openai/skills',
defaultSubpath: 'skills/.curated',
sourceType: 'github',
},
{
id: 'cursor',
label: 'Cursor',
description: "Cursor's plugin skills",
source: 'cursor/plugins',
defaultSubpath: 'pstack/skills',
sourceType: 'github',
},
{
id: 'mattpocock',
label: 'Matt Pocock',
description: 'Matt Pocock skills collection',
source: 'mattpocock/skills',
sourceType: 'github',
},
];
@@ -42,6 +58,8 @@ const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__';
const skillsCatalogLastLoadedAt = new Map<string, number>();
const skillsCatalogLoadInFlight = new Map<string, Promise<boolean>>();
const sourceLoadInFlight = new Map<string, Promise<boolean>>();
let activeSourceLoads = 0;
const getSkillsCatalogCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
@@ -71,13 +89,10 @@ export interface SkillsCatalogState {
sources: SkillsCatalogSource[];
itemsBySource: Record<string, SkillsCatalogItem[]>;
selectedSourceId: string | null;
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
loadedSourceIds: Record<string, boolean>;
clawdhubHasMoreBySource: Record<string, boolean>;
isLoadingCatalog: boolean;
isLoadingSource: boolean;
isLoadingMore: boolean;
isScanning: boolean;
isInstalling: boolean;
@@ -91,7 +106,6 @@ export interface SkillsCatalogState {
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
loadMoreClawdHub: () => Promise<boolean>;
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
}
@@ -102,13 +116,10 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
sources: FALLBACK_SOURCES,
itemsBySource: {},
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
pageInfoBySource: {},
loadedSourceIds: {},
clawdhubHasMoreBySource: {},
isLoadingCatalog: false,
isLoadingSource: false,
isLoadingMore: false,
isScanning: false,
isInstalling: false,
@@ -141,9 +152,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const previous = {
sources: get().sources,
itemsBySource: get().itemsBySource,
pageInfoBySource: get().pageInfoBySource,
loadedSourceIds: get().loadedSourceIds,
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
};
let lastError: SkillsCatalogResponse['error'] | null = null;
@@ -168,9 +177,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
const currentSelected = get().selectedSourceId;
const selectedSourceId =
(currentSelected && sources.some((s) => s.id === currentSelected))
@@ -180,9 +187,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({
sources,
itemsBySource,
pageInfoBySource,
loadedSourceIds,
clawdhubHasMoreBySource,
selectedSourceId,
});
@@ -197,9 +202,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({
sources: previous.sources,
itemsBySource: previous.itemsBySource,
pageInfoBySource: previous.pageInfoBySource,
loadedSourceIds: previous.loadedSourceIds,
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
});
@@ -222,136 +225,83 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
return false;
}
// Deduplicate concurrent loads of the same source: the background
// loader effect can restart while a request for this source is
// already in flight.
if (!options?.refresh) {
const inFlight = sourceLoadInFlight.get(sourceId);
if (inFlight) {
return inFlight;
}
}
activeSourceLoads += 1;
set({ isLoadingSource: true, lastCatalogError: null });
try {
const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
const request = (async () => {
try {
const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
if (!response.ok || (!payload?.ok && !hasItems)) {
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
}));
return true;
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
if (!response.ok || (!payload?.ok && !hasItems)) {
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
}));
return true;
}
set({
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
});
return false;
}
const items = payload?.items || [];
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
}));
return true;
} catch (error) {
set({
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
});
return false;
}
const items = payload?.items || [];
const nextCursor = payload?.nextCursor ?? null;
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
clawdhubHasMoreBySource: {
...state.clawdhubHasMoreBySource,
[sourceId]: items.length > 0,
},
}));
return true;
} catch (error) {
set({
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
});
return false;
} finally {
set({ isLoadingSource: false });
}
},
loadMoreClawdHub: async () => {
const selectedSourceId = get().selectedSourceId;
if (!selectedSourceId) {
return false;
}
const pageInfo = get().pageInfoBySource[selectedSourceId];
const cursor = pageInfo?.nextCursor || null;
set({ isLoadingMore: true });
try {
const currentDirectory = getRequestDirectory();
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
if (currentDirectory) {
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
}
if (cursor) {
parts.push(`cursor=${encodeURIComponent(cursor)}`);
}
const queryParams = `?${parts.join('&')}`;
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
if (!response.ok || !payload?.ok) {
return false;
}
const nextCursor = payload.nextCursor ?? null;
const currentItems = get().itemsBySource[selectedSourceId] || [];
const items = payload.items || [];
const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item]));
let newCount = 0;
for (const item of items) {
const key = `${item.sourceId}:${item.skillDir}`;
if (!merged.has(key)) {
newCount += 1;
} finally {
activeSourceLoads -= 1;
if (activeSourceLoads === 0) {
set({ isLoadingSource: false });
}
merged.set(key, item);
}
})();
const noMore = items.length === 0 || newCount === 0;
set((state) => ({
itemsBySource: {
...state.itemsBySource,
[selectedSourceId]: Array.from(merged.values()),
},
pageInfoBySource: {
...state.pageInfoBySource,
[selectedSourceId]: { nextCursor },
},
clawdhubHasMoreBySource: {
...state.clawdhubHasMoreBySource,
[selectedSourceId]: !noMore,
},
}));
return true;
} catch {
return false;
sourceLoadInFlight.set(sourceId, request);
try {
return await request;
} finally {
set({ isLoadingMore: false });
if (sourceLoadInFlight.get(sourceId) === request) {
sourceLoadInFlight.delete(sourceId);
}
}
},
@@ -77,14 +77,42 @@ describe('useSkillsStore directory resolution', () => {
});
invalidateSkillsLoadCache(activeProjectPath);
invalidateSkillsLoadCache('/workspace/other-project');
useSkillsStore.setState({
selectedSkillName: null,
skills: [],
skillsByDirectory: {},
isLoading: false,
skillDraft: null,
});
});
test('loading another project leaves the active project\'s skills alone', async () => {
// Settings can browse a project the app is not on. Chat autocompletes read
// `skills`, so that list must keep describing the active project.
const activeSkills = [{
name: 'active-only',
path: `${activeProjectPath}/.agents/skills/active-only/SKILL.md`,
scope: 'project' as const,
source: 'agents' as const,
description: 'Active project skill',
group: undefined,
renamable: false,
}];
useSkillsStore.setState({
skills: activeSkills,
skillsByDirectory: { [activeProjectPath]: activeSkills },
});
const loaded = await useSkillsStore.getState().loadSkills('/workspace/other-project');
expect(loaded).toBe(true);
expect(runtimeFetchCalls[0]?.url).toContain(`directory=${encodeURIComponent('/workspace/other-project')}`);
const state = useSkillsStore.getState();
expect(state.skills).toEqual(activeSkills);
expect(state.skillsByDirectory['/workspace/other-project']?.map((skill) => skill.name)).toEqual(['repo-local-skill']);
});
test('loadSkills scopes discovery to the active project even when client directory is unset', async () => {
const loaded = await useSkillsStore.getState().loadSkills();
+107 -51
View File
@@ -20,6 +20,19 @@ import { filterSkillsByRuntimeFlags } from './skillVisibility';
// project selector (and Commands/Agents). Falling back only to the session
// directory misses repository-local `.agents/skills` when the client directory
// is unset or points elsewhere while an active project exists.
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the project
* the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
return getRequestDirectory();
};
const getRequestDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
@@ -144,24 +157,27 @@ interface SkillDetail {
interface SkillsStore {
selectedSkillName: string | null;
/** Skills of the project the app is on. Chat and autocompletes read this one. */
skills: DiscoveredSkill[];
/** Every directory loaded so far, including the ambient one. */
skillsByDirectory: Record<string, DiscoveredSkill[]>;
isLoading: boolean;
skillDraft: SkillDraft | null;
setSelectedSkill: (name: string | null) => void;
setSkillDraft: (draft: SkillDraft | null) => void;
loadSkills: () => Promise<boolean>;
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
renameSkill: (name: string, newName: string) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
loadSkills: (directory?: string | null) => Promise<boolean>;
getSkillDetail: (name: string, directory?: string | null) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig, directory?: string | null) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>, directory?: string | null) => Promise<boolean>;
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
// Supporting files
readSupportingFile: (skillName: string, filePath: string) => Promise<string | null>;
writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise<boolean>;
deleteSupportingFile: (skillName: string, filePath: string) => Promise<boolean>;
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
writeSupportingFile: (skillName: string, filePath: string, content: string, directory?: string | null) => Promise<boolean>;
deleteSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<boolean>;
}
declare global {
@@ -186,12 +202,16 @@ export const invalidateSkillsLoadCache = (directory: string | null = getRequestD
};
const upsertSkillLocal = (
set: (state: Partial<SkillsStore>) => void,
set: (updater: (state: SkillsStore) => Partial<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
config: Partial<SkillConfig>,
directory: string | null,
) => {
const existing = get().skills.find((skill) => skill.name === name);
const cacheKey = getSkillsCacheKey(directory);
const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory());
const current = get().skillsByDirectory[cacheKey] ?? [];
const existing = current.find((skill) => skill.name === name);
const path = config.targetPath ?? existing?.path ?? '';
const nextSkill: DiscoveredSkill = {
...existing,
@@ -202,11 +222,16 @@ const upsertSkillLocal = (
description: config.description ?? existing?.description ?? '',
group: parseSkillGroup(path),
};
const skills = get().skills;
const nextSkills = skills.some((skill) => skill.name === name)
? skills.map((skill) => (skill.name === name ? nextSkill : skill))
: [...skills, nextSkill];
set({ skills: nextSkills });
const nextSkills = current.some((skill) => skill.name === name)
? current.map((skill) => (skill.name === name ? nextSkill : skill))
: [...current, nextSkill];
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: nextSkills },
};
if (isAmbient) next.skills = nextSkills;
return next;
});
};
const removeSkillLocal = (
@@ -230,12 +255,27 @@ const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
const EMPTY_SKILLS: DiscoveredSkill[] = [];
/**
* Skills of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectSkillsForDirectory = (
state: Pick<SkillsStore, 'skillsByDirectory'>,
directory?: string | null,
): DiscoveredSkill[] => {
const cacheKey = getSkillsCacheKey(resolveDirectory(directory));
return state.skillsByDirectory[cacheKey] ?? EMPTY_SKILLS;
};
export const useSkillsStore = create<SkillsStore>()(
devtools(
persist(
(set, get) => ({
selectedSkillName: null,
skills: [],
skillsByDirectory: {},
isLoading: false,
skillDraft: null,
@@ -247,12 +287,13 @@ export const useSkillsStore = create<SkillsStore>()(
set({ skillDraft: draft });
},
loadSkills: async () => {
const directory = getRequestDirectory();
loadSkills: async (requestedDirectory?: string | null) => {
const directory = resolveDirectory(requestedDirectory);
const cacheKey = getSkillsCacheKey(directory);
const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory());
const now = Date.now();
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedSkills = get().skills.length > 0;
const hasCachedSkills = (get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : [])).length > 0;
if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) {
return true;
@@ -265,7 +306,9 @@ export const useSkillsStore = create<SkillsStore>()(
const request = (async () => {
set({ isLoading: true });
const previousSkills = get().skills;
// Failure must never look like an empty project. The mirror is the
// fallback so a directory loaded before this map existed still counts.
const previousSkills = get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : []);
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
@@ -306,7 +349,14 @@ export const useSkillsStore = create<SkillsStore>()(
data.externalSkills ?? null,
);
set({ skills: visibleSkills, isLoading: false });
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
isLoading: false,
};
if (isAmbient) next.skills = visibleSkills;
return next;
});
skillsLastLoadedAt.set(cacheKey, Date.now());
return true;
} catch (error) {
@@ -317,7 +367,14 @@ export const useSkillsStore = create<SkillsStore>()(
}
console.error("Failed to load skills:", lastError);
set({ skills: previousSkills, isLoading: false });
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
isLoading: false,
};
if (isAmbient) next.skills = previousSkills;
return next;
});
return false;
})();
@@ -329,9 +386,9 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
getSkillDetail: async (name: string) => {
getSkillDetail: async (name: string, requestedDirectory?: string | null) => {
try {
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
@@ -347,7 +404,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
createSkill: async (config: SkillConfig) => {
createSkill: async (config: SkillConfig, requestedDirectory?: string | null) => {
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
@@ -359,7 +416,7 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.source) skillConfig.source = config.source;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
@@ -380,12 +437,12 @@ export const useSkillsStore = create<SkillsStore>()(
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertSkillLocal(set, get, config.name, config);
upsertSkillLocal(set, get, config.name, config, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) {
upsertSkillLocal(set, get, config.name, config);
upsertSkillLocal(set, get, config.name, config, directory);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
@@ -399,7 +456,7 @@ export const useSkillsStore = create<SkillsStore>()(
return true;
}
const loaded = await get().loadSkills();
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
@@ -409,7 +466,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
updateSkill: async (name: string, config: Partial<SkillConfig>, requestedDirectory?: string | null) => {
try {
const skillConfig: Record<string, unknown> = {};
@@ -418,7 +475,7 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
@@ -439,12 +496,12 @@ export const useSkillsStore = create<SkillsStore>()(
invalidateSkillsLoadCache(directory);
if (payload?.requiresManualRestart) {
upsertSkillLocal(set, get, name, config);
upsertSkillLocal(set, get, name, config, directory);
return true;
}
if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) {
upsertSkillLocal(set, get, name, config);
upsertSkillLocal(set, get, name, config, directory);
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
return true;
}
@@ -458,7 +515,7 @@ export const useSkillsStore = create<SkillsStore>()(
return true;
}
const loaded = await get().loadSkills();
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
@@ -468,11 +525,11 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
renameSkill: async (name: string, newName: string) => {
renameSkill: async (name: string, newName: string, requestedDirectory?: string | null) => {
startConfigUpdate("Renaming skill...");
let requiresReload = false;
try {
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
@@ -501,7 +558,7 @@ export const useSkillsStore = create<SkillsStore>()(
return true;
}
const loaded = await get().loadSkills();
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
@@ -515,9 +572,9 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
deleteSkill: async (name: string) => {
deleteSkill: async (name: string, requestedDirectory?: string | null) => {
try {
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
@@ -553,7 +610,7 @@ export const useSkillsStore = create<SkillsStore>()(
return true;
}
const loaded = await get().loadSkills();
const loaded = await get().loadSkills(directory);
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
@@ -568,14 +625,13 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
getSkillByName: (name: string) => {
const { skills } = get();
return skills.find((s) => s.name === name);
getSkillByName: (name: string, requestedDirectory?: string | null) => {
return selectSkillsForDirectory(get(), requestedDirectory).find((skill) => skill.name === name);
},
readSupportingFile: async (skillName: string, filePath: string) => {
readSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => {
try {
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
@@ -593,9 +649,9 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
writeSupportingFile: async (skillName: string, filePath: string, content: string, requestedDirectory?: string | null) => {
try {
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
@@ -616,9 +672,9 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
deleteSupportingFile: async (skillName: string, filePath: string) => {
deleteSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => {
try {
const directory = getRequestDirectory();
const directory = resolveDirectory(requestedDirectory);
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
@@ -12,6 +12,47 @@ const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo',
describe('terminal state reconciliation', () => {
afterEach(() => useTerminalStore.getState().clearAll());
test('adopts unknown server sessions into the fresh placeholder tab', () => {
setup();
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
{ sessionId: 'srv-2', status: 'exited', createdAt: null },
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']);
expect(state.tabs[0].terminalSessionId).toBe('srv-1');
expect(state.tabs[0].lifecycle).toBe('running');
expect(state.tabs[1].lifecycle).toBe('exited');
expect(state.activeTabId).toBe('srv-1');
});
test('adoption is additive: existing tabs and referenced sessions survive', () => {
const tabId = setup();
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1);
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live');
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-live', status: 'running', createdAt: 1 },
{ sessionId: 'srv-orphan', status: 'running', createdAt: 2 },
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs).toHaveLength(2);
expect(state.tabs[0].id).toBe(tabId);
expect(state.tabs[1].id).toBe('srv-orphan');
expect(state.activeTabId).toBe(tabId);
});
test('re-adopting the same sessions changes nothing', () => {
setup();
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
]);
const before = useTerminalStore.getState().sessions;
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
]);
expect(useTerminalStore.getState().sessions).toBe(before);
});
test('applies snapshots atomically and deduplicates output by sequence', () => {
const tabId = setup();
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
@@ -71,6 +71,10 @@ interface TerminalStore {
getBuffer: (directory: string, tabId: string) => TerminalBuffer;
createTab: (directory: string) => string;
adoptServerSessions: (
directory: string,
serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>,
) => void;
setActiveTab: (directory: string, tabId: string) => void;
setTabLabel: (directory: string, tabId: string, label: string) => void;
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
@@ -334,6 +338,60 @@ export const useTerminalStore = create<TerminalStore>()(
return tabId;
},
/**
* The server owns which terminal sessions exist; the local tab list is
* only this client's projection. Adoption is strictly additive: server
* sessions no local tab references become tabs (id = session id, the
* create/attach contract), and nothing is ever removed here, so a
* failed or partial listing cannot destroy local tabs.
*/
adoptServerSessions: (directory, serverSessions) => {
const key = normalizeDirectory(directory);
if (!key || serverSessions.length === 0) return;
set((state) => {
const existing = state.sessions.get(key);
const knownIds = new Set<string>();
for (const tab of existing?.tabs ?? []) {
knownIds.add(tab.id);
if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId);
}
const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId));
if (newcomers.length === 0) return state;
const tabs = [...(existing?.tabs ?? [])];
// A single untouched placeholder tab (fresh directory state) is
// replaced by the first adopted session instead of sitting next to it.
const placeholder = tabs.length === 1
&& tabs[0].terminalSessionId === null
&& tabs[0].lifecycle === 'idle'
&& !state.buffers.has(bufferKey(key, tabs[0].id))
? tabs[0]
: null;
if (placeholder) tabs.length = 0;
for (const session of newcomers) {
const tab: TerminalTab = {
...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)),
terminalSessionId: session.sessionId,
lifecycle: session.status,
createdAt: session.createdAt ?? Date.now(),
};
tabs.push(tab);
}
const previousActive = existing?.activeTabId ?? null;
const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive)
? previousActive
: tabs[0]?.id ?? null;
const newSessions = new Map(state.sessions);
newSessions.set(key, { tabs, activeTabId });
return { sessions: newSessions };
});
},
setActiveTab: (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
set((state) => {
@@ -28,6 +28,183 @@ describe('useUIStore context panel tabs', () => {
expect(tabs).toHaveLength(1);
expect(tabs[0]?.readOnly).toBe(false);
});
test('keeps a plan tab that carries its owning project', () => {
const directory = '/repo';
const projectRef = { id: 'proj_1', path: '/repo' };
useUIStore.getState().openContextPanelTab(directory, {
mode: 'plan',
projectPlanId: 'plan-1',
projectPlanRef: projectRef,
dedupeKey: `plan:${projectRef.id}:plan-1`,
label: 'My plan',
});
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
expect(tabs).toHaveLength(1);
expect(tabs[0]?.projectPlanId).toBe('plan-1');
expect(tabs[0]?.projectPlanRef).toEqual(projectRef);
});
test('dedupes plan tabs by owner and plan id, not by plan id alone', () => {
const directory = '/repo';
useUIStore.getState().openContextPanelTab(directory, {
mode: 'plan',
projectPlanId: 'plan-1',
projectPlanRef: { id: 'proj_1', path: '/repo' },
dedupeKey: 'plan:proj_1:plan-1',
});
useUIStore.getState().openContextPanelTab(directory, {
mode: 'plan',
projectPlanId: 'plan-1',
projectPlanRef: { id: 'proj_1', path: '/repo' },
dedupeKey: 'plan:proj_1:plan-1',
});
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
expect(tabs).toHaveLength(1);
});
test('drops persisted plan tabs whose owner is missing instead of guessing it', () => {
const directory = '/repo';
const persisted = {
contextPanelByDirectory: {
[directory]: {
isOpen: true,
expanded: false,
widthByMode: {},
touchedAt: 1,
activeTabId: 'plan:plan-1',
tabs: [
// Pre-owner tab: has an id but no projectPlanRef.
{
id: 'plan:plan-1',
mode: 'plan',
targetPath: null,
projectPlanId: 'plan-1',
projectPlanRef: null,
dedupeKey: 'plan:plan-1',
label: 'Old plan',
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: null,
touchedAt: 1,
},
],
},
},
};
// SAFETY: the object mirrors the persisted context-panel shape exactly;
// setState bypasses the persist middleware's typing, not its migration.
useUIStore.setState(persisted as never);
// Sanitization runs whenever panel state is touched; opening a valid tab
// is the ordinary touch that would flush stale persisted tabs out.
useUIStore.getState().openContextPanelTab(directory, {
mode: 'plan',
projectPlanId: 'plan-2',
projectPlanRef: { id: 'proj_1', path: '/repo' },
dedupeKey: 'plan:proj_1:plan-2',
});
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
expect(tabs).toHaveLength(1);
expect(tabs[0]?.projectPlanId).toBe('plan-2');
});
test('keeps a generic filesystem plan tab that has no saved-plan identity', () => {
const directory = '/repo';
useUIStore.getState().openContextSurface(directory, 'plan');
// A later touch runs the same sanitizer rehydrate uses.
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
const planTab = tabs.find((tab) => tab.mode === 'plan');
expect(planTab).toBeDefined();
expect(planTab?.projectPlanId).toBeNull();
expect(planTab?.projectPlanRef).toBeNull();
});
test('keeps a persisted generic plan tab through rehydration-like touches', () => {
const directory = '/repo';
const persisted = {
contextPanelByDirectory: {
[directory]: {
isOpen: true,
expanded: false,
widthByMode: {},
touchedAt: 1,
activeTabId: 'plan',
tabs: [
{
id: 'plan',
mode: 'plan',
targetPath: null,
projectPlanId: null,
projectPlanRef: null,
dedupeKey: 'plan',
label: 'Plan',
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: null,
touchedAt: 1,
},
],
},
},
};
// SAFETY: the object mirrors the persisted context-panel shape exactly;
// setState bypasses the persist middleware's typing, not its migration.
useUIStore.setState(persisted as never);
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true);
});
test('drops a persisted saved-plan tab carrying an owner but no plan id', () => {
const directory = '/repo';
const persisted = {
contextPanelByDirectory: {
[directory]: {
isOpen: true,
expanded: false,
widthByMode: {},
touchedAt: 1,
activeTabId: null,
tabs: [
{
id: 'plan:proj_1:plan-1',
mode: 'plan',
targetPath: null,
projectPlanId: null,
projectPlanRef: { id: 'proj_1', path: '/repo' },
dedupeKey: 'plan:proj_1:plan-1',
label: 'Half-identified',
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: null,
touchedAt: 1,
},
],
},
},
};
// SAFETY: the object mirrors the persisted context-panel shape exactly;
// setState bypasses the persist middleware's typing, not its migration.
useUIStore.setState(persisted as never);
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(false);
});
});
describe('useUIStore openContextSurface', () => {
+156 -120
View File
@@ -7,14 +7,13 @@ import type { ShortcutCombo } from '@/lib/shortcuts';
import type { DraftStarterRef } from '@/lib/draftStarters';
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type { TerminalShell } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/projectContextApi';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
@@ -39,6 +38,10 @@ type ContextPanelTab = {
panel. Project plans are addressed by id because their markdown is
server-owned and has no client-visible path. */
projectPlanId: string | null;
/** The project that owns `projectPlanId`. Persisted with the tab so a
restored plan tab opens against its own project instead of guessing the
owner from whatever directory happens to be current. */
projectPlanRef: ProjectRef | null;
dedupeKey: string;
label: string | null;
sessionTitleFallback: string | null;
@@ -52,6 +55,7 @@ type ContextPanelTabDescriptor = {
mode: ContextPanelMode;
targetPath?: string | null;
projectPlanId?: string | null;
projectPlanRef?: ProjectRef | null;
dedupeKey?: string | null;
label?: string | null;
sessionTitleFallback?: string | null;
@@ -77,7 +81,6 @@ type PendingFileNavigation = {
column: number;
};
export type MainTabGuard = (nextTab: MainTab) => boolean;
export type EventStreamStatus =
| 'idle'
| 'connecting'
@@ -129,15 +132,9 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_MAX_TABS = 12;
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
const LEFT_SIDEBAR_MIN_WIDTH = 280;
const activeMainTabByRuntime = new Map<string, MainTab>();
/** Separates browser tabs opened in the same millisecond. */
let browserTabSequence = 0;
const runtimeMemoryKey = (value?: string | null): string => {
const key = (value ?? getRuntimeKey()).trim();
return key || 'default';
};
// Shared with rail/panel consumers so contextPanelByDirectory lookups agree on keys.
export const normalizeContextPanelDirectoryKey = (value: string): string => normalizeDirectoryPath(value);
@@ -197,7 +194,19 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
};
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
};
/** A plan tab's owner must be a complete project reference or nothing; a
half-valid one is worse than none because it points the editor somewhere. */
const normalizeContextPanelProjectPlanRef = (value: unknown): ProjectRef | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const candidate = value as { id?: unknown; path?: unknown };
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const path = typeof candidate.path === 'string' ? candidate.path.trim() : '';
return id && path ? { id, path } : null;
};
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
@@ -249,6 +258,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
projectPlanId: typeof descriptor.projectPlanId === 'string' && descriptor.projectPlanId.trim()
? descriptor.projectPlanId.trim()
: null,
projectPlanRef: normalizeContextPanelProjectPlanRef(descriptor.projectPlanRef),
dedupeKey,
label: normalizeContextTabLabel(descriptor.label),
sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback),
@@ -309,6 +319,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
mode?: unknown;
targetPath?: unknown;
projectPlanId?: unknown;
projectPlanRef?: unknown;
dedupeKey?: unknown;
label?: unknown;
sessionTitleFallback?: unknown;
@@ -332,6 +343,19 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
}
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
const projectPlanId = typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
? candidate.projectPlanId.trim()
: null;
const projectPlanRef = normalizeContextPanelProjectPlanRef(candidate.projectPlanRef);
// `mode: 'plan'` covers two documents: a saved Project knowledge plan
// (needs both the plan id and its owning project) and a plain session
// filesystem plan (has neither). Only the half-identified form — id
// without owner — is unopenable: the editor would have to guess the
// project from the current directory, which is exactly the bug that made
// saved plans open empty. Such tabs are dropped rather than resurrected.
if (candidate.mode === 'plan' && (projectPlanId !== null) !== (projectPlanRef !== null)) {
continue;
}
const dedupeKey = normalizeContextPanelTabDedupeKey(
candidate.mode,
targetPath,
@@ -347,9 +371,8 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
id,
mode: candidate.mode,
targetPath,
projectPlanId: typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
? candidate.projectPlanId.trim()
: null,
projectPlanId,
projectPlanRef,
dedupeKey,
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null),
@@ -402,7 +425,9 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
const upsertContextPanelTab = (
current: ContextPanelDirectoryState,
descriptor: ContextPanelTabDescriptor,
options?: { reveal?: boolean },
): ContextPanelDirectoryState => {
const reveal = options?.reveal !== false;
const nextTab = createContextPanelTab(descriptor);
// A real file tab replaces the empty editor placeholder ('file' with no
// target) that the rail can open before any file is picked.
@@ -412,27 +437,35 @@ const upsertContextPanelTab = (
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
const tabs = existingIndex === -1
? [...baseTabs, nextTab]
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
const activeTabId = nextTab.id;
// A background upsert (an agent working a page) keeps the panel exactly as
// the user left it: closed stays closed, and whatever tab they were on
// stays active. The tab still exists — panes are kept mounted regardless of
// visibility — so agent control and a later manual open both find it.
const activeTabId = reveal
? nextTab.id
: current.activeTabId ?? nextTab.id;
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, activeTabId);
return {
...current,
isOpen: true,
isOpen: reveal ? true : current.isOpen,
tabs: clampedTabs,
activeTabId: resolveActiveContextPanelTabID(clampedTabs, activeTabId),
touchedAt: Date.now(),
@@ -546,6 +579,10 @@ const sanitizeContextPanelByDirectory = (
let tabs = sanitizeContextPanelTabs(candidate.tabs);
let activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null;
// Legacy single-tab state can name a saved project plan, but it carries
// no owner and cannot be migrated into an openable saved-plan tab — that
// combination is dropped by sanitize above. A generic filesystem plan tab
// (no plan id) revives fine from the descriptor alone.
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
tabs = [createContextPanelTab({
mode: candidate.mode,
@@ -616,6 +653,9 @@ interface UIStore {
hasManuallyResizedLeftSidebar: boolean;
contextPanelByDirectory: Record<string, ContextPanelDirectoryState>;
contextRailOrder: string[];
/** Surface ids the user hid from the context rail; stored as the hidden set
so surfaces added later appear for everyone. */
contextRailHiddenSurfaces: string[];
contextEditorTreeVisible: boolean;
contextEditorTreeWidth: number;
notesPanelHeight: number;
@@ -648,13 +688,9 @@ interface UIStore {
workStatusHiddenSections: string[];
isSessionSwitcherOpen: boolean;
isSessionDropdownOpen: boolean;
activeMainTab: MainTab;
mainTabGuard: MainTabGuard | null;
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
pendingDiffStaged: boolean;
pendingDiffScope: PendingDiffScope | null;
pendingDiagramFile: string | null;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
@@ -676,10 +712,18 @@ interface UIStore {
settingsPage: string;
settingsHasOpenedOnce: boolean;
settingsProjectsSelectedId: string | null;
/**
* Project the Settings pages are looking at. `null` follows the app's active
* project. Settings browses another project's configuration without moving
* the chat, the session list or the file tree, so this is its own state and
* not a second writer of the active project.
*/
settingsProjectPath: string | null;
settingsRemoteInstancesSelectedId: string | null;
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
streamingAutoFollowEnabled: boolean;
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
sessionGoalEnabled: boolean;
@@ -756,6 +800,8 @@ interface UIStore {
maxLastMessageLength: number; // chars — truncate {last_message} when summarization is off
showTerminalQuickKeysOnDesktop: boolean;
/** Header session tabs (web/desktop), opt-in. Off keeps the plain session title. */
sessionTabsEnabled: boolean;
persistChatDraft: boolean;
showOpenCodeUpdateNotifications: boolean;
agentControlToolEnabled: boolean;
@@ -791,7 +837,6 @@ interface UIStore {
collapsibleUserMessages: boolean;
stickyUserHeader: boolean;
promptNavigatorEnabled: boolean;
expandedEditorToolbar: boolean;
showSplitAssistantMessageActions: boolean;
allowPromptingSubagentSessions: boolean;
isExpandedInput: boolean;
@@ -807,14 +852,13 @@ interface UIStore {
toggleContextEditorTree: () => void;
setContextEditorTreeWidth: (width: number) => void;
openContextSurface: (directory: string, mode: ContextPanelMode) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor, options?: { reveal?: boolean }) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
openContextOverview: (directory: string) => void;
openContextPlan: (directory: string) => void;
openContextPreview: (directory: string, url: string) => void;
openContextBrowser: (directory: string, url?: string) => void;
openContextBrowser: (directory: string, url?: string, options?: { reveal?: boolean }) => void;
openNewContextBrowserTab: (directory: string) => void;
setContextPanelTabTargetPath: (directory: string, tabID: string, targetPath: string) => void;
setActiveContextPanelTab: (directory: string, tabID: string) => void;
@@ -832,20 +876,15 @@ interface UIStore {
setWorkStatusOverlayOpen: (open: boolean) => void;
setWorkStatusSectionVisible: (sectionId: string, visible: boolean) => void;
setWorkStatusHiddenSections: (sectionIds: string[]) => void;
setContextRailSurfaceVisible: (surfaceId: string, visible: boolean) => void;
setContextRailHiddenSurfaces: (surfaceIds: string[]) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setSessionDropdownOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
prepareForRuntimeSwitch: (runtimeKey?: string | null) => void;
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void;
setPendingDiagramFile: (filePath: string | null) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
navigateToDiff: (filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
consumePendingDiffFile: () => string | null;
navigateToDiagram: (filePath: string) => void;
consumePendingDiagramFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
toggleCommandPalette: () => void;
setCommandPaletteOpen: (open: boolean) => void;
@@ -867,9 +906,11 @@ interface UIStore {
setSidebarSection: (section: SidebarSection) => void;
setSettingsPage: (slug: string) => void;
setSettingsProjectsSelectedId: (projectId: string | null) => void;
setSettingsProjectPath: (path: string | null) => void;
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setStreamingAutoFollowEnabled: (value: boolean) => void;
setSessionRecapEnabled: (value: boolean) => void;
setSessionSuggestionEnabled: (value: boolean) => void;
setSessionGoalEnabled: (value: boolean) => void;
@@ -932,6 +973,7 @@ interface UIStore {
setNativeNotificationsEnabled: (value: boolean) => void;
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
setSessionTabsEnabled: (value: boolean) => void;
setNotifyOnSubtasks: (value: boolean) => void;
setDockBadgeEnabled: (value: boolean) => void;
setNotifyOnCompletion: (value: boolean) => void;
@@ -969,7 +1011,6 @@ interface UIStore {
setCollapsibleUserMessages: (value: boolean) => void;
setStickyUserHeader: (value: boolean) => void;
setPromptNavigatorEnabled: (value: boolean) => void;
setExpandedEditorToolbar: (value: boolean) => void;
setShowSplitAssistantMessageActions: (value: boolean) => void;
setAllowPromptingSubagentSessions: (value: boolean) => void;
viewPagerPage: 'left' | 'center' | 'right';
@@ -999,6 +1040,7 @@ export const useUIStore = create<UIStore>()(
hasManuallyResizedLeftSidebar: false,
contextPanelByDirectory: {},
contextRailOrder: [],
contextRailHiddenSurfaces: [],
contextEditorTreeVisible: true,
contextEditorTreeWidth: 240,
notesPanelHeight: 112,
@@ -1011,13 +1053,9 @@ export const useUIStore = create<UIStore>()(
workStatusHiddenSections: [],
isSessionSwitcherOpen: false,
isSessionDropdownOpen: false,
activeMainTab: 'chat',
mainTabGuard: null,
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
pendingDiffStaged: false,
pendingDiffScope: null,
pendingDiagramFile: null,
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
@@ -1037,10 +1075,12 @@ export const useUIStore = create<UIStore>()(
settingsPage: 'home',
settingsHasOpenedOnce: false,
settingsProjectsSelectedId: null,
settingsProjectPath: null,
settingsRemoteInstancesSelectedId: null,
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
streamingAutoFollowEnabled: true,
sessionRecapEnabled: true,
sessionSuggestionEnabled: true,
sessionGoalEnabled: true,
@@ -1107,6 +1147,7 @@ export const useUIStore = create<UIStore>()(
maxLastMessageLength: 250,
showTerminalQuickKeysOnDesktop: false,
sessionTabsEnabled: false,
persistChatDraft: true,
showOpenCodeUpdateNotifications: !isWindowsArm64(),
agentControlToolEnabled: true,
@@ -1132,7 +1173,6 @@ export const useUIStore = create<UIStore>()(
collapsibleUserMessages: true,
stickyUserHeader: false,
promptNavigatorEnabled: true,
expandedEditorToolbar: false,
showSplitAssistantMessageActions: false,
allowPromptingSubagentSessions: false,
draftStartersVisible: true,
@@ -1244,7 +1284,7 @@ export const useUIStore = create<UIStore>()(
state.openContextPanelTab(normalizedDirectory, { mode });
},
openContextPanelTab: (directory, tab) => {
openContextPanelTab: (directory, tab, options) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
@@ -1255,7 +1295,7 @@ export const useUIStore = create<UIStore>()(
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: upsertContextPanelTab(current, tab),
[normalizedDirectory]: upsertContextPanelTab(current, tab, options),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
@@ -1318,15 +1358,6 @@ export const useUIStore = create<UIStore>()(
get().openContextPanelTab(normalizedDirectory, { mode: 'context' });
},
openContextPlan: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
get().openContextPanelTab(normalizedDirectory, { mode: 'plan' });
},
openContextPreview: (directory, url) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedUrl = (url || '').trim();
@@ -1356,7 +1387,7 @@ export const useUIStore = create<UIStore>()(
label: null,
});
},
openContextBrowser: (directory, url = '') => {
openContextBrowser: (directory, url = '', options) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory || isVSCodeRuntime()) return;
const targetUrl = typeof url === 'string' && url.trim().length > 0 ? url.trim() : '';
@@ -1365,7 +1396,7 @@ export const useUIStore = create<UIStore>()(
targetPath: targetUrl,
dedupeKey: targetUrl || 'browser',
label: null,
});
}, options);
},
setContextPanelTabTargetPath: (directory, tabID, targetPath) => {
@@ -1610,6 +1641,23 @@ export const useUIStore = create<UIStore>()(
set({ workStatusHiddenSections: [...new Set(sectionIds)] });
},
setContextRailSurfaceVisible: (surfaceId, visible) => {
set((state) => {
const hidden = state.contextRailHiddenSurfaces;
const isHidden = hidden.includes(surfaceId);
if (visible === !isHidden) return state;
return {
contextRailHiddenSurfaces: visible
? hidden.filter((entry) => entry !== surfaceId)
: [...hidden, surfaceId],
};
});
},
setContextRailHiddenSurfaces: (surfaceIds) => {
set({ contextRailHiddenSurfaces: [...new Set(surfaceIds)] });
},
setSessionSwitcherOpen: (open) => {
if (get().isSessionSwitcherOpen === open) {
@@ -1625,31 +1673,6 @@ export const useUIStore = create<UIStore>()(
set({ isSessionDropdownOpen: open });
},
setMainTabGuard: (guard) => {
if (get().mainTabGuard === guard) {
return;
}
set({ mainTabGuard: guard });
},
setActiveMainTab: (tab) => {
const guard = get().mainTabGuard;
if (guard && !guard(tab)) {
return;
}
activeMainTabByRuntime.set(runtimeMemoryKey(), tab);
set({ activeMainTab: tab });
},
prepareForRuntimeSwitch: (runtimeKey?: string | null) => {
activeMainTabByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeMainTab);
},
restoreForRuntimeSwitch: (runtimeKey?: string | null) => {
const restored = activeMainTabByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat';
set({ activeMainTab: restored });
},
setPendingDiffFile: (filePath, staged = false, scope = null) => {
set({
pendingDiffFile: filePath,
@@ -1658,10 +1681,6 @@ export const useUIStore = create<UIStore>()(
});
},
setPendingDiagramFile: (filePath) => {
set({ pendingDiagramFile: filePath });
},
setPendingFileNavigation: (navigation) => {
set({ pendingFileNavigation: navigation });
},
@@ -1671,11 +1690,7 @@ export const useUIStore = create<UIStore>()(
},
navigateToDiff: (filePath, staged = false, scope = null) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
return;
}
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeMainTab: 'diff' });
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope });
},
consumePendingDiffFile: () => {
@@ -1686,22 +1701,6 @@ export const useUIStore = create<UIStore>()(
return pendingDiffFile;
},
navigateToDiagram: (filePath) => {
const guard = get().mainTabGuard;
if (guard && !guard('diagram')) {
return;
}
set({ pendingDiagramFile: filePath, activeMainTab: 'diagram' });
},
consumePendingDiagramFile: () => {
const { pendingDiagramFile } = get();
if (pendingDiagramFile) {
set({ pendingDiagramFile: null });
}
return pendingDiagramFile;
},
setIsMobile: (isMobile) => {
set({ isMobile });
},
@@ -1798,6 +1797,11 @@ export const useUIStore = create<UIStore>()(
set({ settingsPage: slug });
},
setSettingsProjectPath: (path) => {
const trimmed = path?.trim();
set({ settingsProjectPath: trimmed ? trimmed : null });
},
setSettingsProjectsSelectedId: (projectId) => {
set({ settingsProjectsSelectedId: projectId });
},
@@ -1817,6 +1821,10 @@ export const useUIStore = create<UIStore>()(
set({ showReasoningTraces: value });
},
setStreamingAutoFollowEnabled: (value) => {
set({ streamingAutoFollowEnabled: value });
},
setSessionRecapEnabled: (value) => {
set({ sessionRecapEnabled: value });
},
@@ -2303,6 +2311,10 @@ export const useUIStore = create<UIStore>()(
set({ showTerminalQuickKeysOnDesktop: value });
},
setSessionTabsEnabled: (value) => {
set({ sessionTabsEnabled: value });
},
setNotifyOnSubtasks: (value) => {
set({ notifyOnSubtasks: value });
},
@@ -2408,9 +2420,6 @@ export const useUIStore = create<UIStore>()(
setPromptNavigatorEnabled: (value) => {
set({ promptNavigatorEnabled: value });
},
setExpandedEditorToolbar: (value: boolean) => {
set({ expandedEditorToolbar: value });
},
setShowSplitAssistantMessageActions: (value) => {
set({ showSplitAssistantMessageActions: value });
},
@@ -2462,13 +2471,36 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createDeferredSafeJSONStorage(),
version: 14,
version: 18,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
}
const state = persistedState as Record<string, unknown>;
// v15 -> v16: the main-area surface concept is gone from persistence
// (the chat always owns the desktop main area; panel surfaces have
// their own state). Drop the historic fields so a stored non-chat
// value cannot rehydrate into a blank main area.
if (version < 16) {
delete state.activeMainTab;
delete state.activeSurface;
}
// v16 -> v17: the editor toolbar is always docked; the preference is gone.
if (version < 17) {
delete state.expandedEditorToolbar;
}
// v17 -> v18: the default shortcut layout was redesigned around the
// mod+k leader and the held digit prefixes. Old overrides were
// recorded against the previous defaults (e.g. a bare 'mod' surface
// prefix now collides with session tabs), so custom bindings start
// fresh on the new system.
if (version < 18) {
delete state.shortcutOverrides;
}
// v13 -> v14: the separate 'preview' surface merged into 'browser'.
// Stored preview tabs keep their URL and become browser tabs; their
// id encodes the mode, so it is rebuilt rather than left dangling.
@@ -2654,6 +2686,9 @@ export const useUIStore = create<UIStore>()(
state.autoSaveEnabled = true;
}
state.contextRailHiddenSurfaces = Array.isArray(state.contextRailHiddenSurfaces)
? (state.contextRailHiddenSurfaces as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '')
: [];
state.contextRailOrder = Array.isArray(state.contextRailOrder)
? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '')
: [];
@@ -2666,6 +2701,7 @@ export const useUIStore = create<UIStore>()(
sidebarWidth: state.sidebarWidth,
contextPanelByDirectory: state.contextPanelByDirectory,
contextRailOrder: state.contextRailOrder,
contextRailHiddenSurfaces: state.contextRailHiddenSurfaces,
contextEditorTreeVisible: state.contextEditorTreeVisible,
contextEditorTreeWidth: state.contextEditorTreeWidth,
notesPanelHeight: state.notesPanelHeight,
@@ -2674,7 +2710,6 @@ export const useUIStore = create<UIStore>()(
workStatusPanelEnabled: state.workStatusPanelEnabled,
workStatusHiddenSections: state.workStatusHiddenSections,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection,
settingsPage: state.settingsPage,
settingsHasOpenedOnce: state.settingsHasOpenedOnce,
@@ -2683,6 +2718,7 @@ export const useUIStore = create<UIStore>()(
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
sessionGoalEnabled: state.sessionGoalEnabled,
@@ -2723,6 +2759,7 @@ export const useUIStore = create<UIStore>()(
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
sessionTabsEnabled: state.sessionTabsEnabled,
notifyOnSubtasks: state.notifyOnSubtasks,
dockBadgeEnabled: state.dockBadgeEnabled,
notifyOnCompletion: state.notifyOnCompletion,
@@ -2756,7 +2793,6 @@ export const useUIStore = create<UIStore>()(
collapsibleUserMessages: state.collapsibleUserMessages,
stickyUserHeader: state.stickyUserHeader,
promptNavigatorEnabled: state.promptNavigatorEnabled,
expandedEditorToolbar: state.expandedEditorToolbar,
showSplitAssistantMessageActions: state.showSplitAssistantMessageActions,
allowPromptingSubagentSessions: state.allowPromptingSubagentSessions,
draftStartersVisible: state.draftStartersVisible,
@@ -0,0 +1,314 @@
// Tracks every fetch() request as "in flight" from call to promise settle,
// samples two series once per second, and keeps a 5-minute rolling window for
// plotting:
// 1. in-flight request count
// 2. percentile distribution of currently in-flight request ages: p50, p90,
// p99, max (ms since each unsettled fetch started; 0 when nothing is in
// flight)
// Mirrors the streamDebug.ts pattern: collection is gated behind an
// enable/disable toggle (driven by the debug panel), state lives on `window`
// to survive HMR, and the UI polls a serializable snapshot instead of
// subscribing to a store (this is high-frequency debug data, see stores docs).
const STORAGE_KEY = 'openchamber_requests_in_flight';
const SAMPLE_INTERVAL_MS = 1000;
const WINDOW_MS = 5 * 60 * 1000;
const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS);
type RequestsInFlightState = {
enabled: boolean;
startedAt: number;
inFlight: number;
peak: number;
totalStarted: number;
totalSettled: number;
samples: number[];
p50Samples: number[];
p90Samples: number[];
p99Samples: number[];
maxSamples: number[];
peakAgeMs: number;
inFlightStarts: Map<number, number>;
sampleCount: number;
lastSampleAt: number | null;
fetchWrapped: boolean;
originalFetch: typeof window.fetch | null;
sampleTimer: number | null;
};
export type RequestsInFlightSnapshot = {
enabled: boolean;
startedAt: number | null;
durationMs: number;
inFlight: number;
peak: number;
totalStarted: number;
totalSettled: number;
samples: number[];
ageP50: number;
ageP90: number;
ageP99: number;
ageMax: number;
peakAgeMs: number;
p50Samples: number[];
p90Samples: number[];
p99Samples: number[];
maxSamples: number[];
sampleCount: number;
lastSampleAt: number | null;
windowSeconds: number;
};
declare global {
interface Window {
__openchamberRequestsInFlight__?: RequestsInFlightState;
}
}
export const requestsInFlightEnabled = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
};
const createState = (): RequestsInFlightState => {
const startedAt = Date.now();
return {
enabled: true,
startedAt,
inFlight: 0,
peak: 0,
totalStarted: 0,
totalSettled: 0,
samples: [],
p50Samples: [],
p90Samples: [],
p99Samples: [],
maxSamples: [],
peakAgeMs: 0,
inFlightStarts: new Map<number, number>(),
sampleCount: 0,
lastSampleAt: null,
fetchWrapped: false,
originalFetch: null,
sampleTimer: null,
};
};
let nextRequestId = 1;
const recordStart = (id: number, startMs: number): void => {
const state = window.__openchamberRequestsInFlight__;
if (!state || !state.enabled) return;
state.inFlight += 1;
state.totalStarted += 1;
if (state.inFlight > state.peak) state.peak = state.inFlight;
state.inFlightStarts.set(id, startMs);
};
const recordSettle = (id: number): void => {
const state = window.__openchamberRequestsInFlight__;
if (!state || !state.enabled) return;
state.inFlight = Math.max(0, state.inFlight - 1);
state.totalSettled += 1;
state.inFlightStarts.delete(id);
};
// Sorted ages (ms) of every currently in-flight request. Empty when nothing
// is in flight. Used both for live snapshot reporting and per-second sampling.
const currentAges = (state: RequestsInFlightState): number[] => {
if (state.inFlightStarts.size === 0) return [];
const now = Date.now();
const ages: number[] = [];
for (const start of state.inFlightStarts.values()) {
ages.push(Math.max(0, now - start));
}
ages.sort((a, b) => a - b);
return ages;
};
// Linear-interpolation percentile of a pre-sorted array.
const percentile = (sorted: number[], p: number): number => {
const n = sorted.length;
if (n === 0) return 0;
if (n === 1) return sorted[0];
const rank = (p / 100) * (n - 1);
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
};
const installFetchTracker = (): void => {
if (typeof window === 'undefined') return;
const state = window.__openchamberRequestsInFlight__;
if (!state || state.fetchWrapped) return;
const original = window.fetch.bind(window);
state.originalFetch = original;
state.fetchWrapped = true;
const tracker = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const id = nextRequestId++;
recordStart(id, Date.now());
try {
return await original(input, init);
} finally {
recordSettle(id);
}
};
window.fetch = tracker as typeof window.fetch;
};
const uninstallFetchTracker = (): void => {
if (typeof window === 'undefined') return;
const state = window.__openchamberRequestsInFlight__;
if (!state || !state.fetchWrapped || !state.originalFetch) return;
window.fetch = state.originalFetch;
state.fetchWrapped = false;
state.originalFetch = null;
};
const trimSamples = (arr: number[]): void => {
if (arr.length > MAX_SAMPLES) arr.splice(0, arr.length - MAX_SAMPLES);
};
const pushSample = (): void => {
const state = window.__openchamberRequestsInFlight__;
if (!state || !state.enabled) return;
state.samples.push(state.inFlight);
const ages = currentAges(state);
const mx = ages.length > 0 ? ages[ages.length - 1] : 0;
state.p50Samples.push(percentile(ages, 50));
state.p90Samples.push(percentile(ages, 90));
state.p99Samples.push(percentile(ages, 99));
state.maxSamples.push(mx);
if (mx > state.peakAgeMs) state.peakAgeMs = mx;
state.sampleCount += 1;
trimSamples(state.samples);
trimSamples(state.p50Samples);
trimSamples(state.p90Samples);
trimSamples(state.p99Samples);
trimSamples(state.maxSamples);
state.lastSampleAt = Date.now();
};
const startSampling = (): void => {
if (typeof window === 'undefined') return;
const state = window.__openchamberRequestsInFlight__;
if (!state || state.sampleTimer != null) return;
state.sampleTimer = window.setInterval(pushSample, SAMPLE_INTERVAL_MS);
};
const stopSampling = (): void => {
if (typeof window === 'undefined') return;
const state = window.__openchamberRequestsInFlight__;
if (!state || state.sampleTimer == null) return;
window.clearInterval(state.sampleTimer);
state.sampleTimer = null;
};
export const setRequestsInFlightTrackingEnabled = (enabled: boolean): void => {
if (typeof window === 'undefined') return;
try {
if (enabled) {
// Idempotent: tear down any prior tracking first so a repeated
// enable can never wrap window.fetch twice (which would double-count).
stopSampling();
uninstallFetchTracker();
window.localStorage.setItem(STORAGE_KEY, '1');
window.__openchamberRequestsInFlight__ = createState();
installFetchTracker();
startSampling();
return;
}
window.localStorage.removeItem(STORAGE_KEY);
stopSampling();
uninstallFetchTracker();
delete window.__openchamberRequestsInFlight__;
} catch {
// ignore storage failures in debug helper
}
};
export const resetRequestsInFlight = (): void => {
if (typeof window === 'undefined') return;
const state = window.__openchamberRequestsInFlight__;
if (!state) return;
const fresh = createState();
state.startedAt = fresh.startedAt;
state.inFlight = fresh.inFlight;
state.peak = fresh.peak;
state.totalStarted = fresh.totalStarted;
state.totalSettled = fresh.totalSettled;
state.samples = fresh.samples;
state.p50Samples = fresh.p50Samples;
state.p90Samples = fresh.p90Samples;
state.p99Samples = fresh.p99Samples;
state.maxSamples = fresh.maxSamples;
state.peakAgeMs = fresh.peakAgeMs;
state.inFlightStarts = fresh.inFlightStarts;
state.sampleCount = fresh.sampleCount;
state.lastSampleAt = fresh.lastSampleAt;
};
export const getRequestsInFlightSnapshot = (): RequestsInFlightSnapshot => {
if (typeof window === 'undefined') {
return emptySnapshot();
}
const state = window.__openchamberRequestsInFlight__;
if (!requestsInFlightEnabled() || !state) {
return emptySnapshot();
}
const ages = currentAges(state);
return {
enabled: true,
startedAt: state.startedAt,
durationMs: Math.max(0, Date.now() - state.startedAt),
inFlight: state.inFlight,
peak: state.peak,
totalStarted: state.totalStarted,
totalSettled: state.totalSettled,
samples: state.samples.slice(),
ageP50: percentile(ages, 50),
ageP90: percentile(ages, 90),
ageP99: percentile(ages, 99),
ageMax: ages.length > 0 ? ages[ages.length - 1] : 0,
peakAgeMs: state.peakAgeMs,
p50Samples: state.p50Samples.slice(),
p90Samples: state.p90Samples.slice(),
p99Samples: state.p99Samples.slice(),
maxSamples: state.maxSamples.slice(),
sampleCount: state.sampleCount,
lastSampleAt: state.lastSampleAt,
windowSeconds: MAX_SAMPLES,
};
};
const emptySnapshot = (): RequestsInFlightSnapshot => ({
enabled: false,
startedAt: null,
durationMs: 0,
inFlight: 0,
peak: 0,
totalStarted: 0,
totalSettled: 0,
samples: [],
ageP50: 0,
ageP90: 0,
ageP99: 0,
ageMax: 0,
peakAgeMs: 0,
p50Samples: [],
p90Samples: [],
p99Samples: [],
maxSamples: [],
sampleCount: 0,
lastSampleAt: null,
windowSeconds: MAX_SAMPLES,
});