From bedf95b8a07eac165d243b5ddbdba619160bbba5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 17:01:04 -0400 Subject: [PATCH 1/6] fix(web): background command line leads with its name and quiets Stop Claude labels a background command with a prose description, and the line set any command label in monospace next to a sans "Running", with a white Stop as the loudest thing on it. The line now leads with the name, says "running in background", uses monospace only for a label that is the command itself, and mutes Stop after the elapsed time. The shared title reads the same way, so the mobile status pill follows. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/ThreadActivityBanners.tsx | 68 ++++++++++++------- .../src/state/threadExecution.ts | 22 +++--- 2 files changed, 56 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 168b674d26c92..51f033f7c4330 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -21,7 +21,6 @@ import { } from "lucide-react"; import { Fragment } from "react"; import { tickTextContent } from "~/lib/tickTextContent"; -import { cn } from "~/lib/utils"; import { Button, InlineButton } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; @@ -126,6 +125,8 @@ export function backgroundWorkNowItem(input: { const subagent = item?.type === "subagent" ? item : undefined; return { task, + // Claude labels a command with a prose description; only the command itself is code. + isRawCommand: item?.type === "command_execution" && item.input.trim() === task.label, activity: subagent?.progress?.replace(/\s+/gu, " ").trim() || null, startedAtMs: item?.startedAt ? DateTime.toEpochMillis(item.startedAt) : null, updatedAtMs: subagent ? DateTime.toEpochMillis(subagent.updatedAt) : 0, @@ -136,46 +137,61 @@ export function backgroundWorkNowItem(input: { const newest = rows .filter((row) => row.activity) .toSorted((left, right) => right.updatedAtMs - left.updatedAtMs)[0]; - const isCommand = (row: (typeof rows)[number]) => row.task.kind !== "subagent"; - const verb = presentation.waiting ? "Waiting on" : "Running"; + const label = (row: (typeof rows)[number]) => + row.isRawCommand ? {row.task.label} : row.task.label; const onlyThreadId = only?.childThreadId; const startedAtMs = only?.startedAtMs ?? null; return { id: `background-work:${input.threadId}`, - icon: rows.every(isCommand) ? : , - title: only ? ( - <> - {verb} {only.task.label} - - ) : ( - presentation.title - ), - description: only ? ( - only.activity - ) : newest ? ( - `${newest.task.label}: ${newest.activity}` - ) : rows.every(isCommand) ? ( - {rows.map((row) => row.task.label).join(", ")} + icon: rows.every((row) => row.task.kind !== "subagent") ? ( + ) : ( - rows.map((row) => row.task.label).join(", ") + ), + title: + only?.task.named === true ? ( + <> + {presentation.waiting ? "Waiting on " : null} + {label(only)} + + ) : ( + {presentation.title} + ), + description: only + ? (only.activity ?? + (presentation.waiting || !only.task.named ? null : "running in background")) + : newest + ? `${newest.task.label}: ${newest.activity}` + : rows.map((row, index) => ( + + {index > 0 ? ", " : null} + {label(row)} + + )), actions: ( <> {startedAtMs === null ? null : ( - formatDuration(Math.max(1_000, nowMs - startedAtMs)), - true, - )} - className="tabular-nums" - /> + <> + formatDuration(Math.max(1_000, nowMs - startedAtMs)), + true, + )} + className="tabular-nums" + /> + ยท + )} {onlyThreadId === undefined ? null : ( onOpenThread(onlyThreadId)}> Open )} - void input.onStop()}> + void input.onStop()} + > {input.stopping ? "Stopping..." : "Stop"} diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index accf5c98df9a9..0ca3a6fb8832a 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -297,6 +297,8 @@ export interface PendingBackgroundWorkItem { readonly taskId: string; readonly kind: BackgroundWorkKind; readonly label: string; + /** False when the label is only the kind's noun, such as "command". */ + readonly named: boolean; readonly childThreadId: ThreadId | undefined; } @@ -323,13 +325,12 @@ export function presentPendingBackgroundWork( task.kind === "subagent" && description !== undefined ? formatSubagentDisplayTitle(description).trim() : description; + const named = label !== undefined && label.length > 0; return { taskId: task.taskId, kind: task.kind, - label: - label === undefined || label.length === 0 - ? BACKGROUND_WORK_KINDS[task.kind].singular - : label, + label: named ? label : BACKGROUND_WORK_KINDS[task.kind].singular, + named, childThreadId: task.kind === "subagent" ? task.childThreadId : undefined, }; }) @@ -341,14 +342,14 @@ export function presentPendingBackgroundWork( const [only] = items; if (items.length === 1 && only !== undefined) { const noun = BACKGROUND_WORK_KINDS[only.kind].singular; - const named = only.label !== noun; + const { named } = only; const title = waiting ? named ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}` : `Waiting on a ${noun}` : named - ? `Running: ${only.label}` - : `Running a ${noun}`; + ? `${only.label} running in background` + : `A ${noun} running in background`; return { title, items, waiting }; } const counts = new Map(); @@ -357,7 +358,12 @@ export function presentPendingBackgroundWork( const { singular, plural } = BACKGROUND_WORK_KINDS[kind]; return `${count} ${count === 1 ? singular : plural}`; }); - return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting }; + const groupsText = joinWithAnd(groups); + return { + title: waiting ? `Waiting on ${groupsText}` : `${groupsText} running in background`, + items, + waiting, + }; } export interface ProviderGoalPresentation { From f9233a4bd34a9d592ebe05a56c93adfe7da8e97a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 17:18:54 -0400 Subject: [PATCH 2/6] fix(web): a long background command name no longer hides its running state The running state sat inside the truncating title, so a long name cut it off on web, and the mobile pill truncated it off the end of its title. Web now shows it beside the time, and the shared single-task title leads with it. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/chat/ThreadActivityBanners.tsx | 11 +++++++++-- packages/client-runtime/src/state/threadExecution.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 51f033f7c4330..7b9c1edab656e 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -139,6 +139,8 @@ export function backgroundWorkNowItem(input: { .toSorted((left, right) => right.updatedAtMs - left.updatedAtMs)[0]; const label = (row: (typeof rows)[number]) => row.isRawCommand ? {row.task.label} : row.task.label; + const showsRunningState = + only !== undefined && only.task.named && only.activity === null && !presentation.waiting; const onlyThreadId = only?.childThreadId; const startedAtMs = only?.startedAtMs ?? null; return { @@ -158,8 +160,7 @@ export function backgroundWorkNowItem(input: { {presentation.title} ), description: only - ? (only.activity ?? - (presentation.waiting || !only.task.named ? null : "running in background")) + ? only.activity : newest ? `${newest.task.label}: ${newest.activity}` : rows.map((row, index) => ( @@ -170,6 +171,12 @@ export function backgroundWorkNowItem(input: { )), actions: ( <> + {/* Outside the truncating title, so a long name cannot cut the state off. */} + {showsRunningState ? ( + + running in background + + ) : null} {startedAtMs === null ? null : ( <> Date: Wed, 28 Oct 2026 18:47:13 -0400 Subject: [PATCH 3/6] fix(web): background work line reads "Running in background " The state leads and the name follows, so a long name truncates without hiding what is happening. The shared title uses the same order for mobile, and the presentation exposes the subject so web can style it. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/ThreadActivityBanners.tsx | 25 ++++++------------- .../src/state/threadExecution.ts | 14 ++++++----- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 7b9c1edab656e..dc43ec5331c62 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -139,8 +139,6 @@ export function backgroundWorkNowItem(input: { .toSorted((left, right) => right.updatedAtMs - left.updatedAtMs)[0]; const label = (row: (typeof rows)[number]) => row.isRawCommand ? {row.task.label} : row.task.label; - const showsRunningState = - only !== undefined && only.task.named && only.activity === null && !presentation.waiting; const onlyThreadId = only?.childThreadId; const startedAtMs = only?.startedAtMs ?? null; return { @@ -150,15 +148,14 @@ export function backgroundWorkNowItem(input: { ) : ( ), - title: - only?.task.named === true ? ( - <> - {presentation.waiting ? "Waiting on " : null} - {label(only)} - - ) : ( - {presentation.title} - ), + title: ( + <> + {presentation.waiting ? "Waiting on " : "Running in background "} + + {only?.task.named === true ? label(only) : presentation.subject} + + + ), description: only ? only.activity : newest @@ -171,12 +168,6 @@ export function backgroundWorkNowItem(input: { )), actions: ( <> - {/* Outside the truncating title, so a long name cannot cut the state off. */} - {showsRunningState ? ( - - running in background - - ) : null} {startedAtMs === null ? null : ( <> ; readonly waiting: boolean; } @@ -343,14 +345,13 @@ export function presentPendingBackgroundWork( if (items.length === 1 && only !== undefined) { const noun = BACKGROUND_WORK_KINDS[only.kind].singular; const { named } = only; + const subject = named ? only.label : `a ${noun}`; const title = waiting ? named ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}` - : `Waiting on a ${noun}` - : named - ? `Running in background: ${only.label}` - : `A ${noun} running in background`; - return { title, items, waiting }; + : `Waiting on ${subject}` + : `Running in background: ${subject}`; + return { title, subject, items, waiting }; } const counts = new Map(); for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1); @@ -360,7 +361,8 @@ export function presentPendingBackgroundWork( }); const groupsText = joinWithAnd(groups); return { - title: waiting ? `Waiting on ${groupsText}` : `${groupsText} running in background`, + title: waiting ? `Waiting on ${groupsText}` : `Running in background: ${groupsText}`, + subject: groupsText, items, waiting, }; From 0ec49744f4085dc0ae55f9b903f5bd33e517ace5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 30 Oct 2026 13:05:06 -0400 Subject: [PATCH 4/6] fix(web): background work line sits right under the message A finished message reserves an invisible hover row for copy, fork and time, which left 48px between the message and the background work line. While the line shows, those actions move below it, like they do after trailing work. --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/MessagesTimeline.logic.ts | 28 +++++++++----- .../src/components/chat/MessagesTimeline.tsx | 38 ++++++++++++++++++- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253cc1ea2e84b..3d479a56340d9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3244,6 +3244,7 @@ export default function ChatView(props: ChatViewProps) { {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} isPreparingWorktree={isPreparingWorktree} footer={timelineFooter} + footerContinuesMessage={backgroundWorkNow !== null} listRef={legendListRef} runs={serverProjection?.runs ?? []} latestRun={activeActivityRun} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4ea9e852edfba..9f39ff62774b6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1192,6 +1192,21 @@ function deriveTurnFolds(input: { return foldsByAnchorEntryId; } +export function assistantMetaRow( + row: Extract, + createdAt: string, +): Extract { + return { + kind: "assistant-meta", + id: `assistant-meta:${row.message.id}`, + ...(row.projectedItem === undefined ? {} : { projectedItem: row.projectedItem }), + createdAt, + message: row.message, + showAssistantCopyButton: row.showAssistantCopyButton, + assistantCopyStreaming: row.assistantCopyStreaming, + }; +} + function attachTrailingToolGroupsToAssistant( rows: ReadonlyArray, ): MessagesTimelineRow[] { @@ -1259,15 +1274,10 @@ function attachTrailingToolGroupsToAssistant( } messageRowsWithoutMeta.add(row.id); - metaRowsAfterIndex.set(lastTrailingWorkIndex, { - kind: "assistant-meta", - id: `assistant-meta:${row.message.id}`, - ...(row.projectedItem === undefined ? {} : { projectedItem: row.projectedItem }), - createdAt: rows[lastTrailingWorkIndex]?.createdAt ?? row.message.updatedAt, - message: row.message, - showAssistantCopyButton: row.showAssistantCopyButton, - assistantCopyStreaming: row.assistantCopyStreaming, - }); + metaRowsAfterIndex.set( + lastTrailingWorkIndex, + assistantMetaRow(row, rows[lastTrailingWorkIndex]?.createdAt ?? row.message.updatedAt), + ); } const result: MessagesTimelineRow[] = []; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a9cab1554a3d3..53e851cd38ab8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -71,6 +71,7 @@ import { useAssistantCitationTarget } from "./useAssistantCitationTarget"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { resolveCitationPhase, type CitedHistory } from "./citationProcedure"; import { + assistantMetaRow, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapCurrentIndex, @@ -232,6 +233,8 @@ interface MessagesTimelineProps { /** Which request is waiting, so a second one of the same kind is announced too. */ awaitingRequestId?: string | null; footer?: ReactNode; + /** The footer continues the last message, so that message's actions follow it. */ + footerContinuesMessage?: boolean; listRef: React.RefObject; timelineEntries: ReadonlyArray; @@ -330,6 +333,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ activeTurnStartedAt = null, worktreeSetup = null, footer = null, + footerContinuesMessage = false, onCancelWorktreeSetup, retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS, onRetryWorkspacePreparation, @@ -683,6 +687,20 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, }); const rows = useStableRows(rawRows, listIdentityKey); + const lastRow = rows.at(-1); + // A message reserves an invisible hover row for its actions, which would hold the footer away. + const messageBeforeFooter = + footerContinuesMessage && + lastRow?.kind === "message" && + lastRow.message.role === "assistant" && + lastRow.showAssistantMeta + ? lastRow + : null; + const messageBeforeFooterRow = messageBeforeFooter && { + ...messageBeforeFooter, + showAssistantMeta: false, + showAssistantCopyButton: false, + }; const unmatchedPendingApprovals = new Map(pendingApprovalsByRequestId); if (unmatchedPendingApprovals.size > 0) { for (const row of rows) { @@ -1225,19 +1243,35 @@ const ConversationTimeline = memo(function ConversationTimeline({ data-timeline-root="true" > - + ); - const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState }; + const listExtraData = { + identity: listIdentityKey, + rowCount: rows.length, + sharedState, + messageBeforeFooterRow, + }; const timelineListFooter = ( {footer} + {messageBeforeFooter ? ( + +
+ +
+
+ ) : null} {unmatchedPendingApprovals.size > 0 ? (
From 0e779853d50d34d44852902261d5f38abcedd9f2 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 30 Oct 2026 13:15:00 -0400 Subject: [PATCH 5/6] fix(web): message actions stay on the message when other messages wait in the footer --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 979fad90cb806..7d135b56a2cdf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3337,7 +3337,9 @@ export default function ChatView(props: ChatViewProps) { {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} isPreparingWorktree={isPreparingWorktree} footer={timelineFooter} - footerContinuesMessage={workNow !== null} + footerContinuesMessage={ + workNow !== null && pendingGuestMessageCount === 0 && !hasQueuedTurns + } listRef={legendListRef} runs={serverProjection?.runs ?? []} latestRun={activeActivityRun} From d08ed96a67b0a2267fdf7b1ab6632f67259200da Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 01:29:28 +0000 Subject: [PATCH 6/6] fix(web): place trailing response actions below background work --- .../src/components/chat/MessagesTimeline.tsx | 21 ++++++++++++------- .../components/chat/ThreadActivityBanners.tsx | 4 +++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d5ae86dc90113..f1b0bb85ec8de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -711,6 +711,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ showAssistantMeta: false, showAssistantCopyButton: false, }; + const metaAfterFooter = + footerContinuesMessage && lastRow?.kind === "assistant-meta" + ? lastRow + : messageBeforeFooter + ? assistantMetaRow(messageBeforeFooter, messageBeforeFooter.message.updatedAt) + : null; const matchedApprovalRequestIds = new Set(); for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) { const entries = @@ -1262,9 +1268,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ data-timeline-root="true" > - + {item.kind === "assistant-meta" && item.id === metaAfterFooter?.id ? null : ( + + )}
@@ -1275,6 +1283,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ rowCount: rows.length, sharedState, messageBeforeFooterRow, + metaAfterFooter, }; const timelineListFooter = ( {footer} - {messageBeforeFooter ? ( + {metaAfterFooter ? (
- +
) : null} diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 1d691f446e92c..f29cd31a5aea5 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -9,6 +9,7 @@ import { presentProviderGoal, type RunStopPresentation, } from "@t3tools/client-runtime/state/thread-execution"; +import { commandDisplayText } from "@t3tools/client-runtime/work-log/command-label"; import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; @@ -172,7 +173,8 @@ export function backgroundWorkNowItem(input: { return { task, // Claude labels a command with a prose description; only the command itself is code. - isRawCommand: item?.type === "command_execution" && item.input.trim() === task.label, + isRawCommand: + item?.type === "command_execution" && commandDisplayText(item.input.trim()) === task.label, activity: subagent?.progress?.replace(/\s+/gu, " ").trim() || null, startedAtMs: item?.startedAt ? DateTime.toEpochMillis(item.startedAt) : null, updatedAtMs: subagent ? DateTime.toEpochMillis(subagent.updatedAt) : 0,