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 (
+
+ )
+ }
+
const render = (row: Accessor, onSizeChange?: () => void) => {
- if (Predicate.isTagged(row(), "TurnGap")) return
+ if (Predicate.isTagged(row(), "TurnGap")) return
// 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 (
@@ -829,6 +885,7 @@ export function createSessionTimelineRowRenderer(input: {
historicalAgent={context()?.agent ?? ""}
historicalModel={context()?.model ?? { id: "", providerID: "" }}
actions={input.actions}
+ run={joined()}
/>
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
+}