diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2a176a444369..20f24aff2c295 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3436,6 +3436,9 @@ export default function ChatView(props: ChatViewProps) { {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} isPreparingWorktree={isPreparingWorktree} footer={timelineFooter} + footerContinuesMessage={ + workNow !== null && pendingGuestMessageCount === 0 && !hasQueuedTurns + } 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 2fe811db4d18f..aecd8a3403c53 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1217,6 +1217,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[] { @@ -1284,15 +1299,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 4c28163766058..f1b0bb85ec8de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -77,6 +77,7 @@ import { useAssistantCitationTarget } from "./useAssistantCitationTarget"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { resolveCitationPhase, type CitedHistory } from "./citationProcedure"; import { + assistantMetaRow, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapCurrentIndex, @@ -238,6 +239,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; @@ -341,6 +344,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ activeTurnStartedAt = null, worktreeSetup = null, footer = null, + footerContinuesMessage = false, onCancelWorktreeSetup, retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS, onRetryWorkspacePreparation, @@ -693,6 +697,26 @@ 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 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 = @@ -1244,19 +1268,36 @@ const ConversationTimeline = memo(function ConversationTimeline({ data-timeline-root="true" > - + {item.kind === "assistant-meta" && item.id === metaAfterFooter?.id ? null : ( + + )} ); - const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState }; + const listExtraData = { + identity: listIdentityKey, + rowCount: rows.length, + sharedState, + messageBeforeFooterRow, + metaAfterFooter, + }; const timelineListFooter = ( {footer} + {metaAfterFooter ? ( + +
+ +
+
+ ) : null} {unmatchedPendingApprovals.length > 0 ? (
diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 5342bd09b3ca9..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"; @@ -24,7 +25,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"; @@ -172,6 +172,9 @@ 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" && 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, @@ -182,53 +185,65 @@ 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 ? ( + icon: rows.every((row) => row.task.kind !== "subagent") ? ( + + ) : ( + + ), + title: ( <> - {verb}{" "} - - {only.task.label} + {presentation.waiting ? "Waiting on " : "Running in background "} + + {only?.task.named === true ? label(only) : presentation.subject} - ) : ( - presentation.title - ), - description: only ? ( - only.activity - ) : newest ? ( - `${newest.task.label}: ${newest.activity}` - ) : rows.every(isCommand) ? ( - {rows.map((row) => row.task.label).join(", ")} - ) : ( - rows.map((row) => row.task.label).join(", ") ), + description: only + ? only.activity + : 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 711f813a3b8ef..9b9efacb2011a 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -348,11 +348,15 @@ 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; } export interface PendingBackgroundWorkPresentation { readonly title: string; + /** What is running or waited on: a label, "a command", or "2 commands". */ + readonly subject: string; readonly items: ReadonlyArray; readonly waiting: boolean; } @@ -378,13 +382,12 @@ export function presentPendingBackgroundWork( : task.kind === "command" || task.kind === "monitor" ? commandDisplayText(description) : 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, }; }) @@ -396,15 +399,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 subject = named ? only.label : `a ${noun}`; const title = waiting ? named ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}` - : `Waiting on a ${noun}` - : named - ? `Running: ${only.label}` - : `Running a ${noun}`; - 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); @@ -412,7 +414,13 @@ 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}` : `Running in background: ${groupsText}`, + subject: groupsText, + items, + waiting, + }; } export interface ProviderGoalPresentation {