From 1484b6e0b5d1160aacd5e8e8f3b3533068109456 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 21:52:49 -0300 Subject: [PATCH 1/5] fix(web): back-to-back messages from you stack as one burst with a single steer label --- .../chat/MessagesTimeline.logic.test.ts | 57 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 40 +++++++++++-- .../src/components/chat/MessagesTimeline.tsx | 22 +++---- .../chat/TimelineUserMessageRow.tsx | 44 +++++++++++--- 4 files changed, 141 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bd74694dc46af..c94364347f773 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1390,6 +1390,63 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("stacks back-to-back steers as one burst with a single steer label", () => { + const runId = RunId.make("double-steer-run"); + const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); + const message = ( + id: string, + second: number, + role: "user" | "assistant", + inputIntent?: "turn_start" | "steer", + ): TimelineEntry => ({ + kind: "message", + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role, + text: id, + runId, + ...(inputIntent ? { inputIntent } : {}), + createdAt: time(second), + updatedAt: time(second), + streaming: false, + }, + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + message("prompt", 0, "user", "turn_start"), + { + kind: "work", + id: "work", + createdAt: time(4), + entry: { id: "work", createdAt: time(4), runId, label: "Ran command", tone: "tool" }, + }, + message("first-steer", 10, "user", "steer"), + message("second-steer", 12, "user", "steer"), + message("final", 30, "assistant"), + ], + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, + isWorking: false, + expandedRunIds: new Set(), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + const byId = new Map(rows.map((row) => [row.id, row])); + expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext"); + expect(byId.get("first-steer")).toMatchObject({ + groupedWithPrevious: false, + groupedWithNext: true, + continuesSteer: false, + }); + expect(byId.get("second-steer")).toMatchObject({ + groupedWithPrevious: true, + groupedWithNext: false, + continuesSteer: true, + }); + }); + it("keeps the previous turn folded while a newly sent message awaits its turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c26bcf51a718a..65ac8314340ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,5 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; @@ -594,6 +595,11 @@ type MessagesTimelineRowContent = assistantCopyStreaming: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; revertTurnCount?: number | undefined; + /** Your messages sent back to back stack as one burst, like a chat app. */ + groupedWithPrevious?: boolean; + groupedWithNext?: boolean; + /** Set when the message above is a steer too, so the label shows once. */ + continuesSteer?: boolean; } | { kind: "assistant-meta"; @@ -2134,13 +2140,39 @@ export function deriveMessagesTimelineRows(input: { const result = attachTrailingToolGroupsToAssistant( attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds), ); - return result.map((row, index) => - timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1]) - ? { ...row, continuesWorkLog: true } - : row, + return result.map((row, index) => { + if (timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])) { + return { ...row, continuesWorkLog: true }; + } + if (!timelineRowIsOwnUserMessage(row)) return row; + const previous = result[index - 1]; + const groupedWithPrevious = timelineRowIsOwnUserMessage(previous); + const groupedWithNext = timelineRowIsOwnUserMessage(result[index + 1]); + if (!groupedWithPrevious && !groupedWithNext) return row; + return { + ...row, + groupedWithPrevious, + groupedWithNext, + continuesSteer: + groupedWithPrevious && messageIsSteer(row.message) && messageIsSteer(previous.message), + }; + }); +} + +function timelineRowIsOwnUserMessage( + row: MessagesTimelineRow | undefined, +): row is Extract { + return ( + row?.kind === "message" && + row.message.role === "user" && + resolveUserMessagePresentation(row.message).attribution === null ); } +function messageIsSteer(message: ChatMessage): boolean { + return message.inputIntent === "steer" || message.inputIntent === "promoted_queued_to_steer"; +} + function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean { return ( row !== undefined && diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a9cab1554a3d3..2c276a4dcd453 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1433,16 +1433,18 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ? undefined : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" - : (row.kind === "message" && - row.message.role === "assistant" && - !row.showAssistantMeta) || - row.kind === "worktree-setup" || - row.kind === "event" || - row.kind === "attempt-fold" || - row.kind === "html-render" || - row.kind === "mcp-app" - ? "pb-2" - : "pb-4", + : row.kind === "message" && row.groupedWithNext + ? "pb-1" + : (row.kind === "message" && + row.message.role === "assistant" && + !row.showAssistantMeta) || + row.kind === "worktree-setup" || + row.kind === "event" || + row.kind === "attempt-fold" || + row.kind === "html-render" || + row.kind === "mcp-app" + ? "pb-2" + : "pb-4", (row.kind === "message" && row.message.role === "assistant") || row.kind === "assistant-meta" ? "group/assistant" diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index c4363a1b45983..57b81bef898bb 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -274,7 +274,13 @@ export function FoldedMessageHeader(props: { ); } -function UserMessageProjectedStatus({ row }: { row: Extract }) { +function UserMessageProjectedStatus({ + row, + className, +}: { + row: Extract; + className?: string | undefined; +}) { const status = row.projectedItem?.item.status; if ( status === undefined || @@ -284,7 +290,7 @@ function UserMessageProjectedStatus({ row }: { row: Extract +
{status} @@ -465,10 +471,20 @@ function UserTimelineRow({ row }: { row: Extract {userMessage.attribution === "t3code" ? (

) : null} - {row.message.inputIntent === "steer" || - row.message.inputIntent === "promoted_queued_to_steer" ? ( - + {(row.message.inputIntent === "steer" || + row.message.inputIntent === "promoted_queued_to_steer") && + !row.continuesSteer ? ( +
+ +
) : null}
) : null}
- + {!folded || expanded ? ( -
+
{folded && senderThreadId ? ( ctx.onOpenThread(senderThreadId)} tone="muted"> From 014cba2ac21270df43a0876abd8c4ffd751c4b8e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 22:27:04 -0300 Subject: [PATCH 2/5] fix(web): grouped messages update when a new message joins the burst --- .../chat/MessagesTimeline.logic.test.ts | 47 ++++++++++++------- .../components/chat/MessagesTimeline.logic.ts | 5 +- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c94364347f773..066a2212c9248 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1413,25 +1413,38 @@ describe("deriveMessagesTimelineRows", () => { streaming: false, }, }); - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - message("prompt", 0, "user", "turn_start"), - { - kind: "work", - id: "work", - createdAt: time(4), - entry: { id: "work", createdAt: time(4), runId, label: "Ran command", tone: "tool" }, - }, - message("first-steer", 10, "user", "steer"), + const prompt = message("prompt", 0, "user", "turn_start"); + const work: TimelineEntry = { + kind: "work", + id: "work", + createdAt: time(4), + entry: { id: "work", createdAt: time(4), runId, label: "Ran command", tone: "tool" }, + }; + const firstSteer = message("first-steer", 10, "user", "steer"); + const deriveRows = (timelineEntries: TimelineEntry[]) => + deriveMessagesTimelineRows({ + timelineEntries, + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, + isWorking: false, + expandedRunIds: new Set(), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + // The second steer arrives while the first message object is reused, as live updates do. + const before = computeStableMessagesTimelineRows(deriveRows([prompt, work, firstSteer]), { + result: [], + byId: new Map(), + }); + const rows = computeStableMessagesTimelineRows( + deriveRows([ + prompt, + work, + firstSteer, message("second-steer", 12, "user", "steer"), message("final", 30, "assistant"), - ], - latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, - isWorking: false, - expandedRunIds: new Set(), - turnDiffSummaries: [], - supportsConversationRollback: false, - }); + ]), + before, + ).result; const byId = new Map(rows.map((row) => [row.id, row])); expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 65ac8314340ac..e2f88ba1ad326 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2510,7 +2510,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.showAssistantCopyButton === bm.showAssistantCopyButton && a.assistantCopyStreaming === bm.assistantCopyStreaming && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && - a.revertTurnCount === bm.revertTurnCount + a.revertTurnCount === bm.revertTurnCount && + a.groupedWithPrevious === bm.groupedWithPrevious && + a.groupedWithNext === bm.groupedWithNext && + a.continuesSteer === bm.continuesSteer ); } } From 234e518cc5034e8383550c599bc31a5329e7651e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 22:40:21 -0300 Subject: [PATCH 3/5] fix(web): grouped bubbles keep their actions on the same row --- .../chat/TimelineUserMessageRow.tsx | 570 +++++++++--------- 1 file changed, 288 insertions(+), 282 deletions(-) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 57b81bef898bb..48572dd6c93e8 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -274,13 +274,7 @@ export function FoldedMessageHeader(props: { ); } -function UserMessageProjectedStatus({ - row, - className, -}: { - row: Extract; - className?: string | undefined; -}) { +function UserMessageProjectedStatus({ row }: { row: Extract }) { const status = row.projectedItem?.item.status; if ( status === undefined || @@ -290,7 +284,7 @@ function UserMessageProjectedStatus({ ) return null; return ( -
+
{status} @@ -475,14 +469,63 @@ function UserTimelineRow({ row }: { row: Extract +
+ {folded && senderThreadId ? ( + ctx.onOpenThread(senderThreadId)} tone="muted"> + Open thread + + ) : null} + + }> + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
+ {typeof revertTurnCount === "number" && ( + + )} + {resolvedContext.text && ( + + )} + {promptLink !== null && ( + + )} +
+
+
+ ) : null; + return (
@@ -503,288 +546,251 @@ function UserTimelineRow({ row }: { row: Extract - -
+ ) : null} -
- {/* Outside the fold so a collapsed message still has a heading to navigate to. */} - - {folded - ? senderLabel - : userMessage.guestName === undefined - ? "You" - : `${userMessage.guestName} (guest)`} - - {folded ? ( - setExpanded((value) => !value)} - /> - ) : null} - {/* Find reads the DOM, so a folded message stays mounted but hidden while find is open. */} - {!folded || expanded || ctx.findActive ? ( - - - {!folded || expanded ? ( -
-
- {folded && senderThreadId ? ( - ctx.onOpenThread(senderThreadId)} tone="muted"> - Open thread - - ) : null} - - }> - {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} - - -
- {typeof revertTurnCount === "number" && ( - - )} - {resolvedContext.text && ( - + ) : null} +
+ - )} - {promptLink !== null && ( - - )} +
-
+ ) : null}
- ) : null} + {stacked ? metaRow : null} + + + {stacked ? null : metaRow} +
+ ); +} + +function StackedBubbleRow({ stacked, children }: { stacked: boolean; children: ReactNode }) { + return stacked ? ( +
+ {children}
+ ) : ( + children ); } From bb9502da63e74e66155ea546aa4a690161284712 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 23:27:13 -0300 Subject: [PATCH 4/5] fix(web): a message keeps its expanded state when it joins a burst --- .../components/chat/TimelineUserMessageRow.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 48572dd6c93e8..e4586f1fb4673 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -527,7 +527,6 @@ function UserTimelineRow({ row }: { row: Extract {userMessage.attribution === "t3code" ? (

+ return ( +

{children}
- ) : ( - children ); } From f09992fab00800df7cb7c3c773bc8e580e4f0230 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 02:13:49 -0300 Subject: [PATCH 5/5] fix(web): grouped messages keep their time and actions below the bubble --- .../chat/MessagesTimeline.logic.test.ts | 2 - .../components/chat/MessagesTimeline.logic.ts | 19 +- .../chat/TimelineUserMessageRow.tsx | 550 ++++++++---------- 3 files changed, 263 insertions(+), 308 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 066a2212c9248..c9aec993bcd86 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1449,12 +1449,10 @@ describe("deriveMessagesTimelineRows", () => { const byId = new Map(rows.map((row) => [row.id, row])); expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext"); expect(byId.get("first-steer")).toMatchObject({ - groupedWithPrevious: false, groupedWithNext: true, continuesSteer: false, }); expect(byId.get("second-steer")).toMatchObject({ - groupedWithPrevious: true, groupedWithNext: false, continuesSteer: true, }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e2f88ba1ad326..1f22bc1c14978 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -595,8 +595,7 @@ type MessagesTimelineRowContent = assistantCopyStreaming: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; revertTurnCount?: number | undefined; - /** Your messages sent back to back stack as one burst, like a chat app. */ - groupedWithPrevious?: boolean; + /** Another of your messages follows directly, so the two sit closer together. */ groupedWithNext?: boolean; /** Set when the message above is a steer too, so the label shows once. */ continuesSteer?: boolean; @@ -2146,16 +2145,13 @@ export function deriveMessagesTimelineRows(input: { } if (!timelineRowIsOwnUserMessage(row)) return row; const previous = result[index - 1]; - const groupedWithPrevious = timelineRowIsOwnUserMessage(previous); const groupedWithNext = timelineRowIsOwnUserMessage(result[index + 1]); - if (!groupedWithPrevious && !groupedWithNext) return row; - return { - ...row, - groupedWithPrevious, - groupedWithNext, - continuesSteer: - groupedWithPrevious && messageIsSteer(row.message) && messageIsSteer(previous.message), - }; + const continuesSteer = + timelineRowIsOwnUserMessage(previous) && + messageIsSteer(row.message) && + messageIsSteer(previous.message); + if (!groupedWithNext && !continuesSteer) return row; + return { ...row, groupedWithNext, continuesSteer }; }); } @@ -2511,7 +2507,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.assistantCopyStreaming === bm.assistantCopyStreaming && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && a.revertTurnCount === bm.revertTurnCount && - a.groupedWithPrevious === bm.groupedWithPrevious && a.groupedWithNext === bm.groupedWithNext && a.continuesSteer === bm.continuesSteer ); diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index e4586f1fb4673..58785db77ca47 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -465,64 +465,6 @@ function UserTimelineRow({ row }: { row: Extract -
- {folded && senderThreadId ? ( - ctx.onOpenThread(senderThreadId)} tone="muted"> - Open thread - - ) : null} - - }> - {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} - - -
- {typeof revertTurnCount === "number" && ( - - )} - {resolvedContext.text && ( - - )} - {promptLink !== null && ( - - )} -
-
-
- ) : null; - return (
) : null} - -
- {/* Outside the fold so a collapsed message still has a heading to navigate to. */} - - {folded - ? senderLabel - : userMessage.guestName === undefined - ? "You" - : `${userMessage.guestName} (guest)`} - - {folded ? ( - setExpanded((value) => !value)} - /> - ) : null} - {/* Find reads the DOM, so a folded message stays mounted but hidden while find is open. */} - {!folded || expanded || ctx.findActive ? ( -