fix(ui): preserve draft and effort selections
This commit is contained in:
@@ -362,6 +362,15 @@ const SessionRenameForm: React.FC<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [value, setValue] = React.useState(initialTitle);
|
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 commit = () => {
|
||||||
const next = value.trim();
|
const next = value.trim();
|
||||||
if (!next || next === initialTitle.trim()) {
|
if (!next || next === initialTitle.trim()) {
|
||||||
@@ -385,7 +394,7 @@ const SessionRenameForm: React.FC<{
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
ref={focusRenameInput}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onChange={(event) => setValue(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
|||||||
@@ -326,7 +326,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
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 currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
||||||
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
||||||
@@ -728,6 +734,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const effectiveAgentName = uiAgentName || currentAgentName;
|
const effectiveAgentName = uiAgentName || currentAgentName;
|
||||||
if (currentSessionId && effectiveAgentName) {
|
if (currentSessionId && effectiveAgentName) {
|
||||||
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
|
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
|
||||||
|
// An explicit "Default" is a choice: it stops the fallbacks below.
|
||||||
|
if (savedVariant === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (savedVariant && variantOptions.includes(savedVariant)) {
|
if (savedVariant && variantOptions.includes(savedVariant)) {
|
||||||
return savedVariant;
|
return savedVariant;
|
||||||
}
|
}
|
||||||
@@ -777,7 +787,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
|
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
|
||||||
if (currentSessionId && effectiveAgentName) {
|
if (currentSessionId && effectiveAgentName) {
|
||||||
saveAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId, variant);
|
// `null`, not `undefined`: picking "Default" is a choice to record,
|
||||||
|
// not the absence of one.
|
||||||
|
saveAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId, variant ?? null);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
addRecentEffort,
|
addRecentEffort,
|
||||||
@@ -1113,11 +1125,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return;
|
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)) {
|
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||||
setCurrentVariantOverride(
|
setCurrentVariant(resolveInheritedVariantForModel(currentProviderId, currentModelId));
|
||||||
null,
|
|
||||||
resolveInheritedVariantForModel(currentProviderId, currentModelId),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,7 +1154,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
|
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
|
||||||
if (savedVariant && availableVariants.includes(savedVariant)) {
|
if (savedVariant && availableVariants.includes(savedVariant)) {
|
||||||
setCurrentVariantOverride(savedVariant, inheritedVariant);
|
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);
|
setCurrentVariantOverride(null, inheritedVariant);
|
||||||
} else {
|
} else {
|
||||||
setCurrentVariant(inheritedVariant);
|
setCurrentVariant(inheritedVariant);
|
||||||
|
|||||||
@@ -783,6 +783,15 @@ export const Header: React.FC = () => {
|
|||||||
const beginHeaderSessionRenameRef = React.useRef(beginHeaderSessionRename);
|
const beginHeaderSessionRenameRef = React.useRef(beginHeaderSessionRename);
|
||||||
beginHeaderSessionRenameRef.current = 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(() => {
|
React.useEffect(() => {
|
||||||
setIsHeaderSessionMenuOpen(false);
|
setIsHeaderSessionMenuOpen(false);
|
||||||
setPendingHeaderRetentionAction(null);
|
setPendingHeaderRetentionAction(null);
|
||||||
@@ -1422,9 +1431,9 @@ export const Header: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={focusHeaderRenameInput}
|
||||||
value={headerSessionTitleDraft}
|
value={headerSessionTitleDraft}
|
||||||
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
|
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
|
||||||
autoFocus
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
@@ -1579,9 +1588,9 @@ export const Header: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={focusHeaderRenameInput}
|
||||||
value={headerSessionTitleDraft}
|
value={headerSessionTitleDraft}
|
||||||
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
|
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
|
||||||
autoFocus
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
|||||||
const renameDraftRef = React.useRef(renameDraft);
|
const renameDraftRef = React.useRef(renameDraft);
|
||||||
renameDraftRef.current = renameDraft;
|
renameDraftRef.current = renameDraft;
|
||||||
const renameTargetRef = React.useRef<string | null>(null);
|
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 formRef = React.useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
const session = node.session;
|
const session = node.session;
|
||||||
@@ -633,9 +635,24 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
|||||||
}
|
}
|
||||||
if (renameTargetRef.current === session.id) return;
|
if (renameTargetRef.current === session.id) return;
|
||||||
renameTargetRef.current = session.id;
|
renameTargetRef.current = session.id;
|
||||||
|
pendingRenameSelectRef.current = true;
|
||||||
setRenameDraft(editTitle);
|
setRenameDraft(editTitle);
|
||||||
}, [editingId, editTitle, session.id]);
|
}, [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) {
|
if (editingId === session.id) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -656,6 +673,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
value={renameDraft}
|
value={renameDraft}
|
||||||
onChange={(event) => setRenameDraft(event.target.value)}
|
onChange={(event) => setRenameDraft(event.target.value)}
|
||||||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||||
|
|||||||
@@ -148,11 +148,18 @@ const resolveSessionSendConfig = (sessionId: string) => {
|
|||||||
?? config.currentModelId
|
?? config.currentModelId
|
||||||
?? selection.lastUsedProvider?.modelID;
|
?? 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
|
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;
|
: undefined;
|
||||||
|
const variant = savedVariant ?? undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerID,
|
providerID,
|
||||||
|
|||||||
@@ -218,11 +218,22 @@ Each of them therefore keeps two things:
|
|||||||
tracks the **active** project only.
|
tracks the **active** project only.
|
||||||
|
|
||||||
Thinking variants keep the effective value in `currentVariant` so existing send
|
Thinking variants keep the effective value in `currentVariant` so existing send
|
||||||
paths capture a stable configuration. The transient `currentVariantSelection`
|
paths capture a stable configuration. `currentVariantSelection` says where that
|
||||||
distinguishes automatic initialization from a picker or shortcut choosing an
|
value came from: a string is an effort chosen in the picker or by the shortcut,
|
||||||
explicit override or `Default`; returning to `Default` restores its inherited
|
`null` is an explicit `Default`, and `undefined` is automatic initialization,
|
||||||
effective value. Only explicit overrides are stored in the per-session
|
which lets the inherited default apply.
|
||||||
selection store.
|
|
||||||
|
`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
|
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
|
active project, which is what non-Settings callers pass. A load for another
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ interface ContextState {
|
|||||||
|
|
||||||
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
|
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
|
||||||
|
|
||||||
// sessionId → agentName → "providerId/modelId" → variant
|
// sessionId → agentName → "providerId/modelId" → variant, where `null` is
|
||||||
sessionAgentModelVariantSelections: Map<string, Map<string, Map<string, string>>>;
|
// 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>;
|
currentAgentContext: Map<string, string>;
|
||||||
|
|
||||||
@@ -45,8 +47,8 @@ interface ContextActions {
|
|||||||
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
|
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
|
||||||
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
|
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
|
||||||
|
|
||||||
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
|
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => void;
|
||||||
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
|
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;
|
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;
|
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) => {
|
set((state) => {
|
||||||
const newSelections = new Map(state.sessionAgentModelVariantSelections);
|
const newSelections = new Map(state.sessionAgentModelVariantSelections);
|
||||||
|
|
||||||
|
|||||||
@@ -544,7 +544,8 @@ describe('useConfigStore provider persistence', () => {
|
|||||||
|
|
||||||
useConfigStore.getState().setCurrentVariantOverride('max', 'high');
|
useConfigStore.getState().setCurrentVariantOverride('max', 'high');
|
||||||
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
|
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' });
|
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().currentVariantSelection.override).toBe('high');
|
||||||
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
|
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
|
||||||
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
|
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', () => {
|
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().cycleCurrentVariant()).toBe(undefined);
|
||||||
expect(useConfigStore.getState().currentVariant).toBe('low');
|
expect(useConfigStore.getState().currentVariant).toBe(undefined);
|
||||||
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
|
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -608,6 +609,73 @@ describe('useConfigStore provider persistence', () => {
|
|||||||
expect(useConfigStore.getState().currentVariant).toBe('medium');
|
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', () => {
|
test('setAgent applies settings default variant for a saved session agent model', () => {
|
||||||
const sessionId = 'ses_existing_agent_model_default_variant';
|
const sessionId = 'ses_existing_agent_model_default_variant';
|
||||||
useSessionUIStore.setState({ currentSessionId: sessionId });
|
useSessionUIStore.setState({ currentSessionId: sessionId });
|
||||||
|
|||||||
@@ -907,11 +907,28 @@ interface DirectoryScopedConfig {
|
|||||||
selectionSource?: "auto" | "manual";
|
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 = {
|
type CurrentVariantSelection = {
|
||||||
override: string | null | undefined;
|
override: string | null | undefined;
|
||||||
inherited: string | 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
|
* Lift the active directory's cached provider/agent snapshot into the top-level
|
||||||
* fields the pickers read (`providers`, `agents`, selections), so a cold start
|
* fields the pickers read (`providers`, `agents`, selections), so a cold start
|
||||||
@@ -1908,7 +1925,7 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
|
|
||||||
setCurrentVariantOverride: (override, inherited) => {
|
setCurrentVariantOverride: (override, inherited) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentVariant = override ?? inherited;
|
const currentVariant = resolveVariantFromSelection({ override, inherited });
|
||||||
if (
|
if (
|
||||||
state.currentVariant === currentVariant
|
state.currentVariant === currentVariant
|
||||||
&& state.currentVariantSelection.override === override
|
&& state.currentVariantSelection.override === override
|
||||||
@@ -2534,8 +2551,27 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
if (agentName) {
|
if (agentName) {
|
||||||
const { currentSessionId } = useSessionUIStore.getState();
|
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) => {
|
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 directoryKey = state.activeDirectoryKey;
|
||||||
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||||
providers: state.providers,
|
providers: state.providers,
|
||||||
@@ -2561,6 +2597,7 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
currentProviderId: providerId,
|
currentProviderId: providerId,
|
||||||
currentModelId: modelId,
|
currentModelId: modelId,
|
||||||
currentVariant: variant,
|
currentVariant: variant,
|
||||||
|
currentVariantSelection: variantSelection,
|
||||||
selectionSource: "manual",
|
selectionSource: "manual",
|
||||||
directoryScoped: {
|
directoryScoped: {
|
||||||
...state.directoryScoped,
|
...state.directoryScoped,
|
||||||
@@ -2570,16 +2607,24 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveVariantForModel = (
|
const resolveVariantSelectionForModel = (
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
agentVariant?: string,
|
agentVariant?: string,
|
||||||
): string | undefined => {
|
): CurrentVariantSelection => {
|
||||||
const model = providers
|
const model = providers
|
||||||
.find((provider) => provider.id === providerId)
|
.find((provider) => provider.id === providerId)
|
||||||
?.models.find((candidate) => candidate.id === modelId) as { variants?: Record<string, unknown> } | undefined;
|
?.models.find((candidate) => candidate.id === modelId) as { variants?: Record<string, unknown> } | undefined;
|
||||||
const variants = model?.variants;
|
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
|
const savedVariant = currentSessionId
|
||||||
? useSelectionStore.getState().getAgentModelVariantForSession(
|
? useSelectionStore.getState().getAgentModelVariantForSession(
|
||||||
@@ -2589,14 +2634,23 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
modelId,
|
modelId,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// `null` is this session's explicit "Default"; it outranks
|
||||||
for (const candidate of [savedVariant, agentVariant, settingsDefaultVariant]) {
|
// the agent and settings defaults just like a named effort.
|
||||||
if (candidate && Object.prototype.hasOwnProperty.call(variants, candidate)) {
|
if (savedVariant === null || isAvailable(savedVariant)) {
|
||||||
return candidate;
|
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);
|
const agent = agents.find((candidate) => candidate.name === agentName);
|
||||||
@@ -2608,14 +2662,11 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
|
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
|
||||||
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
|
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
|
||||||
const resolvedVariant = resolveVariantForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant);
|
applyResolvedModelSelection(
|
||||||
if (
|
existingAgentModel.providerId,
|
||||||
currentProviderId !== existingAgentModel.providerId
|
existingAgentModel.modelId,
|
||||||
|| currentModelId !== existingAgentModel.modelId
|
resolveVariantSelectionForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant),
|
||||||
|| get().currentVariant !== resolvedVariant
|
);
|
||||||
) {
|
|
||||||
applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, resolvedVariant);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2628,7 +2679,7 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
|
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
|
||||||
|
|
||||||
if (agentModel) {
|
if (agentModel) {
|
||||||
applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant));
|
applyResolvedModelSelection(providerID, modelID, resolveVariantSelectionForModel(providerID, modelID, agent?.variant));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2660,7 +2711,7 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
if (parsed) {
|
if (parsed) {
|
||||||
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
|
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
|
||||||
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,21 @@ export type SelectionState = {
|
|||||||
getSessionAgentSelection: (sessionId: string) => string | null
|
getSessionAgentSelection: (sessionId: string) => string | null
|
||||||
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
|
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
|
||||||
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null
|
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 => (
|
const isPersistedSelectionState = (state: unknown): state is PersistedSelectionState => (
|
||||||
typeof state === "object" && state !== null
|
typeof state === "object" && state !== null
|
||||||
)
|
)
|
||||||
|
|
||||||
// In-memory variant storage (not persisted)
|
// In-memory variant storage (not persisted). `null` is an explicit "Default".
|
||||||
const agentModelVariantSelections = new Map<string, Map<string, Map<string, string>>>()
|
const agentModelVariantSelections = new Map<string, Map<string, Map<string, string | null>>>()
|
||||||
|
|
||||||
// Maximum number of sessions to persist to local storage to prevent unbounded growth
|
// Maximum number of sessions to persist to local storage to prevent unbounded growth
|
||||||
const MAX_PERSISTED_SESSIONS = 150
|
const MAX_PERSISTED_SESSIONS = 150
|
||||||
@@ -91,20 +96,21 @@ export const useSelectionStore = create<SelectionState>()(
|
|||||||
|
|
||||||
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
|
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
|
||||||
const key = `${providerId}/${modelId}`
|
const key = `${providerId}/${modelId}`
|
||||||
|
const clears = variant === undefined
|
||||||
let agentMap = agentModelVariantSelections.get(sessionId)
|
let agentMap = agentModelVariantSelections.get(sessionId)
|
||||||
if (!agentMap && variant) {
|
if (!agentMap && !clears) {
|
||||||
agentMap = new Map()
|
agentMap = new Map()
|
||||||
agentModelVariantSelections.set(sessionId, agentMap)
|
agentModelVariantSelections.set(sessionId, agentMap)
|
||||||
}
|
}
|
||||||
if (!agentMap) return
|
if (!agentMap) return
|
||||||
let modelMap = agentMap.get(agentName)
|
let modelMap = agentMap.get(agentName)
|
||||||
if (!modelMap && variant) {
|
if (!modelMap && !clears) {
|
||||||
modelMap = new Map()
|
modelMap = new Map()
|
||||||
agentMap.set(agentName, modelMap)
|
agentMap.set(agentName, modelMap)
|
||||||
}
|
}
|
||||||
if (!modelMap) return
|
if (!modelMap) return
|
||||||
|
|
||||||
if (!variant) {
|
if (clears) {
|
||||||
modelMap.delete(key)
|
modelMap.delete(key)
|
||||||
if (modelMap.size === 0) {
|
if (modelMap.size === 0) {
|
||||||
agentMap.delete(agentName)
|
agentMap.delete(agentName)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
|
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -398,7 +399,10 @@ describe('openNewSessionDraft project binding', () => {
|
|||||||
const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' };
|
const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' };
|
||||||
const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' };
|
const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' };
|
||||||
|
|
||||||
|
const DRAFT_TARGET_KEY = 'oc.chatInput.lastDraftTarget';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY);
|
||||||
useSessionUIStore.setState({
|
useSessionUIStore.setState({
|
||||||
currentSessionId: null,
|
currentSessionId: null,
|
||||||
currentSessionDirectory: null,
|
currentSessionDirectory: null,
|
||||||
@@ -412,6 +416,10 @@ describe('openNewSessionDraft project binding', () => {
|
|||||||
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
|
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
test('defaults an implicit draft to Chat when active project differs', () => {
|
test('defaults an implicit draft to Chat when active project differs', () => {
|
||||||
useSessionUIStore.getState().openNewSessionDraft();
|
useSessionUIStore.getState().openNewSessionDraft();
|
||||||
const draft = useSessionUIStore.getState().newSessionDraft;
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
||||||
@@ -449,6 +457,56 @@ describe('openNewSessionDraft project binding', () => {
|
|||||||
expect(draft.open).toBe(true);
|
expect(draft.open).toBe(true);
|
||||||
expect(draft.selectedProjectId).toBe(projectB.id);
|
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', () => {
|
describe('createSession draft lifecycle', () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
|
|||||||
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
|
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
|
||||||
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
|
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
|
||||||
import { normalizePath } from "@/lib/pathNormalization"
|
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 { isVSCodeRuntime } from "@/lib/desktop"
|
||||||
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
||||||
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
||||||
@@ -261,6 +261,8 @@ function notifyMessageSent(sessionId: string): void {
|
|||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type NewSessionDraftTarget = "chat" | "project"
|
||||||
|
|
||||||
export type NewSessionDraftState = {
|
export type NewSessionDraftState = {
|
||||||
draftId: number
|
draftId: number
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -276,7 +278,7 @@ export type NewSessionDraftState = {
|
|||||||
syntheticParts?: SyntheticContextPart[]
|
syntheticParts?: SyntheticContextPart[]
|
||||||
targetFolderId?: string
|
targetFolderId?: string
|
||||||
projectContextPins?: { notes: string[]; plans: string[] }
|
projectContextPins?: { notes: string[]; plans: string[] }
|
||||||
target: "chat" | "project"
|
target: NewSessionDraftTarget
|
||||||
preparedChatDirectory?: string | null
|
preparedChatDirectory?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,16 +420,25 @@ const resolveDirectoryKey = (session: Session): string | null => {
|
|||||||
const safeStorage = getDeferredSafeStorage()
|
const safeStorage = getDeferredSafeStorage()
|
||||||
const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget"
|
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 => {
|
const readPersistedDraftTarget = (): PersistedDraftTarget | null => {
|
||||||
try {
|
try {
|
||||||
const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY)
|
const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY)
|
||||||
if (!raw) return null
|
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 {
|
return {
|
||||||
projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null,
|
projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null,
|
||||||
directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null),
|
directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null),
|
||||||
|
target: parsed?.target === "chat" || parsed?.target === "project" ? parsed.target : null,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -723,7 +734,7 @@ const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Pr
|
|||||||
}
|
}
|
||||||
useSessionUIStore.setState({ newSessionDraft: nextDraft })
|
useSessionUIStore.setState({ newSessionDraft: nextDraft })
|
||||||
writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: 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)
|
void activateConfigForDirectory(recovered)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +842,7 @@ export async function materializeOpenDraftSession(selection: {
|
|||||||
persistDraftTarget({
|
persistDraftTarget({
|
||||||
projectId: draftProjectId,
|
projectId: draftProjectId,
|
||||||
directory: createdDirectory,
|
directory: createdDirectory,
|
||||||
|
target: draft.target,
|
||||||
})
|
})
|
||||||
|
|
||||||
const draftSyntheticParts = draft.syntheticParts
|
const draftSyntheticParts = draft.syntheticParts
|
||||||
@@ -840,10 +852,13 @@ export async function materializeOpenDraftSession(selection: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
|
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
|
const variantOverride = configState.currentProviderId === selection.providerID
|
||||||
&& configState.currentModelId === selection.modelID
|
&& configState.currentModelId === selection.modelID
|
||||||
&& configState.currentAgentName === effectiveDraftAgent
|
&& configState.currentAgentName === effectiveDraftAgent
|
||||||
? configState.currentVariantSelection.override ?? undefined
|
? configState.currentVariantSelection.override
|
||||||
: selection.variant
|
: selection.variant
|
||||||
|
|
||||||
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
|
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
|
||||||
@@ -1106,14 +1121,34 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
const explicitDirectory = options?.directoryOverride !== undefined
|
const explicitDirectory = options?.directoryOverride !== undefined
|
||||||
? normalizePath(options.directoryOverride)
|
? normalizePath(options.directoryOverride)
|
||||||
: null
|
: 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
|
let target = isVSCodeRuntime() ? "project" : options?.target
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const hasExplicitProjectTarget = options?.directoryOverride !== undefined
|
const hasExplicitProjectTarget = options?.directoryOverride !== undefined
|
||||||
|| (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID)
|
|| (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID)
|
||||||
|| isVSCodeRuntime()
|
|| isVSCodeRuntime()
|
||||||
target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget
|
target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID
|
||||||
? "chat"
|
? "chat"
|
||||||
: "project"
|
: hasExplicitProjectTarget || restoresProjectTarget
|
||||||
|
? "project"
|
||||||
|
: "chat"
|
||||||
}
|
}
|
||||||
const explicitProject = target === "project" && options?.selectedProjectId
|
const explicitProject = target === "project" && options?.selectedProjectId
|
||||||
? projects.find((p) => p.id === options.selectedProjectId) ?? null
|
? projects.find((p) => p.id === options.selectedProjectId) ?? null
|
||||||
@@ -1126,24 +1161,23 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
return projects[0] ?? null
|
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 : (() => {
|
const selectedProject = target === "chat" ? null : (() => {
|
||||||
if (explicitProject) return explicitProject
|
if (explicitProject) return explicitProject
|
||||||
if (explicitDirectory !== null) return inferredProjectFromDir
|
if (explicitDirectory !== null) return inferredProjectFromDir
|
||||||
if (currentDirectory) return currentDirProject
|
// A chat session leaves a managed scratch directory behind as the current
|
||||||
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
|
// 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 : (() => {
|
const directory = target === "chat" ? null : (() => {
|
||||||
if (explicitDirectory !== null) return explicitDirectory
|
if (explicitDirectory !== null) return explicitDirectory
|
||||||
if (explicitProject) return normalizePath(explicitProject.path ?? null)
|
if (explicitProject) return normalizePath(explicitProject.path ?? null)
|
||||||
if (currentDirectory) return currentDirectory
|
// A chat session's directory is a managed scratch folder, never a
|
||||||
if (persistedTarget?.directory) return persistedTarget.directory
|
// 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)
|
return normalizePath(selectedProject?.path ?? null)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -1151,7 +1185,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
warmChatsRootDirectory()
|
warmChatsRootDirectory()
|
||||||
}
|
}
|
||||||
|
|
||||||
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
|
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory, target })
|
||||||
|
|
||||||
const nextDraft: NewSessionDraftState = {
|
const nextDraft: NewSessionDraftState = {
|
||||||
draftId: nextDraftId++,
|
draftId: nextDraftId++,
|
||||||
@@ -1305,6 +1339,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)
|
void activateConfigForDirectory(nextDirectory)
|
||||||
|
|
||||||
if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) {
|
if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) {
|
||||||
|
|||||||
Reference in New Issue
Block a user