From 77311b71533a21023619b61c2711eaa03188014d Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Sat, 5 Sep 2026 18:13:11 +0800 Subject: [PATCH 1/4] fix(chat): preserve configured and user-selected models --- src/agent/protocol/input.ts | 2 + src/agent/session/AgentSession.ts | 1 + src/agent/turn/TurnRunner.ts | 14 +- src/cli/createLocalGateway.ts | 11 +- src/gateway/client/InProcessGateway.ts | 12 +- src/gateway/dialog/modelCatalog.ts | 8 +- src/gateway/protocol/types.ts | 5 +- src/session/transcript/TranscriptReplay.ts | 6 + src/web/client/protocol.ts | 7 +- tests/gateway/dialog-model-selection.spec.ts | 176 +++++++++++++++++ ui/e2e/fixtures/model-selection.html | 1 + ui/e2e/fixtures/model-selection.jsx | 62 ++++++ ui/e2e/model-selection.config.mjs | 10 + ui/e2e/model-selection.spec.mjs | 82 ++++++++ ui/server/pilotdeck-bridge.js | 16 +- ui/server/pilotdeck-bridge.test.js | 17 ++ .../chat-v2/ChatInterfaceV2.queue.test.tsx | 2 + ui/src/components/chat-v2/ChatInterfaceV2.tsx | 11 ++ ui/src/components/chat-v2/ComposerV2.tsx | 21 ++- .../useChatComposerState.attachments.test.tsx | 40 ++++ .../chat/hooks/useChatComposerState.ts | 10 +- .../chat/hooks/useChatModelSelection.test.tsx | 169 +++++++++++++++++ .../chat/hooks/useChatModelSelection.ts | 178 ++++++++++++++++++ .../chat/hooks/useChatProviderState.ts | 164 +--------------- ui/src/components/chat/types/queuedInput.ts | 2 + .../chat/utils/sessionLauncher.spec.ts | 14 ++ .../components/chat/utils/sessionLauncher.ts | 6 + ui/src/i18n/locales/en/chat.json | 1 + ui/src/i18n/locales/zh-CN/chat.json | 1 + 29 files changed, 872 insertions(+), 177 deletions(-) create mode 100644 tests/gateway/dialog-model-selection.spec.ts create mode 100644 ui/e2e/fixtures/model-selection.html create mode 100644 ui/e2e/fixtures/model-selection.jsx create mode 100644 ui/e2e/model-selection.config.mjs create mode 100644 ui/e2e/model-selection.spec.mjs create mode 100644 ui/src/components/chat/hooks/useChatModelSelection.test.tsx create mode 100644 ui/src/components/chat/hooks/useChatModelSelection.ts diff --git a/src/agent/protocol/input.ts b/src/agent/protocol/input.ts index 9cb4d6298..88cedff42 100644 --- a/src/agent/protocol/input.ts +++ b/src/agent/protocol/input.ts @@ -35,4 +35,6 @@ export type AgentSubmitOptions = { */ syntheticMessages?: import("../../model/index.js").CanonicalMessage[]; modelOverride?: AgentModelOverride; + /** Persisted dialog preference, separate from a one-turn override. */ + modelSelection?: NonNullable; }; diff --git a/src/agent/session/AgentSession.ts b/src/agent/session/AgentSession.ts index 1df57dae0..92ccaa4fa 100644 --- a/src/agent/session/AgentSession.ts +++ b/src/agent/session/AgentSession.ts @@ -95,6 +95,7 @@ export class AgentSession { permissionRules: submitOptions.permissionRules, syntheticMessages: submitOptions.syntheticMessages, modelOverride: submitOptions.modelOverride, + modelSelection: submitOptions.modelSelection, abortSignal: this.state.abortController.signal, openSteerMailbox: () => this.steerMailbox.start(turnId), drainSteerMessages: () => this.steerMailbox.drain(turnId), diff --git a/src/agent/turn/TurnRunner.ts b/src/agent/turn/TurnRunner.ts index 4d7229aab..d9a935c0d 100644 --- a/src/agent/turn/TurnRunner.ts +++ b/src/agent/turn/TurnRunner.ts @@ -36,6 +36,7 @@ export type TurnRunnerOptions = { /** Synthetic messages appended after user input; stored with metadata.synthetic flag. */ syntheticMessages?: CanonicalMessage[]; modelOverride?: AgentModelOverride; + modelSelection?: NonNullable; openSteerMailbox?: () => void; drainSteerMessages?: () => AgentSteerMessage[]; drainOrCloseSteerMailbox?: () => { messages: AgentSteerMessage[]; closed: boolean }; @@ -488,12 +489,15 @@ export class TurnRunner { const snapshot = metadataStore.getSnapshot(); const prompt = allHumanText(acceptedMessages); - if (!prompt) return; + if (!prompt && !options.modelSelection) return; - const boundedPrompt = prompt.slice(0, SESSION_LISTING_PROMPT_MAX_CHARS); + const boundedPrompt = prompt?.slice(0, SESSION_LISTING_PROMPT_MAX_CHARS); await metadataStore.record(options.turnId, { - ...(snapshot.firstPrompt ? {} : { firstPrompt: boundedPrompt }), - lastPrompt: boundedPrompt, + ...(boundedPrompt ? { + ...(snapshot.firstPrompt ? {} : { firstPrompt: boundedPrompt }), + lastPrompt: boundedPrompt, + } : {}), + ...(options.modelSelection ? { modelSelection: { ...options.modelSelection } } : {}), updatedAt: this.now().toISOString(), }).catch(() => {}); } @@ -505,6 +509,8 @@ function isVisibleFailureStatus(status: AgentStatusMessageInput): boolean { function acceptedInputMetadata(options: TurnRunnerOptions): Record | undefined { const metadata: Record = {}; + // Save alongside input so a crash before the metadata snapshot cannot lose the choice. + if (options.modelSelection) metadata.modelSelection = { ...options.modelSelection }; if (options.permissionMode) { metadata.permissionMode = options.permissionMode; } diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index d220a1aa5..76862fe59 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -415,13 +415,22 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre }, async resolveTurnModelSelection(input) { const projectKey = await dialogProjects.resolveProjectKey(input.projectKey ?? fallbackProjectRoot); + if (input.modelSelection !== undefined && input.modelOverride !== undefined) { + throw new DialogGatewayError("INVALID_MODEL_OVERRIDE", "Specify modelSelection or modelOverride, not both."); + } + if (input.modelSelection !== undefined) { + validateModelSelection(projectKey, input.modelSelection, env); + return input.modelSelection.mode === "model" + ? { selection: input.modelSelection, source: "turn" as const } + : { source: "router" as const }; + } if (input.modelOverride) { validateExplicitModelSelection(projectKey, input.modelOverride, env); return { selection: input.modelOverride, source: "turn" as const }; } const saved = await readSavedModel(projectKey, input.sessionKey); + if (saved) validateModelSelection(projectKey, saved, env); if (saved?.mode === "model") { - validateExplicitModelSelection(projectKey, saved, env); return { selection: saved, source: "session" as const }; } const config = loadPilotConfig({ projectRoot: projectKey, env }).config; diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index c8f08faf1..d6692da63 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -554,9 +554,11 @@ export class InProcessGateway implements Gateway { })); const modelSelection = this.options.resolveTurnModelSelection ? await this.options.resolveTurnModelSelection(input) - : input.modelOverride - ? { selection: input.modelOverride, source: "turn" as const } - : { source: "default" as const }; + : input.modelSelection?.mode === "auto" + ? { source: "router" as const } + : input.modelSelection?.mode === "model" || input.modelOverride + ? { selection: input.modelSelection?.mode === "model" ? input.modelSelection : input.modelOverride, source: "turn" as const } + : { source: "default" as const }; let lastEmittedModel: string | undefined; if (modelSelection.selection) { const event: GatewayEvent = { @@ -577,6 +579,7 @@ export class InProcessGateway implements Gateway { agentInput, { turnId: runId, + modelSelection: input.modelSelection, maxTurns: input.maxTurns, runMode, permissionMode, @@ -634,6 +637,9 @@ export class InProcessGateway implements Gateway { lastEmittedModel = `${event.event.provider}\0${event.event.model}`; } for (const gatewayEvent of mapAgentEvent(event, runId)) { + if (gatewayEvent.type === "input_accepted" && input.modelSelection) { + gatewayEvent.modelSelection = { ...input.modelSelection }; + } if (gatewayEvent.type === "context_budget") { this.recordGatewayStatusMessage({ sessionKey: input.sessionKey, diff --git a/src/gateway/dialog/modelCatalog.ts b/src/gateway/dialog/modelCatalog.ts index 974db6fbe..817a1c0c2 100644 --- a/src/gateway/dialog/modelCatalog.ts +++ b/src/gateway/dialog/modelCatalog.ts @@ -51,11 +51,15 @@ export function listModelCatalog(input: ModelCatalogListInput, env: NodeJS.Proce && (!query || "router auto".includes(query))) { items.unshift({ id: "router/auto", provider: "router", model: "auto", displayName: "Auto", available: true, capabilities: {} }); } - return { items, router: { enabled: routerEnabled, autoAvailable: routerEnabled } }; + return { + items, + defaultSelection: { mode: "model", provider: config.agent.model.provider, model: config.agent.model.model }, + router: { enabled: routerEnabled, autoAvailable: routerEnabled }, + }; } export function validateModelSelection(projectKey: string, selection: SessionModelSelection, env: NodeJS.ProcessEnv = process.env): void { - if (selection.mode === "auto") { + if (selection?.mode === "auto") { if (!listModelCatalog({ projectKey }, env).router.autoAvailable) { throw new DialogGatewayError("ROUTER_AUTO_UNAVAILABLE", "Router auto is not available for this project."); } diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index ce48e271e..81cbbe0d3 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -100,6 +100,8 @@ export type GatewaySubmitTurnInput = { uploadedAttachments?: UploadedAttachmentRef[]; /** A one-turn model override. Persisted session preferences are managed separately. */ modelOverride?: ExplicitModelSelection; + /** Dialog choice: used for this turn and saved with accepted input. */ + modelSelection?: SessionModelSelection; runMode?: AgentRunMode; mode?: GatewayMode; /** The user's actual permission preference before plan-mode override. */ @@ -172,7 +174,7 @@ type GatewayTurnScopedEventMetadata = { export type GatewayEvent = GatewayTurnScopedEventMetadata & ( | { type: "turn_started"; runId: string } - | { type: "input_accepted"; runId: string } + | { type: "input_accepted"; runId: string; modelSelection?: SessionModelSelection } | { type: "steer_applied"; itemId: string; message: CanonicalMessage } | { type: "steer_unapplied"; itemId: string; reason: "turn_ended" } | { type: "model_request_started"; model?: string; provider?: string } @@ -474,6 +476,7 @@ export type ModelCatalogListInput = { }; export type ModelCatalogListResult = { + defaultSelection: ExplicitModelSelection; items: ModelCatalogItem[]; router: { enabled: boolean; autoAvailable: boolean }; }; diff --git a/src/session/transcript/TranscriptReplay.ts b/src/session/transcript/TranscriptReplay.ts index d0ce0ffa7..0d242d23f 100644 --- a/src/session/transcript/TranscriptReplay.ts +++ b/src/session/transcript/TranscriptReplay.ts @@ -60,6 +60,12 @@ export function replayTranscriptEntries(entries: AgentTranscriptEntry[]): AgentT switch (entry.type) { case "accepted_input": + if (entry.metadata?.modelSelection) { + const choice = entry.metadata.modelSelection as SessionMetadataValue["modelSelection"]; + if (choice?.mode === "auto" || (choice?.mode === "model" && typeof choice.provider === "string" && typeof choice.model === "string")) { + metadata = mergeMetadata(metadata, { modelSelection: { ...choice } }); + } + } if (!beforeBoundary) { messages.push(...cloneMessages(entry.messages)); events.push({ diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index d2692c6e8..45b4729e5 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -175,6 +175,7 @@ export type WebSubmitTurnInput = { projectKey?: string; uploadedAttachments?: Array<{ uploadId: string; attachmentIds?: string[] }>; modelOverride?: WebExplicitModelSelection; + modelSelection?: { mode: "auto" } | WebExplicitModelSelection; attachments?: WebChannelAttachment[]; runMode?: WebAgentRunMode; mode?: WebGatewayMode; @@ -224,7 +225,11 @@ export type WebCommandsListResult = { pinned: unknown[]; builtIn: unknown[]; cus export type WebExplicitModelSelection = { mode: "model"; provider: string; model: string; reasoning?: number; temperature?: number; speed?: number }; export type WebSessionModelSelection = { mode: "auto" } | WebExplicitModelSelection; export type WebModelCatalogListInput = { projectKey: string; query?: string; provider?: string; includeAuto?: boolean }; -export type WebModelCatalogListResult = { items: unknown[]; router: { enabled: boolean; autoAvailable: boolean } }; +export type WebModelCatalogListResult = { + defaultSelection: WebExplicitModelSelection; + items: unknown[]; + router: { enabled: boolean; autoAvailable: boolean }; +}; export type WebSessionModelInput = { projectKey: string; sessionKey: string }; export type WebSessionModelResult = WebSessionModelInput & { saved?: WebSessionModelSelection; effective: { provider: string; model: string; source: "session" | "router" | "default"; reasoning?: number; temperature?: number; speed?: number } }; diff --git a/tests/gateway/dialog-model-selection.spec.ts b/tests/gateway/dialog-model-selection.spec.ts new file mode 100644 index 000000000..6821b0714 --- /dev/null +++ b/tests/gateway/dialog-model-selection.spec.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import type { PilotConfigSnapshot } from '../../src/pilot/config/types.js'; +import { createLocalGateway } from '../../src/cli/createLocalGateway.js'; +import { createModelRuntime, type CanonicalModelEvent, type CanonicalModelRequest } from '../../src/model/index.js'; +import { createAgentProjectSessionStorage, readTranscript, replayTranscriptEntries } from '../../src/session/index.js'; +import type { GatewayEvent, GatewaySubmitTurnInput } from '../../src/gateway/protocol/types.js'; + +const A = { mode: 'model' as const, provider: 'alpha', model: 'first' }; +const B = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; +const CONFIG = ` +schemaVersion: 1 +agent: + model: zeta/configured + maxContextTokens: 65536 + maxOutputTokens: 8192 +extension: + builtinPluginsEnabled: + windows-skills: false + browser-use: false + funasr: false +memory: + enabled: false +telemetry: + enabled: false +model: + providers: + alpha: + protocol: openai + url: https://example.test/v1 + apiKey: test-key + models: + first: {} + zeta: + protocol: openai + url: https://example.test/v1 + apiKey: test-key + speedMapping: openai_service_tier + models: + configured: + capabilities: + supportsSpeed: true +router: + enabled: true + scenarios: + default: zeta/configured + fallback: + default: [alpha/first] + zeroUsageRetry: + enabled: false + transientRetry: + enabled: false +`; + +async function fixture(t: test.TestContext) { + const home = await mkdtemp(join(tmpdir(), 'pilotdeck-model-choice-')); + await writeFile(join(home, 'pilotdeck.yaml'), CONFIG); + await mkdir(join(home, 'skills'), { recursive: true }); + const requests: CanonicalModelRequest[] = []; + let failZeta = false; + const options = { + pilotHome: home, projectRoot: home, + env: { ...process.env, PILOT_HOME: home, PILOT_AGENT_MODEL: undefined, PILOTDECK_CONFIG_PATH: undefined }, + builtinSkillsRoot: join(home, 'skills'), + __testModelFactory: (snapshot: PilotConfigSnapshot) => ({ + ...createModelRuntime(snapshot.config.model), + async *stream(request: CanonicalModelRequest): AsyncIterable { + requests.push(request); + yield { type: 'request_started', provider: request.provider, model: request.model }; + if (failZeta && request.provider === 'zeta') { + yield { type: 'error', error: { provider: request.provider, protocol: 'openai', code: 'auth_error', message: 'test failure', retryable: false } }; + return; + } + yield { type: 'message_start', role: 'assistant' }; + yield { type: 'text_delta', text: 'ok' }; + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 1 } }; + yield { type: 'message_end', finishReason: 'stop' }; + }, + async complete() { return { role: 'assistant' as const, content: [{ type: 'text' as const, text: '' }], finishReason: 'stop' as const }; }, + }), + }; + let local = createLocalGateway(options); + t.after(async () => { local.dispose(); await rm(home, { recursive: true, force: true }); }); + return { + home, requests, + get gateway() { return local.gateway; }, + fail() { failZeta = true; }, + restart() { local.dispose(); local = createLocalGateway(options); }, + async submit(modelSelection?: GatewaySubmitTurnInput['modelSelection'], modelOverride?: GatewaySubmitTurnInput['modelOverride']) { + const events: GatewayEvent[] = []; + for await (const event of local.gateway.submitTurn({ + projectKey: home, sessionKey: 'web:model-choice', channelKey: 'web', message: 'hello', modelSelection, modelOverride, + })) events.push(event); + return events; + }, + async saved() { return (await local.gateway.sessionModelGet!({ projectKey: home, sessionKey: 'web:model-choice' })).saved; }, + }; +} + +test('first-turn choice and parameters are durable at acceptance and survive gateway restart', async (t) => { + const f = await fixture(t); + const catalog = await f.gateway.modelCatalogList!({ projectKey: f.home, includeAuto: true }); + assert.equal(catalog.items[0]!.id, 'router/auto'); + assert.equal(catalog.items[1]!.id, 'alpha/first'); + assert.deepEqual(catalog.defaultSelection, { mode: 'model', provider: B.provider, model: B.model }); + for await (const event of f.gateway.submitTurn({ projectKey: f.home, sessionKey: 'web:model-choice', channelKey: 'web', message: 'hello', modelSelection: B })) { + if (event.type === 'input_accepted') { + assert.deepEqual(event.modelSelection, B); + assert.deepEqual(await f.saved(), B); + } + } + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0]!.provider, B.provider); + assert.equal(f.requests[0]!.temperature, B.temperature); + assert.equal(f.requests[0]!.speed, B.speed); + assert.equal(f.requests[0]!.thinking?.mode, 'high'); + const storage = createAgentProjectSessionStorage({ projectRoot: f.home, pilotHome: f.home, sessionId: 'web:model-choice' }); + const entries = (await readTranscript(storage.transcriptPath)).entries; + const acceptedOnly = entries.filter((e) => e.type === 'accepted_input'); + assert.deepEqual(replayTranscriptEntries(acceptedOnly).metadata.modelSelection, B, 'crash before metadata snapshot retains the choice'); + f.restart(); + assert.deepEqual(await f.saved(), B); + await f.submit(); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.equal(f.requests.at(-1)!.speed, B.speed); +}); + +test('explicit Auto replaces saved concrete choice; one-turn overrides do not change saved preferences', async (t) => { + const f = await fixture(t); + await f.submit(A); + assert.equal(f.requests.at(-1)!.provider, A.provider); + await f.submit({ mode: 'auto' }); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.deepEqual(await f.saved(), { mode: 'auto' }); + f.restart(); + await f.submit(); + assert.equal(f.requests.at(-1)!.provider, B.provider); + await f.submit(A); + await f.submit(undefined, B); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.deepEqual(await f.saved(), A); + await f.submit(); + assert.equal(f.requests.at(-1)!.provider, A.provider); +}); + +test('concrete choices fail without silently falling back, while Auto retains fallback', async (t) => { + const f = await fixture(t); + f.fail(); + await f.submit(B); + assert.ok(f.requests.length > 0); + assert.deepEqual([...new Set(f.requests.map((r) => r.provider))], ['zeta']); + f.requests.length = 0; + f.restart(); + await f.submit({ mode: 'auto' }); + assert.deepEqual([...new Set(f.requests.map((r) => r.provider))], ['zeta', 'alpha']); + assert.deepEqual(await f.saved(), { mode: 'auto' }); +}); + +test('invalid and conflicting choices cannot execute or replace the saved preference', async (t) => { + const f = await fixture(t); + await f.submit(A); + f.requests.length = 0; + for (const input of [ + { modelSelection: { ...B, model: 'missing' } }, + { modelSelection: B, modelOverride: A }, + { modelSelection: null as unknown as GatewaySubmitTurnInput['modelSelection'] }, + ]) { + const events = await f.submit(input.modelSelection, input.modelOverride); + assert.equal(events.some((event) => event.type === 'input_accepted'), false); + } + assert.equal(f.requests.length, 0); + assert.deepEqual(await f.saved(), A); +}); diff --git a/ui/e2e/fixtures/model-selection.html b/ui/e2e/fixtures/model-selection.html new file mode 100644 index 000000000..c32d3b819 --- /dev/null +++ b/ui/e2e/fixtures/model-selection.html @@ -0,0 +1 @@ +Model selection fixture
diff --git a/ui/e2e/fixtures/model-selection.jsx b/ui/e2e/fixtures/model-selection.jsx new file mode 100644 index 000000000..9465a41b0 --- /dev/null +++ b/ui/e2e/fixtures/model-selection.jsx @@ -0,0 +1,62 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import Composer from '../../src/components/chat-v2/ComposerV2'; +import { useChatModelSelection } from '../../src/components/chat/hooks/useChatModelSelection'; +import { startSessionCommand } from '../../src/components/chat/utils/sessionLauncher'; +import i18n from '../../src/i18n/config'; +import '../../src/index.css'; +i18n.changeLanguage('en'); +const noop = () => {}; +const props = { + placeholder: 'Message', renderInputWithMentions: (text) => text, + onTextareaClick: noop, onTextareaKeyDown: noop, onTextareaPaste: noop, onTextareaScrollSync: noop, onTextareaInput: noop, + onAbortSession: noop, openImagePicker: noop, onAddAttachmentFiles: noop, attachedImages: [], onRemoveImage: noop, onRetryImage: noop, + documentReferences: [], onRemoveDocumentReference: noop, uploadingImages: new Map(), imageErrors: new Map(), + filteredFiles: [], selectedFileMentions: [], selectedSkills: [], selectedCommands: [], filteredCommands: [], frequentCommands: [], + getRootProps: () => ({}), getInputProps: () => ({}), pendingPermissionRequests: [], permissionMode: 'default', runMode: 'agent', + onPermissionModeChange: noop, onRunModeChange: noop, onInsertSlash: noop, onToggleCommandMenu: noop, +}; +function App() { + const [projectKey, setProject] = useState('/general'); + const [sessionId, setSession] = useState(new URLSearchParams(location.search).get('session') || undefined); + const [input, setInput] = useState('hello'); + const [loading, setLoading] = useState(false); + const [frame, setFrame] = useState(null); + const listener = useRef(noop); + const subscribe = useCallback((fn) => { listener.current = fn; return noop; }, []); + const model = useChatModelSelection({ projectKey, sessionId, subscribe }); + const textareaRef = useRef(null), highlightRef = useRef(null); + const send = (event) => { + event.preventDefault(); + if (!model.isModelSelectionReady) return; + startSessionCommand({ + selectedProject: { name: 'fixture', path: projectKey }, sessionId, + command: input, modelSelection: model.modelSelection, + sendMessage: (message) => { + setFrame(message); setLoading(true); setInput(''); + void fetch('/api/test-submit', { method: 'POST', body: JSON.stringify(message) }).then((r) => r.json()).then((accepted) => { + if (!sessionId) listener.current({ kind: 'session_created', projectKey, newSessionId: accepted.sessionId }); + setSession(accepted.sessionId); + listener.current({ type: 'model-selection-saved', sessionId: accepted.sessionId, selection: message.options.modelSelection }); + const running = message.options.modelSelection.mode === 'auto' + ? { provider: 'zeta', model: 'configured' } : message.options.modelSelection; + listener.current({ type: 'model-selection-changed', sessionId: accepted.sessionId, modelProvider: running.provider, model: running.model, runId: 'run-1' }); + }); + return true; + }, + }); + }; + return
+ + + + {JSON.stringify(model.modelSelection)} + {JSON.stringify(frame)} + setInput(e.target.value)} onSubmit={send} + onModelSelectionChange={(choice) => { void model.setModelSelection(choice); }} + runningModel={model.runningModels[sessionId]}/> +
; +} +createRoot(document.getElementById('root')).render(); diff --git a/ui/e2e/model-selection.config.mjs b/ui/e2e/model-selection.config.mjs new file mode 100644 index 000000000..2a8b9875d --- /dev/null +++ b/ui/e2e/model-selection.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export default defineConfig({ + testDir: '.', testMatch: ['model-selection.spec.mjs'], outputDir: '/tmp/pilotdeck-model-selection-playwright', workers: 1, + use: { baseURL: 'http://127.0.0.1:5180', viewport: { width: 1100, height: 800 }, screenshot: 'only-on-failure' }, + webServer: { command: 'node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5180 --strictPort', cwd: uiRoot, + url: 'http://127.0.0.1:5180/e2e/fixtures/model-selection.html', reuseExistingServer: false }, +}); diff --git a/ui/e2e/model-selection.spec.mjs b/ui/e2e/model-selection.spec.mjs new file mode 100644 index 000000000..cc56a1449 --- /dev/null +++ b/ui/e2e/model-selection.spec.mjs @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; +const A = { mode: 'model', provider: 'alpha', model: 'first' }; +const B = { mode: 'model', provider: 'zeta', model: 'configured' }; +const items = [A, B].map((x) => ({ id: `${x.provider}/${x.model}`, provider: x.provider, model: x.model, displayName: x.model, available: true, capabilities: {} })); +const catalog = { items: [{ id: 'router/auto', provider: 'router', model: 'auto', displayName: 'Auto', available: true, capabilities: {} }, ...items], defaultSelection: B, router: { autoAvailable: true } }; +async function setup(page, { holdCatalog = false, unavailable = false } = {}) { + const saved = new Map(); + const submitted = []; + let release; + const gate = new Promise((r) => { release = r; }); + await page.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + let result = {}; + if (url.pathname === '/api/models') { + if (holdCatalog) await gate; + result = unavailable ? { ...catalog, items: catalog.items.filter((x) => x.id !== 'zeta/configured') } : catalog; + } else if (url.pathname === '/api/sessions/model') { + if (request.method() === 'PUT') { + const data = request.postDataJSON(); saved.set(data.sessionKey, data.selection); + } + result = { saved: saved.get(url.searchParams.get('sessionKey')), effective: A }; + } else if (url.pathname === '/api/test-submit') { + const data = request.postDataJSON(); submitted.push(data); + saved.set('web:created', data.options.modelSelection); + result = { sessionId: 'web:created' }; + } + await route.fulfill({ json: result }); + }); + await page.goto('/e2e/fixtures/model-selection.html'); + return { saved, submitted, release }; +} +const choice = async (page) => JSON.parse(await page.getByTestId('selection').textContent()); + +test('general and project defaults match configuration and sending is blocked while loading', async ({ page }) => { + const { release, submitted } = await setup(page, { holdCatalog: true }); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); + release(); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'Project', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + expect(submitted[0].options.modelSelection).toEqual(B); + await expect.poll(() => choice(page)).toEqual(B); +}); + +test('manual selection survives sending, completion and reload', async ({ page }) => { + const { submitted } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + expect(submitted[0].options.modelSelection).toEqual(A); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + await page.goto('/e2e/fixtures/model-selection.html?session=web:created'); + await expect.poll(() => choice(page)).toEqual(A); +}); + +test('explicit Auto stays Auto when the server reports a concrete running model', async ({ page }) => { + const { submitted } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'Auto', exact: true }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + expect(submitted[0].options.modelSelection).toEqual({ mode: 'auto' }); + await expect(page.getByRole('status').filter({ hasText: 'Running:' })).toContainText('zeta/configured'); + await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); +}); + +test('unavailable configured models block sending and the picker still allows recovery', async ({ page }) => { + await setup(page, { unavailable: true }); + await expect.poll(() => choice(page)).toEqual(B); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('zeta/configured'); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeEnabled(); +}); diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index a2f722739..de28da7d1 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -827,7 +827,10 @@ export function gatewayEventToFrames(event, sessionId, provider) { const base = { sessionId, provider, ...(event.runId ? { runId: event.runId } : {}) }; switch (event.type) { case 'input_accepted': - return []; + return event.modelSelection ? [{ + type: 'model-selection-saved', sessionId: base.sessionId, + runId: event.runId, selection: event.modelSelection, + }] : []; case 'steer_unapplied': return []; case 'steer_applied': { @@ -863,6 +866,15 @@ export function gatewayEventToFrames(event, sessionId, provider) { text: 'started', }), ]; + case 'model_selection_changed': + return [{ + type: 'model-selection-changed', + sessionId: base.sessionId, + runId: event.runId, + modelProvider: event.provider, + model: event.model, + source: event.source, + }]; case 'model_request_started': return [ createNormalizedMessage({ @@ -1516,6 +1528,7 @@ export async function runChatViaGateway( kind: 'session_created', newSessionId: sessionKey, sessionKey, + projectKey, }), ); } @@ -1568,6 +1581,7 @@ export async function runChatViaGateway( runId, ...(Array.isArray(options?.uploadedAttachments) ? { uploadedAttachments: options.uploadedAttachments } : {}), ...(options?.modelOverride ? { modelOverride: options.modelOverride } : {}), + ...(options?.modelSelection ? { modelSelection: options.modelSelection } : {}), ...(basePermissionMode ? { basePermissionMode } : {}), ...(attachments.length > 0 ? { attachments } : {}), ...(workspaceCwd ? { workspaceCwd } : {}), diff --git a/ui/server/pilotdeck-bridge.test.js b/ui/server/pilotdeck-bridge.test.js index 9b062ccfb..40f33ee51 100644 --- a/ui/server/pilotdeck-bridge.test.js +++ b/ui/server/pilotdeck-bridge.test.js @@ -902,3 +902,20 @@ describe('Always-On turn notification forwarding', () => { }); }); }); + +describe('dialog model preference frames', () => { + it('keeps Auto and explicit parameter choices in persisted queued messages', () => { + for (const selection of [{ mode: 'auto' }, { mode: 'model', provider: 'chosen', model: 'selected', reasoning: 0.8, temperature: 0.3, speed: 1 }]) { + const item = { id: 'queued-model', options: { modelSelection: selection } }; + expect(hydrateQueuedInputOptions(restoreQueuedInputFromStorage(serializeQueuedInputForStorage(item)).options).modelSelection).toEqual(selection); + } + }); + + it('forwards accepted preference separately from the model actually executing', () => { + const sessionId = 'web:s'; + const accepted = gatewayEventToFrames({ type: 'input_accepted', runId: 'run-1', modelSelection: { mode: 'auto' } }, sessionId, 'pilotdeck'); + expect(accepted[0]).toMatchObject({ type: 'model-selection-saved', sessionId, selection: { mode: 'auto' } }); + const running = gatewayEventToFrames({ type: 'model_selection_changed', runId: 'run-1', provider: 'chosen', model: 'routed', source: 'router' }, sessionId, 'pilotdeck'); + expect(running[0]).toMatchObject({ type: 'model-selection-changed', modelProvider: 'chosen', model: 'routed', runId: 'run-1' }); + }); +}); diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx index 6bb2d9b35..c702b541b 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx @@ -45,6 +45,8 @@ vi.mock('../chat/hooks/useChatProviderState', () => ({ modelSelection: { mode: 'auto' }, setModelSelection: vi.fn(async () => undefined), isModelCatalogLoading: false, + isModelSelectionReady: true, + runningModels: {}, modelCatalogError: null, thinkingModelContext: null, permissionMode: 'default', diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index 2ed8171a6..9254ddbdc 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -140,6 +140,8 @@ function ChatInterfaceV2({ modelSelection, setModelSelection, isModelCatalogLoading, + isModelSelectionReady, + runningModels, modelCatalogError, thinkingModelContext, permissionMode, @@ -302,6 +304,7 @@ function ChatInterfaceV2({ currentSessionId, model, modelSelection, + isModelSelectionReady, runMode, permissionMode: effectivePermissionMode, basePermissionMode: permissionMode, @@ -563,6 +566,7 @@ function ChatInterfaceV2({ throw new Error(t('edit.missingTarget', { defaultValue: 'The last message can no longer be edited.' })); } + if (!isModelSelectionReady || !modelSelection) throw new Error(modelCatalogError || "Model selection is still loading."); const attachments = Array.isArray(message.attachments) ? message.attachments : []; const references = attachments .map((attachment) => normalizeContentReference(attachment.contentReference ?? attachment)) @@ -610,6 +614,7 @@ function ChatInterfaceV2({ command, runId, userVisibleInput: editedText, + modelSelection: { ...modelSelection }, toolsSettings: getPilotDeckSettings(), runMode, permissionMode: effectivePermissionMode, @@ -630,6 +635,9 @@ function ChatInterfaceV2({ return result; }, [ currentSessionId, + isModelSelectionReady, + modelSelection, + modelCatalogError, effectivePermissionMode, model, permissionMode, @@ -772,6 +780,9 @@ function ChatInterfaceV2({ modelCatalog={modelCatalog} modelSelection={modelSelection} isModelCatalogLoading={isModelCatalogLoading} + isModelSelectionReady={isModelSelectionReady} + runningModel={runningModels[selectedSession?.id || currentSessionId || ""]?.runId === activeRunId + ? runningModels[selectedSession?.id || currentSessionId || ""] : undefined} modelCatalogError={modelCatalogError} projectKey={selectedProject?.fullPath || selectedProject?.path || ''} onModelSelectionChange={(selection) => { diff --git a/ui/src/components/chat-v2/ComposerV2.tsx b/ui/src/components/chat-v2/ComposerV2.tsx index 8e8dd3795..abddce57c 100644 --- a/ui/src/components/chat-v2/ComposerV2.tsx +++ b/ui/src/components/chat-v2/ComposerV2.tsx @@ -157,6 +157,8 @@ export type ComposerV2Props = { modelCatalog: ChatModelCatalogItem[]; modelSelection: ChatModelSelection | null; isModelCatalogLoading?: boolean; + isModelSelectionReady?: boolean; + runningModel?: { provider: string; model: string }; modelCatalogError?: string | null; projectKey: string; onModelSelectionChange: (selection: ChatModelSelection) => void; @@ -510,6 +512,8 @@ export default function ComposerV2({ modelCatalog, modelSelection, isModelCatalogLoading = false, + isModelSelectionReady = true, + runningModel, modelCatalogError, projectKey, onModelSelectionChange, @@ -646,7 +650,7 @@ export default function ComposerV2({ ); const hasUploadingImages = [...uploadingImages.values()].some((percent) => percent < 100); const attachmentLimitError = imageErrors.get(MAX_ATTACHMENTS_ERROR_KEY); - const disabled = !hasDraftContent || isSubmitPending || hasUploadingImages; + const disabled = !hasDraftContent || isSubmitPending || hasUploadingImages || !isModelSelectionReady; const primaryAction = getComposerPrimaryAction({ isLoading, isInputQueuePaused, @@ -695,7 +699,7 @@ export default function ComposerV2({ modelSelection?.mode === "auto" ? (t("input.models.auto", { defaultValue: "Auto" }) as string) : selectedModel?.displayName || - selectedModel?.model || + selectedModel?.model || (modelSelection?.mode === "model" ? modelSelection.model : "") || (t("input.models.select", { defaultValue: "Select model", }) as string); @@ -742,6 +746,11 @@ export default function ComposerV2({ >
{queueTray} + {isLoading && runningModel ? ( +
+ {t('input.models.running', { model: `${runningModel.provider}/${runningModel.model}`, defaultValue: 'Running: {{model}}' })} +
+ ) : null} {pendingPermissionRequests.length > 0 ? (
{ + if (!isModelSelectionReady) { event.preventDefault(); return; } if (showWorkspacePicker && !workspaceSelectedProject) { event.preventDefault(); setWorkspaceMenuForceOpen(true); @@ -1532,14 +1542,13 @@ export default function ComposerV2({ />
+ {modelCatalogError ? ( +
{modelCatalogError}
+ ) : null} {isModelCatalogLoading ? (
- ) : modelCatalogError ? ( -
- {modelCatalogError} -
) : filteredModels.length === 0 ? (
{t("input.models.empty", { diff --git a/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx b/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx index b66cdaa79..289b8987a 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx +++ b/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx @@ -39,6 +39,46 @@ describe('useChatComposerState attachment submission', () => { vi.restoreAllMocks(); }); + it.each([false, true])('snapshots the selected model before attachment preparation (queued=%s)', async (queued) => { + let finishUpload!: () => void; + const uploadGate = new Promise((resolve) => { finishUpload = resolve; }); + mocks.uploadAttachmentBatch.mockImplementation(async ({ files }: { files: File[] }) => { + await uploadGate; + return { + uploadId: 'upload-model', attachmentIds: ['attachment-model'], + attachments: files.map((file) => ({ attachmentId: 'attachment-model', name: file.name, + relativePath: `.tmp/chat-uploads/upload-model/${file.name}`, bytes: file.size, mimeType: file.type })), + }; + }); + const sendMessage = vi.fn(() => true); + const enqueuePreparedInput = vi.fn(async () => ({ ok: true })); + const initialChoice = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; + const selectedProject = { name: 'demo', displayName: 'Demo', fullPath: '/tmp/demo' }; + const selectedSession = queued ? { id: 'web:queue' } : null; + const { result, rerender } = renderHook(({ modelSelection }) => useChatComposerState({ + selectedProject, selectedSession, currentSessionId: queued ? 'web:queue' : null, + model: 'zeta/configured', modelSelection, isModelSelectionReady: true, + permissionMode: 'default', runMode: 'agent', cycleRunMode: vi.fn(), isLoading: queued, + canAbortSession: queued, tokenBudget: null, sendMessage, enqueuePreparedInput, + pendingViewSessionRef: { current: null }, scrollToBottom: vi.fn(), addMessage: vi.fn(), + clearMessages: vi.fn(), rewindMessages: vi.fn(), setIsLoading: vi.fn(), setCanAbortSession: vi.fn(), + setIsAborting: vi.fn(), setClaudeStatus: vi.fn(), setPilotDeckStatus: vi.fn(), setIsUserScrolledUp: vi.fn(), + pendingPermissionRequests: [], setPendingPermissionRequests: vi.fn(), + }), { initialProps: { modelSelection: initialChoice as import('./useChatProviderState').ChatModelSelection } }); + act(() => { + result.current.setInput('keep my selected model'); + result.current.addAttachmentFiles([new File(['content'], 'test.txt', { type: 'text/plain' })]); + }); + await waitFor(() => expect(result.current.attachedImages).toHaveLength(1)); + let submitting!: Promise; + act(() => { submitting = result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); + rerender({ modelSelection: { mode: 'auto' } }); + await act(async () => { finishUpload(); await submitting; }); + expect(queued ? enqueuePreparedInput : sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + options: expect.objectContaining({ modelSelection: initialChoice }), + })); + }); + it('does not create an optimistic sidebar session when attachment upload fails', async () => { mocks.uploadAttachmentBatch.mockRejectedValue(new Error('upload failed')); vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index 73b48166c..3c447b3df 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -64,6 +64,7 @@ interface UseChatComposerStateArgs { currentSessionId: string | null; model: string; modelSelection?: ChatModelSelection | null; + isModelSelectionReady?: boolean; permissionMode: PermissionMode | string; basePermissionMode?: PermissionMode | string; runMode?: string; @@ -260,6 +261,7 @@ export function useChatComposerState({ currentSessionId, model, modelSelection, + isModelSelectionReady = true, permissionMode, basePermissionMode, runMode, @@ -1124,6 +1126,8 @@ export function useChatComposerState({ event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, ) => { event.preventDefault(); + if (!isModelSelectionReady) return; + const submittedModelSelection = modelSelection ? { ...modelSelection } : undefined; const currentInput = inputValueRef.current; const submitAttachedImages = attachedImages; let submittedAttachmentFiles = submitAttachedImages; @@ -1386,7 +1390,6 @@ export function useChatComposerState({ const toolsSettings = getPilotDeckSettings(); const sessionSummary = getNotificationSessionSummary(submitSelectedSession, userVisibleInput); const resolvedProjectPath = getSelectedProjectPath(selectedProject); - const modelOverride = modelSelection?.mode === 'model' ? modelSelection : undefined; const clearSubmittedComposerState = () => { if (inputValueRef.current === currentInput) { @@ -1448,7 +1451,7 @@ export function useChatComposerState({ images: uploadedImages, attachments: turnAttachments, uploadedAttachments: uploadedAttachmentRefs, - modelOverride, + modelSelection: submittedModelSelection, }, }) ?? { ok: false, error: 'Message queue is unavailable.' }; if (!result.ok) { @@ -1492,7 +1495,7 @@ export function useChatComposerState({ thinking: thinkingModeToConfig(thinkingMode), sessionSummary, toolsSettings, - modelOverride, + modelSelection: submittedModelSelection, images: uploadedImages, attachments: turnAttachments, uploadedAttachments: uploadedAttachmentRefs, @@ -1546,6 +1549,7 @@ export function useChatComposerState({ clearSelectedCommands, model, modelSelection, + isModelSelectionReady, currentSessionId, executeCommand, isLoading, diff --git a/ui/src/components/chat/hooks/useChatModelSelection.test.tsx b/ui/src/components/chat/hooks/useChatModelSelection.test.tsx new file mode 100644 index 000000000..bae4e64aa --- /dev/null +++ b/ui/src/components/chat/hooks/useChatModelSelection.test.tsx @@ -0,0 +1,169 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useChatModelSelection } from './useChatModelSelection'; +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../utils/api', () => ({ authenticatedFetch: fetchMock })); +const A = { mode: 'model' as const, provider: 'alpha', model: 'first' }; +const B = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; +const items = [A, B].map((s) => ({ id: `${s.provider}/${s.model}`, provider: s.provider, model: s.model, displayName: s.model, available: true, capabilities: {} })); +const catalog = { items: [{ id: 'router/auto', provider: 'router', model: 'auto', displayName: 'Auto', available: true, capabilities: {} }, ...items], defaultSelection: B, router: { autoAvailable: true } }; +const json = (data: unknown, status = 200) => ({ ok: status < 400, status, json: async () => data }); +const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; +let listener: (message: any) => void; +const subscribe = (fn: typeof listener) => { listener = fn; return () => {}; }; +const setup = (sessionId?: string, projectKey = '/general') => renderHook( + (props) => useChatModelSelection({ ...props, subscribe }), { initialProps: { sessionId, projectKey } }, +); +beforeEach(() => { + localStorage.clear(); fetchMock.mockReset(); + fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { effective: A })); +}); +afterEach(cleanup); + +describe('dialog model selection', () => { + it.each(['/general', '/project'])('uses configured default in %s, even with Auto and another model first', async (project) => { + const { result } = setup(undefined, project); + expect(result.current.isModelSelectionReady).toBe(false); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(B); + expect(localStorage.length).toBe(0); + expect(fetchMock.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false); + }); + it('restores exact saved parameters without borrowing project parameters', async () => { + localStorage.setItem('composer-model-/general', JSON.stringify(B)); + const saved = { ...B, reasoning: 0.2, temperature: undefined, speed: undefined }; + fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved, effective: A })); + const { result } = setup('web:saved'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(saved); + }); + it('preserves a manual choice when a new session receives its permanent ID', async () => { + const { result, rerender } = setup(); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => result.current.setModelSelection(A)); + rerender({ projectKey: '/general', sessionId: 'web:created' }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(A); + }); + it('retains unavailable choices and lets the user select a replacement', async () => { + const unavailable = { ...A, model: 'removed' }; + localStorage.setItem('composer-model-/general', JSON.stringify(unavailable)); + const { result } = setup(); + await waitFor(() => expect(result.current.isModelCatalogLoading).toBe(false)); + expect(result.current.modelSelection).toEqual(unavailable); + expect(result.current.isModelSelectionReady).toBe(false); + expect(result.current.modelCatalogError).toContain('alpha/removed'); + await act(() => result.current.setModelSelection(B)); + expect(result.current.isModelSelectionReady).toBe(true); + }); + it('keeps a later welcome-page choice when the first submission receives a permanent ID', async () => { + const { result, rerender } = setup('new-session-123'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => result.current.setModelSelection(A)); + expect(fetchMock.mock.calls.some(([, opts]) => opts?.method === 'PUT')).toBe(false); + act(() => listener({ kind: 'session_created', projectKey: '/general', newSessionId: 'web:created' })); + fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved: B })); + rerender({ projectKey: '/general', sessionId: 'web:created' }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(A); + }); + it('keeps sending blocked when returning to a session whose save is still pending', async () => { + const save = deferred>(); + fetchMock.mockImplementation(async (url: string, opts?: any) => opts?.method === 'PUT' + ? save.promise : json(url.startsWith('/api/models?') ? catalog : { saved: B })); + const { result, rerender } = setup('web:saved'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + let saving!: Promise; + await act(async () => { saving = result.current.setModelSelection(A); }); + rerender({ projectKey: '/other', sessionId: 'web:other' }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + rerender({ projectKey: '/general', sessionId: 'web:saved' }); + await waitFor(() => expect(result.current.isModelCatalogLoading).toBe(false)); + expect(result.current.modelSelection).toEqual(A); + expect(result.current.isModelSelectionReady).toBe(false); + await act(async () => { save.resolve(json({})); await saving; }); + expect(result.current.isModelSelectionReady).toBe(true); + }); + it('does not let acceptance of an older queued choice erase the next-message draft on reload', async () => { + const first = setup('web:queued'); + await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); + await act(() => first.result.current.setModelSelection(B)); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: A })); + first.unmount(); + fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved: A })); + const { result } = setup('web:queued'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(B); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: B })); + expect(localStorage.getItem('pending-composer-model-["/general","web:queued"]')).toBeNull(); + }); + it('ignores delayed old-project responses and blocks during scope changes', async () => { + const old = deferred>(); + fetchMock.mockImplementationOnce(() => old.promise); + const { result, rerender } = setup(); + rerender({ projectKey: '/project', sessionId: undefined }); + expect(result.current.modelSelection).toBeNull(); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => { old.resolve(json({ ...catalog, defaultSelection: A })); }); + expect(result.current.modelSelection).toEqual(B); + }); + it('keeps a user choice when an earlier config reload finishes later', async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + const reload = deferred>(); + fetchMock.mockImplementationOnce(() => reload.promise); + act(() => listener({ type: 'config:reloaded' })); + await act(() => result.current.setModelSelection(A)); + await act(() => { reload.resolve(json(catalog)); }); + expect(result.current.modelSelection).toEqual(A); + expect(result.current.isModelSelectionReady).toBe(true); + }); + it('refreshes untouched defaults without saving them as explicit preferences', async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + fetchMock.mockResolvedValueOnce(json({ ...catalog, defaultSelection: A })); + act(() => listener({ type: 'config:reloaded' })); + await waitFor(() => expect(result.current.modelSelection).toEqual(A)); + expect(localStorage.length).toBe(0); + }); + it('serializes saves and blocks sending until the latest choice is saved', async () => { + const { result } = setup('web:saved'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + const first = deferred>(), second = deferred>(); + fetchMock.mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); + let saveA!: Promise, saveB!: Promise; + await act(async () => { saveA = result.current.setModelSelection(A); }); + await act(async () => { saveB = result.current.setModelSelection(B); }); + expect(result.current.isModelSelectionReady).toBe(false); + expect(fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'PUT')).toHaveLength(1); + await act(async () => { first.resolve(json({})); await saveA; }); + expect(result.current.isModelSelectionReady).toBe(false); + await act(async () => { second.resolve(json({})); await saveB; }); + expect(result.current.modelSelection).toEqual(B); + expect(result.current.isModelSelectionReady).toBe(true); + expect(fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'PUT').map(([, opts]) => JSON.parse(opts.body).selection)).toEqual([A, B]); + }); + it('preserves next-turn Auto across refresh while the current turn is busy', async () => { + fetchMock.mockImplementation(async (url: string, opts?: any) => opts?.method === 'PUT' + ? json({ error: { code: 'SESSION_BUSY' } }, 409) + : json(url.startsWith('/api/models?') ? catalog : { saved: A })); + const first = setup('web:busy'); + await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); + await act(() => first.result.current.setModelSelection({ mode: 'auto' })); + first.unmount(); + const { result } = setup('web:busy'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual({ mode: 'auto' }); + act(() => listener({ type: 'model-selection-changed', sessionId: 'web:busy', modelProvider: 'alpha', model: 'first', runId: 'run-old' })); + expect(result.current.modelSelection).toEqual({ mode: 'auto' }); + expect(result.current.runningModels['web:busy'].model).toBe('first'); + }); + it('reports save failures without enabling sending or reverting the choice', async () => { + const { result } = setup('web:saved'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + fetchMock.mockResolvedValueOnce(json({ error: { message: 'Save failed' } }, 500)); + await act(async () => { await expect(result.current.setModelSelection(A)).rejects.toThrow('Save failed'); }); + expect(result.current.modelSelection).toEqual(A); + expect(result.current.isModelSelectionReady).toBe(false); + }); +}); diff --git a/ui/src/components/chat/hooks/useChatModelSelection.ts b/ui/src/components/chat/hooks/useChatModelSelection.ts new file mode 100644 index 000000000..856dda11e --- /dev/null +++ b/ui/src/components/chat/hooks/useChatModelSelection.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import { modelSelectionId, normalizeModelSelection, parseCatalogItem } from '../../chat-v2/modelCapabilityOptions'; +import type { ChatModelCatalogItem, ChatModelSelection } from './useChatProviderState'; +import { safeLocalStorage } from '../utils/chatStorage'; + +type Subscribe = (listener: (message: any) => void) => () => void; +type SelectionState = { + scope: string; + selection: ChatModelSelection | null; + catalog: ChatModelCatalogItem[]; + loading: boolean; + saving: boolean; + error: string | null; +}; + +function readSelection(key: string): ChatModelSelection | null { + try { return normalizeModelSelection(JSON.parse(safeLocalStorage.getItem(key) || 'null')); } + catch { return null; } +} + +function selectionError(selection: ChatModelSelection | null, catalog: ChatModelCatalogItem[]) { + if (!selection) return 'No default model is configured. Choose a model.'; + if (!catalog.some((item) => item.id === modelSelectionId(selection) && item.available)) { + return `Selected model is unavailable: ${modelSelectionId(selection)}. Choose another model.`; + } + return null; +} + +/** A dialog choice is distinct from both a catalog row and a running request's model. */ +export function useChatModelSelection({ projectKey, sessionId: selectedSessionId, subscribe }: { + projectKey: string; + sessionId?: string; + subscribe: Subscribe; +}) { + const sessionId = selectedSessionId?.startsWith('new-session-') ? undefined : selectedSessionId; + const scope = JSON.stringify([projectKey, sessionId || '']); + const scopeRef = useRef(scope); + scopeRef.current = scope; + const drafts = useRef(new Map()); + const saveVersions = useRef(new Map()); + const pendingSaves = useRef(new Map()); + const saveTail = useRef>(Promise.resolve()); + const [refresh, setRefresh] = useState(0); + const [state, setState] = useState({ + scope: '', selection: null, catalog: [], loading: true, saving: false, error: null, + }); + const [runningModels, setRunningModels] = useState>({}); + + useEffect(() => subscribe((message) => { + if (message?.type === 'config:reloaded') setRefresh((value) => value + 1); + const events = [message, ...(message?.activeTurnMessages || [])]; + for (const event of events) { + // Bind a welcome-page choice to its new session before the session GET can finish. + // A user may already have selected the next model while the first submission starts. + if (event?.kind === 'session_created' && event.newSessionId && event.projectKey === projectKey && !sessionId) { + const draft = drafts.current.get(scope); + if (draft) { + const createdScope = JSON.stringify([projectKey, event.newSessionId]); + drafts.current.set(createdScope, draft); + safeLocalStorage.setItem(`pending-composer-model-${createdScope}`, JSON.stringify(draft)); + } + } + if (event?.type === 'model-selection-saved' && event.sessionId) { + const acceptedScope = JSON.stringify([projectKey, event.sessionId]); + const pendingKey = `pending-composer-model-${acceptedScope}`; + if (JSON.stringify(readSelection(pendingKey)) === JSON.stringify(event.selection)) safeLocalStorage.removeItem(pendingKey); + } + if (event?.type !== 'model-selection-changed' || !event.sessionId) continue; + setRunningModels((previous) => ({ + ...previous, + [event.sessionId]: { provider: event.modelProvider, model: event.model, runId: event.runId }, + })); + } + }), [projectKey, sessionId, scope, subscribe]); + + useEffect(() => { + const controller = new AbortController(); + const current = () => !controller.signal.aborted && scopeRef.current === scope; + setState((previous) => ({ + scope, selection: previous.scope === scope ? previous.selection : null, + catalog: previous.scope === scope ? previous.catalog : [], loading: true, + saving: pendingSaves.current.has(scope), error: null, + })); + if (!projectKey) return () => controller.abort(); + + const readJson = async (url: string) => { + const response = await authenticatedFetch(url, { signal: controller.signal }); + const data = await response.json(); + if (!response.ok) throw new Error(data?.error?.message || 'Failed to load model selection.'); + return data; + }; + void (async () => { + try { + const [catalogData, sessionData] = await Promise.all([ + readJson(`/api/models?projectKey=${encodeURIComponent(projectKey)}&includeAuto=true`), + sessionId ? readJson(`/api/sessions/model?${new URLSearchParams({ projectKey, sessionKey: sessionId })}`) : null, + ]); + if (!current()) return; + const catalog: ChatModelCatalogItem[] = (Array.isArray(catalogData.items) ? catalogData.items : []) + .map(parseCatalogItem).filter((item: ChatModelCatalogItem | null): item is ChatModelCatalogItem => Boolean(item)); + // Only explicit user choices populate these keys. Loading a catalog must never write a preference. + const selection = drafts.current.get(scope) + || (sessionId ? readSelection(`pending-composer-model-${scope}`) : null) + || normalizeModelSelection(sessionData?.saved) + || readSelection(`composer-model-${projectKey}`) + || normalizeModelSelection(catalogData.defaultSelection); + setState({ + scope, selection, catalog, loading: false, + saving: pendingSaves.current.has(scope), + error: selectionError(selection, catalog), + }); + } catch (error) { + if (current()) setState((previous) => ({ + ...previous, loading: false, error: error instanceof Error ? error.message : String(error), + })); + } + })(); + return () => controller.abort(); + }, [projectKey, sessionId, scope, refresh]); + + const setModelSelection = useCallback(async (value: ChatModelSelection) => { + const selection = { ...value }; + drafts.current.set(scope, selection); + safeLocalStorage.setItem(`composer-model-${projectKey}`, JSON.stringify(selection)); + const pendingKey = `pending-composer-model-${scope}`; + if (sessionId) safeLocalStorage.setItem(pendingKey, JSON.stringify(selection)); + const version = (saveVersions.current.get(scope) || 0) + 1; + saveVersions.current.set(scope, version); + if (sessionId) pendingSaves.current.set(scope, version); + setState((previous) => ({ + ...previous, selection, saving: Boolean(sessionId), + error: selectionError(selection, previous.catalog), + })); + if (!sessionId) return; + + // Serialize writes: a slower save of A must never overwrite a later choice of B. + const save = saveTail.current.catch(() => {}).then(async () => { + if (saveVersions.current.get(scope) !== version) return; + const response = await authenticatedFetch('/api/sessions/model', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectKey, sessionKey: sessionId, selection }), + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + // While a turn runs, this is the next message's draft. Submission will persist it atomically. + if (response.status === 409 && data?.error?.code === 'SESSION_BUSY') return; + throw new Error(data?.error?.message || 'Failed to save model selection.'); + } + // Retain the next-message draft until matching input is accepted. An older + // queued message can still persist its own snapshot after this PUT succeeds. + }); + saveTail.current = save; + try { await save; } + catch (error) { + if (scopeRef.current === scope && saveVersions.current.get(scope) === version) { + setState((previous) => ({ ...previous, error: error instanceof Error ? error.message : String(error) })); + throw error; + } + } finally { + if (pendingSaves.current.get(scope) === version) pendingSaves.current.delete(scope); + if (scopeRef.current === scope && saveVersions.current.get(scope) === version) { + setState((previous) => ({ ...previous, saving: false })); + } + } + }, [projectKey, sessionId, scope]); + + const isCurrent = state.scope === scope; + return { + modelSelection: isCurrent ? state.selection : null, + modelCatalog: isCurrent ? state.catalog : [], + isModelCatalogLoading: !isCurrent || state.loading, + isModelSelectionReady: isCurrent && !state.loading && !state.saving && !state.error && Boolean(state.selection), + modelCatalogError: isCurrent ? state.error : null, + setModelSelection, + runningModels, + }; +} diff --git a/ui/src/components/chat/hooks/useChatProviderState.ts b/ui/src/components/chat/hooks/useChatProviderState.ts index 140b647f6..b739c61e3 100644 --- a/ui/src/components/chat/hooks/useChatProviderState.ts +++ b/ui/src/components/chat/hooks/useChatProviderState.ts @@ -4,11 +4,7 @@ import { useWebSocket } from '../../../contexts/WebSocketContext'; import { CLAUDE_MODELS } from '../../../../shared/modelConstants'; import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { Project, ProjectSession } from '../../../types/app'; -import { - mergeModelSelections, - normalizeModelSelection, - parseCatalogItem, -} from '../../chat-v2/modelCapabilityOptions'; +import { useChatModelSelection } from './useChatModelSelection'; interface UseChatProviderStateArgs { selectedProject: Project | null; @@ -124,10 +120,11 @@ export function useChatProviderState({ selectedProject, selectedSession }: UseCh }); const [modelOptions, setModelOptions] = useState(DEFAULT_MODEL_OPTIONS); const [thinkingModelContext, setThinkingModelContext] = useState(null); - const [modelCatalog, setModelCatalog] = useState([]); - const [modelSelection, setModelSelectionState] = useState(null); - const [isModelCatalogLoading, setIsModelCatalogLoading] = useState(false); - const [modelCatalogError, setModelCatalogError] = useState(null); + const modelState = useChatModelSelection({ + projectKey: selectedProject?.fullPath || selectedProject?.path || '', + sessionId: selectedSession?.id, + subscribe, + }); useEffect(() => { const defaultMode = readStoredPermissionMode(DEFAULT_PERMISSION_MODE_KEY); @@ -214,121 +211,6 @@ export function useChatProviderState({ selectedProject, selectedSession }: UseCh }; }, []); - useEffect(() => { - const projectKey = selectedProject?.fullPath || selectedProject?.path || ''; - if (!projectKey) { - setModelCatalog([]); - setModelSelectionState(null); - return; - } - - const abortController = new AbortController(); - const loadModels = async () => { - setIsModelCatalogLoading(true); - setModelCatalogError(null); - try { - const catalogResponse = await authenticatedFetch( - `/api/models?projectKey=${encodeURIComponent(projectKey)}&includeAuto=true`, - { signal: abortController.signal }, - ); - const catalogData = await catalogResponse.json().catch(() => ({})); - if (!catalogResponse.ok) { - throw new Error(catalogData?.error?.message || 'Failed to load models'); - } - - const items = Array.isArray(catalogData?.items) - ? catalogData.items - .map((item: unknown) => parseCatalogItem(item)) - .filter((item: ChatModelCatalogItem | null): item is ChatModelCatalogItem => Boolean(item)) - : []; - setModelCatalog(items); - - let storedSelection: ChatModelSelection | null = null; - const stored = localStorage.getItem(`composer-model-${projectKey}`); - if (stored) { - try { - storedSelection = normalizeModelSelection(JSON.parse(stored)); - let isValidStoredSelection = false; - if (storedSelection?.mode === 'auto') { - isValidStoredSelection = catalogData?.router?.autoAvailable === true; - } else if (storedSelection?.mode === 'model') { - const { provider, model: modelId } = storedSelection; - isValidStoredSelection = items.some((item: ChatModelCatalogItem) => ( - item.provider === provider && item.model === modelId && item.available - )); - } - if (!isValidStoredSelection) { - storedSelection = null; - localStorage.removeItem(`composer-model-${projectKey}`); - } - } catch { - localStorage.removeItem(`composer-model-${projectKey}`); - } - } - - let nextSelection: ChatModelSelection | null = null; - if (selectedSession?.id) { - const params = new URLSearchParams({ - sessionKey: selectedSession.id, - projectKey, - }); - const sessionResponse = await authenticatedFetch(`/api/sessions/model?${params}`, { - signal: abortController.signal, - }); - if (sessionResponse.ok) { - const sessionData = await sessionResponse.json(); - const savedSelection = normalizeModelSelection(sessionData?.saved); - const effectiveSelection = sessionData?.effective?.provider && sessionData?.effective?.model - ? normalizeModelSelection({ - mode: 'model', - provider: sessionData.effective.provider, - model: sessionData.effective.model, - reasoning: sessionData.effective.reasoning, - temperature: sessionData.effective.temperature, - speed: sessionData.effective.speed, - }) - : null; - nextSelection = mergeModelSelections(savedSelection, storedSelection) || effectiveSelection; - if (!savedSelection && storedSelection) { - void authenticatedFetch('/api/sessions/model', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectKey, - sessionKey: selectedSession.id, - selection: storedSelection, - }), - }); - } - } - } - - if (!nextSelection) { - nextSelection = storedSelection; - } - if (!nextSelection && catalogData?.router?.autoAvailable) { - nextSelection = { mode: 'auto' }; - } - if (!nextSelection) { - const current = items.find((item: ChatModelCatalogItem) => item.available); - if (current) { - nextSelection = { mode: 'model', provider: current.provider, model: current.model }; - } - } - setModelSelectionState(nextSelection); - } catch (error) { - if ((error as { name?: string })?.name !== 'AbortError') { - setModelCatalogError(error instanceof Error ? error.message : 'Failed to load models'); - } - } finally { - if (!abortController.signal.aborted) setIsModelCatalogLoading(false); - } - }; - - void loadModels(); - return () => abortController.abort(); - }, [selectedProject?.fullPath, selectedProject?.path, selectedSession?.id]); - useEffect(() => { return subscribe((message: any) => { if (message?.type !== 'config:reloaded') return; @@ -356,41 +238,13 @@ export function useChatProviderState({ selectedProject, selectedSession }: UseCh setPermissionMode(nextMode); }, [permissionMode, setPermissionMode]); - const setModelSelection = useCallback(async (selection: ChatModelSelection) => { - const projectKey = selectedProject?.fullPath || selectedProject?.path || ''; - setModelSelectionState(selection); - if (projectKey) { - localStorage.setItem(`composer-model-${projectKey}`, JSON.stringify(selection)); - } - if (selection.mode === 'model') { - setModel(`${selection.provider}/${selection.model}`); - } - if (!projectKey || !selectedSession?.id) return; - - const response = await authenticatedFetch('/api/sessions/model', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectKey, - sessionKey: selectedSession.id, - selection, - }), - }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data?.error?.message || 'Failed to save model selection'); - } - }, [selectedProject?.fullPath, selectedProject?.path, selectedSession?.id]); return { - model, + model: modelState.modelSelection?.mode === 'model' + ? `${modelState.modelSelection.provider}/${modelState.modelSelection.model}` : model, setModel, modelOptions, - modelCatalog, - modelSelection, - setModelSelection, - isModelCatalogLoading, - modelCatalogError, + ...modelState, thinkingModelContext, permissionMode, setPermissionMode, diff --git a/ui/src/components/chat/types/queuedInput.ts b/ui/src/components/chat/types/queuedInput.ts index cc25c76c0..4b4006e0e 100644 --- a/ui/src/components/chat/types/queuedInput.ts +++ b/ui/src/components/chat/types/queuedInput.ts @@ -1,3 +1,4 @@ +import type { ChatModelSelection } from '../hooks/useChatProviderState'; import type { ChatAttachment } from './types'; export type QueuedInputStatus = 'queued' | 'steering' | 'dispatching' | 'delivery_uncertain' | 'failed'; @@ -34,6 +35,7 @@ export type PreparedQueuedInput = { permissionMode?: string; basePermissionMode?: string; model?: string; + modelSelection?: ChatModelSelection; modelOverride?: { mode: 'model'; provider: string; diff --git a/ui/src/components/chat/utils/sessionLauncher.spec.ts b/ui/src/components/chat/utils/sessionLauncher.spec.ts index 3cf63f86f..9edcf5dbd 100644 --- a/ui/src/components/chat/utils/sessionLauncher.spec.ts +++ b/ui/src/components/chat/utils/sessionLauncher.spec.ts @@ -99,3 +99,17 @@ describe('sessionLauncher turn identity', () => { }); }); }); + +describe('dialog model submission', () => { + it.each([{ mode: 'auto' as const }, { mode: 'model' as const, provider: 'selected', model: 'chosen', reasoning: 0.8, temperature: 0.2, speed: 1 }])('snapshots %j in both new and edited requests', (selection) => { + const sendMessage = vi.fn(); + const common = { sendMessage, selectedProject: { name: 'demo', path: '/demo' } as Project, command: 'hello', modelSelection: selection }; + startSessionCommand(common); + regenerateLastSessionCommand({ ...common, requestId: 'edit', sessionId: 'web:s', expectedTurnId: 'old' }); + for (const [frame] of sendMessage.mock.calls) { + expect(frame.options.modelSelection).toEqual(selection); + expect(frame.options.modelSelection).not.toBe(selection); + expect(frame.options.modelOverride).toBeUndefined(); + } + }); +}); diff --git a/ui/src/components/chat/utils/sessionLauncher.ts b/ui/src/components/chat/utils/sessionLauncher.ts index c0389d852..c1283028d 100644 --- a/ui/src/components/chat/utils/sessionLauncher.ts +++ b/ui/src/components/chat/utils/sessionLauncher.ts @@ -1,3 +1,4 @@ +import type { ChatModelSelection } from '../hooks/useChatProviderState'; import type { Project, ProjectSession } from '../../../types/app'; import type { ChatAttachment, ChatRunMode, PilotDeckSettings, PermissionMode } from '../types/types'; import { getPilotDeckSettings, safeLocalStorage } from './chatStorage'; @@ -17,6 +18,7 @@ type StartSessionOptions = { thinking?: unknown; sessionSummary?: string | null; toolsSettings?: PilotDeckSettings; + modelSelection?: ChatModelSelection; modelOverride?: { mode: 'model'; provider: string; @@ -131,6 +133,7 @@ export function startSessionCommand({ sessionSummary, toolsSettings = getPilotDeckSettings(), modelOverride, + modelSelection, images, attachments, uploadedAttachments, @@ -159,6 +162,7 @@ export function startSessionCommand({ ...(thinking ? { thinking } : {}), sessionSummary, ...(modelOverride ? { modelOverride } : {}), + ...(modelSelection ? { modelSelection: { ...modelSelection } } : {}), ...(typeof userVisibleInput === 'string' && userVisibleInput.trim() ? { userVisibleInput: userVisibleInput.trim() } : {}), @@ -196,6 +200,7 @@ export function regenerateLastSessionCommand({ attachments, uploadedAttachments, displayAttachments, + modelSelection, workspaceCwd, syntheticMessages, }: RegenerateLastSessionOptions): void { @@ -228,6 +233,7 @@ export function regenerateLastSessionCommand({ ...(Array.isArray(uploadedAttachments) && uploadedAttachments.length > 0 ? { uploadedAttachments } : {}), + ...(modelSelection ? { modelSelection: { ...modelSelection } } : {}), ...(Array.isArray(displayAttachments) ? { displayAttachments } : {}), ...(resolvedWorkspaceCwd ? { workspaceCwd: resolvedWorkspaceCwd } : {}), ...(Array.isArray(syntheticMessages) && syntheticMessages.length > 0 diff --git a/ui/src/i18n/locales/en/chat.json b/ui/src/i18n/locales/en/chat.json index 1be520d68..e30638b33 100644 --- a/ui/src/i18n/locales/en/chat.json +++ b/ui/src/i18n/locales/en/chat.json @@ -348,6 +348,7 @@ "askDescription": "Only answers questions without modifying files" }, "models": { + "running": "Running: {{model}}", "select": "Select model", "auto": "Auto", "change": "Select model", diff --git a/ui/src/i18n/locales/zh-CN/chat.json b/ui/src/i18n/locales/zh-CN/chat.json index b1a86a9f9..6920e85e9 100644 --- a/ui/src/i18n/locales/zh-CN/chat.json +++ b/ui/src/i18n/locales/zh-CN/chat.json @@ -331,6 +331,7 @@ "askDescription": "仅回答问题,不修改文件" }, "models": { + "running": "本轮模型:{{model}}", "select": "选择模型", "auto": "自动", "change": "选择模型", From bedf3aee9a65df5a264c1f8a625c93fcb5bbb348 Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Sat, 5 Sep 2026 19:15:37 +0800 Subject: [PATCH 2/4] fix(chat): scope model drafts and preserve built-in commands --- ui/e2e/fixtures/model-selection.jsx | 46 +++++++-- ui/e2e/model-selection.spec.mjs | 51 ++++++++++ ui/server/pilotdeck-bridge.js | 4 +- .../chat-v2/ChatInterfaceV2.queue.test.tsx | 1 + ui/src/components/chat-v2/ChatInterfaceV2.tsx | 8 +- ui/src/components/chat-v2/ComposerV2.tsx | 7 +- .../useChatComposerState.attachments.test.tsx | 11 ++- .../useChatComposerState.commands.test.tsx | 72 ++++++++++++++ .../chat/hooks/useChatComposerState.ts | 18 +++- .../chat/hooks/useChatModelSelection.test.tsx | 93 ++++++++++++++++++- .../chat/hooks/useChatModelSelection.ts | 89 ++++++++++++++---- .../components/chat/utils/composerCommand.ts | 15 +++ 12 files changed, 377 insertions(+), 38 deletions(-) create mode 100644 ui/src/components/chat/hooks/useChatComposerState.commands.test.tsx create mode 100644 ui/src/components/chat/utils/composerCommand.ts diff --git a/ui/e2e/fixtures/model-selection.jsx b/ui/e2e/fixtures/model-selection.jsx index 9465a41b0..9fb14481a 100644 --- a/ui/e2e/fixtures/model-selection.jsx +++ b/ui/e2e/fixtures/model-selection.jsx @@ -2,7 +2,8 @@ import React, { useCallback, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import Composer from '../../src/components/chat-v2/ComposerV2'; import { useChatModelSelection } from '../../src/components/chat/hooks/useChatModelSelection'; -import { startSessionCommand } from '../../src/components/chat/utils/sessionLauncher'; +import { useChatComposerState } from '../../src/components/chat/hooks/useChatComposerState'; +import { startSessionCommand, createUserTurnRunId } from '../../src/components/chat/utils/sessionLauncher'; import i18n from '../../src/i18n/config'; import '../../src/index.css'; i18n.changeLanguage('en'); @@ -29,15 +30,17 @@ function App() { const send = (event) => { event.preventDefault(); if (!model.isModelSelectionReady) return; + const runId = createUserTurnRunId(); + model.registerModelSelectionSubmission(runId); startSessionCommand({ selectedProject: { name: 'fixture', path: projectKey }, sessionId, - command: input, modelSelection: model.modelSelection, + command: input, modelSelection: model.modelSelection, runId, sendMessage: (message) => { setFrame(message); setLoading(true); setInput(''); void fetch('/api/test-submit', { method: 'POST', body: JSON.stringify(message) }).then((r) => r.json()).then((accepted) => { - if (!sessionId) listener.current({ kind: 'session_created', projectKey, newSessionId: accepted.sessionId }); + if (!sessionId) listener.current({ kind: 'session_created', projectKey, newSessionId: accepted.sessionId, runId }); setSession(accepted.sessionId); - listener.current({ type: 'model-selection-saved', sessionId: accepted.sessionId, selection: message.options.modelSelection }); + listener.current({ type: 'model-selection-saved', sessionId: accepted.sessionId, selection: message.options.modelSelection, runId }); const running = message.options.modelSelection.mode === 'auto' ? { provider: 'zeta', model: 'configured' } : message.options.modelSelection; listener.current({ type: 'model-selection-changed', sessionId: accepted.sessionId, modelProvider: running.provider, model: running.model, runId: 'run-1' }); @@ -47,7 +50,7 @@ function App() { }); }; return
- + {JSON.stringify(model.modelSelection)} @@ -59,4 +62,35 @@ function App() { runningModel={model.runningModels[sessionId]}/>
; } -createRoot(document.getElementById('root')).render(); + +const commandProject = { name: 'fixture', fullPath: '/general' }; +function CommandApp() { + const [settingsOpened, setSettingsOpened] = useState(0); + const [messages, setMessages] = useState([]); + const [sent, setSent] = useState(0); + const pendingViewSessionRef = useRef(null); + const ready = new URLSearchParams(location.search).get('ready') === 'true'; + const composer = useChatComposerState({ + selectedProject: commandProject, selectedSession: null, currentSessionId: null, + model: 'missing/model', modelSelection: { mode: 'model', provider: 'missing', model: 'model' }, + isModelSelectionReady: ready, permissionMode: 'default', cycleRunMode: noop, isLoading: false, + canAbortSession: false, tokenBudget: null, sendMessage: () => { setSent((n) => n + 1); return true; }, + onShowSettings: () => setSettingsOpened((n) => n + 1), pendingViewSessionRef, scrollToBottom: noop, + addMessage: (message) => setMessages((previous) => [...previous, message]), clearMessages: noop, rewindMessages: noop, + setIsLoading: noop, setCanAbortSession: noop, setIsAborting: noop, setClaudeStatus: noop, setPilotDeckStatus: noop, + setIsUserScrolledUp: noop, pendingPermissionRequests: [], setPendingPermissionRequests: noop, + }); + return
+ {settingsOpened} + {composer.slashCommandsCount} + {sent} + {JSON.stringify(messages)} + +
; +} +createRoot(document.getElementById('root')).render(new URLSearchParams(location.search).has('commands') ? : ); diff --git a/ui/e2e/model-selection.spec.mjs b/ui/e2e/model-selection.spec.mjs index cc56a1449..e6b525e15 100644 --- a/ui/e2e/model-selection.spec.mjs +++ b/ui/e2e/model-selection.spec.mjs @@ -80,3 +80,54 @@ test('unavailable configured models block sending and the picker still allows re await page.getByRole('button', { name: 'first', exact: true }).click(); await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeEnabled(); }); + +test('a new conversation inherits the latest choice made inside the preceding session', async ({ page }) => { + const { submitted } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'General', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(B); +}); + +for (const ready of [false, true]) test(`settings/help commands work with model ready=${ready}, via click and keyboard`, async ({ page }) => { + const executed = []; + await page.route('**/api/**', async (route) => { + const isExecute = new URL(route.request().url()).pathname === '/api/commands/execute'; + const name = isExecute ? route.request().postDataJSON().commandName : ''; + if (isExecute) executed.push(name); + await route.fulfill({ json: isExecute + ? { type: 'builtin', action: name.slice(1), data: { content: 'Fixture help text' } } + : { pinned: [], custom: [], builtIn: ['/config', '/help'].map((name) => ({ name, namespace: 'builtin', type: 'builtin', metadata: { type: 'builtin' } })) } }); + }); + await page.goto(`/e2e/fixtures/model-selection.html?commands=1&ready=${ready}`); + await expect(page.getByTestId('commands-loaded')).toHaveText('2'); + const input = page.getByRole('textbox', { name: 'Message', exact: true }); + const send = page.getByRole('button', { name: 'Send', exact: true }); + // A space completes the command token and closes the suggestion menu. + await input.fill('/config '); + await expect(send).toBeEnabled(); + await send.click(); + await expect.poll(() => executed).toEqual(['/config']); + await expect(page.getByTestId('settings-opened')).toHaveText('1'); + await input.fill('/help '); + await input.press('Enter'); + await expect(page.getByTestId('command-messages')).toContainText('Fixture help text'); + expect(executed).toEqual(['/config', '/help']); + await expect(page.getByTestId('model-requests')).toHaveText('0'); + if (!ready) { + await input.fill('ordinary model request'); + await expect(send).toBeDisabled(); + await input.press('Enter'); + await input.fill('/unknown '); + await expect(send).toBeDisabled(); + await input.press('Enter'); + await expect(page.getByTestId('model-requests')).toHaveText('0'); + } +}); diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index de28da7d1..8813f38ec 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -1518,7 +1518,7 @@ export async function runChatViaGateway( const state = ensureSessionState(sessionKey, projectKey, channelKey); const staleRunId = state.active ? state.runId : undefined; - + const runId = resolveTurnRunId(options?.runId); if (isNewSession) { writer.send( @@ -1529,11 +1529,11 @@ export async function runChatViaGateway( newSessionId: sessionKey, sessionKey, projectKey, + runId, }), ); } - const runId = resolveTurnRunId(options?.runId); if (!staleRunId) { setLocalActiveRun(state, runId); setPendingGatewayRun(state, runId); diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx index c702b541b..e128370d4 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx @@ -44,6 +44,7 @@ vi.mock('../chat/hooks/useChatProviderState', () => ({ modelCatalog: [], modelSelection: { mode: 'auto' }, setModelSelection: vi.fn(async () => undefined), + registerModelSelectionSubmission: vi.fn(() => vi.fn()), isModelCatalogLoading: false, isModelSelectionReady: true, runningModels: {}, diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index 9254ddbdc..3e1c48d14 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -139,6 +139,7 @@ function ChatInterfaceV2({ modelCatalog, modelSelection, setModelSelection, + registerModelSelectionSubmission, isModelCatalogLoading, isModelSelectionReady, runningModels, @@ -286,6 +287,7 @@ function ChatInterfaceV2({ openImagePicker, addAttachmentFiles, handleSubmit, + canSubmitWithoutModel, handleInputChange, insertAtCursor, handleKeyDown, @@ -305,6 +307,7 @@ function ChatInterfaceV2({ model, modelSelection, isModelSelectionReady, + registerModelSelectionSubmission, runMode, permissionMode: effectivePermissionMode, basePermissionMode: permissionMode, @@ -605,6 +608,7 @@ function ChatInterfaceV2({ }); const effectiveThinkingMode = getEffectiveThinkingMode(thinkingMode, thinkingModeAvailability); + const forgetSubmission = registerModelSelectionSubmission(runId); regenerateLastSessionCommand({ sendMessage, selectedProject, @@ -632,12 +636,13 @@ function ChatInterfaceV2({ }], }); - return result; + return result.catch((error) => { forgetSubmission(); throw error; }); }, [ currentSessionId, isModelSelectionReady, modelSelection, modelCatalogError, + registerModelSelectionSubmission, effectivePermissionMode, model, permissionMode, @@ -781,6 +786,7 @@ function ChatInterfaceV2({ modelSelection={modelSelection} isModelCatalogLoading={isModelCatalogLoading} isModelSelectionReady={isModelSelectionReady} + canSubmitWithoutModel={canSubmitWithoutModel} runningModel={runningModels[selectedSession?.id || currentSessionId || ""]?.runId === activeRunId ? runningModels[selectedSession?.id || currentSessionId || ""] : undefined} modelCatalogError={modelCatalogError} diff --git a/ui/src/components/chat-v2/ComposerV2.tsx b/ui/src/components/chat-v2/ComposerV2.tsx index abddce57c..3fdac79ab 100644 --- a/ui/src/components/chat-v2/ComposerV2.tsx +++ b/ui/src/components/chat-v2/ComposerV2.tsx @@ -158,6 +158,7 @@ export type ComposerV2Props = { modelSelection: ChatModelSelection | null; isModelCatalogLoading?: boolean; isModelSelectionReady?: boolean; + canSubmitWithoutModel?: boolean; runningModel?: { provider: string; model: string }; modelCatalogError?: string | null; projectKey: string; @@ -513,6 +514,7 @@ export default function ComposerV2({ modelSelection, isModelCatalogLoading = false, isModelSelectionReady = true, + canSubmitWithoutModel = false, runningModel, modelCatalogError, projectKey, @@ -650,7 +652,8 @@ export default function ComposerV2({ ); const hasUploadingImages = [...uploadingImages.values()].some((percent) => percent < 100); const attachmentLimitError = imageErrors.get(MAX_ATTACHMENTS_ERROR_KEY); - const disabled = !hasDraftContent || isSubmitPending || hasUploadingImages || !isModelSelectionReady; + const modelBlocksSubmission = !isModelSelectionReady && !canSubmitWithoutModel; + const disabled = !hasDraftContent || isSubmitPending || hasUploadingImages || modelBlocksSubmission; const primaryAction = getComposerPrimaryAction({ isLoading, isInputQueuePaused, @@ -765,7 +768,7 @@ export default function ComposerV2({ {!hasBlockingPermissionPanel ? (
{ - if (!isModelSelectionReady) { event.preventDefault(); return; } + if (modelBlocksSubmission) { event.preventDefault(); return; } if (showWorkspacePicker && !workspaceSelectedProject) { event.preventDefault(); setWorkspaceMenuForceOpen(true); diff --git a/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx b/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx index 289b8987a..16a5b41d2 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx +++ b/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx @@ -52,19 +52,22 @@ describe('useChatComposerState attachment submission', () => { }); const sendMessage = vi.fn(() => true); const enqueuePreparedInput = vi.fn(async () => ({ ok: true })); + const registerEarlierChoice = vi.fn(() => vi.fn()); + const registerLaterChoice = vi.fn(() => vi.fn()); const initialChoice = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; const selectedProject = { name: 'demo', displayName: 'Demo', fullPath: '/tmp/demo' }; const selectedSession = queued ? { id: 'web:queue' } : null; - const { result, rerender } = renderHook(({ modelSelection }) => useChatComposerState({ + const { result, rerender } = renderHook(({ modelSelection, registerModelSelectionSubmission }) => useChatComposerState({ selectedProject, selectedSession, currentSessionId: queued ? 'web:queue' : null, model: 'zeta/configured', modelSelection, isModelSelectionReady: true, + registerModelSelectionSubmission, permissionMode: 'default', runMode: 'agent', cycleRunMode: vi.fn(), isLoading: queued, canAbortSession: queued, tokenBudget: null, sendMessage, enqueuePreparedInput, pendingViewSessionRef: { current: null }, scrollToBottom: vi.fn(), addMessage: vi.fn(), clearMessages: vi.fn(), rewindMessages: vi.fn(), setIsLoading: vi.fn(), setCanAbortSession: vi.fn(), setIsAborting: vi.fn(), setClaudeStatus: vi.fn(), setPilotDeckStatus: vi.fn(), setIsUserScrolledUp: vi.fn(), pendingPermissionRequests: [], setPendingPermissionRequests: vi.fn(), - }), { initialProps: { modelSelection: initialChoice as import('./useChatProviderState').ChatModelSelection } }); + }), { initialProps: { modelSelection: initialChoice as import('./useChatProviderState').ChatModelSelection, registerModelSelectionSubmission: registerEarlierChoice } }); act(() => { result.current.setInput('keep my selected model'); result.current.addAttachmentFiles([new File(['content'], 'test.txt', { type: 'text/plain' })]); @@ -72,11 +75,13 @@ describe('useChatComposerState attachment submission', () => { await waitFor(() => expect(result.current.attachedImages).toHaveLength(1)); let submitting!: Promise; act(() => { submitting = result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); - rerender({ modelSelection: { mode: 'auto' } }); + rerender({ modelSelection: { mode: 'auto' }, registerModelSelectionSubmission: registerLaterChoice }); await act(async () => { finishUpload(); await submitting; }); expect(queued ? enqueuePreparedInput : sendMessage).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ modelSelection: initialChoice }), })); + expect(registerEarlierChoice).toHaveBeenCalledOnce(); + expect(registerLaterChoice).not.toHaveBeenCalled(); }); it('does not create an optimistic sidebar session when attachment upload fails', async () => { diff --git a/ui/src/components/chat/hooks/useChatComposerState.commands.test.tsx b/ui/src/components/chat/hooks/useChatComposerState.commands.test.tsx new file mode 100644 index 000000000..1b29f2cd6 --- /dev/null +++ b/ui/src/components/chat/hooks/useChatComposerState.commands.test.tsx @@ -0,0 +1,72 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { useChatComposerState } from './useChatComposerState'; + +const fetchMock = vi.hoisted(() => vi.fn()); +const config = { name: '/config', namespace: 'pinned', type: 'builtin', metadata: { type: 'builtin' } }; +const help = { name: '/help', namespace: 'builtin' }; +const custom = { name: '/summarize', namespace: 'user', type: 'command', path: '/tmp/demo/.pilotdeck/commands/summarize.md' }; +vi.mock('../../../utils/api', () => ({ authenticatedFetch: fetchMock })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +beforeEach(() => { + localStorage.clear(); fetchMock.mockReset(); + fetchMock.mockImplementation(async (url: string, options?: any) => ({ ok: true, json: async () => url === '/api/commands/execute' + ? { type: 'builtin', action: JSON.parse(options.body).commandName.slice(1), data: { content: 'Help content' } } + : { pinned: [config], custom: [custom], builtIn: [help] }, + })); +}); +afterEach(cleanup); + +function setup(isModelSelectionReady: boolean) { + const onShowSettings = vi.fn(); + const sendMessage = vi.fn(() => true); + const addMessage = vi.fn(); + const selectedProject = { name: 'demo', displayName: 'Demo', fullPath: '/tmp/demo' }; + const { result } = renderHook(() => useChatComposerState({ + selectedProject, + selectedSession: null, currentSessionId: null, + model: 'removed/model', modelSelection: { mode: 'model', provider: 'removed', model: 'model' }, isModelSelectionReady, + permissionMode: 'default', runMode: 'agent', cycleRunMode: vi.fn(), isLoading: false, + canAbortSession: false, tokenBudget: null, sendMessage, onShowSettings, + pendingViewSessionRef: { current: null }, scrollToBottom: vi.fn(), addMessage, + clearMessages: vi.fn(), rewindMessages: vi.fn(), setIsLoading: vi.fn(), setCanAbortSession: vi.fn(), + setIsAborting: vi.fn(), setClaudeStatus: vi.fn(), setPilotDeckStatus: vi.fn(), setIsUserScrolledUp: vi.fn(), + pendingPermissionRequests: [], setPendingPermissionRequests: vi.fn(), + })); + return { result, onShowSettings, sendMessage, addMessage }; +} + +it.each([true, false])('allows settings and help while model ready=%s', async (ready) => { + const { result, onShowSettings, sendMessage, addMessage } = setup(ready); + await waitFor(() => expect(result.current.slashCommandsCount).toBe(3)); + act(() => result.current.setInput('/config')); + expect(result.current.canSubmitWithoutModel).toBe(true); + await act(() => result.current.handleSubmit({ preventDefault: vi.fn() } as never)); + expect(onShowSettings).toHaveBeenCalledOnce(); + act(() => result.current.setInput('/help')); + await act(() => result.current.handleSubmit({ preventDefault: vi.fn() } as never)); + expect(addMessage).toHaveBeenCalledWith(expect.objectContaining({ content: 'Help content' })); + expect(sendMessage).not.toHaveBeenCalled(); +}); + +it('allows a selected built-in command chip without a model', async () => { + const { result, onShowSettings, sendMessage } = setup(false); + await waitFor(() => expect(result.current.slashCommandsCount).toBe(3)); + act(() => result.current.handleCommandSelect(config, 0, false)); + expect(result.current.selectedCommands).toHaveLength(1); + expect(result.current.canSubmitWithoutModel).toBe(true); + await act(() => result.current.handleSubmit({ preventDefault: vi.fn() } as never)); + expect(onShowSettings).toHaveBeenCalledOnce(); + expect(sendMessage).not.toHaveBeenCalled(); +}); + +it.each(['ordinary message', '/unknown', '/config-extra', '/summarize'])('blocks %s without a usable model and preserves the input', async (input) => { + const { result, sendMessage } = setup(false); + await waitFor(() => expect(result.current.slashCommandsCount).toBe(3)); + act(() => result.current.setInput(input)); + expect(result.current.canSubmitWithoutModel).toBe(false); + await act(() => result.current.handleSubmit({ preventDefault: vi.fn() } as never)); + expect(sendMessage).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.some(([url]) => url === '/api/commands/execute')).toBe(false); + expect(result.current.input).toBe(input); +}); diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index 3c447b3df..422d958c3 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { getSubmittedCommand, isModelIndependentCommand } from '../utils/composerCommand'; import type { ChangeEvent, ClipboardEvent, @@ -65,6 +66,7 @@ interface UseChatComposerStateArgs { model: string; modelSelection?: ChatModelSelection | null; isModelSelectionReady?: boolean; + registerModelSelectionSubmission?: (runId: string) => () => void; permissionMode: PermissionMode | string; basePermissionMode?: PermissionMode | string; runMode?: string; @@ -262,6 +264,7 @@ export function useChatComposerState({ model, modelSelection, isModelSelectionReady = true, + registerModelSelectionSubmission, permissionMode, basePermissionMode, runMode, @@ -1126,7 +1129,8 @@ export function useChatComposerState({ event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, ) => { event.preventDefault(); - if (!isModelSelectionReady) return; + const submitCommand = getSubmittedCommand(inputValueRef.current, selectedCommands, slashCommands); + if (!isModelSelectionReady && (skipSlashDetectionOnceRef.current || !isModelIndependentCommand(submitCommand))) return; const submittedModelSelection = modelSelection ? { ...modelSelection } : undefined; const currentInput = inputValueRef.current; const submitAttachedImages = attachedImages; @@ -1179,8 +1183,7 @@ export function useChatComposerState({ } return; } else if (trimmedInput.startsWith('/')) { - const commandName = trimmedInput.match(/^(\S+)/)?.[1] ?? trimmedInput; - const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName); + const matchedCommand = submitCommand; if (matchedCommand) { const commandResult = await executeCommand(matchedCommand, trimmedInput); if (!commandResult) return; @@ -1209,6 +1212,9 @@ export function useChatComposerState({ } } + // Custom commands can expand and re-enter this handler. They still need + // a usable model before entering the normal/queued submission path. + if (!isModelSelectionReady) return; const userVisibleInput = currentInput.trim() || (hasDocumentReferences ? referenceOnlyPrompt @@ -1427,6 +1433,7 @@ export function useChatComposerState({ // server atomically decides whether to dispatch now or retain the item, // avoiding upload/session-busy races while preserving the richer PR payload. if (shouldRoutePreparedInputThroughQueue(queueTargetSessionId)) { + const forgetSubmission = registerModelSelectionSubmission?.(runId); const result = await enqueuePreparedInput?.({ id: runId, runId, @@ -1455,6 +1462,7 @@ export function useChatComposerState({ }, }) ?? { ok: false, error: 'Message queue is unavailable.' }; if (!result.ok) { + forgetSubmission?.(); addMessage({ type: 'error', content: result.error || 'Failed to queue this message.', @@ -1480,6 +1488,7 @@ export function useChatComposerState({ // server acknowledgement, reconnecting could execute it twice. Dispatch // first and only expose optimistic session state after the WebSocket has // accepted the frame locally. + const forgetSubmission = registerModelSelectionSubmission?.(runId); const startedSessionId = startSessionCommand({ sendMessage, selectedProject, @@ -1502,6 +1511,7 @@ export function useChatComposerState({ }); if (!startedSessionId) { + forgetSubmission?.(); addMessage({ type: 'error', content: 'Connection lost before the message could be sent. Reconnect and try again.', @@ -1550,6 +1560,7 @@ export function useChatComposerState({ model, modelSelection, isModelSelectionReady, + registerModelSelectionSubmission, currentSessionId, executeCommand, isLoading, @@ -2030,6 +2041,7 @@ export function useChatComposerState({ return { input, + canSubmitWithoutModel: !skipSlashDetectionOnceRef.current && isModelIndependentCommand(getSubmittedCommand(input, selectedCommands, slashCommands)), setInput, textareaRef, inputHighlightRef, diff --git a/ui/src/components/chat/hooks/useChatModelSelection.test.tsx b/ui/src/components/chat/hooks/useChatModelSelection.test.tsx index bae4e64aa..fc39bb830 100644 --- a/ui/src/components/chat/hooks/useChatModelSelection.test.tsx +++ b/ui/src/components/chat/hooks/useChatModelSelection.test.tsx @@ -61,7 +61,8 @@ describe('dialog model selection', () => { await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); await act(() => result.current.setModelSelection(A)); expect(fetchMock.mock.calls.some(([, opts]) => opts?.method === 'PUT')).toBe(false); - act(() => listener({ kind: 'session_created', projectKey: '/general', newSessionId: 'web:created' })); + result.current.registerModelSelectionSubmission('run-created'); + act(() => listener({ kind: 'session_created', projectKey: '/general', newSessionId: 'web:created', runId: 'run-created' })); fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved: B })); rerender({ projectKey: '/general', sessionId: 'web:created' }); await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); @@ -88,13 +89,14 @@ describe('dialog model selection', () => { const first = setup('web:queued'); await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); await act(() => first.result.current.setModelSelection(B)); + first.result.current.registerModelSelectionSubmission('run-b'); act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: A })); first.unmount(); fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved: A })); const { result } = setup('web:queued'); await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); expect(result.current.modelSelection).toEqual(B); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: B })); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: B, runId: 'run-b' })); expect(localStorage.getItem('pending-composer-model-["/general","web:queued"]')).toBeNull(); }); it('ignores delayed old-project responses and blocks during scope changes', async () => { @@ -166,4 +168,91 @@ describe('dialog model selection', () => { expect(result.current.modelSelection).toEqual(A); expect(result.current.isModelSelectionReady).toBe(false); }); + + it('uses the latest project preference after returning to the welcome page repeatedly', async () => { + const { result, rerender } = setup(); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + for (const [index, choice, next] of [[1, A, B], [2, B, A]] as const) { + await act(() => result.current.setModelSelection(choice)); + result.current.registerModelSelectionSubmission(`run-${index}`); + act(() => listener({ kind: 'session_created', projectKey: '/general', newSessionId: `web:${index}`, runId: `run-${index}` })); + expect(Object.keys(localStorage).filter((key) => key.startsWith('pending-composer-model-') && key.includes('welcome:'))).toHaveLength(0); + rerender({ projectKey: '/general', sessionId: `web:${index}` }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => result.current.setModelSelection(next)); + rerender({ projectKey: '/general', sessionId: undefined }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(next); + } + }); + + it('does not let a late creation acknowledgement consume another welcome-page choice', async () => { + const { result, rerender } = setup(); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => result.current.setModelSelection(A)); + result.current.registerModelSelectionSubmission('run-old-welcome'); + rerender({ projectKey: '/general', sessionId: 'web:history' }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + rerender({ projectKey: '/general', sessionId: undefined }); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => result.current.setModelSelection(B)); + act(() => listener({ kind: 'session_created', newSessionId: 'web:old-created', runId: 'run-old-welcome' })); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:old-created', selection: A, runId: 'run-old-welcome' })); + act(() => listener({ type: 'config:reloaded' })); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + expect(result.current.modelSelection).toEqual(B); + }); + + it('matches A → B → A acknowledgements by submission and revision, including replay after refresh', async () => { + fetchMock.mockImplementation(async (url: string, options?: any) => options?.method === 'PUT' + ? json({ error: { code: 'SESSION_BUSY' } }, 409) + : json(url.startsWith('/api/models?') ? catalog : { saved: B })); + const first = setup('web:queue'); + await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); + await act(() => first.result.current.setModelSelection(A)); + first.result.current.registerModelSelectionSubmission('run-old-a'); + await act(() => first.result.current.setModelSelection(B)); + first.result.current.registerModelSelectionSubmission('run-b'); + await act(() => first.result.current.setModelSelection(A)); + const pendingKey = 'pending-composer-model-["/general","web:queue"]'; + const currentDraft = localStorage.getItem(pendingKey); + first.unmount(); + const reloaded = setup('web:queue'); + await waitFor(() => expect(reloaded.result.current.isModelSelectionReady).toBe(true)); + const events = [ + { type: 'model-selection-saved', sessionId: 'web:queue', runId: 'run-old-a', selection: A }, + { type: 'model-selection-saved', sessionId: 'web:queue', runId: 'run-b', selection: B }, + ]; + act(() => listener({ activeTurnMessages: [...events, ...events] })); + expect(localStorage.getItem(pendingKey)).toBe(currentDraft); + reloaded.unmount(); + const last = setup('web:queue'); + await waitFor(() => expect(last.result.current.isModelSelectionReady).toBe(true)); + expect(last.result.current.modelSelection).toEqual(A); + last.result.current.registerModelSelectionSubmission('run-new-a'); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queue', runId: 'run-new-a', selection: A })); + expect(localStorage.getItem(pendingKey)).toBeNull(); + }); + + it('keeps an attachment-delayed submission associated with the choice captured before the await', async () => { + const { result } = setup('web:upload'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + await act(() => result.current.setModelSelection(A)); + const registerEarlierChoice = result.current.registerModelSelectionSubmission; + await act(() => result.current.setModelSelection(B)); + await act(() => result.current.setModelSelection(A)); + registerEarlierChoice('run-upload'); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:upload', runId: 'run-upload', selection: A })); + expect(localStorage.getItem('pending-composer-model-["/general","web:upload"]')).not.toBeNull(); + }); + + it('migrates pending value-only preferences without allowing an uncorrelated acknowledgement to clear them', async () => { + const key = 'pending-composer-model-["/general","web:legacy"]'; + localStorage.setItem(key, JSON.stringify(A)); + const { result } = setup('web:legacy'); + await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); + act(() => listener({ type: 'model-selection-saved', sessionId: 'web:legacy', selection: A, runId: 'unregistered' })); + expect(JSON.parse(localStorage.getItem(key)!).selection).toEqual(A); + expect(result.current.modelSelection).toEqual(A); + }); }); diff --git a/ui/src/components/chat/hooks/useChatModelSelection.ts b/ui/src/components/chat/hooks/useChatModelSelection.ts index 856dda11e..e01e8f3da 100644 --- a/ui/src/components/chat/hooks/useChatModelSelection.ts +++ b/ui/src/components/chat/hooks/useChatModelSelection.ts @@ -1,8 +1,36 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import { modelSelectionId, normalizeModelSelection, parseCatalogItem } from '../../chat-v2/modelCapabilityOptions'; import type { ChatModelCatalogItem, ChatModelSelection } from './useChatProviderState'; import { safeLocalStorage } from '../utils/chatStorage'; +import { createUserTurnRunId } from '../utils/sessionLauncher'; + +type ModelDraft = { id: string; selection: ChatModelSelection }; +type ModelSubmission = { scope: string; projectKey: string; sessionId?: string; draftId?: string }; +const draftKey = (scope: string) => `pending-composer-model-${scope}`; +const submissionKey = (runId: string) => `submitted-composer-model-${runId}`; + +function readDraft(scope: string): ModelDraft | null { + try { + const value = JSON.parse(safeLocalStorage.getItem(draftKey(scope)) || 'null'); + const selection = normalizeModelSelection(value?.selection); + if (selection && typeof value.id === 'string') return { id: value.id, selection }; + // Preserve pending choices made by the previous version; old value-only + // acknowledgements cannot identify or consume their new revision. + const legacy = normalizeModelSelection(value); + if (!legacy) return null; + const draft = { id: createUserTurnRunId(), selection: legacy }; + safeLocalStorage.setItem(draftKey(scope), JSON.stringify(draft)); + return draft; + } catch { return null; } +} + +function readSubmission(runId?: string): ModelSubmission | null { + try { + const value = JSON.parse(safeLocalStorage.getItem(submissionKey(runId || '')) || 'null'); + return typeof value?.scope === 'string' && typeof value?.projectKey === 'string' ? value : null; + } catch { return null; } +} type Subscribe = (listener: (message: any) => void) => () => void; type SelectionState = { @@ -12,6 +40,7 @@ type SelectionState = { loading: boolean; saving: boolean; error: string | null; + draft?: ModelDraft | null; }; function readSelection(key: string): ChatModelSelection | null { @@ -34,10 +63,11 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId subscribe: Subscribe; }) { const sessionId = selectedSessionId?.startsWith('new-session-') ? undefined : selectedSessionId; - const scope = JSON.stringify([projectKey, sessionId || '']); + // Each visit to the welcome page owns a different draft, even in one project. + const scope = useMemo(() => JSON.stringify([projectKey, sessionId || `welcome:${createUserTurnRunId()}`]), [projectKey, sessionId]); const scopeRef = useRef(scope); scopeRef.current = scope; - const drafts = useRef(new Map()); + const drafts = useRef(new Map()); const saveVersions = useRef(new Map()); const pendingSaves = useRef(new Map()); const saveTail = useRef>(Promise.resolve()); @@ -53,18 +83,30 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId for (const event of events) { // Bind a welcome-page choice to its new session before the session GET can finish. // A user may already have selected the next model while the first submission starts. - if (event?.kind === 'session_created' && event.newSessionId && event.projectKey === projectKey && !sessionId) { - const draft = drafts.current.get(scope); + const submission = event?.kind === 'session_created' || event?.type === 'model-selection-saved' + ? readSubmission(event.runId) : null; + if (event?.kind === 'session_created' && event.newSessionId && submission && !submission.sessionId) { + const draft = drafts.current.get(submission.scope) || readDraft(submission.scope); + const createdScope = JSON.stringify([submission.projectKey, event.newSessionId]); if (draft) { - const createdScope = JSON.stringify([projectKey, event.newSessionId]); drafts.current.set(createdScope, draft); - safeLocalStorage.setItem(`pending-composer-model-${createdScope}`, JSON.stringify(draft)); + safeLocalStorage.setItem(draftKey(createdScope), JSON.stringify(draft)); + } + drafts.current.delete(submission.scope); + safeLocalStorage.removeItem(draftKey(submission.scope)); + safeLocalStorage.setItem(submissionKey(event.runId), JSON.stringify({ ...submission, scope: createdScope, sessionId: event.newSessionId })); + } + if (event?.type === 'model-selection-saved' && event.sessionId && submission) { + const acceptedScope = JSON.stringify([submission.projectKey, event.sessionId]); + if (submission.draftId && readDraft(acceptedScope)?.id === submission.draftId) { + safeLocalStorage.removeItem(draftKey(acceptedScope)); } + safeLocalStorage.removeItem(submissionKey(event.runId)); } - if (event?.type === 'model-selection-saved' && event.sessionId) { - const acceptedScope = JSON.stringify([projectKey, event.sessionId]); - const pendingKey = `pending-composer-model-${acceptedScope}`; - if (JSON.stringify(readSelection(pendingKey)) === JSON.stringify(event.selection)) safeLocalStorage.removeItem(pendingKey); + // Failed/cancelled turns may never accept input. Drop their correlation, + // while retaining the user's pending model choice for a future message. + if ((event?.kind === 'complete' || event?.kind === 'interrupted') && event.runId) { + safeLocalStorage.removeItem(submissionKey(event.runId)); } if (event?.type !== 'model-selection-changed' || !event.sessionId) continue; setRunningModels((previous) => ({ @@ -72,7 +114,7 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId [event.sessionId]: { provider: event.modelProvider, model: event.model, runId: event.runId }, })); } - }), [projectKey, sessionId, scope, subscribe]); + }), [subscribe]); useEffect(() => { const controller = new AbortController(); @@ -100,13 +142,13 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId const catalog: ChatModelCatalogItem[] = (Array.isArray(catalogData.items) ? catalogData.items : []) .map(parseCatalogItem).filter((item: ChatModelCatalogItem | null): item is ChatModelCatalogItem => Boolean(item)); // Only explicit user choices populate these keys. Loading a catalog must never write a preference. - const selection = drafts.current.get(scope) - || (sessionId ? readSelection(`pending-composer-model-${scope}`) : null) + const draft = drafts.current.get(scope) || readDraft(scope); + const selection = draft?.selection || normalizeModelSelection(sessionData?.saved) || readSelection(`composer-model-${projectKey}`) || normalizeModelSelection(catalogData.defaultSelection); setState({ - scope, selection, catalog, loading: false, + scope, selection, draft, catalog, loading: false, saving: pendingSaves.current.has(scope), error: selectionError(selection, catalog), }); @@ -121,15 +163,15 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId const setModelSelection = useCallback(async (value: ChatModelSelection) => { const selection = { ...value }; - drafts.current.set(scope, selection); + const draft = { id: createUserTurnRunId(), selection }; + drafts.current.set(scope, draft); safeLocalStorage.setItem(`composer-model-${projectKey}`, JSON.stringify(selection)); - const pendingKey = `pending-composer-model-${scope}`; - if (sessionId) safeLocalStorage.setItem(pendingKey, JSON.stringify(selection)); + safeLocalStorage.setItem(draftKey(scope), JSON.stringify(draft)); const version = (saveVersions.current.get(scope) || 0) + 1; saveVersions.current.set(scope, version); if (sessionId) pendingSaves.current.set(scope, version); setState((previous) => ({ - ...previous, selection, saving: Boolean(sessionId), + ...previous, selection, draft, saving: Boolean(sessionId), error: selectionError(selection, previous.catalog), })); if (!sessionId) return; @@ -166,6 +208,14 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId }, [projectKey, sessionId, scope]); const isCurrent = state.scope === scope; + // This closure captures the rendered choice, just like the submission's + // model snapshot. A later choice during attachment work must not replace it. + const registerModelSelectionSubmission = useCallback((runId: string) => { + const submission: ModelSubmission = { scope, projectKey, sessionId, + draftId: state.scope === scope ? state.draft?.id : undefined }; + safeLocalStorage.setItem(submissionKey(runId), JSON.stringify(submission)); + return () => safeLocalStorage.removeItem(submissionKey(runId)); + }, [scope, projectKey, sessionId, state.scope, state.draft?.id]); return { modelSelection: isCurrent ? state.selection : null, modelCatalog: isCurrent ? state.catalog : [], @@ -173,6 +223,7 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId isModelSelectionReady: isCurrent && !state.loading && !state.saving && !state.error && Boolean(state.selection), modelCatalogError: isCurrent ? state.error : null, setModelSelection, + registerModelSelectionSubmission, runningModels, }; } diff --git a/ui/src/components/chat/utils/composerCommand.ts b/ui/src/components/chat/utils/composerCommand.ts new file mode 100644 index 000000000..2b6594d68 --- /dev/null +++ b/ui/src/components/chat/utils/composerCommand.ts @@ -0,0 +1,15 @@ +import type { SlashCommand } from '../hooks/useSlashCommands'; + +/** Use the same command resolution for button availability and execution. */ +export function getSubmittedCommand(input: string, selected: SlashCommand[], commands: SlashCommand[]): SlashCommand | undefined { + if (selected.length === 1) return selected[0]; + const name = input.trim().match(/^(\/\S+)/)?.[1]; + return name ? commands.find((command) => command.name === name) : undefined; +} + +export function isModelIndependentCommand(command?: SlashCommand): boolean { + if (!command || command.path || command.metadata?.passthrough) return false; + // Pinned/frequent are display groups; keep the underlying command type. + const kind = command.type || command.metadata?.type || command.namespace; + return kind === 'builtin'; +} From 98b7142160e3a648542ac58f575363e03cc30fc3 Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Sat, 5 Sep 2026 20:34:34 +0800 Subject: [PATCH 3/4] fix(chat): share the latest model choice across conversations --- docs/dialog-improvement-api.md | 15 +- docs/trd-dialog-improvement.md | 14 +- src/agent/protocol/input.ts | 2 +- src/cli/createLocalGateway.ts | 3 +- src/gateway/dialog/modelCatalog.ts | 3 +- src/gateway/protocol/types.ts | 5 +- src/web/client/protocol.ts | 2 +- tests/gateway/dialog-model-selection.spec.ts | 18 + ui/e2e/fixtures/model-selection.jsx | 3 +- ui/e2e/model-selection.spec.mjs | 33 +- ui/server/pilotdeck-bridge.js | 4 - ui/server/pilotdeck-bridge.test.js | 4 +- ui/server/routes/models.js | 1 - ui/server/routes/models.test.js | 24 ++ .../chat-v2/ChatInterfaceV2.queue.test.tsx | 1 - ui/src/components/chat-v2/ChatInterfaceV2.tsx | 6 +- .../useChatComposerState.attachments.test.tsx | 11 +- .../chat/hooks/useChatComposerState.ts | 7 - .../chat/hooks/useChatModelSelection.test.tsx | 373 +++++++----------- .../chat/hooks/useChatModelSelection.ts | 225 +---------- .../chat/hooks/useChatProviderState.ts | 2 - .../chat/utils/globalModelSelection.ts | 112 ++++++ .../contexts/WebSocketContext.models.test.tsx | 37 ++ ui/src/contexts/WebSocketContext.tsx | 4 + 24 files changed, 421 insertions(+), 488 deletions(-) create mode 100644 ui/src/components/chat/utils/globalModelSelection.ts create mode 100644 ui/src/contexts/WebSocketContext.models.test.tsx diff --git a/docs/dialog-improvement-api.md b/docs/dialog-improvement-api.md index 7214bd1a1..e43752ca2 100644 --- a/docs/dialog-improvement-api.md +++ b/docs/dialog-improvement-api.md @@ -357,7 +357,7 @@ Query 参数: | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `projectKey` | string | 是 | 项目注册表中的标识 | +| `projectKey` | string | 否 | 仅兼容旧客户端;模型目录为全局配置,不按项目过滤或扫描项目 | | `query` | string | 否 | 按 provider、model、displayName 检索 | | `provider` | string | 否 | 过滤 provider | | `includeAuto` | boolean | 否 | 是否在 Router 可用时返回 auto | @@ -388,6 +388,7 @@ type ModelCatalogItem = { }; type ModelsResponse = { + defaultSelection: { mode: "model"; provider: string; model: string }; items: ModelCatalogItem[]; router: { enabled: boolean; @@ -408,6 +409,10 @@ type ModelsResponse = { - 未返回的能力表示该模型不支持对应参数。 - Router 开启且支持 auto 时,接口可返回 `{ provider: "router", model: "auto" }` 虚拟条目。 +Web Composer 使用同一浏览器、同一站点的全局模型偏好:首次使用 `defaultSelection`,用户手动选择后保存完整模型及参数,跨项目、会话、刷新和标签页复用。系统默认值变化只影响未手动选择的用户;不可用的已选模型保留并提示重新选择。旧项目和会话草稿不参与全局偏好恢复,避免无法确定时间顺序时任意继承旧选择。 + +Composer 不再请求会话模型 GET/PUT 来恢复或保存选择。每条消息仍携带提交时的 `modelSelection` 快照,运行中和排队消息不受后续选择影响。下列会话 API 为兼容已有客户端保留,不决定 Web Composer 的全局选择。模型目录由页面内共享缓存复用,配置重载或 WebSocket 重连后失效。 + ### 6.2 查询会话模型设置 `GET /api/sessions/model?sessionKey=&projectKey=` @@ -500,6 +505,7 @@ Gateway WebSocket 方法:`submit_turn` ```ts type SessionModelOverride = { + mode: "model"; provider: string; model: string; reasoning?: number; @@ -522,21 +528,22 @@ type SubmitTurnRequest = { mode?: "default" | "plan" | "bypassPermissions"; basePermissionMode?: "default" | "plan" | "bypassPermissions"; modelOverride?: SessionModelOverride; + modelSelection?: { mode: "auto" } | SessionModelOverride; runId?: string; }; ``` -`modelOverride` 仅覆盖当前 turn,不修改第 6.3 节保存的会话模型设置。未传 `modelOverride` 时使用保存的会话设置;会话未设置时,Router 开启则使用 auto,否则使用系统默认模型。 +Web Composer 始终提交 `modelSelection` 快照,可明确选择具体模型或 Auto,并随已接收输入记录。`modelOverride` 为已有客户端保留,只覆盖当前 turn;两者不能同时传入。仅当两者都未传时,才使用兼容的会话设置;会话未设置时,Router 开启则使用 auto,否则使用系统默认模型。 服务端校验: -- `modelOverride.provider/model` 必须存在且可用。 +- 显式选择的 `provider/model` 必须存在且可用;Auto 必须有可用 Router。 - reasoning、temperature、speed 必须满足模型 capabilities;speed 使用 `0..1` 的统一数值语义。 - `uploadedAttachments` 必须属于同一 `projectKey`、状态为 completed 且未过期。 - `mode` 和 `basePermissionMode` 必须属于声明枚举。 - 校验失败时不得启动模型调用。 -模型选择优先级:本轮 `modelOverride` > 已保存的会话模型 > Router auto > 系统默认模型。 +模型选择优先级:本轮 `modelSelection` 或 `modelOverride`(互斥)> 兼容会话设置 > Router auto > 系统默认模型。Web Composer 始终发送快照,所以不依赖会话回退。 响应为 Gateway 事件流,新增事件: diff --git a/docs/trd-dialog-improvement.md b/docs/trd-dialog-improvement.md index 8c81e0f9d..24c3d3852 100644 --- a/docs/trd-dialog-improvement.md +++ b/docs/trd-dialog-improvement.md @@ -213,7 +213,9 @@ type UploadedAttachmentRef = { ### 9.1 模型目录 -`GET /api/models?projectKey=&query=&provider=&includeAuto=` +`GET /api/models?query=&provider=&includeAuto=` + +模型目录直接读取全局配置,不枚举项目或会话;旧客户端传入的 `projectKey` 仅为兼容保留。返回 `defaultSelection` 明确指定系统默认模型,目录顺序不影响选择。 返回 provider、model、displayName、available 以及 reasoning(推理强度)、temperature 和可选 speed 的能力声明。对话框统一使用 0..1 的数值语义;每个模型可通过能力声明限制可用范围、步长或枚举值,后端负责把 0..1 值映射为 Provider 所需参数。temperature 和 speed 统一范围为 0..1。官方 OpenAI / Anthropic 模型默认声明 speed;自定义模型需显式 `supportsSpeed: true`,且 Google Provider 当前不支持该字段。目录将 speed 暴露为枚举 `0`(标准)与 `1`(快速)。 @@ -227,6 +229,7 @@ type UploadedAttachmentRef = { ```ts type SessionModelOverride = { + mode: "model"; provider: string; model: string; reasoning?: number; @@ -235,11 +238,16 @@ type SessionModelOverride = { }; type GatewaySubmitTurnInput = ExistingGatewaySubmitTurnInput & { modelOverride?: SessionModelOverride; + modelSelection?: { mode: "auto" } | SessionModelOverride; uploadedAttachments?: UploadedAttachmentRef[]; }; ``` -### 9.3 会话模型状态 +### 9.3 Web 全局偏好与兼容会话模型状态 + +Web Composer 只保存同一浏览器、同一站点内最近一次手动选择的模型及参数,不区分项目或会话,并同步其他标签页。未手动选择时使用系统默认模型;手动选择后,配置默认值变化、会话切换、提交确认和运行结果都不改写偏好。已选模型不可用时提示重新选择,不自动切换。 + +Composer 不再调用下列会话模型读写接口。它在准备附件之前固定每条提交的 `modelSelection`,队列、编辑重发和历史执行记录继续保留各自的快照。会话 API 保留给已有客户端,其保存值不参与 Web 全局选择恢复。 新增会话模型读写接口: @@ -249,7 +257,7 @@ type GatewaySubmitTurnInput = ExistingGatewaySubmitTurnInput & { 保存值写入 session metadata,会话恢复后继续生效。`mode=auto` 仅在 Router 开启时允许;清除设置后,Router 开启则回到 auto,Router 关闭则回到 `agent.model`。 -`submit_turn.modelOverride` 只覆盖本轮,不修改会话保存值。模型解析顺序:本轮 `modelOverride` > 会话保存模型 > Router auto/路由决策 > `agent.model` 默认模型。 +`submit_turn.modelSelection` 为 Web 提交时固定的模型快照,随已接收输入记录;`modelOverride` 只覆盖本轮,不修改会话保存值。两者互斥,均优先于兼容会话设置。只有两者都未传时才使用会话保存模型 > Router auto/路由决策 > `agent.model` 默认模型;Web Composer 不依赖这条回退路径。 `provider/model` 不存在或不可用返回 `INVALID_MODEL_OVERRIDE`;reasoning、temperature 或 speed 不满足模型能力返回 `UNSUPPORTED_MODEL_PARAMETER`。未声明支持的参数不发送给 Provider。speed 必须在 canonical request 入口通过 `0..1` 校验,再由支持 speed 的 Provider adapter 映射为原生字段;Google Provider 不声明或接收 speed。 diff --git a/src/agent/protocol/input.ts b/src/agent/protocol/input.ts index 88cedff42..05d0291be 100644 --- a/src/agent/protocol/input.ts +++ b/src/agent/protocol/input.ts @@ -35,6 +35,6 @@ export type AgentSubmitOptions = { */ syntheticMessages?: import("../../model/index.js").CanonicalMessage[]; modelOverride?: AgentModelOverride; - /** Persisted dialog preference, separate from a one-turn override. */ + /** Submitted model snapshot, recorded for replay and legacy session clients. */ modelSelection?: NonNullable; }; diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index 76862fe59..b78e8f7c7 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -382,8 +382,7 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre return listCommands({ ...input, projectKey }, pilotHome); }, async modelCatalogList(input) { - const projectKey = await dialogProjects.resolveProjectKey(input.projectKey); - return listModelCatalog({ ...input, projectKey }, env); + return listModelCatalog(input, env); }, async sessionModelGet(input) { const projectKey = await dialogProjects.resolveProjectKey(input.projectKey); diff --git a/src/gateway/dialog/modelCatalog.ts b/src/gateway/dialog/modelCatalog.ts index 817a1c0c2..916df09a9 100644 --- a/src/gateway/dialog/modelCatalog.ts +++ b/src/gateway/dialog/modelCatalog.ts @@ -16,8 +16,7 @@ const REASONING_VALUES = new Map([ ]); export function listModelCatalog(input: ModelCatalogListInput, env: NodeJS.ProcessEnv = process.env): ModelCatalogListResult { - if (!input.projectKey?.trim()) throw new DialogGatewayError("PROJECT_NOT_FOUND", "projectKey is required."); - const config = loadPilotConfig({ projectRoot: input.projectKey, env }).config; + const config = loadPilotConfig({ env }).config; const query = input.query?.trim().toLocaleLowerCase() ?? ""; const items: ModelCatalogItem[] = []; for (const [providerId, provider] of Object.entries(config.model.providers)) { diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index 81cbbe0d3..e9c77af05 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -100,7 +100,7 @@ export type GatewaySubmitTurnInput = { uploadedAttachments?: UploadedAttachmentRef[]; /** A one-turn model override. Persisted session preferences are managed separately. */ modelOverride?: ExplicitModelSelection; - /** Dialog choice: used for this turn and saved with accepted input. */ + /** Submitted choice: used for this turn and recorded with accepted input; never updates the Web global preference. */ modelSelection?: SessionModelSelection; runMode?: AgentRunMode; mode?: GatewayMode; @@ -469,7 +469,8 @@ export type ModelCatalogItem = { }; export type ModelCatalogListInput = { - projectKey: string; + /** Accepted for compatibility; the model catalog is global. */ + projectKey?: string; query?: string; provider?: string; includeAuto?: boolean; diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index 45b4729e5..3c6b70153 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -224,7 +224,7 @@ export type WebCommandsListInput = { projectKey: string; query?: string; cursor? export type WebCommandsListResult = { pinned: unknown[]; builtIn: unknown[]; custom: unknown[]; nextCursor?: string }; export type WebExplicitModelSelection = { mode: "model"; provider: string; model: string; reasoning?: number; temperature?: number; speed?: number }; export type WebSessionModelSelection = { mode: "auto" } | WebExplicitModelSelection; -export type WebModelCatalogListInput = { projectKey: string; query?: string; provider?: string; includeAuto?: boolean }; +export type WebModelCatalogListInput = { projectKey?: string; query?: string; provider?: string; includeAuto?: boolean }; export type WebModelCatalogListResult = { defaultSelection: WebExplicitModelSelection; items: unknown[]; diff --git a/tests/gateway/dialog-model-selection.spec.ts b/tests/gateway/dialog-model-selection.spec.ts index 6821b0714..655e78466 100644 --- a/tests/gateway/dialog-model-selection.spec.ts +++ b/tests/gateway/dialog-model-selection.spec.ts @@ -174,3 +174,21 @@ test('invalid and conflicting choices cannot execute or replace the saved prefer assert.equal(f.requests.length, 0); assert.deepEqual(await f.saved(), A); }); + +test('global model catalog needs no project registration and ignores legacy project scope', async (t) => { + const f = await fixture(t); + const global = await f.gateway.modelCatalogList!({ includeAuto: true }); + const unregistered = await f.gateway.modelCatalogList!({ projectKey: '/not-a-registered-project', includeAuto: true }); + assert.deepEqual(unregistered, global); + assert.deepEqual(global.defaultSelection, { mode: 'model', provider: B.provider, model: B.model }); +}); + +test('a new explicit snapshot overrides an old session preference after restart', async (t) => { + const f = await fixture(t); + await f.submit(A); + f.restart(); + await f.submit(B); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.equal(f.requests.at(-1)!.model, B.model); + assert.equal(f.requests.at(-1)!.temperature, B.temperature); +}); diff --git a/ui/e2e/fixtures/model-selection.jsx b/ui/e2e/fixtures/model-selection.jsx index 9fb14481a..a25085802 100644 --- a/ui/e2e/fixtures/model-selection.jsx +++ b/ui/e2e/fixtures/model-selection.jsx @@ -25,13 +25,12 @@ function App() { const [frame, setFrame] = useState(null); const listener = useRef(noop); const subscribe = useCallback((fn) => { listener.current = fn; return noop; }, []); - const model = useChatModelSelection({ projectKey, sessionId, subscribe }); + const model = useChatModelSelection({ subscribe }); const textareaRef = useRef(null), highlightRef = useRef(null); const send = (event) => { event.preventDefault(); if (!model.isModelSelectionReady) return; const runId = createUserTurnRunId(); - model.registerModelSelectionSubmission(runId); startSessionCommand({ selectedProject: { name: 'fixture', path: projectKey }, sessionId, command: input, modelSelection: model.modelSelection, runId, diff --git a/ui/e2e/model-selection.spec.mjs b/ui/e2e/model-selection.spec.mjs index e6b525e15..0f31d5442 100644 --- a/ui/e2e/model-selection.spec.mjs +++ b/ui/e2e/model-selection.spec.mjs @@ -6,12 +6,14 @@ const catalog = { items: [{ id: 'router/auto', provider: 'router', model: 'auto' async function setup(page, { holdCatalog = false, unavailable = false } = {}) { const saved = new Map(); const submitted = []; + const modelRequests = []; let release; const gate = new Promise((r) => { release = r; }); await page.route('**/api/**', async (route) => { const request = route.request(); const url = new URL(request.url()); let result = {}; + if (url.pathname === '/api/models' || url.pathname === '/api/sessions/model') modelRequests.push({ path: url.pathname, query: url.search, method: request.method() }); if (url.pathname === '/api/models') { if (holdCatalog) await gate; result = unavailable ? { ...catalog, items: catalog.items.filter((x) => x.id !== 'zeta/configured') } : catalog; @@ -28,7 +30,7 @@ async function setup(page, { holdCatalog = false, unavailable = false } = {}) { await route.fulfill({ json: result }); }); await page.goto('/e2e/fixtures/model-selection.html'); - return { saved, submitted, release }; + return { saved, submitted, release, modelRequests }; } const choice = async (page) => JSON.parse(await page.getByTestId('selection').textContent()); @@ -46,10 +48,13 @@ test('general and project defaults match configuration and sending is blocked wh }); test('manual selection survives sending, completion and reload', async ({ page }) => { - const { submitted } = await setup(page); + const { submitted, modelRequests } = await setup(page); await expect.poll(() => choice(page)).toEqual(B); await page.getByRole('button', { name: 'configured', exact: true }).click(); await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'Project', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + expect(modelRequests).toEqual([{ path: '/api/models', query: '?includeAuto=true', method: 'GET' }]); await page.getByRole('button', { name: 'Send', exact: true }).click(); await expect.poll(() => submitted.length).toBe(1); expect(submitted[0].options.modelSelection).toEqual(A); @@ -81,11 +86,14 @@ test('unavailable configured models block sending and the picker still allows re await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeEnabled(); }); -test('a new conversation inherits the latest choice made inside the preceding session', async ({ page }) => { - const { submitted } = await setup(page); +test('new conversations and projects share the latest choice without model reloads', async ({ page }) => { + const { submitted, modelRequests } = await setup(page); await expect.poll(() => choice(page)).toEqual(B); await page.getByRole('button', { name: 'configured', exact: true }).click(); await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'Project', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + expect(modelRequests).toEqual([{ path: '/api/models', query: '?includeAuto=true', method: 'GET' }]); await page.getByRole('button', { name: 'Send', exact: true }).click(); await expect.poll(() => submitted.length).toBe(1); await page.getByRole('button', { name: 'Finish', exact: true }).click(); @@ -131,3 +139,20 @@ for (const ready of [false, true]) test(`settings/help commands work with model await expect(page.getByTestId('model-requests')).toHaveText('0'); } }); + + +test('manual model choices synchronize between browser tabs', async ({ page, context }) => { + await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + const other = await context.newPage(); + await setup(other); + await expect.poll(() => choice(other)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await expect.poll(() => choice(other)).toEqual(A); + await other.getByRole('button', { name: 'first', exact: true }).click(); + await other.getByRole('button', { name: 'Auto', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); + await page.reload(); + await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); +}); diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index 8813f38ec..adc5e1d22 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -827,10 +827,6 @@ export function gatewayEventToFrames(event, sessionId, provider) { const base = { sessionId, provider, ...(event.runId ? { runId: event.runId } : {}) }; switch (event.type) { case 'input_accepted': - return event.modelSelection ? [{ - type: 'model-selection-saved', sessionId: base.sessionId, - runId: event.runId, selection: event.modelSelection, - }] : []; case 'steer_unapplied': return []; case 'steer_applied': { diff --git a/ui/server/pilotdeck-bridge.test.js b/ui/server/pilotdeck-bridge.test.js index 40f33ee51..7ed31c52b 100644 --- a/ui/server/pilotdeck-bridge.test.js +++ b/ui/server/pilotdeck-bridge.test.js @@ -911,10 +911,10 @@ describe('dialog model preference frames', () => { } }); - it('forwards accepted preference separately from the model actually executing', () => { + it('reports execution models without broadcasting changes to the composer preference', () => { const sessionId = 'web:s'; const accepted = gatewayEventToFrames({ type: 'input_accepted', runId: 'run-1', modelSelection: { mode: 'auto' } }, sessionId, 'pilotdeck'); - expect(accepted[0]).toMatchObject({ type: 'model-selection-saved', sessionId, selection: { mode: 'auto' } }); + expect(accepted).toEqual([]); const running = gatewayEventToFrames({ type: 'model_selection_changed', runId: 'run-1', provider: 'chosen', model: 'routed', source: 'router' }, sessionId, 'pilotdeck'); expect(running[0]).toMatchObject({ type: 'model-selection-changed', modelProvider: 'chosen', model: 'routed', runId: 'run-1' }); }); diff --git a/ui/server/routes/models.js b/ui/server/routes/models.js index 93940b23d..3f08bc8ff 100644 --- a/ui/server/routes/models.js +++ b/ui/server/routes/models.js @@ -8,7 +8,6 @@ router.get('/', async (req, res) => { const gateway = await getPilotDeckGateway(); if (!(await hasCapability(gateway, 'model_catalog_list'))) return unavailable(res, 'model_catalog_list'); return res.json(await gateway.modelCatalogList({ - projectKey: stringParam(req.query.projectKey), query: optionalString(req.query.query), provider: optionalString(req.query.provider), includeAuto: req.query.includeAuto === undefined ? undefined : String(req.query.includeAuto) !== 'false', diff --git a/ui/server/routes/models.test.js b/ui/server/routes/models.test.js index 106a4a277..0688d952e 100644 --- a/ui/server/routes/models.test.js +++ b/ui/server/routes/models.test.js @@ -9,6 +9,30 @@ afterEach(() => { }); describe('model routes', () => { + it('serves the global catalog without passing project scope to the gateway', async () => { + const modelCatalogList = vi.fn(async () => ({ items: [], defaultSelection: { mode: 'auto' } })); + vi.doMock('../pilotdeck-bridge.js', () => ({ + getPilotDeckGateway: vi.fn(async () => ({ + describeServer: vi.fn(async () => ({ capabilities: ['model_catalog_list'] })), modelCatalogList, + })), + })); + const { default: routes } = await import('./models.js'); + const app = express(); app.use('/api/models', routes); + const server = app.listen(0); + try { + const { port } = server.address(); + for (const suffix of ['', '&projectKey=/old-project']) { + const response = await nativeFetch(`http://127.0.0.1:${port}/api/models?includeAuto=true${suffix}`); + expect(response.status).toBe(200); + await response.json(); + } + expect(modelCatalogList.mock.calls.map(([input]) => input)).toEqual([ + { query: undefined, provider: undefined, includeAuto: true }, + { query: undefined, provider: undefined, includeAuto: true }, + ]); + } finally { await new Promise((resolve) => server.close(resolve)); } + }); + it('returns 422 for unsupported model parameters', async () => { const error = Object.assign(new Error('temperature is unsupported'), { code: 'UNSUPPORTED_MODEL_PARAMETER', diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx index e128370d4..c702b541b 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx @@ -44,7 +44,6 @@ vi.mock('../chat/hooks/useChatProviderState', () => ({ modelCatalog: [], modelSelection: { mode: 'auto' }, setModelSelection: vi.fn(async () => undefined), - registerModelSelectionSubmission: vi.fn(() => vi.fn()), isModelCatalogLoading: false, isModelSelectionReady: true, runningModels: {}, diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index 3e1c48d14..b89a63c09 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -139,7 +139,6 @@ function ChatInterfaceV2({ modelCatalog, modelSelection, setModelSelection, - registerModelSelectionSubmission, isModelCatalogLoading, isModelSelectionReady, runningModels, @@ -307,7 +306,6 @@ function ChatInterfaceV2({ model, modelSelection, isModelSelectionReady, - registerModelSelectionSubmission, runMode, permissionMode: effectivePermissionMode, basePermissionMode: permissionMode, @@ -608,7 +606,6 @@ function ChatInterfaceV2({ }); const effectiveThinkingMode = getEffectiveThinkingMode(thinkingMode, thinkingModeAvailability); - const forgetSubmission = registerModelSelectionSubmission(runId); regenerateLastSessionCommand({ sendMessage, selectedProject, @@ -636,13 +633,12 @@ function ChatInterfaceV2({ }], }); - return result.catch((error) => { forgetSubmission(); throw error; }); + return result; }, [ currentSessionId, isModelSelectionReady, modelSelection, modelCatalogError, - registerModelSelectionSubmission, effectivePermissionMode, model, permissionMode, diff --git a/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx b/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx index 16a5b41d2..289b8987a 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx +++ b/ui/src/components/chat/hooks/useChatComposerState.attachments.test.tsx @@ -52,22 +52,19 @@ describe('useChatComposerState attachment submission', () => { }); const sendMessage = vi.fn(() => true); const enqueuePreparedInput = vi.fn(async () => ({ ok: true })); - const registerEarlierChoice = vi.fn(() => vi.fn()); - const registerLaterChoice = vi.fn(() => vi.fn()); const initialChoice = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; const selectedProject = { name: 'demo', displayName: 'Demo', fullPath: '/tmp/demo' }; const selectedSession = queued ? { id: 'web:queue' } : null; - const { result, rerender } = renderHook(({ modelSelection, registerModelSelectionSubmission }) => useChatComposerState({ + const { result, rerender } = renderHook(({ modelSelection }) => useChatComposerState({ selectedProject, selectedSession, currentSessionId: queued ? 'web:queue' : null, model: 'zeta/configured', modelSelection, isModelSelectionReady: true, - registerModelSelectionSubmission, permissionMode: 'default', runMode: 'agent', cycleRunMode: vi.fn(), isLoading: queued, canAbortSession: queued, tokenBudget: null, sendMessage, enqueuePreparedInput, pendingViewSessionRef: { current: null }, scrollToBottom: vi.fn(), addMessage: vi.fn(), clearMessages: vi.fn(), rewindMessages: vi.fn(), setIsLoading: vi.fn(), setCanAbortSession: vi.fn(), setIsAborting: vi.fn(), setClaudeStatus: vi.fn(), setPilotDeckStatus: vi.fn(), setIsUserScrolledUp: vi.fn(), pendingPermissionRequests: [], setPendingPermissionRequests: vi.fn(), - }), { initialProps: { modelSelection: initialChoice as import('./useChatProviderState').ChatModelSelection, registerModelSelectionSubmission: registerEarlierChoice } }); + }), { initialProps: { modelSelection: initialChoice as import('./useChatProviderState').ChatModelSelection } }); act(() => { result.current.setInput('keep my selected model'); result.current.addAttachmentFiles([new File(['content'], 'test.txt', { type: 'text/plain' })]); @@ -75,13 +72,11 @@ describe('useChatComposerState attachment submission', () => { await waitFor(() => expect(result.current.attachedImages).toHaveLength(1)); let submitting!: Promise; act(() => { submitting = result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); - rerender({ modelSelection: { mode: 'auto' }, registerModelSelectionSubmission: registerLaterChoice }); + rerender({ modelSelection: { mode: 'auto' } }); await act(async () => { finishUpload(); await submitting; }); expect(queued ? enqueuePreparedInput : sendMessage).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ modelSelection: initialChoice }), })); - expect(registerEarlierChoice).toHaveBeenCalledOnce(); - expect(registerLaterChoice).not.toHaveBeenCalled(); }); it('does not create an optimistic sidebar session when attachment upload fails', async () => { diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index 422d958c3..99658a760 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -66,7 +66,6 @@ interface UseChatComposerStateArgs { model: string; modelSelection?: ChatModelSelection | null; isModelSelectionReady?: boolean; - registerModelSelectionSubmission?: (runId: string) => () => void; permissionMode: PermissionMode | string; basePermissionMode?: PermissionMode | string; runMode?: string; @@ -264,7 +263,6 @@ export function useChatComposerState({ model, modelSelection, isModelSelectionReady = true, - registerModelSelectionSubmission, permissionMode, basePermissionMode, runMode, @@ -1433,7 +1431,6 @@ export function useChatComposerState({ // server atomically decides whether to dispatch now or retain the item, // avoiding upload/session-busy races while preserving the richer PR payload. if (shouldRoutePreparedInputThroughQueue(queueTargetSessionId)) { - const forgetSubmission = registerModelSelectionSubmission?.(runId); const result = await enqueuePreparedInput?.({ id: runId, runId, @@ -1462,7 +1459,6 @@ export function useChatComposerState({ }, }) ?? { ok: false, error: 'Message queue is unavailable.' }; if (!result.ok) { - forgetSubmission?.(); addMessage({ type: 'error', content: result.error || 'Failed to queue this message.', @@ -1488,7 +1484,6 @@ export function useChatComposerState({ // server acknowledgement, reconnecting could execute it twice. Dispatch // first and only expose optimistic session state after the WebSocket has // accepted the frame locally. - const forgetSubmission = registerModelSelectionSubmission?.(runId); const startedSessionId = startSessionCommand({ sendMessage, selectedProject, @@ -1511,7 +1506,6 @@ export function useChatComposerState({ }); if (!startedSessionId) { - forgetSubmission?.(); addMessage({ type: 'error', content: 'Connection lost before the message could be sent. Reconnect and try again.', @@ -1560,7 +1554,6 @@ export function useChatComposerState({ model, modelSelection, isModelSelectionReady, - registerModelSelectionSubmission, currentSessionId, executeCommand, isLoading, diff --git a/ui/src/components/chat/hooks/useChatModelSelection.test.tsx b/ui/src/components/chat/hooks/useChatModelSelection.test.tsx index fc39bb830..5fbdea1ec 100644 --- a/ui/src/components/chat/hooks/useChatModelSelection.test.tsx +++ b/ui/src/components/chat/hooks/useChatModelSelection.test.tsx @@ -1,258 +1,179 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useChatModelSelection } from './useChatModelSelection'; -const fetchMock = vi.hoisted(() => vi.fn()); -vi.mock('../../../utils/api', () => ({ authenticatedFetch: fetchMock })); +import { createGlobalModelSelectionStore, GLOBAL_MODEL_SELECTION_KEY } from '../utils/globalModelSelection'; +const mocks = vi.hoisted(() => ({ fetch: vi.fn(), store: null as any })); +vi.mock('../../../utils/api', () => ({ authenticatedFetch: mocks.fetch })); +vi.mock('../utils/globalModelSelection', async (importOriginal) => ({ + ...await importOriginal(), + get globalModelSelectionStore() { return mocks.store; }, +})); const A = { mode: 'model' as const, provider: 'alpha', model: 'first' }; const B = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; const items = [A, B].map((s) => ({ id: `${s.provider}/${s.model}`, provider: s.provider, model: s.model, displayName: s.model, available: true, capabilities: {} })); -const catalog = { items: [{ id: 'router/auto', provider: 'router', model: 'auto', displayName: 'Auto', available: true, capabilities: {} }, ...items], defaultSelection: B, router: { autoAvailable: true } }; +const catalog = { items: [{ id: 'router/auto', provider: 'router', model: 'auto', displayName: 'Auto', available: true, capabilities: {} }, ...items], defaultSelection: B }; const json = (data: unknown, status = 200) => ({ ok: status < 400, status, json: async () => data }); const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; -let listener: (message: any) => void; -const subscribe = (fn: typeof listener) => { listener = fn; return () => {}; }; -const setup = (sessionId?: string, projectKey = '/general') => renderHook( - (props) => useChatModelSelection({ ...props, subscribe }), { initialProps: { sessionId, projectKey } }, -); +const listeners = new Set<(message: any) => void>(); +const subscribe = (fn: (message: any) => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; }; +const emit = (message: any) => act(() => { + // The WebSocket provider invalidates the shared catalog before notifying consumers. + if (message.type === 'config:reloaded' || message.type === 'websocket-reconnected') mocks.store.invalidate(); + for (const listener of listeners) listener(message); +}); +const setup = () => renderHook(() => useChatModelSelection({ subscribe })); +const ready = async (hook: ReturnType) => waitFor(() => expect(hook.result.current.isModelSelectionReady).toBe(true)); +const saved = () => JSON.parse(localStorage.getItem(GLOBAL_MODEL_SELECTION_KEY) || 'null'); beforeEach(() => { - localStorage.clear(); fetchMock.mockReset(); - fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { effective: A })); + localStorage.clear(); mocks.fetch.mockReset(); listeners.clear(); + mocks.fetch.mockResolvedValue(json(catalog)); + mocks.store = createGlobalModelSelectionStore(); }); afterEach(cleanup); -describe('dialog model selection', () => { - it.each(['/general', '/project'])('uses configured default in %s, even with Auto and another model first', async (project) => { - const { result } = setup(undefined, project); - expect(result.current.isModelSelectionReady).toBe(false); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(B); - expect(localStorage.length).toBe(0); - expect(fetchMock.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false); - }); - it('restores exact saved parameters without borrowing project parameters', async () => { - localStorage.setItem('composer-model-/general', JSON.stringify(B)); - const saved = { ...B, reasoning: 0.2, temperature: undefined, speed: undefined }; - fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved, effective: A })); - const { result } = setup('web:saved'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(saved); - }); - it('preserves a manual choice when a new session receives its permanent ID', async () => { - const { result, rerender } = setup(); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => result.current.setModelSelection(A)); - rerender({ projectKey: '/general', sessionId: 'web:created' }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(A); +describe('global model selection', () => { + it('uses the configured default, never the first catalog row, without saving it as a manual preference', async () => { + const hook = setup(); + expect(hook.result.current.isModelSelectionReady).toBe(false); + await ready(hook); + expect(hook.result.current.modelSelection).toEqual(B); + expect(saved()).toBeNull(); + expect(mocks.fetch).toHaveBeenCalledExactlyOnceWith('/api/models?includeAuto=true'); }); - it('retains unavailable choices and lets the user select a replacement', async () => { - const unavailable = { ...A, model: 'removed' }; - localStorage.setItem('composer-model-/general', JSON.stringify(unavailable)); - const { result } = setup(); - await waitFor(() => expect(result.current.isModelCatalogLoading).toBe(false)); - expect(result.current.modelSelection).toEqual(unavailable); - expect(result.current.isModelSelectionReady).toBe(false); - expect(result.current.modelCatalogError).toContain('alpha/removed'); - await act(() => result.current.setModelSelection(B)); - expect(result.current.isModelSelectionReady).toBe(true); - }); - it('keeps a later welcome-page choice when the first submission receives a permanent ID', async () => { - const { result, rerender } = setup('new-session-123'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => result.current.setModelSelection(A)); - expect(fetchMock.mock.calls.some(([, opts]) => opts?.method === 'PUT')).toBe(false); - result.current.registerModelSelectionSubmission('run-created'); - act(() => listener({ kind: 'session_created', projectKey: '/general', newSessionId: 'web:created', runId: 'run-created' })); - fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved: B })); - rerender({ projectKey: '/general', sessionId: 'web:created' }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(A); + + it('restores exact global parameters and ignores all old project/session drafts', async () => { + const choice = { ...B, reasoning: 0.2, temperature: undefined, speed: undefined }; + localStorage.setItem(GLOBAL_MODEL_SELECTION_KEY, JSON.stringify(choice)); + localStorage.setItem('composer-model-/project', JSON.stringify(A)); + localStorage.setItem('pending-composer-model-["/project","web:old"]', JSON.stringify({ selection: A, id: 'old' })); + const hook = setup(); + await ready(hook); + expect(hook.result.current.modelSelection).toEqual(choice); + expect(mocks.fetch).toHaveBeenCalledTimes(1); }); - it('keeps sending blocked when returning to a session whose save is still pending', async () => { - const save = deferred>(); - fetchMock.mockImplementation(async (url: string, opts?: any) => opts?.method === 'PUT' - ? save.promise : json(url.startsWith('/api/models?') ? catalog : { saved: B })); - const { result, rerender } = setup('web:saved'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - let saving!: Promise; - await act(async () => { saving = result.current.setModelSelection(A); }); - rerender({ projectKey: '/other', sessionId: 'web:other' }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - rerender({ projectKey: '/general', sessionId: 'web:saved' }); - await waitFor(() => expect(result.current.isModelCatalogLoading).toBe(false)); - expect(result.current.modelSelection).toEqual(A); - expect(result.current.isModelSelectionReady).toBe(false); - await act(async () => { save.resolve(json({})); await saving; }); - expect(result.current.isModelSelectionReady).toBe(true); + + it('shares the latest choice across mounted composers and remounts without another request or loading gap', async () => { + const first = setup(), second = setup(); + await ready(first); await ready(second); + await act(() => first.result.current.setModelSelection(A)); + expect(second.result.current.modelSelection).toEqual(A); + first.unmount(); second.unmount(); + const next = setup(); + expect(next.result.current.modelSelection).toEqual(A); + expect(next.result.current.isModelSelectionReady).toBe(true); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + expect(saved()).toEqual(A); }); - it('does not let acceptance of an older queued choice erase the next-message draft on reload', async () => { - const first = setup('web:queued'); - await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); - await act(() => first.result.current.setModelSelection(B)); - first.result.current.registerModelSelectionSubmission('run-b'); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: A })); + + it('persists the latest manual choice across a full application reload without a session GET or PUT', async () => { + const first = setup(); await ready(first); + await act(() => first.result.current.setModelSelection(A)); first.unmount(); - fetchMock.mockImplementation(async (url: string) => json(url.startsWith('/api/models?') ? catalog : { saved: A })); - const { result } = setup('web:queued'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(B); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queued', selection: B, runId: 'run-b' })); - expect(localStorage.getItem('pending-composer-model-["/general","web:queued"]')).toBeNull(); - }); - it('ignores delayed old-project responses and blocks during scope changes', async () => { - const old = deferred>(); - fetchMock.mockImplementationOnce(() => old.promise); - const { result, rerender } = setup(); - rerender({ projectKey: '/project', sessionId: undefined }); - expect(result.current.modelSelection).toBeNull(); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => { old.resolve(json({ ...catalog, defaultSelection: A })); }); - expect(result.current.modelSelection).toEqual(B); + mocks.store = createGlobalModelSelectionStore(); + const next = setup(); await ready(next); + expect(next.result.current.modelSelection).toEqual(A); + expect(mocks.fetch.mock.calls.every(([url]) => url === '/api/models?includeAuto=true')).toBe(true); }); - it('keeps a user choice when an earlier config reload finishes later', async () => { - const { result } = setup(); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - const reload = deferred>(); - fetchMock.mockImplementationOnce(() => reload.promise); - act(() => listener({ type: 'config:reloaded' })); - await act(() => result.current.setModelSelection(A)); - await act(() => { reload.resolve(json(catalog)); }); - expect(result.current.modelSelection).toEqual(A); - expect(result.current.isModelSelectionReady).toBe(true); - }); - it('refreshes untouched defaults without saving them as explicit preferences', async () => { - const { result } = setup(); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - fetchMock.mockResolvedValueOnce(json({ ...catalog, defaultSelection: A })); - act(() => listener({ type: 'config:reloaded' })); - await waitFor(() => expect(result.current.modelSelection).toEqual(A)); - expect(localStorage.length).toBe(0); + + it('never lets old A/B queue acknowledgements, replay, completion or session creation overwrite the latest A', async () => { + const hook = setup(); await ready(hook); + for (const choice of [A, B, A]) await act(() => hook.result.current.setModelSelection(choice)); + emit({ activeTurnMessages: [ + { type: 'model-selection-saved', selection: A, sessionId: 'web:old', runId: 'old-a' }, + { type: 'model-selection-saved', selection: B, sessionId: 'web:old', runId: 'old-b' }, + { kind: 'session_created', newSessionId: 'web:created', runId: 'old-b' }, + { kind: 'complete', runId: 'old-b' }, + ] }); + expect(hook.result.current.modelSelection).toEqual(A); + expect(saved()).toEqual(A); + expect(localStorage.length).toBe(1); }); - it('serializes saves and blocks sending until the latest choice is saved', async () => { - const { result } = setup('web:saved'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - const first = deferred>(), second = deferred>(); - fetchMock.mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); - let saveA!: Promise, saveB!: Promise; - await act(async () => { saveA = result.current.setModelSelection(A); }); - await act(async () => { saveB = result.current.setModelSelection(B); }); - expect(result.current.isModelSelectionReady).toBe(false); - expect(fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'PUT')).toHaveLength(1); - await act(async () => { first.resolve(json({})); await saveA; }); - expect(result.current.isModelSelectionReady).toBe(false); - await act(async () => { second.resolve(json({})); await saveB; }); - expect(result.current.modelSelection).toEqual(B); - expect(result.current.isModelSelectionReady).toBe(true); - expect(fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'PUT').map(([, opts]) => JSON.parse(opts.body).selection)).toEqual([A, B]); + + it('keeps Auto selected when execution reports a concrete model', async () => { + const hook = setup(); await ready(hook); + await act(() => hook.result.current.setModelSelection({ mode: 'auto' })); + emit({ type: 'model-selection-changed', sessionId: 'web:busy', modelProvider: A.provider, model: A.model }); + expect(hook.result.current.modelSelection).toEqual({ mode: 'auto' }); + expect(hook.result.current.runningModels['web:busy'].model).toBe(A.model); + expect(saved()).toEqual({ mode: 'auto' }); }); - it('preserves next-turn Auto across refresh while the current turn is busy', async () => { - fetchMock.mockImplementation(async (url: string, opts?: any) => opts?.method === 'PUT' - ? json({ error: { code: 'SESSION_BUSY' } }, 409) - : json(url.startsWith('/api/models?') ? catalog : { saved: A })); - const first = setup('web:busy'); - await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); - await act(() => first.result.current.setModelSelection({ mode: 'auto' })); - first.unmount(); - const { result } = setup('web:busy'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual({ mode: 'auto' }); - act(() => listener({ type: 'model-selection-changed', sessionId: 'web:busy', modelProvider: 'alpha', model: 'first', runId: 'run-old' })); - expect(result.current.modelSelection).toEqual({ mode: 'auto' }); - expect(result.current.runningModels['web:busy'].model).toBe('first'); + + it('preserves an unavailable choice and permits manual recovery', async () => { + const unavailable = { ...A, model: 'removed' }; + localStorage.setItem(GLOBAL_MODEL_SELECTION_KEY, JSON.stringify(unavailable)); + const hook = setup(); + await waitFor(() => expect(hook.result.current.isModelCatalogLoading).toBe(false)); + expect(hook.result.current.modelSelection).toEqual(unavailable); + expect(hook.result.current.isModelSelectionReady).toBe(false); + expect(hook.result.current.modelCatalogError).toContain('alpha/removed'); + await act(() => hook.result.current.setModelSelection(B)); + expect(hook.result.current.isModelSelectionReady).toBe(true); }); - it('reports save failures without enabling sending or reverting the choice', async () => { - const { result } = setup('web:saved'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - fetchMock.mockResolvedValueOnce(json({ error: { message: 'Save failed' } }, 500)); - await act(async () => { await expect(result.current.setModelSelection(A)).rejects.toThrow('Save failed'); }); - expect(result.current.modelSelection).toEqual(A); - expect(result.current.isModelSelectionReady).toBe(false); + + it('updates untouched defaults after configuration changes but never overrides a manual preference', async () => { + const hook = setup(); await ready(hook); + mocks.fetch.mockResolvedValueOnce(json({ ...catalog, defaultSelection: A })); + emit({ type: 'config:reloaded' }); + await waitFor(() => expect(hook.result.current.modelSelection).toEqual(A)); + expect(saved()).toBeNull(); + await act(() => hook.result.current.setModelSelection(A)); + emit({ type: 'config:reloaded' }); await ready(hook); + expect(hook.result.current.modelSelection).toEqual(A); + expect(saved()).toEqual(A); }); - it('uses the latest project preference after returning to the welcome page repeatedly', async () => { - const { result, rerender } = setup(); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - for (const [index, choice, next] of [[1, A, B], [2, B, A]] as const) { - await act(() => result.current.setModelSelection(choice)); - result.current.registerModelSelectionSubmission(`run-${index}`); - act(() => listener({ kind: 'session_created', projectKey: '/general', newSessionId: `web:${index}`, runId: `run-${index}` })); - expect(Object.keys(localStorage).filter((key) => key.startsWith('pending-composer-model-') && key.includes('welcome:'))).toHaveLength(0); - rerender({ projectKey: '/general', sessionId: `web:${index}` }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => result.current.setModelSelection(next)); - rerender({ projectKey: '/general', sessionId: undefined }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(next); - } + it('keeps a choice made during an outstanding catalog refresh and disables removed models after refresh', async () => { + const hook = setup(); await ready(hook); + const pending = deferred>(); + mocks.fetch.mockReturnValueOnce(pending.promise); + emit({ type: 'config:reloaded' }); + await act(() => hook.result.current.setModelSelection(A)); + await act(() => pending.resolve(json({ ...catalog, items: [] }))); + expect(hook.result.current.modelSelection).toEqual(A); + expect(hook.result.current.isModelSelectionReady).toBe(false); + expect(hook.result.current.modelCatalogError).toContain('alpha/first'); }); - it('does not let a late creation acknowledgement consume another welcome-page choice', async () => { - const { result, rerender } = setup(); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => result.current.setModelSelection(A)); - result.current.registerModelSelectionSubmission('run-old-welcome'); - rerender({ projectKey: '/general', sessionId: 'web:history' }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - rerender({ projectKey: '/general', sessionId: undefined }); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => result.current.setModelSelection(B)); - act(() => listener({ kind: 'session_created', newSessionId: 'web:old-created', runId: 'run-old-welcome' })); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:old-created', selection: A, runId: 'run-old-welcome' })); - act(() => listener({ type: 'config:reloaded' })); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - expect(result.current.modelSelection).toEqual(B); + it('retries after a config change during the first load instead of caching a stale response', async () => { + const pending = deferred>(); + mocks.fetch.mockReturnValueOnce(pending.promise); + const hook = setup(); + emit({ type: 'config:reloaded' }); + mocks.fetch.mockResolvedValueOnce(json({ ...catalog, defaultSelection: A })); + await act(() => pending.resolve(json(catalog))); + await ready(hook); + expect(hook.result.current.modelSelection).toEqual(A); + expect(mocks.fetch).toHaveBeenCalledTimes(2); }); - it('matches A → B → A acknowledgements by submission and revision, including replay after refresh', async () => { - fetchMock.mockImplementation(async (url: string, options?: any) => options?.method === 'PUT' - ? json({ error: { code: 'SESSION_BUSY' } }, 409) - : json(url.startsWith('/api/models?') ? catalog : { saved: B })); - const first = setup('web:queue'); - await waitFor(() => expect(first.result.current.isModelSelectionReady).toBe(true)); - await act(() => first.result.current.setModelSelection(A)); - first.result.current.registerModelSelectionSubmission('run-old-a'); - await act(() => first.result.current.setModelSelection(B)); - first.result.current.registerModelSelectionSubmission('run-b'); - await act(() => first.result.current.setModelSelection(A)); - const pendingKey = 'pending-composer-model-["/general","web:queue"]'; - const currentDraft = localStorage.getItem(pendingKey); - first.unmount(); - const reloaded = setup('web:queue'); - await waitFor(() => expect(reloaded.result.current.isModelSelectionReady).toBe(true)); - const events = [ - { type: 'model-selection-saved', sessionId: 'web:queue', runId: 'run-old-a', selection: A }, - { type: 'model-selection-saved', sessionId: 'web:queue', runId: 'run-b', selection: B }, - ]; - act(() => listener({ activeTurnMessages: [...events, ...events] })); - expect(localStorage.getItem(pendingKey)).toBe(currentDraft); - reloaded.unmount(); - const last = setup('web:queue'); - await waitFor(() => expect(last.result.current.isModelSelectionReady).toBe(true)); - expect(last.result.current.modelSelection).toEqual(A); - last.result.current.registerModelSelectionSubmission('run-new-a'); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:queue', runId: 'run-new-a', selection: A })); - expect(localStorage.getItem(pendingKey)).toBeNull(); + it('synchronizes other tabs and restores the current system default if the preference is cleared', async () => { + const hook = setup(); await ready(hook); + localStorage.setItem(GLOBAL_MODEL_SELECTION_KEY, JSON.stringify(A)); + act(() => window.dispatchEvent(new StorageEvent('storage', { key: GLOBAL_MODEL_SELECTION_KEY, storageArea: localStorage }))); + expect(hook.result.current.modelSelection).toEqual(A); + localStorage.removeItem(GLOBAL_MODEL_SELECTION_KEY); + act(() => window.dispatchEvent(new StorageEvent('storage', { key: null, storageArea: localStorage }))); + expect(hook.result.current.modelSelection).toEqual(B); + expect(mocks.fetch).toHaveBeenCalledTimes(1); }); - it('keeps an attachment-delayed submission associated with the choice captured before the await', async () => { - const { result } = setup('web:upload'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - await act(() => result.current.setModelSelection(A)); - const registerEarlierChoice = result.current.registerModelSelectionSubmission; - await act(() => result.current.setModelSelection(B)); - await act(() => result.current.setModelSelection(A)); - registerEarlierChoice('run-upload'); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:upload', runId: 'run-upload', selection: A })); - expect(localStorage.getItem('pending-composer-model-["/general","web:upload"]')).not.toBeNull(); + it('refreshes defaults after a configuration change while the composer was unmounted', async () => { + const first = setup(); await ready(first); first.unmount(); + emit({ type: 'config:reloaded' }); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + mocks.fetch.mockResolvedValueOnce(json({ ...catalog, defaultSelection: A })); + const next = setup(); await ready(next); + expect(next.result.current.modelSelection).toEqual(A); + expect(mocks.fetch).toHaveBeenCalledTimes(2); }); - it('migrates pending value-only preferences without allowing an uncorrelated acknowledgement to clear them', async () => { - const key = 'pending-composer-model-["/general","web:legacy"]'; - localStorage.setItem(key, JSON.stringify(A)); - const { result } = setup('web:legacy'); - await waitFor(() => expect(result.current.isModelSelectionReady).toBe(true)); - act(() => listener({ type: 'model-selection-saved', sessionId: 'web:legacy', selection: A, runId: 'unregistered' })); - expect(JSON.parse(localStorage.getItem(key)!).selection).toEqual(A); - expect(result.current.modelSelection).toEqual(A); + it('recovers catalog failures after reconnect while retaining the user choice', async () => { + localStorage.setItem(GLOBAL_MODEL_SELECTION_KEY, JSON.stringify(A)); + mocks.fetch.mockResolvedValueOnce(json({ error: { message: 'Gateway unavailable' } }, 503)); + const hook = setup(); + await waitFor(() => expect(hook.result.current.modelCatalogError).toBe('Gateway unavailable')); + expect(hook.result.current.isModelSelectionReady).toBe(false); + emit({ type: 'websocket-reconnected' }); await ready(hook); + expect(hook.result.current.modelSelection).toEqual(A); }); }); diff --git a/ui/src/components/chat/hooks/useChatModelSelection.ts b/ui/src/components/chat/hooks/useChatModelSelection.ts index e01e8f3da..d8af6d082 100644 --- a/ui/src/components/chat/hooks/useChatModelSelection.ts +++ b/ui/src/components/chat/hooks/useChatModelSelection.ts @@ -1,113 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { authenticatedFetch } from '../../../utils/api'; -import { modelSelectionId, normalizeModelSelection, parseCatalogItem } from '../../chat-v2/modelCapabilityOptions'; -import type { ChatModelCatalogItem, ChatModelSelection } from './useChatProviderState'; -import { safeLocalStorage } from '../utils/chatStorage'; -import { createUserTurnRunId } from '../utils/sessionLauncher'; - -type ModelDraft = { id: string; selection: ChatModelSelection }; -type ModelSubmission = { scope: string; projectKey: string; sessionId?: string; draftId?: string }; -const draftKey = (scope: string) => `pending-composer-model-${scope}`; -const submissionKey = (runId: string) => `submitted-composer-model-${runId}`; - -function readDraft(scope: string): ModelDraft | null { - try { - const value = JSON.parse(safeLocalStorage.getItem(draftKey(scope)) || 'null'); - const selection = normalizeModelSelection(value?.selection); - if (selection && typeof value.id === 'string') return { id: value.id, selection }; - // Preserve pending choices made by the previous version; old value-only - // acknowledgements cannot identify or consume their new revision. - const legacy = normalizeModelSelection(value); - if (!legacy) return null; - const draft = { id: createUserTurnRunId(), selection: legacy }; - safeLocalStorage.setItem(draftKey(scope), JSON.stringify(draft)); - return draft; - } catch { return null; } -} - -function readSubmission(runId?: string): ModelSubmission | null { - try { - const value = JSON.parse(safeLocalStorage.getItem(submissionKey(runId || '')) || 'null'); - return typeof value?.scope === 'string' && typeof value?.projectKey === 'string' ? value : null; - } catch { return null; } -} +import { useEffect, useState, useSyncExternalStore } from 'react'; +import { globalModelSelectionStore, modelSelectionError } from '../utils/globalModelSelection'; type Subscribe = (listener: (message: any) => void) => () => void; -type SelectionState = { - scope: string; - selection: ChatModelSelection | null; - catalog: ChatModelCatalogItem[]; - loading: boolean; - saving: boolean; - error: string | null; - draft?: ModelDraft | null; -}; - -function readSelection(key: string): ChatModelSelection | null { - try { return normalizeModelSelection(JSON.parse(safeLocalStorage.getItem(key) || 'null')); } - catch { return null; } -} -function selectionError(selection: ChatModelSelection | null, catalog: ChatModelCatalogItem[]) { - if (!selection) return 'No default model is configured. Choose a model.'; - if (!catalog.some((item) => item.id === modelSelectionId(selection) && item.available)) { - return `Selected model is unavailable: ${modelSelectionId(selection)}. Choose another model.`; - } - return null; -} - -/** A dialog choice is distinct from both a catalog row and a running request's model. */ -export function useChatModelSelection({ projectKey, sessionId: selectedSessionId, subscribe }: { - projectKey: string; - sessionId?: string; - subscribe: Subscribe; -}) { - const sessionId = selectedSessionId?.startsWith('new-session-') ? undefined : selectedSessionId; - // Each visit to the welcome page owns a different draft, even in one project. - const scope = useMemo(() => JSON.stringify([projectKey, sessionId || `welcome:${createUserTurnRunId()}`]), [projectKey, sessionId]); - const scopeRef = useRef(scope); - scopeRef.current = scope; - const drafts = useRef(new Map()); - const saveVersions = useRef(new Map()); - const pendingSaves = useRef(new Map()); - const saveTail = useRef>(Promise.resolve()); - const [refresh, setRefresh] = useState(0); - const [state, setState] = useState({ - scope: '', selection: null, catalog: [], loading: true, saving: false, error: null, - }); +/** Only manual choices change the preference; run events describe execution history. */ +export function useChatModelSelection({ subscribe }: { subscribe: Subscribe }) { + const state = useSyncExternalStore(globalModelSelectionStore.subscribe, globalModelSelectionStore.getSnapshot); const [runningModels, setRunningModels] = useState>({}); + useEffect(() => { void globalModelSelectionStore.load(); }, []); useEffect(() => subscribe((message) => { - if (message?.type === 'config:reloaded') setRefresh((value) => value + 1); - const events = [message, ...(message?.activeTurnMessages || [])]; - for (const event of events) { - // Bind a welcome-page choice to its new session before the session GET can finish. - // A user may already have selected the next model while the first submission starts. - const submission = event?.kind === 'session_created' || event?.type === 'model-selection-saved' - ? readSubmission(event.runId) : null; - if (event?.kind === 'session_created' && event.newSessionId && submission && !submission.sessionId) { - const draft = drafts.current.get(submission.scope) || readDraft(submission.scope); - const createdScope = JSON.stringify([submission.projectKey, event.newSessionId]); - if (draft) { - drafts.current.set(createdScope, draft); - safeLocalStorage.setItem(draftKey(createdScope), JSON.stringify(draft)); - } - drafts.current.delete(submission.scope); - safeLocalStorage.removeItem(draftKey(submission.scope)); - safeLocalStorage.setItem(submissionKey(event.runId), JSON.stringify({ ...submission, scope: createdScope, sessionId: event.newSessionId })); - } - if (event?.type === 'model-selection-saved' && event.sessionId && submission) { - const acceptedScope = JSON.stringify([submission.projectKey, event.sessionId]); - if (submission.draftId && readDraft(acceptedScope)?.id === submission.draftId) { - safeLocalStorage.removeItem(draftKey(acceptedScope)); - } - safeLocalStorage.removeItem(submissionKey(event.runId)); - } - // Failed/cancelled turns may never accept input. Drop their correlation, - // while retaining the user's pending model choice for a future message. - if ((event?.kind === 'complete' || event?.kind === 'interrupted') && event.runId) { - safeLocalStorage.removeItem(submissionKey(event.runId)); - } + for (const event of [message, ...(message?.activeTurnMessages || [])]) { if (event?.type !== 'model-selection-changed' || !event.sessionId) continue; setRunningModels((previous) => ({ ...previous, @@ -116,114 +19,14 @@ export function useChatModelSelection({ projectKey, sessionId: selectedSessionId } }), [subscribe]); - useEffect(() => { - const controller = new AbortController(); - const current = () => !controller.signal.aborted && scopeRef.current === scope; - setState((previous) => ({ - scope, selection: previous.scope === scope ? previous.selection : null, - catalog: previous.scope === scope ? previous.catalog : [], loading: true, - saving: pendingSaves.current.has(scope), error: null, - })); - if (!projectKey) return () => controller.abort(); - - const readJson = async (url: string) => { - const response = await authenticatedFetch(url, { signal: controller.signal }); - const data = await response.json(); - if (!response.ok) throw new Error(data?.error?.message || 'Failed to load model selection.'); - return data; - }; - void (async () => { - try { - const [catalogData, sessionData] = await Promise.all([ - readJson(`/api/models?projectKey=${encodeURIComponent(projectKey)}&includeAuto=true`), - sessionId ? readJson(`/api/sessions/model?${new URLSearchParams({ projectKey, sessionKey: sessionId })}`) : null, - ]); - if (!current()) return; - const catalog: ChatModelCatalogItem[] = (Array.isArray(catalogData.items) ? catalogData.items : []) - .map(parseCatalogItem).filter((item: ChatModelCatalogItem | null): item is ChatModelCatalogItem => Boolean(item)); - // Only explicit user choices populate these keys. Loading a catalog must never write a preference. - const draft = drafts.current.get(scope) || readDraft(scope); - const selection = draft?.selection - || normalizeModelSelection(sessionData?.saved) - || readSelection(`composer-model-${projectKey}`) - || normalizeModelSelection(catalogData.defaultSelection); - setState({ - scope, selection, draft, catalog, loading: false, - saving: pendingSaves.current.has(scope), - error: selectionError(selection, catalog), - }); - } catch (error) { - if (current()) setState((previous) => ({ - ...previous, loading: false, error: error instanceof Error ? error.message : String(error), - })); - } - })(); - return () => controller.abort(); - }, [projectKey, sessionId, scope, refresh]); - - const setModelSelection = useCallback(async (value: ChatModelSelection) => { - const selection = { ...value }; - const draft = { id: createUserTurnRunId(), selection }; - drafts.current.set(scope, draft); - safeLocalStorage.setItem(`composer-model-${projectKey}`, JSON.stringify(selection)); - safeLocalStorage.setItem(draftKey(scope), JSON.stringify(draft)); - const version = (saveVersions.current.get(scope) || 0) + 1; - saveVersions.current.set(scope, version); - if (sessionId) pendingSaves.current.set(scope, version); - setState((previous) => ({ - ...previous, selection, draft, saving: Boolean(sessionId), - error: selectionError(selection, previous.catalog), - })); - if (!sessionId) return; - - // Serialize writes: a slower save of A must never overwrite a later choice of B. - const save = saveTail.current.catch(() => {}).then(async () => { - if (saveVersions.current.get(scope) !== version) return; - const response = await authenticatedFetch('/api/sessions/model', { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectKey, sessionKey: sessionId, selection }), - }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - // While a turn runs, this is the next message's draft. Submission will persist it atomically. - if (response.status === 409 && data?.error?.code === 'SESSION_BUSY') return; - throw new Error(data?.error?.message || 'Failed to save model selection.'); - } - // Retain the next-message draft until matching input is accepted. An older - // queued message can still persist its own snapshot after this PUT succeeds. - }); - saveTail.current = save; - try { await save; } - catch (error) { - if (scopeRef.current === scope && saveVersions.current.get(scope) === version) { - setState((previous) => ({ ...previous, error: error instanceof Error ? error.message : String(error) })); - throw error; - } - } finally { - if (pendingSaves.current.get(scope) === version) pendingSaves.current.delete(scope); - if (scopeRef.current === scope && saveVersions.current.get(scope) === version) { - setState((previous) => ({ ...previous, saving: false })); - } - } - }, [projectKey, sessionId, scope]); - - const isCurrent = state.scope === scope; - // This closure captures the rendered choice, just like the submission's - // model snapshot. A later choice during attachment work must not replace it. - const registerModelSelectionSubmission = useCallback((runId: string) => { - const submission: ModelSubmission = { scope, projectKey, sessionId, - draftId: state.scope === scope ? state.draft?.id : undefined }; - safeLocalStorage.setItem(submissionKey(runId), JSON.stringify(submission)); - return () => safeLocalStorage.removeItem(submissionKey(runId)); - }, [scope, projectKey, sessionId, state.scope, state.draft?.id]); + const error = modelSelectionError(state); return { - modelSelection: isCurrent ? state.selection : null, - modelCatalog: isCurrent ? state.catalog : [], - isModelCatalogLoading: !isCurrent || state.loading, - isModelSelectionReady: isCurrent && !state.loading && !state.saving && !state.error && Boolean(state.selection), - modelCatalogError: isCurrent ? state.error : null, - setModelSelection, - registerModelSelectionSubmission, + modelSelection: state.selection, + modelCatalog: state.catalog, + isModelCatalogLoading: state.loading && state.catalog.length === 0, + isModelSelectionReady: !state.loading && !error && Boolean(state.selection), + modelCatalogError: state.loading ? null : error, + setModelSelection: globalModelSelectionStore.select, runningModels, }; } diff --git a/ui/src/components/chat/hooks/useChatProviderState.ts b/ui/src/components/chat/hooks/useChatProviderState.ts index b739c61e3..cdd6e4afa 100644 --- a/ui/src/components/chat/hooks/useChatProviderState.ts +++ b/ui/src/components/chat/hooks/useChatProviderState.ts @@ -121,8 +121,6 @@ export function useChatProviderState({ selectedProject, selectedSession }: UseCh const [modelOptions, setModelOptions] = useState(DEFAULT_MODEL_OPTIONS); const [thinkingModelContext, setThinkingModelContext] = useState(null); const modelState = useChatModelSelection({ - projectKey: selectedProject?.fullPath || selectedProject?.path || '', - sessionId: selectedSession?.id, subscribe, }); diff --git a/ui/src/components/chat/utils/globalModelSelection.ts b/ui/src/components/chat/utils/globalModelSelection.ts new file mode 100644 index 000000000..745b636ef --- /dev/null +++ b/ui/src/components/chat/utils/globalModelSelection.ts @@ -0,0 +1,112 @@ +import { authenticatedFetch } from '../../../utils/api'; +import { modelSelectionId, normalizeModelSelection, parseCatalogItem } from '../../chat-v2/modelCapabilityOptions'; +import type { ChatModelCatalogItem, ChatModelSelection } from '../hooks/useChatProviderState'; +import { safeLocalStorage } from './chatStorage'; + +export const GLOBAL_MODEL_SELECTION_KEY = 'composer-model-global'; + +type State = { + selection: ChatModelSelection | null; + catalog: ChatModelCatalogItem[]; + loading: boolean; + error: string | null; +}; + +function readPreference(): ChatModelSelection | null { + try { return normalizeModelSelection(JSON.parse(safeLocalStorage.getItem(GLOBAL_MODEL_SELECTION_KEY) || 'null')); } + catch { return null; } +} + +/** One browser preference and one catalog, independent of projects and sessions. */ +export function createGlobalModelSelectionStore() { + let preference = readPreference(); + let defaultSelection: ChatModelSelection | null = null; + let state: State = { selection: preference, catalog: [], loading: true, error: null }; + let loaded = false; + let request: Promise | null = null; + let reloadRequested = false; + const listeners = new Set<() => void>(); + const publish = (patch: Partial) => { + state = { ...state, ...patch }; + for (const listener of listeners) listener(); + }; + const syncPreference = () => { + preference = readPreference(); + publish({ selection: preference || defaultSelection }); + }; + const onStorage = (event: StorageEvent) => { + if ((event.key === GLOBAL_MODEL_SELECTION_KEY || event.key === null) && event.storageArea === localStorage) syncPreference(); + }; + + const load = (refresh = false): Promise => { + if (request) { + if (refresh) reloadRequested = true; + return request; + } + if (loaded && !refresh) return Promise.resolve(); + publish({ loading: true, error: null }); + request = (async () => { + // Config changes arriving during a read must not leave an older catalog cached. + do { + reloadRequested = false; + try { + const response = await authenticatedFetch('/api/models?includeAuto=true'); + const data = await response.json(); + if (!response.ok) throw new Error(data?.error?.message || 'Failed to load models.'); + if (reloadRequested) continue; + defaultSelection = normalizeModelSelection(data.defaultSelection); + const catalog: ChatModelCatalogItem[] = (Array.isArray(data.items) ? data.items : []) + .map(parseCatalogItem).filter((item: ChatModelCatalogItem | null): item is ChatModelCatalogItem => Boolean(item)); + loaded = true; + publish({ catalog, selection: preference || defaultSelection, loading: false, error: null }); + } catch (error) { + if (reloadRequested) continue; + loaded = false; + publish({ loading: false, error: error instanceof Error ? error.message : String(error) }); + } + } while (reloadRequested); + })().finally(() => { request = null; }); + return request; + }; + + return { + getSnapshot: () => state, + subscribe(listener: () => void) { + if (listeners.size === 0) { + // A different tab may have changed the preference while no composer was mounted. + syncPreference(); + window.addEventListener('storage', onStorage); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) window.removeEventListener('storage', onStorage); + }; + }, + load, + invalidate() { + loaded = false; + if (listeners.size > 0) void load(true); + else { + publish({ loading: true }); + if (request) reloadRequested = true; + } + }, + async select(value: ChatModelSelection) { + preference = { ...value }; + safeLocalStorage.setItem(GLOBAL_MODEL_SELECTION_KEY, JSON.stringify(preference)); + publish({ selection: preference }); + }, + }; +} + +export const globalModelSelectionStore = createGlobalModelSelectionStore(); + +export function modelSelectionError(state: State) { + if (state.error) return state.error; + if (!state.selection) return 'No default model is configured. Choose a model.'; + if (!state.catalog.some((item) => item.id === modelSelectionId(state.selection) && item.available)) { + return `Selected model is unavailable: ${modelSelectionId(state.selection)}. Choose another model.`; + } + return null; +} diff --git a/ui/src/contexts/WebSocketContext.models.test.tsx b/ui/src/contexts/WebSocketContext.models.test.tsx new file mode 100644 index 000000000..63af88241 --- /dev/null +++ b/ui/src/contexts/WebSocketContext.models.test.tsx @@ -0,0 +1,37 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { WebSocketProvider } from './WebSocketContext'; +const invalidate = vi.hoisted(() => vi.fn()); +vi.mock('../components/chat/utils/globalModelSelection', () => ({ globalModelSelectionStore: { invalidate } })); +vi.mock('../components/auth/context/AuthContext', () => ({ useAuth: () => ({ token: 'fixture' }) })); + +class Socket extends EventTarget { + static OPEN = 1; + static instances: Socket[] = []; + readyState = 1; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + constructor() { super(); Socket.instances.push(this); } + send() {} + close() { this.dispatchEvent(new Event('close')); this.onclose?.(); } +} + +afterEach(() => { + cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); invalidate.mockClear(); Socket.instances = []; +}); + +it('invalidates the catalog on config changes and reconnect even without a mounted composer', () => { + vi.useFakeTimers(); vi.stubGlobal('WebSocket', Socket); + render(
Settings
); + const first = Socket.instances[0]; + act(() => first.onopen?.()); + act(() => first.onmessage?.({ data: JSON.stringify({ type: 'stream_delta' }) })); + expect(invalidate).not.toHaveBeenCalled(); + act(() => first.onmessage?.({ data: JSON.stringify({ type: 'config:reloaded' }) })); + expect(invalidate).toHaveBeenCalledTimes(1); + act(() => first.close()); + act(() => vi.advanceTimersByTime(1000)); + act(() => Socket.instances[1].onopen?.()); + expect(invalidate).toHaveBeenCalledTimes(2); +}); diff --git a/ui/src/contexts/WebSocketContext.tsx b/ui/src/contexts/WebSocketContext.tsx index 85eb7bfb4..dc60da10a 100644 --- a/ui/src/contexts/WebSocketContext.tsx +++ b/ui/src/contexts/WebSocketContext.tsx @@ -1,3 +1,4 @@ +import { globalModelSelectionStore } from '../components/chat/utils/globalModelSelection'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useAuth } from '../components/auth/context/AuthContext'; import { IS_PLATFORM } from '../constants/config'; @@ -131,6 +132,7 @@ const useWebSocketProviderState = (): WebSocketContextType => { websocket.addEventListener('close', () => clearInterval(pingInterval)); if (hasConnectedRef.current) { + globalModelSelectionStore.invalidate(); const reconnectMsg = { type: 'websocket-reconnected', timestamp: Date.now() }; const subs = subscribersRef.current; if (subs.size > 0) { @@ -147,6 +149,8 @@ const useWebSocketProviderState = (): WebSocketContextType => { if (connectIdRef.current !== id) return; try { const data = JSON.parse(event.data); + // Invalidate even while the composer is unmounted (for example in settings). + if (data?.type === 'config:reloaded') globalModelSelectionStore.invalidate(); const subs = subscribersRef.current; if (subs.size > 0) { subs.forEach((sub) => { From e482103916032ebf1bf348e5d035f55d69b4e652 Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Sat, 5 Sep 2026 21:34:21 +0800 Subject: [PATCH 4/4] fix(chat): show response model in hover actions --- src/gateway/client/InProcessGateway.ts | 3 +++ src/gateway/protocol/types.ts | 2 +- src/model/protocol/canonical.ts | 2 ++ src/model/streaming/assembleModelMessage.ts | 4 +++ src/web/client/protocol.ts | 2 +- src/web/client/webMessage.ts | 5 +++- src/web/server/readSessionMessages.ts | 1 + tests/gateway/dialog-model-selection.spec.ts | 12 +++++++++ ui/e2e/fixtures/model-selection.jsx | 15 ++++------- ui/e2e/model-selection.spec.mjs | 26 +++++++++++++++++-- ui/server/pilotdeck-bridge.js | 1 + ui/server/pilotdeck-bridge.test.js | 6 +++++ ui/server/routes/messages.js | 1 + .../chat-v2/ChatInterfaceV2.queue.test.tsx | 1 - ui/src/components/chat-v2/ChatInterfaceV2.tsx | 3 --- ui/src/components/chat-v2/ComposerV2.tsx | 7 ----- ui/src/components/chat-v2/MessageRowV2.tsx | 5 ++++ .../chat-v2/MessageRowV2.userActions.test.tsx | 12 +++++++++ .../components/chat/hooks/useChatMessages.ts | 4 +++ .../chat/hooks/useChatModelSelection.test.tsx | 8 ++---- .../chat/hooks/useChatModelSelection.ts | 21 +++------------ .../chat/hooks/useChatProviderState.ts | 4 +-- .../chat/hooks/useChatRealtimeHandlers.ts | 2 +- ui/src/components/chat/types/types.ts | 2 ++ ui/src/i18n/locales/en/chat.json | 1 - ui/src/i18n/locales/zh-CN/chat.json | 1 - .../stores/useSessionStore.streaming.test.ts | 11 ++++++++ ui/src/stores/useSessionStore.ts | 14 +++++++--- 28 files changed, 116 insertions(+), 60 deletions(-) diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index d6692da63..9fd33a8d2 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -560,6 +560,7 @@ export class InProcessGateway implements Gateway { ? { selection: input.modelSelection?.mode === "model" ? input.modelSelection : input.modelOverride, source: "turn" as const } : { source: "default" as const }; let lastEmittedModel: string | undefined; + let actualRequestModel: string | undefined; if (modelSelection.selection) { const event: GatewayEvent = { type: "model_selection_changed", @@ -623,6 +624,7 @@ export class InProcessGateway implements Gateway { if (event.type === "input_accepted") { await this.commitAcceptedTurnReplacement(input.sessionKey, runId); } + if (event.type === "model_event" && event.event.type === "request_started") actualRequestModel = event.event.model; if (event.type === "model_event" && event.event.type === "request_started" && lastEmittedModel !== `${event.event.provider}\0${event.event.model}`) { const selectionEvent: GatewayEvent = { @@ -637,6 +639,7 @@ export class InProcessGateway implements Gateway { lastEmittedModel = `${event.event.provider}\0${event.event.model}`; } for (const gatewayEvent of mapAgentEvent(event, runId)) { + if (gatewayEvent.type === "assistant_text_delta" && actualRequestModel) gatewayEvent.model = actualRequestModel; if (gatewayEvent.type === "input_accepted" && input.modelSelection) { gatewayEvent.modelSelection = { ...input.modelSelection }; } diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index e9c77af05..7708bfe21 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -187,7 +187,7 @@ export type GatewayEvent = GatewayTurnScopedEventMetadata & ( temperature?: number; speed?: number; } - | { type: "assistant_text_delta"; text: string } + | { type: "assistant_text_delta"; text: string; model?: string } | { type: "assistant_attachment"; attachment: GatewayOutboundAttachment } | { type: "file_artifacts"; artifacts: import("../../session/artifacts/FileArtifact.js").FileArtifact[] } | { type: "assistant_thinking_delta"; text: string } diff --git a/src/model/protocol/canonical.ts b/src/model/protocol/canonical.ts index 390a1a39a..0b98f03a0 100644 --- a/src/model/protocol/canonical.ts +++ b/src/model/protocol/canonical.ts @@ -134,6 +134,8 @@ export type CanonicalContentBlock = | CanonicalMediaReferenceBlock; export type CanonicalMessageMetadata = { + /** Actual model that generated this assistant message. */ + model?: string; /** True for messages injected by the system (e.g. JSON self-correct prompts). */ synthetic?: boolean; /** Synthetic prompt that should be consumed by the next assistant response only. */ diff --git a/src/model/streaming/assembleModelMessage.ts b/src/model/streaming/assembleModelMessage.ts index a6d24f455..1838177ad 100644 --- a/src/model/streaming/assembleModelMessage.ts +++ b/src/model/streaming/assembleModelMessage.ts @@ -18,6 +18,7 @@ import { export type ModelMessageAssemblerState = { content: CanonicalContentBlock[]; textBuffer: string; + model?: string; thinkingBuffer: string; thinkingReasoningContentBuffer: string; thinkingSignature?: string; @@ -67,6 +68,8 @@ export function applyModelEventToAssembler( ): void { switch (event.type) { case "request_started": + state.model = event.model; + return; case "message_start": case "tool_call_start": case "tool_call_delta": @@ -151,6 +154,7 @@ export function assembleAssistantMessage(state: ModelMessageAssemblerState): Ass message: { role: "assistant", content: [...state.content], + ...(state.model ? { metadata: { model: state.model } } : {}), }, finishReason: state.finishReason ?? (state.error ? "error" : "unknown"), hasMessageEnd: state.hasMessageEnd, diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index 3c6b70153..477a91a16 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -50,7 +50,7 @@ export type WebGatewayEvent = WebGatewayEventMetadata & ( | { type: "steer_applied"; itemId: string; message: import("../../model/index.js").CanonicalMessage } | { type: "steer_unapplied"; itemId: string; reason: "turn_ended" } | { type: "model_selection_changed"; provider: string; model: string; source: "turn" | "session" | "router" | "default"; reasoning?: number; temperature?: number; speed?: number } - | { type: "assistant_text_delta"; text: string } + | { type: "assistant_text_delta"; text: string; model?: string } | { type: "assistant_thinking_delta"; text: string } | { type: "file_artifacts"; artifacts: import("../../session/artifacts/FileArtifact.js").FileArtifact[] } | { diff --git a/src/web/client/webMessage.ts b/src/web/client/webMessage.ts index 205e3f12a..896bdd1c0 100644 --- a/src/web/client/webMessage.ts +++ b/src/web/client/webMessage.ts @@ -114,6 +114,8 @@ export type WebMessage = { requestId?: string; ok?: boolean; text?: string; + /** Actual generating model, without the provider prefix. */ + model?: string; contentI18n?: { key: string; params?: Record }; userHintI18n?: { key: string; params?: Record }; images?: Array<{ @@ -207,7 +209,7 @@ export function applyWebGatewayEvent( ...state, messages: state.messages.map((m) => m.id === state.currentAssistantId - ? { ...m, text: `${m.text ?? ""}${event.text}` } + ? { ...m, text: `${m.text ?? ""}${event.text}`, ...(event.model ? { model: event.model } : {}) } : m, ), }; @@ -222,6 +224,7 @@ export function applyWebGatewayEvent( role: "assistant", kind: "text", text: event.text, + ...(event.model ? { model: event.model } : {}), source: "live", }; return { diff --git a/src/web/server/readSessionMessages.ts b/src/web/server/readSessionMessages.ts index bda41032f..4264fa3d6 100644 --- a/src/web/server/readSessionMessages.ts +++ b/src/web/server/readSessionMessages.ts @@ -538,6 +538,7 @@ export function flattenCanonicalMessage( role, kind: "text", text: textBuffer, + ...(role === "assistant" && typeof message.metadata?.model === "string" ? { model: message.metadata.model } : {}), ...(pendingImages.length > 0 ? { images: pendingImages } : {}), ...(context.forkUnsupportedContent ? { diff --git a/tests/gateway/dialog-model-selection.spec.ts b/tests/gateway/dialog-model-selection.spec.ts index 655e78466..227d60c4f 100644 --- a/tests/gateway/dialog-model-selection.spec.ts +++ b/tests/gateway/dialog-model-selection.spec.ts @@ -7,6 +7,7 @@ import type { PilotConfigSnapshot } from '../../src/pilot/config/types.js'; import { createLocalGateway } from '../../src/cli/createLocalGateway.js'; import { createModelRuntime, type CanonicalModelEvent, type CanonicalModelRequest } from '../../src/model/index.js'; import { createAgentProjectSessionStorage, readTranscript, replayTranscriptEntries } from '../../src/session/index.js'; +import { readWebSessionMessages } from '../../src/web/server/readSessionMessages.js'; import type { GatewayEvent, GatewaySubmitTurnInput } from '../../src/gateway/protocol/types.js'; const A = { mode: 'model' as const, provider: 'alpha', model: 'first' }; @@ -192,3 +193,14 @@ test('a new explicit snapshot overrides an old session preference after restart' assert.equal(f.requests.at(-1)!.model, B.model); assert.equal(f.requests.at(-1)!.temperature, B.temperature); }); + + +test('response model survives transcript replay and differs from the next submitted choice', async (t) => { + const f = await fixture(t); + const aEvents = await f.submit(A); + assert.ok(aEvents.some((event) => event.type === 'assistant_text_delta' && event.model === A.model)); + await f.submit(B); + f.restart(); + const history = await readWebSessionMessages({ projectKey: f.home, sessionKey: 'web:model-choice' }, { projectRoot: f.home, pilotHome: f.home }); + assert.deepEqual(history.messages.filter((message) => message.role === 'assistant' && message.kind === 'text').map((message) => message.model), [A.model, B.model]); +}); diff --git a/ui/e2e/fixtures/model-selection.jsx b/ui/e2e/fixtures/model-selection.jsx index a25085802..be57a5da7 100644 --- a/ui/e2e/fixtures/model-selection.jsx +++ b/ui/e2e/fixtures/model-selection.jsx @@ -1,5 +1,6 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import MessageRow from '../../src/components/chat-v2/MessageRowV2'; import Composer from '../../src/components/chat-v2/ComposerV2'; import { useChatModelSelection } from '../../src/components/chat/hooks/useChatModelSelection'; import { useChatComposerState } from '../../src/components/chat/hooks/useChatComposerState'; @@ -23,9 +24,7 @@ function App() { const [input, setInput] = useState('hello'); const [loading, setLoading] = useState(false); const [frame, setFrame] = useState(null); - const listener = useRef(noop); - const subscribe = useCallback((fn) => { listener.current = fn; return noop; }, []); - const model = useChatModelSelection({ subscribe }); + const model = useChatModelSelection(); const textareaRef = useRef(null), highlightRef = useRef(null); const send = (event) => { event.preventDefault(); @@ -37,12 +36,7 @@ function App() { sendMessage: (message) => { setFrame(message); setLoading(true); setInput(''); void fetch('/api/test-submit', { method: 'POST', body: JSON.stringify(message) }).then((r) => r.json()).then((accepted) => { - if (!sessionId) listener.current({ kind: 'session_created', projectKey, newSessionId: accepted.sessionId, runId }); setSession(accepted.sessionId); - listener.current({ type: 'model-selection-saved', sessionId: accepted.sessionId, selection: message.options.modelSelection, runId }); - const running = message.options.modelSelection.mode === 'auto' - ? { provider: 'zeta', model: 'configured' } : message.options.modelSelection; - listener.current({ type: 'model-selection-changed', sessionId: accepted.sessionId, modelProvider: running.provider, model: running.model, runId: 'run-1' }); }); return true; }, @@ -54,11 +48,12 @@ function App() { {JSON.stringify(model.modelSelection)} {JSON.stringify(frame)} + {frame ?
[]} />
: null} setInput(e.target.value)} onSubmit={send} onModelSelectionChange={(choice) => { void model.setModelSelection(choice); }} - runningModel={model.runningModels[sessionId]}/> + />
; } diff --git a/ui/e2e/model-selection.spec.mjs b/ui/e2e/model-selection.spec.mjs index 0f31d5442..47e509950 100644 --- a/ui/e2e/model-selection.spec.mjs +++ b/ui/e2e/model-selection.spec.mjs @@ -64,7 +64,7 @@ test('manual selection survives sending, completion and reload', async ({ page } await expect.poll(() => choice(page)).toEqual(A); }); -test('explicit Auto stays Auto when the server reports a concrete running model', async ({ page }) => { +test('explicit Auto stays selected without a composer execution banner', async ({ page }) => { const { submitted } = await setup(page); await expect.poll(() => choice(page)).toEqual(B); await page.getByRole('button', { name: 'configured', exact: true }).click(); @@ -72,7 +72,7 @@ test('explicit Auto stays Auto when the server reports a concrete running model' await page.getByRole('button', { name: 'Send', exact: true }).click(); await expect.poll(() => submitted.length).toBe(1); expect(submitted[0].options.modelSelection).toEqual({ mode: 'auto' }); - await expect(page.getByRole('status').filter({ hasText: 'Running:' })).toContainText('zeta/configured'); + await expect(page.getByText('Running:', { exact: false })).toHaveCount(0); await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); }); @@ -156,3 +156,25 @@ test('manual model choices synchronize between browser tabs', async ({ page, con await page.reload(); await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); }); + +test('response model appears before time only with the response hover actions', async ({ page }) => { + const { submitted } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + const response = page.getByTestId('response-fixture'); + const actions = response.getByTestId('assistant-message-actions'); + const label = actions.getByTestId('assistant-message-model'); + await expect(label).toHaveText('configured'); + await expect(actions).toHaveCSS('opacity', '0'); + await response.hover(); + await expect(actions).toHaveCSS('opacity', '1'); + expect(await actions.evaluate((el) => el.firstElementChild.dataset.testid)).toBe('assistant-message-model'); + await expect(label).not.toHaveAttribute('title'); + await expect(actions).not.toContainText('zeta/'); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + await expect(label).toHaveText('configured'); + await expect(actions).toHaveCSS('opacity', '0'); +}); diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index adc5e1d22..83ff74b69 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -887,6 +887,7 @@ export function gatewayEventToFrames(event, sessionId, provider) { ...base, kind: 'stream_delta', content: event.text, + ...(event.model ? { model: event.model } : {}), }), ]; case 'assistant_thinking_delta': diff --git a/ui/server/pilotdeck-bridge.test.js b/ui/server/pilotdeck-bridge.test.js index 7ed31c52b..8857fb559 100644 --- a/ui/server/pilotdeck-bridge.test.js +++ b/ui/server/pilotdeck-bridge.test.js @@ -919,3 +919,9 @@ describe('dialog model preference frames', () => { expect(running[0]).toMatchObject({ type: 'model-selection-changed', modelProvider: 'chosen', model: 'routed', runId: 'run-1' }); }); }); + + +it('carries the actual model on assistant text deltas', () => { + const frames = gatewayEventToFrames({ type: 'assistant_text_delta', text: 'Hello', model: 'qwen3.8-27b', runId: 'run-model' }, 'web:model', 'pilotdeck'); + expect(frames[0]).toMatchObject({ kind: 'stream_delta', model: 'qwen3.8-27b', content: 'Hello', runId: 'run-model' }); +}); diff --git a/ui/server/routes/messages.js b/ui/server/routes/messages.js index 6ac520388..36761c45a 100644 --- a/ui/server/routes/messages.js +++ b/ui/server/routes/messages.js @@ -187,6 +187,7 @@ function mapWebMessageToNormalized(message, sessionId) { kind: 'text', role: message.role === 'user' ? 'user' : 'assistant', content: message.text || '', + ...(message.role === 'assistant' && typeof message.model === 'string' ? { model: message.model } : {}), ...(Array.isArray(message.images) && message.images.length > 0 ? { images: message.images.map((image) => image?.data).filter(Boolean) } : {}), diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx index c702b541b..925bac0e3 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx @@ -46,7 +46,6 @@ vi.mock('../chat/hooks/useChatProviderState', () => ({ setModelSelection: vi.fn(async () => undefined), isModelCatalogLoading: false, isModelSelectionReady: true, - runningModels: {}, modelCatalogError: null, thinkingModelContext: null, permissionMode: 'default', diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index b89a63c09..99dcd777e 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -141,7 +141,6 @@ function ChatInterfaceV2({ setModelSelection, isModelCatalogLoading, isModelSelectionReady, - runningModels, modelCatalogError, thinkingModelContext, permissionMode, @@ -783,8 +782,6 @@ function ChatInterfaceV2({ isModelCatalogLoading={isModelCatalogLoading} isModelSelectionReady={isModelSelectionReady} canSubmitWithoutModel={canSubmitWithoutModel} - runningModel={runningModels[selectedSession?.id || currentSessionId || ""]?.runId === activeRunId - ? runningModels[selectedSession?.id || currentSessionId || ""] : undefined} modelCatalogError={modelCatalogError} projectKey={selectedProject?.fullPath || selectedProject?.path || ''} onModelSelectionChange={(selection) => { diff --git a/ui/src/components/chat-v2/ComposerV2.tsx b/ui/src/components/chat-v2/ComposerV2.tsx index 3fdac79ab..b014ec814 100644 --- a/ui/src/components/chat-v2/ComposerV2.tsx +++ b/ui/src/components/chat-v2/ComposerV2.tsx @@ -159,7 +159,6 @@ export type ComposerV2Props = { isModelCatalogLoading?: boolean; isModelSelectionReady?: boolean; canSubmitWithoutModel?: boolean; - runningModel?: { provider: string; model: string }; modelCatalogError?: string | null; projectKey: string; onModelSelectionChange: (selection: ChatModelSelection) => void; @@ -515,7 +514,6 @@ export default function ComposerV2({ isModelCatalogLoading = false, isModelSelectionReady = true, canSubmitWithoutModel = false, - runningModel, modelCatalogError, projectKey, onModelSelectionChange, @@ -749,11 +747,6 @@ export default function ComposerV2({ >
{queueTray} - {isLoading && runningModel ? ( -
- {t('input.models.running', { model: `${runningModel.provider}/${runningModel.model}`, defaultValue: 'Running: {{model}}' })} -
- ) : null} {pendingPermissionRequests.length > 0 ? (
+ {message.model ? ( + + {message.model} + + ) : null} {assistantMessageTime ? (