From ac5231adc781b6bf8b6d50eb26d07cc6fa957984 Mon Sep 17 00:00:00 2001 From: vimtor <36263538+vimtor@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:57:07 +0000 Subject: [PATCH 1/3] fix(session-ui): name a pending steer's model only when it changes --- .../app/e2e/regression/session-queue.spec.ts | 6 +- .../session-message-projection.spec.ts | 7 ++ .../src/message/current-message.tsx | 3 + .../src/message/message-content.tsx | 32 +++++++- .../src/message/prompt-message.stories.tsx | 18 +++++ .../src/timeline/projection.test.ts | 52 +++++++++++++ .../session-ui/src/timeline/projection.ts | 75 +++++++++++++++---- .../src/timeline/session-timeline-row.tsx | 2 + 8 files changed, 174 insertions(+), 21 deletions(-) diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 34dc2f25d307..9d3314da91a4 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -1179,10 +1179,8 @@ for (const delivery of ["steer", "queue"] as const) { await expect(bubble).toHaveCSS("color", colors.pending.text) await expect(bubble).toHaveCSS("transition-duration", "0s") await expect(pending.locator('[data-slot="user-message-copy-wrapper"]')).toHaveCSS("opacity", "1") - // Pending stands in for the time of a steer that has not been sent. - await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending\s*ยท\s*Queue Model$/, { - useInnerText: true, - }) + // The steer keeps U1's agent and model, so Pending is all its meta line says. + await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending$/, { useInnerText: true }) await bubble.evaluate((element) => (element.dataset.deliveryMarker = "pending")) } diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index 03ca9604ed56..152e1042f260 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -65,6 +65,8 @@ story("drops a pending footer at once on delivery with and without hover", async await page.mouse.move(0, 0) await expect(footer).toHaveCSS("height", "20px") await expect(footer).toHaveCSS("margin-top", "8px") + // A steer names only the model it changes, so change it to measure the gap after the labels. + await timeline.getByLabel("Changes model", { exact: true }).check() for (const hover of [true, false]) { await timeline.getByLabel("Delivery state").selectOption("steering") @@ -154,7 +156,12 @@ story("marks pending metadata without badging delivered, starting, or queued pro await expect(separator).toHaveText("\u00B7") await expect(timeline.locator('[data-slot="user-message-meta-label"]')).toHaveCSS("gap", "4px") + // A steer with the previous prompt's agent and model joins the running turn unchanged, so it names neither. await timeline.getByLabel("Delivery state").selectOption("steering") + await expect(timeline.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending$/, { useInnerText: true }) + await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveCount(0) + await timeline.getByLabel("Changes model", { exact: true }).check() + await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveText(["Claude Sonnet 4", "Balanced"]) await timeline.getByLabel("Voice message", { exact: true }).check() const voice = timeline.getByRole("img", { name: "Voice", exact: true }) await expect(voice).toBeVisible() diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index 7f5c01a1b187..654d32f753c2 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -27,6 +27,8 @@ export function SessionUserMessage(props: { voice?: boolean historicalAgent: string historicalModel: SessionMessageAssistant["model"] + /** The agent and model the previous prompt chose; a waiting steer names only what it changes. */ + previousSelection?: { agent: string; model: SessionMessageAssistant["model"] } actions?: SessionUserActions run?: UserRun fold?: boolean @@ -43,6 +45,7 @@ export function SessionUserMessage(props: { voice={props.voice} agent={props.historicalAgent} model={props.historicalModel} + previousSelection={props.previousSelection} actions={props.actions} run={props.run} fold={props.fold} diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 469d074c5022..15ea363eb7c4 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -92,6 +92,12 @@ function modelItems(data: ReturnType, model: SessionMessageAssis return [{ label: name }, { label: model.variant[0]?.toUpperCase() + model.variant.slice(1), variant: true }] } +function sameModel(a: SessionMessageAssistant["model"], b: SessionMessageAssistant["model"]) { + return ( + a.providerID === b.providerID && a.id === b.id && (a.variant ?? "default") === (b.variant ?? "default") + ) +} + function MetaItems(props: { items: MetaItem[]; slot: "user-message" | "text-part" }) { return ( @@ -304,6 +310,8 @@ export function CurrentUserMessageDisplay(props: { text: string agent: string model: SessionMessageAssistant["model"] + /** The agent and model the previous prompt chose; a waiting steer names only what it changes. */ + previousSelection?: { agent: string; model: SessionMessageAssistant["model"] } actions?: SessionUserActions comments?: SessionUserComment[] references?: SessionUserAttachmentReference[] @@ -353,13 +361,31 @@ export function CurrentUserMessageDisplay(props: { // Mobile shows the current agent and model in the composer and switch notices, so repeating them per message is noise. const mobile = createMediaQuery("(max-width: 767px)") - const labels = createMemo(() => { + const agentItems = createMemo(() => { const agent = agentLabel(props.agent, data.agents) - return [...(agent ? [{ label: agent }] : []), ...modelItems(data, props.model)] + return agent ? [{ label: agent }] : [] + }) + + // A waiting steer joins the running turn, so its agent and model only say something when it changes them. + const changedLabels = createMemo(() => { + const previous = props.previousSelection + + if (!previous) return [...agentItems(), ...modelItems(data, props.model)] + + return [ + ...(previous.agent === props.agent ? [] : agentItems()), + ...(sameModel(previous.model, props.model) ? [] : modelItems(data, props.model)), + ] }) - const metaLabels = createMemo(() => (mobile() ? [] : labels())) + const metaLabels = createMemo(() => { + if (mobile()) return [] + + if (waiting()) return changedLabels() + + return [...agentItems(), ...modelItems(data, props.model)] + }) // In a run, only the last message keeps the meta line; the others offer their actions beside the bubble. // Bubble corners tighten where two bubbles meet, as in the iOS app's grouped corners. diff --git a/packages/session-ui/src/message/prompt-message.stories.tsx b/packages/session-ui/src/message/prompt-message.stories.tsx index 0e01dd5c340d..a9ab9116f483 100644 --- a/packages/session-ui/src/message/prompt-message.stories.tsx +++ b/packages/session-ui/src/message/prompt-message.stories.tsx @@ -21,11 +21,19 @@ export const Pending = { voice: boolean text: boolean grouped: boolean + changed: boolean }>({ status: "steering", voice: false, text: true, grouped: false, + changed: false, + }) + + // The prompt before this one; a waiting steer names only the agent and model it changes. + const previousSelection = () => ({ + agent: "build", + model: state.changed ? { ...STORY_MODEL, variant: "precise" } : STORY_MODEL, }) const message = createMemo(() => ({ @@ -102,6 +110,14 @@ export const Pending = { />{" "} Grouped prompts + @@ -111,6 +127,7 @@ export const Pending = { text="Keep the earlier prompt in the same run." agent="build" model={STORY_MODEL} + previousSelection={previousSelection()} actions={actions} run={{ above: false, below: true }} /> @@ -121,6 +138,7 @@ export const Pending = { text={message().text} agent="build" model={STORY_MODEL} + previousSelection={previousSelection()} voice={state.voice} actions={actions} run={{ above: state.grouped && state.text, below: false }} diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index d33515443e85..5e947ca359e7 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -269,6 +269,58 @@ describe("createTimelineProjection", () => { expect(result.rowByKey.get("user-message:user-1")).toBe(result.rows[2]) }) + test("indexes the agent and model each prompt's predecessor chose, ignoring later switches in its turn", () => { + const first = { id: "first", providerID: "provider" } satisfies ModelRef + const second = { id: "second", providerID: "provider", variant: "high" } satisfies ModelRef + + const messages = [ + { id: "user-1", type: "user", text: "first", time: { created: 1 } }, + { + id: "assistant-1", + type: "assistant", + agent: "build", + model: first, + content: [{ type: "text", text: "answer" }], + time: { created: 2, completed: 3 }, + }, + // A steer's selection reconfigures the running turn before the steer is delivered. + { id: "model", type: "model-switched", model: second, time: { created: 4 } }, + { + id: "assistant-2", + type: "assistant", + agent: "build", + model: second, + content: [{ type: "text", text: "more" }], + time: { created: 5, completed: 6 }, + }, + { + id: "user-2", + type: "user", + text: "steer", + metadata: { agent: "build", model: { modelID: "second", providerID: "provider", variant: "high" } }, + time: { created: 7 }, + }, + { + id: "user-3", + type: "user", + text: "another steer", + metadata: { agent: "plan", model: { modelID: "second", providerID: "provider", variant: "high" } }, + time: { created: 8 }, + }, + ] satisfies SessionMessageInfo[] + + const result = createTimelineProjection({ + sessionMessages: messages, + status: { type: "busy" }, + reasoningMode: "full", + }) + + expect(result.previousSelectionByID.has("user-1")).toBe(false) + // Without metadata, the first prompt chose what its first reply ran with, not the switch a later steer made. + expect(result.previousSelectionByID.get("user-2")).toEqual({ agent: "build", model: first }) + expect(result.previousSelectionByID.get("user-3")).toEqual({ agent: "build", model: second }) + }) + test("reuses a stable projected row array", () => { const messages = [ { id: "user-1", type: "user", text: "first", time: { created: 1 } }, diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index a43ae4bbc247..645e75c7df53 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -124,6 +124,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) { rows, sessionMessageByID, userContextByID: indexUserContext(input.sessionMessages), + previousSelectionByID: indexPreviousSelection(input.sessionMessages), } } @@ -144,6 +145,7 @@ export function createReactiveTimelineProjection(input: { ) const userContextByID = createMemo(() => indexUserContext(input.sessionMessages())) + const previousSelectionByID = createMemo(() => indexPreviousSelection(input.sessionMessages())) const assistantMessagesByParent = createMemo(() => indexAssistantMessages(input.sessionMessages())) // Row structure depends on the empty/non-empty boundary, not each text delta. @@ -224,6 +226,7 @@ export function createReactiveTimelineProjection(input: { rows, sessionMessageByID, userContextByID, + previousSelectionByID, } } @@ -792,8 +795,24 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine return next } +type UserSelection = { agent: string; model: ModelRef } + +/** The agent and model a user message's metadata names, when it names them. */ +function localSelection(message: SessionMessageUser): Partial { + const localModel = Option.getOrUndefined(decodeLocalModel(message.metadata))?.model + const localModelID = localModel?.id ?? localModel?.modelID + + return { + agent: Option.getOrUndefined(decodeLocalAgent(message.metadata))?.agent, + model: + localModel && localModelID + ? { id: localModelID, providerID: localModel.providerID, variant: localModel.variant } + : undefined, + } +} + function indexUserContext(messages: SessionMessageInfo[]) { - const result = new Map() + const result = new Map() let agent = "" let model: ModelRef = { id: "", providerID: "" } let userID: string | undefined @@ -805,19 +824,8 @@ function indexUserContext(messages: SessionMessageInfo[]) { if (message.type === "user") { userID = message.id - const localModel = Option.getOrUndefined(decodeLocalModel(message.metadata))?.model - const localModelID = localModel?.id ?? localModel?.modelID - - result.set(message.id, { - agent: Option.match(decodeLocalAgent(message.metadata), { - onNone: () => agent, - onSome: (local) => local.agent, - }), - model: - localModel && localModelID - ? { id: localModelID, providerID: localModel.providerID, variant: localModel.variant } - : model, - }) + const local = localSelection(message) + result.set(message.id, { agent: local.agent ?? agent, model: local.model ?? model }) } if (message.type === "shell") userID = undefined @@ -832,6 +840,45 @@ function indexUserContext(messages: SessionMessageInfo[]) { return result } +/** + * The agent and model the previous user message chose, for each user message after the first. A message whose + * metadata names neither chose what its first reply ran with; later replies, which a steer's switch reconfigures, + * do not count, so a waiting steer can tell whether it changes the agent or model of the turn it joins. + */ +function indexPreviousSelection(messages: SessionMessageInfo[]) { + const result = new Map() + let agent = "" + let model: ModelRef = { id: "", providerID: "" } + let previous: { chosen: Partial; current: UserSelection } | undefined + + messages.forEach((message) => { + if (message.type === "agent-switched") agent = message.agent + + if (message.type === "model-switched") model = message.model + + if (message.type === "assistant") { + agent = message.agent + model = message.model + + if (previous) { + previous.chosen.agent ??= message.agent + previous.chosen.model ??= message.model + } + } + + if (message.type !== "user") return + + if (previous) + result.set(message.id, { + agent: previous.chosen.agent ?? previous.current.agent, + model: previous.chosen.model ?? previous.current.model, + }) + previous = { chosen: localSelection(message), current: { agent, model } } + }) + + return result +} + function indexAssistantMessages(messages: SessionMessageInfo[]) { const result = new Map() let userID: string | undefined diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 3b6359775c7e..87c5a0fba876 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -938,6 +938,7 @@ export function createSessionTimelineRowRenderer(input: { }) const context = createMemo(() => input.projection.userContextByID().get(current().userMessageID)) + const previous = createMemo(() => input.projection.previousSelectionByID().get(current().userMessageID)) const joined = run(current().userMessageID) const grow = arriving(current().userMessageID) @@ -959,6 +960,7 @@ export function createSessionTimelineRowRenderer(input: { voice={presentation()?.voice} historicalAgent={context()?.agent ?? ""} historicalModel={context()?.model ?? { id: "", providerID: "" }} + previousSelection={previous()} actions={input.actions} run={joined()} fold={input.foldUserMessages?.()} From 067c1078969e7bde01269988f285ac717ae80844 Mon Sep 17 00:00:00 2001 From: vimtor <36263538+vimtor@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:27:16 +0000 Subject: [PATCH 2/3] fix(session-ui): drop the icon beside a pending steer's label --- .../component-tests/session-message-projection.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index 152e1042f260..35e38b2a5af0 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -166,6 +166,8 @@ story("marks pending metadata without badging delivered, starting, or queued pro const voice = timeline.getByRole("img", { name: "Voice", exact: true }) await expect(voice).toBeVisible() await expect(indicator).toBeVisible() + // The faded bubble already marks a steer as waiting, so Pending is plain text with no icon. + await expect(indicator).toHaveText("Pending") await expect(indicator.locator("svg")).toHaveCount(0) await expect(voice.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-wave-audio") From 323c8af39dddfe30c11a92d0cf5e3b179b5add31 Mon Sep 17 00:00:00 2001 From: vimtor <36263538+vimtor@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:52:57 +0000 Subject: [PATCH 3/3] fix(session-ui): satisfy lint in the budget-limit projection --- packages/session-ui/src/timeline/projection.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 645e75c7df53..3802d7db245a 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -678,12 +678,14 @@ function usageLimit( // OpenCode Console says when the budget resets in its refusal's metadata, which every protocol's envelope names `resetsAt`. function budgetLimit(message: Pick) { const type = message.error?.type + if (type !== "provider.budget-limit" && type !== "provider.model-budget-limit") return {} + const kind = type === "provider.budget-limit" ? ("budget-limit" as const) : ("model-budget-limit" as const) const at = Date.parse(/"resetsAt"\s*:\s*"([^"]+)"/.exec(message.error?.response?.body ?? "")?.[1] ?? "") - return { - kind: type === "provider.budget-limit" ? ("budget-limit" as const) : ("model-budget-limit" as const), - ...(Number.isNaN(at) ? {} : { resetAt: at }), - } + + if (Number.isNaN(at)) return { kind } + + return { kind, resetAt: at } } function isInterrupted(error: SessionMessageAssistant["error"]) {