From d340512cc6bad6ee7dc94404783481edb93c729d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 28 Jul 2026 23:44:19 +0100 Subject: [PATCH] fix(web): keep agent tab title synced with model (#2021) --- .../components/task/session-tab-title.test.ts | 51 +++++++++++- apps/web/components/task/session-tab-title.ts | 4 +- apps/web/components/task/session-tab.tsx | 16 +++- apps/web/e2e/pages/session-page.ts | 5 ++ .../tests/chat/model-selector-error.spec.ts | 54 +++++++++++++ .../workflow/workflow-agent-switch.spec.ts | 78 ++++++++++++------- .../ui/acp-model-configuration-summary.md | 2 + 7 files changed, 177 insertions(+), 33 deletions(-) diff --git a/apps/web/components/task/session-tab-title.test.ts b/apps/web/components/task/session-tab-title.test.ts index 5343abf580..bfca1d8b4d 100644 --- a/apps/web/components/task/session-tab-title.test.ts +++ b/apps/web/components/task/session-tab-title.test.ts @@ -3,6 +3,7 @@ import { resolveSessionTabTitle } from "./session-tab-title"; const SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const SPARK_MODEL_NAME = "GPT-5.3-Codex-Spark"; +const DEFAULT_MODEL_ID = "mock-default"; const PROFILE_LABEL = "GPT-5.5 (medium)"; const baseArgs = { @@ -41,16 +42,60 @@ describe("resolveSessionTabTitle", () => { ).toBe(SPARK_MODEL_NAME); }); - it("keeps the saved profile label when ACP reports its baseline model", () => { + it("uses the authoritative current model over the saved profile label", () => { expect( resolveSessionTabTitle({ ...baseArgs, currentModelId: SPARK_MODEL_ID, modelOptions: [{ id: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }], }), - ).toBe(PROFILE_LABEL); + ).toBe(SPARK_MODEL_NAME); }); + it("uses the live model config value when the generic current model is stale", () => { + expect( + resolveSessionTabTitle({ + ...baseArgs, + currentModelId: DEFAULT_MODEL_ID, + configOptions: [ + { + type: "select", + id: "model", + name: "Model", + currentValue: SPARK_MODEL_ID, + options: [ + { value: DEFAULT_MODEL_ID, name: "Mock Default" }, + { value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }, + ], + }, + ], + }), + ).toBe(SPARK_MODEL_NAME); + }); + + it("prefers a provider-updated model config over a stale active model", () => { + expect( + resolveSessionTabTitle({ + ...baseArgs, + activeModelId: DEFAULT_MODEL_ID, + configOptions: [ + { + type: "select", + id: "model", + name: "Model", + currentValue: SPARK_MODEL_ID, + options: [ + { value: DEFAULT_MODEL_ID, name: "Mock Default" }, + { value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }, + ], + }, + ], + }), + ).toBe(SPARK_MODEL_NAME); + }); +}); + +describe("resolveSessionTabTitle fallbacks", () => { it("includes non-model config selections in the title", () => { expect( resolveSessionTabTitle({ @@ -62,7 +107,7 @@ describe("resolveSessionTabTitle", () => { type: "select", id: "model", name: "Model", - currentValue: "gpt-5.5", + currentValue: SPARK_MODEL_ID, options: [ { value: "gpt-5.5", name: "GPT-5.5" }, { value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }, diff --git a/apps/web/components/task/session-tab-title.ts b/apps/web/components/task/session-tab-title.ts index bbcaa42d8f..20719b9b0d 100644 --- a/apps/web/components/task/session-tab-title.ts +++ b/apps/web/components/task/session-tab-title.ts @@ -42,10 +42,12 @@ function resolveModelTitle( export function resolveSessionTabTitle(args: ResolveSessionTabTitleArgs): string | null { if (args.customName) return args.customName; + const modelConfig = args.configOptions.find(isModelConfigOption); + const currentModelId = modelConfig?.currentValue || args.currentModelId; return ( + resolveModelTitle(args, currentModelId) ?? resolveModelTitle(args, args.activeModelId) ?? args.agentLabel ?? - resolveModelTitle(args, args.currentModelId) ?? resolveModelTitle(args, args.snapshotModel) ); } diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 5c62f4b252..3ceb098db5 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -362,6 +362,18 @@ function SessionTabBody({ return ; } +/** Keeps the Dockview panel title synchronized when Dockview restores a stale title. */ +function useSessionTabTitleSync(api: IDockviewPanelHeaderProps["api"], tabTitle: string | null) { + useEffect(() => { + const syncTitle = () => { + if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle); + }; + syncTitle(); + const disposable = api.onDidTitleChange(syncTitle); + return () => disposable.dispose(); + }, [tabTitle, api]); +} + /** * Custom dockview tab for session panels. * Shows agent logo, index badge, and star for primary; right-click for lifecycle actions. @@ -389,9 +401,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState); - useEffect(() => { - if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle); - }, [tabTitle, api]); + useSessionTabTitleSync(api, tabTitle); const showMultiSessionBadges = sessionCount > 1; // Multi-session tab close means delete, not hide-only. Running/starting sessions are diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 1f9c45c519..1e7510c508 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -1368,6 +1368,11 @@ export class SessionPage { return this.sessionTabByText(text).locator(".tabler-icon-star").first(); } + /** Primary star icon inside a session tab identified by its session ID. */ + primaryStarInSessionTab(sessionId: string): Locator { + return this.sessionTabBySessionId(sessionId).locator(".tabler-icon-star").first(); + } + /** "Move to next step" button in the chat status bar. */ proceedNextStepButton(): Locator { return this.page.getByTestId("proceed-next-step"); diff --git a/apps/web/e2e/tests/chat/model-selector-error.spec.ts b/apps/web/e2e/tests/chat/model-selector-error.spec.ts index 7f01df7f9a..3a77fe387f 100644 --- a/apps/web/e2e/tests/chat/model-selector-error.spec.ts +++ b/apps/web/e2e/tests/chat/model-selector-error.spec.ts @@ -168,6 +168,60 @@ test.describe("Chat model selector — RPC failure", () => { * session snapshot and SSR re-served the pre-change model on reload. */ test.describe("Chat model selector — persistence", () => { + test("agent tab keeps the selected model title after reload", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Agent Tab Model Title Test", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("expected an auto-started session"); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + + const trigger = testPage.getByRole("button", { name: "Session model settings" }); + await trigger.click(); + await testPage.getByRole("option", { name: /Mock Smart/ }).click(); + await expect(trigger).toContainText("Mock Smart", { timeout: 5_000 }); + + const agentTab = session.sessionTabBySessionId(task.session_id); + await expect(agentTab).toContainText("Mock Smart", { timeout: 5_000 }); + await expect + .poll(async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + const metadata = sessions.find((item) => item.id === task.session_id)?.metadata; + const runtime = metadata?.runtime_config as { model?: string } | undefined; + const selector = metadata?.acp_model_state as + | { + config_options?: Array<{ id?: string; current_value?: string }>; + } + | undefined; + const selectorModel = selector?.config_options?.find( + (option) => option.id === "model", + )?.current_value; + return `${runtime?.model}/${selectorModel}`; + }) + .toBe("mock-smart/mock-smart"); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await expect(trigger).toContainText("Mock Smart", { timeout: 15_000 }); + await expect(agentTab).toContainText("Mock Smart", { timeout: 15_000 }); + }); + test("completed turn metadata keeps changed options after a page reload", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts index 4ce7a08b12..45f546c2e4 100644 --- a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts @@ -227,10 +227,14 @@ test.describe("Workflow agent profile switching", () => { const session = new SessionPage(testPage); await expect(session.chat).toBeVisible({ timeout: 15_000 }); - // Wait for the first session tab to appear with the correct profile name - const sessionTabs = testPage.locator('[data-testid^="session-tab-"]'); - await expect(sessionTabs.first()).toBeVisible({ timeout: 30_000 }); - await expect(sessionTabs.first()).toContainText("Profile A", { timeout: 10_000 }); + // The session tab represents the workflow-started Profile A session. Its title + // is model-derived, so identify the tab by stable session ID instead of text. + const initialSessions = await pollSessions(apiClient, task.id, 1, 30_000); + const profileASession = initialSessions.find((item) => item.agent_profile_id === profileA.id); + expect(profileASession).toBeDefined(); + await expect(session.sessionTabBySessionId(profileASession!.id)).toBeVisible({ + timeout: 30_000, + }); // Wait for the agent to be ready (WAITING_FOR_INPUT) before moving for (let i = 0; i < 20; i++) { @@ -443,10 +447,11 @@ test.describe("Workflow agent profile switching", () => { /** * Verifies the StartCreatedSession fix: when a task is created in a step with an - * agent profile override, the session tab shows the step's agent profile name - * (not the workspace default). + * agent profile override, the initial session uses that profile rather than + * the workspace default. Tab titles are model-derived, so the tab is located + * by its stable session ID. */ - test("initial task creation shows step's agent profile in session tab", async ({ + test("initial task creation uses step's agent profile for the session tab", async ({ testPage, apiClient, seedData, @@ -483,10 +488,12 @@ test.describe("Workflow agent profile switching", () => { const session = new SessionPage(testPage); await expect(session.chat).toBeVisible({ timeout: 15_000 }); - // The session tab should show "Profile A" (the step's agent profile name) - const sessionTab = testPage.locator('[data-testid^="session-tab-"]').first(); - await expect(sessionTab).toBeVisible({ timeout: 30_000 }); - await expect(sessionTab).toContainText("Profile A", { timeout: 10_000 }); + const sessions = await pollSessions(apiClient, task.id, 1, 30_000); + const profileASession = sessions.find((item) => item.agent_profile_id === profileA.id); + expect(profileASession).toBeDefined(); + await expect(session.sessionTabBySessionId(profileASession!.id)).toBeVisible({ + timeout: 30_000, + }); }); /** @@ -542,8 +549,10 @@ test.describe("Workflow agent profile switching", () => { const session = new SessionPage(testPage); await expect(session.chat).toBeVisible({ timeout: 15_000 }); - // Wait for Profile A tab to appear and be active - const profileATab = session.sessionTabByText("Profile A"); + const initialSessions = await pollSessions(apiClient, task.id, 1, 60_000); + const profileASession = initialSessions.find((item) => item.agent_profile_id === profileA.id); + expect(profileASession).toBeDefined(); + const profileATab = session.sessionTabBySessionId(profileASession!.id); await expect(profileATab).toBeVisible({ timeout: 60_000 }); // Wait for agent to be ready (WAITING_FOR_INPUT) @@ -560,11 +569,16 @@ test.describe("Workflow agent profile switching", () => { // Move to Step2 — triggers new session with profileB await apiClient.moveTask(task.id, workflow.id, step2.id); - // The new "Profile B" tab should appear and the primary star should move to it - // (the active tab stays on Profile A because the SSR-load pin protects it). - const profileBTab = session.sessionTabByText("Profile B"); + // The new Profile B session tab should appear and the primary star should move to it. + // The active tab stays on Profile A because the SSR-load pin protects it. + const finalSessions = await pollSessions(apiClient, task.id, 2, 60_000); + const profileBSession = finalSessions.find((item) => item.agent_profile_id === profileB.id); + expect(profileBSession).toBeDefined(); + const profileBTab = session.sessionTabBySessionId(profileBSession!.id); await expect(profileBTab).toBeVisible({ timeout: 60_000 }); - await expect(session.primaryStarInTab("Profile B")).toBeVisible({ timeout: 60_000 }); + await expect(session.primaryStarInSessionTab(profileBSession!.id)).toBeVisible({ + timeout: 60_000, + }); }); /** @@ -620,10 +634,15 @@ test.describe("Workflow agent profile switching", () => { // Move to Step1 → auto_start with profileA → on_turn_complete → Step2 (profileB) await apiClient.moveTask(task.id, workflow.id, step1.id); - // After the cascade, the Profile B tab should appear and own the primary star. - const profileBTab = session.sessionTabByText("Profile B"); + // After the cascade, the Profile B session tab should appear and own the primary star. + const finalSessions = await pollSessions(apiClient, task.id, 2, 90_000); + const profileBSession = finalSessions.find((item) => item.agent_profile_id === profileB.id); + expect(profileBSession).toBeDefined(); + const profileBTab = session.sessionTabBySessionId(profileBSession!.id); await expect(profileBTab).toBeVisible({ timeout: 90_000 }); - await expect(session.primaryStarInTab("Profile B")).toBeVisible({ timeout: 90_000 }); + await expect(session.primaryStarInSessionTab(profileBSession!.id)).toBeVisible({ + timeout: 90_000, + }); }); /** @@ -687,12 +706,17 @@ test.describe("Workflow agent profile switching", () => { // Move to Step2 await apiClient.moveTask(task.id, workflow.id, step2.id); - // The Profile B tab should appear and own the primary star (no reload needed). + // The Profile B session tab should appear and own the primary star (no reload needed). // The non-terminal Profile A tab stays user-pinned, so the star moving is // what proves SetPrimarySession's WS broadcast landed. - const profileBTab = session.sessionTabByText("Profile B"); + const finalSessions = await pollSessions(apiClient, task.id, 2, 60_000); + const profileBSession = finalSessions.find((item) => item.agent_profile_id === profileB.id); + expect(profileBSession).toBeDefined(); + const profileBTab = session.sessionTabBySessionId(profileBSession!.id); await expect(profileBTab).toBeVisible({ timeout: 60_000 }); - await expect(session.primaryStarInTab("Profile B")).toBeVisible({ timeout: 60_000 }); + await expect(session.primaryStarInSessionTab(profileBSession!.id)).toBeVisible({ + timeout: 60_000, + }); }); /** @@ -741,8 +765,11 @@ test.describe("Workflow agent profile switching", () => { const session = new SessionPage(testPage); await expect(session.chat).toBeVisible({ timeout: 15_000 }); - // Wait for Profile B session (Step1 override) to be ready - const profileBTab = session.sessionTabByText("Profile B"); + // Wait for the Profile B session (Step1 override) to be ready. + const initialSessions = await pollSessions(apiClient, task.id, 1, 60_000); + const profileBSession = initialSessions.find((item) => item.agent_profile_id === profileB.id); + expect(profileBSession).toBeDefined(); + const profileBTab = session.sessionTabBySessionId(profileBSession!.id); await expect(profileBTab).toBeVisible({ timeout: 60_000 }); await expect .poll( @@ -775,7 +802,6 @@ test.describe("Workflow agent profile switching", () => { ) .toEqual({ profileBPrimary: true, profileBCompleted: false, profileACount: 0 }); await expect(profileBTab).toBeVisible({ timeout: 60_000 }); - await expect(session.sessionTabByText("Profile A")).toHaveCount(0); }); /** diff --git a/docs/specs/ui/acp-model-configuration-summary.md b/docs/specs/ui/acp-model-configuration-summary.md index 918bef0239..469c5c415d 100644 --- a/docs/specs/ui/acp-model-configuration-summary.md +++ b/docs/specs/ui/acp-model-configuration-summary.md @@ -20,6 +20,7 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf - Hovering or focusing the closed task selector shows a compact tooltip containing every currently rendered selector option as provider-supplied `Name: Value` rows, including baseline-matching values. The tooltip contains no descriptions or inferred provider knowledge. Opening the selector shows compact option names and selected values; entering an option submenu shows that option's provider description and the provider descriptions of its selectable values when supplied. - Kandev preserves optional ACP descriptions for both top-level config options and selectable values throughout the adapter, backend event, WebSocket, store, and selector pipeline. Missing descriptions produce no invented or hard-coded explanatory text. - Task-detail boot data includes the last persisted model list, live config options, and provider-default baseline so the compact label is complete on the first render instead of repainting after WebSocket reconnection. +- An unnamed agent tab derives its title from the task session's authoritative current model and live config values. The title updates when the model changes and remains correct after a task-detail refresh; a user-supplied session name continues to override the derived title. - Each turn stores an immutable configuration snapshot when the turn is created. The snapshot contains the effective model, mode, ordered selected config values with their provider-supplied display names, and the task-session provider-default baseline used for comparison. Agent-message metadata renders from this turn snapshot instead of the session's latest mutable runtime configuration. - Agent-message metadata always shows the attributed model and shows only non-model config values that differ from the turn's captured baseline. It keeps option names in the compact row because message attribution is read outside the selector context. Baseline-matching values and the session mode are omitted from the compact row; the mode remains available in turn metadata. - Prompt-usage metadata may refine the turn's attributed model when the provider reports the actual model used, but it does not replace the turn's captured config values or baseline. @@ -39,6 +40,7 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf - **GIVEN** an ACP option or value supplies a description, **WHEN** the user enters that option's submenu, **THEN** Kandev shows the provider text. The closed trigger and top-level option list remain compact, and missing descriptions leave the description region absent. - **GIVEN** a task selector has current model and config values, **WHEN** the user hovers or focuses its closed trigger, **THEN** a compact tooltip lists every selector option as `Name: Value` without provider descriptions, including values omitted from the changed-only trigger label. - **GIVEN** a task session has persisted dynamic model configuration, **WHEN** the task-detail page is refreshed, **THEN** the first rendered selector label includes all changed values without waiting for a WebSocket event. +- **GIVEN** an unnamed agent tab whose selected model changes during the session, **WHEN** the model update converges or the task-detail page is refreshed, **THEN** the existing tab title shows the current selected model instead of the agent profile's original model label. - **GIVEN** turn A runs with reasoning `High`, **WHEN** reasoning changes to `Low` before turn B, **THEN** turn A continues to show `Reasoning effort: High` and turn B shows `Reasoning effort: Low`. - **GIVEN** a turn uses only provider-default options, **WHEN** its agent-message metadata renders, **THEN** the compact row shows only the attributed model. - **GIVEN** a legacy turn has no configuration snapshot, **WHEN** the session's runtime options later change, **THEN** the legacy turn does not display those current options.