Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/web/src/components/chat/AgentElapsed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ function elapsedBetween(startedAt: string, endIso: string | null): string {
* Elapsed time for the current activation. Live agents self-tick via DOM
* writes (zero React commits per tick); settled agents freeze at completedAt.
*/
export function AgentElapsed({ agent }: { agent: RuntimeSubagent }) {
export function AgentElapsed({
agent,
}: {
agent: Pick<RuntimeSubagent, "status" | "startedAt" | "completedAt">;
}) {
const textRef = useRef<HTMLSpanElement>(null);
const live = agent.status === "running" || agent.status === "waiting";
const startedAt = agent.startedAt;
Expand Down
108 changes: 106 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
deriveTimelineEntriesFromVisibleTurnItems,
deriveTimelineEntriesFromVisibleTurnItemsWithState,
workEntryDisplayIndicatesToolFailure,
type TimelineEntry,
} from "../../session-logic";
import { makeStreamingTimelineFixture } from "../../test-fixtures";
import type { TurnDiffSummary } from "../../types";
Expand Down Expand Up @@ -3679,7 +3680,7 @@ describe("linked timeline resources", () => {
});
const common = { isWorking: false, turnDiffSummaries: [], supportsConversationRollback: false };

it("previews a thought and joins adjacent worklogs without removing message boundaries", () => {
it("previews a thought and separates subagent cards from worklogs", () => {
const rows = deriveMessagesTimelineRows({
...common,
timelineEntries: [
Expand Down Expand Up @@ -3717,8 +3718,8 @@ describe("linked timeline resources", () => {
});
expect(rows.find((row) => row.kind === "work")).toMatchObject({
displayLabel: "First paragraph. Second paragraph.",
continuesWorkLog: true,
});
expect(rows.find((row) => row.kind === "work")?.continuesWorkLog).toBeUndefined();
expect(rows.find((row) => row.id === "child")?.continuesWorkLog).toBeUndefined();
});

Expand All @@ -3745,6 +3746,109 @@ describe("linked timeline resources", () => {
expect(rows[1]).toMatchObject({ subagents: [{ item: { id: "a" } }, { item: { id: "b" } }] });
});

it.each([
{ status: "completed", envelope: "direct", role: "general" },
{ status: "completed", envelope: "structured", role: "general" },
{ status: "completed", envelope: "text", role: "general" },
{ status: "completed", envelope: "structured", role: "research" },
{ status: "running", envelope: "direct", role: "general" },
{ status: "running", envelope: "direct", role: "research" },
] as const)(
"matches $status $role delegation calls by child identity with $envelope output",
({ status, envelope, role }) => {
const child = (id: string) => {
const entry = event(id, "subagent");
return {
...entry,
projectedItem: {
item: {
...entry.projectedItem.item,
origin: "app_owned",
subagentId: id,
prompt:
role === "general" ? id : `Act as the ${role} sub-agent for this task.\n\n${id}`,
childThreadId: null,
},
} as OrchestrationV2ProjectedTurnItem,
};
};
const delegation = (id: string, taskId: string, failed = false): TimelineEntry => ({
id,
kind: "work",
createdAt: "2026-09-08T10:00:02Z",
entry: {
id,
runId,
createdAt: "2026-09-08T10:00:02Z",
label: "Delegated a child task",
tone: failed ? "error" : "tool",
itemType: "dynamic_tool",
toolLifecycleStatus: failed
? "failed"
: status === "running"
? "inProgress"
: "completed",
projectedItem: {
item: {
id,
runId,
type: "dynamic_tool",
status: failed ? "failed" : status,
toolName: "t3-code.delegate_task",
input: { task: taskId === "b" ? "a" : taskId, role },
...(status === "completed"
? {
output:
envelope === "structured"
? { content: JSON.stringify({ taskId }), structuredContent: { taskId } }
: envelope === "text"
? { content: [{ type: "text", text: JSON.stringify({ taskId }) }] }
: { taskId },
}
: {}),
},
} as OrchestrationV2ProjectedTurnItem,
},
});
const rows = deriveMessagesTimelineRows({
...common,
isWorking: status === "running",
runningRunId: status === "running" ? runId : null,
timelineEntries: [
child("a"),
delegation("delegate-a", "a"),
...(status === "completed" ? [child("b")] : []),
delegation("delegate-b", "b"),
delegation("unmatched", "other-child"),
child("c"),
delegation("failed", "c", true),
child("d"),
],
expandedRunIds: new Set([runId]),
});
if (status === "completed") {
expect(rows.find((row) => row.id === "a")).toMatchObject({
subagents: [{ item: { id: "a" } }, { item: { id: "b" } }],
});
expect(rows.some((row) => row.id === "b")).toBe(false);
} else {
expect(rows.find((row) => row.id === "a")).toBeDefined();
expect(rows.find((row) => row.id === "b")).toBeUndefined();
}
expect(rows.find((row) => row.id === "c")).toBeDefined();
expect(rows.find((row) => row.id === "d")).toBeDefined();
const visibleTools = rows.flatMap((row) =>
row.kind === "work" || row.kind === "work-live"
? row.groupedEntries.map((entry) => entry.id)
: [],
);
expect(visibleTools).toContain("unmatched");
expect(visibleTools).toContain("failed");
expect(visibleTools.includes("delegate-a")).toBe(status === "running");
expect(visibleTools.includes("delegate-b")).toBe(status === "running");
},
);

it("keeps created-chat summaries after the final answer and folds only their timeline rows", () => {
const timelineEntries = [
{
Expand Down
75 changes: 55 additions & 20 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ import {
} from "@t3tools/contracts";
import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
import {
resolveT3McpToolDefinition,
resolveT3McpToolPresentation,
type T3McpToolPresentation,
} from "@t3tools/shared/t3McpToolPresentation";
import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";

function timelineEntryRunId(entry: TimelineEntry): RunId | null {
Expand Down Expand Up @@ -990,6 +992,37 @@ function attachTrailingToolGroupsToAssistant(
return result;
}

// Delegation already has a durable child card. Remove its tool row only after
// the returned task ID identifies that child; pending calls can share a prompt.
function withoutSubagentDelegationRows(entries: ReadonlyArray<TimelineEntry>) {
const childrenByRun = new Map<RunId, Set<string>>();
for (const entry of entries) {
if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") continue;
const item = entry.projectedItem.item;
if (item.origin !== "app_owned" || item.runId === null) continue;
const children = childrenByRun.get(item.runId) ?? new Set<string>();
children.add(item.subagentId);
childrenByRun.set(item.runId, children);
}
return entries.filter((entry) => {
if (entry.kind !== "work" || workEntryDisplayIndicatesToolFailure(entry.entry)) return true;
const item = entry.entry.projectedItem?.item ?? entry.entry.structuredPayload;
if (
item?.type !== "dynamic_tool" ||
item.runId === null ||
(item.status !== "running" && item.status !== "completed") ||
resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "delegate"
)
return true;
const output = compactDynamicToolOutput(item.output);
if (output?.isError) return true;
if (output?.taskId !== undefined) {
return !childrenByRun.get(item.runId)?.has(output.taskId);
}
return true;
});
}

export function deriveMessagesTimelineRows(input: {
timelineEntries: ReadonlyArray<TimelineEntry>;
latestRun?: TimelineLatestRun | null;
Expand All @@ -1006,6 +1039,7 @@ export function deriveMessagesTimelineRows(input: {
/** Live bootstrap progress. Renders a stage card under the first user message. */
worktreeSetup?: WorktreeSetupSnapshot | null;
}): MessagesTimelineRow[] {
const timelineEntries = withoutSubagentDelegationRows(input.timelineEntries);
const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
for (const summary of input.turnDiffSummaries) {
if (summary.assistantMessageId) {
Expand All @@ -1014,28 +1048,28 @@ export function deriveMessagesTimelineRows(input: {
}
const revertTurnCountByUserMessageId = input.supportsConversationRollback
? deriveRevertTurnCountByUserMessageId({
timelineEntries: input.timelineEntries,
timelineEntries: timelineEntries,
checkpoints: input.turnDiffSummaries,
})
: new Map<MessageId, number>();
const nextRows: MessagesTimelineRow[] = [];
const durationStartByMessageId = computeMessageDurationStart(
input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
);
const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries);
const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
const failedRunIds = failedTimelineRunIds(input.timelineEntries, input.latestRun ?? null);
const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
input.timelineEntries,
timelineEntries,
failedRunIds,
);
const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
timelineEntries: input.timelineEntries,
timelineEntries: timelineEntries,
unsettledRunId,
isWorking: input.isWorking,
});
const foldsByAnchorEntryId = deriveTurnFolds({
timelineEntries: input.timelineEntries,
timelineEntries: timelineEntries,
terminalAssistantMessageIds,
latestRun: input.latestRun ?? null,
unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
Expand Down Expand Up @@ -1065,8 +1099,8 @@ export function deriveMessagesTimelineRows(input: {
// A steer continues the current turn. Keep its elapsed-time header below
// the initiating prompt (or automatic wake), rather than moving it down.
const activeTurnHeaderIndex = input.isWorking
? lastResponseBoundaryIndex(input.timelineEntries) + 1
: input.timelineEntries.length;
? lastResponseBoundaryIndex(timelineEntries) + 1
: timelineEntries.length;

// Contiguous trailing work entries of the active run collapse into one live
// row that survives between actions: while a tool runs it shows that tool,
Expand All @@ -1075,8 +1109,8 @@ export function deriveMessagesTimelineRows(input: {
const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
if (input.isWorking && unsettledRunId !== null) {
let tailAttemptId: string | null | undefined;
for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
const entry = input.timelineEntries[index]!;
for (let index = timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
const entry = timelineEntries[index]!;
if (
entry.kind !== "work" ||
entry.entry.tone === "error" ||
Expand Down Expand Up @@ -1164,8 +1198,8 @@ export function deriveMessagesTimelineRows(input: {
);
};

for (let index = 0; index < input.timelineEntries.length; index += 1) {
const timelineEntry = input.timelineEntries[index];
for (let index = 0; index < timelineEntries.length; index += 1) {
const timelineEntry = timelineEntries[index];
if (!timelineEntry) {
continue;
}
Expand Down Expand Up @@ -1261,8 +1295,8 @@ export function deriveMessagesTimelineRows(input: {
}
const groupedEntries = [timelineEntry.entry];
let cursor = index + 1;
while (cursor < input.timelineEntries.length) {
const nextEntry = input.timelineEntries[cursor];
while (cursor < timelineEntries.length) {
const nextEntry = timelineEntries[cursor];
if (
!nextEntry ||
nextEntry.kind !== "work" ||
Expand Down Expand Up @@ -1403,7 +1437,9 @@ export function deriveMessagesTimelineRows(input: {
timelineEntry.projectedItem.item.type === "subagent" &&
previous?.kind === "event" &&
previous.projectedItem.item.type === "subagent" &&
previous.projectedItem.item.runId === timelineEntry.projectedItem.item.runId
previous.projectedItem.item.runId === timelineEntry.projectedItem.item.runId &&
previous.projectedItem.item.providerTurnId ===
timelineEntry.projectedItem.item.providerTurnId
) {
nextRows[nextRows.length - 1] = {
...previous,
Expand Down Expand Up @@ -1516,7 +1552,7 @@ export function deriveMessagesTimelineRows(input: {
// A running setup owns the working slot above its card and shows no
// activity row of its own; every other state gets the usual tail.
const hasWorkingRow = nextRows.some((row) => row.kind === "working");
if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) {
if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) {
appendWorkingRow();
}
if (
Expand All @@ -1533,7 +1569,7 @@ export function deriveMessagesTimelineRows(input: {
}

const result = attachTrailingToolGroupsToAssistant(
attachCreatedThreadSummaries(nextRows, input.timelineEntries),
attachCreatedThreadSummaries(nextRows, timelineEntries),
);
return result.map((row, index) =>
timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])
Expand All @@ -1549,8 +1585,7 @@ function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean {
(row.kind === "work" ||
row.kind === "work-toggle" ||
row.kind === "work-live" ||
row.kind === "thinking" ||
(row.kind === "event" && row.projectedItem.item.type === "subagent"))
row.kind === "thinking")
);
}

Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1850,6 +1850,8 @@ describe("MessagesTimeline", () => {
async ({ status, progress, result, preview }) => {
activityTestState.expandedRuns = true;
activityTestState.subagentTooltips = true;
vi.stubGlobal("HTMLElement", ElementStub);
window.HTMLElement = ElementStub as typeof HTMLElement;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("requestAnimationFrame", () => 0);
vi.stubGlobal("cancelAnimationFrame", () => {});
Expand Down Expand Up @@ -1902,7 +1904,7 @@ describe("MessagesTimeline", () => {
/>,
);
});
const groupLabel = status === "running" ? "Kicked off 1 subagent" : "Ran 1 subagent";
const groupLabel = "1 subagent";
const group = () =>
renderer!.root.findAll(
(node) => node.type === "button" && node.props["aria-label"] === groupLabel,
Expand All @@ -1912,7 +1914,7 @@ describe("MessagesTimeline", () => {
(node) => node.type === "button" && node.props["aria-label"] === "Open Package audit",
);
expect(child()).toHaveLength(0);
await act(() => group().props.onClick());
await act(() => group().props.onClick({ nativeEvent: new Event("click") }));
expect(child()).toHaveLength(1);
const content = renderer!.root
.findAll((node) => typeof node.type === "string")
Expand All @@ -1924,10 +1926,11 @@ describe("MessagesTimeline", () => {
if (progress && progress !== preview) expect(content).not.toContain(progress);
await act(() => child()[0]!.props.onClick());
expect(onOpenThread).toHaveBeenCalledWith("thread-subagent-1");
await act(() => group().props.onClick());
await act(() => group().props.onClick({ nativeEvent: new Event("click") }));
expect(child()).toHaveLength(0);
} finally {
await act(() => renderer?.unmount());
vi.stubGlobal("HTMLElement", undefined);
}
},
);
Expand Down
Loading
Loading