Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions packages/app/e2e/regression/session-queue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -154,11 +156,18 @@ 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()
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")
Expand Down
3 changes: 3 additions & 0 deletions packages/session-ui/src/message/current-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand Down
32 changes: 29 additions & 3 deletions packages/session-ui/src/message/message-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ function modelItems(data: ReturnType<typeof useData>, 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 (
<For each={props.items}>
Expand Down Expand Up @@ -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[]
Expand Down Expand Up @@ -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<MetaItem[]>(() => {
const agentItems = createMemo<MetaItem[]>(() => {
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<MetaItem[]>(() => {
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.
Expand Down
18 changes: 18 additions & 0 deletions packages/session-ui/src/message/prompt-message.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionMessageUser>(() => ({
Expand Down Expand Up @@ -102,6 +110,14 @@ export const Pending = {
/>{" "}
Grouped prompts
</label>
<label>
<input
type="checkbox"
checked={state.changed}
onChange={(event) => setState("changed", event.currentTarget.checked)}
/>{" "}
Changes model
</label>
</div>
<CurrentSessionProviders document={document()}>
<Show when={state.grouped && state.text}>
Expand All @@ -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 }}
/>
Expand All @@ -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 }}
Expand Down
52 changes: 52 additions & 0 deletions packages/session-ui/src/timeline/projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
85 changes: 67 additions & 18 deletions packages/session-ui/src/timeline/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
rows,
sessionMessageByID,
userContextByID: indexUserContext(input.sessionMessages),
previousSelectionByID: indexPreviousSelection(input.sessionMessages),
}
}

Expand All @@ -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.
Expand Down Expand Up @@ -224,6 +226,7 @@ export function createReactiveTimelineProjection(input: {
rows,
sessionMessageByID,
userContextByID,
previousSelectionByID,
}
}

Expand Down Expand Up @@ -675,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<SessionMessageAssistant, "error">) {
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"]) {
Expand Down Expand Up @@ -792,8 +797,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<UserSelection> {
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<string, { agent: string; model: ModelRef }>()
const result = new Map<string, UserSelection>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let userID: string | undefined
Expand All @@ -805,19 +826,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
Expand All @@ -832,6 +842,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<string, UserSelection>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let previous: { chosen: Partial<UserSelection>; 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<string, SessionMessageAssistant[]>()
let userID: string | undefined
Expand Down
2 changes: 2 additions & 0 deletions packages/session-ui/src/timeline/session-timeline-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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?.()}
Expand Down
Loading