Skip to content
Open
3 changes: 3 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
28 changes: 19 additions & 9 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,21 @@ function deriveTurnFolds(input: {
return foldsByAnchorEntryId;
}

export function assistantMetaRow(
row: Extract<MessagesTimelineRow, { kind: "message" }>,
createdAt: string,
): Extract<MessagesTimelineRow, { kind: "assistant-meta" }> {
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>,
): MessagesTimelineRow[] {
Expand Down Expand Up @@ -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[] = [];
Expand Down
45 changes: 43 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<LegendListRef | null>;
timelineEntries: ReadonlyArray<TimelineEntry>;
Expand Down Expand Up @@ -341,6 +344,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
activeTurnStartedAt = null,
worktreeSetup = null,
footer = null,
footerContinuesMessage = false,
onCancelWorktreeSetup,
retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,
onRetryWorkspacePreparation,
Expand Down Expand Up @@ -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" &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
lastRow.message.role === "assistant" &&
lastRow.showAssistantMeta
? lastRow
: null;
const messageBeforeFooterRow = messageBeforeFooter && {
...messageBeforeFooter,
showAssistantMeta: false,
showAssistantCopyButton: false,
};
Comment on lines +709 to +713

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 gpt-6.1-sol responding on behalf of vycdev

Reuse the trailing assistant-meta row when one already exists, preserving its timestamp and copy state. Otherwise keep the current message-to-metadata behavior.

Suggested change
const messageBeforeFooterRow = messageBeforeFooter && {
...messageBeforeFooter,
showAssistantMeta: false,
showAssistantCopyButton: false,
};
const messageBeforeFooterRow = messageBeforeFooter && {
...messageBeforeFooter,
showAssistantMeta: false,
showAssistantCopyButton: false,
};
const metaAfterFooter =
footerContinuesMessage && lastRow?.kind === "assistant-meta"
? lastRow
: messageBeforeFooter
? assistantMetaRow(messageBeforeFooter, messageBeforeFooter.message.updatedAt)
: null;

const metaAfterFooter =
footerContinuesMessage && lastRow?.kind === "assistant-meta"
? lastRow
: messageBeforeFooter
? assistantMetaRow(messageBeforeFooter, messageBeforeFooter.message.updatedAt)
: null;
const matchedApprovalRequestIds = new Set<string>();
for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) {
const entries =
Expand Down Expand Up @@ -1244,19 +1268,36 @@ const ConversationTimeline = memo(function ConversationTimeline({
data-timeline-root="true"
>
<AudioPreviewUnmountContext value={onAudioUnmount}>
<TimelineRowContent row={item} />
{item.kind === "assistant-meta" && item.id === metaAfterFooter?.id ? null : (
<TimelineRowContent
row={item.id === messageBeforeFooterRow?.id ? messageBeforeFooterRow : item}
/>
)}
</AudioPreviewUnmountContext>
</div>
</div>
</TimelineRowCtx>
);
const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState };
const listExtraData = {
identity: listIdentityKey,
rowCount: rows.length,
sharedState,
messageBeforeFooterRow,
metaAfterFooter,
};
const timelineListFooter = (
<TimelineListFooter
composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment}
onGrow={onFooterGrow}
>
{footer}
{metaAfterFooter ? (
<TimelineRowCtx value={sharedState}>
<div className="chat-content-lane">
<AssistantMetaTimelineRow row={metaAfterFooter} />
</div>
</TimelineRowCtx>
) : null}
{unmatchedPendingApprovals.length > 0 ? (
<TimelineRowCtx value={sharedState}>
<div className="messages-timeline-row-frame">
Expand Down
77 changes: 46 additions & 31 deletions apps/web/src/components/chat/ThreadActivityBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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 ? (
<span className="font-mono in-data-[slot=popover-popup]:whitespace-pre-wrap">
{row.task.label}
</span>
) : (
row.task.label
);
const onlyThreadId = only?.childThreadId;
const startedAtMs = only?.startedAtMs ?? null;
return {
id: `background-work:${input.threadId}`,
icon: rows.every(isCommand) ? <TerminalIcon /> : <CircleDashedIcon />,
title: only ? (
icon: rows.every((row) => row.task.kind !== "subagent") ? (
<TerminalIcon />
) : (
<CircleDashedIcon />
),
title: (
<>
{verb}{" "}
<span
className={cn(
isCommand(only) && "font-mono in-data-[slot=popover-popup]:whitespace-pre-wrap",
)}
>
{only.task.label}
{presentation.waiting ? "Waiting on " : "Running in background "}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<span className="text-foreground/80">
{only?.task.named === true ? label(only) : presentation.subject}
</span>
</>
) : (
presentation.title
),
description: only ? (
only.activity
) : newest ? (
`${newest.task.label}: ${newest.activity}`
) : rows.every(isCommand) ? (
<span className="font-mono">{rows.map((row) => row.task.label).join(", ")}</span>
) : (
rows.map((row) => row.task.label).join(", ")
),
description: only
? only.activity
: newest
? `${newest.task.label}: ${newest.activity}`
: rows.map((row, index) => (
<Fragment key={row.task.taskId}>
{index > 0 ? ", " : null}
{label(row)}
</Fragment>
)),
actions: (
<>
{startedAtMs === null ? null : (
<span
ref={tickTextContent(
(nowMs) => formatDuration(Math.max(1_000, nowMs - startedAtMs)),
true,
)}
className="tabular-nums"
/>
<>
<span
ref={tickTextContent(
(nowMs) => formatDuration(Math.max(1_000, nowMs - startedAtMs)),
true,
)}
className="tabular-nums"
/>
<span aria-hidden>·</span>
</>
)}
{onlyThreadId === undefined ? null : (
<InlineButton tone="muted" onClick={() => onOpenThread(onlyThreadId)}>
Open
</InlineButton>
)}
<InlineButton disabled={input.stopDisabled} onClick={() => void input.onStop()}>
<InlineButton
tone="muted"
disabled={input.stopDisabled}
onClick={() => void input.onStop()}
>
{input.stopping ? "Stopping..." : "Stop"}
</InlineButton>
</>
Expand Down
30 changes: 19 additions & 11 deletions packages/client-runtime/src/state/threadExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PendingBackgroundWorkItem>;
readonly waiting: boolean;
}
Expand All @@ -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,
};
})
Expand All @@ -396,23 +399,28 @@ 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<BackgroundWorkKind, number>();
for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
const groups = Array.from(counts, ([kind, count]) => {
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 {
Expand Down
Loading