diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 753ae66e..2054ec0a 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 02b11d8a..4dcb50a8 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -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); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 6648550b..42153409 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -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()((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) {