Merge remote-tracking branch 'origin/main' into feat/nested-git-repos

# Conflicts:
#	packages/ui/src/components/views/GitView.tsx
#	packages/ui/src/stores/DOCUMENTATION.md
#	packages/ui/src/stores/useGitStore.ts
This commit is contained in:
jaygupta17
2026-08-30 09:48:35 +05:30
640 changed files with 46571 additions and 5636 deletions
+13 -3
View File
@@ -38,7 +38,7 @@ Examples:
- `useFeatureFlagsStore.ts`
- `useUpdateStore.ts`
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.
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. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
Context-panel session chats mount only the active chat iframe. After installing
its message listener, the iframe requests its authoritative visibility from the
@@ -84,7 +84,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors.
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
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.
@@ -147,11 +147,13 @@ Important properties:
- `directories: Map<string, DirectoryGitState>` is the source of truth
- loading state is per-directory, not global
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
- in-flight dedupe exists for status and `ensureAll()`
- in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request
- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
- a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today)
- `fetchAll({ force: true })` forces the status fetch as well as the log refresh
- branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once
- diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected
@@ -214,6 +216,13 @@ Each of them therefore keeps two things:
- 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
@@ -306,6 +315,7 @@ Expected model:
- `GitView` / `DiffView` ensure current-directory Git state when visible
- explicit Git actions refresh status/branches/log as needed
- every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry
- a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint
- a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops
- refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView
@@ -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(); };
+43 -7
View File
@@ -27,13 +27,19 @@ interface AgentMemoryState {
projectPath: string | null;
loading: boolean;
loaded: boolean;
/** When the held entries were last read successfully. */
loadedAt: number | null;
/** True once the server has reported the feature switched off. */
disabled: boolean;
globalFailed: boolean;
projectFailed: boolean;
error: string | null;
load: (projectPath: string | null) => Promise<void>;
/**
* `maxAgeMs` skips the read when the same project's entries were loaded
* more recently than that; omit it for an unconditional re-read.
*/
load: (projectPath: string | null, options?: { maxAgeMs?: number }) => Promise<void>;
/** Re-read the store the last load used. */
refresh: () => Promise<void>;
saveEntry: (
@@ -55,8 +61,17 @@ const EMPTY_STATE = {
globalFailed: false,
projectFailed: false,
error: null as string | null,
loadedAt: null as number | 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
@@ -91,20 +106,37 @@ const errorMessage = (error: unknown, fallback: string): string => (
export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
...EMPTY_STATE,
load: async (projectPath) => {
load: async (projectPath, options) => {
const previous = get();
const ownerChanged = previous.projectPath !== projectPath;
if (
options?.maxAgeMs !== undefined
&& !ownerChanged
&& previous.loaded
&& previous.loadedAt !== null
&& Date.now() - previous.loadedAt < options.maxAgeMs
) {
return;
}
const requestId = ++loadSequence;
set({ loading: true, 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,
loading: false,
loaded: true,
loadedAt: Date.now(),
disabled: false,
error: null,
});
@@ -119,7 +151,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 +193,3 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
set({ ...EMPTY_STATE });
},
}));
@@ -268,6 +268,7 @@ describe('useConfigStore provider persistence', () => {
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
selectedProviderId: '',
currentAgentName: undefined,
agents: [],
@@ -525,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 });
@@ -643,6 +698,79 @@ describe('useConfigStore provider persistence', () => {
expect(state.currentModelId).toBe('model-a');
});
test('[issue-2531] setAgent keeps the manual model when switching to an agent without an override', () => {
const sessionId = 'ses_2531_mode_switch';
useSessionUIStore.setState({ currentSessionId: sessionId });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('deepseek', 'deepseek-v4-pro'), provider('kimi', 'kimi-k3')],
agents: [testAgent('build'), testAgent('plan')],
settingsDefaultModel: 'deepseek/deepseek-v4-pro',
currentProviderId: 'kimi',
currentModelId: 'kimi-k3',
currentAgentName: 'build',
selectionSource: 'manual',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentAgentName).toBe('plan');
expect(state.currentProviderId).toBe('kimi');
expect(state.currentModelId).toBe('kimi-k3');
});
test('[issue-2690] setAgent persists the kept manual model for the session and agent', () => {
const sessionId = 'ses_2690_persist_kept_model';
useSessionUIStore.setState({ currentSessionId: sessionId });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('deepseek', 'deepseek-v4-pro'), provider('kimi', 'kimi-k3')],
agents: [testAgent('build'), testAgent('plan')],
settingsDefaultModel: 'deepseek/deepseek-v4-pro',
currentProviderId: 'kimi',
currentModelId: 'kimi-k3',
currentAgentName: 'build',
selectionSource: 'manual',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
// Keeping the pair only in memory loses it on reload; the write is what
// makes the choice survive.
const selection = useSelectionStore.getState();
expect(selection.getSessionModelSelection(sessionId)).toEqual({ providerId: 'kimi', modelId: 'kimi-k3' });
expect(selection.getAgentModelForSession(sessionId, 'plan')).toEqual({ providerId: 'kimi', modelId: 'kimi-k3' });
});
test('[issue-2690] setAgent falls back to the settings default when the kept model is gone', () => {
const sessionId = 'ses_2690_stale_model';
useSessionUIStore.setState({ currentSessionId: sessionId });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('deepseek', 'deepseek-v4-pro')],
agents: [testAgent('build'), testAgent('plan')],
settingsDefaultModel: 'deepseek/deepseek-v4-pro',
// The provider still exists but this model was removed from it.
currentProviderId: 'deepseek',
currentModelId: 'retired-model',
currentAgentName: 'build',
selectionSource: 'manual',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('deepseek');
expect(state.currentModelId).toBe('deepseek-v4-pro');
});
test('loadAgents does not fetch OpenCode config directly', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
@@ -700,6 +828,29 @@ describe('useConfigStore provider persistence', () => {
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,
@@ -1036,6 +1187,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: {
@@ -1057,6 +1210,7 @@ describe('useConfigStore provider persistence', () => {
agents: [testAgent('other-agent')],
currentProviderId: 'other',
currentModelId: 'other-model',
currentVariant: 'low',
currentAgentName: 'other-agent',
selectedProviderId: 'other',
agentModelSelections: {},
@@ -1076,6 +1230,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', () => {
+129 -26
View File
@@ -67,7 +67,26 @@ interface OpenChamberDefaults {
sttLanguage?: string;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
// Directory activation re-reads the OpenChamber defaults, which are global,
// not per directory: one request serves the switches that land inside this
// window, and concurrent activations share the in-flight one.
const OPENCHAMBER_DEFAULTS_FRESH_MS = 15_000;
let openChamberDefaultsCache: { at: number; request: Promise<OpenChamberDefaults> } | null = null;
const fetchOpenChamberDefaults = (): Promise<OpenChamberDefaults> => {
const now = Date.now();
if (openChamberDefaultsCache && now - openChamberDefaultsCache.at < OPENCHAMBER_DEFAULTS_FRESH_MS) {
return openChamberDefaultsCache.request;
}
const request = requestOpenChamberDefaults();
openChamberDefaultsCache = { at: now, request };
request.catch(() => {
if (openChamberDefaultsCache?.request === request) openChamberDefaultsCache = null;
});
return request;
};
const requestOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
markStartupTrace('config.defaults:start');
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
const finish = (source: string, result: OpenChamberDefaults) => {
@@ -885,6 +904,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 +1030,7 @@ interface ConfigStore {
currentProviderId: string;
currentModelId: string;
currentVariant: string | undefined;
currentVariantSelection: CurrentVariantSelection;
currentAgentName: string | undefined;
selectedProviderId: string;
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
@@ -1042,6 +1067,10 @@ interface ConfigStore {
sayVoice: string;
browserVoice: string;
localTtsVoiceId: number;
/** Local TTS model the chosen voice belongs to (catalog id). */
localTtsModelId: string;
/** Local and macOS voices follow the language of the text being read. */
ttsFollowTextLanguage: boolean;
openaiVoice: string;
openaiApiKey: string;
openaiCompatibleUrl: string;
@@ -1069,6 +1098,8 @@ interface ConfigStore {
setSayVoice: (voice: string) => void;
setBrowserVoice: (voice: string) => void;
setLocalTtsVoiceId: (voiceId: number) => void;
setLocalTtsModelId: (modelId: string) => void;
setTtsFollowTextLanguage: (enabled: boolean) => void;
setOpenaiVoice: (voice: string) => void;
setOpenaiApiKey: (apiKey: string) => void;
setOpenaiCompatibleUrl: (url: string) => void;
@@ -1098,7 +1129,8 @@ 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; projectDefaultVariant?: string }) => void;
@@ -1171,6 +1203,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: "",
currentModelId: "",
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
@@ -1250,6 +1283,21 @@ export const useConfigStore = create<ConfigStore>()(
}
return 0;
})(),
localTtsModelId: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('localTtsModelId');
if (saved) return saved;
}
return 'kokoro-en-v0_19';
})(),
ttsFollowTextLanguage: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ttsFollowTextLanguage');
if (saved !== null) return saved === 'true';
}
return true;
})(),
// Browser voice - load from localStorage or default to empty (auto-select)
browserVoice: (() => {
if (typeof window !== 'undefined') {
@@ -1437,6 +1485,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,
@@ -1453,6 +1502,7 @@ export const useConfigStore = create<ConfigStore>()(
agents: [],
currentProviderId: "",
currentModelId: "",
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
@@ -1847,13 +1897,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,
@@ -1865,18 +1924,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",
},
},
};
});
@@ -1894,22 +1952,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) => {
@@ -2413,6 +2475,9 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId,
currentModelId,
} = get();
// Captured before the first set below, which unconditionally
// marks the selection as manual.
const hadManualSelection = get().selectionSource === "manual";
set((state) => {
const directoryKey = state.activeDirectoryKey;
@@ -2532,8 +2597,7 @@ export const useConfigStore = create<ConfigStore>()(
// Prefer a session-level manual override for this agent over the
// agent's configured default. Re-applying setAgent after subtask
// completion / rematerialization must not clobber the override
// (issue #2404). Explicit agent-picker switches still force the
// agent default via ModelControls' shouldPreferAgentModel path.
// (issue #2404).
if (currentSessionId) {
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
@@ -2562,6 +2626,27 @@ export const useConfigStore = create<ConfigStore>()(
}
}
// The user has a live manual model selection and the target
// agent configures no model of its own. Switching modes or
// agents must not reset the selection to the settings default
// (issue #2531) — mode switches are not model changes.
if (
hadManualSelection
&& currentProviderId
&& currentModelId
&& hasProviderModel(providers, currentProviderId, currentModelId)
) {
// Keeping the pair in memory is not enough: without a write
// the settings default wins again after a reload. The removed
// ModelControls path persisted here, so this must too.
if (currentSessionId) {
const selection = useSelectionStore.getState();
selection.saveSessionModelSelection(currentSessionId, currentProviderId, currentModelId);
selection.saveAgentModelForSession(currentSessionId, agentName, currentProviderId, currentModelId);
}
return;
}
// If the agent has no preferred model, use settings default.
if (settingsDefaultModel) {
const parsed = parseModelString(settingsDefaultModel);
@@ -2659,6 +2744,10 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
nextState.currentVariant = resolvedVariant;
nextState.currentVariantSelection = {
override: resolvedVariant,
inherited: resolvedVariant,
};
}
return nextState;
@@ -2894,6 +2983,20 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setLocalTtsModelId: (modelId: string) => {
set({ localTtsModelId: modelId });
if (typeof window !== 'undefined') {
localStorage.setItem('localTtsModelId', modelId);
}
},
setTtsFollowTextLanguage: (enabled: boolean) => {
set({ ttsFollowTextLanguage: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('ttsFollowTextLanguage', String(enabled));
}
},
setBrowserVoice: (voice: string) => {
set({ browserVoice: voice });
if (typeof window !== 'undefined') {
+2 -1
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import { getDesktopHomeDirectory, isVSCodeRuntime } from '@/lib/desktop';
import { getVSCodeBootstrapConfig } from '@/lib/vscodeBootstrap';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { updateDesktopSettings } from '@/lib/persistence';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
@@ -227,7 +228,7 @@ const getVsCodeWorkspaceFolder = (): string | null => {
if (!isVSCodeRuntime()) {
return null;
}
const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
const workspaceFolder = getVSCodeBootstrapConfig()?.workspaceFolder;
if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) {
return null;
}
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitStatus } from '@/lib/api/types';
import { useGitStore } from './useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
// The real transport has no server in tests and fails as a generic error.
// Tests that exercise other failure modes swap this implementation; the
@@ -140,6 +141,85 @@ describe('useGitStore', () => {
expect(lightResult).toBe(fullResult);
});
test('deduplicates concurrent status requests when no mutation occurs', async () => {
setDirectoryStatus(createStatus());
let statusCalls = 0;
const request = createDeferred<GitStatus>();
const git = createGitApi(() => {
statusCalls += 1;
return request.promise;
});
const first = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
const second = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
await Promise.resolve();
expect(statusCalls).toBe(1);
request.resolve(createStatus());
await Promise.all([first, second]);
expect(statusCalls).toBe(1);
});
test('a refresh after a mutation does not join the pre-mutation in-flight status request', async () => {
setDirectoryStatus(createStatus());
const requests: Deferred<GitStatus>[] = [];
let statusCalls = 0;
const git = createGitApi(() => {
statusCalls += 1;
const request = createDeferred<GitStatus>();
requests.push(request);
return request.promise;
});
const preMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
await Promise.resolve();
expect(statusCalls).toBe(1);
// A successful git mutation invalidates the adapter status cache, which
// notifies the store that the in-flight request predates the mutation.
notifyGitStatusInvalidated('/repo');
const postMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
await Promise.resolve();
expect(statusCalls).toBe(2);
requests[1].resolve({ ...createStatus(), current: 'feature' });
await postMutation;
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
// The late pre-mutation response cannot overwrite the newer authoritative one.
requests[0].resolve(createStatus());
await preMutation;
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
});
test('fetchAll({ force: true }) forces a fresh status fetch past the in-flight dedup', async () => {
setDirectoryStatus(createStatus());
const requests: Deferred<GitStatus>[] = [];
let statusCalls = 0;
const git = createGitApi(() => {
statusCalls += 1;
const request = createDeferred<GitStatus>();
requests.push(request);
return request.promise;
});
const inFlight = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
await Promise.resolve();
expect(statusCalls).toBe(1);
const all = useGitStore.getState().fetchAll('/repo', git, { force: true });
await Promise.resolve();
expect(statusCalls).toBe(2);
requests[1].resolve({ ...createStatus(), current: 'feature' });
requests[0].resolve(createStatus());
await Promise.allSettled([inFlight, all]);
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
});
test('does not let an older status fetch undo an optimistic mutation', async () => {
const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]);
setDirectoryStatus(initial);
+39 -10
View File
@@ -10,6 +10,7 @@ import type {
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp';
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
@@ -63,7 +64,7 @@ interface GitStore {
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise<boolean>;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise<boolean>;
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
@@ -123,7 +124,7 @@ interface GitAPI {
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
const diffFetchGenerationByDirectory = new Map<string, number>();
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
const inFlightStatusFetches = new Map<string, { promise: Promise<boolean>; statusMutationRevision: number }>();
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
const inFlightNestedRepoDiscovery = new Map<string, Promise<void>>();
const requestGenerationByChannel = new Map<string, number>();
@@ -131,7 +132,10 @@ const statusMutationRevisionByDirectory = new Map<string, number>();
let gitRuntimeGeneration = 0;
let activeGitRuntimeKey = getRuntimeKey();
const runtimeDirectoryKey = (runtimeKey: string, directory: string) => JSON.stringify([runtimeKey, directory]);
// Trimmed to match `gitApiHttp`'s cache keys, so an invalidation notified for a
// directory keys the same entry the store's own lookups do.
const runtimeDirectoryKey = (runtimeKey: string, directory: string) =>
JSON.stringify([runtimeKey, directory.trim()]);
const getStatusFetchKey = (runtimeKey: string, directory: string, mode: GitStatusFetchMode): string =>
JSON.stringify([runtimeKey, directory, mode]);
const channelKey = (runtimeKey: string, directory: string, channel: string) =>
@@ -175,6 +179,18 @@ const bumpStatusMutationRevision = (runtimeKey: string, directory: string): void
statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1);
};
const getStatusMutationRevision = (runtimeKey: string, directory: string): number =>
statusMutationRevisionByDirectory.get(runtimeDirectoryKey(runtimeKey, directory)) ?? 0;
// A successful status-affecting git mutation invalidates the runtime adapter's
// status cache (see lib/gitStatusInvalidation.ts). Bump the per-directory
// mutation revision so a status request admitted before the mutation can
// neither be joined by a post-mutation refresh nor commit its stale payload
// over the refreshed state.
subscribeGitStatusInvalidations((directory) => {
bumpStatusMutationRevision(getRuntimeKey(), directory);
});
const getDiffFetchGeneration = (directory: string): number =>
diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0;
@@ -679,10 +695,16 @@ export const useGitStore = create<GitStore>()(
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
const runtimeKey = getRuntimeKey();
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
const existing = inFlightStatusFetches.get(statusFetchKey)
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined);
if (existing) {
return existing;
const statusMutationRevision = getStatusMutationRevision(runtimeKey, directory);
if (!options.force) {
const existing = inFlightStatusFetches.get(statusFetchKey)
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined);
// Join an in-flight request only when it was admitted at the current
// mutation revision; a request that predates a mutation must not
// satisfy the post-mutation refresh.
if (existing && existing.statusMutationRevision === statusMutationRevision) {
return existing.promise;
}
}
const token = startRequest(directory, 'status', true);
@@ -706,8 +728,12 @@ export const useGitStore = create<GitStore>()(
try {
const now = Date.now();
// A known answer — repo or not — is cached for the stale window.
// Re-probing every non-repo directory (managed chats live in one)
// made each switch into such a directory cost a git check.
const shouldProbeRepository =
dirState.isGitRepo !== true ||
dirState.isGitRepo === null ||
dirState.isGitRepo === undefined ||
now - (dirState.lastRepoCheckAt || 0) > REPO_CHECK_STALE_THRESHOLD;
let isRepo = dirState.isGitRepo === true;
@@ -816,12 +842,12 @@ export const useGitStore = create<GitStore>()(
return statusChanged;
})();
inFlightStatusFetches.set(statusFetchKey, fetchPromise);
inFlightStatusFetches.set(statusFetchKey, { promise: fetchPromise, statusMutationRevision });
try {
return await fetchPromise;
} finally {
if (inFlightStatusFetches.get(statusFetchKey) === fetchPromise) {
if (inFlightStatusFetches.get(statusFetchKey)?.promise === fetchPromise) {
inFlightStatusFetches.delete(statusFetchKey);
}
}
@@ -1025,8 +1051,11 @@ export const useGitStore = create<GitStore>()(
const { force = false, silentIfCached = false } = options;
const now = Date.now();
// `force` applies to status as well as log: a forced refresh must not
// resolve from an in-flight status request admitted earlier.
await get().fetchStatus(directory, git, {
silent: silentIfCached && Boolean(dirState?.status),
force,
});
const updatedDirState = get().directories.get(directory);
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import type { LinearAuthStatus, RuntimeAPIs } from '@/lib/api/types';
type LinearAuthStatusWithError = LinearAuthStatus & { error?: string };
type LinearAuthStore = {
status: LinearAuthStatusWithError | null;
isLoading: boolean;
hasChecked: boolean;
setStatus: (status: LinearAuthStatusWithError | null) => void;
refreshStatus: (
runtimeLinear?: RuntimeAPIs['linear'],
options?: { force?: boolean }
) => Promise<LinearAuthStatusWithError | null>;
};
const fetchStatus = async (
runtimeLinear?: RuntimeAPIs['linear']
): Promise<LinearAuthStatusWithError> => {
if (!runtimeLinear) {
return { connected: false };
}
return runtimeLinear.authStatus();
};
let inFlightAuthRefresh: Promise<LinearAuthStatusWithError | null> | null = null;
export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
status: null,
isLoading: false,
hasChecked: false,
setStatus: (status) => set({ status, hasChecked: true }),
refreshStatus: async (runtimeLinear, options) => {
if (!runtimeLinear) {
return get().status;
}
const { hasChecked, status } = get();
if (hasChecked && !options?.force) {
return status;
}
if (inFlightAuthRefresh) return inFlightAuthRefresh;
set({ isLoading: true });
inFlightAuthRefresh = (async () => {
try {
const payload = await fetchStatus(runtimeLinear);
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A failed request is not an authoritative disconnect. Keep the last
// known status and leave `hasChecked` false so the next caller retries
// instead of hiding Linear for the rest of the session.
set((state) => ({
status: state.status
? { ...state.status, error: message }
: { connected: false, error: message },
isLoading: false,
}));
return null;
}
})().finally(() => { inFlightAuthRefresh = null; });
return inFlightAuthRefresh;
},
}));
+25
View File
@@ -53,6 +53,8 @@ type RefreshOptions = {
silent?: boolean;
};
const ensureFreshInFlight = new Map<string, Promise<void>>();
type TestConnectionResult = {
status?: McpStatus;
error?: string;
@@ -64,11 +66,19 @@ interface McpStore {
diagnosticsByDirectory: Record<string, McpRuntimeDiagnosticMap>;
loadingKeys: Record<string, boolean>;
lastErrorKeys: Record<string, string | null>;
/** When each directory's status was last fetched successfully. */
refreshedAtKeys: Record<string, number>;
getStatusForDirectory: (directory?: string | null) => McpStatusMap;
getDiagnosticForDirectory: (directory?: string | null) => McpRuntimeDiagnosticMap;
getErrorForDirectory: (directory?: string | null) => string | null;
refresh: (options?: RefreshOptions) => Promise<void>;
/**
* Refresh only when the directory has no status yet or the last successful
* fetch is older than `maxAgeMs`. Mount-time consumers use this so a panel
* that remounts on every session switch does not refetch on every switch.
*/
ensureFresh: (options: RefreshOptions & { maxAgeMs: number }) => Promise<void>;
connect: (name: string, directory?: string | null) => Promise<void>;
disconnect: (name: string, directory?: string | null) => Promise<void>;
startAuth: (name: string, directory?: string | null) => Promise<string>;
@@ -89,6 +99,7 @@ export const useMcpStore = create<McpStore>()(
diagnosticsByDirectory: {},
loadingKeys: {},
lastErrorKeys: {},
refreshedAtKeys: {},
getStatusForDirectory: (directory) => {
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
@@ -131,6 +142,7 @@ export const useMcpStore = create<McpStore>()(
},
loadingKeys: { ...state.loadingKeys, [key]: false },
lastErrorKeys: { ...state.lastErrorKeys, [key]: null },
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
@@ -141,6 +153,19 @@ export const useMcpStore = create<McpStore>()(
}
},
ensureFresh: async ({ maxAgeMs, ...options }) => {
const key = toKey(normalizeDirectory(options.directory ?? useDirectoryStore.getState().currentDirectory));
const refreshedAt = get().refreshedAtKeys[key];
if (refreshedAt !== undefined && Date.now() - refreshedAt < maxAgeMs) return;
const inFlight = ensureFreshInFlight.get(key);
if (inFlight) return inFlight;
const request = get().refresh(options).finally(() => {
ensureFreshInFlight.delete(key);
});
ensureFreshInFlight.set(key, request);
return request;
},
connect: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const key = toKey(normalized);
@@ -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
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ProjectEntry } from "@/lib/api/types"
import type { DesktopSettings } from "@/lib/desktop"
import { useProjectsStore } from "./useProjectsStore"
import { useDirectoryStore } from "./useDirectoryStore"
describe("useProjectsStore settings synchronization", () => {
test("treats a successful empty project snapshot as authoritative", () => {
@@ -18,6 +19,39 @@ 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", () => {
@@ -86,3 +120,53 @@ describe("useProjectsStore default model and thinking level", () => {
expect(project?.defaultVariant).toBe(undefined)
})
})
describe("useProjectsStore.addProjects", () => {
const resetProjects = () => {
useProjectsStore.setState({
projects: [],
activeProjectId: null,
manualProjectOrder: [],
})
}
test("adds multiple new projects in one update and activates the first", async () => {
resetProjects()
const added = await useProjectsStore.getState().addProjects(["/one", "/two", "/three"])
expect(added).toHaveLength(3)
expect(useProjectsStore.getState().projects.map((p) => p.path)).toEqual(["/one", "/two", "/three"])
expect(useProjectsStore.getState().activeProjectId).toBe(added[0].id)
expect(added[0].addedAt).toBe(added[1].addedAt)
})
test("skips already-added paths and duplicates within the batch", async () => {
resetProjects()
await useProjectsStore.getState().addProjects(["/one"])
const added = await useProjectsStore.getState().addProjects(["/one", "/two", "/two", "/one"])
expect(added).toHaveLength(1)
expect(added[0].path).toBe("/two")
expect(useProjectsStore.getState().projects.map((p) => p.path)).toEqual(["/one", "/two"])
})
test("skips invalid paths and returns an empty array when nothing is addable", async () => {
resetProjects()
const added = await useProjectsStore.getState().addProjects(["", " ", 42 as unknown as string])
expect(added).toEqual([])
expect(useProjectsStore.getState().projects).toEqual([])
})
test("normalizes paths (trailing separators, backslashes, tilde expansion)", async () => {
resetProjects()
const added = await useProjectsStore.getState().addProjects(["/repo/", "C:\\repo", "~/project"])
const home = useDirectoryStore.getState().homeDirectory;
expect(added.map((p) => p.path)).toEqual(["/repo", "C:/repo", home ? `${home}/project` : "~/project"])
})
})
+124 -18
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';
@@ -13,7 +13,8 @@ import { PROJECT_COLORS } from '@/lib/projectMeta';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getVSCodeBootstrapConfig, isVSCodeRuntime } from './utils/vscodeRuntime';
import { getVSCodeBootstrapConfig } from '@/lib/vscodeBootstrap';
import { isVSCodeRuntime } from './utils/vscodeRuntime';
/** Pick a color key that's least used among existing projects */
const pickAutoColor = (projects: ProjectEntry[]): string => {
@@ -49,7 +50,8 @@ interface ProjectsStore {
activeProjectId: string | null;
manualProjectOrder: string[];
addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null;
addProject: (path: string, options?: { label?: string; id?: string }) => Promise<ProjectEntry | null>;
addProjects: (paths: string[]) => Promise<ProjectEntry[]>;
removeProject: (id: string) => void;
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
@@ -68,7 +70,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;
}
@@ -167,6 +169,13 @@ const normalizeProjectPath = (value: string): string => {
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
};
// VS Code workspace folder paths come from the extension host with uppercase
// drive letters (see resolveWorkspaceFolders in packages/vscode), while paths
// typed or browsed in the webview keep the lowercase drive of fsPath. Normalize
// to the workspace form so dedupe and active-path matching agree on Windows.
const normalizeVSCodeWorkspacePath = (value: string): string =>
value.replace(/^([a-z]):/, (_, letter: string) => letter.toUpperCase() + ':');
// Folder names are shown verbatim: title-casing them turned `.ssh` into `.Ssh`
// and made every project look like a name the user never chose.
const deriveProjectLabel = (path: string): string => {
@@ -584,8 +593,29 @@ export const useProjectsStore = create<ProjectsStore>()(
return { ok: true, normalizedPath: normalized };
},
addProject: (path: string, options?: { label?: string; id?: string }) => {
addProject: async (path: string, options?: { label?: string; id?: string }) => {
if (isVSCodeProjectsRuntime) {
// Projects are scoped to VS Code workspace folders in this runtime.
// Adding a folder through the extension host makes the project appear
// in the workspace and the new folder is synced back as a project.
const validation = get().validateProjectPath(path);
if (!validation.ok || !validation.normalizedPath) {
return null;
}
const normalizedPath = normalizeVSCodeWorkspacePath(validation.normalizedPath);
const existing = get().projects.find((project) => project.path === normalizedPath);
if (existing) {
return existing;
}
const runtimeApis = getRegisteredRuntimeAPIs();
if (runtimeApis?.vscode?.addWorkspaceFolder) {
try {
const folders = await runtimeApis.vscode.addWorkspaceFolder(normalizedPath);
return get().syncVSCodeWorkspaceFolders(folders, normalizedPath);
} catch {
return null;
}
}
return null;
}
const { validateProjectPath } = get();
@@ -625,6 +655,69 @@ export const useProjectsStore = create<ProjectsStore>()(
return entry;
},
addProjects: async (paths: string[]) => {
if (isVSCodeProjectsRuntime) {
// VS Code paths are added via runtimeApis.vscode.addWorkspaceFolder,
// which is reached only by addProject. Iterate so valid selections
// succeed instead of silently returning []. Dedupe by path so the
// returned array mirrors the non-VS Code contract.
const added: ProjectEntry[] = [];
const seen = new Set<string>();
for (const path of paths) {
if (seen.has(path)) continue;
seen.add(path);
const project = await get().addProject(path);
if (project) {
added.push(project);
}
}
return added;
}
const current = get();
const existingPaths = new Set(current.projects.map((project) => project.path));
const now = Date.now();
const entries: ProjectEntry[] = [];
const seenPaths = new Set<string>();
for (const rawPath of paths) {
const validation = get().validateProjectPath(rawPath);
if (!validation.ok || !validation.normalizedPath) {
continue;
}
const normalizedPath = validation.normalizedPath;
if (existingPaths.has(normalizedPath) || seenPaths.has(normalizedPath)) {
continue;
}
seenPaths.add(normalizedPath);
entries.push({
id: createProjectIdFromPath(normalizedPath),
path: normalizedPath,
label: deriveProjectLabel(normalizedPath),
color: pickAutoColor([...current.projects, ...entries]),
addedAt: now,
lastOpenedAt: now,
});
}
if (entries.length === 0) {
return [];
}
const nextProjects = [...current.projects, ...entries];
set({ projects: nextProjects });
if (streamDebugEnabled()) {
console.info('[ProjectsStore] Added projects', entries);
}
// Mirror addProject: the first newly added project becomes active.
get().setActiveProject(entries[0].id);
for (const entry of entries) {
void get().discoverProjectIcon(entry.id);
}
return entries;
},
removeProject: (id: string) => {
if (isVSCodeProjectsRuntime) {
return;
@@ -809,7 +902,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) {
@@ -838,7 +931,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) {
@@ -874,7 +967,7 @@ export const useProjectsStore = create<ProjectsStore>()(
}
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return {
@@ -924,32 +1017,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 });
@@ -1005,9 +1109,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,
});
}
});
}
@@ -0,0 +1,176 @@
// Regression test for issue #2582: "Add Project" in the VS Code extension
// always failed with the "Failed to add project" toast because
// useProjectsStore.addProject() returned null unconditionally in the VS Code
// runtime (projects are scoped to VS Code workspace folders). The fix makes
// addProject() add the chosen directory as a workspace folder through the
// extension host and sync the new folder back as a project.
import { beforeEach, describe, expect, mock, test } from 'bun:test';
// VS Code runtime detection reads window.__VSCODE_CONFIG__ at module load time;
// bun test has no browser window, so install a test window before importing the
// store (mirrors packages/vscode/src/webviewHtml.ts which sets the config).
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__VSCODE_CONFIG__: {
workspaceFolder: '/workspace/project-one',
workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }],
},
__OPENCHAMBER_LOCAL_ORIGIN__: '',
addEventListener: () => {},
removeEventListener: () => {},
},
});
// Transitive imports read location.search / navigator / localStorage as bare
// globals at module load time.
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: { href: 'https://example.test/', search: '', pathname: '/', hash: '' },
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { platform: 'linux', userAgent: 'bun-test', language: 'en-US', maxTouchPoints: 0 },
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: (() => {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => { store.set(key, String(value)); },
removeItem: (key: string) => { store.delete(key); },
clear: () => { store.clear(); },
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() { return store.size; },
};
})(),
});
const noop = () => {};
const opencodeClientStub = new Proxy(
{
setDirectory: noop,
getDirectory: () => null,
getFilesystemHome: async () => null,
getSystemInfo: async () => null,
listLocalDirectory: async () => [],
cloneRepository: async () => ({}),
createDirectory: async () => {},
},
{
get(target, prop) {
if (prop in target) {
// SAFETY: `prop in target` was just checked, so the key exists on the
// stub object and the cast narrows to its known key type.
return target[prop as keyof typeof target];
}
return noop;
},
},
);
mock.module('@/lib/opencode/client', () => ({
opencodeClient: opencodeClientStub,
}));
mock.module('@/lib/persistence', () => ({
updateDesktopSettings: async () => {},
}));
const addWorkspaceFolderCalls: string[] = [];
let addWorkspaceFolderError: Error | null = null;
// SAFETY: the store only needs the vscode capability plus the runtime flag;
// everything else on RuntimeAPIs is never reached by the addProject path.
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: () => ({
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' },
vscode: {
async addWorkspaceFolder(path: string) {
addWorkspaceFolderCalls.push(path);
if (addWorkspaceFolderError) {
throw addWorkspaceFolderError;
}
return [
{ name: 'project-one', path: '/workspace/project-one' },
{ name: 'my-project', path },
];
},
},
}),
registerRuntimeAPIs: () => {},
}));
const { useProjectsStore } = await import('@/stores/useProjectsStore');
beforeEach(() => {
addWorkspaceFolderCalls.length = 0;
addWorkspaceFolderError = null;
});
describe('issue #2582: addProject in the VS Code runtime', () => {
test('adds the directory as a workspace folder and syncs it as a project', async () => {
const added = await useProjectsStore.getState().addProject('/home/user/my-project');
expect(addWorkspaceFolderCalls).toEqual(['/home/user/my-project']);
expect(added).not.toBeNull();
expect(added?.path).toBe('/home/user/my-project');
expect(useProjectsStore.getState().projects.find((p) => p.path === '/home/user/my-project')).toBeTruthy();
});
test('returns the existing project for a folder already in the workspace without calling the host', async () => {
const existing = await useProjectsStore.getState().addProject('/workspace/project-one');
expect(addWorkspaceFolderCalls).toEqual([]);
expect(existing?.path).toBe('/workspace/project-one');
});
test('returns null when the extension host cannot add the folder', async () => {
addWorkspaceFolderError = new Error('cancelled');
const added = await useProjectsStore.getState().addProject('/other/path');
expect(added).toBeNull();
expect(useProjectsStore.getState().projects.find((p) => p.path === '/other/path')).toBeFalsy();
});
test('addProjects iterates addProject in the VS Code runtime so valid selections succeed', async () => {
// Regression: addProjects used to return [] unconditionally for the
// VS Code runtime, which made the batch-add path toast "Failed to
// add project" even for valid selections. The fix calls
// addWorkspaceFolder per path; we assert the host is invoked once
// per selection (not skipped) and that any successful add returns
// a non-null entry.
const added = await useProjectsStore.getState().addProjects([
'/home/user/project-a',
'/home/user/project-b',
]);
expect(addWorkspaceFolderCalls).toEqual([
'/home/user/project-a',
'/home/user/project-b',
]);
// The mock's addWorkspaceFolder returns the second entry keyed by
// `path`, so project-a lands; project-b is not reflected in
// projects because the mock's hardcoded return array doesn't
// include it. The point of the test is the call sequence, not the
// final projects state (covered by the dedicated addProject tests).
expect(added.length).toBeGreaterThanOrEqual(1);
});
test('addProjects dedupes paths within a single batch in the VS Code runtime', async () => {
// A path repeated within one batch must hit the extension host once,
// not twice — mirrors the non-VS Code contract (seenPaths Set).
addWorkspaceFolderCalls.length = 0;
await useProjectsStore.getState().addProjects([
'/home/user/project-a',
'/home/user/project-a',
'/home/user/project-b',
]);
expect(addWorkspaceFolderCalls).toEqual([
'/home/user/project-a',
'/home/user/project-b',
]);
});
});
@@ -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', () => {
@@ -142,6 +319,75 @@ describe('useUIStore closeContextPanelTab surface stability', () => {
});
});
describe('useUIStore closeContextPanelTabs bulk', () => {
const directory = '/repo';
test('closing every tab of the only surface closes the panel', () => {
useUIStore.getState().openContextBrowser(directory, 'https://a.test');
useUIStore.getState().openContextBrowser(directory, 'https://b.test');
useUIStore.getState().openContextBrowser(directory, 'https://c.test');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const ids = state0?.tabs.map((tab) => tab.id) ?? [];
useUIStore.getState().closeContextPanelTabs(directory, ids);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs).toHaveLength(0);
expect(state?.isOpen).toBe(false);
});
test('closing all tabs of the active surface closes the panel but keeps other surfaces in state', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileIds = state0?.tabs.filter((tab) => tab.mode === 'file').map((tab) => tab.id) ?? [];
useUIStore.getState().closeContextPanelTabs(directory, fileIds);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['terminal']);
expect(state?.activeTabId).toBe('terminal');
// Matches the single-close rule: emptying the active surface closes the panel.
expect(state?.isOpen).toBe(false);
});
test('closing only inactive-mode tabs leaves the active tab and panel intact', () => {
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTab = state0?.tabs.find((tab) => tab.mode === 'file');
useUIStore.getState().closeContextPanelTabs(directory, [fileTab?.id as string]);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.activeTabId).toBe('terminal');
expect(state?.isOpen).toBe(true);
});
test('closing a subset of the active surface including the active tab keeps a remaining same-mode tab', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
useUIStore.getState().openContextFile(directory, '/repo/c.ts');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTabs = state0?.tabs.filter((tab) => tab.mode === 'file') ?? [];
const keptFile = fileTabs.find((tab) => tab.targetPath === '/repo/a.ts');
const closedIds = fileTabs.filter((tab) => tab.id !== keptFile?.id).map((tab) => tab.id);
expect(state0?.tabs.find((tab) => tab.id === state0.activeTabId)?.targetPath).toBe('/repo/c.ts');
useUIStore.getState().closeContextPanelTabs(directory, closedIds);
const state = useUIStore.getState().contextPanelByDirectory[directory];
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
expect(activeTab?.mode).toBe('file');
expect(activeTab?.targetPath).toBe('/repo/a.ts');
expect(state?.isOpen).toBe(true);
expect(state?.tabs.some((tab) => tab.mode === 'terminal')).toBe(true);
});
});
describe('useUIStore per-surface panel widths', () => {
const directory = '/repo';
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore';
describe('linear issue list filters', () => {
beforeEach(() => {
useUIStore.setState({
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListPriority: 'all',
linearIssueFocus: null,
});
});
test('stores status, assignee, team, and priority across setter calls', () => {
useUIStore.getState().setLinearIssueListStatus('todo');
expect(useUIStore.getState().linearIssueListStatus).toBe('todo');
useUIStore.getState().setLinearIssueListStatus('started');
expect(useUIStore.getState().linearIssueListStatus).toBe('started');
useUIStore.getState().setLinearIssueListStatus('inReview');
expect(useUIStore.getState().linearIssueListStatus).toBe('inReview');
useUIStore.getState().setLinearIssueListStatus('completed');
expect(useUIStore.getState().linearIssueListStatus).toBe('completed');
useUIStore.getState().setLinearIssueListStatus('canceled');
expect(useUIStore.getState().linearIssueListStatus).toBe('canceled');
useUIStore.getState().setLinearIssueListStatus('duplicate');
expect(useUIStore.getState().linearIssueListStatus).toBe('duplicate');
useUIStore.getState().setLinearIssueListStatus('backlog');
expect(useUIStore.getState().linearIssueListStatus).toBe('backlog');
useUIStore.getState().setLinearIssueListStatus('all');
useUIStore.getState().setLinearIssueListAssignee('me');
useUIStore.getState().setLinearIssueListTeamId('team-eng');
useUIStore.getState().setLinearIssueListPriority('urgent');
expect(useUIStore.getState().linearIssueListStatus).toBe('all');
expect(useUIStore.getState().linearIssueListAssignee).toBe('me');
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
expect(useUIStore.getState().linearIssueListPriority).toBe('urgent');
});
test('resets status, assignee, team, and priority together', () => {
useUIStore.getState().setLinearIssueListStatus('todo');
useUIStore.getState().setLinearIssueListAssignee('me');
useUIStore.getState().setLinearIssueListTeamId('team-eng');
useUIStore.getState().setLinearIssueListPriority('urgent');
useUIStore.getState().resetLinearIssueListFilters();
expect(useUIStore.getState().linearIssueListStatus).toBe('all');
expect(useUIStore.getState().linearIssueListAssignee).toBe('any');
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
expect(useUIStore.getState().linearIssueListPriority).toBe('all');
});
test('treats a blank team id as all teams', () => {
useUIStore.getState().setLinearIssueListTeamId('team-eng');
useUIStore.getState().setLinearIssueListTeamId(' ');
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
});
test('stores a one-shot Linear issue identifier for the rail panel', () => {
useUIStore.getState().setLinearIssueFocus(' ENG-12 ');
expect(useUIStore.getState().linearIssueFocus).toBe('ENG-12');
useUIStore.getState().setLinearIssueFocus(' ');
expect(useUIStore.getState().linearIssueFocus).toBeNull();
useUIStore.getState().setLinearIssueFocus('ENG-12');
useUIStore.getState().setLinearIssueFocus(null);
expect(useUIStore.getState().linearIssueFocus).toBeNull();
});
});
+253 -60
View File
@@ -7,13 +7,14 @@ 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 type { TerminalShell } from '@/lib/api/types';
import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, 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 PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -24,11 +25,52 @@ export type WeekStartPreference = 'auto' | 'sunday' | 'monday';
export type DesktopWindowControlsPosition = 'left' | 'right';
export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights';
export type FileEditorKeymap = 'default' | 'vim';
export type LargeTextPasteBehavior = 'ask' | 'attach' | 'inline';
export const DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR: LargeTextPasteBehavior = 'ask';
export const normalizeLargeTextPasteBehavior = (value: unknown): LargeTextPasteBehavior => {
if (value === 'attach' || value === 'inline' || value === 'ask') {
return value;
}
return DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR;
};
function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap {
return value === 'vim' ? 'vim' : 'default';
}
export const LINEAR_ISSUE_LIST_ALL_TEAMS = 'all';
function sanitizeLinearIssueListStatus(value: unknown): LinearIssueListStatus {
return value === 'all'
|| value === 'backlog'
|| value === 'todo'
|| value === 'started'
|| value === 'inReview'
|| value === 'completed'
|| value === 'canceled'
|| value === 'duplicate'
? value
: 'all';
}
function sanitizeLinearIssueListAssignee(value: unknown): LinearIssueListAssignee {
return value === 'me' || value === 'any' ? value : 'any';
}
function sanitizeLinearIssueListTeamId(value: unknown): string {
if (typeof value !== 'string') return LINEAR_ISSUE_LIST_ALL_TEAMS;
const teamId = value.trim();
return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS;
}
function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority {
return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all'
? value
: 'all';
}
type ContextPanelTab = {
id: string;
mode: ContextPanelMode;
@@ -37,6 +79,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;
@@ -50,6 +96,7 @@ type ContextPanelTabDescriptor = {
mode: ContextPanelMode;
targetPath?: string | null;
projectPlanId?: string | null;
projectPlanRef?: ProjectRef | null;
dedupeKey?: string | null;
label?: string | null;
sessionTitleFallback?: string | null;
@@ -191,6 +238,18 @@ const normalizePendingDiffScope = (value: unknown): PendingDiffScope | 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 => {
if (mode === 'file') {
return targetPath || mode;
@@ -240,6 +299,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),
@@ -300,6 +360,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
mode?: unknown;
targetPath?: unknown;
projectPlanId?: unknown;
projectPlanRef?: unknown;
dedupeKey?: unknown;
label?: unknown;
sessionTitleFallback?: unknown;
@@ -312,7 +373,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
// Legacy 'preview' tabs are converted to 'browser' by the v14 migration;
// anything still carrying an unknown mode here is discarded rather than
// resurrected into a tab the panel cannot render.
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'linear' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
continue;
}
@@ -323,6 +384,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,
@@ -338,9 +412,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),
@@ -393,7 +466,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.
@@ -403,41 +478,54 @@ 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(),
};
};
const closeContextPanelTab = (
const closeContextPanelTabs = (
current: ContextPanelDirectoryState,
tabID: string,
tabIds: readonly string[],
): ContextPanelDirectoryState => {
const closedTab = current.tabs.find((tab) => tab.id === tabID) ?? null;
const nextTabs = current.tabs.filter((tab) => tab.id !== tabID);
const closed = new Set(tabIds);
const closedTabs = current.tabs.filter((tab) => closed.has(tab.id));
const nextTabs = current.tabs.filter((tab) => !closed.has(tab.id));
if (nextTabs.length === current.tabs.length) {
return current;
}
if (current.activeTabId !== tabID) {
const activeClosed = current.activeTabId ? closed.has(current.activeTabId) : false;
if (!activeClosed) {
return {
...current,
tabs: nextTabs,
@@ -447,10 +535,11 @@ const closeContextPanelTab = (
};
}
// Closing the active tab stays inside the active surface: activate the most
// recent remaining tab of the same mode, and when it was the last one just
// close the panel instead of jumping to another surface.
const sameModeTabs = closedTab ? nextTabs.filter((tab) => tab.mode === closedTab.mode) : [];
// Closing the active tab stays inside its surface: activate the most recent
// remaining tab of the same mode, and when none remain just close the panel
// instead of jumping to another surface.
const activeMode = closedTabs.find((tab) => tab.id === current.activeTabId)?.mode ?? null;
const sameModeTabs = activeMode ? nextTabs.filter((tab) => tab.mode === activeMode) : [];
const nextSameModeTab = sameModeTabs.length > 0
? sameModeTabs.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best))
: null;
@@ -537,6 +626,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,
@@ -556,7 +649,7 @@ const sanitizeContextPanelByDirectory = (
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
if (
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal')
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal')
&& typeof value === 'number'
&& Number.isFinite(value)
) {
@@ -607,6 +700,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;
@@ -722,6 +818,12 @@ interface UIStore {
/** Width of the walkthrough table of contents, in pixels. */
walkthroughTocWidth: number;
gitChangesViewMode: 'flat' | 'tree';
linearIssueListStatus: LinearIssueListStatus;
linearIssueListAssignee: LinearIssueListAssignee;
linearIssueListTeamId: string;
linearIssueListPriority: LinearIssueListPriority;
/** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */
linearIssueFocus: string | null;
isTimelineDialogOpen: boolean;
isPromptNavigatorPanelOpen: boolean;
isImagePreviewOpen: boolean;
@@ -773,6 +875,7 @@ interface UIStore {
/** Active tab of the project context panel (notes/todos/plans). */
projectContextTab: string;
inputSpellcheckEnabled: boolean;
largeTextPasteBehavior: LargeTextPasteBehavior;
wideChatLayoutEnabled: boolean;
codeBlockLineWrap: boolean;
showToolFileIcons: boolean;
@@ -803,19 +906,19 @@ 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;
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
closeContextPanelTab: (directory: string, tabID: string) => void;
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
@@ -828,6 +931,8 @@ 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;
setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void;
@@ -915,6 +1020,12 @@ interface UIStore {
setDiffWrapLines: (wrap: boolean) => void;
setWalkthroughTocWidth: (width: number) => void;
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
setLinearIssueListTeamId: (teamId: string) => void;
setLinearIssueListPriority: (priority: LinearIssueListPriority) => void;
resetLinearIssueListFilters: () => void;
setLinearIssueFocus: (identifier: string | null) => void;
setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void;
setPromptNavigatorPanelOpen: (open: boolean) => void;
@@ -946,6 +1057,7 @@ interface UIStore {
setProjectContextSidebarWidth: (width: number) => void;
setProjectContextTab: (value: string) => void;
setInputSpellcheckEnabled: (value: boolean) => void;
setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void;
setWideChatLayoutEnabled: (value: boolean) => void;
setCodeBlockLineWrap: (value: boolean) => void;
setShowToolFileIcons: (value: boolean) => void;
@@ -990,6 +1102,7 @@ export const useUIStore = create<UIStore>()(
hasManuallyResizedLeftSidebar: false,
contextPanelByDirectory: {},
contextRailOrder: [],
contextRailHiddenSurfaces: [],
contextEditorTreeVisible: true,
contextEditorTreeWidth: 240,
notesPanelHeight: 112,
@@ -1070,6 +1183,11 @@ export const useUIStore = create<UIStore>()(
diffWrapLines: false,
walkthroughTocWidth: 224,
gitChangesViewMode: 'flat',
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListPriority: 'all',
linearIssueFocus: null,
isTimelineDialogOpen: false,
isPromptNavigatorPanelOpen: false,
isImagePreviewOpen: false,
@@ -1107,6 +1225,7 @@ export const useUIStore = create<UIStore>()(
projectContextSidebarWidth: 168,
projectContextTab: 'notes',
inputSpellcheckEnabled: false,
largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR,
wideChatLayoutEnabled: false,
codeBlockLineWrap: true,
showToolFileIcons: true,
@@ -1233,7 +1352,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;
@@ -1244,7 +1363,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) };
@@ -1307,15 +1426,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();
@@ -1345,7 +1455,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() : '';
@@ -1354,7 +1464,7 @@ export const useUIStore = create<UIStore>()(
targetPath: targetUrl,
dedupeKey: targetUrl || 'browser',
label: null,
});
}, options);
},
setContextPanelTabTargetPath: (directory, tabID, targetPath) => {
@@ -1438,34 +1548,43 @@ export const useUIStore = create<UIStore>()(
},
closeContextPanelTab: (directory, tabID) => {
get().closeContextPanelTabs(directory, [tabID]);
},
closeContextPanelTabs: (directory, tabIds) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedTabID = (tabID || '').trim();
if (!normalizedDirectory || !normalizedTabID) {
const normalizedTabIds = (tabIds ?? [])
.map((id) => (id || '').trim())
.filter((id) => id.length > 0);
if (!normalizedDirectory || normalizedTabIds.length === 0) {
return;
}
const closingTab = get().contextPanelByDirectory[normalizedDirectory]?.tabs
.find((tab) => tab.id === normalizedTabID);
const closedTabs = normalizedTabIds
.map((id) => get().contextPanelByDirectory[normalizedDirectory]?.tabs.find((tab) => tab.id === id))
.filter((tab): tab is ContextPanelTab => Boolean(tab));
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
if (!current.tabs.some((tab) => normalizedTabIds.includes(tab.id))) {
return state;
}
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: closeContextPanelTab(current, normalizedTabID),
[normalizedDirectory]: closeContextPanelTabs(current, normalizedTabIds),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
// Keep the editor's own open-file state in sync so a reopened
// editor surface does not resurrect the closed file.
if (closingTab?.mode === 'file' && closingTab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, closingTab.targetPath);
// Keep the editor's own open-file state in sync so closed files do not
// resurrect when the editor surface reopens.
for (const tab of closedTabs) {
if (tab.mode === 'file' && tab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, tab.targetPath);
}
}
},
@@ -1599,6 +1718,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) {
@@ -1967,7 +2103,37 @@ export const useUIStore = create<UIStore>()(
setGitChangesViewMode: (mode) => {
set({ gitChangesViewMode: mode });
},
setLinearIssueListStatus: (status) => {
set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) });
},
setLinearIssueListAssignee: (assignee) => {
set({ linearIssueListAssignee: sanitizeLinearIssueListAssignee(assignee) });
},
setLinearIssueListTeamId: (teamId) => {
set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) });
},
setLinearIssueListPriority: (priority) => {
set({ linearIssueListPriority: sanitizeLinearIssueListPriority(priority) });
},
resetLinearIssueListFilters: () => {
set({
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListPriority: 'all',
});
},
setLinearIssueFocus: (identifier) => {
const trimmed = identifier?.trim() ?? '';
set({ linearIssueFocus: trimmed || null });
},
setInputBarOffset: (offset) => {
set({ inputBarOffset: offset });
},
@@ -2314,6 +2480,9 @@ export const useUIStore = create<UIStore>()(
setInputSpellcheckEnabled: (value) => {
set({ inputSpellcheckEnabled: value });
},
setLargeTextPasteBehavior: (value) => {
set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) });
},
setWideChatLayoutEnabled: (value) => {
set({ wideChatLayoutEnabled: value });
},
@@ -2412,7 +2581,7 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createDeferredSafeJSONStorage(),
version: 17,
version: 18,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
@@ -2433,6 +2602,15 @@ export const useUIStore = create<UIStore>()(
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.
@@ -2612,12 +2790,21 @@ export const useUIStore = create<UIStore>()(
}
}
state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus);
state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee);
state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId);
state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority);
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior);
if (typeof state.autoSaveEnabled !== 'boolean') {
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() !== '')
: [];
@@ -2630,6 +2817,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,
@@ -2684,6 +2872,10 @@ export const useUIStore = create<UIStore>()(
diffWrapLines: state.diffWrapLines,
walkthroughTocWidth: state.walkthroughTocWidth,
gitChangesViewMode: state.gitChangesViewMode,
linearIssueListStatus: state.linearIssueListStatus,
linearIssueListAssignee: state.linearIssueListAssignee,
linearIssueListTeamId: state.linearIssueListTeamId,
linearIssueListPriority: state.linearIssueListPriority,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
@@ -2706,6 +2898,7 @@ export const useUIStore = create<UIStore>()(
agentMemoryViewedAt: state.agentMemoryViewedAt,
projectContextSidebarWidth: state.projectContextSidebarWidth,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
largeTextPasteBehavior: state.largeTextPasteBehavior,
wideChatLayoutEnabled: state.wideChatLayoutEnabled,
codeBlockLineWrap: state.codeBlockLineWrap,
showToolFileIcons: state.showToolFileIcons,
+9 -4
View File
@@ -11,6 +11,8 @@ import {
isVSCodeRuntime,
isWebRuntime,
} from '@/lib/desktop';
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
import { getUpdateInstallErrorMessage } from '@/lib/updateInstallError';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
@@ -314,15 +316,18 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
return;
}
set({ error: null });
try {
const ok = await restartToApplyUpdate();
if (!ok) {
throw new Error('Desktop restart only works on Local instance');
// No desktop bridge at all — the update was never installable here.
throw new Error(formatMessage(useI18nStore.getState().dictionary, 'updateDialog.error.restartUnavailable'));
}
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to restart',
});
// Keep the real installer failure; the dialog shows it and the button
// stays clickable for another attempt.
set({ error: getUpdateInstallErrorMessage(error instanceof Error ? error : new Error(String(error))) });
}
},
@@ -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,
});
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import type { RuntimeAPIs } from '@/lib/api/types';
import { isVSCodeRuntime } from './vscodeRuntime';
describe('VS Code runtime detection', () => {
@@ -9,6 +10,13 @@ describe('VS Code runtime detection', () => {
})).toBe(true);
});
test('uses registered runtime APIs when bootstrap is absent', () => {
const runtimeApis = {
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true },
} as RuntimeAPIs;
expect(isVSCodeRuntime(runtimeApis, null)).toBe(true);
});
test('does not classify an unregistered web runtime as VS Code', () => {
expect(isVSCodeRuntime(null, null)).toBe(false);
});
+7 -14
View File
@@ -1,18 +1,11 @@
import type { RuntimeAPIs } from '@/lib/api/types';
export interface VSCodeBootstrapConfig {
workspaceFolder?: unknown;
workspaceFolders?: unknown;
}
export const getVSCodeBootstrapConfig = (): VSCodeBootstrapConfig | null => {
if (typeof window === 'undefined') {
return null;
}
return (window as unknown as { __VSCODE_CONFIG__?: VSCodeBootstrapConfig }).__VSCODE_CONFIG__ ?? null;
};
import {
getVSCodeBootstrapConfig,
isVSCodeBootstrapPresent,
type VSCodeBootstrapConfig,
} from '@/lib/vscodeBootstrap';
export const isVSCodeRuntime = (
runtimeApis: RuntimeAPIs | null,
bootstrapConfig = getVSCodeBootstrapConfig(),
): boolean => Boolean(bootstrapConfig || runtimeApis?.runtime?.isVSCode);
bootstrapConfig: VSCodeBootstrapConfig | null = getVSCodeBootstrapConfig(),
): boolean => Boolean(isVSCodeBootstrapPresent(bootstrapConfig) || runtimeApis?.runtime?.isVSCode);
@@ -0,0 +1,120 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
/**
* Integration-style coverage for #2359: store modules evaluate before
* RuntimeAPIs registration, with only extension-host __VSCODE_CONFIG__ present
* and a stale lastDirectory in storage. The directory store must settle on the
* VS Code workspace folder rather than the stale persisted directory.
*/
const WORKSPACE = '/tmp/oc-ws-project-a';
const STALE = '/tmp/oc-ws-other';
const storage = new Map<string, string>([
['lastDirectory', STALE],
['homeDirectory', STALE],
]);
interface TestWindow {
__VSCODE_CONFIG__?: { workspaceFolder: string; workspaceFolders: { name: string; path: string }[] };
__OPENCHAMBER_HOME__?: string;
localStorage: Storage;
matchMedia: () => { matches: boolean };
addEventListener: () => void;
removeEventListener: () => void;
}
const testLocalStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, String(value));
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
key: () => null,
length: 0,
} satisfies Storage;
/**
* bun test runs without a DOM, so `globalThis` has neither `window` nor
* `localStorage` to assign through, and these store modules read both at module
* evaluation time. Defining the properties directly installs a stub carrying
* exactly the members they touch, without asserting it is a real `Window`.
*/
const setTestWindow = (value: TestWindow | undefined): void => {
if (value === undefined) {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'localStorage');
return;
}
Object.defineProperty(globalThis, 'window', { value, configurable: true, writable: true });
Object.defineProperty(globalThis, 'localStorage', {
value: value.localStorage,
configurable: true,
writable: true,
});
};
const installWindow = () => {
setTestWindow({
__VSCODE_CONFIG__: {
workspaceFolder: WORKSPACE,
workspaceFolders: [{ name: 'oc-ws-project-a', path: WORKSPACE }],
},
__OPENCHAMBER_HOME__: WORKSPACE,
localStorage: testLocalStorage,
matchMedia: () => ({ matches: false }),
addEventListener: () => undefined,
removeEventListener: () => undefined,
});
};
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: () => null,
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
setDirectory: () => undefined,
getDirectory: () => WORKSPACE,
getFilesystemHome: async () => WORKSPACE,
getSystemInfo: async () => ({ homeDirectory: WORKSPACE }),
},
}));
mock.module('@/lib/persistence', () => ({
updateDesktopSettings: async () => undefined,
}));
mock.module('@/lib/runtime-switch', () => ({
subscribeRuntimeEndpointChanged: () => () => undefined,
getRuntimeApiBaseUrl: () => 'http://127.0.0.1:9',
getRuntimeKey: () => 'test',
}));
mock.module('@/stores/useFileSearchStore', () => ({
useFileSearchStore: {
getState: () => ({ clearCache: () => undefined, invalidateDirectory: () => undefined }),
},
}));
describe('VS Code store init before RuntimeAPIs (#2359)', () => {
afterEach(() => {
setTestWindow(undefined);
});
test('directory store starts on the workspace folder, not the stale persisted directory', async () => {
installWindow();
const { useDirectoryStore } = await import('@/stores/useDirectoryStore');
const state = useDirectoryStore.getState();
expect(state.currentDirectory).toBe(WORKSPACE);
expect(state.homeDirectory).toBe(WORKSPACE);
expect(state.directoryHistory).toEqual([WORKSPACE]);
expect(state.currentDirectory).not.toBe(STALE);
});
});