Merge pull request #3320 from openchamber/fix/ui-thinking-effort-draft-project-rename

Various session UI fixes: drafts, rename, worktree creation, thinking effort
This commit is contained in:
Bohdan Triapitsyn
2026-09-04 20:00:42 +03:00
committed by GitHub
55 changed files with 2808 additions and 375 deletions
+10 -1
View File
@@ -362,6 +362,15 @@ const SessionRenameForm: React.FC<{
const { t } = useI18n();
const [value, setValue] = React.useState(initialTitle);
// Opens with the whole title selected, so the first keystroke replaces it.
// Stable ref callback: an inline one would re-run on every render and
// re-select the text mid-edit.
const focusRenameInput = React.useCallback((node: HTMLInputElement | null) => {
if (!node) return;
node.focus();
node.select();
}, []);
const commit = () => {
const next = value.trim();
if (!next || next === initialTitle.trim()) {
@@ -385,7 +394,7 @@ const SessionRenameForm: React.FC<{
}}
>
<input
autoFocus
ref={focusRenameInput}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
@@ -326,7 +326,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const currentModelId = useConfigStore((state) => state.currentModelId);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
const currentVariant = currentVariantSelection.override ?? undefined;
// What the picker shows is what the next send carries: an explicit choice
// when there is one, "Default" when "Default" was picked, and otherwise the
// inherited effort — showing "Default" while an inherited effort is in
// force is how a switch away from it looks like it did not stick.
const currentVariant = currentVariantSelection.override === null
? undefined
: currentVariantSelection.override ?? effectiveCurrentVariant;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
@@ -719,7 +725,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
/**
* The session's recorded choice for this model, in the selection store's
* three states: an effort name, `null` for an explicit "Default", and
* `undefined` for no choice at all. Collapsing `null` into `undefined` here
* would hand a real "Default" back to the callers as "nothing chosen", and
* they would re-record it as a choice on the next write.
*/
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string): string | null | undefined => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
return undefined;
@@ -728,6 +741,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const effectiveAgentName = uiAgentName || currentAgentName;
if (currentSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
// An explicit "Default" is a choice: it stops the fallbacks below.
if (savedVariant === null) {
return null;
}
if (savedVariant && variantOptions.includes(savedVariant)) {
return savedVariant;
}
@@ -760,7 +777,16 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return liveConfigAgentName || currentAgentName;
}, [currentAgentName, currentSessionId]);
const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
/**
* Records `variant` as this session's effort for the model, in the same
* three states the selection store defines: an effort name, `null` for an
* explicit "Default", `undefined` for no choice so the inherited effort
* applies. Callers decide which one they mean — the picker turns its own
* "Default" into `null`, while restore paths pass `undefined` through when
* they found nothing, because "the history carries no effort" is not the
* user having chosen "Default".
*/
const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
manualVariantSelectionRef.current = false;
@@ -770,10 +796,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
manualVariantSelectionRef.current = true;
setCurrentVariantOverride(
variant ?? null,
variant,
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
);
addRecentEffort(providerId, modelId, variant);
addRecentEffort(providerId, modelId, variant ?? undefined);
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
if (currentSessionId && effectiveAgentName) {
@@ -790,7 +816,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setCurrentVariantOverride,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName() ?? undefined;
const result = tryApplyModelSelection(providerId, modelId, effectiveAgentName);
if (result !== 'applied') {
@@ -852,25 +878,34 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
&& getModelVariantOptions(latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID).includes(latestLoadedUserChoice.variant)
? latestLoadedUserChoice.variant
: undefined;
const restoreAgentName = latestLoadedUserChoice.agent || currentAgentName || undefined;
// A message carrying no effort is not evidence that the user has none:
// a send under an explicit "Default" carries none either, and the echo
// of that very send arrives here. Keep what the session already
// recorded, and let a concrete historical effort replace it.
const restoredVariant = historicalVariant ?? (currentSessionId && restoreAgentName
? getAgentModelVariantForSession(
currentSessionId,
restoreAgentName,
latestLoadedUserChoice.providerID,
latestLoadedUserChoice.modelID,
)
: undefined);
const applyResult = applyModelSelectionWithVariant(
latestLoadedUserChoice.providerID,
latestLoadedUserChoice.modelID,
historicalVariant,
latestLoadedUserChoice.agent || currentAgentName || undefined,
restoredVariant,
restoreAgentName,
);
if (applyResult !== 'applied') {
return;
}
// The effort is not written again here: `applyModelSelectionWithVariant`
// above already recorded `historicalVariant` for this same agent and
// model, and a second write can only disagree with the first.
if (latestLoadedUserChoice.agent) {
saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent);
saveAgentModelVariantForSession(
currentSessionId,
latestLoadedUserChoice.agent,
latestLoadedUserChoice.providerID,
latestLoadedUserChoice.modelID,
historicalVariant,
);
}
saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID);
latestLoadedUserChoiceRestoreRef.current = restoreKey;
@@ -884,11 +919,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
latestLoadedUserChoice,
setAgent,
applyModelSelectionWithVariant,
getAgentModelVariantForSession,
getModelVariantOptions,
getSessionModelSelection,
resolveModelVariantSelection,
saveSessionAgentSelection,
saveAgentModelVariantForSession,
saveSessionModelSelection,
]);
@@ -1113,11 +1148,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return;
}
// The chosen effort does not exist on this model: drop the choice and
// inherit, rather than pin an explicit "Default" the user never picked.
if (currentVariant && !availableVariants.includes(currentVariant)) {
setCurrentVariantOverride(
null,
resolveInheritedVariantForModel(currentProviderId, currentModelId),
);
setCurrentVariant(resolveInheritedVariantForModel(currentProviderId, currentModelId));
return;
}
@@ -1143,7 +1177,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
if (savedVariant && availableVariants.includes(savedVariant)) {
setCurrentVariantOverride(savedVariant, inheritedVariant);
} else if (currentVariantSelection.override === null) {
} else if (savedVariant === null || currentVariantSelection.override === null) {
// "Default" was picked for this session, or is picked right now.
setCurrentVariantOverride(null, inheritedVariant);
} else {
setCurrentVariant(inheritedVariant);
@@ -1172,7 +1207,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const handleVariantSelect = React.useCallback((variant: string | undefined) => {
if (currentProviderId && currentModelId) {
commitVariantSelectionForModel(currentProviderId, currentModelId, variant);
// Picked in the effort menu, so no effort means the user picked
// "Default" — a choice, recorded as `null`.
commitVariantSelectionForModel(currentProviderId, currentModelId, variant ?? null);
}
}, [commitVariantSelectionForModel, currentModelId, currentProviderId]);
@@ -1234,8 +1271,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
) => {
try {
const effectiveAgentName = options?.agentName ?? resolveLiveAgentName() ?? undefined;
// `applyVariant` is only set when the user adjusted the effort in
// the model picker, so no effort means an explicit "Default".
const result = options?.applyVariant
? applyModelSelectionWithVariant(providerId, modelId, options.variant, effectiveAgentName)
? applyModelSelectionWithVariant(providerId, modelId, options.variant ?? null, effectiveAgentName)
: tryApplyModelSelection(providerId, modelId, effectiveAgentName);
if (result !== 'applied') {
if (result === 'provider-missing') {
@@ -1592,8 +1631,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
}
const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => {
const result = applyModelSelectionWithVariant(providerId, modelId, variant);
const handleMobileModelApply = (providerId: string, modelId: string, variant: string | null | undefined) => {
// Chosen in the mobile model sheet, and the row already showed this
// effort: no effort there means the user is applying "Default".
const result = applyModelSelectionWithVariant(providerId, modelId, variant ?? null);
if (result !== 'applied') {
if (result === 'provider-missing') {
console.error('[ModelControls] Provider not available for selection:', providerId);
@@ -1630,7 +1671,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const variantOptions = getModelVariantOptions(providerId, modelId);
const hasVariants = variantOptions.length > 0;
const resolvedVariant = resolveModelVariantSelection(providerId, modelId);
const variantLabel = hasVariants ? formatEffortLabel(resolvedVariant) : null;
// Both an explicit "Default" and no choice at all read as "Default".
const variantLabel = hasVariants ? formatEffortLabel(resolvedVariant ?? undefined) : null;
const isExpanded = expandedMobileModelKey === rowKey;
const inlineVariantOptions = [undefined, ...variantOptions].slice(0, MAX_INLINE_MOBILE_VARIANT_OPTIONS);
const hasVariantOverflow = inlineVariantOptions.length < variantOptions.length + 1;
@@ -1935,7 +1977,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
};
const handleSelect = (variant: string | undefined) => {
const result = applyModelSelectionWithVariant(targetProviderId, targetModelId, variant);
// Chosen in the mobile effort panel: no effort means "Default".
const result = applyModelSelectionWithVariant(targetProviderId, targetModelId, variant ?? null);
if (result !== 'applied') {
return;
}
@@ -0,0 +1,406 @@
import React, { act } from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import { create } from 'zustand';
/**
* Restoring a session must not invent an effort choice.
*
* The selection store keeps three states for a session's effort: an effort
* name, `null` for an explicit "Default", and `undefined` for no choice at all.
* Only the picker may write `null`. When a restore path writes it instead, the
* session latches onto "Default" — `null` outranks the agent and settings
* defaults by design — and the concrete effort the session's own history
* carries can never come back. These tests pin who writes what.
*/
type VariantChoice = string | null | undefined;
type UserModelChoice = {
id: string;
agent?: string;
providerID: string;
modelID: string;
variant?: string;
};
const PROVIDER_ID = 'openai';
const MODEL_ID = 'gpt-5.5';
const AGENT = 'build';
const SESSION_ID = 'ses_restore';
const model = {
id: MODEL_ID,
name: MODEL_ID,
providerID: PROVIDER_ID,
variants: { low: {}, high: {} },
};
const provider = { id: PROVIDER_ID, name: PROVIDER_ID, models: [model] };
const agent = { name: AGENT, mode: 'primary' as const };
let latestUserChoice: UserModelChoice | null = null;
let forcePreserveManualOverride: boolean | null = null;
/** Every effort written for the session, in order, including `undefined`. */
const variantWrites: VariantChoice[] = [];
/** Every `(override, inherited)` pair pushed into the config store. */
const overrideWrites: Array<{ override: VariantChoice; inherited: string | undefined }> = [];
type ConfigState = {
providers: typeof provider[];
agents: typeof agent[];
modelsMetadata: Record<string, never>;
currentProviderId: string;
currentModelId: string;
currentVariant: string | undefined;
currentVariantSelection: { override: VariantChoice; inherited: string | undefined };
currentAgentName: string | undefined;
settingsDefaultVariant: string | undefined;
settingsDefaultAgent: string | undefined;
selectionSource: 'auto' | 'manual';
setProvider: (providerId: string) => void;
setSelectedProvider: (providerId: string) => void;
setModel: (modelId: string) => void;
setAgent: (agentName: string) => void;
setCurrentVariant: (variant: string | undefined) => void;
setCurrentVariantOverride: (override: VariantChoice, inherited: string | undefined) => void;
getCurrentProvider: () => typeof provider;
getCurrentAgent: () => typeof agent;
getVisibleAgents: () => typeof agent[];
getCurrentModelVariants: () => string[];
getModelMetadata: () => undefined;
};
const useConfigStore = create<ConfigState>((set) => ({
providers: [provider],
agents: [agent],
modelsMetadata: {},
currentProviderId: PROVIDER_ID,
currentModelId: MODEL_ID,
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: AGENT,
settingsDefaultVariant: undefined,
settingsDefaultAgent: undefined,
selectionSource: 'auto',
setProvider: (providerId) => set({ currentProviderId: providerId }),
setSelectedProvider: () => undefined,
setModel: (modelId) => set({ currentModelId: modelId }),
setAgent: (agentName) => set({ currentAgentName: agentName }),
// Mirrors the real store, including its no-op guard: without that guard an
// unchanged write returns a fresh state object every render and the
// component's variant effects never settle.
setCurrentVariant: (variant) => {
useConfigStore.getState().setCurrentVariantOverride(undefined, variant);
},
setCurrentVariantOverride: (override, inherited) => {
set((state) => {
const currentVariant = override === null ? undefined : override ?? inherited;
if (
state.currentVariant === currentVariant
&& state.currentVariantSelection.override === override
&& state.currentVariantSelection.inherited === inherited
) {
return state;
}
overrideWrites.push({ override, inherited });
return { currentVariant, currentVariantSelection: { override, inherited } };
});
},
getCurrentProvider: () => provider,
getCurrentAgent: () => agent,
getVisibleAgents: () => [agent],
getCurrentModelVariants: () => Object.keys(model.variants),
getModelMetadata: () => undefined,
}));
type SelectionState = {
savedVariant: VariantChoice;
sessionAgentSelections: Map<string, string>;
getSessionModelSelection: () => { providerId: string; modelId: string } | null;
getSessionAgentSelection: () => string | null;
getAgentModelForSession: () => { providerId: string; modelId: string } | null;
getAgentModelVariantForSession: () => VariantChoice;
saveSessionModelSelection: () => void;
saveSessionAgentSelection: () => void;
saveAgentModelForSession: () => void;
saveAgentModelVariantForSession: (
sessionId: string,
agentName: string,
providerId: string,
modelId: string,
variant: VariantChoice,
) => void;
};
const useSelectionStore = create<SelectionState>((set, get) => ({
savedVariant: undefined,
sessionAgentSelections: new Map([[SESSION_ID, AGENT]]),
getSessionModelSelection: () => ({ providerId: PROVIDER_ID, modelId: MODEL_ID }),
getSessionAgentSelection: () => AGENT,
getAgentModelForSession: () => ({ providerId: PROVIDER_ID, modelId: MODEL_ID }),
getAgentModelVariantForSession: () => get().savedVariant,
saveSessionModelSelection: () => undefined,
saveSessionAgentSelection: () => undefined,
saveAgentModelForSession: () => undefined,
saveAgentModelVariantForSession: (_sessionId, _agentName, _providerId, _modelId, variant) => {
variantWrites.push(variant);
set({ savedVariant: variant });
},
}));
const useSessionUIStore = create(() => ({
currentSessionId: SESSION_ID,
getDirectoryForSession: () => '/workspace/project',
}));
const useUIStore = create(() => ({
isMobile: false,
isModelSelectorOpen: false,
hiddenModels: [],
providerOrder: [],
shortcutOverrides: {},
isFavoriteModel: () => false,
toggleFavoriteModel: () => undefined,
reorderFavoriteModel: () => undefined,
setProviderOrder: () => undefined,
setModelSelectorOpen: () => undefined,
setSettingsDialogOpen: () => undefined,
setSettingsPage: () => undefined,
addRecentAgent: () => undefined,
addRecentModel: () => undefined,
addRecentEffort: () => undefined,
}));
const passthrough = ({ children }: React.PropsWithChildren) => <div>{children}</div>;
// Captured by value before the module is replaced: reading it back off the
// namespace afterwards would resolve to the replacement and recurse.
const { shouldPreserveManualModelOverride: realShouldPreserveManualModelOverride } =
await import('@/lib/messages/userModelChoice');
mock.module('@/lib/messages/userModelChoice', () => ({
findLatestUserModelChoice: () => latestUserChoice,
// The real guard, unless a test opts out: whether it fires decides which
// restore branch runs, and the branch that erased a recorded Default is the
// one it declines to protect.
shouldPreserveManualModelOverride: (args: Parameters<typeof realShouldPreserveManualModelOverride>[0]) => (
forcePreserveManualOverride ?? realShouldPreserveManualModelOverride(args)
),
}));
mock.module('@/stores/useConfigStore', () => ({ useConfigStore }));
mock.module('@/sync/selection-store', () => ({ useSelectionStore }));
mock.module('@/sync/session-ui-store', () => ({ useSessionUIStore }));
mock.module('@/stores/useUIStore', () => ({ useUIStore }));
mock.module('@/stores/contextStore', () => ({
useContextStore: <T,>(selector: (state: { hasHydrated: boolean }) => T): T => selector({ hasHydrated: true }),
}));
mock.module('@/sync/sync-context', () => ({
useSessionMessages: () => [],
useSessionRenderable: () => true,
}));
mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) }));
mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] }));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: passthrough,
DropdownMenuContent: passthrough,
DropdownMenuItem: passthrough,
DropdownMenuLabel: passthrough,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: passthrough,
}));
mock.module('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough }));
mock.module('@/components/ui/ProviderLogo', () => ({ ProviderLogo: () => null }));
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough }));
mock.module('@/components/ui/tooltip', () => ({
Tooltip: passthrough,
TooltipContent: passthrough,
TooltipTrigger: passthrough,
}));
mock.module('@/components/icon/Icon', () => ({ Icon: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({ ModelPickerList: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useIsVSCodeRuntime: () => false }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModels: [], recentModels: [] }) }));
mock.module('@/hooks/useIsTextTruncated', () => ({ useIsTextTruncated: () => false }));
mock.module('@/hooks/useOpenCodeReadiness', () => ({
useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }),
}));
mock.module('@/lib/device', () => ({ useDeviceInfo: () => ({ isTouch: false }) }));
mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false }));
mock.module('@/lib/startupTrace', () => ({ markStartupTrace: () => undefined }));
const { ModelControls } = await import('./ModelControls');
const { I18nProvider } = await import('@/lib/i18n');
const DOM_GLOBAL_NAMES = [
'window',
'document',
'navigator',
'Node',
'Element',
'HTMLElement',
'HTMLIFrameElement',
'localStorage',
'requestAnimationFrame',
'cancelAnimationFrame',
'IS_REACT_ACT_ENVIRONMENT',
] as const;
const frameTimers = new Map<number, ReturnType<Window['setTimeout']>>();
let nextFrameHandle = 1;
const installDom = () => {
const happyWindow = new Window({ url: 'http://localhost' });
const previous = DOM_GLOBAL_NAMES.map(
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
);
const values = {
window: happyWindow,
document: happyWindow.document,
navigator: happyWindow.navigator,
Node: happyWindow.Node,
Element: happyWindow.Element,
HTMLElement: happyWindow.HTMLElement,
HTMLIFrameElement: happyWindow.HTMLIFrameElement,
localStorage: happyWindow.localStorage,
// The component focuses the composer through rAF on several paths.
requestAnimationFrame: (callback: FrameRequestCallback) => {
const handle = nextFrameHandle++;
frameTimers.set(handle, happyWindow.setTimeout(() => {
frameTimers.delete(handle);
callback(0);
}, 0));
return handle;
},
cancelAnimationFrame: (handle: number) => {
const timer = frameTimers.get(handle);
if (timer === undefined) return;
frameTimers.delete(handle);
happyWindow.clearTimeout(timer);
},
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const name of DOM_GLOBAL_NAMES) {
Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true });
}
const container = document.createElement('div');
document.body.appendChild(container);
return {
container,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
const renderModelControls = async () => {
const dom = installDom();
const root = createRoot(dom.container);
await act(async () => root.render(
<I18nProvider>
<ModelControls />
</I18nProvider>,
));
return {
dom,
cleanup: async () => {
await act(async () => root.unmount());
dom.restore();
},
};
};
describe('ModelControls effort restore', () => {
beforeEach(() => {
variantWrites.length = 0;
overrideWrites.length = 0;
latestUserChoice = null;
forcePreserveManualOverride = null;
useSelectionStore.setState({ savedVariant: undefined });
useConfigStore.setState({
currentProviderId: PROVIDER_ID,
currentModelId: MODEL_ID,
currentAgentName: AGENT,
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
settingsDefaultVariant: undefined,
selectionSource: 'auto',
});
});
test('restores the concrete effort the session history carries', async () => {
latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' };
const { cleanup } = await renderModelControls();
try {
expect(variantWrites).toContain('low');
expect(variantWrites).not.toContain(null);
expect(useSelectionStore.getState().savedVariant).toBe('low');
expect(useConfigStore.getState().currentVariantSelection.override).toBe('low');
} finally {
await cleanup();
}
});
test('history without an effort records no choice instead of an explicit Default', async () => {
latestUserChoice = { id: 'msg-2', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID };
const { cleanup } = await renderModelControls();
try {
expect(variantWrites).not.toContain(null);
expect(useSelectionStore.getState().savedVariant).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection.override).toBe(undefined);
} finally {
await cleanup();
}
});
test('the echo of a Default send does not erase the recorded Default', async () => {
// The reported repro. The send under "Default" carried no effort, so the
// message it echoes back carries none either, and its model matches the one
// the send saved — which is exactly when the manual-override guard declines
// to protect the selection and the history branch runs.
latestUserChoice = { id: 'msg-echo', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID };
useSelectionStore.setState({ savedVariant: null });
useConfigStore.setState({
selectionSource: 'manual',
settingsDefaultVariant: 'low',
currentVariantSelection: { override: null, inherited: 'low' },
});
const { cleanup } = await renderModelControls();
try {
expect(useSelectionStore.getState().savedVariant).toBeNull();
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
expect(useConfigStore.getState().currentVariant).toBe(undefined);
} finally {
await cleanup();
}
});
test('a preserved manual override keeps a recorded explicit Default', async () => {
latestUserChoice = { id: 'msg-3', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'high' };
forcePreserveManualOverride = true;
useSelectionStore.setState({ savedVariant: null });
useConfigStore.setState({ selectionSource: 'manual' });
const { cleanup } = await renderModelControls();
try {
expect(useSelectionStore.getState().savedVariant).toBeNull();
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
} finally {
await cleanup();
}
});
});
@@ -17,6 +17,7 @@
import React from 'react';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
import { formatDirectoryName } from '@/lib/utils';
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -191,6 +192,14 @@ export function useDraftTarget(enabled: boolean) {
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
);
// The draft's own pending flags clear once the directory exists, which is
// before setup commands and the initial git reset finish; the bootstrap
// state covers that remaining window (and creations the draft never knew
// about, such as the New Worktree dialog), so the probe never reads the
// transient bootstrap files as the branch being dirty.
const selectedDraftDirectoryBootstrapPending = useWorktreeBootstrapPending(selectedDraftDirectory);
const draftDirectoryNeedsFreshStatusRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (
!enabled
@@ -198,15 +207,26 @@ export function useDraftTarget(enabled: boolean) {
|| selectedDraftProject?.kind === 'chat'
|| newSessionDraft?.pendingWorktreeRequestId
|| newSessionDraft?.bootstrapPendingDirectory
|| selectedDraftDirectoryBootstrapPending
) {
if (selectedDraftDirectoryBootstrapPending && selectedDraftDirectory) {
draftDirectoryNeedsFreshStatusRef.current = selectedDraftDirectory;
}
setDirtyDraftDirectory(null);
return;
}
let cancelled = false;
setDirtyDraftDirectory(null);
getGitStatus(selectedDraftDirectory, { mode: 'light' })
const needsFreshStatus = draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory;
const statusRequest = needsFreshStatus
? getGitStatus(selectedDraftDirectory, { mode: 'light', fresh: true })
: getGitStatus(selectedDraftDirectory, { mode: 'light' });
statusRequest
.then((status) => {
if (!cancelled && needsFreshStatus && draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory) {
draftDirectoryNeedsFreshStatusRef.current = null;
}
if (!cancelled && (status.files?.length ?? 0) > 0) {
setDirtyDraftDirectory(selectedDraftDirectory);
}
@@ -218,7 +238,7 @@ export function useDraftTarget(enabled: boolean) {
return () => {
cancelled = true;
};
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftDirectoryBootstrapPending, selectedDraftProject?.kind]);
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
@@ -56,6 +56,7 @@ import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedC
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { getAgentColor } from '@/lib/agentColors';
import { isCapacitorMobileApp } from '@/apps/mobileNativeChrome';
import { WorktreeRequiresGitRepositoryError } from '@/lib/worktrees/worktreeCreate';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
@@ -1331,17 +1332,14 @@ const AssistantMessageBody = React.memo(({
const effectiveStreamPhase: StreamPhase = hasStopFinish ? 'completed' : streamPhase;
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentProjectRef = React.useMemo(() => {
if (!canUseProjectPlanActions) {
return null;
}
const sessionProjectRef = React.useMemo(() => {
const directory = effectiveDirectory
?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null)
?? '';
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
}, [availableWorktreesByProject, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
const currentProjectRef = canUseProjectPlanActions ? sessionProjectRef : null;
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
@@ -1377,28 +1375,48 @@ const AssistantMessageBody = React.memo(({
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!createSessionFromAssistantMessage || !assistantPlanText.trim()) {
if (!assistantPlanText.trim()) {
return;
}
setIsForkDialogOpen(true);
},
[createSessionFromAssistantMessage, assistantPlanText]
[assistantPlanText]
);
const handleConfirmFork = React.useCallback(
async (execution: ForkSessionExecution) => {
if (!createSessionFromAssistantMessage) {
return;
}
setIsForkSubmitting(true);
try {
await createSessionFromAssistantMessage(messageId, execution);
if (!sessionId) {
throw new Error('Source session is unavailable');
}
const sourceDirectory = effectiveDirectory ?? getDirectoryForSession(sessionId);
if (!sourceDirectory) {
throw new Error('Source session directory is unavailable');
}
await createSessionFromAssistantMessage({
sessionId,
directory: sourceDirectory,
text: assistantPlanText,
}, execution);
setIsForkDialogOpen(false);
} catch (error) {
console.error('Failed to start a session from an assistant message:', error);
if (error instanceof WorktreeRequiresGitRepositoryError) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
const description = error instanceof Error ? error.message : undefined;
toast.error(
t('rightSidebar.contextNotesTodo.toast.createSessionFailed'),
description ? { description } : undefined
);
} finally {
setIsForkSubmitting(false);
}
},
[createSessionFromAssistantMessage, messageId]
[assistantPlanText, createSessionFromAssistantMessage, effectiveDirectory, getDirectoryForSession, sessionId, t]
);
const handleForkMultiRunClick = React.useCallback(
@@ -2136,6 +2154,8 @@ const AssistantMessageBody = React.memo(({
open={isForkDialogOpen}
onOpenChange={setIsForkDialogOpen}
projectDirectory={effectiveDirectory ?? null}
sourceSessionId={sessionId ?? null}
worktreeProjectDirectory={sessionProjectRef?.path ?? null}
submitting={isForkSubmitting}
onConfirm={handleConfirmFork}
/>
@@ -14,7 +14,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { useUIStore } from '@/stores/useUIStore';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
@@ -57,7 +56,6 @@ import {
extractFirstChangedLineFromDiff,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPatchText,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
@@ -108,14 +106,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
return trimmed;
};
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
'edit',
'write',
'apply_patch',
'patch',
]);
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = Math.max(0, (end ?? now) - start);
const seconds = duration / 1000;
@@ -1699,19 +1689,16 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const status = state?.status as string | undefined;
const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled';
const isSuccessfullyFinalized = status === 'completed';
const isError = status === 'error' || status === 'failed';
const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized);
const previousPartIdRef = React.useRef<string | undefined>(part.id);
const observedActiveGitToolRef = React.useRef(!isFinalized);
React.useEffect(() => {
if (previousPartIdRef.current === part.id) {
return;
}
previousPartIdRef.current = part.id;
observedActiveGitToolRef.current = !isFinalized;
// Reset latch only when tool identity changes.
setActiveLatched(!isFinalized);
}, [isFinalized, part.id]);
@@ -1722,36 +1709,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
}, [isFinalized]);
React.useEffect(() => {
if (!isFinalized) {
observedActiveGitToolRef.current = true;
return;
}
// Historical completed tools can remount when the timeline changes.
// Refresh only for a tool whose active state this instance observed.
const finalizedAfterObservedActive = observedActiveGitToolRef.current;
if (!finalizedAfterObservedActive) {
return;
}
if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
observedActiveGitToolRef.current = false;
return;
}
if (!currentDirectory) {
return;
}
observedActiveGitToolRef.current = false;
const paths = getMutatedToolPaths(normalizedPartTool, input, metadata)
.map((path) => getRelativePath(path, currentDirectory));
sessionEvents.requestGitRefresh({
directory: currentDirectory,
...(paths.length > 0 ? { paths } : {}),
});
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
@@ -5,7 +5,6 @@ import {
getApplyPatchFilePath,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
getRenderablePatchInfo,
@@ -57,28 +56,6 @@ describe('toolDiffUtils', () => {
})).toBe('/workspace/project/src/second.ts');
});
test('lists every apply_patch mutation path, including both sides of a move', () => {
expect(getMutatedToolPaths('apply_patch', undefined, {
files: [
{ filePath: '/workspace/project/src/deleted.ts', type: 'delete' },
{
filePath: '/workspace/project/src/old.ts',
movePath: '/workspace/project/src/new.ts',
type: 'move',
},
],
})).toEqual([
'/workspace/project/src/deleted.ts',
'/workspace/project/src/new.ts',
'/workspace/project/src/old.ts',
]);
});
test('does not invent paths for bash or task tools', () => {
expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]);
expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]);
});
test('selects the move patch and line from the same non-deleted file', () => {
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
const movedPatch = '@@ -42 +42 @@\n-before\n+after';
@@ -200,29 +200,6 @@ export const getPrimaryToolPath = (
return null;
};
export const getMutatedToolPaths = (
toolName: string,
input: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string[] => {
if (toolName === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata.files : [];
const paths = new Set<string>();
for (const file of files) {
if (!isRecord(file)) continue;
const filePath = getApplyPatchFilePath(file);
if (filePath) paths.add(filePath);
if (file.type === 'move' && typeof file.filePath === 'string') {
paths.add(file.filePath);
}
}
return [...paths];
}
const primaryPath = getPrimaryToolPath(toolName, input, metadata);
return primaryPath ? [primaryPath] : [];
};
const supportsDiffMetadata = (toolName: string): boolean => (
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch'
);
@@ -179,6 +179,13 @@ including edits the user made by hand and excluding session edits that are
already committed. If a session-authored count is ever needed, it has to come
from aggregating message summaries, not from `Session.summary`.
One exception: while the directory is a worktree whose creation has not
finished (`useWorktreeBootstrapPending`), the working tree transiently holds
bootstrap files that the initial git reset is about to remove. Those are not
changes on the branch, so the panel neither fetches status nor renders the
changed-files row until the bootstrap settles, then forces one status fetch so
the row reflects the reset tree rather than a mid-creation snapshot.
## Section order
Ordering is by durability, not category:
@@ -3,6 +3,7 @@ import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
import { useSessionMessages } from '@/sync/sync-context';
@@ -67,12 +68,45 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
),
);
// A worktree that is still being created transiently looks dirty until its
// setup commands and initial git reset finish. Those files are not changes
// on the branch, so the changed-files readout stays hidden while the
// bootstrap runs — and stays hidden until one fresh status fetch completes
// afterwards, because the shared cache may still hold a snapshot captured
// mid-creation (refresh hints fire while setup commands touch files) and
// lifting the gate onto it would flash the transient state.
const worktreeCreationPending = useWorktreeBootstrapPending(gitDirectory);
const [postBootstrapRefreshDirectory, setPostBootstrapRefreshDirectory] = React.useState<string | null>(null);
const awaitingPostBootstrapStatus = postBootstrapRefreshDirectory !== null
&& postBootstrapRefreshDirectory === gitDirectory;
// Warm the shared git cache through the background-network gate so the panel
// never competes with the chat's own bootstrap traffic for sockets.
React.useEffect(() => {
if (!showRepository || !gitDirectory || !git) return;
if (worktreeCreationPending) {
setPostBootstrapRefreshDirectory(gitDirectory);
return;
}
if (awaitingPostBootstrapStatus) {
let cancelled = false;
void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, {
force: true,
silent: true,
throwOnError: true,
}))
.then(() => {
if (!cancelled) {
setPostBootstrapRefreshDirectory((current) => (current === gitDirectory ? null : current));
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}
void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git));
}, [gitDirectory, git, ensureStatus, showRepository]);
}, [gitDirectory, git, ensureStatus, fetchStatus, showRepository, worktreeCreationPending, awaitingPostBootstrapStatus]);
// Own the live invalidation for the repository readout. The desktop
// composer's changed-files row no longer renders, so this panel must not
@@ -175,6 +209,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
// event is reset to an empty array too, and carries real content only on
// revert. Git status is the one authoritative, already-cached answer.
const changed = React.useMemo(() => {
if (worktreeCreationPending || awaitingPostBootstrapStatus) return null;
const files = gitStatus?.files ?? [];
if (files.length === 0) return null;
const stats = gitStatus?.diffStats;
@@ -187,7 +222,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
}
}
return { files: files.length, additions, deletions, hasStats: Boolean(stats) };
}, [gitStatus?.files, gitStatus?.diffStats]);
}, [gitStatus?.files, gitStatus?.diffStats, worktreeCreationPending, awaitingPostBootstrapStatus]);
const attentionReason = gitStatus?.attentionReason
?? (gitStatus?.rebaseInProgress ? 'rebase' : null)
+11 -2
View File
@@ -783,6 +783,15 @@ export const Header: React.FC = () => {
const beginHeaderSessionRenameRef = React.useRef(beginHeaderSessionRename);
beginHeaderSessionRenameRef.current = beginHeaderSessionRename;
// The rename field opens with the whole title selected, so the first
// keystroke replaces it. Stable ref callback: an inline one would re-run on
// every render and re-select the text mid-edit.
const focusHeaderRenameInput = React.useCallback((node: HTMLInputElement | null) => {
if (!node) return;
node.focus();
node.select();
}, []);
React.useEffect(() => {
setIsHeaderSessionMenuOpen(false);
setPendingHeaderRetentionAction(null);
@@ -1422,9 +1431,9 @@ export const Header: React.FC = () => {
}}
>
<input
ref={focusHeaderRenameInput}
value={headerSessionTitleDraft}
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
autoFocus
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
@@ -1579,9 +1588,9 @@ export const Header: React.FC = () => {
}}
>
<input
ref={focusHeaderRenameInput}
value={headerSessionTitleDraft}
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
autoFocus
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
@@ -17,6 +17,9 @@ import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS, EXECUTION_FORK_GOAL_INSTRUCTIONS } from '@/lib/messages/executionMeta';
import { useI18n } from '@/lib/i18n';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
export type ForkSessionExecution = {
providerID: string;
@@ -32,13 +35,22 @@ type ForkSessionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
projectDirectory: string | null;
sourceSessionId: string | null;
worktreeProjectDirectory: string | null;
submitting?: boolean;
onConfirm: (execution: ForkSessionExecution) => Promise<void> | void;
};
export function ForkSessionDialog(props: ForkSessionDialogProps) {
const { t } = useI18n();
const { open, onOpenChange, projectDirectory, submitting = false, onConfirm } = props;
const { open, onOpenChange, projectDirectory, sourceSessionId, worktreeProjectDirectory, submitting = false, onConfirm } = props;
const metadataProjectDirectory = useSessionUIStore((state) => (
sourceSessionId ? state.worktreeMetadata.get(sourceSessionId)?.projectDirectory ?? null : null
));
const resolvedWorktreeProjectDirectory = metadataProjectDirectory ?? worktreeProjectDirectory;
const git = useRuntimeAPIs().git;
const isGitRepository = useIsGitRepo(resolvedWorktreeProjectDirectory);
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const loadProviders = useConfigStore((state) => state.loadProviders);
const loadConfigAgents = useConfigStore((state) => state.loadAgents);
@@ -56,7 +68,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS);
const [createWorktree, setCreateWorktree] = React.useState(false);
const [runAsGoal, setRunAsGoal] = React.useState(false);
const showCreateWorktree = React.useMemo(() => !isVSCodeRuntime(), []);
const showCreateWorktree = !isVSCodeRuntime() && isGitRepository === true;
// The goal loop lives in the web server; VS Code only renders goal state.
const showRunAsGoal = React.useMemo(() => !isVSCodeRuntime(), []);
@@ -79,6 +91,11 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
void loadAgentsStoreAgents();
}, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]);
React.useEffect(() => {
if (!open || !resolvedWorktreeProjectDirectory || !git) return;
void ensureGitStatus(resolvedWorktreeProjectDirectory, git);
}, [ensureGitStatus, git, open, resolvedWorktreeProjectDirectory]);
// Reset only when the dialog transitions to open. Reading the store snapshot
// here (instead of subscribing) avoids clobbering in-progress user edits when
// the config store refreshes in the background while the dialog is open.
@@ -29,14 +29,14 @@ import { useI18n } from '@/lib/i18n';
type GitHubTab = 'issues' | 'prs';
export type GitHubWorktreeSelection =
| { type: 'issue'; item: GitHubIssue; includeDiff?: boolean }
| { type: 'pr'; item: GitHubPullRequestSummary; includeDiff?: boolean };
interface GitHubIntegrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (result: {
type: 'issue' | 'pr';
item: GitHubIssue | GitHubPullRequestSummary;
includeDiff?: boolean;
} | null) => void;
onSelect: (result: GitHubWorktreeSelection | null) => void;
}
interface ValidationResult {
@@ -0,0 +1,215 @@
import React, { act } from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import { create } from 'zustand';
type GitHubSelection = {
type: 'issue';
item: { number: number; title: string };
};
type WorktreeState = {
availableWorktreesByProject: Map<string, Array<{ name: string }>>;
};
const project = { id: 'project-a', path: '/workspace/project-a' };
const useWorktreeStore = create<WorktreeState>(() => ({
availableWorktreesByProject: new Map(),
}));
let selectGitHubItem: ((selection: GitHubSelection) => void) | null = null;
const projectStoreState = { getActiveProject: () => project };
const githubAuthState = { status: { connected: true }, hasChecked: true };
const linearAuthState = { status: null, hasChecked: true };
const uiState = { isMobile: false };
const gitState = { fetchBranches: async () => undefined };
const selectProjectState = <T,>(selector: (state: typeof projectStoreState) => T): T => selector(projectStoreState);
const selectGitHubAuthState = <T,>(selector: (state: typeof githubAuthState) => T): T => selector(githubAuthState);
const selectLinearAuthState = <T,>(selector: (state: typeof linearAuthState) => T): T => selector(linearAuthState);
const selectUIState = <T,>(selector: (state: typeof uiState) => T): T => selector(uiState);
const selectGitState = <T,>(selector: (state: typeof gitState) => T): T => selector(gitState);
const passthrough = ({ children }: React.PropsWithChildren) => <div>{children}</div>;
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: React.PropsWithChildren<{ open: boolean }>) => open ? <>{children}</> : null,
DialogContent: passthrough,
DialogHeader: passthrough,
DialogTitle: passthrough,
DialogFooter: passthrough,
}));
mock.module('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
mock.module('@/components/ui/button', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
}));
mock.module('@/components/ui', () => ({
toast: { error: () => undefined, success: () => undefined },
}));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: passthrough,
DropdownMenuContent: passthrough,
DropdownMenuTrigger: passthrough,
}));
mock.module('@/components/ui/command', () => ({
Command: passthrough,
CommandEmpty: passthrough,
CommandGroup: passthrough,
CommandInput: () => null,
CommandItem: passthrough,
CommandList: passthrough,
CommandSeparator: () => null,
}));
mock.module('@/components/ui/sortable-tabs-strip', () => ({ SortableTabsStrip: () => null }));
mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough }));
mock.module('@/components/icon/Icon', () => ({ Icon: () => null }));
mock.module('@/components/ui/dropdown-trigger', () => ({ dropdownTriggerVariants: () => '' }));
mock.module('@/lib/utils', () => ({ cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' ') }));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: selectProjectState,
}));
mock.module('@/stores/useGitHubAuthStore', () => ({
useGitHubAuthStore: selectGitHubAuthState,
}));
mock.module('@/stores/useLinearAuthStore', () => ({
useLinearAuthStore: selectLinearAuthState,
}));
mock.module('@/stores/useUIStore', () => ({
useUIStore: selectUIState,
}));
mock.module('@/sync/session-ui-store', () => ({
materializeOpenDraftSession: async () => null,
useSessionUIStore: useWorktreeStore,
}));
mock.module('@/sync/session-actions', () => ({
createSession: async () => null,
updateSessionTitle: async () => undefined,
}));
mock.module('@/hooks/useRuntimeAPIs', () => ({
useRuntimeAPIs: () => ({ github: {}, git: null, linear: null }),
}));
mock.module('@/stores/useGitStore', () => ({
useGitBranches: () => ({ all: ['main'] }),
useGitLoadingBranches: () => false,
useGitStore: selectGitState,
}));
mock.module('@/lib/worktrees/worktreeManager', () => ({
validateWorktreeCreate: async () => ({ ok: true, errors: [] }),
}));
mock.module('@/lib/worktrees/worktreeCreate', () => ({ createWorktreeWithDefaults: async () => null }));
mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ waitForWorktreeBootstrap: async () => undefined }));
mock.module('@/lib/openchamberConfig', () => ({
getWorktreeSetupCommands: async () => [],
getWorktreeSetupWaitEnabled: async () => false,
}));
mock.module('@/lib/worktrees/worktreeStatus', () => ({ getRootBranch: async () => 'main' }));
mock.module('@/lib/git/branchNameGenerator', () => ({ generateBranchSlug: () => 'draft-name' }));
mock.module('./GitHubIntegrationDialog', () => ({
GitHubIntegrationDialog: ({ onSelect }: { onSelect: (selection: GitHubSelection) => void }) => {
selectGitHubItem = onSelect;
return null;
},
}));
mock.module('./LinearIssuePickerDialog', () => ({ LinearIssuePickerDialog: () => null }));
const { NewWorktreeDialog } = await import('./NewWorktreeDialog');
const { I18nProvider } = await import('@/lib/i18n');
const DOM_GLOBAL_NAMES = [
'window',
'document',
'navigator',
'Node',
'Element',
'HTMLElement',
'HTMLIFrameElement',
'localStorage',
'IS_REACT_ACT_ENVIRONMENT',
] as const;
const installDom = () => {
const happyWindow = new Window({ url: 'http://localhost' });
const previous = DOM_GLOBAL_NAMES.map(
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
);
const values = {
window: happyWindow,
document: happyWindow.document,
navigator: happyWindow.navigator,
Node: happyWindow.Node,
Element: happyWindow.Element,
HTMLElement: happyWindow.HTMLElement,
HTMLIFrameElement: happyWindow.HTMLIFrameElement,
localStorage: happyWindow.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const name of DOM_GLOBAL_NAMES) {
Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true });
}
const container = document.createElement('div');
document.body.appendChild(container);
return {
container,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
describe('NewWorktreeDialog behavior', () => {
test('preserves selected issue values when available worktree names change', async () => {
const dom = installDom();
const root = createRoot(dom.container);
useWorktreeStore.setState({ availableWorktreesByProject: new Map() });
try {
await act(async () => root.render(
<I18nProvider>
<NewWorktreeDialog open onOpenChange={() => undefined} />
</I18nProvider>,
));
if (!selectGitHubItem) throw new Error('Expected GitHub selection handler');
await act(async () => selectGitHubItem?.({
type: 'issue',
item: { number: 42, title: 'Keep the selected issue' },
}));
const [branchInput, worktreeInput] = dom.container.querySelectorAll<HTMLInputElement>('input');
expect(branchInput?.value).toBe('issue-42-draft-name');
expect(worktreeInput?.value).toBe('issue-42-draft-name');
expect(dom.container.textContent).toContain('Keep the selected issue');
await act(async () => useWorktreeStore.setState({
availableWorktreesByProject: new Map([
[project.path, [{ name: 'newly-created-worktree' }]],
]),
}));
expect(branchInput?.value).toBe('issue-42-draft-name');
expect(worktreeInput?.value).toBe('issue-42-draft-name');
expect(dom.container.textContent).toContain('Keep the selected issue');
} finally {
await act(async () => root.unmount());
selectGitHubItem = null;
dom.restore();
}
});
});
@@ -51,7 +51,7 @@ import {
} from '@/lib/worktrees/worktreeSourceBranchPreference';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
import { GitHubIntegrationDialog, type GitHubWorktreeSelection } from './GitHubIntegrationDialog';
import { LinearIssuePickerDialog } from './LinearIssuePickerDialog';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
@@ -65,7 +65,7 @@ import type {
LinearIssue,
LinearIssueComment,
} from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager';
import { useI18n } from '@/lib/i18n';
type Mode = 'new-branch' | 'existing-branch';
@@ -456,6 +456,7 @@ export function NewWorktreeDialog({
// Creation state
const [isCreating, setIsCreating] = React.useState(false);
const [validationAbortController, setValidationAbortController] = React.useState<AbortController | null>(null);
const initializedForCurrentOpen = React.useRef(false);
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
const configState = useConfigStore.getState();
@@ -493,9 +494,7 @@ export function NewWorktreeDialog({
: undefined;
const provider = configState.providers.find((p) => p.id === providerID);
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| undefined;
const model = provider?.models.find((m) => m.id === modelID);
const variants = model?.variants;
if (!variants) return settingsDefaultVariant || currentVariant || undefined;
if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant;
@@ -763,7 +762,12 @@ export function NewWorktreeDialog({
// Reset state on each open. Resetting on close would empty the form during
// the close animation, causing visible flicker.
React.useLayoutEffect(() => {
if (!open) return;
if (!open) {
initializedForCurrentOpen.current = false;
return;
}
if (initializedForCurrentOpen.current) return;
initializedForCurrentOpen.current = true;
setMode('new-branch');
setExistingBranchState({
@@ -840,14 +844,15 @@ export function NewWorktreeDialog({
if (normalizedBranch && normalizedWorktree) {
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null;
const result = await validateWorktreeCreate(projectRef, {
const validateArgs: CreateWorktreeArgs = {
mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new',
branchName: normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined),
...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
});
};
if (prConfig?.ensureRemoteName) validateArgs.ensureRemoteName = prConfig.ensureRemoteName;
if (prConfig?.ensureRemoteUrl) validateArgs.ensureRemoteUrl = prConfig.ensureRemoteUrl;
const result = await validateWorktreeCreate(projectRef, validateArgs);
if (abortController.signal.aborted) return;
@@ -961,13 +966,13 @@ export function NewWorktreeDialog({
const sourceBranch = newBranchState.sourceBranch;
let sourceLabel = '';
const args = (() => {
const args: CreateWorktreeArgs = (() => {
if (linkedPr) {
const prConfig = resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches);
sourceLabel = prConfig.sourceLabel;
return {
const prArgs: CreateWorktreeArgs = {
preferredName: normalizedBranch || normalizedWorktree,
mode: 'existing' as const,
mode: 'existing',
branchName: normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: prConfig.existingBranch,
@@ -976,22 +981,24 @@ export function NewWorktreeDialog({
upstreamRemote: prConfig.upstreamRemote,
upstreamBranch: prConfig.upstreamBranch,
returnAfterDirectoryCreated: true,
...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
};
if (prConfig.ensureRemoteName) prArgs.ensureRemoteName = prConfig.ensureRemoteName;
if (prConfig.ensureRemoteUrl) prArgs.ensureRemoteUrl = prConfig.ensureRemoteUrl;
return prArgs;
}
sourceLabel = mode === 'new-branch' ? sourceBranch : '';
return {
const baseArgs: CreateWorktreeArgs = {
preferredName: normalizedBranch || normalizedWorktree,
mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const,
mode: mode === 'existing-branch' ? 'existing' : 'new',
branchName: mode === 'existing-branch' ? undefined : normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
setupCommands,
returnAfterDirectoryCreated: true,
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
};
if (sourceBranch && mode === 'new-branch') baseArgs.startRef = sourceBranch;
return baseArgs;
})();
const metadata = await createWorktreeWithDefaults(projectRef, args);
@@ -1084,11 +1091,7 @@ export function NewWorktreeDialog({
};
// Handle GitHub selection
const handleGitHubSelect = (result: {
type: 'issue' | 'pr';
item: GitHubIssue | GitHubPullRequestSummary;
includeDiff?: boolean;
} | null) => {
const handleGitHubSelect = (result: GitHubWorktreeSelection | null) => {
if (!result) {
setNewBranchState(prev => ({
...prev,
@@ -1102,7 +1105,7 @@ export function NewWorktreeDialog({
}
if (result.type === 'issue') {
const issue = result.item as GitHubIssue;
const issue = result.item;
const newBranchName = `issue-${issue.number}-${generateBranchSlug()}`;
setNewBranchState(prev => ({
...prev,
@@ -1115,7 +1118,7 @@ export function NewWorktreeDialog({
isSyncingWorktreeName: true,
}));
} else if (result.type === 'pr') {
const pr = result.item as GitHubPullRequestSummary;
const pr = result.item;
setNewBranchState(prev => ({
...prev,
linkedPr: pr,
@@ -1261,7 +1264,11 @@ export function NewWorktreeDialog({
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
onSelect={(id) => {
if (id === 'new-branch' || id === 'existing-branch') {
handleModeChange(id);
}
}}
variant="active-pill"
layoutMode="fit"
className="w-full"
@@ -1767,7 +1774,11 @@ export function NewWorktreeDialog({
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
onSelect={(id) => {
if (id === 'new-branch' || id === 'existing-branch') {
handleModeChange(id);
}
}}
variant="active-pill"
layoutMode="fit"
className="w-full"
@@ -347,6 +347,8 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
const renameDraftRef = React.useRef(renameDraft);
renameDraftRef.current = renameDraft;
const renameTargetRef = React.useRef<string | null>(null);
const pendingRenameSelectRef = React.useRef(false);
const renameInputRef = React.useRef<HTMLInputElement>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session;
@@ -633,9 +635,24 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
}
if (renameTargetRef.current === session.id) return;
renameTargetRef.current = session.id;
pendingRenameSelectRef.current = true;
setRenameDraft(editTitle);
}, [editingId, editTitle, session.id]);
// Entering rename mode selects the whole title, so the first keystroke
// replaces it instead of appending to it. The selection waits for the commit
// that actually renders `editTitle`: the draft state above is seeded when the
// row mounts, so on a session whose title changed since then the input still
// holds the old text during the commit that opens the form.
React.useLayoutEffect(() => {
if (editingId !== session.id || !pendingRenameSelectRef.current) return;
const input = renameInputRef.current;
if (!input || input.value !== editTitle) return;
pendingRenameSelectRef.current = false;
input.focus();
input.select();
}, [editingId, editTitle, renameDraft, session.id]);
if (editingId === session.id) {
return (
<div
@@ -656,6 +673,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
}}
>
<input
ref={renameInputRef}
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
+123 -48
View File
@@ -20,6 +20,7 @@ import {
useGitLoadingLog,
} from '@/stores/useGitStore';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
@@ -203,8 +204,14 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
const [worktreeBootstrapSnapshot, setWorktreeBootstrapSnapshot] = React.useState<{
directory: string;
status: 'pending' | 'ready' | 'failed' | null;
} | null>(null);
const [postBootstrapRefresh, setPostBootstrapRefresh] = React.useState<{
directory: string;
status: 'refreshing' | 'failed';
} | null>(null);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
@@ -285,7 +292,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const isLogLoading = useGitLoadingLog(gitDirectory ?? null);
const {
setActiveDirectory,
fetchAll,
ensureAll,
fetchStatus,
fetchBranches,
@@ -301,7 +307,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
selectNestedRepo,
} = useGitStore(useShallow((state) => ({
setActiveDirectory: state.setActiveDirectory,
fetchAll: state.fetchAll,
ensureAll: state.ensureAll,
fetchStatus: state.fetchStatus,
fetchBranches: state.fetchBranches,
@@ -329,7 +334,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
});
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
@@ -438,11 +442,13 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory) {
setWorktreeBootstrapStatus(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
setWorktreeBootstrapSnapshot(null);
return;
}
const bootstrapDirectory = normalizePath(currentDirectory) ?? currentDirectory;
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
let cancelled = false;
let timeoutId: number | null = null;
@@ -452,7 +458,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
if (cancelled) {
return;
}
setWorktreeBootstrapStatus(next.status);
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: next.status });
if (next.status === 'pending') {
timeoutId = window.setTimeout(() => {
void poll();
@@ -460,7 +466,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
} catch {
if (!cancelled) {
setWorktreeBootstrapStatus(null);
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
}
}
};
@@ -475,37 +481,84 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
};
}, [isActive, currentDirectory]);
React.useEffect(() => {
const previous = previousBootstrapStatusRef.current;
previousBootstrapStatusRef.current = worktreeBootstrapStatus;
if (!currentDirectory || !git) {
return;
}
if (previous === 'pending' && worktreeBootstrapStatus === 'ready') {
setIsWaitingForGitRefreshAfterBootstrap(true);
void fetchAll(currentDirectory, git).finally(() => {
window.setTimeout(() => {
setIsWaitingForGitRefreshAfterBootstrap(false);
}, 1200);
});
}
if (worktreeBootstrapStatus === 'failed') {
setDraftBootstrapPendingDirectory(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
}
}, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]);
const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
const isDraftBootstrapPendingForCurrentDirectory = Boolean(
currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory)
);
const sharedWorktreeBootstrapPending = useWorktreeBootstrapPending(currentDirectory ?? null);
const normalizedCurrentBootstrapDirectory = normalizePath(currentDirectory);
const observedWorktreeBootstrapStatus = worktreeBootstrapSnapshot?.directory === normalizedCurrentBootstrapDirectory
? worktreeBootstrapSnapshot.status
: null;
const isPendingWorktreeSetup = Boolean(
currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory)
currentDirectory
&& (
sharedWorktreeBootstrapPending
|| observedWorktreeBootstrapStatus === 'pending'
|| (isDraftBootstrapPendingForCurrentDirectory && newSessionDraft?.pendingWorktreeRequestId)
)
);
const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap;
const isPostBootstrapRefreshForCurrentDirectory = Boolean(
normalizedCurrentBootstrapDirectory
&& postBootstrapRefresh?.directory === normalizedCurrentBootstrapDirectory
);
React.useEffect(() => {
if (!normalizedCurrentBootstrapDirectory) return;
if (observedWorktreeBootstrapStatus === 'failed') {
setDraftBootstrapPendingDirectory(null);
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory ? null : current
));
return;
}
if (isPendingWorktreeSetup) {
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory && current.status === 'refreshing'
? current
: { directory: normalizedCurrentBootstrapDirectory, status: 'refreshing' }
));
return;
}
if (
postBootstrapRefresh?.directory !== normalizedCurrentBootstrapDirectory
|| postBootstrapRefresh.status !== 'refreshing'
|| !gitDirectory
|| !git
) {
return;
}
let cancelled = false;
void fetchStatus(gitDirectory, git, {
force: true,
silent: true,
throwOnError: true,
}).then(() => {
if (cancelled) return;
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory ? null : current
));
}).catch(() => {
if (cancelled) return;
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory
? { ...current, status: 'failed' }
: current
));
});
return () => {
cancelled = true;
};
}, [fetchStatus, git, gitDirectory, isPendingWorktreeSetup, normalizedCurrentBootstrapDirectory, observedWorktreeBootstrapStatus, postBootstrapRefresh, setDraftBootstrapPendingDirectory]);
const shouldHideGitState = isPendingWorktreeSetup || isPostBootstrapRefreshForCurrentDirectory;
const postBootstrapRefreshFailed = isPostBootstrapRefreshForCurrentDirectory
&& postBootstrapRefresh?.status === 'failed';
const initialSnapshot = React.useMemo(() => {
if (!gitDirectory) return null;
@@ -2336,6 +2389,42 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
);
}
if (shouldHideGitState) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
{!postBootstrapRefreshFailed ? (
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
) : null}
<p className="typography-ui-label font-semibold text-foreground">
{postBootstrapRefreshFailed
? t('gitView.toast.refreshRepositoryFailed')
: t('gitView.empty.worktreeSetupInProgress')}
</p>
{!postBootstrapRefreshFailed ? (
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.worktreeSetupDescription')}
</p>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => {
if (!normalizedCurrentBootstrapDirectory) return;
setPostBootstrapRefresh({
directory: normalizedCurrentBootstrapDirectory,
status: 'refreshing',
});
}}
>
{t('gitView.empty.retryDiscovery')}
</Button>
)}
</div>
);
}
if (isGitRepo === null || (isGitRepo === true && !status)) {
return (
<div className="flex h-full items-center justify-center">
@@ -2348,20 +2437,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
if (isGitRepo === false) {
if (shouldHideNotGitState) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.empty.worktreeSetupInProgress')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.worktreeSetupDescription')}
</p>
</div>
);
}
// Nested repository discovery states (discovering, failed, unsupported,
// none found, or settling on the auto-selected repository).
return (
@@ -148,11 +148,18 @@ const resolveSessionSendConfig = (sessionId: string) => {
?? config.currentModelId
?? selection.lastUsedProvider?.modelID;
const variant =
// A recorded `null` is an explicit "Default": it stops the lookup and sends
// no effort, instead of falling through to the persisted copy.
const savedVariant =
selectedAgent && providerID && modelID
? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)
?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID))
? (() => {
const live = selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID);
return live !== undefined
? live
: context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID);
})()
: undefined;
const variant = savedVariant ?? undefined;
return {
providerID,
@@ -0,0 +1,18 @@
import React from 'react';
import { getWorktreeBootstrapState, subscribeWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap';
/**
* Whether `directory` is a worktree whose creation has not finished yet: the
* directory exists, but setup commands and the initial git reset are still
* running. Until that completes the working tree transiently looks dirty, so
* surfaces that report uncommitted changes consult this and show nothing
* instead of presenting bootstrap noise as real changes on the branch.
*/
export const useWorktreeBootstrapPending = (directory: string | null): boolean => {
const getSnapshot = React.useCallback(
() => (directory ? getWorktreeBootstrapState(directory)?.status === 'pending' : false),
[directory],
);
return React.useSyncExternalStore(subscribeWorktreeBootstrapState, getSnapshot, getSnapshot);
};
+1 -1
View File
@@ -495,7 +495,7 @@ interface GitWorktreeAPI {
export interface GitAPI {
checkIsGitRepository(directory: string): Promise<boolean>;
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus>;
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
+1 -1
View File
@@ -84,7 +84,7 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
return gitHttp.checkIsGitRepository(directory);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<import('./api/types').GitStatus> {
export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<import('./api/types').GitStatus> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitStatus(directory, options);
return gitHttp.getGitStatus(directory, options);
+75
View File
@@ -29,6 +29,7 @@ import {
unstageGitFiles,
} from './gitApiHttp';
import type { GitStatus } from './api/types';
import { sessionEvents } from './sessionEvents';
type FetchCall = {
input: RequestInfo | URL;
@@ -141,6 +142,28 @@ describe('gitApiHttp index mutations', () => {
});
describe('gitApiHttp status cache', () => {
test('a Git refresh hint invalidates the cached status before listeners fetch', async () => {
installWindowMock();
let statusRequestCount = 0;
globalThis.fetch = async () => {
statusRequestCount += 1;
return jsonResponse(statusPayload({ behind: statusRequestCount }));
};
try {
const directory = '/repo-cache-tool-mutation';
const first = await getGitStatus(directory);
sessionEvents.requestGitRefresh({ directory });
const afterMutation = await getGitStatus(directory);
expect(first.behind).toBe(1);
expect(afterMutation.behind).toBe(2);
expect(statusRequestCount).toBe(2);
} finally {
restoreMocks();
}
});
test('invalidates cached status after fetch', async () => {
installWindowMock();
const calls: FetchCall[] = [];
@@ -188,6 +211,58 @@ describe('gitApiHttp status cache', () => {
restoreMocks();
}
});
test('fresh status bypasses an unexpired cached snapshot', async () => {
installWindowMock();
let statusRequestCount = 0;
globalThis.fetch = (async () => {
statusRequestCount += 1;
return jsonResponse(statusPayload({ behind: statusRequestCount }));
}) as typeof fetch;
try {
const directory = '/repo-cache-fresh';
const first = await getGitStatus(directory);
const cached = await getGitStatus(directory);
const fresh = await getGitStatus(directory, { fresh: true });
expect(first.behind).toBe(1);
expect(cached.behind).toBe(1);
expect(fresh.behind).toBe(2);
expect(statusRequestCount).toBe(2);
} finally {
restoreMocks();
}
});
test('fresh status cannot be replaced in cache by an older in-flight response', async () => {
installWindowMock();
const statusResolvers: Array<(response: Response) => void> = [];
// SAFETY: the mock accepts the same arguments as fetch and always returns
// a pending Response promise controlled by this test.
globalThis.fetch = (async () => new Promise<Response>((resolve) => {
statusResolvers.push(resolve);
})) as typeof fetch;
try {
const directory = '/repo-cache-fresh-race';
const older = getGitStatus(directory);
await new Promise((resolve) => setTimeout(resolve, 0));
const fresh = getGitStatus(directory, { fresh: true });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(statusResolvers).toHaveLength(2);
statusResolvers[1](jsonResponse(statusPayload({ current: 'fresh' })));
statusResolvers[0](jsonResponse(statusPayload({ current: 'stale' })));
expect((await fresh).current).toBe('fresh');
expect((await older).current).toBe('stale');
expect((await getGitStatus(directory)).current).toBe('fresh');
expect(statusResolvers).toHaveLength(2);
} finally {
restoreMocks();
}
});
});
const statusPayload = (overrides: Partial<GitStatus> = {}): GitStatus => ({
+15 -4
View File
@@ -40,7 +40,7 @@ import { normalizePath } from './pathNormalization';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation';
const API_BASE = '/api/git';
const GIT_STATUS_CACHE_TTL_MS = 1200;
@@ -60,8 +60,7 @@ const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
const invalidateGitStatusCache = (directory: string): void => {
const runtimeKey = getRuntimeKey();
const clearGitStatusCache = (runtimeKey: string, directory: string): void => {
const key = getDirectoryCacheKey(runtimeKey, directory);
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
for (const mode of [undefined, 'light'] as const) {
@@ -69,6 +68,13 @@ const invalidateGitStatusCache = (directory: string): void => {
gitStatusCache.delete(statusKey);
gitStatusInFlight.delete(statusKey);
}
};
subscribeGitStatusInvalidations((directory) => {
clearGitStatusCache(getRuntimeKey(), directory);
});
const invalidateGitStatusCache = (directory: string): void => {
notifyGitStatusInvalidated(directory);
};
@@ -162,9 +168,14 @@ export async function listGitDirectories(root: string): Promise<string[]> {
.filter((path): path is string => path !== null);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> {
const mode = options?.mode;
const runtimeKey = getRuntimeKey();
if (options?.fresh) {
// A forced read must cross the transport cache boundary too. Advancing the
// version also prevents an older in-flight response from repopulating it.
clearGitStatusCache(runtimeKey, directory);
}
const key = getStatusCacheKey(runtimeKey, directory, mode);
const now = Date.now();
const cached = gitStatusCache.get(key);
+7 -7
View File
@@ -1,18 +1,18 @@
/**
* Minimal notification channel for git status invalidation.
*
* Every successful status-affecting git mutation must call
* Every confirmed status-affecting mutation must call
* `notifyGitStatusInvalidated`. `useGitStore` subscribes and bumps its
* per-directory status mutation revision so an immediate refresh cannot join an
* in-flight status request admitted before the mutation, and a stale response
* cannot commit over newer authoritative state.
* cannot commit over newer authoritative state. The HTTP adapter also subscribes
* and clears its short-lived status cache.
*
* Runtime parity: this is about the store's in-flight status request, not about
* adapter caching, so it applies to every runtime. The HTTP adapter in
* `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the
* VS Code bridge) have no cache of their own, so the dispatch layer in
* `gitApi.ts` emits it for them after a successful runtime mutation. Either
* path announces a mutation exactly once.
* adapter caching, so it applies to every runtime. HTTP mutations emit from
* `gitApiHttp.ts`; runtime adapters such as the VS Code bridge emit from the
* dispatch layer in `gitApi.ts`. Tool and editor mutations emit through the
* shared Git refresh hint. Each path announces a mutation exactly once.
*/
type GitStatusInvalidationListener = (directory: string) => void;
+21
View File
@@ -1,4 +1,6 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { Part } from '@opencode-ai/sdk/v2/client';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
import type { WorktreeMetadata } from '@/types/worktree';
export type SessionDeleteRequest = {
@@ -24,6 +26,12 @@ const deleteListeners = new Set<DeleteListener>();
const createListeners = new Set<CreateListener>();
const directoryListeners = new Set<DirectoryListener>();
const gitRefreshListeners = new Set<GitRefreshListener>();
const gitMutatingTools = new Set(['bash', 'edit', 'write', 'apply_patch', 'patch']);
const normalizeToolName = (tool: string): string => {
const parts = tool.trim().toLowerCase().split('.').filter(Boolean);
return parts[parts.length - 1] ?? '';
};
export const sessionEvents = {
onDeleteRequest(listener: DeleteListener) {
@@ -67,6 +75,19 @@ export const sessionEvents = {
if (!hint.directory.trim()) {
return;
}
notifyGitStatusInvalidated(hint.directory);
gitRefreshListeners.forEach((listener) => listener(hint));
},
requestGitRefreshForToolTransition(directory: string, previousPart: Part | undefined, nextPart: Part) {
if (nextPart.type !== 'tool' || nextPart.state.status !== 'completed') {
return;
}
if (previousPart?.type === 'tool' && previousPart.state.status === 'completed') {
return;
}
if (!gitMutatingTools.has(normalizeToolName(nextPart.tool))) {
return;
}
sessionEvents.requestGitRefresh({ directory });
},
};
@@ -51,6 +51,7 @@ const {
markWorktreeBootstrapPending,
setWorktreeBootstrapState,
startWorktreeBootstrapWatcher,
subscribeWorktreeBootstrapState,
waitForWorktreeBootstrap,
waitForWorktreeGitReady,
} = await import('./worktreeBootstrap');
@@ -246,3 +247,39 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
clearWorktreeBootstrapState('/repo-wt');
});
});
describe('worktreeBootstrap subscription', () => {
beforeEach(() => {
clearWorktreeBootstrapState('/repo-wt');
});
test('notifies subscribers as a directory enters and leaves bootstrap', () => {
const pendingSnapshots: boolean[] = [];
const unsubscribe = subscribeWorktreeBootstrapState(() => {
pendingSnapshots.push(getWorktreeBootstrapState('/repo-wt')?.status === 'pending');
});
try {
markWorktreeBootstrapPending('/repo-wt');
setWorktreeBootstrapState('/repo-wt', { status: 'ready', error: null, updatedAt: 2 });
clearWorktreeBootstrapState('/repo-wt');
} finally {
unsubscribe();
}
expect(pendingSnapshots).toEqual([true, false, false]);
});
test('stops notifying after unsubscribe', () => {
let notifications = 0;
const unsubscribe = subscribeWorktreeBootstrapState(() => {
notifications += 1;
});
markWorktreeBootstrapPending('/repo-wt');
unsubscribe();
clearWorktreeBootstrapState('/repo-wt');
expect(notifications).toBe(1);
});
});
@@ -24,6 +24,23 @@ const watchers = new Map<string, { cancelled: boolean; lifecycleVersion: number
const getKey = (directory: string): string => normalizePath(directory);
const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`;
// UI surfaces subscribe to know when a directory enters or leaves bootstrap,
// so a half-created worktree's transient files are never shown as changes.
const bootstrapListeners = new Set<() => void>();
const notifyBootstrapListeners = (): void => {
for (const listener of bootstrapListeners) {
listener();
}
};
export const subscribeWorktreeBootstrapState = (listener: () => void): (() => void) => {
bootstrapListeners.add(listener);
return () => {
bootstrapListeners.delete(listener);
};
};
const startLifecycle = (key: string): void => {
const watcher = watchers.get(key);
if (watcher) {
@@ -72,6 +89,7 @@ const storePolledState = (
}
state.set(key, next);
notifyBootstrapListeners();
return next;
};
@@ -98,6 +116,7 @@ export const markWorktreeBootstrapPending = (directory: string): void => {
error: null,
updatedAt: Date.now(),
});
notifyBootstrapListeners();
};
export const clearWorktreeBootstrapState = (directory: string): void => {
@@ -108,6 +127,7 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
startLifecycle(key);
state.delete(key);
lifecycleVersions.delete(key);
notifyBootstrapListeners();
};
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
@@ -117,6 +137,7 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots
}
startLifecycle(key);
state.set(key, next);
notifyBootstrapListeners();
};
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
@@ -15,9 +15,11 @@ let gitStatus: {
behind: number;
} | null = null;
let branchTracking: MockBranchTracking = {};
let isGitRepository = true;
const createdPayloads: CreateWorktreeArgs[] = [];
mock.module('@/lib/gitApi', () => ({
checkIsGitRepository: () => Promise.resolve(isGitRepository),
getGitStatus: () => (gitStatus ? Promise.resolve(gitStatus) : Promise.reject(new Error('no status'))),
getGitBranches: () => Promise.resolve({
all: [],
@@ -52,7 +54,11 @@ mock.module('@/lib/worktrees/worktreeManager', () => ({
},
}));
const { createWorktreeWithDefaults, withWorktreeRemoteStartRef } = await import('./worktreeCreate');
const {
createWorktreeWithDefaults,
withWorktreeRemoteStartRef,
WorktreeRequiresGitRepositoryError,
} = await import('./worktreeCreate');
const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({
preferredName: 'openchamber/feature',
@@ -168,9 +174,17 @@ describe('createWorktreeWithDefaults remote source integration', () => {
projectRoot = '/repo';
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 };
branchTracking = { main: 'origin/main' };
isGitRepository = true;
createdPayloads.length = 0;
});
test('rejects a non-Git project before asking the runtime to create a worktree', async () => {
isGitRepository = false;
await expect(createWorktreeWithDefaults(project, baseArgs())).rejects.toThrow(WorktreeRequiresGitRepositoryError);
expect(createdPayloads).toHaveLength(0);
});
test('sets the new branch\'s own upstream when using the tracked remote source', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
@@ -1,8 +1,15 @@
import { getGitBranches, getGitStatus } from '@/lib/gitApi';
import { checkIsGitRepository, getGitBranches, getGitStatus } from '@/lib/gitApi';
import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager';
import { createWorktree } from '@/lib/worktrees/worktreeManager';
import { getRootBranch, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus';
export class WorktreeRequiresGitRepositoryError extends Error {
constructor() {
super('Worktree creation requires a Git repository');
this.name = 'WorktreeRequiresGitRepositoryError';
}
}
const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => {
const value = String(tracking || '').trim().replace(/^remotes\//, '');
if (!value) {
@@ -162,6 +169,10 @@ export const createWorktreeWithDefaults = async (
args: CreateWorktreeArgs,
options?: { resolvedRootTrackingRemote?: string | null }
) => {
const isGitRepository = await checkIsGitRepository(project.path);
if (!isGitRepository) {
throw new WorktreeRequiresGitRepositoryError();
}
const remoteArgs = await withWorktreeRemoteStartRef(project, args);
const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, remoteArgs, options);
return createWorktree(project, resolvedArgs);
@@ -118,6 +118,7 @@ const {
getLatestWorktreeMetadata,
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
removeProjectWorktree,
validateWorktreeCreate,
worktreeMapsEqual,
} = await import('./worktreeManager');
@@ -389,6 +390,44 @@ describe('worktreeManager list invalidation', () => {
expect(metadata.worktreeStatus).toBe('pending');
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
});
test('removes a worktree from sidebar topology owned by another registered checkout', async () => {
const removed: WorktreeMetadata = {
path: '/worktrees/removed',
projectDirectory: '/repo',
branch: 'removed',
label: 'removed',
};
const sibling: WorktreeMetadata = {
path: '/worktrees/sibling',
projectDirectory: '/repo',
branch: 'sibling',
label: 'sibling',
};
const unrelatedEntries: WorktreeMetadata[] = [{
path: '/other/worktree',
projectDirectory: '/other',
branch: 'other',
label: 'other',
}];
sessionState.availableWorktreesByProject = new Map([
['/worktrees/configured', [removed, sibling]],
['/other', unrelatedEntries],
]);
sessionState.availableWorktrees = [removed, sibling, ...unrelatedEntries];
sessionState.worktreeMetadata = new Map([
['removed-session', removed],
['sibling-session', sibling],
]);
await removeProjectWorktree({ id: 'path:/repo', path: '/repo' }, removed);
expect(sessionState.availableWorktreesByProject.get('/worktrees/configured')).toEqual([sibling]);
expect(sessionState.availableWorktreesByProject.get('/other')).toBe(unrelatedEntries);
expect(sessionState.availableWorktrees).toEqual([sibling, ...unrelatedEntries]);
expect(sessionState.worktreeMetadata.has('removed-session')).toBe(false);
expect(sessionState.worktreeMetadata.get('sibling-session')).toBe(sibling);
});
});
describe('worktreeMapsEqual', () => {
@@ -606,14 +606,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
// Update sidebar store so removed worktree disappears immediately
const normalizedWorktreePath = normalizePath(worktree.path);
const sidebarProjectKey = projectDirectory;
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
const updatedByProject = new Map(currentByProject);
const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? [];
updatedByProject.set(
sidebarProjectKey,
projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath),
);
for (const [projectKey, projectWorktrees] of currentByProject) {
const remainingWorktrees = projectWorktrees.filter(
(candidate) => normalizePath(candidate.path) !== normalizedWorktreePath,
);
if (remainingWorktrees.length !== projectWorktrees.length) {
updatedByProject.set(projectKey, remainingWorktrees);
}
}
// Clean up worktreeMetadata for sessions in the removed worktree
const currentMetadata = useSessionUIStore.getState().worktreeMetadata;
+28 -7
View File
@@ -156,7 +156,8 @@ Important properties:
- 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
- `fetchStatus({ force: true })` and `fetchAll({ force: true })` cross both the store and runtime transport caches; a forced reconciliation must reach the active runtime rather than reuse an unexpired browser status snapshot
- status requests do not start while a managed worktree bootstrap is pending, and a response admitted before bootstrap began is discarded if it completes after the directory enters `pending`; the `--no-checkout` population window is not user working-tree state
- 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
@@ -220,11 +221,29 @@ Each of them therefore keeps two things:
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.
paths capture a stable configuration. `currentVariantSelection` says where that
value came from: a string is an effort chosen in the picker or by the shortcut,
`null` is an explicit `Default`, and `undefined` is automatic initialization,
which lets the inherited default apply.
`Default` sends no effort at all. It cannot resolve back to the inherited
default: the settings default would take effect again, and the next assistant
reply echoes that effort back as an explicit choice, so the picker jumps off
`Default` one message after the user chose it. For the same reason the
per-session selection store records an explicit `Default` (as `null`) instead of
clearing the entry — a cleared entry is indistinguishable from never having
chosen, and the settings default wins again on the next agent or session switch.
Only a place where the user chose may write `null`. Restore paths — message
history, a preserved manual override — pass their own "found nothing" through
as `undefined`, because a session whose history carries no effort is not a
session where `Default` was picked. A restore that manufactures `null` latches
the session onto `Default`: `null` outranks the agent and settings defaults by
design, so the concrete effort it displaced can never come back.
Every write of `currentVariant` writes `currentVariantSelection` with it. They
are one selection; updating only the effective value leaves the picker showing
one effort while sends carry another.
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
@@ -317,9 +336,11 @@ Do not raise limits casually.
Expected model:
- `GitView` / `DiffView` ensure current-directory Git state when visible
- the Git view gates its status-derived content and actions while a managed worktree bootstrap is pending, then keeps the gate closed until one forced fresh status read succeeds; refresh failure exposes retry without revealing the cached bootstrap snapshot
- 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
- the sync event handler issues one Git refresh hint when a live file-mutating tool first reaches `completed`; this does not depend on `ToolPart` mounting, and duplicate terminal events do not replay the hint
- every Git refresh hint invalidates the store request generation and the HTTP status cache before visible consumers request status, so they share one post-mutation read instead of accepting a cached or pre-mutation response
- 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
- targeted diff remounts preserve the user's current file-section anchor and intra-file offset before paint instead of resetting the stacked view to the top
+7 -5
View File
@@ -24,8 +24,10 @@ interface ContextState {
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
// sessionId → agentName → "providerId/modelId" → variant
sessionAgentModelVariantSelections: Map<string, Map<string, Map<string, string>>>;
// sessionId → agentName → "providerId/modelId" → variant, where `null` is
// an explicit "Default" (send no effort) and a missing entry means the
// inherited default applies.
sessionAgentModelVariantSelections: Map<string, Map<string, Map<string, string | null>>>;
currentAgentContext: Map<string, string>;
@@ -45,8 +47,8 @@ interface ContextActions {
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | null | undefined;
getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => ContextUsage | null;
@@ -145,7 +147,7 @@ export const useContextStore = create<ContextStore>()(
return agentMap.get(agentName) || null;
},
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => {
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => {
set((state) => {
const newSelections = new Map(state.sessionAgentModelVariantSelections);
+71 -3
View File
@@ -544,7 +544,8 @@ describe('useConfigStore provider persistence', () => {
useConfigStore.getState().setCurrentVariantOverride('max', 'high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('high');
// Default is a choice to send no effort, not a way back to the inherited one.
expect(useConfigStore.getState().currentVariant).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'high' });
});
@@ -562,7 +563,7 @@ describe('useConfigStore provider persistence', () => {
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');
expect(useConfigStore.getState().currentVariant).toBe(undefined);
});
test('an unavailable explicit variant cycles back to Default', () => {
@@ -576,7 +577,7 @@ describe('useConfigStore provider persistence', () => {
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('low');
expect(useConfigStore.getState().currentVariant).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
});
@@ -608,6 +609,73 @@ describe('useConfigStore provider persistence', () => {
expect(useConfigStore.getState().currentVariant).toBe('medium');
});
test('an explicit Default effort sends no variant instead of the settings default', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: 'low',
currentVariantSelection: { override: 'low', inherited: 'low' },
settingsDefaultVariant: 'low',
directoryScoped: {},
});
useConfigStore.getState().setCurrentVariantOverride(null, 'low');
expect(useConfigStore.getState().currentVariant).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'low' });
});
test('setAgent keeps a session Default effort instead of restoring the settings default', () => {
const sessionId = 'ses_agent_default_effort';
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveAgentModelForSession(sessionId, 'plan', 'openai', 'gpt-5.5');
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', null);
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('plan')],
settingsDefaultVariant: 'low',
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: 'low',
currentVariantSelection: { override: undefined, inherited: 'low' },
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentVariant).toBe(undefined);
expect(state.currentVariantSelection).toEqual({ override: null, inherited: 'low' });
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe(undefined);
});
test('setAgent reports the same effort through currentVariant and the picker selection', () => {
const sessionId = 'ses_agent_effort_in_sync';
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveAgentModelForSession(sessionId, 'plan', 'openai', 'gpt-5.5');
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', 'high');
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('plan')],
settingsDefaultVariant: 'low',
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: 'low',
currentVariantSelection: { override: 'low', inherited: 'low' },
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentVariant).toBe('high');
expect(state.currentVariantSelection).toEqual({ override: 'high', inherited: 'low' });
});
test('setAgent applies settings default variant for a saved session agent model', () => {
const sessionId = 'ses_existing_agent_model_default_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
+72 -21
View File
@@ -904,11 +904,28 @@ interface DirectoryScopedConfig {
selectionSource?: "auto" | "manual";
}
/**
* The thinking-effort selection, split into what the user picked and what
* applies when they picked nothing:
*
* - `override: string` an effort chosen in the picker
* - `override: null` "Default" chosen in the picker send no effort
* - `override: undefined` nothing chosen the inherited default applies
*
* `null` and `undefined` are not interchangeable: collapsing them makes the
* "Default" entry unpickable, because the settings default silently takes
* effect again and the next assistant reply echoes it back as an explicit
* choice.
*/
type CurrentVariantSelection = {
override: string | null | undefined;
inherited: string | undefined;
};
const resolveVariantFromSelection = (selection: CurrentVariantSelection): string | undefined => (
selection.override === null ? undefined : selection.override ?? selection.inherited
);
/**
* Lift the active directory's cached provider/agent snapshot into the top-level
* fields the pickers read (`providers`, `agents`, selections), so a cold start
@@ -1902,7 +1919,7 @@ export const useConfigStore = create<ConfigStore>()(
setCurrentVariantOverride: (override, inherited) => {
set((state) => {
const currentVariant = override ?? inherited;
const currentVariant = resolveVariantFromSelection({ override, inherited });
if (
state.currentVariant === currentVariant
&& state.currentVariantSelection.override === override
@@ -2527,8 +2544,27 @@ export const useConfigStore = create<ConfigStore>()(
if (agentName) {
const { currentSessionId } = useSessionUIStore.getState();
const applyResolvedModelSelection = (providerId: string, modelId: string, variant?: string) => {
// Writes the effort alongside the model, because the two are one
// selection: leaving `currentVariantSelection` behind would let the
// picker show one effort while sends carry another.
const applyResolvedModelSelection = (
providerId: string,
modelId: string,
variantSelection: CurrentVariantSelection,
) => {
set((state) => {
const variant = resolveVariantFromSelection(variantSelection);
if (
state.currentProviderId === providerId
&& state.currentModelId === modelId
&& state.currentVariant === variant
&& state.currentVariantSelection.override === variantSelection.override
&& state.currentVariantSelection.inherited === variantSelection.inherited
&& state.selectionSource === "manual"
) {
return state;
}
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
@@ -2554,6 +2590,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: providerId,
currentModelId: modelId,
currentVariant: variant,
currentVariantSelection: variantSelection,
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
@@ -2563,16 +2600,24 @@ export const useConfigStore = create<ConfigStore>()(
});
};
const resolveVariantForModel = (
const resolveVariantSelectionForModel = (
providerId: string,
modelId: string,
agentVariant?: string,
): string | undefined => {
): CurrentVariantSelection => {
const model = providers
.find((provider) => provider.id === providerId)
?.models.find((candidate) => candidate.id === modelId) as { variants?: Record<string, unknown> } | undefined;
const variants = model?.variants;
if (!variants) return undefined;
if (!variants) return { override: undefined, inherited: undefined };
const isAvailable = (candidate: string | null | undefined): candidate is string => (
candidate !== null
&& candidate !== undefined
&& Object.prototype.hasOwnProperty.call(variants, candidate)
);
const inherited = [agentVariant, settingsDefaultVariant].find(isAvailable);
const savedVariant = currentSessionId
? useSelectionStore.getState().getAgentModelVariantForSession(
@@ -2582,14 +2627,23 @@ export const useConfigStore = create<ConfigStore>()(
modelId,
)
: undefined;
for (const candidate of [savedVariant, agentVariant, settingsDefaultVariant]) {
if (candidate && Object.prototype.hasOwnProperty.call(variants, candidate)) {
return candidate;
}
// `null` is this session's explicit "Default"; it outranks
// the agent and settings defaults just like a named effort.
if (savedVariant === null || isAvailable(savedVariant)) {
return { override: savedVariant, inherited };
}
return undefined;
// While drafting there is no session record to read the choice
// back from, and switching agent is not a change of effort:
// keep the picker's choice for this same model, "Default"
// (an explicit `null`) included.
const liveSelection = get().currentVariantSelection;
const sameModel = get().currentProviderId === providerId && get().currentModelId === modelId;
if (!currentSessionId && sameModel && (liveSelection.override === null || isAvailable(liveSelection.override))) {
return { override: liveSelection.override, inherited };
}
return { override: undefined, inherited };
};
const agent = agents.find((candidate) => candidate.name === agentName);
@@ -2601,14 +2655,11 @@ export const useConfigStore = create<ConfigStore>()(
if (currentSessionId) {
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
const resolvedVariant = resolveVariantForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant);
if (
currentProviderId !== existingAgentModel.providerId
|| currentModelId !== existingAgentModel.modelId
|| get().currentVariant !== resolvedVariant
) {
applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, resolvedVariant);
}
applyResolvedModelSelection(
existingAgentModel.providerId,
existingAgentModel.modelId,
resolveVariantSelectionForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant),
);
return;
}
}
@@ -2621,7 +2672,7 @@ export const useConfigStore = create<ConfigStore>()(
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
if (agentModel) {
applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant));
applyResolvedModelSelection(providerID, modelID, resolveVariantSelectionForModel(providerID, modelID, agent?.variant));
return;
}
}
@@ -2653,7 +2704,7 @@ export const useConfigStore = create<ConfigStore>()(
if (parsed) {
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
applyResolvedModelSelection(parsed.providerId, parsed.modelId, resolveVariantForModel(parsed.providerId, parsed.modelId, agent?.variant));
applyResolvedModelSelection(parsed.providerId, parsed.modelId, resolveVariantSelectionForModel(parsed.providerId, parsed.modelId, agent?.variant));
return;
}
}
+56 -1
View File
@@ -3,6 +3,7 @@ import type { GitStatus } from '@/lib/api/types';
import { useGitStore } from './useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
import { clearWorktreeBootstrapState, markWorktreeBootstrapPending } from '@/lib/worktrees/worktreeBootstrap';
// The real transport has no server in tests and fails as a generic error.
// Tests that exercise other failure modes swap this implementation; the
@@ -86,6 +87,7 @@ const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
describe('useGitStore', () => {
beforeEach(() => {
clearWorktreeBootstrapState('/repo');
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
});
@@ -198,8 +200,10 @@ describe('useGitStore', () => {
setDirectoryStatus(createStatus());
const requests: Deferred<GitStatus>[] = [];
let statusCalls = 0;
const git = createGitApi(() => {
const statusOptions: Array<{ mode?: 'light'; fresh?: boolean } | undefined> = [];
const git = createGitApi((_directory, options) => {
statusCalls += 1;
statusOptions.push(options);
const request = createDeferred<GitStatus>();
requests.push(request);
return request.promise;
@@ -212,6 +216,7 @@ describe('useGitStore', () => {
const all = useGitStore.getState().fetchAll('/repo', git, { force: true });
await Promise.resolve();
expect(statusCalls).toBe(2);
expect(statusOptions).toEqual([undefined, { fresh: true }]);
requests[1].resolve({ ...createStatus(), current: 'feature' });
requests[0].resolve(createStatus());
@@ -220,6 +225,56 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
});
test('does not request status while worktree bootstrap is pending', async () => {
setDirectoryStatus(createStatus());
let statusCalls = 0;
const git = createGitApi(async () => {
statusCalls += 1;
return createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]);
});
markWorktreeBootstrapPending('/repo');
const changed = await useGitStore.getState().fetchStatus('/repo', git, { force: true });
expect(changed).toBe(false);
expect(statusCalls).toBe(0);
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
});
test('does not publish a status response after bootstrap becomes pending', async () => {
setDirectoryStatus(createStatus());
const request = createDeferred<GitStatus>();
const git = createGitApi(() => request.promise);
const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
await Promise.resolve();
markWorktreeBootstrapPending('/repo');
request.resolve(createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]));
expect(await loading).toBe(false);
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
});
test('can propagate a forced status failure to a reconciliation owner', async () => {
setDirectoryStatus(createStatus());
const git = createGitApi(async () => {
throw new Error('offline');
});
const originalConsoleError = console.error;
console.error = () => undefined;
try {
await expect(useGitStore.getState().fetchStatus('/repo', git, {
force: true,
silent: true,
throwOnError: true,
})).rejects.toThrow('offline');
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
} finally {
console.error = originalConsoleError;
}
});
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);
+20 -3
View File
@@ -11,6 +11,7 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp';
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
import { getWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap';
const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
@@ -28,6 +29,7 @@ const DIFF_CACHE_MAX_ENTRIES = 30;
const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200;
type GitStatusFetchMode = 'full' | 'light';
type GitStatusRequestOptions = { mode?: 'light'; fresh?: boolean };
// Discovery outcome for a root that is not itself a git repository. The three
// states are mutually exclusive: a repository list (possibly empty), a failed
@@ -64,7 +66,7 @@ interface GitStore {
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise<boolean>;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean; throwOnError?: 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>;
@@ -115,7 +117,7 @@ interface GitFileDiffResponse {
interface GitAPI {
checkIsGitRepository: (directory: string) => Promise<boolean>;
getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise<GitStatus>;
getGitStatus: (directory: string, options?: GitStatusRequestOptions) => Promise<GitStatus>;
getGitBranches: (directory: string) => Promise<GitBranch>;
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
@@ -692,6 +694,9 @@ export const useGitStore = create<GitStore>()(
},
fetchStatus: async (directory, git, options = {}) => {
if (getWorktreeBootstrapState(directory)?.status === 'pending') {
return false;
}
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
const runtimeKey = getRuntimeKey();
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
@@ -757,8 +762,17 @@ export const useGitStore = create<GitStore>()(
return false;
}
const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined);
let statusOptions: GitStatusRequestOptions | undefined;
if (options.mode || options.force) {
statusOptions = {};
if (options.mode) statusOptions.mode = options.mode;
if (options.force) statusOptions.fresh = true;
}
const newStatus = await git.getGitStatus(directory, statusOptions);
if (!isRequestCurrent(token, directory)) return false;
// A request admitted before worktree creation must not publish a
// transient --no-checkout/reset snapshot after bootstrap begins.
if (getWorktreeBootstrapState(directory)?.status === 'pending') return false;
const latestState = get().directories.get(directory) ?? createEmptyDirectoryState();
if (hasStatusChanged(latestState.status, newStatus)) {
@@ -830,6 +844,9 @@ export const useGitStore = create<GitStore>()(
}
} catch (error) {
console.error('Failed to fetch git status:', error);
if (options.throwOnError) {
throw error;
}
} finally {
if (!silent && isRequestCurrent(token, directory)) {
const newDirectories = new Map(get().directories);
+9
View File
@@ -290,6 +290,7 @@ Rules:
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`.
10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
11. Starting a session from an assistant answer carries the source session ID, rendered directory, and answer text into the action. It must not rediscover that context from the globally active child store or the OpenCode client's fallback directory: the visible session may belong to an existing worktree while the active provider directory points elsewhere. New isolated worktrees resolve their registered parent project from that captured directory, preferring recorded worktree metadata when available. The dialog offers creation only after the project root is confirmed as a Git repository, and the creation boundary repeats that check so stale or bypassed UI state cannot run Git commands against a non-repository directory; failures leave the dialog open and visible.
Examples of global-store updates performed in `session-actions.ts`:
@@ -371,6 +372,14 @@ The global sessions store persists and hydrates one bounded, runtime-scoped star
VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively.
### Remembering the last draft target
`session-ui-store.ts` persists the side of the composer's target selector the user last worked on under `oc.chatInput.lastDraftTarget`, so a plain new session reopens there instead of always landing on Chat. The record holds a project id, a directory, and `target`, which is `"chat"`, `"project"`, or `null`.
`null` is what a record written before `target` existed reads as, and it leaves the Chat default in place rather than guessing a side from the directory. A recorded project that no longer exists falls back to Chat the same way. Only a picker choice writes `"chat"` or `"project"`.
A session's own directory is not a target choice. "New session in the current directory" forwards the current session's directory even when that session is a managed chat, and a chat scratch directory names no project, so those overrides resolve to a chat draft. Treating one as an explicit project target is how a plus pressed inside a chat opened a project draft.
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
```typescript
@@ -6,6 +6,8 @@ const createSessionCalls: Array<{ title?: string; directory: string | null; pare
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
const savedVariantCalls: Array<string | undefined> = []
let configVariantOverride: string | null | undefined
let projects: Array<{ id: string; path: string; label: string }> = []
const createdWorktreeProjects: Array<{ id: string; path: string }> = []
// Sync's session→directory index. `createSession` writes it, and directory
// resolution reads it as the authoritative source, so the mock has to keep one.
const sessionDirectoryRegistry = new Map<string, string>()
@@ -111,7 +113,7 @@ mock.module("@/stores/useConfigStore", () => ({
mock.module("@/stores/useProjectsStore", () => ({
useProjectsStore: {
getState: () => ({
projects: [],
projects,
activeProjectId: null,
getActiveProject: () => null,
}),
@@ -127,6 +129,12 @@ mock.module("@/stores/useDirectoryStore", () => ({
},
}))
mock.module("@/stores/useSessionGoalArmStore", () => ({
useSessionGoalArmStore: {
getState: () => ({ setArmed: () => undefined }),
},
}))
mock.module("@/stores/useGlobalSessionsStore", () => ({
useGlobalSessionsStore: {
getState: () => ({
@@ -304,6 +312,33 @@ mock.module("../session-actions", () => ({
abortCurrentOperation: mock(async () => undefined),
}))
mock.module("@/lib/git/branchNameGenerator", () => ({
generateBranchName: () => "generated-branch",
}))
mock.module("@/lib/openchamberConfig", () => ({
getWorktreeSetupCommands: async () => [],
getWorktreeSetupWaitEnabled: async () => false,
}))
mock.module("@/lib/worktrees/worktreeBootstrap", () => ({
waitForWorktreeBootstrap: async () => undefined,
}))
mock.module("@/lib/worktrees/worktreeCreate", () => ({
createWorktreeWithDefaults: async (project: { id: string; path: string }) => {
createdWorktreeProjects.push(project)
return {
source: "sdk",
name: "generated-branch",
path: "/worktrees/generated-branch",
projectDirectory: project.path,
branch: "generated-branch",
label: "generated-branch",
}
},
}))
const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store")
describe("issue 2039 draft auto-accept", () => {
@@ -500,3 +535,94 @@ describe("issue 2039 draft auto-accept", () => {
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree")
})
})
describe("assistant answer worktree routing", () => {
test("reports session creation failure instead of completing silently", async () => {
const state = useSessionUIStore.getState()
const createFromAssistantMessage = state.createSessionFromAssistantMessage
const originalCreateSession = state.createSession
useSessionUIStore.setState({
createSession: async () => null,
})
try {
await expect(createFromAssistantMessage({
sessionId: "source-session",
directory: "/repo",
text: "Implement the plan",
}, {
providerID: "provider",
modelID: "model",
variant: "",
agent: "build",
instructions: "Follow the answer",
})).rejects.toThrow("Failed to create session")
} finally {
useSessionUIStore.setState({ createSession: originalCreateSession })
}
})
test("creates a sibling worktree from the captured source worktree directory", async () => {
projects = [
{ id: "project", path: "/repo", label: "Repo" },
{ id: "source-worktree", path: "/worktrees/source", label: "Source worktree" },
]
createdWorktreeProjects.length = 0
const sourceWorktree = {
path: "/worktrees/source",
projectDirectory: "/repo",
branch: "source",
label: "source",
}
const state = useSessionUIStore.getState()
const createFromAssistantMessage = state.createSessionFromAssistantMessage
const originalCreateSession = state.createSession
const originalSendMessage = state.sendMessage
const originalWorktreeMetadata = state.worktreeMetadata
let createdDirectory: string | null | undefined
useSessionUIStore.setState({
availableWorktreesByProject: new Map([["/repo", [sourceWorktree]]]),
worktreeMetadata: new Map([["source-session", sourceWorktree]]),
createSession: async (_title, directory) => {
createdDirectory = directory
return {
id: "created-session",
slug: "created-session",
projectID: "project",
directory: directory ?? "",
title: "Created session",
version: "1",
time: { created: 1, updated: 1 },
}
},
sendMessage: async () => undefined,
})
try {
await createFromAssistantMessage({
sessionId: "source-session",
directory: "/worktrees/source",
text: "Implement the plan",
}, {
providerID: "provider",
modelID: "model",
variant: "",
agent: "build",
instructions: "Follow the answer",
createWorktree: true,
})
} finally {
useSessionUIStore.setState({
createSession: originalCreateSession,
sendMessage: originalSendMessage,
worktreeMetadata: originalWorktreeMetadata,
})
projects = []
}
expect(createdWorktreeProjects).toEqual([{ id: "project", path: "/repo" }])
expect(createdDirectory).toBe("/worktrees/generated-branch")
})
})
@@ -67,6 +67,7 @@ mock.module("@/components/ui", () => ({
import { INITIAL_STATE, type State } from "../types"
import { ChildStoreManager, type DirectoryStore } from "../child-store"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { sessionEvents } from "@/lib/sessionEvents"
const {
createEventRoutingIndex,
handleEvent,
@@ -340,4 +341,40 @@ describe("resyncBlockingRequestsForDirectory", () => {
unsubscribe()
childStores.disposeAll()
})
test("refreshes Git once when a live mutating tool completes between renders", () => {
const childStores = new ChildStoreManager()
childStores.ensureChild("/repo", { bootstrap: false })
const routingIndex = createEventRoutingIndex()
const refreshes: Array<{ directory: string; paths?: string[] }> = []
const unsubscribe = sessionEvents.onGitRefreshHint((hint) => refreshes.push(hint))
// SAFETY: this fixture supplies the SDK event discriminator and the tool
// part identity, tool name, and state fields consumed by the reducer.
const toolEvent = (tool: string, status: "pending" | "completed" | "error") => ({
type: "message.part.updated",
properties: {
part: {
id: "prt_tool",
messageID: "msg_assistant",
sessionID: "ses_a",
type: "tool",
tool,
state: { status, input: {}, metadata: {} },
},
},
}) as Event
try {
handleEvent("/repo", toolEvent("apply_patch", "pending"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("read", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("edit", "error"), childStores, routingIndex, getRuntimeKey())
expect(refreshes).toEqual([{ directory: "/repo" }])
} finally {
unsubscribe()
childStores.disposeAll()
}
})
})
@@ -45,7 +45,15 @@ mock.module("@/lib/runtime-switch", () => ({
return () => undefined
},
}))
import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router"
import {
applySessionEventsToGlobalSessions,
applySessionEventToGlobalSessions,
} from "../session-event-router"
import {
registerBulkArchiveEchoes,
releaseBulkArchiveEchoes,
shouldConsumeBulkArchiveEcho,
} from "../bulk-archive-echo"
const buildSession = (title: string, time: Session["time"]): Session => ({
id: "ses_1",
@@ -146,4 +154,35 @@ describe("applySessionEventToGlobalSessions", () => {
expect(mutationCalls).toBe(1)
expect(upsertedSessions).toHaveLength(1_000)
})
test("consumes only the matching bulk archive echo", () => {
registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100)
expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", {
created: 1,
updated: 20,
archived: 20,
})), runtimeKey, 101)).toBe(true)
expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", {
created: 1,
updated: 21,
archived: 21,
})), runtimeKey, 101)).toBe(false)
expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", {
created: 1,
updated: 20,
archived: 20,
})), "runtime-b", 101)).toBe(false)
})
test("does not consume an expired or released bulk archive echo", () => {
registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100)
const event = buildEvent(buildSession("Initial", { created: 1, updated: 20, archived: 20 }))
expect(shouldConsumeBulkArchiveEcho(event, runtimeKey, 30_101)).toBe(false)
registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100)
releaseBulkArchiveEchoes(runtimeKey, ["ses_1"])
expect(shouldConsumeBulkArchiveEcho(event, runtimeKey, 101)).toBe(false)
})
})
+49
View File
@@ -0,0 +1,49 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
const BULK_ARCHIVE_ECHO_TTL_MS = 30_000
const pendingEchoes = new Map<string, Map<string, { archivedAt: number; expiresAt: number }>>()
subscribeRuntimeEndpointWillChange(() => pendingEchoes.clear())
export const registerBulkArchiveEchoes = (
runtimeKey: string,
sessions: Iterable<{ id: string; archivedAt: number }>,
now = Date.now(),
): void => {
let runtimeEchoes = pendingEchoes.get(runtimeKey)
if (!runtimeEchoes) {
runtimeEchoes = new Map()
pendingEchoes.set(runtimeKey, runtimeEchoes)
}
for (const session of sessions) {
runtimeEchoes.set(session.id, {
archivedAt: session.archivedAt,
expiresAt: now + BULK_ARCHIVE_ECHO_TTL_MS,
})
}
}
export const releaseBulkArchiveEchoes = (runtimeKey: string, sessionIds: Iterable<string>): void => {
const runtimeEchoes = pendingEchoes.get(runtimeKey)
if (!runtimeEchoes) return
for (const sessionId of sessionIds) runtimeEchoes.delete(sessionId)
if (runtimeEchoes.size === 0) pendingEchoes.delete(runtimeKey)
}
export const shouldConsumeBulkArchiveEcho = (
event: Event,
runtimeKey: string,
now = Date.now(),
): boolean => {
if (event.type !== "session.updated") return false
const runtimeEchoes = pendingEchoes.get(runtimeKey)
const expected = runtimeEchoes?.get(event.properties.info.id)
if (!expected) return false
if (expected.expiresAt < now) {
runtimeEchoes?.delete(event.properties.info.id)
if (runtimeEchoes?.size === 0) pendingEchoes.delete(runtimeKey)
return false
}
return event.properties.info.time.archived === expected.archivedAt
}
@@ -92,6 +92,16 @@ describe("persisted directory sessions", () => {
expect(readManagedChatSessions()).toEqual([])
})
test("coalesces a continuing burst into one trailing session write", async () => {
persistSessions(directory, [session(1, 1)])
await new Promise((resolve) => setTimeout(resolve, 30))
persistSessions(directory, [session(1, 2)])
await waitForPersistence()
expect(storage.writes).toBe(1)
expect(readDirCache(directory).sessions?.[0]?.time.updated).toBe(2)
})
test("keeps the 50 most recently updated sessions across restart reads", async () => {
const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated))
+1 -1
View File
@@ -177,7 +177,7 @@ function scheduleSessionCacheWrite(directory: string, sessions: Session[]): void
if (pending.runtimeKey !== runtimeKey) pendingSessionWrites.delete(pendingKey)
}
pendingSessionWrites.set(key, { runtimeKey, key, legacyKey: legacyCacheKey(directory, "sessions"), sessions })
if (pendingSessionWriteTimer !== undefined) return
if (pendingSessionWriteTimer !== undefined) clearTimeout(pendingSessionWriteTimer)
pendingSessionWriteTimer = setTimeout(flushPendingSessionWrites, SESSION_PERSIST_DEBOUNCE_MS)
}
+13 -7
View File
@@ -29,16 +29,21 @@ export type SelectionState = {
getSessionAgentSelection: (sessionId: string) => string | null
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined
/**
* `variant` is the effort chosen for this agent/model in this session:
* a name, `null` for an explicit "Default" (send no effort), or `undefined`
* to forget the choice so the inherited default applies again.
*/
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => void
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | null | undefined
}
const isPersistedSelectionState = (state: unknown): state is PersistedSelectionState => (
typeof state === "object" && state !== null
)
// In-memory variant storage (not persisted)
const agentModelVariantSelections = new Map<string, Map<string, Map<string, string>>>()
// In-memory variant storage (not persisted). `null` is an explicit "Default".
const agentModelVariantSelections = new Map<string, Map<string, Map<string, string | null>>>()
// Maximum number of sessions to persist to local storage to prevent unbounded growth
const MAX_PERSISTED_SESSIONS = 150
@@ -91,20 +96,21 @@ export const useSelectionStore = create<SelectionState>()(
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
const key = `${providerId}/${modelId}`
const clears = variant === undefined
let agentMap = agentModelVariantSelections.get(sessionId)
if (!agentMap && variant) {
if (!agentMap && !clears) {
agentMap = new Map()
agentModelVariantSelections.set(sessionId, agentMap)
}
if (!agentMap) return
let modelMap = agentMap.get(agentName)
if (!modelMap && variant) {
if (!modelMap && !clears) {
modelMap = new Map()
agentMap.set(agentName, modelMap)
}
if (!modelMap) return
if (!variant) {
if (clears) {
modelMap.delete(key)
if (modelMap.size === 0) {
agentMap.delete(agentName)
+185 -1
View File
@@ -21,7 +21,16 @@ let sessionDeleteError: unknown | null = null
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
const globalUpsertedSessions: unknown[] = []
const globalUpsertedSessionBatches: Session[][] = []
const globalRemovedSessionIds: string[] = []
// Sessions this client is holding. `archiveSessions` reads them to decide which
// sessions can be archived by the server in one batch.
let globalActiveSessions: Session[] = []
const archiveBatchRequests: Array<{ directory: string; ids: string[] }> = []
let archiveBatchResponse: { status: number; body: unknown } = {
status: 404,
body: { error: 'not found' },
}
const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = []
const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = []
@@ -243,11 +252,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
},
useGlobalSessionsStore: {
getState: () => ({
activeSessions: [],
activeSessions: globalActiveSessions,
archivedSessions: [],
upsertSession: (session: unknown) => {
globalUpsertedSessions.push(session)
},
upsertSessions: (sessions: Session[]) => {
globalUpsertedSessionBatches.push(sessions)
globalUpsertedSessions.push(...sessions)
},
removeSessions: (ids: Iterable<string>) => {
globalRemovedSessionIds.push(...ids)
},
@@ -255,6 +268,18 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
},
}))
mock.module("@/lib/runtime-fetch", () => ({
runtimeFetch: async (path: string, init?: { body?: string }) => {
const payload = JSON.parse(String(init?.body ?? "{}"))
archiveBatchRequests.push({ directory: payload.directory, ids: payload.ids })
void path
return new Response(JSON.stringify(archiveBatchResponse.body), {
status: archiveBatchResponse.status,
headers: { "content-type": "application/json" },
})
},
}))
mock.module("./session-deletion-cleanup", () => ({
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => {
deletedCleanupIdentities.push(identity)
@@ -396,6 +421,10 @@ describe("confirmed session removal", () => {
sessionUpdateResult = {}
beforeSessionUpdateResolve = null
beforeSessionDeleteResolve = null
globalUpsertedSessionBatches.length = 0
globalActiveSessions = []
archiveBatchRequests.length = 0
archiveBatchResponse = { status: 404, body: { error: 'not found' } }
})
test("does not remove live or persisted state when delete fails", async () => {
@@ -636,6 +665,161 @@ describe("confirmed session removal", () => {
})
})
describe("archiving a batch through the server", () => {
const liveSession = (id: string, metadata?: Record<string, unknown>): Session => ({
id,
directory: "/test/project",
time: { created: 1 },
...(metadata ? { metadata } : {}),
} as unknown as Session)
const archivedSession = (id: string): Session => ({
id,
directory: "/test/project",
time: { created: 1, archived: 2 },
} as unknown as Session)
beforeEach(() => {
replyCalls.length = 0
globalUpsertedSessions.length = 0
globalUpsertedSessionBatches.length = 0
globalActiveSessions = []
archiveBatchRequests.length = 0
archiveBatchResponse = { status: 404, body: { error: "not found" } }
sessionUpdateResult = {}
beforeSessionUpdateResolve = null
})
test("archives held sessions in one request and reconciles the stores once", async () => {
globalActiveSessions = [liveSession("session-a"), liveSession("session-b")]
archiveBatchResponse = {
status: 200,
body: { archived: [archivedSession("session-a"), archivedSession("session-b")], failedIds: [] },
}
const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] })
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
const result = await archiveSessions(["session-a", "session-b"])
expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] })
expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a", "session-b"] }])
// The point of the batch: no per-session SDK call, and one store write for
// the whole set instead of one per session.
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([])
expect(globalUpsertedSessionBatches).toHaveLength(1)
expect(source.getState().session).toEqual([])
expect(source.getState().sessionRevision).toBe(1)
})
test("batches sessions held only by the live directory store", async () => {
archiveBatchResponse = {
status: 200,
body: { archived: [archivedSession("session-a")], failedIds: [] },
}
const source = createStore({}, { session: [liveSession("session-a")] })
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
const result = await archiveSessions(["session-a"])
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] })
expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a"] }])
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([])
})
test("reports the sessions the server could not archive without losing the rest", async () => {
globalActiveSessions = [liveSession("session-a"), liveSession("session-b")]
archiveBatchResponse = {
status: 200,
body: { archived: [archivedSession("session-a")], failedIds: ["session-b"] },
}
const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] })
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
const result = await archiveSessions(["session-a", "session-b"])
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: ["session-b"] })
expect(source.getState().session.map((item) => item.id)).toEqual(["session-b"])
})
test("falls back to archiving one by one when the runtime does not serve the route", async () => {
globalActiveSessions = [liveSession("session-a"), liveSession("session-b")]
archiveBatchResponse = { status: 501, body: { error: "not supported in VS Code" } }
sessionUpdateResult = { data: archivedSession("session-a") }
const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] })
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
const result = await archiveSessions(["session-a", "session-b"])
expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] })
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
.toEqual(["session-a", "session-b"])
expect(source.getState().session).toEqual([])
})
test("treats a malformed batch answer as unavailable instead of as an empty success", async () => {
globalActiveSessions = [liveSession("session-a")]
archiveBatchResponse = { status: 200, body: { archived: [{ title: "no id" }], failedIds: [] } }
sessionUpdateResult = { data: archivedSession("session-a") }
const source = createStore({}, { session: [liveSession("session-a")] })
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
const result = await archiveSessions(["session-a"])
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] })
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
.toEqual(["session-a"])
})
test("keeps review and btw sessions on the per-session path", async () => {
const review = liveSession("session-review", { openchamber: { kind: "review", originalSessionID: "session-parent" } })
const parentWithFork = liveSession("session-parent", { openchamber: { btwSessionID: "session-fork" } })
globalActiveSessions = [liveSession("session-plain"), review, parentWithFork]
archiveBatchResponse = {
status: 200,
body: { archived: [archivedSession("session-plain")], failedIds: [] },
}
sessionUpdateResult = { data: archivedSession("session-review") }
const source = createStore({}, { session: [liveSession("session-plain"), review, parentWithFork] })
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
await archiveSessions(["session-plain", "session-review", "session-parent"])
// Unlinking a partner rewrites another session's metadata, so those two
// never travel in the batch.
expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-plain"] }])
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
.toEqual(["session-review", "session-parent"])
})
test("does not reconcile a batch answered after a runtime switch", async () => {
globalActiveSessions = [liveSession("session-a")]
archiveBatchResponse = {
status: 200,
body: { archived: [archivedSession("session-a")], failedIds: [] },
}
const source = createStore({}, { session: [liveSession("session-a")] })
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-a.test", runtimeKey: "archive-bulk-a" })
const capturedRuntimeKey = getRuntimeKey()
const { archiveSessions, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
const pending = archiveSessions(["session-a"], { expectedRuntimeKey: capturedRuntimeKey })
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-b.test", runtimeKey: "archive-bulk-b" })
const result = await pending
expect(result).toEqual({ archivedIds: [], failedIds: ["session-a"] })
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
expect(globalUpsertedSessionBatches).toEqual([])
})
})
describe("session restore (unarchive)", () => {
beforeEach(() => {
replyCalls.length = 0
+202 -11
View File
@@ -33,6 +33,8 @@ import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessi
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { requestSessionArchiveBatch } from "./session-archive-batch"
import { registerBulkArchiveEchoes, releaseBulkArchiveEchoes } from "./bulk-archive-echo"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error"
import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification"
@@ -75,20 +77,29 @@ let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null
function sessionMutationPatch(
/**
* Revision patch for one or more sessions changing in the same store write.
*
* A batch bumps the revision once, because it is one state change: consumers
* compare revisions to decide whether their view of the list is stale, and a
* batch leaves them stale exactly once rather than once per session.
*/
function sessionsMutationPatch(
state: ReturnType<DirectoryStoreApi["getState"]>,
sessionId: string,
sessionIds: Iterable<string>,
deleted: boolean,
) {
const revision = (state.sessionRevision ?? 0) + 1
const sessionEventRevision = { ...(state.sessionEventRevision ?? {}) }
const sessionDeletedRevision = { ...(state.sessionDeletedRevision ?? {}) }
if (deleted) {
sessionDeletedRevision[sessionId] = revision
delete sessionEventRevision[sessionId]
} else {
sessionEventRevision[sessionId] = revision
delete sessionDeletedRevision[sessionId]
for (const sessionId of sessionIds) {
if (deleted) {
sessionDeletedRevision[sessionId] = revision
delete sessionEventRevision[sessionId]
} else {
sessionEventRevision[sessionId] = revision
delete sessionDeletedRevision[sessionId]
}
}
return {
sessionListSource: "live" as const,
@@ -98,6 +109,14 @@ function sessionMutationPatch(
}
}
function sessionMutationPatch(
state: ReturnType<DirectoryStoreApi["getState"]>,
sessionId: string,
deleted: boolean,
) {
return sessionsMutationPatch(state, [sessionId], deleted)
}
function invalidateSessionLoads(sessionId: string, directories: Iterable<string | null | undefined>): void {
const loader = getImperativeSessionMessageLoader()
if (!loader) return
@@ -1039,6 +1058,50 @@ function removeSessionFromLiveStores(sessionId: string, preferredDirectory?: str
return snapshots
}
/**
* Remove a batch of server-confirmed sessions from every live child store.
*
* Each affected store is written once for the whole batch. Removing the
* sessions one at a time notified every subscriber and therefore re-rendered
* the sidebar once per session, which is what made archiving a worktree's
* sessions block the main thread for seconds.
*/
function removeSessionsFromLiveStores(sessionIds: Iterable<string>, preferredDirectory?: string): SessionListSnapshot[] {
const ids = new Set(sessionIds)
if (!_childStores || ids.size === 0) return []
const snapshots: SessionListSnapshot[] = []
const visited = new Set<string>()
const candidates: Array<[string, DirectoryStoreApi]> = []
if (preferredDirectory) {
const preferredStore = _childStores.children.get(preferredDirectory)
if (preferredStore) {
candidates.push([preferredDirectory, preferredStore])
visited.add(preferredDirectory)
}
}
for (const entry of _childStores.children.entries()) {
if (visited.has(entry[0])) continue
candidates.push(entry)
}
for (const [directory, store] of candidates) {
const current = store.getState()
const removed = current.session.filter((session) => ids.has(session.id)).map((session) => session.id)
if (removed.length === 0) continue
snapshots.push({ directory })
store.setState({
session: current.session.filter((session) => !ids.has(session.id)),
...sessionsMutationPatch(current, removed, true),
})
}
return snapshots
}
function cleanupSessionWorktreeMetadata(sessionId: string): void {
useSessionUIStore.getState().setWorktreeMetadata(sessionId, null)
}
@@ -1252,7 +1315,14 @@ export type ArchiveSessionsOptions = {
}
/**
* Archive several sessions sequentially, preserving partial results.
* Archive several sessions, preserving partial results.
*
* Sessions that carry no review or btw link are archived by their directory's
* server in one request, and the whole answer is reconciled with a single store
* write. The remainder review sessions, btw forks, sessions with an active
* btw fork, and any session this client does not hold keep the per-session
* path, because unlinking a partner is UI-owned work that reads and rewrites
* another session's metadata.
*
* One failed session never blocks or erases the others: it is reported in
* `failedIds` while the remaining IDs are still attempted. When
@@ -1269,10 +1339,55 @@ export async function archiveSessions(
const archivedIds: string[] = []
const failedIds: string[] = []
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
if (ids.length === 0) return { archivedIds, failedIds }
for (const [index, id] of ids.entries()) {
const plan = planArchiveBatches(ids)
for (const [directory, batchIds] of plan.batchesByDirectory) {
if (isStaleRuntime(expectedRuntimeKey)) {
failedIds.push(...ids.slice(index))
failedIds.push(...batchIds)
continue
}
const archivedAt = Date.now()
registerBulkArchiveEchoes(
expectedRuntimeKey,
batchIds.map((id) => ({ id, archivedAt })),
)
const result = await requestSessionArchiveBatch(directory, batchIds, archivedAt)
if (isStaleRuntime(expectedRuntimeKey)) {
failedIds.push(...batchIds)
continue
}
if (result.outcome === "archived") {
releaseBulkArchiveEchoes(expectedRuntimeKey, batchIds)
registerBulkArchiveEchoes(
expectedRuntimeKey,
result.archived.flatMap((session) => (
session.time?.archived === undefined
? []
: [{ id: session.id, archivedAt: session.time.archived }]
)),
)
commitArchivedSessions(result.archived, directory)
archivedIds.push(...result.archived.map((session) => session.id))
failedIds.push(...result.failedIds)
continue
}
// The runtime does not serve the batch route, or its answer could not be
// trusted. Archiving each session individually is slower but reaches the
// same state, and re-archiving a session the server already archived writes
// the same field again.
console.warn("[session-actions] archive batch unavailable, archiving one by one", result.reason)
releaseBulkArchiveEchoes(expectedRuntimeKey, batchIds)
plan.individualIds.push(...batchIds)
}
for (const [index, id] of plan.individualIds.entries()) {
if (isStaleRuntime(expectedRuntimeKey)) {
failedIds.push(...plan.individualIds.slice(index))
break
}
if (await archiveSession(id, expectedRuntimeKey)) archivedIds.push(id)
@@ -1282,6 +1397,82 @@ export async function archiveSessions(
return { archivedIds, failedIds }
}
/**
* A session whose archive also has to rewrite another session's metadata.
*
* Review sessions and btw forks point at a parent that must be unlinked, and a
* parent with an active btw fork has to delete that fork. Those are
* read-modify-write pairs on a second session, so they stay on the per-session
* path instead of the server batch.
*/
function hasLinkedSessionCleanup(session: Session): boolean {
return isReviewSession(session) || isBtwSession(session) || Boolean(getBtwSessionID(session))
}
/**
* Split the requested IDs into per-directory server batches and the sessions
* that must be archived individually.
*
* Link classification reads this client's session records rather than
* refetching each session: those records are kept current by the same
* `session.updated` events that publish a link created anywhere else, so a
* fetch per session would buy no authority the store does not already have.
* A session this client does not hold is classified as individual, which
* restores the per-session fetch for exactly the cases where the store has
* nothing to say.
*/
function planArchiveBatches(ids: string[]) {
const global = useGlobalSessionsStore.getState()
const knownSessions = new Map<string, Session>()
for (const session of [...global.activeSessions, ...global.archivedSessions]) {
knownSessions.set(session.id, session)
}
for (const store of _childStores?.children.values() ?? []) {
for (const session of store.getState().session) knownSessions.set(session.id, session)
}
const batchesByDirectory = new Map<string, string[]>()
const individualIds: string[] = []
for (const id of ids) {
const session = knownSessions.get(id)
const directory = session
? resolveGlobalSessionDirectory(session) ?? getSessionDirectory(id)
: undefined
if (!session || !directory || hasLinkedSessionCleanup(session)) {
individualIds.push(id)
continue
}
const batch = batchesByDirectory.get(directory)
if (batch) batch.push(id)
else batchesByDirectory.set(directory, [id])
}
return { batchesByDirectory, individualIds }
}
/**
* Reconcile a server-confirmed archive batch with one write per store.
*
* This mirrors what `archiveSession` does for a single session drop it from
* the live directory stores, invalidate its cached messages, move it to the
* archived bucket, and clear it if it was open with the per-session store
* notifications collapsed into one.
*/
function commitArchivedSessions(sessions: Session[], directory: string): void {
if (sessions.length === 0) return
const ids = sessions.map((session) => session.id)
const snapshots = removeSessionsFromLiveStores(ids, directory)
const directories = [...snapshots.map((snapshot) => snapshot.directory), directory]
for (const id of ids) invalidateSessionLoads(id, directories)
useGlobalSessionsStore.getState().upsertSessions(sessions)
const ui = useSessionUIStore.getState()
if (ui.currentSessionId && ids.includes(ui.currentSessionId)) ui.setCurrentSession(null)
}
/**
* Sentinel written to `time.archived` when restoring a session.
*
@@ -0,0 +1,77 @@
/**
* Server-side archive batch.
*
* Archiving the sessions linked to a worktree one request at a time is what
* made removing a worktree with many sessions take tens of seconds: every
* session cost its own round trip and its own store reconciliation. This asks
* the OpenChamber server to archive the whole batch next to OpenCode, so the
* browser spends one request and reconciles once.
*
* The route is an OpenChamber capability, not an OpenCode one. Runtimes that do
* not serve it (the VS Code webview has no server process) answer with a stable
* unsupported status, and callers fall back to archiving session by session.
*/
import type { Session } from '@opencode-ai/sdk/v2/client';
import { z } from 'zod';
import { runtimeFetch } from '@/lib/runtime-fetch';
/**
* The route answers with sessions OpenCode itself returned from
* `session.update`. Only the identity this layer routes on is asserted here;
* every other field is carried through to the stores exactly as the server
* sent it, the same as for any other session response.
*/
const archiveResponseSchema = z.object({
archived: z.array(z.looseObject({ id: z.string().min(1) })),
failedIds: z.array(z.string().min(1)),
});
export type SessionArchiveBatchResult =
| { outcome: 'archived'; archived: Session[]; failedIds: string[] }
| { outcome: 'unavailable'; reason: string };
export async function requestSessionArchiveBatch(
directory: string,
ids: string[],
archivedAt: number,
): Promise<SessionArchiveBatchResult> {
let response: Response;
try {
response = await runtimeFetch('/api/openchamber/sessions/archive', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ directory, ids, archivedAt }),
});
} catch (error) {
return { outcome: 'unavailable', reason: error instanceof Error ? error.message : 'archive request failed' };
}
if (!response.ok) {
return { outcome: 'unavailable', reason: `archive request failed with ${response.status}` };
}
let body: unknown;
try {
body = await response.json();
} catch (error) {
return { outcome: 'unavailable', reason: error instanceof Error ? error.message : 'archive response was not JSON' };
}
const parsed = archiveResponseSchema.safeParse(body);
if (!parsed.success) {
// A body this layer cannot read is reported as unavailable rather than as
// an empty success, so a caller never mistakes "the response made no
// sense" for "nothing needed archiving" and drops the sessions.
return { outcome: 'unavailable', reason: `malformed archive response: ${parsed.error.issues[0]?.message ?? 'unknown shape'}` };
}
return {
outcome: 'archived',
// SAFETY: the schema guarantees the non-empty string `id` this layer keys
// on; the remaining fields are the server's own session payload.
archived: parsed.data.archived as Session[],
failedIds: parsed.data.failedIds,
};
}
@@ -8,8 +8,10 @@ import { setActionRefs, setOptimisticRefs } from './session-actions';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSelectionStore } from './selection-store';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
/**
@@ -398,7 +400,10 @@ describe('openNewSessionDraft project binding', () => {
const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' };
const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' };
const DRAFT_TARGET_KEY = 'oc.chatInput.lastDraftTarget';
beforeEach(() => {
getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY);
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
@@ -412,6 +417,10 @@ describe('openNewSessionDraft project binding', () => {
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
});
afterEach(() => {
getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY);
});
test('defaults an implicit draft to Chat when active project differs', () => {
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
@@ -449,6 +458,82 @@ describe('openNewSessionDraft project binding', () => {
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBe(projectB.id);
});
test('reopens an implicit draft on the project the target selector was last set to', () => {
useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id });
useSessionUIStore.getState().closeNewSessionDraft();
// A chat session leaves its managed scratch directory current; the project
// to reopen on can only come from the recorded target.
useDirectoryStore.getState().setDirectory(
'/Users/tester/.config/openchamber/chats/ses_chat',
{ showOverlay: false },
);
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.target).toBe('project');
expect(draft.selectedProjectId).toBe(projectB.id);
expect(draft.directoryOverride).toBe(projectB.path);
});
test('setNewSessionDraftTarget records Chat, so the next implicit draft opens on Chat', () => {
useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id });
useSessionUIStore.getState().setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID });
useSessionUIStore.getState().closeNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft();
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
});
test('keeps the Chat default for a record written before the target was stored', () => {
getDeferredSafeStorage().setItem(
DRAFT_TARGET_KEY,
JSON.stringify({ projectId: projectB.id, directory: projectB.path }),
);
useSessionUIStore.getState().openNewSessionDraft();
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
});
test('a chat scratch directory forwarded as override opens a chat draft', () => {
// "New session in the current directory" callers forward the current
// session's directory even when that session is a chat; its scratch
// directory names no project.
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: '/Users/tester/.config/openchamber/chats/ses_chat',
});
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.target).toBe('chat');
expect(draft.directoryOverride).toBeNull();
});
test('a chat scratch override opens Chat even when the recorded target is a project', () => {
getDeferredSafeStorage().setItem(
DRAFT_TARGET_KEY,
JSON.stringify({ projectId: projectB.id, directory: projectB.path, target: 'project' }),
);
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: '/Users/tester/.config/openchamber/chats/ses_chat',
});
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
});
test('falls back to Chat when the last project target no longer exists', () => {
getDeferredSafeStorage().setItem(
DRAFT_TARGET_KEY,
JSON.stringify({ projectId: 'proj-removed', directory: '/projects/removed', target: 'project' }),
);
useSessionUIStore.getState().openNewSessionDraft();
expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat');
});
});
describe('createSession draft lifecycle', () => {
@@ -962,3 +1047,84 @@ describe('deleteSessions option forwarding', () => {
expect(deleteSessionCalls).toEqual([]);
});
});
describe('sendMessage effort record', () => {
let originalSendMessage;
const SESSION = 'session-effort';
const PROVIDER = 'provider-a';
const MODEL = 'model-a';
const AGENT = 'build';
const readRecord = () => useSelectionStore
.getState()
.getAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL);
beforeEach(() => {
const childStore = {
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
setState: () => {},
};
const childStores = {
children: new Map(),
ensureChild: () => childStore,
getChild: () => childStore,
};
setActionRefs(opencodeClient, childStores, () => '/current/project');
setOptimisticRefs(() => {}, () => {});
useConfigStore.setState({
isConnected: true,
currentProviderId: PROVIDER,
currentModelId: MODEL,
currentAgentName: AGENT,
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
});
useSessionUIStore.setState({
currentSessionId: SESSION,
currentSessionDirectory: '/current/project',
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
});
originalSendMessage = opencodeClient.sendMessage;
opencodeClient.sendMessage = async () => 'msg';
});
afterEach(() => {
opencodeClient.sendMessage = originalSendMessage;
useSelectionStore.getState().saveAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL, undefined);
});
const send = (variant) => useSessionUIStore.getState().sendMessage(
'hello', PROVIDER, MODEL, AGENT, undefined, undefined, undefined, variant, 'normal',
);
test('keeps an explicit Default across the send that follows it', async () => {
// What the picker leaves behind: `null` recorded, and a send that carries
// no effort because "Default" means exactly that.
useSelectionStore.getState().saveAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL, null);
useConfigStore.setState({ currentVariantSelection: { override: null, inherited: 'high' } });
await send(undefined);
expect(readRecord()).toBeNull();
});
test('records the effort a send carries', async () => {
useConfigStore.setState({ currentVariantSelection: { override: 'high', inherited: 'high' } });
await send('high');
expect(readRecord()).toBe('high');
});
test('records no choice when the live selection inherits its effort', async () => {
useConfigStore.setState({
currentVariant: 'high',
currentVariantSelection: { override: undefined, inherited: 'high' },
});
await send('high');
expect(readRecord()).toBe(undefined);
});
});
+129 -69
View File
@@ -14,7 +14,7 @@
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
import { create } from "zustand"
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
import type { Session, Part, TextPart } from "@opencode-ai/sdk/v2/client"
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
import type { WorktreeMetadata } from "@/types/worktree"
import { opencodeClient } from "@/lib/opencode/client"
@@ -31,9 +31,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization"
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories"
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
@@ -252,6 +251,12 @@ type AssistantMessageSessionExecution = {
runAsGoal?: boolean
}
type AssistantMessageSessionSource = {
sessionId: string
directory: string
text: string
}
function notifyMessageSent(sessionId: string): void {
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
.catch(() => { /* ignore */ })
@@ -261,6 +266,8 @@ function notifyMessageSent(sessionId: string): void {
// Types
// ---------------------------------------------------------------------------
type NewSessionDraftTarget = "chat" | "project"
export type NewSessionDraftState = {
draftId: number
open: boolean
@@ -276,7 +283,7 @@ export type NewSessionDraftState = {
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
target: "chat" | "project"
target: NewSessionDraftTarget
preparedChatDirectory?: string | null
}
@@ -383,7 +390,7 @@ export type SessionUIState = {
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
handleSlashUndo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise<void>
createSessionFromAssistantMessage: (source: AssistantMessageSessionSource, execution: AssistantMessageSessionExecution) => Promise<void>
// Data access helpers (read from sync)
getSessionsByDirectory: (directory: string) => Session[]
@@ -418,16 +425,25 @@ const resolveDirectoryKey = (session: Session): string | null => {
const safeStorage = getDeferredSafeStorage()
const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget"
type PersistedDraftTarget = { projectId: string | null; directory: string | null }
// `target` records which side of the composer's target selector the user last
// worked on, so a plain "new session" reopens there instead of always landing
// on Chat. Records written before this field existed carry no kind — they stay
// `null` and leave the Chat default in place rather than guessing one.
type PersistedDraftTarget = {
projectId: string | null
directory: string | null
target: NewSessionDraftTarget | null
}
const readPersistedDraftTarget = (): PersistedDraftTarget | null => {
try {
const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown }
const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown; target?: unknown }
return {
projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null,
directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null),
target: parsed?.target === "chat" || parsed?.target === "project" ? parsed.target : null,
}
} catch {
return null
@@ -723,7 +739,7 @@ const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Pr
}
useSessionUIStore.setState({ newSessionDraft: nextDraft })
writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: nextDraft })
persistDraftTarget({ projectId: nextDraft.selectedProjectId ?? null, directory: recovered })
persistDraftTarget({ projectId: nextDraft.selectedProjectId ?? null, directory: recovered, target: nextDraft.target })
void activateConfigForDirectory(recovered)
}
@@ -769,6 +785,29 @@ const createSessionWithDraftLifecycle = async (
}
}
/**
* The effort a send should record for its session.
*
* A send carries `undefined` both when no effort was ever chosen and when the
* user explicitly picked "Default", so the sent value alone cannot tell the two
* apart, and recording it raw clears a real "Default". The live selection can
* tell them apart, because its `override` keeps `null` for "Default" but only
* while it still describes the agent and model being sent to. Otherwise the
* send's own value is all there is to go on.
*/
const resolveVariantToRecord = (
agentName: string | undefined,
providerID: string,
modelID: string,
sentVariant: string | undefined,
): string | null | undefined => {
const config = useConfigStore.getState()
const describesThisSend = config.currentProviderId === providerID
&& config.currentModelId === modelID
&& config.currentAgentName === agentName
return describesThisSend ? config.currentVariantSelection.override : sentVariant
}
export async function materializeOpenDraftSession(selection: {
providerID: string
modelID: string
@@ -831,6 +870,7 @@ export async function materializeOpenDraftSession(selection: {
persistDraftTarget({
projectId: draftProjectId,
directory: createdDirectory,
target: draft.target,
})
const draftSyntheticParts = draft.syntheticParts
@@ -840,11 +880,12 @@ export async function materializeOpenDraftSession(selection: {
})
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
const variantOverride = configState.currentProviderId === selection.providerID
&& configState.currentModelId === selection.modelID
&& configState.currentAgentName === effectiveDraftAgent
? configState.currentVariantSelection.override ?? undefined
: selection.variant
const variantOverride = resolveVariantToRecord(
effectiveDraftAgent,
selection.providerID,
selection.modelID,
selection.variant,
)
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
@@ -1103,17 +1144,43 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null)
const persistedTarget = readPersistedDraftTarget()
const explicitDirectory = options?.directoryOverride !== undefined
// Callers that forward "the current session's directory" forward it for
// chat sessions too, and a chat session's scratch directory names no
// project. Treating it as an explicit project target would force a project
// draft rooted in scratch; it is a request for another chat.
const rawExplicitDirectory = options?.directoryOverride !== undefined
? normalizePath(options.directoryOverride)
: null
const explicitDirectoryIsChat = rawExplicitDirectory !== null && isChatDirectoryPath(rawExplicitDirectory)
const explicitDirectory = explicitDirectoryIsChat ? null : rawExplicitDirectory
const persistedProjectById = persistedTarget?.projectId
? projects.find((p) => p.id === persistedTarget.projectId) ?? null
: null
const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null)
const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory)
const persistedProject = persistedProjectById ?? persistedProjectByDir
// Nothing explicit was asked for: reopen on the side the user last worked
// on. Only a recorded project target that still resolves to an existing
// project beats Chat — a project removed since must not open a draft
// pointing at a directory that is no longer registered.
const restoresProjectTarget = !isVSCodeRuntime()
&& !options?.target
&& options?.directoryOverride === undefined
&& options?.selectedProjectId === undefined
&& persistedTarget?.target === "project"
&& persistedProject !== null
let target = isVSCodeRuntime() ? "project" : options?.target
if (!target) {
const hasExplicitProjectTarget = options?.directoryOverride !== undefined
const hasExplicitProjectTarget = (options?.directoryOverride !== undefined && !explicitDirectoryIsChat)
|| (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID)
|| isVSCodeRuntime()
target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget
target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID
? "chat"
: "project"
: hasExplicitProjectTarget || restoresProjectTarget
? "project"
: "chat"
}
const explicitProject = target === "project" && options?.selectedProjectId
? projects.find((p) => p.id === options.selectedProjectId) ?? null
@@ -1126,24 +1193,23 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return projects[0] ?? null
})()
const persistedProjectById = persistedTarget?.projectId
? projects.find((p) => p.id === persistedTarget.projectId) ?? null
: null
const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null)
const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory)
const selectedProject = target === "chat" ? null : (() => {
if (explicitProject) return explicitProject
if (explicitDirectory !== null) return inferredProjectFromDir
if (currentDirectory) return currentDirProject
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
// A chat session leaves a managed scratch directory behind as the current
// one; it owns no project, so it must not decide this draft's project —
// the recorded target below knows which project the user last chose.
if (currentDirectory && !isChatDirectoryPath(currentDirectory)) return currentDirProject
return persistedProject ?? fallbackProject
})()
const directory = target === "chat" ? null : (() => {
if (explicitDirectory !== null) return explicitDirectory
if (explicitProject) return normalizePath(explicitProject.path ?? null)
if (currentDirectory) return currentDirectory
if (persistedTarget?.directory) return persistedTarget.directory
// A chat session's directory is a managed scratch folder, never a
// project: letting it through would open a project draft rooted in it.
if (currentDirectory && !isChatDirectoryPath(currentDirectory)) return currentDirectory
if (persistedTarget?.directory && !isChatDirectoryPath(persistedTarget.directory)) return persistedTarget.directory
return normalizePath(selectedProject?.path ?? null)
})()
@@ -1151,7 +1217,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
warmChatsRootDirectory()
}
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory, target })
const nextDraft: NewSessionDraftState = {
draftId: nextDraftId++,
@@ -1305,6 +1371,15 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
}
})
// Picking a side of the target selector is the choice the next plain "new
// session" reopens on, so it is recorded here too — not only when a draft
// is opened or a session is created from one.
const chosenDraft = get().newSessionDraft
persistDraftTarget({
projectId: chosenDraft.target === "chat" ? null : chosenDraft.selectedProjectId ?? null,
directory: chosenDraft.directoryOverride ?? null,
target: chosenDraft.target,
})
void activateConfigForDirectory(nextDirectory)
if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) {
@@ -1667,7 +1742,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (targetSessionId && effectiveAgent) {
useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent)
useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant)
useSelectionStore.getState().saveAgentModelVariantForSession(
targetSessionId,
effectiveAgent,
providerID,
modelID,
resolveVariantToRecord(effectiveAgent, providerID, modelID, variant),
)
}
if (targetSessionId) {
@@ -1901,47 +1982,26 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
// ---------------------------------------------------------------------------
// createSessionFromAssistantMessage — reads from sync
// createSessionFromAssistantMessage — uses the rendered source context
// ---------------------------------------------------------------------------
createSessionFromAssistantMessage: async (sourceMessageId, execution) => {
if (!sourceMessageId) return
createSessionFromAssistantMessage: async (source, execution) => {
if (!source.sessionId) return
if (!execution?.instructions?.trim()) return
// Find which session this message belongs to by scanning sync state
const state = getDirectoryState()
if (!state) return
let sourceSessionId: string | undefined
let sourceMessage: Message | undefined
for (const [sid, msgs] of Object.entries(state.message ?? {})) {
const found = msgs.find((m) => m.id === sourceMessageId)
if (found) {
sourceSessionId = sid
sourceMessage = found
break
}
}
if (!sourceMessage || sourceMessage.role !== "assistant") return
const sourceParts = getSyncParts(sourceMessageId)
const assistantPlanText = flattenAssistantTextParts(sourceParts)
const assistantPlanText = source.text
if (!assistantPlanText.trim()) return
const directory = resolveSessionDirectory(
sourceSessionId ?? null,
(sid) => get().worktreeMetadata.get(sid),
)
const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined
const sourceDirectory = normalizePath(source.directory)
if (!sourceDirectory) {
throw new Error("Source session directory is unavailable")
}
const sourceWorktreeMetadata = get().worktreeMetadata.get(source.sessionId)
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
const providerID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const modelID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
if (!pID || !mID) return
if (!providerID || !modelID) return
const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null)
let sessionDirectory = sourceDirectory
let sessionDirectory: string | null = sourceDirectory
let createdWorktree: WorktreeMetadata | null = null
let createdWorktreeProject: { id: string; path: string } | null = null
@@ -1950,11 +2010,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const project = resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceDirectory,
sourceWorktreeMetadata?.projectDirectory ?? null,
) ?? resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceWorktreeMetadata?.projectDirectory ?? null,
sourceDirectory,
)
if (!project?.path) {
throw new Error("Project is not registered in OpenChamber")
@@ -1985,13 +2045,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
}
}
const session = await get().createSession(undefined, sessionDirectory || null, null)
const session = await get().createSession(undefined, sessionDirectory, null)
if (!session) {
if (createdWorktree && createdWorktreeProject) {
const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager")
await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined)
}
return
throw new Error("Failed to create session")
}
if (createdWorktree) {
@@ -2011,8 +2071,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
await get().sendMessage(
composeForkSessionMessage(execution.instructions, assistantPlanText),
pID,
mID,
providerID,
modelID,
execution.agent || undefined,
undefined,
undefined,
+16 -1
View File
@@ -38,7 +38,11 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { useSessionUIStore } from "./session-ui-store"
import { stripSessionDiffSnapshots } from "./sanitize"
import { upsertSessionRecord } from "./session-records"
import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router"
import {
applySessionEventToGlobalSessions,
applySessionEventsToGlobalSessions,
} from "./session-event-router"
import { shouldConsumeBulkArchiveEcho } from "./bulk-archive-echo"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
import { messagesBefore } from "./message-ordering"
@@ -79,6 +83,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { formatMessage, useI18nStore } from "@/lib/i18n"
import { sessionEvents } from "@/lib/sessionEvents"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -1592,6 +1597,8 @@ export function handleEvent(
return
}
if (shouldConsumeBulkArchiveEcho(payload, expectedRuntimeKey)) return
const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores, batch)
if (payload.type === "session.deleted" && expectedRuntimeKey === getRuntimeKey()) {
@@ -1824,6 +1831,10 @@ export function handleEvent(
// type will mutate. This preserves reference identity for untouched slices
// so Zustand selectors skip re-renders for unrelated subscribers.
const current = getDirectoryEventState(store, batch)
const updatedPart = payload.type === "message.part.updated" ? payload.properties.part : undefined
const previousPart = updatedPart && "messageID" in updatedPart
? current.part[updatedPart.messageID]?.find((part) => part.id === updatedPart.id)
: undefined
const draft: State = { ...current }
const clonedFields = batch?.clonedFields.get(store) ?? new Set<keyof State>()
const newlyClonedFields: Array<keyof State> = []
@@ -1900,6 +1911,10 @@ export function handleEvent(
const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed
const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization
if (reducerChanged && updatedPart) {
sessionEvents.requestGitRefreshForToolTransition(resolvedDirectory, previousPart, updatedPart)
}
if (reducerChanged) {
countSyncPerformance("reducerChangedEvents")
const eventSessionID = getSessionIdFromPayload(payload) ?? undefined
+1 -1
View File
@@ -69,7 +69,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
return sendBridgeMessage<boolean>('api:git/check', { directory });
},
getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise<GitStatus> => {
getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> => {
return sendBridgeMessage<GitStatus>('api:git/status', { directory, mode: options?.mode });
},
+8
View File
@@ -384,6 +384,14 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
return unsupportedWebRouteResponse('Remote tunnel settings');
}
// Archiving a batch of sessions server-side needs an OpenChamber server
// process; the extension host has none. Answering explicitly keeps the
// shared UI on its per-session archive path instead of leaving the request
// to the generic proxy.
if (normalizedPathname === '/api/openchamber/sessions/archive') {
return unsupportedWebRouteResponse('Server-side session archiving');
}
if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) {
return unsupportedWebRouteResponse('Scheduled tasks');
}
@@ -253,6 +253,40 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory
return asNonEmptyString(latest?.id);
};
/**
* Upper bound on one archive batch.
*
* The batch is applied one session at a time against OpenCode, so an unbounded
* list would hold a request open for as long as the list is large. Callers with
* more sessions than this send several batches and keep their own partial
* results.
*/
const MAX_ARCHIVE_BATCH = 500;
const parseArchiveRequest = (payload) => {
const rawIds = payload?.ids;
if (!Array.isArray(rawIds) || rawIds.length === 0) {
return { ok: false, error: 'ids must be a non-empty array of session ids' };
}
if (rawIds.length > MAX_ARCHIVE_BATCH) {
return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` };
}
const ids = [];
for (const value of rawIds) {
const id = asNonEmptyString(value);
if (!id) return { ok: false, error: 'ids must contain non-empty session ids' };
ids.push(id);
}
const archivedAt = payload?.archivedAt;
if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) {
return { ok: false, error: 'archivedAt must be a positive integer timestamp' };
}
return { ok: true, ids, archivedAt: archivedAt ?? Date.now() };
};
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
if (projectID) {
@@ -579,6 +613,64 @@ export const createOpenChamberSessionService = (dependencies) => {
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
};
/**
* Archive a batch of sessions in one request.
*
* The UI archives every session linked to a worktree before removing it.
* Doing that from the browser costs one request per session plus a store
* reconciliation between each of them, which is what made deleting a
* worktree with many sessions take tens of seconds. Here the batch stays on
* the server, next to OpenCode, and the client reconciles once.
*
* Sessions are updated one at a time on purpose: they are archived against a
* single OpenCode instance, and a fan-out of concurrent writes would trade a
* UI stall for server event-loop starvation. One failed session never stops
* the batch it is reported in `failedIds` while the rest still archive, so
* callers keep the partial-failure behaviour they already show.
*/
const archive = async (payload = {}) => {
const parsed = parseArchiveRequest(payload);
if (!parsed.ok) {
throw new OpenChamberControlError(parsed.error, 400);
}
const resolvedDirectory = await resolveRequestedDirectory({
payload,
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
});
if (!resolvedDirectory.ok) {
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
const directory = resolvedDirectory.directory;
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() });
const archived = [];
const failedIds = [];
for (const sessionID of parsed.ids) {
try {
const response = await client.session.update({
sessionID,
directory,
time: { archived: parsed.archivedAt },
});
const session = response?.data;
if (session?.id) archived.push(session);
else failedIds.push(sessionID);
} catch (error) {
console.warn('[OpenChamberSessions] failed to archive session', sessionID, error);
failedIds.push(sessionID);
}
}
return { directory, archived, failedIds };
};
const create = async (payload = {}) => {
const title = asNonEmptyString(payload.title);
const prompt = asNonEmptyString(payload.prompt);
@@ -813,6 +905,7 @@ export const createOpenChamberSessionService = (dependencies) => {
return {
create,
archive,
send: (sessionID, payload) => runExisting('send', sessionID, payload),
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
};
@@ -843,6 +936,15 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => {
}
});
app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => {
try {
return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {}));
} catch (error) {
console.error('[OpenChamberSessions] failed to archive sessions:', error);
return sendServiceError(res, error, 'Failed to archive sessions');
}
});
app.post(
'/api/openchamber/sessions/:sessionId/send',
express.json({ limit: '1mb' }),
@@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
let existingSessionMessages = [];
let dispatchedUserMessageSeq = 0;
@@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
fork: sessionForkMock,
messages: sessionMessagesMock,
command: sessionCommandMock,
update: sessionUpdateMock,
},
command: {
list: commandListMock,
@@ -132,6 +134,92 @@ describe('openchamber session routes', () => {
sessionCommandMock.mockResolvedValue({ data: {} });
commandListMock.mockReset();
commandListMock.mockResolvedValue({ data: [] });
sessionUpdateMock.mockReset();
sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
});
describe('archiving a batch of sessions', () => {
it('archives every id against the resolved directory and returns the archived sessions', async () => {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
.expect(200);
expect(response.body.directory).toBe('/repo/app');
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
expect(response.body.failedIds).toEqual([]);
expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
expect(sessionUpdateMock).toHaveBeenCalledWith({
sessionID: 'ses_a',
directory: '/repo/app',
time: { archived: 1700 },
});
});
it('keeps archiving after a failed session and reports it as failed', async () => {
sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
if (sessionID === 'ses_b') throw new Error('session.update failed');
return { data: { id: sessionID } };
});
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
.expect(200);
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
expect(response.body.failedIds).toEqual(['ses_b']);
});
it('reports a session the server did not confirm as failed instead of archived', async () => {
sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
));
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
.expect(200);
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
expect(response.body.failedIds).toEqual(['ses_b']);
});
it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
const { app } = createApp();
await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
.expect(400);
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a', ''] })
.expect(400);
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
.expect(400);
expect(sessionUpdateMock).not.toHaveBeenCalled();
});
it('rejects a directory the runtime does not accept', async () => {
const { app } = createApp({
validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
});
await request(app)
.post('/api/openchamber/sessions/archive')
.send({ directory: '/elsewhere', ids: ['ses_a'] })
.expect(400);
expect(sessionUpdateMock).not.toHaveBeenCalled();
});
});
it('creates a session for a directory', async () => {