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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user