;
onUpdateProjectScript: (
scriptId: string,
@@ -70,6 +73,7 @@ export const ChatHeader = memo(function ChatHeader({
onAddProjectScript,
onUpdateProjectScript,
onDeleteProjectScript,
+ onOpenParentThread,
}: ChatHeaderProps) {
const primaryEnvironmentId = usePrimaryEnvironmentId();
const showOpenInPicker = shouldShowOpenInPicker({
@@ -81,19 +85,40 @@ export const ChatHeader = memo(function ChatHeader({
-
-
+
+
+ {activeThreadTitle}
+
+ }
+ />
+ {activeThreadTitle}
+
+ {onOpenParentThread && (
+
+
+ }
>
- {activeThreadTitle}
-
- }
- />
- {activeThreadTitle}
-
+
+
+ Open parent conversation
+
+ )}
+
{
@@ -46,6 +46,25 @@ vi.mock("@pierre/diffs/react", () => {
return { FileDiff: MockFileDiff };
});
+const storeMock = vi.hoisted(() => ({
+ state: {
+ threadShellByKey: {},
+ } as {
+ threadShellByKey: Record;
+ },
+}));
+
+vi.mock("../../state/entities", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useThreadShell: (ref: { environmentId: string; threadId: string } | null) =>
+ ref === null
+ ? null
+ : (storeMock.state.threadShellByKey[`${ref.environmentId}\0${ref.threadId}`] ?? null),
+ };
+});
+
function matchMedia() {
return {
matches: false,
@@ -83,6 +102,8 @@ beforeAll(() => {
documentElement: {
classList,
offsetHeight: 0,
+ removeAttribute: () => {},
+ setAttribute: () => {},
},
});
});
@@ -90,6 +111,12 @@ beforeAll(() => {
const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
+beforeEach(() => {
+ storeMock.state = {
+ threadShellByKey: {},
+ };
+});
+
function buildProps() {
return {
isWorking: false,
@@ -300,6 +327,73 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain("</review_comment>");
});
+ it("renders a deduped resumed subagent block as working when the parent turn matches", async () => {
+ const childThreadId = ThreadId.make("subagent-child-1");
+ const parentTurnId = TurnId.make("turn-followup");
+ storeMock.state = {
+ threadShellByKey: {
+ [`${ACTIVE_THREAD_ENVIRONMENT_ID}\0${childThreadId}`]: {
+ id: childThreadId,
+ title: "Say hi briefly",
+ parentRelation: {
+ kind: "subagent",
+ rootThreadId: ThreadId.make("thread-1"),
+ parentThreadId: ThreadId.make("thread-1"),
+ parentTurnId,
+ parentItemId: "call-send-input",
+ parentActivitySequence: 2,
+ providerThreadId: "provider-child-1",
+ titleSeed: "Say hi in German",
+ depth: 1,
+ startedAt: "2026-03-17T19:12:30.000Z",
+ completedAt: null,
+ status: "running",
+ },
+ },
+ },
+ };
+
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("Subagent - Say hi briefly");
+ expect(markup).toContain("Working");
+ expect(markup).not.toContain("Completed in");
+ });
+
it("renders file review comments as source code instead of diffs", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
@@ -364,4 +458,36 @@ describe("MessagesTimeline", () => {
expect(markup).toContain("lucide-x");
expect(markup).toContain('aria-label="Tool call failed"');
});
+
+ it("renders expandable subagent rows without status labels", async () => {
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("Subagent");
+ expect(markup).toContain("Create one original haiku in English");
+ expect(markup).not.toContain("Done");
+ expect(markup).not.toContain("Running");
+ expect(markup).toContain('aria-label="Subagent - Create one original haiku');
+ });
});
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index b0d83be7b10..2cce9dde866 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -3,9 +3,11 @@ import {
type MessageId,
type ScopedThreadRef,
type ServerProviderSkill,
+ type ThreadId,
type TurnId,
} from "@t3tools/contracts";
-import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
+import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { useNavigate } from "@tanstack/react-router";
import {
createContext,
Fragment,
@@ -30,6 +32,13 @@ import {
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
+import {
+ formatSubagentDuration,
+ formatTerminalSubagentStatusDuration,
+ LiveSubagentDuration,
+ subagentStatusToneClass,
+ type SubagentThreadStatus,
+} from "../../subagentDisplay";
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
import {
getRenderablePatch,
@@ -92,6 +101,8 @@ import { cn } from "~/lib/utils";
import { useUiStateStore } from "~/uiStateStore";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
+import { buildThreadRouteParams } from "../../threadRoutes";
+import { useThreadShell } from "../../state/entities";
import {
buildInlineTerminalContextText,
@@ -1439,10 +1450,25 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
}
function workEntryPreview(
- workEntry: Pick,
+ workEntry: Pick<
+ TimelineWorkEntry,
+ | "detail"
+ | "command"
+ | "changedFiles"
+ | "itemType"
+ | "output"
+ | "subagentPrompt"
+ | "subagentChildren"
+ >,
workspaceRoot: string | undefined,
) {
if (workEntry.command) return workEntry.command;
+ if ((workEntry.subagentChildren?.length ?? 0) > 0) return null;
+ if (workEntry.itemType === "collab_agent_tool_call") {
+ const { prompt, output } = resolveSubagentDisplayParts(workEntry);
+ return prompt ?? output;
+ }
+ if (workEntry.subagentPrompt) return workEntry.subagentPrompt;
if (workEntry.detail) return workEntry.detail;
if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
const [firstPath] = workEntry.changedFiles ?? [];
@@ -1468,6 +1494,15 @@ function buildToolCallExpandedBody(
workspaceRoot: string | undefined,
): string | null {
const blocks: string[] = [];
+ if (workEntry.itemType === "collab_agent_tool_call") {
+ const { prompt, output } = resolveSubagentDisplayParts(workEntry);
+ if (prompt) {
+ blocks.push(`Prompt\n${prompt}`);
+ }
+ if (output) {
+ blocks.push(`Output\n${output}`);
+ }
+ }
if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) {
blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`);
}
@@ -1537,6 +1572,38 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
}
+function normalizedSubagentText(value: string | undefined): string {
+ return (value ?? "").replace(/\s+/g, " ").trim();
+}
+
+function resolveSubagentDisplayParts(
+ workEntry: Pick,
+): {
+ prompt: string | null;
+ output: string | null;
+} {
+ const prompt = workEntry.subagentPrompt?.trim() ?? "";
+ const output = workEntry.output?.trim() ?? "";
+ if (!prompt) {
+ return { prompt: null, output: output || null };
+ }
+ if (!output) {
+ return { prompt, output: null };
+ }
+
+ const normalizedPrompt = normalizedSubagentText(prompt).toLowerCase();
+ const normalizedOutput = normalizedSubagentText(output).toLowerCase();
+ const redundantPrompt =
+ normalizedPrompt === normalizedOutput ||
+ normalizedPrompt.startsWith(normalizedOutput) ||
+ normalizedOutput.startsWith(normalizedPrompt);
+
+ return {
+ prompt: redundantPrompt ? null : prompt,
+ output,
+ };
+}
+
const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
@@ -1546,6 +1613,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
const { workEntry, workspaceRoot } = props;
const activity = use(TimelineRowActivityCtx);
const [expanded, setExpanded] = useState(false);
+ if (workEntry.itemType === "collab_agent_tool_call" && workEntry.subagentChildren?.length) {
+ return ;
+ }
const iconConfig = workToneIcon(workEntry.tone);
const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);
@@ -1697,3 +1767,139 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
);
});
+
+const SubagentWorkEntryRows = memo(function SubagentWorkEntryRows({
+ workEntry,
+}: {
+ workEntry: TimelineWorkEntry;
+}) {
+ return (
+
+ {workEntry.subagentChildren?.map((child) => (
+
+ ))}
+
+ );
+});
+
+const SubagentWorkEntryButton = memo(function SubagentWorkEntryButton(props: {
+ parentCreatedAt: string;
+ parentItemId?: string;
+ parentTurnId?: TurnId;
+ threadId: ThreadId;
+ titleSeed?: string;
+}) {
+ const ctx = use(TimelineRowCtx);
+ const navigate = useNavigate();
+ const childShell = useThreadShell(scopeThreadRef(ctx.activeThreadEnvironmentId, props.threadId));
+ const relation =
+ childShell?.parentRelation?.kind === "subagent" ? childShell.parentRelation : null;
+ const rawTitle = childShell?.title?.trim();
+ const title = rawTitle && rawTitle !== "Subagent" ? rawTitle : null;
+ const displayTitle = title ? `Subagent - ${title}` : "Subagent";
+ const terminalSnapshotRef = useRef<{
+ status: Exclude;
+ startedAt: string;
+ completedAt: string | null;
+ } | null>(null);
+ const relationParentItemId = relation?.parentItemId ?? null;
+ const relationParentTurnId = relation?.parentTurnId ?? null;
+ const relationMatchesThisBlock =
+ Boolean(props.parentTurnId && props.parentTurnId === relationParentTurnId) ||
+ !props.parentItemId ||
+ !relationParentItemId ||
+ relationParentItemId === props.parentItemId;
+ if (relation && relationMatchesThisBlock && relation.status !== "running") {
+ terminalSnapshotRef.current = {
+ status: relation.status,
+ startedAt: relation.startedAt,
+ completedAt: relation.completedAt,
+ };
+ }
+ const parentCreatedAfterRelationCompleted = Boolean(
+ props.parentItemId &&
+ relation &&
+ relation.status !== "running" &&
+ relation.completedAt &&
+ Date.parse(props.parentCreatedAt) > Date.parse(relation.completedAt),
+ );
+ const displayState =
+ relation && relationMatchesThisBlock
+ ? {
+ status: relation.status,
+ startedAt: relation.startedAt,
+ completedAt: relation.completedAt,
+ }
+ : parentCreatedAfterRelationCompleted
+ ? {
+ status: "running" as const,
+ startedAt: props.parentCreatedAt,
+ completedAt: null,
+ }
+ : terminalSnapshotRef.current
+ ? terminalSnapshotRef.current
+ : relation?.status === "running"
+ ? {
+ status: "completed" as const,
+ startedAt: props.parentCreatedAt,
+ completedAt: null,
+ }
+ : {
+ status: relation?.status ?? null,
+ startedAt: relation?.startedAt ?? props.parentCreatedAt,
+ completedAt: relation?.completedAt ?? null,
+ };
+ const status = displayState.status;
+ const startedAt = displayState.startedAt;
+ const completedAt = displayState.completedAt;
+ const statusDurationLabel =
+ status === "running" ? (
+
+ ) : (
+ formatTerminalSubagentStatusDuration(status, formatSubagentDuration(startedAt, completedAt))
+ );
+
+ const openChildThread = useCallback(() => {
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(scopeThreadRef(ctx.activeThreadEnvironmentId, props.threadId)),
+ });
+ }, [ctx.activeThreadEnvironmentId, navigate, props.threadId]);
+
+ return (
+
+
+
+
+
+
+ {displayTitle}
+
+
+ {statusDurationLabel}
+
+
+
+
+ );
+});
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
index f174ed8e6c6..867f66567e4 100644
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -22,6 +22,7 @@ import { readLocalApi } from "../localApi";
import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
+import type { Thread } from "../types";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { useSettings } from "./useSettings";
@@ -31,6 +32,35 @@ export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBl
readonly message: string;
}> {}
+function collectLifecycleThreadIds(
+ threads: readonly Pick[],
+ rootThreadIds: ReadonlySet,
+): Set {
+ const threadIds = new Set(rootThreadIds);
+ for (const thread of threads) {
+ if (
+ thread.parentRelation?.kind === "subagent" &&
+ rootThreadIds.has(thread.parentRelation.rootThreadId)
+ ) {
+ threadIds.add(thread.id);
+ }
+ }
+ return threadIds;
+}
+
+function withRootLast(threadIds: ReadonlySet, rootThreadId: ThreadId): ThreadId[] {
+ return [...threadIds].sort((left, right) =>
+ left === rootThreadId ? 1 : right === rootThreadId ? -1 : 0,
+ );
+}
+
+function findThreadById>(
+ threads: readonly T[],
+ threadId: ThreadId,
+): T | null {
+ return threads.find((thread) => thread.id === threadId) ?? null;
+}
+
export function useThreadActions() {
const closeTerminal = useAtomCommand(terminalEnvironment.close);
const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
@@ -85,7 +115,19 @@ export function useThreadActions() {
const resolved = resolveThreadTarget(target);
if (!resolved) return AsyncResult.success(undefined);
const { thread, threadRef } = resolved;
- if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
+ const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => {
+ const shell = readThreadShell(ref);
+ return shell === null ? [] : [shell];
+ });
+ const archivedThreadIds = collectLifecycleThreadIds(threads, new Set([threadRef.threadId]));
+ if (
+ threads.some(
+ (entry) =>
+ archivedThreadIds.has(entry.id) &&
+ entry.session?.status === "running" &&
+ entry.session.activeTurnId != null,
+ )
+ ) {
return AsyncResult.failure(
Cause.fail(
new ThreadArchiveBlockedError({
@@ -97,14 +139,17 @@ export function useThreadActions() {
const currentRouteThreadRef = getCurrentRouteThreadRef();
const shouldNavigateToDraft =
- currentRouteThreadRef?.threadId === threadRef.threadId &&
- currentRouteThreadRef.environmentId === threadRef.environmentId;
- const archiveResult = await archiveThreadMutation({
- environmentId: threadRef.environmentId,
- input: { threadId: threadRef.threadId },
- });
- if (archiveResult._tag === "Failure") {
- return archiveResult;
+ currentRouteThreadRef?.environmentId === threadRef.environmentId &&
+ archivedThreadIds.has(currentRouteThreadRef.threadId);
+
+ for (const archivedThreadId of withRootLast(archivedThreadIds, threadRef.threadId)) {
+ const archiveResult = await archiveThreadMutation({
+ environmentId: threadRef.environmentId,
+ input: { threadId: archivedThreadId },
+ });
+ if (archiveResult._tag === "Failure") {
+ return archiveResult;
+ }
}
if (shouldNavigateToDraft) {
@@ -115,11 +160,11 @@ export function useThreadActions() {
return navigationResult;
}
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
- return archiveResult;
+ return AsyncResult.success(undefined);
}
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
- return archiveResult;
+ return AsyncResult.success(undefined);
},
[archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget],
);
@@ -161,7 +206,7 @@ export function useThreadActions() {
environmentId: threadRef.environmentId,
projectId: thread.projectId,
});
- const deletedIds =
+ const selectedDeleteRootIds =
opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0
? new Set(
[...opts.deletedThreadKeys].flatMap((threadKey) => {
@@ -170,6 +215,11 @@ export function useThreadActions() {
}),
)
: undefined;
+ const targetThreadIds = collectLifecycleThreadIds(threads, new Set([threadRef.threadId]));
+ const deletedIds =
+ selectedDeleteRootIds && selectedDeleteRootIds.size > 0
+ ? collectLifecycleThreadIds(threads, selectedDeleteRootIds)
+ : targetThreadIds;
const survivingThreads =
deletedIds && deletedIds.size > 0
? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id))
@@ -201,43 +251,57 @@ export function useThreadActions() {
shouldDeleteWorktree = confirmationResult.value;
}
- if (thread.session && thread.session.status !== "stopped") {
- await stopThreadSession({
+ for (const deletedThreadId of withRootLast(deletedIds, threadRef.threadId)) {
+ const deletedThread = findThreadById(threads, deletedThreadId);
+ if (deletedThread?.session && deletedThread.session.status !== "stopped") {
+ await stopThreadSession({
+ environmentId: threadRef.environmentId,
+ input: { threadId: deletedThreadId },
+ });
+ }
+
+ await closeTerminal({
environmentId: threadRef.environmentId,
- input: { threadId: threadRef.threadId },
+ input: { threadId: deletedThreadId, deleteHistory: true },
});
}
- await closeTerminal({
- environmentId: threadRef.environmentId,
- input: { threadId: threadRef.threadId, deleteHistory: true },
- });
-
- const deletedThreadIds = deletedIds ?? new Set();
const currentRouteThreadRef = getCurrentRouteThreadRef();
- const shouldNavigateToFallback =
- currentRouteThreadRef?.threadId === threadRef.threadId &&
- currentRouteThreadRef.environmentId === threadRef.environmentId;
+ const activeDeletedThreadId =
+ currentRouteThreadRef?.environmentId === threadRef.environmentId &&
+ deletedIds.has(currentRouteThreadRef.threadId)
+ ? currentRouteThreadRef.threadId
+ : null;
+ const shouldNavigateToFallback = activeDeletedThreadId !== null;
+ const deletedThreadIdForFallback = activeDeletedThreadId ?? threadRef.threadId;
const fallbackThreadId = getFallbackThreadIdAfterDelete({
threads,
- deletedThreadId: threadRef.threadId,
- deletedThreadIds,
+ deletedThreadId: deletedThreadIdForFallback,
+ deletedThreadIds: deletedIds,
sortOrder: sidebarThreadSortOrder,
});
- const deleteResult = await deleteThreadMutation({
- environmentId: threadRef.environmentId,
- input: { threadId: threadRef.threadId },
- });
- if (deleteResult._tag === "Failure") {
- return deleteResult;
+ for (const deletedThreadId of withRootLast(deletedIds, threadRef.threadId)) {
+ const deleteResult = await deleteThreadMutation({
+ environmentId: threadRef.environmentId,
+ input: { threadId: deletedThreadId },
+ });
+ if (deleteResult._tag === "Failure") {
+ return deleteResult;
+ }
}
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
- clearComposerDraftForThread(threadRef);
- clearProjectDraftThreadById(
- scopeProjectRef(threadRef.environmentId, thread.projectId),
- threadRef,
- );
- clearTerminalUiState(threadRef);
+ for (const deletedThreadId of deletedIds) {
+ const deletedThreadRef = scopeThreadRef(threadRef.environmentId, deletedThreadId);
+ const deletedThread = findThreadById(threads, deletedThreadId);
+ clearComposerDraftForThread(deletedThreadRef);
+ if (deletedThread) {
+ clearProjectDraftThreadById(
+ scopeProjectRef(threadRef.environmentId, deletedThread.projectId),
+ deletedThreadRef,
+ );
+ }
+ clearTerminalUiState(deletedThreadRef);
+ }
if (shouldNavigateToFallback) {
if (fallbackThreadId) {
@@ -276,7 +340,7 @@ export function useThreadActions() {
}
if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
- return deleteResult;
+ return AsyncResult.success(undefined);
}
const removeResult = await removeWorktree({
@@ -318,7 +382,7 @@ export function useThreadActions() {
);
return cleanupFailure;
}
- return deleteResult;
+ return AsyncResult.success(undefined);
},
[
clearComposerDraftForThread,
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 0f12e672f66..3c4391132d1 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -1214,6 +1214,505 @@ describe("deriveWorkLogEntries", () => {
});
});
+ it("collapses streamed subagent output under the parent collab tool id", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-output-a",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ status: "inProgress",
+ title: "Subagent",
+ detail: "Inspect the auth flow",
+ data: {
+ toolCallId: "collab-1",
+ parentCollab: {
+ itemId: "collab-1",
+ detail: "Inspect the auth flow",
+ },
+ rawOutput: {
+ content: "Found the login path. ",
+ },
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-output-b",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ status: "inProgress",
+ title: "Subagent",
+ detail: "Inspect the auth flow",
+ data: {
+ toolCallId: "collab-1",
+ parentCollab: {
+ itemId: "collab-1",
+ detail: "Inspect the auth flow",
+ },
+ rawOutput: {
+ content: "No failures.",
+ },
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-complete",
+ createdAt: "2026-02-23T00:00:03.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Inspect the auth flow",
+ data: {
+ item: {
+ id: "collab-1",
+ },
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ id: "subagent-complete",
+ itemType: "collab_agent_tool_call",
+ subagentPrompt: "Inspect the auth flow",
+ output: "Found the login path. No failures.",
+ });
+ });
+
+ it("preserves same-timestamp subagent output chunk order", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-output-z",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Commit staged changes",
+ data: {
+ toolCallId: "collab-commit",
+ parentCollab: {
+ itemId: "collab-commit",
+ detail: "Commit staged changes",
+ },
+ rawOutput: {
+ content: "createdCommit: yes\n\n**hash:** `884f",
+ },
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-output-a",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Commit staged changes",
+ data: {
+ toolCallId: "collab-commit",
+ parentCollab: {
+ itemId: "collab-commit",
+ detail: "Commit staged changes",
+ },
+ rawOutput: {
+ content: "619b`\n**subject:** `Fix overage reset display`",
+ },
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.output).toBe(
+ "createdCommit: yes\n\n**hash:** `884f619b`\n**subject:** `Fix overage reset display`",
+ );
+ });
+
+ it("collapses late subagent output into an already completed subagent row", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-complete",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Create a haiku",
+ data: {
+ item: {
+ id: "collab-haiku",
+ },
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-output-late",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ status: "inProgress",
+ title: "Subagent",
+ detail: "below",
+ data: {
+ toolCallId: "collab-haiku",
+ parentCollab: {
+ itemId: "collab-haiku",
+ detail: "below",
+ },
+ rawOutput: {
+ content:
+ "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below",
+ },
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ id: "subagent-output-late",
+ itemType: "collab_agent_tool_call",
+ subagentPrompt: "Create a haiku",
+ output: "Rain lifts from the wires\nA window gathers pale dawn\nFootsteps bloom below",
+ });
+ });
+
+ it("omits empty subagent placeholders around prompt and output rows", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-empty-before",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ status: "inProgress",
+ },
+ }),
+ makeActivity({
+ id: "subagent-prompt",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "tool.updated",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Create a haiku",
+ data: {
+ item: {
+ id: "collab-haiku",
+ prompt: "Create a haiku",
+ },
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-empty-after",
+ createdAt: "2026-02-23T00:00:03.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ },
+ }),
+ makeActivity({
+ id: "subagent-output",
+ createdAt: "2026-02-23T00:00:04.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ data: {
+ toolCallId: "collab-haiku",
+ rawOutput: {
+ content: "Rain lifts from wires",
+ },
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ id: "subagent-output",
+ itemType: "collab_agent_tool_call",
+ subagentPrompt: "Create a haiku",
+ output: "Rain lifts from wires",
+ });
+ });
+
+ it("drops duplicate subagent control rows for an already rendered child block", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-spawn",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Run a harmless shell command",
+ data: {
+ item: {
+ id: "call-spawn",
+ prompt: "Run a harmless shell command",
+ tool: "spawnAgent",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ titleSeed: "Run a harmless shell command",
+ },
+ ],
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-wait",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ data: {
+ item: {
+ id: "call-wait",
+ prompt: null,
+ tool: "wait",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ },
+ ],
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-close",
+ createdAt: "2026-02-23T00:00:03.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ data: {
+ item: {
+ id: "call-close",
+ prompt: null,
+ tool: "closeAgent",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ },
+ ],
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ id: "subagent-spawn",
+ itemType: "collab_agent_tool_call",
+ subagentChildren: [
+ {
+ threadId: "subagent-child-1",
+ titleSeed: "Run a harmless shell command",
+ },
+ ],
+ });
+ });
+
+ it("keeps a resumed subagent child block as a new parent work-log row", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-spawn",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Run initial check",
+ data: {
+ item: {
+ id: "call-spawn",
+ prompt: "Run initial check",
+ tool: "spawnAgent",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ parentItemId: "call-spawn",
+ titleSeed: "Run initial check",
+ },
+ ],
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-wait",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ data: {
+ item: {
+ id: "call-wait",
+ prompt: null,
+ tool: "wait",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ parentItemId: "call-spawn",
+ },
+ ],
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-resume",
+ createdAt: "2026-02-23T00:00:03.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ turnId: "turn-followup",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Run follow-up check",
+ data: {
+ item: {
+ id: "call-resume",
+ prompt: "Run follow-up check",
+ tool: "resumeAgent",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ parentItemId: "call-resume",
+ titleSeed: "Run follow-up check",
+ },
+ ],
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(2);
+ expect(entries.map((entry) => entry.id)).toEqual(["subagent-spawn", "subagent-resume"]);
+ expect(entries[0]?.subagentChildren).toEqual([
+ {
+ threadId: "subagent-child-1",
+ parentItemId: "call-spawn",
+ titleSeed: "Run initial check",
+ },
+ ]);
+ expect(entries[1]?.subagentChildren).toEqual([
+ {
+ threadId: "subagent-child-1",
+ parentItemId: "call-resume",
+ titleSeed: "Run follow-up check",
+ },
+ ]);
+ });
+
+ it("drops duplicate resumed subagent child blocks within the same parent turn", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "subagent-resume",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ turnId: "turn-followup",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Say hi in German",
+ data: {
+ item: {
+ id: "call-resume",
+ prompt: "Say hi in German",
+ tool: "resumeAgent",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ parentItemId: "call-resume",
+ titleSeed: "Say hi in German",
+ },
+ ],
+ },
+ },
+ }),
+ makeActivity({
+ id: "subagent-send-input",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "tool.completed",
+ summary: "Subagent",
+ turnId: "turn-followup",
+ payload: {
+ itemType: "collab_agent_tool_call",
+ title: "Subagent",
+ detail: "Say hi in German",
+ data: {
+ item: {
+ id: "call-send-input",
+ prompt: "Say hi in German",
+ tool: "sendInput",
+ },
+ subagentChildren: [
+ {
+ childThreadId: "subagent-child-1",
+ parentItemId: "call-send-input",
+ titleSeed: "Say hi in German",
+ },
+ ],
+ },
+ },
+ }),
+ ];
+
+ const entries = deriveWorkLogEntries(activities);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.id).toBe("subagent-resume");
+ expect(entries[0]?.subagentChildren).toEqual([
+ {
+ threadId: "subagent-child-1",
+ parentItemId: "call-resume",
+ titleSeed: "Say hi in German",
+ },
+ ]);
+ });
+
it("uses completed read-file output previews and still collapses the same tool call", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 5d5051f748e..9c250fa1790 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -9,7 +9,7 @@ import {
ProviderDriverKind,
type ToolLifecycleItemType,
type UserInputQuestion,
- type ThreadId,
+ ThreadId,
type TurnId,
} from "@t3tools/contracts";
@@ -68,7 +68,10 @@ export interface WorkLogEntry {
detail?: string;
command?: string;
rawCommand?: string;
+ output?: string;
changedFiles?: ReadonlyArray;
+ subagentPrompt?: string;
+ subagentChildren?: ReadonlyArray;
tone: "thinking" | "tool" | "info" | "error";
toolTitle?: string;
toolData?: unknown;
@@ -80,6 +83,12 @@ export interface WorkLogEntry {
sourceActivityKind?: OrchestrationThreadActivity["kind"];
}
+export interface SubagentWorkLogChild {
+ threadId: ThreadId;
+ parentItemId?: string;
+ titleSeed?: string;
+}
+
interface DerivedWorkLogEntry extends WorkLogEntry {
activityKind: OrchestrationThreadActivity["kind"];
collapseKey?: string;
@@ -637,7 +646,9 @@ export function deriveWorkLogEntries(
if (isPlanBoundaryToolActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
}
- return collapseDerivedWorkLogEntries(entries).map((entry) => {
+ return dedupeSubagentChildWorkEntries(
+ collapseDerivedWorkLogEntries(entries.filter((entry) => !isEmptySubagentWorkLogEntry(entry))),
+ ).map((entry) => {
const { activityKind, collapseKey: _collapseKey, ...rest } = entry;
return Object.assign(rest, { sourceActivityKind: activityKind });
});
@@ -719,6 +730,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
};
const itemType = extractWorkLogItemType(payload);
const requestKind = extractWorkLogRequestKind(payload);
+ const subagentOutput =
+ itemType === "collab_agent_tool_call" ? extractSubagentOutput(payload) : null;
+ const subagentPrompt =
+ itemType === "collab_agent_tool_call" ? extractSubagentPrompt(payload, detail) : null;
+ const subagentChildren =
+ itemType === "collab_agent_tool_call" ? extractSubagentChildren(payload) : [];
if (detail) {
entry.detail = detail;
}
@@ -728,9 +745,18 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (commandPreview.rawCommand) {
entry.rawCommand = commandPreview.rawCommand;
}
+ if (subagentOutput) {
+ entry.output = subagentOutput;
+ }
if (changedFiles.length > 0) {
entry.changedFiles = changedFiles;
}
+ if (subagentPrompt) {
+ entry.subagentPrompt = subagentPrompt;
+ }
+ if (subagentChildren.length > 0) {
+ entry.subagentChildren = subagentChildren;
+ }
if (title) {
entry.toolTitle = title;
}
@@ -778,6 +804,50 @@ function collapseDerivedWorkLogEntries(
return collapsed;
}
+function dedupeSubagentChildWorkEntries(
+ entries: ReadonlyArray,
+): DerivedWorkLogEntry[] {
+ const seenChildActivityKeys = new Set();
+ const deduped: DerivedWorkLogEntry[] = [];
+ for (const entry of entries) {
+ if (entry.itemType !== "collab_agent_tool_call" || !entry.subagentChildren?.length) {
+ deduped.push(entry);
+ continue;
+ }
+ const unseenChildren = entry.subagentChildren.filter((child) => {
+ const activityScope = entry.turnId ?? child.parentItemId ?? "";
+ const key = `${child.threadId}:${activityScope}`;
+ if (seenChildActivityKeys.has(key)) {
+ return false;
+ }
+ seenChildActivityKeys.add(key);
+ return true;
+ });
+ if (unseenChildren.length === 0) {
+ continue;
+ }
+ deduped.push(
+ unseenChildren.length === entry.subagentChildren.length
+ ? entry
+ : {
+ ...entry,
+ subagentChildren: unseenChildren,
+ },
+ );
+ }
+ return deduped;
+}
+
+function isEmptySubagentWorkLogEntry(entry: DerivedWorkLogEntry): boolean {
+ return (
+ entry.itemType === "collab_agent_tool_call" &&
+ !entry.detail &&
+ !entry.subagentPrompt &&
+ !entry.output &&
+ (entry.subagentChildren?.length ?? 0) === 0
+ );
+}
+
function shouldCollapseToolLifecycleEntries(
previous: DerivedWorkLogEntry,
next: DerivedWorkLogEntry,
@@ -788,7 +858,14 @@ function shouldCollapseToolLifecycleEntries(
if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
return false;
}
- if (previous.activityKind === "tool.completed") {
+ if (
+ previous.activityKind === "tool.completed" &&
+ !(
+ next.activityKind === "tool.updated" &&
+ previous.toolCallId !== undefined &&
+ previous.toolCallId === next.toolCallId
+ )
+ ) {
return false;
}
if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
@@ -808,23 +885,43 @@ function mergeDerivedWorkLogEntries(
next: DerivedWorkLogEntry,
): DerivedWorkLogEntry {
const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
- const detail = next.detail ?? previous.detail;
+ const itemType = next.itemType ?? previous.itemType;
+ const detail =
+ itemType === "collab_agent_tool_call"
+ ? (previous.detail ?? next.detail)
+ : (next.detail ?? previous.detail);
const command = next.command ?? previous.command;
const rawCommand = next.rawCommand ?? previous.rawCommand;
+ const output =
+ itemType === "collab_agent_tool_call"
+ ? mergeTextOutputChunk(previous.output, next.output)
+ : mergeTextOutput(previous.output, next.output);
+ const subagentPrompt =
+ itemType === "collab_agent_tool_call"
+ ? (previous.subagentPrompt ?? next.subagentPrompt)
+ : (next.subagentPrompt ?? previous.subagentPrompt);
+ const subagentChildren =
+ itemType === "collab_agent_tool_call"
+ ? mergeSubagentChildren(previous.subagentChildren, next.subagentChildren)
+ : (next.subagentChildren ?? previous.subagentChildren);
const toolTitle = next.toolTitle ?? previous.toolTitle;
- const itemType = next.itemType ?? previous.itemType;
const requestKind = next.requestKind ?? previous.requestKind;
- const collapseKey = next.collapseKey ?? previous.collapseKey;
const toolCallId = next.toolCallId ?? previous.toolCallId;
const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
const toolData = next.toolData ?? previous.toolData;
+ const collapseKey = toolCallId
+ ? `tool:${toolCallId}`
+ : (next.collapseKey ?? previous.collapseKey);
return {
...previous,
...next,
...(detail ? { detail } : {}),
...(command ? { command } : {}),
...(rawCommand ? { rawCommand } : {}),
+ ...(output ? { output } : {}),
...(changedFiles.length > 0 ? { changedFiles } : {}),
+ ...(subagentPrompt ? { subagentPrompt } : {}),
+ ...(subagentChildren && subagentChildren.length > 0 ? { subagentChildren } : {}),
...(toolTitle ? { toolTitle } : {}),
...(itemType ? { itemType } : {}),
...(requestKind ? { requestKind } : {}),
@@ -835,6 +932,54 @@ function mergeDerivedWorkLogEntries(
};
}
+function mergeSubagentChildren(
+ previous: ReadonlyArray | undefined,
+ next: ReadonlyArray | undefined,
+): ReadonlyArray | undefined {
+ const merged = [...(previous ?? []), ...(next ?? [])];
+ if (merged.length === 0) {
+ return undefined;
+ }
+ const byChildActivity = new Map();
+ for (const child of merged) {
+ const key = `${child.threadId}:${child.parentItemId ?? ""}`;
+ const existing = byChildActivity.get(key);
+ const titleSeed = existing?.titleSeed ?? child.titleSeed;
+ byChildActivity.set(key, {
+ threadId: child.threadId,
+ ...(child.parentItemId ? { parentItemId: child.parentItemId } : {}),
+ ...(titleSeed ? { titleSeed } : {}),
+ });
+ }
+ return [...byChildActivity.values()];
+}
+
+function mergeTextOutput(
+ previous: string | undefined,
+ next: string | undefined,
+): string | undefined {
+ if (!previous) {
+ return next;
+ }
+ if (!next) {
+ return previous;
+ }
+ return `${previous}${next}`;
+}
+
+function mergeTextOutputChunk(
+ previous: string | undefined,
+ next: string | undefined,
+): string | undefined {
+ if (!previous) {
+ return next;
+ }
+ if (!next) {
+ return previous;
+ }
+ return `${previous}${next}`;
+}
+
function mergeChangedFiles(
previous: ReadonlyArray | undefined,
next: ReadonlyArray | undefined,
@@ -1083,7 +1228,14 @@ function extractToolTitle(payload: Record | null): string | nul
function extractToolCallId(payload: Record | null): string | null {
const data = asRecord(payload?.data);
- return asTrimmedString(data?.toolCallId);
+ const item = asRecord(data?.item);
+ const parentCollab = asRecord(data?.parentCollab);
+ return (
+ asTrimmedString(data?.toolCallId) ??
+ asTrimmedString(parentCollab?.itemId) ??
+ asTrimmedString(data?.itemId) ??
+ asTrimmedString(item?.id)
+ );
}
function normalizeInlinePreview(value: string): string {
@@ -1210,6 +1362,87 @@ function stripTrailingExitCode(value: string): {
};
}
+function firstStringFromRecord(
+ record: Record | null,
+ keys: ReadonlyArray,
+): string | null {
+ if (!record) {
+ return null;
+ }
+ for (const key of keys) {
+ const value = asTrimmedString(record[key]);
+ if (value) {
+ return value;
+ }
+ }
+ return null;
+}
+
+function firstRawStringFromRecord(
+ record: Record | null,
+ keys: ReadonlyArray,
+): string | null {
+ if (!record) {
+ return null;
+ }
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "string" && value.length > 0) {
+ return value;
+ }
+ }
+ return null;
+}
+
+function extractSubagentOutput(payload: Record | null): string | null {
+ const data = asRecord(payload?.data);
+ const rawOutput = asRecord(data?.rawOutput);
+ return firstRawStringFromRecord(rawOutput, ["content", "output", "text", "stdout", "result"]);
+}
+
+function extractSubagentPrompt(
+ payload: Record | null,
+ fallbackDetail: string | null,
+): string | null {
+ const data = asRecord(payload?.data);
+ const item = asRecord(data?.item);
+ const itemInput = asRecord(item?.input);
+ const rawInput = asRecord(data?.rawInput);
+ const parentCollab = asRecord(data?.parentCollab);
+ return (
+ asTrimmedString(parentCollab?.detail) ??
+ firstStringFromRecord(itemInput, ["prompt", "message", "description", "task"]) ??
+ firstStringFromRecord(rawInput, ["prompt", "message", "description", "task"]) ??
+ firstStringFromRecord(item, ["prompt", "message", "description", "task"]) ??
+ fallbackDetail
+ );
+}
+
+function extractSubagentChildren(
+ payload: Record | null,
+): ReadonlyArray {
+ const data = asRecord(payload?.data);
+ const children = Array.isArray(data?.subagentChildren) ? data.subagentChildren : [];
+ const result: SubagentWorkLogChild[] = [];
+ const seen = new Set();
+ for (const value of children) {
+ const record = asRecord(value);
+ const rawThreadId = asTrimmedString(record?.childThreadId) ?? asTrimmedString(record?.threadId);
+ if (!rawThreadId || seen.has(rawThreadId)) {
+ continue;
+ }
+ seen.add(rawThreadId);
+ const titleSeed = asTrimmedString(record?.titleSeed);
+ const parentItemId = asTrimmedString(record?.parentItemId);
+ result.push({
+ threadId: ThreadId.make(rawThreadId),
+ ...(parentItemId ? { parentItemId } : {}),
+ ...(titleSeed ? { titleSeed } : {}),
+ });
+ }
+ return result;
+}
+
function extractWorkLogItemType(
payload: Record | null,
): WorkLogEntry["itemType"] | undefined {
@@ -1321,7 +1554,10 @@ function compareActivitiesByOrder(
return lifecycleRankComparison;
}
- return left.id.localeCompare(right.id);
+ // Stable sort preserves arrival order for unsequenced same-timestamp events.
+ // Streaming text chunks can share millisecond timestamps; sorting those by
+ // random event ids can scramble the reconstructed output.
+ return 0;
}
function compareActivityLifecycleRank(kind: string): number {
diff --git a/apps/web/src/subagentDisplay.test.tsx b/apps/web/src/subagentDisplay.test.tsx
new file mode 100644
index 00000000000..46981aa55de
--- /dev/null
+++ b/apps/web/src/subagentDisplay.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ formatRunningSubagentDuration,
+ formatTerminalSubagentStatusDuration,
+ subagentDurationFallbackLabel,
+} from "./subagentDisplay";
+
+describe("subagentDurationFallbackLabel", () => {
+ it("does not report terminal subagents without completion time as completed", () => {
+ expect(subagentDurationFallbackLabel("completed")).toBe("duration unknown");
+ expect(subagentDurationFallbackLabel("errored")).toBe("duration unknown");
+ expect(subagentDurationFallbackLabel("interrupted")).toBe("duration unknown");
+ expect(subagentDurationFallbackLabel("stopped")).toBe("duration unknown");
+ });
+
+ it("keeps distinct fallback labels for running and unknown relations", () => {
+ expect(subagentDurationFallbackLabel("running")).toBe("Working");
+ expect(subagentDurationFallbackLabel(null)).toBe("status unknown");
+ });
+});
+
+describe("formatRunningSubagentDuration", () => {
+ it("uses working wording for active subagents", () => {
+ expect(formatRunningSubagentDuration(new Date().toISOString())).toMatch(/^Working( for .+)?$/);
+ });
+});
+
+describe("formatTerminalSubagentStatusDuration", () => {
+ it("formats successful completion as completed in duration", () => {
+ expect(formatTerminalSubagentStatusDuration("completed", "5s")).toBe("Completed in 5s");
+ });
+});
diff --git a/apps/web/src/subagentDisplay.tsx b/apps/web/src/subagentDisplay.tsx
new file mode 100644
index 00000000000..718c1e987d9
--- /dev/null
+++ b/apps/web/src/subagentDisplay.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useRef } from "react";
+
+import { formatElapsed } from "./session-logic";
+import type { ThreadShell } from "./types";
+
+export type SubagentThreadStatus = Extract<
+ NonNullable,
+ { kind: "subagent" }
+>["status"];
+
+export function subagentStatusLabel(status: SubagentThreadStatus | null): string {
+ switch (status) {
+ case "running":
+ return "Running";
+ case "completed":
+ return "Completed";
+ case "errored":
+ return "Errored";
+ case "interrupted":
+ return "Interrupted";
+ case "stopped":
+ return "Stopped";
+ case null:
+ return "Unknown";
+ }
+}
+
+export function subagentStatusToneClass(status: SubagentThreadStatus | null): string {
+ switch (status) {
+ case "running":
+ return "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300";
+ case "completed":
+ return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
+ case "errored":
+ return "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
+ case "interrupted":
+ case "stopped":
+ return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
+ case null:
+ return "border-muted-foreground/25 bg-muted/40 text-muted-foreground";
+ }
+}
+
+export function formatSubagentDuration(startIso: string, endIso: string | null): string | null {
+ if (!endIso) {
+ return null;
+ }
+ return formatElapsed(startIso, endIso);
+}
+
+export function subagentDurationFallbackLabel(status: SubagentThreadStatus | null): string {
+ switch (status) {
+ case "running":
+ return "Working";
+ case "completed":
+ case "errored":
+ case "interrupted":
+ case "stopped":
+ return "duration unknown";
+ case null:
+ return "status unknown";
+ }
+}
+
+export function formatRunningSubagentDuration(startedAt: string): string {
+ const elapsed = formatElapsed(startedAt, new Date().toISOString());
+ return elapsed ? `Working for ${elapsed}` : "Working";
+}
+
+export function formatTerminalSubagentStatusDuration(
+ status: Exclude | null,
+ duration: string | null,
+): string {
+ if (!duration) {
+ return subagentDurationFallbackLabel(status);
+ }
+
+ switch (status) {
+ case "completed":
+ return `Completed in ${duration}`;
+ case "errored":
+ return `Errored after ${duration}`;
+ case "interrupted":
+ return `Interrupted after ${duration}`;
+ case "stopped":
+ return `Stopped after ${duration}`;
+ case null:
+ return subagentDurationFallbackLabel(null);
+ }
+}
+
+export function LiveSubagentDuration({ startedAt }: { startedAt: string }) {
+ const ref = useRef(null);
+ const initialText = formatRunningSubagentDuration(startedAt);
+
+ useEffect(() => {
+ const update = () => {
+ if (ref.current) {
+ ref.current.textContent = formatRunningSubagentDuration(startedAt);
+ }
+ };
+ update();
+ const id = setInterval(update, 1000);
+ return () => clearInterval(id);
+ }, [startedAt]);
+
+ return {initialText} ;
+}
diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts
index 94eb1c65370..9bf2b1a9381 100644
--- a/packages/client-runtime/src/state/threadReducer.test.ts
+++ b/packages/client-runtime/src/state/threadReducer.test.ts
@@ -5,6 +5,7 @@ import {
EventId,
MessageId,
ProjectId,
+ ProviderItemId,
ProviderInstanceId,
ThreadId,
TurnId,
@@ -100,6 +101,49 @@ describe("applyThreadDetailEvent", () => {
expect(result.thread.session).toBeNull();
}
});
+
+ it("preserves subagent parent relation on fresh threads", () => {
+ const parentRelation = {
+ kind: "subagent" as const,
+ rootThreadId: ThreadId.make("thread-root"),
+ parentThreadId: ThreadId.make("thread-parent"),
+ parentTurnId: TurnId.make("turn-parent"),
+ parentItemId: ProviderItemId.make("call-spawn"),
+ parentActivitySequence: 1,
+ providerThreadId: "provider-child-1",
+ titleSeed: "Inspect websocket handling",
+ depth: 1,
+ startedAt: "2026-04-01T01:00:00.000Z",
+ completedAt: null,
+ status: "running" as const,
+ };
+ const result = applyThreadDetailEvent(baseThread, {
+ ...baseEventFields,
+ sequence: 1,
+ occurredAt: "2026-04-01T01:00:00.000Z",
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-child"),
+ type: "thread.created",
+ payload: {
+ threadId: ThreadId.make("thread-child"),
+ projectId: ProjectId.make("project-1"),
+ title: "Subagent",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ parentRelation,
+ createdAt: "2026-04-01T01:00:00.000Z",
+ updatedAt: "2026-04-01T01:00:00.000Z",
+ },
+ });
+
+ expect(result.kind).toBe("updated");
+ if (result.kind === "updated") {
+ expect(result.thread.parentRelation).toEqual(parentRelation);
+ }
+ });
});
describe("thread.deleted", () => {
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 670540fee70..45f736479d3 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -68,6 +68,9 @@ export function applyThreadDetailEvent(
interactionMode: event.payload.interactionMode,
branch: event.payload.branch,
worktreePath: event.payload.worktreePath,
+ ...(event.payload.parentRelation !== undefined
+ ? { parentRelation: event.payload.parentRelation }
+ : {}),
latestTurn: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
@@ -114,6 +117,9 @@ export function applyThreadDetailEvent(
...(event.payload.worktreePath !== undefined
? { worktreePath: event.payload.worktreePath }
: {}),
+ ...(event.payload.parentRelation !== undefined
+ ? { parentRelation: event.payload.parentRelation }
+ : {}),
updatedAt: event.payload.updatedAt,
},
};
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 623fed0917b..dc45713bc32 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -341,6 +341,28 @@ export const OrchestrationLatestTurn = Schema.Struct({
});
export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;
+export const OrchestrationThreadParentRelation = Schema.Union([
+ Schema.Struct({
+ kind: Schema.Literal("root"),
+ rootThreadId: ThreadId,
+ }),
+ Schema.Struct({
+ kind: Schema.Literal("subagent"),
+ rootThreadId: ThreadId,
+ parentThreadId: ThreadId,
+ parentTurnId: Schema.NullOr(TurnId),
+ parentItemId: ProviderItemId,
+ parentActivitySequence: NonNegativeInt,
+ providerThreadId: TrimmedNonEmptyString,
+ titleSeed: Schema.NullOr(TrimmedNonEmptyString),
+ depth: NonNegativeInt,
+ startedAt: IsoDateTime,
+ completedAt: Schema.NullOr(IsoDateTime),
+ status: Schema.Literals(["running", "completed", "errored", "interrupted", "stopped"]),
+ }),
+]);
+export type OrchestrationThreadParentRelation = typeof OrchestrationThreadParentRelation.Type;
+
export const OrchestrationThread = Schema.Struct({
id: ThreadId,
projectId: ProjectId,
@@ -357,6 +379,7 @@ export const OrchestrationThread = Schema.Struct({
updatedAt: IsoDateTime,
archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
deletedAt: Schema.NullOr(IsoDateTime),
+ parentRelation: Schema.optional(OrchestrationThreadParentRelation),
messages: Schema.Array(OrchestrationMessage),
proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
@@ -402,6 +425,7 @@ export const OrchestrationThreadShell = Schema.Struct({
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
+ parentRelation: Schema.optional(OrchestrationThreadParentRelation),
session: Schema.NullOr(OrchestrationSession),
latestUserMessageAt: Schema.NullOr(IsoDateTime),
hasPendingApprovals: Schema.Boolean,
@@ -503,6 +527,7 @@ const ThreadCreateCommand = Schema.Struct({
),
branch: Schema.NullOr(TrimmedNonEmptyString),
worktreePath: Schema.NullOr(TrimmedNonEmptyString),
+ parentRelation: Schema.optional(OrchestrationThreadParentRelation),
createdAt: IsoDateTime,
});
@@ -532,6 +557,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
modelSelection: Schema.optional(ModelSelection),
branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ parentRelation: Schema.optional(OrchestrationThreadParentRelation),
});
const ThreadRuntimeModeSetCommand = Schema.Struct({
@@ -725,6 +751,16 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({
createdAt: IsoDateTime,
});
+const ThreadMessageUserAppendCommand = Schema.Struct({
+ type: Schema.Literal("thread.message.user.append"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ messageId: MessageId,
+ text: TrimmedNonEmptyString,
+ turnId: Schema.optional(TurnId),
+ createdAt: IsoDateTime,
+});
+
const ThreadProposedPlanUpsertCommand = Schema.Struct({
type: Schema.Literal("thread.proposed-plan.upsert"),
commandId: CommandId,
@@ -767,6 +803,7 @@ const InternalOrchestrationCommand = Schema.Union([
ThreadSessionSetCommand,
ThreadMessageAssistantDeltaCommand,
ThreadMessageAssistantCompleteCommand,
+ ThreadMessageUserAppendCommand,
ThreadProposedPlanUpsertCommand,
ThreadTurnDiffCompleteCommand,
ThreadActivityAppendCommand,
@@ -847,6 +884,7 @@ export const ThreadCreatedPayload = Schema.Struct({
),
branch: Schema.NullOr(TrimmedNonEmptyString),
worktreePath: Schema.NullOr(TrimmedNonEmptyString),
+ parentRelation: Schema.optional(OrchestrationThreadParentRelation),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
});
@@ -873,6 +911,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
modelSelection: Schema.optional(ModelSelection),
branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ parentRelation: Schema.optional(OrchestrationThreadParentRelation),
updatedAt: IsoDateTime,
});