feat(session-ui): group consecutive user messages under one meta line (#57552)

This commit is contained in:
Kit Langton
2026-10-20 21:52:35 -07:00
committed by GitHub
Unverified
parent 1afe5e2c53
commit 34e3e8fab8
7 changed files with 484 additions and 90 deletions
@@ -715,3 +715,42 @@ test("scrolls the timeline behind the composer region, fading at the card, and k
await expect.poll(async () => (await dock.boundingBox())!.height).toBeGreaterThan(before + 40)
await expect.poll(geometry).toEqual(expected)
})
test("groups consecutive sent prompts under the last one's meta line, with each bubble's actions beside it", async ({
page,
}) => {
const prompt = (id: string, text: string, created: number) =>
userMessage([userText(text, { id: `prt_${id}_text` })], { id, created })
await setupTimeline(page, {
messages: [
prompt("msg_0001_run", "Group these prompts.", 1700000000000),
prompt("msg_0002_run", "Like the iOS app does.", 1700000001000),
],
})
const row = (id: string) =>
page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`)
const first = row("msg_0001_run")
const last = row("msg_0002_run")
const bubble = (row: Locator) => row.locator('[data-slot="user-message-text"]')
await expect(bubble(last)).toHaveText("Like the iOS app does.")
// Bubbles of a run sit 4px apart, as in the iOS app, and only the last carries the meta line.
await expect
.poll(async () => {
const above = (await bubble(first).boundingBox())!
const below = (await bubble(last).boundingBox())!
return Math.round(below.y - above.y - above.height)
})
.toBe(4)
await expect(first.locator('[data-slot="user-message-meta-tail"]')).toBeHidden()
await expect(last.locator('[data-slot="user-message-meta-tail"]')).toBeVisible()
// The first message keeps its own actions, shown beside its bubble on hover.
await bubble(first).hover()
await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1")
await expect(first.getByRole("button", { name: "Copy message" })).toHaveCount(1)
})
@@ -194,6 +194,9 @@
border: none;
padding: 8px 12px;
border-radius: 10px;
/* Where two bubbles of a run meet, their trailing corners tighten to 4px. */
border-start-end-radius: calc(10px - 6px * var(--user-run-top, 0));
border-end-end-radius: calc(10px - 6px * var(--user-run-bottom, 0));
transition:
background-color 150ms ease-out,
color 150ms ease-out;
@@ -230,8 +233,8 @@
}
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
height: calc(24px * var(--user-run-meta, 1));
margin-top: calc(4px * var(--user-run-meta, 1));
display: flex;
align-items: center;
justify-content: flex-end;
@@ -292,6 +295,50 @@
transition: none;
}
[data-slot="user-message-copy-wrapper"] > * {
opacity: var(--user-run-meta, 1);
}
/* The last message of a run carries the meta line; the others fold theirs away and offer their actions beside
the bubble. */
&[data-run-folded] {
[data-slot="user-message-body"] {
position: relative;
}
[data-slot="user-message-copy-wrapper"] {
overflow: clip;
pointer-events: none;
}
}
&[data-run-meta-hidden] [data-slot="user-message-copy-wrapper"] {
visibility: hidden;
}
[data-slot="user-message-run-actions"] {
position: absolute;
/* Centered on the bubble's last line. */
bottom: calc(8px + (1lh - 24px) / 2);
inset-inline-end: calc(100% + 4px);
display: flex;
align-items: center;
gap: 10px;
transition: opacity 0.15s ease;
@media (hover: hover) {
opacity: 0;
pointer-events: none;
}
}
&:hover [data-slot="user-message-run-actions"],
&:focus-within [data-slot="user-message-run-actions"],
[data-slot="user-message-run-actions"]:has([data-expanded]) {
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
color: var(--v2-text-text-base);
}
@@ -11,6 +11,7 @@ import {
} from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
import { currentStreamingInput } from "./streaming-input"
import type { UserRun } from "../timeline/user-runs"
// Registers the genui_render renderer with the tool registry.
import "../genui/tool"
@@ -30,6 +31,7 @@ export function SessionUserMessage(props: {
historicalAgent: string
historicalModel: SessionMessageAssistant["model"]
actions?: SessionUserActions
run?: UserRun
}) {
return (
<CurrentUserMessageDisplay
@@ -42,6 +44,7 @@ export function SessionUserMessage(props: {
agent={props.historicalAgent}
model={props.historicalModel}
actions={props.actions}
run={props.run}
/>
)
}
@@ -6,6 +6,7 @@ import {
Index,
onCleanup,
Show,
untrack,
type ComponentProps,
type JSX,
} from "solid-js"
@@ -33,6 +34,8 @@ import { TextShimmer } from "@opencode/ui/text-shimmer"
import { BasicTool } from "../components/basic-tool"
import { reasoningHeading } from "../timeline/projection"
import { Card } from "@opencode/ui/card"
import { springStyle } from "@opencode/ui/motion/solid"
import { userRunSpring, type UserRun } from "../timeline/user-runs"
import type {
PromptAgentAttachment,
PromptFileAttachment,
@@ -267,6 +270,7 @@ export function CurrentUserMessageDisplay(props: {
comments?: SessionUserComment[]
references?: SessionUserAttachmentReference[]
voice?: boolean
run?: UserRun
}) {
const data = useData()
const dialog = useDialog()
@@ -299,6 +303,31 @@ export function CurrentUserMessageDisplay(props: {
const stamp = createMemo(() => timefmt().format(props.message.time.created))
// 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.
const folded = createMemo(() => !!props.run?.below && !!props.text)
const meetsAbove = createMemo(
() => !!props.run?.above && !!props.text && attachments().length === 0 && references().length === 0,
)
const runPose = () => ({ meta: folded() ? 0 : 1, top: meetsAbove() ? 1 : 0, bottom: folded() ? 1 : 0 })
// The first paint is the resting pose; the spring's first write lands a frame later.
const restingPose = untrack(runPose)
let root: HTMLDivElement | undefined
springStyle(
() => root,
runPose,
(el, value) => {
el.style.setProperty("--user-run-meta", `${value.meta}`)
el.style.setProperty("--user-run-top", `${value.top}`)
el.style.setProperty("--user-run-bottom", `${value.bottom}`)
el.toggleAttribute("data-run-meta-hidden", value.meta === 0)
},
userRunSpring,
)
const copy = async (text: string) => {
if (!text || !(await writeClipboard(text))) return
setState("copied", true)
@@ -325,6 +354,96 @@ export function CurrentUserMessageDisplay(props: {
setState("updating", false)
}
const renderActions = () => (
<>
<Show when={pending()}>
<MessageActionButton
icon="arrow-down-to-line"
label={i18n.t("ui.message.moveToQueue")}
disabled={state.updating}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void updatePending("queue")
}}
aria-label={i18n.t("ui.message.moveToQueue")}
/>
<MessageActionButton
icon="outline-trash"
label={i18n.t("ui.message.deletePending")}
disabled={state.updating}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void updatePending("remove")
}}
aria-label={i18n.t("ui.message.deletePending")}
/>
</Show>
<Show when={props.actions?.revert && !pending()}>
<MessageActionButton
icon="reset"
label={i18n.t("ui.message.revertMessage")}
disabled={state.reverting}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void revert()
}}
aria-label={i18n.t("ui.message.revertMessage")}
/>
</Show>
{/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */}
<Show
when={props.voice}
fallback={
<Show when={props.text}>
<MessageActionButton
icon={state.copied ? "check" : "copy"}
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void copy(props.text)
}}
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</Show>
}
>
<Menu.Context modal={false}>
<Tooltip
appearance="compact"
value={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
placement="top"
gutter={4}
>
<Menu.Context.Trigger
as={IconButton}
icon={<Icon name={state.copied ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void copy(props.text)
}}
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</Tooltip>
<Menu.Context.Portal>
<Menu.Context.Content>
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
<Menu.Item onSelect={() => void copy(props.message.text)}>
{i18n.t("ui.message.copyFullMessage")}
</Menu.Item>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
</>
)
const renderAttachments = () => (
<Show when={attachments().length > 0 || references().length > 0}>
<div data-slot="user-message-attachments">
@@ -381,8 +500,16 @@ export function CurrentUserMessageDisplay(props: {
return (
<div
ref={root}
data-component="user-message"
data-pending={waiting() ? "true" : undefined}
data-run-folded={folded() ? "" : undefined}
data-run-meta-hidden={restingPose.meta === 0 ? "" : undefined}
style={{
"--user-run-meta": `${restingPose.meta}`,
"--user-run-top": `${restingPose.top}`,
"--user-run-bottom": `${restingPose.bottom}`,
}}
data-timeline-part-id={props.text ? `${props.message.id}:text:0` : undefined}
>
<Show when={waiting()}>
@@ -415,6 +542,9 @@ export function CurrentUserMessageDisplay(props: {
<UserMessageComments comments={comments()} bounded />
</Show>
</div>
<Show when={folded()}>
<div data-slot="user-message-run-actions">{renderActions()}</div>
</Show>
</div>
</Show>
{/* Like the TUI, a pending steer of only attachments still offers its actions. */}
@@ -435,91 +565,7 @@ export function CurrentUserMessageDisplay(props: {
{stamp()}
</span>
</span>
<Show when={pending()}>
<MessageActionButton
icon="arrow-down-to-line"
label={i18n.t("ui.message.moveToQueue")}
disabled={state.updating}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void updatePending("queue")
}}
aria-label={i18n.t("ui.message.moveToQueue")}
/>
<MessageActionButton
icon="outline-trash"
label={i18n.t("ui.message.deletePending")}
disabled={state.updating}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void updatePending("remove")
}}
aria-label={i18n.t("ui.message.deletePending")}
/>
</Show>
<Show when={props.actions?.revert && !pending()}>
<MessageActionButton
icon="reset"
label={i18n.t("ui.message.revertMessage")}
disabled={state.reverting}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void revert()
}}
aria-label={i18n.t("ui.message.revertMessage")}
/>
</Show>
{/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */}
<Show
when={props.voice}
fallback={
<Show when={props.text}>
<MessageActionButton
icon={state.copied ? "check" : "copy"}
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void copy(props.text)
}}
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</Show>
}
>
<Menu.Context modal={false}>
<Tooltip
appearance="compact"
value={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
placement="top"
gutter={4}
>
<Menu.Context.Trigger
as={IconButton}
icon={<Icon name={state.copied ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
void copy(props.text)
}}
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</Tooltip>
<Menu.Context.Portal>
<Menu.Context.Content>
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
<Menu.Item onSelect={() => void copy(props.message.text)}>
{i18n.t("ui.message.copyFullMessage")}
</Menu.Item>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
{renderActions()}
</div>
</Show>
</div>
@@ -6,9 +6,10 @@ import type {
} from "@opencode/client/promise"
import { useI18n } from "@opencode/ui/context/i18n"
import { Option, Predicate, Schema } from "effect"
import { For, Show, createEffect, createMemo, type Accessor, type JSX } from "solid-js"
import { For, Show, createEffect, createMemo, untrack, type Accessor, type JSX } from "solid-js"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { springStyle } from "@opencode/ui/motion/solid"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { useData } from "../context"
@@ -33,6 +34,8 @@ import { SessionRetry } from "../components/session-retry"
import { SessionError } from "../components/session-error"
import { timelineCategory, type TimelineDetail } from "./detail"
import { currentToolFailed } from "../message/current-tool-state"
import { userMessageRuns, userRunSpring } from "./user-runs"
import { attached } from "../components/message-file"
import {
createReactiveTimelineProjection,
Timeline,
@@ -114,6 +117,33 @@ export function createSessionTimelineRowRenderer(input: {
return owners
})
const runs = createMemo(() => {
const messages = input.projection.messageByID()
const context = input.projection.userContextByID()
return userMessageRuns(input.projection.rows(), [...messages.values()], (id) => {
const message = messages.get(id)
const user = context.get(id)
if (message?.type !== "user" || !user) return undefined
const status = input.actions?.pending?.status(id)
const presentation = input.presentation(message)
return {
kind: status === "steering" || status === "stranded" ? "pending" : "sent",
agent: user.agent,
model: user.model,
text: !!(presentation?.displayText ?? message.text),
attachments: (message.files ?? []).some(attached) || !!presentation?.references?.length,
}
})
})
const run = (messageID: string) =>
createMemo(() => runs().get(messageID), undefined, {
equals: (a, b) => a?.above === b?.above && a?.below === b?.below,
})
const workingTurn = (messageID: string) =>
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
@@ -255,7 +285,9 @@ export function createSessionTimelineRowRenderer(input: {
}
reasoningOpen={(id) => input.disclosure.value(id)}
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
toolDefaultOpen={(tool) => (input.timelineDetail || tool.name === "execute" ? contentDefaultOpen(tool) : false)}
toolDefaultOpen={(tool) =>
input.timelineDetail || tool.name === "execute" ? contentDefaultOpen(tool) : false
}
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
fileOpen={(path) =>
@@ -787,8 +819,31 @@ export function createSessionTimelineRowRenderer(input: {
)
}
// A run's messages sit as close as the iOS app's (4 px); the gap springs as a message joins or leaves.
function TurnGap(props: { messageID: string }) {
let element: HTMLDivElement | undefined
const joined = run(props.messageID)
springStyle(
() => element,
() => ({ height: joined()?.above ? 4 : 24 }),
(el, value) => {
el.style.height = `${value.height}px`
},
userRunSpring,
)
return (
<div
ref={element}
data-timeline-row="TurnGap"
aria-hidden="true"
style={{ height: untrack(joined)?.above ? "4px" : "24px" }}
/>
)
}
const render = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
if (Predicate.isTagged(row(), "TurnGap")) return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
if (Predicate.isTagged(row(), "TurnGap")) return <TurnGap messageID={row().userMessageID} />
// The host owns live status and renders this slot itself.
if (Predicate.isTagged(row(), "Activity")) return null
@@ -809,6 +864,7 @@ export function createSessionTimelineRowRenderer(input: {
})
const context = createMemo(() => input.projection.userContextByID().get(current().userMessageID))
const joined = run(current().userMessageID)
return (
<Frame row={current()}>
@@ -829,6 +885,7 @@ export function createSessionTimelineRowRenderer(input: {
historicalAgent={context()?.agent ?? ""}
historicalModel={context()?.model ?? { id: "", providerID: "" }}
actions={input.actions}
run={joined()}
/>
</div>
</div>
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "./detail"
import { Timeline } from "./projection"
import { userMessageRuns, type UserRunMember } from "./user-runs"
const user = (id: string, created: number) =>
({ id, type: "user", text: id, time: { created } }) satisfies SessionMessageInfo
const assistant = (id: string, parentID: string, created: number) =>
({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
metadata: { parentID },
content: [{ type: "text", text: "answer" }],
time: { created, completed: created + 1 },
}) satisfies SessionMessageInfo
const sent: UserRunMember = {
kind: "sent",
agent: "build",
model: { id: "model", providerID: "provider" },
text: true,
attachments: false,
}
function runs(
messages: SessionMessageInfo[],
members: Record<string, Partial<UserRunMember>> = {},
preset?: (typeof timelinePresets)[number]["id"],
) {
const pending = new Set(Object.keys(members).filter((id) => members[id]?.kind === "pending"))
const detail = timelinePresets.find((item) => item.id === preset)?.value
const rows = Timeline.constructSessionMessageRows(
messages,
true,
{ type: "busy" },
pending,
false,
false,
undefined,
detail,
).rows
return Object.fromEntries(userMessageRuns(rows, messages, (id) => ({ ...sent, ...members[id] })))
}
const alone = { above: false, below: false }
describe("userMessageRuns", () => {
test("joins consecutive sent messages into one run", () => {
expect(runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3)])).toEqual({
msg_1: { above: false, below: true },
msg_2: { above: true, below: true },
msg_3: { above: true, below: false },
})
})
test("assistant output between two messages ends the run", () => {
expect(runs([user("msg_1", 1), assistant("msg_2", "msg_1", 2), user("msg_3", 3), user("msg_4", 4)])).toEqual({
msg_1: alone,
msg_3: { above: false, below: true },
msg_4: { above: true, below: false },
})
})
test("pending input never joins a sent message or another pending one", () => {
expect(
runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3), user("msg_4", 4)], {
msg_3: { kind: "pending" },
msg_4: { kind: "pending" },
}),
).toEqual({
msg_1: { above: false, below: true },
msg_2: { above: true, below: false },
msg_3: alone,
msg_4: alone,
})
})
test("a different agent or model ends the run, since the run names one", () => {
expect(
runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3)], {
msg_2: { agent: "plan" },
msg_3: { agent: "plan", model: { id: "model", providerID: "provider", variant: "high" } },
}),
).toEqual({ msg_1: alone, msg_2: alone, msg_3: alone })
})
test("a switch or compaction ends the run even when the timeline hides its notice", () => {
const messages = [
user("msg_1", 1),
{ id: "msg_2", type: "model-switched", model: { id: "model", providerID: "provider" }, time: { created: 2 } },
user("msg_3", 3),
] satisfies SessionMessageInfo[]
expect(runs(messages, {}, "quiet")).toEqual({ msg_1: alone, msg_3: alone })
})
test("only text bubbles join; attachments keep their own gap and the text above keeps its meta line", () => {
expect(
runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3), user("msg_4", 4)], {
msg_2: { text: false, attachments: true },
msg_4: { attachments: true },
}),
).toEqual({ msg_1: alone, msg_2: alone, msg_3: alone, msg_4: alone })
})
test("a lone message stands alone", () => {
expect(runs([user("msg_1", 1), assistant("msg_2", "msg_1", 2)])).toEqual({ msg_1: alone })
})
})
@@ -0,0 +1,87 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
import { Predicate } from "effect"
import type { TimelineRow } from "./timeline-row"
/** A user message's place in a run of consecutive user messages: joined to the one above, below, or both. */
export type UserRun = { above: boolean; below: boolean }
/** What a user message brings to a run: its delivery, the agent and model its meta line names, and its surfaces. */
export type UserRunMember = {
kind: "sent" | "pending"
agent: string
model: SessionMessageAssistant["model"]
/** It has a text bubble, the surface that carries a run's meta line. */
text: boolean
/** Attachments sit above its bubble. */
attachments: boolean
}
/** Layout moves in a run settle on Kit's calm spring: .4 s, bounce 0. */
export const userRunSpring = { omega: (2 * Math.PI) / 0.4, zeta: 1 }
/**
* Like the iOS app's grouped corners, consecutive user messages with nothing but turn gaps between them read as
* one block under a single meta line. Only sent text bubbles with the same agent and model join, and an agent,
* model, location or compaction boundary between them ends the run even when the timeline hides its notice.
*/
export function userMessageRuns(
rows: readonly TimelineRow.TimelineRow[],
messages: readonly SessionMessageInfo[],
member: (messageID: string) => UserRunMember | undefined,
) {
const segments = boundarySegments(messages)
const members = rows.filter((row) => !Predicate.isTagged(row, "TurnGap"))
const joins = (above: TimelineRow.TimelineRow | undefined, below: TimelineRow.TimelineRow | undefined) => {
if (!Predicate.isTagged(above, "UserMessage") || !Predicate.isTagged(below, "UserMessage")) return false
const upper = member(above.userMessageID)
const lower = member(below.userMessageID)
return (
!!upper &&
!!lower &&
upper.kind === "sent" &&
lower.kind === "sent" &&
upper.text &&
lower.text &&
!lower.attachments &&
upper.agent === lower.agent &&
upper.model.providerID === lower.model.providerID &&
upper.model.id === lower.model.id &&
upper.model.variant === lower.model.variant &&
segments.get(above.userMessageID) === segments.get(below.userMessageID)
)
}
return new Map(
members.flatMap((row, index) =>
Predicate.isTagged(row, "UserMessage")
? [
[
row.userMessageID,
{ above: joins(members[index - 1], row), below: joins(row, members[index + 1]) } satisfies UserRun,
] as const,
]
: [],
),
)
}
/** Each user message's count of agent, model, location and compaction boundaries before it. */
function boundarySegments(messages: readonly SessionMessageInfo[]) {
const segments = new Map<string, number>()
let segment = 0
messages.forEach((message) => {
if (
message.type === "agent-switched" ||
message.type === "model-switched" ||
message.type === "location-switched" ||
message.type === "compaction"
)
segment += 1
if (message.type === "user") segments.set(message.id, segment)
})
return segments
}