(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 (
setRenameDraft(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts
index 87bee797..623aaa8e 100644
--- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts
+++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts
@@ -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,
diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md
index cf3964b0..96152f62 100644
--- a/packages/ui/src/stores/DOCUMENTATION.md
+++ b/packages/ui/src/stores/DOCUMENTATION.md
@@ -218,11 +218,22 @@ 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.
+
+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
diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts
index ad824f15..7ee4d7c0 100644
--- a/packages/ui/src/stores/contextStore.ts
+++ b/packages/ui/src/stores/contextStore.ts
@@ -24,8 +24,10 @@ interface ContextState {
sessionAgentModelSelections: Map>;
- // sessionId → agentName → "providerId/modelId" → variant
- sessionAgentModelVariantSelections: Map>>;
+ // 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>>;
currentAgentContext: Map;
@@ -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) => ContextUsage | null;
@@ -145,7 +147,7 @@ export const useContextStore = create()(
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);
diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts
index 5ac465ff..2039ae52 100644
--- a/packages/ui/src/stores/useConfigStore.test.ts
+++ b/packages/ui/src/stores/useConfigStore.test.ts
@@ -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 });
diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts
index e061a64c..a18421b4 100644
--- a/packages/ui/src/stores/useConfigStore.ts
+++ b/packages/ui/src/stores/useConfigStore.ts
@@ -907,11 +907,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
@@ -1908,7 +1925,7 @@ export const useConfigStore = create()(
setCurrentVariantOverride: (override, inherited) => {
set((state) => {
- const currentVariant = override ?? inherited;
+ const currentVariant = resolveVariantFromSelection({ override, inherited });
if (
state.currentVariant === currentVariant
&& state.currentVariantSelection.override === override
@@ -2534,8 +2551,27 @@ export const useConfigStore = create()(
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,
@@ -2561,6 +2597,7 @@ export const useConfigStore = create()(
currentProviderId: providerId,
currentModelId: modelId,
currentVariant: variant,
+ currentVariantSelection: variantSelection,
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
@@ -2570,16 +2607,24 @@ export const useConfigStore = create()(
});
};
- 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 } | 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(
@@ -2589,14 +2634,23 @@ export const useConfigStore = create()(
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);
@@ -2608,14 +2662,11 @@ export const useConfigStore = create()(
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;
}
}
@@ -2628,7 +2679,7 @@ export const useConfigStore = create()(
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;
}
}
@@ -2660,7 +2711,7 @@ export const useConfigStore = create()(
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;
}
}
diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts
index a8c1cb21..6762ef05 100644
--- a/packages/ui/src/sync/selection-store.ts
+++ b/packages/ui/src/sync/selection-store.ts
@@ -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>>()
+// In-memory variant storage (not persisted). `null` is an explicit "Default".
+const agentModelVariantSelections = new Map>>()
// 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()(
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)
diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js
index bff2e2f2..8106a68d 100644
--- a/packages/ui/src/sync/session-ui-store.test.js
+++ b/packages/ui/src/sync/session-ui-store.test.js
@@ -10,6 +10,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore';
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 +399,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 +416,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 +457,56 @@ 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('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', () => {
diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts
index 74fbac64..0ef2cc84 100644
--- a/packages/ui/src/sync/session-ui-store.ts
+++ b/packages/ui/src/sync/session-ui-store.ts
@@ -31,7 +31,7 @@ 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"
@@ -261,6 +261,8 @@ function notifyMessageSent(sessionId: string): void {
// Types
// ---------------------------------------------------------------------------
+type NewSessionDraftTarget = "chat" | "project"
+
export type NewSessionDraftState = {
draftId: number
open: boolean
@@ -276,7 +278,7 @@ export type NewSessionDraftState = {
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
- target: "chat" | "project"
+ target: NewSessionDraftTarget
preparedChatDirectory?: string | null
}
@@ -418,16 +420,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 +734,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)
}
@@ -831,6 +842,7 @@ export async function materializeOpenDraftSession(selection: {
persistDraftTarget({
projectId: draftProjectId,
directory: createdDirectory,
+ target: draft.target,
})
const draftSyntheticParts = draft.syntheticParts
@@ -840,10 +852,13 @@ export async function materializeOpenDraftSession(selection: {
})
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
+ // An explicit "Default" (`null`) is carried over as-is. Flattening it to
+ // `undefined` here would leave the new session with no recorded choice, and
+ // the settings default effort would take the picker back over.
const variantOverride = configState.currentProviderId === selection.providerID
&& configState.currentModelId === selection.modelID
&& configState.currentAgentName === effectiveDraftAgent
- ? configState.currentVariantSelection.override ?? undefined
+ ? configState.currentVariantSelection.override
: selection.variant
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
@@ -1106,14 +1121,34 @@ export const useSessionUIStore = create()((set, get) => ({
const explicitDirectory = options?.directoryOverride !== undefined
? normalizePath(options.directoryOverride)
: 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 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
|| (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 +1161,23 @@ export const useSessionUIStore = create()((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 +1185,7 @@ export const useSessionUIStore = create()((set, get) => ({
warmChatsRootDirectory()
}
- persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
+ persistDraftTarget({ projectId: selectedProject?.id ?? null, directory, target })
const nextDraft: NewSessionDraftState = {
draftId: nextDraftId++,
@@ -1305,6 +1339,15 @@ export const useSessionUIStore = create()((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) {