fix(ui): keep an explicit Default effort across the send that follows it
A send carries no effort both when nothing was chosen and when the user picked "Default", so `sendMessage` could not tell the two apart and recorded the raw value, which clears the entry. Picking "Default", sending, then switching agent and back put the settings default back in the picker — the shape of the bug this branch set out to fix. `materializeOpenDraftSession` already read the live selection to keep that distinction on the draft path. Both paths now share `resolveVariantToRecord`, which prefers `currentVariantSelection.override` while the live selection still describes the agent and model being sent to, and falls back to the sent value when it does not. Three tests go through the real `sendMessage`; two of them fail without this change. The existing ones seeded the record directly, which is why the send path was never covered. Also documents the `oc.chatInput.lastDraftTarget` record in the owning sync documentation: its three `target` values, what a pre-`target` record and a removed project fall back to, and why a chat scratch directory is not a project target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZuVVgziiLjD81W5vaxdH2
This commit is contained in:
co-authored by
Claude Opus 5
parent
4dfbb5bd1c
commit
9299e2a28d
@@ -371,6 +371,14 @@ The global sessions store persists and hydrates one bounded, runtime-scoped star
|
||||
|
||||
VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively.
|
||||
|
||||
### Remembering the last draft target
|
||||
|
||||
`session-ui-store.ts` persists the side of the composer's target selector the user last worked on under `oc.chatInput.lastDraftTarget`, so a plain new session reopens there instead of always landing on Chat. The record holds a project id, a directory, and `target`, which is `"chat"`, `"project"`, or `null`.
|
||||
|
||||
`null` is what a record written before `target` existed reads as, and it leaves the Chat default in place rather than guessing a side from the directory. A recorded project that no longer exists falls back to Chat the same way. Only a picker choice writes `"chat"` or `"project"`.
|
||||
|
||||
A session's own directory is not a target choice. "New session in the current directory" forwards the current session's directory even when that session is a managed chat, and a chat scratch directory names no project, so those overrides resolve to a chat draft. Treating one as an explicit project target is how a plus pressed inside a chat opened a project draft.
|
||||
|
||||
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setActionRefs, setOptimisticRefs } from './session-actions';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSelectionStore } from './selection-store';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
@@ -1046,3 +1047,84 @@ describe('deleteSessions option forwarding', () => {
|
||||
expect(deleteSessionCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage effort record', () => {
|
||||
let originalSendMessage;
|
||||
|
||||
const SESSION = 'session-effort';
|
||||
const PROVIDER = 'provider-a';
|
||||
const MODEL = 'model-a';
|
||||
const AGENT = 'build';
|
||||
|
||||
const readRecord = () => useSelectionStore
|
||||
.getState()
|
||||
.getAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL);
|
||||
|
||||
beforeEach(() => {
|
||||
const childStore = {
|
||||
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
|
||||
setState: () => {},
|
||||
};
|
||||
const childStores = {
|
||||
children: new Map(),
|
||||
ensureChild: () => childStore,
|
||||
getChild: () => childStore,
|
||||
};
|
||||
setActionRefs(opencodeClient, childStores, () => '/current/project');
|
||||
setOptimisticRefs(() => {}, () => {});
|
||||
useConfigStore.setState({
|
||||
isConnected: true,
|
||||
currentProviderId: PROVIDER,
|
||||
currentModelId: MODEL,
|
||||
currentAgentName: AGENT,
|
||||
currentVariant: undefined,
|
||||
currentVariantSelection: { override: undefined, inherited: undefined },
|
||||
});
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: SESSION,
|
||||
currentSessionDirectory: '/current/project',
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
||||
});
|
||||
originalSendMessage = opencodeClient.sendMessage;
|
||||
opencodeClient.sendMessage = async () => 'msg';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
opencodeClient.sendMessage = originalSendMessage;
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL, undefined);
|
||||
});
|
||||
|
||||
const send = (variant) => useSessionUIStore.getState().sendMessage(
|
||||
'hello', PROVIDER, MODEL, AGENT, undefined, undefined, undefined, variant, 'normal',
|
||||
);
|
||||
|
||||
test('keeps an explicit Default across the send that follows it', async () => {
|
||||
// What the picker leaves behind: `null` recorded, and a send that carries
|
||||
// no effort because "Default" means exactly that.
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL, null);
|
||||
useConfigStore.setState({ currentVariantSelection: { override: null, inherited: 'high' } });
|
||||
|
||||
await send(undefined);
|
||||
|
||||
expect(readRecord()).toBeNull();
|
||||
});
|
||||
|
||||
test('records the effort a send carries', async () => {
|
||||
useConfigStore.setState({ currentVariantSelection: { override: 'high', inherited: 'high' } });
|
||||
|
||||
await send('high');
|
||||
|
||||
expect(readRecord()).toBe('high');
|
||||
});
|
||||
|
||||
test('records no choice when the live selection inherits its effort', async () => {
|
||||
useConfigStore.setState({
|
||||
currentVariant: 'high',
|
||||
currentVariantSelection: { override: undefined, inherited: 'high' },
|
||||
});
|
||||
|
||||
await send('high');
|
||||
|
||||
expect(readRecord()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -780,6 +780,29 @@ const createSessionWithDraftLifecycle = async (
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The effort a send should record for its session.
|
||||
*
|
||||
* A send carries `undefined` both when no effort was ever chosen and when the
|
||||
* user explicitly picked "Default", so the sent value alone cannot tell the two
|
||||
* apart, and recording it raw clears a real "Default". The live selection can
|
||||
* tell them apart, because its `override` keeps `null` for "Default" — but only
|
||||
* while it still describes the agent and model being sent to. Otherwise the
|
||||
* send's own value is all there is to go on.
|
||||
*/
|
||||
const resolveVariantToRecord = (
|
||||
agentName: string | undefined,
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
sentVariant: string | undefined,
|
||||
): string | null | undefined => {
|
||||
const config = useConfigStore.getState()
|
||||
const describesThisSend = config.currentProviderId === providerID
|
||||
&& config.currentModelId === modelID
|
||||
&& config.currentAgentName === agentName
|
||||
return describesThisSend ? config.currentVariantSelection.override : sentVariant
|
||||
}
|
||||
|
||||
export async function materializeOpenDraftSession(selection: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
@@ -852,14 +875,12 @@ 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
|
||||
: selection.variant
|
||||
const variantOverride = resolveVariantToRecord(
|
||||
effectiveDraftAgent,
|
||||
selection.providerID,
|
||||
selection.modelID,
|
||||
selection.variant,
|
||||
)
|
||||
|
||||
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
|
||||
|
||||
@@ -1716,7 +1737,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
if (targetSessionId && effectiveAgent) {
|
||||
useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent)
|
||||
useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID)
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant)
|
||||
useSelectionStore.getState().saveAgentModelVariantForSession(
|
||||
targetSessionId,
|
||||
effectiveAgent,
|
||||
providerID,
|
||||
modelID,
|
||||
resolveVariantToRecord(effectiveAgent, providerID, modelID, variant),
|
||||
)
|
||||
}
|
||||
|
||||
if (targetSessionId) {
|
||||
|
||||
Reference in New Issue
Block a user