From 34e3e8fab808f2b26740103264749e713aa458e2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 21:52:35 -0700 Subject: [PATCH] feat(session-ui): group consecutive user messages under one meta line (#57552) --- .../e2e/regression/timeline-geometry.spec.ts | 39 ++++ .../src/components/message-part.css | 51 ++++- .../src/message/current-message.tsx | 3 + .../src/message/message-content.tsx | 216 +++++++++++------- .../src/timeline/session-timeline-row.tsx | 63 ++++- .../session-ui/src/timeline/user-runs.test.ts | 115 ++++++++++ packages/session-ui/src/timeline/user-runs.ts | 87 +++++++ 7 files changed, 484 insertions(+), 90 deletions(-) create mode 100644 packages/session-ui/src/timeline/user-runs.test.ts create mode 100644 packages/session-ui/src/timeline/user-runs.ts diff --git a/packages/app/e2e/regression/timeline-geometry.spec.ts b/packages/app/e2e/regression/timeline-geometry.spec.ts index d053ccd214c..a42dcc59358 100644 --- a/packages/app/e2e/regression/timeline-geometry.spec.ts +++ b/packages/app/e2e/regression/timeline-geometry.spec.ts @@ -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) +}) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 5764b650803..12d846554e9 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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); } diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index c28b8159348..8592235c4fb 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -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 ( ) } diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 33e9bb9544e..a7ec02eea8f 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -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 = () => ( + <> + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void updatePending("queue") + }} + aria-label={i18n.t("ui.message.moveToQueue")} + /> + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void updatePending("remove") + }} + aria-label={i18n.t("ui.message.deletePending")} + /> + + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void revert() + }} + aria-label={i18n.t("ui.message.revertMessage")} + /> + + {/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */} + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void copy(props.text) + }} + aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + + } + > + + + } + 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")} + /> + + + + void copy(props.text)}>{i18n.t("ui.message.copyMessage")} + void copy(props.message.text)}> + {i18n.t("ui.message.copyFullMessage")} + + + + + + + ) + const renderAttachments = () => ( 0 || references().length > 0}>
@@ -381,8 +500,16 @@ export function CurrentUserMessageDisplay(props: { return (
@@ -415,6 +542,9 @@ export function CurrentUserMessageDisplay(props: {
+ +
{renderActions()}
+
{/* Like the TUI, a pending steer of only attachments still offers its actions. */} @@ -435,91 +565,7 @@ export function CurrentUserMessageDisplay(props: { {stamp()} - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void updatePending("queue") - }} - aria-label={i18n.t("ui.message.moveToQueue")} - /> - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void updatePending("remove") - }} - aria-label={i18n.t("ui.message.deletePending")} - /> - - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void revert() - }} - aria-label={i18n.t("ui.message.revertMessage")} - /> - - {/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */} - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void copy(props.text) - }} - aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - - } - > - - - } - 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")} - /> - - - - void copy(props.text)}>{i18n.t("ui.message.copyMessage")} - void copy(props.message.text)}> - {i18n.t("ui.message.copyFullMessage")} - - - - - + {renderActions()} diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 315e905b273..0765133c418 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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 ( + diff --git a/packages/session-ui/src/timeline/user-runs.test.ts b/packages/session-ui/src/timeline/user-runs.test.ts new file mode 100644 index 00000000000..787a8f07cef --- /dev/null +++ b/packages/session-ui/src/timeline/user-runs.test.ts @@ -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> = {}, + 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 }) + }) +}) diff --git a/packages/session-ui/src/timeline/user-runs.ts b/packages/session-ui/src/timeline/user-runs.ts new file mode 100644 index 00000000000..8e1cde90cb9 --- /dev/null +++ b/packages/session-ui/src/timeline/user-runs.ts @@ -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() + 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 +}