From 58f751e22c8e5ac75e428818e65a3f0daa5f5140 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:36:57 -0400 Subject: [PATCH 1/3] fix(desktop): keep chat ticks, menus, and composer scroll consistent Same-branch chats share one history tick, session menus stay glanceable and machine-aware, and a growing composer no longer unpins a thread that was already at the bottom. Co-authored-by: Cursor --- .../src/tuiClient/__tests__/format.test.ts | 25 +++ apps/ade-cli/src/tuiClient/format.ts | 7 + .../services/chat/agentChatService.test.ts | 150 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 89 +++++++++-- .../services/chat/chatTranscriptEntries.ts | 1 + .../sessions/chatSessionProjection.test.ts | 18 +++ .../sessions/chatSessionProjection.ts | 3 + .../chat/AgentChatMessageList.test.tsx | 84 ++++++++++ .../components/chat/AgentChatMessageList.tsx | 92 ++++++++++- .../components/chat/AgentChatPane.test.tsx | 90 +++++++++-- .../components/chat/AgentChatPane.tsx | 127 ++++++--------- .../components/chat/ChatUserMinimap.test.tsx | 43 ++++- .../components/chat/ChatUserMinimap.tsx | 30 +--- .../chat/chatUserMinimap.logic.test.ts | 11 +- .../components/chat/chatUserMinimap.logic.ts | 17 +- .../components/lanes/LaneContextMenu.tsx | 7 +- .../renderer/components/lanes/LanesPage.tsx | 2 + .../components/lanes/laneContextMenuItems.tsx | 137 +++++++++++----- .../terminals/LaneActionsSubmenu.tsx | 88 ++++++++-- .../components/terminals/SessionCard.test.tsx | 27 ++++ .../components/terminals/SessionCard.tsx | 86 +++++++++- .../terminals/SessionContextMenu.test.tsx | 104 +++++++++++- .../terminals/SessionContextMenu.tsx | 107 +++++++++++-- .../terminals/SessionListPane.test.tsx | 46 +++++- .../components/terminals/SessionListPane.tsx | 31 +++- .../terminals/useWorkLaneContextMenu.test.tsx | 2 + .../terminals/useWorkLaneContextMenu.tsx | 21 ++- .../renderer/components/ui/MenuSubmenu.tsx | 4 + .../renderer/components/ui/OpenInSubmenu.tsx | 10 +- .../src/renderer/hooks/useStartChatInLane.ts | 20 ++- .../src/renderer/lib/workDraft.test.ts | 17 +- apps/desktop/src/renderer/lib/workDraft.ts | 20 ++- apps/desktop/src/shared/types/chat.ts | 21 +++ apps/desktop/src/shared/types/sessions.ts | 3 + docs/features/chat/README.md | 11 +- docs/features/chat/composer-and-ui.md | 15 +- .../features/terminals-and-sessions/README.md | 24 +-- .../terminals-and-sessions/ui-surfaces.md | 18 ++- 38 files changed, 1349 insertions(+), 259 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index c93d239d44..37ad2d91aa 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -102,6 +102,31 @@ describe("formatRelativePastTime", () => { }); describe("renderChatLines", () => { + it("renders model handoffs as directional notice lines", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [{ + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "model_handoff", + fromProvider: "claude", + toProvider: "codex", + fromModelId: "anthropic/claude-sonnet-5", + toModelId: "openai/gpt-5.4", + }, + }], + }); + + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ + tone: "notice", + body: "[model] Claude → Codex", + }); + }); + it("LRU-caches assistant markdown parses by message text", () => { __clearAssistantMarkdownCacheForTests(); const text = "Paragraph text\n\n```ts\nconst value = 1;\n```"; diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index fee39f092f..d94b0eadab 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -8,6 +8,7 @@ import { isHostSleepNoticeEvent, } from "../../../desktop/src/shared/hostSleepNotice"; import { approvalRequestKind, isQuestionKind } from "../../../desktop/src/shared/pendingInputAnswers"; +import { providerDisplayLabel } from "../../../desktop/src/shared/pendingInputLabels"; import { renderAdeCardBody } from "./adeCardFormat"; import { highlightCode, type HighlightedToken } from "./highlightCache"; import { glyphFor } from "./theme"; @@ -791,6 +792,12 @@ export function renderChatLines(args: { } continue; } + if (event.type === "model_handoff") { + const from = providerDisplayLabel(event.fromProvider, "previous model"); + const to = providerDisplayLabel(event.toProvider, "new model"); + lines.push({ id, tone: "notice", body: `[model] ${from} → ${to}` }); + continue; + } if (event.type === "text") { // Codex subagent child content is namespaced `codex-subagent:` and belongs // in the subagent transcript, not the parent chat — mirror desktop, which diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2ef850e489..1b9d591552 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -80,6 +80,7 @@ const mockState = vi.hoisted(() => ({ questionReject: ReturnType; permissionReply: ReturnType; }>(), + openCodePromptAsyncBarrier: null as Promise | null, openCodeTitleForNextPrompt: null as string | null, openCodeQuestionForNextPrompt: null as null | { id: string; @@ -430,6 +431,9 @@ vi.mock("../opencode/openCodeRuntime", async () => { // arrives alongside sessionID and directory. promptAsync: vi.fn(async (params: any = {}) => { state.promptBodies.push(params ?? {}); + if (mockState.openCodePromptAsyncBarrier) { + await mockState.openCodePromptAsyncBarrier; + } void (async () => { if (mockState.openCodeTitleForNextPrompt) { pushEvent({ @@ -2136,6 +2140,7 @@ beforeEach(() => { mockState.openCodeSessionCounter = 0; mockState.openCodeForkCalls = []; mockState.openCodeSessions.clear(); + mockState.openCodePromptAsyncBarrier = null; mockState.openCodeTitleForNextPrompt = null; mockState.openCodeQuestionForNextPrompt = null; mockState.droidSessionCounter = 0; @@ -4263,7 +4268,10 @@ describe("createAgentChatService", () => { }); it("recomputes the MCP report when a model switch crosses providers", async () => { - const { service } = createService(); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); const created = await service.createSession({ laneId: "lane-1", provider: "claude", @@ -4285,6 +4293,26 @@ describe("createAgentChatService", () => { delivered: true, }); expect(summary?.mcpCapability?.residual).toBeTruthy(); + expect(summary?.modelHandoffHistory).toEqual([ + expect.objectContaining({ + fromProvider: "claude", + toProvider: "codex", + fromModelId: expect.any(String), + toModelId: expect.any(String), + }), + ]); + expect(events.map((event) => event.event)).toContainEqual( + expect.objectContaining({ + type: "model_handoff", + fromProvider: "claude", + toProvider: "codex", + }), + ); + + const { service: restarted } = createService(); + await expect(restarted.getSessionSummary(created.id)).resolves.toMatchObject({ + modelHandoffHistory: summary?.modelHandoffHistory, + }); }); it("refuses a model switch onto a provider that cannot carry the injected servers", async () => { @@ -40894,6 +40922,126 @@ describe("createAgentChatService", () => { await sendPromise.catch(() => undefined); }); + it("renders OpenCode follow-up text whose message.updated arrives before promptAsync settles", async () => { + // The SSE is live-only. Awaiting promptAsync before draining it used to + // drop the role announcement on a fast follow-up; the role gate then + // swallowed every assistant part while session.idle still completed. + let pulling = false; + const liveQueue: any[] = []; + let liveWaiter: (() => void) | null = null; + const wakeLive = () => { + const waiter = liveWaiter; + liveWaiter = null; + waiter?.(); + }; + const pushLive = (...nextEvents: any[]) => { + if (!pulling) return; + liveQueue.push(...nextEvents); + wakeLive(); + }; + + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () {})(), + } as any); + vi.mocked(openCodeEventStream).mockImplementationOnce((async () => { + return (async function* () { + pulling = true; + wakeLive(); + while (true) { + if (liveQueue.length > 0) { + yield liveQueue.shift(); + continue; + } + await new Promise((resolve) => { + liveWaiter = resolve; + if (liveQueue.length > 0) { + liveWaiter = null; + resolve(); + } + }); + } + })(); + }) as unknown as typeof openCodeEventStream); + + let releasePrompt!: () => void; + mockState.openCodePromptAsyncBarrier = new Promise((resolve) => { + releasePrompt = resolve; + }); + + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "opencode/openai/gpt-5.4", + modelId: "opencode/openai/gpt-5.4", + }); + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "What model are you now?", + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" && event.event.turnStatus === "started", + ); + await vi.waitFor(() => { + expect(pulling).toBe(true); + }); + expect(mockState.openCodeSessions.values().next().value?.promptBodies.length ?? 0).toBe(1); + + const sessionID = [...mockState.openCodeSessions.keys()][0]!; + pushLive( + { + type: "message.updated", + properties: { info: { id: "msg-fast-1", role: "assistant", sessionID } }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "text-fast-1", + type: "text", + text: "Still receiving messages.", + messageID: "msg-fast-1", + sessionID, + }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "finish-fast-1", + sessionID, + type: "step-finish", + tokens: { input: 20, output: 8, cache: { read: 0, write: 0 } }, + }, + }, + }, + { + type: "session.idle", + properties: { sessionID }, + }, + ); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "text" && event.event.text.includes("Still receiving messages."), + ); + + releasePrompt(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => event.event.type === "done", + ); + await sendPromise; + }); + it("fails a cleanly ended OpenCode event stream and clears active child sessions", async () => { const events: AgentChatEventEnvelope[] = []; let releaseStream!: () => void; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index fe102929ae..25f99baf45 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -315,6 +315,7 @@ import type { AgentChatModelCatalogArgs, AgentChatModelCatalogRefreshProvider, AgentChatModelInfo, + AgentChatModelHandoff, AgentChatProvider, AgentChatPrepareCrossMachineHandoffArgs, AgentChatPrepareCrossMachineHandoffResult, @@ -1079,6 +1080,7 @@ type PersistedChatState = { provider: AgentChatProvider; model: string; modelId?: string; + modelHandoffHistory?: AgentChatModelHandoff[]; sessionProfile?: "light" | "workflow"; reasoningEffort?: string | null; fastMode?: boolean; @@ -1207,6 +1209,28 @@ type PersistedChatState = { updatedAt: string; }; +const MAX_MODEL_HANDOFF_HISTORY = 8; + +function normalizeModelHandoffHistory(value: unknown): AgentChatModelHandoff[] | undefined { + if (!Array.isArray(value)) return undefined; + const history = value.flatMap((candidate): AgentChatModelHandoff[] => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return []; + const record = candidate as Record; + const fromProvider = typeof record.fromProvider === "string" ? record.fromProvider.trim() : ""; + const toProvider = typeof record.toProvider === "string" ? record.toProvider.trim() : ""; + if (!fromProvider || !toProvider) return []; + const fromModelId = typeof record.fromModelId === "string" ? record.fromModelId.trim() : ""; + const toModelId = typeof record.toModelId === "string" ? record.toModelId.trim() : ""; + return [{ + fromProvider, + toProvider, + ...(fromModelId ? { fromModelId } : {}), + ...(toModelId ? { toModelId } : {}), + }]; + }); + return history.length ? history.slice(-MAX_MODEL_HANDOFF_HISTORY) : undefined; +} + function normalizeContinuityRecovery(value: unknown): AgentChatContinuityRecovery | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; @@ -13523,6 +13547,9 @@ export function createAgentChatService(args: { provider: managed.session.provider, model: managed.session.model, ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + ...(managed.session.modelHandoffHistory?.length + ? { modelHandoffHistory: managed.session.modelHandoffHistory } + : {}), ...(managed.session.sessionProfile ? { sessionProfile: managed.session.sessionProfile } : {}), ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), ...(managed.session.fastMode === true ? { fastMode: true } : {}), @@ -13812,6 +13839,7 @@ export function createAgentChatService(args: { const modelId = storedModelId.length ? (getModelById(storedModelId) ?? resolveModelAlias(storedModelId))?.id : resolveModelIdFromStoredValue(model, provider); + const modelHandoffHistory = normalizeModelHandoffHistory(record.modelHandoffHistory); const sessionProfile = normalizeSessionProfile(record.sessionProfile); const reasoningEffort = normalizeReasoningEffort(record.reasoningEffort); const fastMode = readLegacyFastMode(record as Record); @@ -13990,6 +14018,7 @@ export function createAgentChatService(args: { provider, model, ...(modelId ? { modelId } : {}), + ...(modelHandoffHistory ? { modelHandoffHistory } : {}), ...(sessionProfile ? { sessionProfile } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -18502,6 +18531,9 @@ export function createAgentChatService(args: { provider, model, ...(hydratedModelId ? { modelId: hydratedModelId } : {}), + ...(persisted?.modelHandoffHistory?.length + ? { modelHandoffHistory: persisted.modelHandoffHistory } + : {}), ...(persisted?.sessionProfile ? { sessionProfile: persisted.sessionProfile } : {}), ...(rowGoal ? { goal: rowGoal } : {}), reasoningEffort: persisted?.reasoningEffort ?? null, @@ -24225,8 +24257,10 @@ export function createAgentChatService(args: { `${args.promptText}${attachmentHint}`, ].filter((section): section is string => Boolean(section)).join("\n\n"); + const previousEventAbort = runtime.eventAbortController; const abortController = new AbortController(); runtime.eventAbortController = abortController; + previousEventAbort?.abort(); runtime.textByPartId.clear(); runtime.reasoningByPartId.clear(); runtime.partTypeByPartId.clear(); @@ -24297,15 +24331,20 @@ export function createAgentChatService(args: { }, }); + let promptFailure: unknown = null; const promptAccepted = runtime.handle.client.session.promptAsync( openCodePromptBody, { throwOnError: true }, - ); - - await promptAccepted; - if (args.onBackendDispatched) { - args.onBackendDispatched(); - } + ).then(() => { + args.onBackendDispatched?.(); + }).catch((error: unknown) => { + promptFailure = error; + abortController.abort(); + throw error; + }); + // Drain the live-only SSE immediately. Awaiting promptAsync first loses + // `message.updated` on fast follow-up turns; the role gate then drops + // every assistant part while `session.idle` still completes the turn. let stepNumber = 0; // Role of every message OpenCode tells us about, keyed by message id. @@ -25166,6 +25205,12 @@ export function createAgentChatService(args: { continue; } } + try { + await promptAccepted; + } catch (error) { + if (!parentSessionIdle) throw promptFailure ?? error; + } + abortController.abort(); if (!parentSessionIdle || runtime.subagentSessions.size > 0) { throw new Error("OpenCode event stream ended before the parent and child sessions became idle"); } @@ -43606,6 +43651,7 @@ export function createAgentChatService(args: { : undefined; const backgroundWork = runtimeBackgroundWork(liveManaged?.runtime ?? null); const activeBackgroundTaskCount = totalBackgroundWork(backgroundWork); + const modelHandoffHistory = liveSession?.modelHandoffHistory ?? persisted?.modelHandoffHistory; const backgroundWorkSince = runtimeBackgroundWorkSince(liveManaged?.runtime ?? null); // Reported even when nothing is live in the runtime's own bookkeeping: a // session holding an SDK process with no background work is exactly the @@ -43639,6 +43685,7 @@ export function createAgentChatService(args: { provider, model, ...(hydratedModelId ? { modelId: hydratedModelId } : {}), + ...(modelHandoffHistory?.length ? { modelHandoffHistory } : {}), sessionProfile: liveSession?.sessionProfile ?? persisted?.sessionProfile, title: row.title ?? null, goal: row.goal ?? null, @@ -46264,6 +46311,7 @@ export function createAgentChatService(args: { || droidPermissionMode !== undefined || cursorModeId !== undefined || cursorConfigValues !== undefined; + let modelHandoff: AgentChatModelHandoff | null = null; if (modelId !== undefined) { const nextModelId = String(modelId ?? "").trim(); @@ -46285,6 +46333,9 @@ export function createAgentChatService(args: { }); } const previousProvider = managed.session.provider; + const previousModelId = managed.session.modelId + ?? resolveModelIdFromStoredValue(managed.session.model, previousProvider) + ?? managed.session.model; // A model switch can cross providers, which means it can move a chat onto // a provider that cannot honor the MCP request the chat was created with. @@ -46326,6 +46377,14 @@ export function createAgentChatService(args: { previousProvider !== nextProvider || managed.session.modelId !== descriptor.id || managed.session.model !== nextModel; + if (modelChanged) { + modelHandoff = { + fromProvider: previousProvider, + toProvider: nextProvider, + ...(previousModelId ? { fromModelId: previousModelId } : {}), + toModelId: descriptor.id, + }; + } const previousCodexRuntime = managed.runtime?.kind === "codex" ? managed.runtime : null; const liveCodexSettings = previousProvider === "codex" @@ -46778,15 +46837,25 @@ export function createAgentChatService(args: { dismissSubagentTakeoverPrompt({ sessionId }); } + if (modelHandoff) { + managed.session.modelHandoffHistory = [ + ...(managed.session.modelHandoffHistory ?? []), + modelHandoff, + ].slice(-MAX_MODEL_HANDOFF_HISTORY); + emitChatEvent(managed, { + type: "model_handoff", + ...modelHandoff, + }); + } + persistChatState(managed); return managed.session; }; /** - * Trigger early warmup of the Claude query for an existing chat session. - * Called from the renderer when the user selects a Claude/Anthropic model in the - * model picker — before they've submitted a message — so the ~30s subprocess - * cold-start happens while they're still composing. + * Explicitly pre-warm a provider query for an existing chat session. + * Model selection itself stays local; callers opt into this only for flows + * that intentionally prepare a runtime before the next message is sent. */ const warmupModel = async ({ sessionId, diff --git a/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts b/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts index c0a1e403ce..a49550fa78 100644 --- a/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts +++ b/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts @@ -58,6 +58,7 @@ const TRANSCRIPT_CONTENT_EVENT_TYPES: ReadonlySet = new Set { expect(projected.lastActivityAt).toBe("2026-08-13T20:26:10.000Z"); expect(projected.cursorCloudAgentId).toBe("bc-cloud-agent"); }); + + it("projects model handoff history onto the Work row", () => { + const projected = projectChatOntoSession(session(), chat({ + modelHandoffHistory: [{ + fromProvider: "claude", + toProvider: "codex", + fromModelId: "anthropic/claude-sonnet-5", + toModelId: "openai/gpt-5.4", + }], + })); + + expect(projected.modelHandoffHistory).toEqual([{ + fromProvider: "claude", + toProvider: "codex", + fromModelId: "anthropic/claude-sonnet-5", + toModelId: "openai/gpt-5.4", + }]); + }); }); diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 8e2096a906..1532dfdd81 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts @@ -80,6 +80,9 @@ export function projectChatOntoSession( ...(chat.backgroundWork ? { backgroundWork: chat.backgroundWork } : {}), ...(chat.backgroundWorkSince ? { backgroundWorkSince: chat.backgroundWorkSince } : {}), ...(chat.runtimeProcesses?.length ? { runtimeProcesses: chat.runtimeProcesses } : {}), + ...(chat.modelHandoffHistory?.length + ? { modelHandoffHistory: chat.modelHandoffHistory } + : {}), ...(chat.claudeTag !== undefined ? { claudeTag: chat.claudeTag } : {}), ...(chat.orchestrationRunId ? { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 911615a2c0..051617d365 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -85,6 +85,7 @@ import { sameSetContents, shouldAbsorbProgrammaticScrollEvent, stabilizeTranscriptToolActivity, + shouldKeepPinnedThroughViewportShrink, shouldStickToBottomAfterScroll, } from "./AgentChatMessageList"; import { looksLikeWireframe } from "./questionOptionPreview"; @@ -1185,6 +1186,33 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.queryByTestId("handoff-brief-chip")).toBeNull(); }); + it("renders a provider handoff divider with direction and provider marks", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "model_handoff", + fromProvider: "claude", + toProvider: "codex", + fromModelId: "anthropic/claude-sonnet-5", + toModelId: "openai/gpt-5.4", + }, + }, + ]); + + const divider = screen.getByTestId("model-handoff-event"); + expect(divider.getAttribute("aria-label")).toBe("Model handoff from Claude to Codex"); + expect(divider.textContent).toContain("handoff"); + expect([...divider.querySelectorAll("[data-model-handoff-provider]")].map((node) => ( + node.getAttribute("data-model-handoff-provider") + ))).toEqual(["claude", "codex"]); + expect([...divider.querySelectorAll("[data-model-handoff-provider]")].every((node) => ( + node.className.includes("h-5") && node.className.includes("w-5") + ))).toBe(true); + expect(divider.querySelector(".items-center.h-6")).toBeTruthy(); + }); + it("draws exactly one fork-history divider between seeded history and the first live event", async () => { renderMessageList([ { @@ -2024,6 +2052,44 @@ describe("AgentChatMessageList transcript rendering", () => { }); }); + it("stays pinned to latest when the composer grows and shrinks the transcript", async () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "user_message", + text: "Keep typing", + deliveryState: "delivered", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "text", + text: "Still at the bottom.", + itemId: "text-1", + turnId: "turn-1", + }, + }, + ]); + + const transcript = document.querySelector(".ade-chat-timeline-pane") as HTMLDivElement; + Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1_000 }); + Object.defineProperty(transcript, "clientHeight", { configurable: true, value: 400 }); + transcript.scrollTop = 600; + fireEvent.scroll(transcript); + + Object.defineProperty(transcript, "clientHeight", { configurable: true, value: 200 }); + // Layout left scrollTop where it was; distance-from-bottom is now 200px. + transcript.scrollTop = 600; + fireEvent.scroll(transcript); + + expect(transcript.scrollTop).toBe(800); + expect(screen.queryByRole("button", { name: "Jump to latest message" })).toBeNull(); + }); + it("automatically backfills an underfilled transcript without requiring a scroll event", async () => { const onLoadOlderHistory = vi.fn(); renderMessageList([], { @@ -2091,6 +2157,24 @@ describe("AgentChatMessageList transcript rendering", () => { })).toBe(true); }); + it("treats a shrinking transcript viewport as a pin, not a user scroll", () => { + expect(shouldKeepPinnedThroughViewportShrink({ + wasStuckToBottom: true, + previousClientHeight: 400, + nextClientHeight: 200, + })).toBe(true); + expect(shouldKeepPinnedThroughViewportShrink({ + wasStuckToBottom: false, + previousClientHeight: 400, + nextClientHeight: 200, + })).toBe(false); + expect(shouldKeepPinnedThroughViewportShrink({ + wasStuckToBottom: true, + previousClientHeight: 0, + nextClientHeight: 200, + })).toBe(false); + }); + it("lets upward wheel intent break bottom-follow before streaming output grows", async () => { const events: AgentChatEventEnvelope[] = [ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index b3a210458f..28928d1fc2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -5,6 +5,7 @@ import { CaretDown, CaretLeft, CaretRight, + ArrowRight, Bug, CloudArrowUp, GitFork, @@ -89,7 +90,7 @@ import type { AgentChatContextAttachment, AgentChatFileRef } from "../../../shar import { getToolMeta } from "./chatToolAppearance"; import { ClaudeLogo, CodexLogo, CursorAgentLogo } from "../terminals/ToolLogos"; import { ModelRowLogo, ProviderLogo } from "../shared/ProviderLogos"; -import { pendingInputHeaderLabel } from "../../../shared/pendingInputLabels"; +import { pendingInputHeaderLabel, providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { isHostResumedNoticeEvent, isHostSleepNoticeEvent } from "../../../shared/hostSleepNotice"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { @@ -2422,6 +2423,42 @@ function renderEvent( ) { const event = envelope.event; + if (event.type === "model_handoff") { + const fromLabel = providerDisplayLabel(event.fromProvider, "Previous model"); + const toLabel = providerDisplayLabel(event.toProvider, "New model"); + return ( +
+ + + + + + + handoff + + + + + + + +
+ ); + } + if (event.type === "scheduled_wake_divider") { const reason = event.reason?.trim(); return ( @@ -4890,6 +4927,19 @@ export function shouldStickToBottomAfterScroll({ : distanceFromBottom <= STICK_RESUME_THRESHOLD_PX; } +export function shouldKeepPinnedThroughViewportShrink({ + wasStuckToBottom, + previousClientHeight, + nextClientHeight, +}: { + wasStuckToBottom: boolean; + previousClientHeight: number; + nextClientHeight: number; +}): boolean { + if (!wasStuckToBottom || previousClientHeight <= 0) return false; + return nextClientHeight < previousClientHeight - 0.5; +} + export function calculateVirtualWindow({ rowCount, scrollTop, @@ -5331,6 +5381,7 @@ function AgentChatMessageListMain({ // latest ADE-authored scrollTop target instead of using a counter, so a real // user scroll never gets swallowed by stale "programmatic" credits. const programmaticScrollTargetRef = useRef(null); + const lastScrollClientHeightRef = useRef(0); const scrollToBottomSoonRef = useRef<((followUpFrames?: number) => void) | null>(null); const scrollMemoryKeyRef = useRef(resolvedScrollMemoryKey); const scrollMemorySnapshotByKeyRef = useRef(new Map()); @@ -5835,10 +5886,22 @@ function AgentChatMessageListMain({ stickToBottomRef.current = stickToBottom; }, [stickToBottom]); + const pinScrollToBottomNow = useCallback((el: HTMLElement) => { + const pinTarget = Math.max(0, el.scrollHeight - el.clientHeight); + const before = el.scrollTop; + if (Math.abs(before - pinTarget) < 1) return; + el.scrollTop = pinTarget; + programmaticScrollTargetRef.current = el.scrollTop; + setScrollTop(el.scrollTop); + }, []); + const measureScrollContainerHeight = useCallback(() => { const el = scrollRef.current; if (!el) return; const nextHeight = el.clientHeight; + if (lastScrollClientHeightRef.current <= 0) { + lastScrollClientHeightRef.current = nextHeight; + } setContainerHeight((current) => (movedByAPixel(current, nextHeight) ? nextHeight : current)); }, []); @@ -5990,12 +6053,22 @@ function AgentChatMessageListMain({ const ro = new ResizeObserver((entries) => { const entry = entries[0]; const nextHeight = Math.max(entry?.contentRect.height ?? 0, el.clientHeight); + const previousHeight = lastScrollClientHeightRef.current; + if (shouldKeepPinnedThroughViewportShrink({ + wasStuckToBottom: stickToBottomRef.current, + previousClientHeight: previousHeight, + nextClientHeight: nextHeight, + })) { + pinScrollToBottomNow(el); + scrollToBottomSoon(2); + } + lastScrollClientHeightRef.current = nextHeight; setContainerHeight((current) => (movedByAPixel(current, nextHeight) ? nextHeight : current)); }); ro.observe(el); measureScrollContainerHeight(); return () => ro.disconnect(); - }, [measureScrollContainerHeight]); + }, [measureScrollContainerHeight, pinScrollToBottomNow, scrollToBottomSoon]); // A short initial tail may not create a scrollbar, so no scroll event can // ever ask for the next page. Keep backfilling while the viewport is @@ -6470,10 +6543,23 @@ function AgentChatMessageListMain({ programmaticTarget, })) { programmaticScrollTargetRef.current = null; + lastScrollClientHeightRef.current = target.clientHeight; setScrollTop(target.scrollTop); return; } programmaticScrollTargetRef.current = null; + const nextClientHeight = target.clientHeight; + const previousClientHeight = lastScrollClientHeightRef.current; + lastScrollClientHeightRef.current = nextClientHeight; + if (shouldKeepPinnedThroughViewportShrink({ + wasStuckToBottom: stickToBottomRef.current, + previousClientHeight, + nextClientHeight, + })) { + pinScrollToBottomNow(target); + scrollToBottomSoon(2); + return; + } const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; // Wider threshold (~1 row of assistant text) so a small wheel nudge // while the turn is streaming actually breaks free instead of snapping @@ -6496,7 +6582,7 @@ function AgentChatMessageListMain({ } setScrollTop(target.scrollTop); maybeRequestOlderHistory(target.scrollTop); - }, [markDetachAnchor, maybeRequestOlderHistory, onReturnToLatest]); + }, [markDetachAnchor, maybeRequestOlderHistory, onReturnToLatest, pinScrollToBottomNow, scrollToBottomSoon]); const handleWheel = useCallback((event: React.WheelEvent) => { if (event.deltaY < 0) { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 29bdc4da1a..8892c072ff 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -5139,7 +5139,7 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByRole("button", { name: "Login to Claude" })).toBeTruthy(); }); - it("keeps the committed model visible until the backend confirms the switch", async () => { + it("keeps model handoff local until the next message is sent", async () => { const session = buildSession("session-1", { status: "idle" }); const sessions = [session]; let resolveUpdateSession!: (value: AgentChatSessionSummary) => void; @@ -5147,7 +5147,7 @@ describe("AgentChatPane submit recovery", () => { resolveUpdateSession = resolve; })); const warmupModel = vi.fn().mockResolvedValue(undefined); - installAdeMocks({ + const { send } = installAdeMocks({ sessions, includeClaudeModel: true, }); @@ -5157,6 +5157,9 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); const trigger = await screen.findByRole("button", { name: /^Select model/ }); + const chatChrome = document.querySelector("[data-chat-chrome-tint]") as HTMLElement | null; + expect(chatChrome).toBeTruthy(); + const committedAccent = chatChrome?.style.getPropertyValue("--chat-accent"); const currentLabel = getModelById(session.modelId ?? "")?.displayName ?? session.modelId ?? ""; const nextLabel = getModelById("anthropic/claude-sonnet-5")?.displayName ?? "Claude Sonnet 5"; const nextLabelPattern = new RegExp(escapeRegExp(nextLabel), "i"); @@ -5167,13 +5170,20 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(nextLabelPattern); + expect(updateSession).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(nextLabel); + expect(chatChrome?.style.getPropertyValue("--chat-accent")).toBe(committedAccent); + expect(warmupModel).not.toHaveBeenCalled(); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Use the new model." } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); await waitFor(() => { expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId, modelId: "anthropic/claude-sonnet-5", }), null); }); - expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(currentLabel); + expect(send).not.toHaveBeenCalled(); expect(warmupModel).not.toHaveBeenCalled(); const updatedSession: AgentChatSessionSummary = { @@ -5190,21 +5200,76 @@ describe("AgentChatPane submit recovery", () => { resolveUpdateSession(updatedSession); await waitFor(() => { - expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(nextLabel); + expect(send).toHaveBeenCalled(); }); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(nextLabel); + expect(warmupModel).not.toHaveBeenCalled(); + }); + + it("keeps the pending model's permission mode local until Send", async () => { + const session = buildSession("session-1", { status: "idle" }); + const sessions = [session]; + let resolveUpdateSession!: (value: AgentChatSessionSummary) => void; + const updateSession = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveUpdateSession = resolve; + })); + const { send } = installAdeMocks({ + sessions, + includeClaudeModel: true, + }); + window.ade.agentChat.updateSession = updateSession as any; + + renderPane(session); + + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + const nextLabel = getModelById("anthropic/claude-sonnet-5")?.displayName ?? "Claude Sonnet 5"; + const nextLabelPattern = new RegExp(escapeRegExp(nextLabel), "i"); + + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); + await clickEnabledModelOption(nextLabelPattern); + + fireEvent.click(await screen.findByRole("button", { name: "Claude permission mode" })); + fireEvent.click(await screen.findByRole("option", { name: /^Bypass/ })); + + expect(updateSession).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Claude permission mode" }).textContent ?? "").toMatch(/Bypass/i); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Use bypass on the new model." } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); await waitFor(() => { - expect(warmupModel).toHaveBeenCalledWith({ + expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId, modelId: "anthropic/claude-sonnet-5", - }, null); + claudePermissionMode: "bypassPermissions", + }), null); + }); + expect(send).not.toHaveBeenCalled(); + + const updatedSession: AgentChatSessionSummary = { + ...session, + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + permissionMode: "full-auto", + interactionMode: "default", + claudePermissionMode: "bypassPermissions", + }; + sessions[0] = updatedSession; + resolveUpdateSession(updatedSession); + + await waitFor(() => { + expect(send).toHaveBeenCalled(); }); + expect(screen.getByRole("button", { name: "Claude permission mode" }).textContent ?? "").toMatch(/Bypass/i); }); - it("keeps the committed model visible when the backend rejects a switch", async () => { + it("does not attempt a failed model handoff before Send", async () => { const session = buildSession("session-1", { status: "idle", fastMode: true }); const updateSession = vi.fn().mockRejectedValue(new Error("switch failed")); const warmupModel = vi.fn().mockResolvedValue(undefined); - installAdeMocks({ + const { send } = installAdeMocks({ sessions: [session], includeClaudeModel: true, }); @@ -5225,6 +5290,11 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(nextLabelPattern); + expect(updateSession).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(nextLabel); + + fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Try the handoff." } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); await waitFor(() => { expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId, @@ -5232,9 +5302,9 @@ describe("AgentChatPane submit recovery", () => { }), null); }); await waitFor(() => { - expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(currentLabel); + expect(send).not.toHaveBeenCalled(); }); - expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain("Fast"); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(nextLabel); expect(warmupModel).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 83efa0848a..dcd04d6630 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1645,6 +1645,13 @@ function resolveChatRuntimeProvider(desc: ModelDescriptor | null | undefined): C return desc ? resolveProviderGroupForModel(desc) : "opencode"; } +function isDeferredComposerModelSelection( + composerModelId: string | null | undefined, + sessionModelId: string | null | undefined, +): boolean { + return Boolean(composerModelId && sessionModelId && composerModelId !== sessionModelId); +} + function runtimeFacingModelId(desc: ModelDescriptor | null | undefined, registryModelId: string): string { if (!desc?.isCliWrapped) return registryModelId; if (desc.family === "cursor" || desc.family === "openai" || desc.family === "factory") { @@ -3973,20 +3980,6 @@ export function AgentChatPane({ () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), [sessions, selectedSessionId] ); - // Does the composer's model actually describe the chat on screen? The - // model-derived accent inputs (the composer model descriptor's family and - // color, which give the chat its accent) trail a prop-driven switch, because - // the pane is not remounted — so those inputs are withheld until this holds, - // and a neutral fallback covers the gap. It gates only where it is read; it - // is not a blanket freshness test for everything keyed to `selectedSession`. - // - // Deliberately not `!chatSelectionTransitioning`: that one compares the - // composer id to the rendered id, which already agree in the frame where the - // incoming id is known but its row has not been listed yet. Here - // `selectedSession` is still null in that frame, so this stays false and the - // stale model args keep being withheld. A draft pane has no session at all, - // so `null === null` holds and it keeps its composer model color. - const composerModelDescribesRenderedChat = (selectedSession?.sessionId ?? null) === renderedSessionId; // Which atomic active-turn dispatch modes this session's backend accepts, // read off the canonical table in shared/types/chat.ts rather than restated // here. @@ -4078,6 +4071,10 @@ export function AgentChatPane({ if (!selectedSession) return null; return selectedSession.modelId ?? resolveRegistryModelId(selectedSession.model); }, [selectedSession]); + const composerModelIdRef = useRef(modelId); + composerModelIdRef.current = modelId; + const selectedSessionModelIdRef = useRef(selectedSessionModelId); + selectedSessionModelIdRef.current = selectedSessionModelId; useEffect(() => { const api = window.ade?.iosSimulator; if (!api?.getStatus) return; @@ -5297,6 +5294,13 @@ export function AgentChatPane({ const modelSelectionDiffersFromSession = Boolean(selectedSession && selectedSessionModelId && selectedSessionModelId !== modelId); + // A model picked in an existing chat is a draft until the next message is + // sent. Keep model-derived chat accents on the committed session so the + // visual identity does not get ahead of the provider handoff. + const composerModelDescribesRenderedChat = + (selectedSession?.sessionId ?? null) === renderedSessionId + && (!selectedSession || !modelSelectionDiffersFromSession); + const sessionProvider = useMemo(() => { if (selectedSession && !modelSelectionDiffersFromSession) return selectedSession.provider; return resolveChatRuntimeProvider(resolveScopedModelDescriptor(modelId, modelCatalogScopeKey)); @@ -5496,6 +5500,12 @@ export function AgentChatPane({ return; } const nextModelId = session.modelId ?? resolveRegistryModelId(session.model); + if (isDeferredComposerModelSelection(composerModelIdRef.current, nextModelId)) { + // A model picked in this chat is local until Send. Session permission + // fields still describe the previous provider, so hydrating from them + // would snap the new model's picker back to its default. + return; + } if (nextModelId) { setModelId(nextModelId); } @@ -7733,7 +7743,14 @@ export function AgentChatPane({ const modeChanged = Object.keys(summaryPatch).some((key) => key !== "title" && key !== "spawnKind" && key !== "subagentTakeoverPromptShownAt" ); - if (modeChanged && envelope.sessionId === selectedSessionIdRef.current) { + if ( + modeChanged + && envelope.sessionId === selectedSessionIdRef.current + && !isDeferredComposerModelSelection( + composerModelIdRef.current, + selectedSessionModelIdRef.current, + ) + ) { if (meta.interactionMode !== undefined) { setInteractionMode(meta.interactionMode ?? initialNativeControls.interactionMode); } @@ -10988,10 +11005,18 @@ export function AgentChatPane({ setOptimisticIfAllowed(sessionId); const modelUpdate = selectedModelChanged ? { modelId } : {}; const fastModeUpdate = selectedFastModeChanged ? { fastMode } : {}; + const pendingNativeUpdate = selectedModelChanged + ? summarizeNativeControls(sessionProvider, nativeControlsRef.current) + : {}; + const pendingCursorConfig = selectedModelChanged && sessionProvider === "cursor" + ? { cursorConfigValues: nativeControlsRef.current.cursorConfigValues } + : {}; await window.ade.agentChat.updateSession({ sessionId, ...modelUpdate, ...fastModeUpdate, + ...pendingNativeUpdate, + ...pendingCursorConfig, }, chatRuntimePinRef.current); void refreshSessions().catch(() => {}); } else if (!sessionId) { @@ -11486,6 +11511,13 @@ export function AgentChatPane({ if (!selectedSessionId) return; + if (isDeferredComposerModelSelection(composerModelIdRef.current, selectedSessionModelIdRef.current)) { + // Persist with the model handoff on Send. Writing now would apply the + // new provider's fields to the still-bound previous provider, then the + // session hydration would snap the picker back to its default. + return; + } + const provider = selectedSession?.provider ?? sessionProvider; const nextSummary = { ...summarizeNativeControls(provider, nextControls), @@ -13230,75 +13262,20 @@ export function AgentChatPane({ if (!selectedSessionId) { draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; } - const previousModelId = modelId; - const previousFastMode = fastModeRef.current; if (options) { setFastModeState(options.fastMode); } const snapshot = buildModelSelectionSnapshot(nextModelId); if (!selectedSessionId || turnActive) { applyModelSelectionSnapshot(snapshot); - if ( - selectedSessionId - && snapshot.nextDesc?.isCliWrapped - && (snapshot.nextDesc.family === "anthropic" || snapshot.nextDesc.family === "cursor") - ) { - window.ade.agentChat.warmupModel({ - sessionId: selectedSessionId, - modelId: nextModelId, - }, chatRuntimePinRef.current).catch(() => { /* warmup is best-effort */ }); - } return; } - setSessionMutationKind("model"); - void window.ade.agentChat.updateSession({ - sessionId: selectedSessionId, - modelId: nextModelId, - ...(options ? { fastMode: fastModeRef.current } : {}), - }, chatRuntimePinRef.current).then((updatedSession) => { - applyModelSelectionSnapshot(snapshot); - patchSessionSummary(selectedSessionId, { - provider: updatedSession.provider, - model: updatedSession.model, - modelId: updatedSession.modelId, - reasoningEffort: updatedSession.reasoningEffort ?? null, - fastMode: updatedSession.fastMode === true, - permissionMode: updatedSession.permissionMode, - interactionMode: updatedSession.interactionMode ?? null, - claudePermissionMode: updatedSession.claudePermissionMode, - codexApprovalPolicy: updatedSession.codexApprovalPolicy, - codexSandbox: updatedSession.codexSandbox, - codexConfigSource: updatedSession.codexConfigSource, - opencodePermissionMode: updatedSession.opencodePermissionMode, - droidPermissionMode: updatedSession.droidPermissionMode, - cursorModeId: updatedSession.cursorModeId, - cursorModeSnapshot: updatedSession.cursorModeSnapshot, - }); - getAgentChatSlashCommandsCached( - { sessionId: selectedSessionId, projectRoot }, - { force: true, pin: chatRuntimePinRef.current }, - ) - .then(setSdkSlashCommands) - .catch(() => {}); - if ( - snapshot.nextDesc?.isCliWrapped - && (snapshot.nextDesc.family === "anthropic" || snapshot.nextDesc.family === "cursor") - ) { - window.ade.agentChat.warmupModel({ - sessionId: selectedSessionId, - modelId: nextModelId, - }, chatRuntimePinRef.current).catch(() => { /* warmup is best-effort */ }); - } - void refreshSessions().catch(() => {}); - }).catch((err) => { - setModelId(previousModelId); - if (options) setFastModeState(previousFastMode); - void refreshSessions().catch(() => {}); - setError(err instanceof Error ? err.message : String(err)); - }).finally(() => { - setSessionMutationKind(null); - }); + // Keep the selection local until submit(). Submit applies the + // existing updateSession handoff immediately before send(), so + // no provider runtime is torn down or rebound while the user is + // still choosing a model or typing. + applyModelSelectionSnapshot(snapshot); }} onReasoningEffortChange={handleReasoningEffortChange} onFastModeChange={handleFastModeChange} diff --git a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx index e56b7833d7..56311177ca 100644 --- a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx @@ -39,7 +39,7 @@ afterEach(() => { }); describe("ChatUserMinimap", () => { - it("keeps resting and hovered ticks visible against the chat canvas", () => { + it("keeps every tick at the same weight while preserving hover color", () => { const view = render( { const hoveredTick = ticks.at(1); expect(ticks).toHaveLength(2); expect(hoveredTick?.className).toContain("bg-[var(--color-fg)]/30"); + expect(ticks.every((tick) => tick.className.includes("left-0"))).toBe(true); + expect(ticks.every((tick) => tick.className.includes("h-0.5"))).toBe(true); + expect(ticks.every((tick) => tick.className.includes("w-2"))).toBe(true); fireEvent.mouseMove(rail, { clientY: 500 }); expect(hoveredTick?.className).toContain("bg-[var(--color-fg)]/75"); - expect(hoveredTick?.className).toContain("w-6"); + expect(hoveredTick?.className).toContain("w-2"); + }); + + it("uses the normal geometry for failed, interrupted, and queued ticks", () => { + const entries: readonly ChatUserMinimapSourceEntry[] = [ + { ...ENTRIES[0]!, turnOutcome: "failed" }, + { ...ENTRIES[1]!, turnOutcome: "interrupted" }, + { ...ENTRIES[1]!, key: "queued", fullUserOrdinal: 1, kind: "queued", turnOutcome: null }, + ]; + const view = render( + , + ); + + const ticks = [...view.container.querySelectorAll("[data-minimap-tick]")]; + expect(ticks).toHaveLength(3); + expect(ticks.every((tick) => ( + tick.className.includes("left-0") + && tick.className.includes("h-0.5") + && tick.className.includes("w-2") + && !tick.className.includes("rotate-45") + ))).toBe(true); + expect(ticks[0]?.className).toContain("bg-red-400/80"); + expect(ticks[1]?.className).toContain("bg-amber-400/70"); + expect(ticks[2]?.className).toContain("bg-cyan-300/70"); }); it("does not draw a guide line between the history ticks", () => { @@ -155,7 +188,11 @@ describe("ChatUserMinimap", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: "Load earlier message markers" })); + const continuationMarker = screen.getByRole("button", { name: "Load earlier message markers" }); + expect(continuationMarker.textContent).toBe("↑"); + expect(continuationMarker.querySelectorAll("span")).toHaveLength(1); + + fireEvent.click(continuationMarker); expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); expect(screen.getByTestId("chat-user-minimap")).toBeTruthy(); diff --git a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx index 3ee09cf742..114f68a15c 100644 --- a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx @@ -42,18 +42,10 @@ type ChatUserMinimapProps = { keyboardFocusRequestId?: number | null; }; -/** Lens widths by distance from the hovered tick; index 3+ is "everything else". */ -const LENS_WIDTHS = ["w-6", "w-4", "w-2.5", "w-2"] as const; - -function lensWidthClass(distance: number | null): string { - if (distance === null) return LENS_WIDTHS[LENS_WIDTHS.length - 1]!; - return LENS_WIDTHS[Math.min(distance, LENS_WIDTHS.length - 1)]!; -} - /** * Tick colour, in precedence order: turn outcome > viewport-active > lens centre - * > rest. A failed/stopped turn is rare and worth more than the position cue, - * which the lens width still conveys. + * > rest. Tick geometry stays constant so colour remains the only type-specific + * visual treatment. */ function tickToneClass( outcome: ChatUserMinimapTurnOutcome | null, @@ -74,11 +66,6 @@ function turnOutcomeLabel(outcome: ChatUserMinimapTurnOutcome | null): string | return null; } -/** Colour alone must never carry the signal, so these ticks also render thicker. */ -function isAttentionOutcome(outcome: ChatUserMinimapTurnOutcome | null): boolean { - return outcome === "failed" || outcome === "interrupted"; -} - function targetsPreviewCard(target: EventTarget): boolean { return target instanceof Element && target.closest("[data-minimap-preview]") !== null; } @@ -228,7 +215,6 @@ export function ChatUserMinimap({ else onLoadOlderHistory?.(); }} > -