diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b5113b5f41a3..0ec3bb9ed2fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -263,6 +263,7 @@ import { isV2LifecycleItem, SubagentAvatar, SubagentElapsed, + SubagentNotificationLink, V2LifecycleRow, type HandoffTimelineRun, } from "./V2LifecycleRow"; @@ -4932,12 +4933,36 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray void) | undefined; -}) { +}; + +const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { + const ctx = use(TimelineRowCtx); + const item = props.workEntry.projectedItem?.item; + const childThreadId = + item?.type === "notification" ? notificationChildThreadId(item.source) : undefined; + if (item?.type !== "notification" || childThreadId === undefined) { + return ; + } + return ( + } + /> + ); +}); + +function WorkEntryLogRow(props: WorkEntryRowProps) { const { workEntry, workspaceRoot, displayLabel } = props; const ctx = use(TimelineRowCtx); const { threadRef, onImageExpand, timestampFormat } = ctx; @@ -5271,7 +5296,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ) : null} ); -}); +} function QuestionAnswerHistory({ answer, diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index ef9e8a3b6f8f..ea453a2b0c35 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -15,6 +15,7 @@ import { Fragment } from "react"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { ProviderDriverKind, + type OrchestrationV2Notification, type OrchestrationV2TurnItem, type ProviderInstanceId, type ServerProvider, @@ -326,6 +327,69 @@ export function SubagentAvatar({ ); } +/** The status a notification reported, frozen at the time it arrived. */ +const NOTIFICATION_OUTCOME_STATUS: Record< + OrchestrationV2Notification["outcome"], + OrchestrationV2TurnItem["status"] | null +> = { + completed: "completed", + failed: "failed", + cancelled: "cancelled", + updated: null, + unknown: null, +}; + +const NOTIFICATION_OUTCOME_LABEL: Record = { + completed: "Finished", + failed: "Failed", + cancelled: "Stopped", + updated: "Updated", + unknown: "Finished", +}; + +/** A notification about one subagent, drawn as that subagent's card. Renders `fallback` when the parent has no record of it. */ +export function SubagentNotificationLink(props: { + readonly parentRef: ScopedThreadRef; + readonly childThreadId: ThreadId; + readonly outcome: OrchestrationV2Notification["outcome"]; + readonly createdAt: string; + readonly timestampFormat: TimestampFormat; + readonly providerStatuses: ReadonlyArray; + readonly onOpenThread: (threadId: ThreadId) => void; + readonly fallback: ReactNode; +}) { + const agent = useAtomValue( + environmentThreadDetails.threadAtom(props.parentRef), + (thread) => + thread?.projection.subagents.find((agent) => agent.childThreadId === props.childThreadId) ?? + null, + ); + if (agent === null) return props.fallback; + return ( + provider.instanceId === agent.providerInstanceId, + )} + title={formatSubagentDisplayTitle(agent.title ?? "Subagent")} + result={agent.result} + progress={agent.progress} + startedAt={agent.startedAt} + completedAt={agent.completedAt} + threadId={props.childThreadId} + onOpenThread={props.onOpenThread} + event={{ + status: NOTIFICATION_OUTCOME_STATUS[props.outcome], + label: NOTIFICATION_OUTCOME_LABEL[props.outcome], + timestamp: formatShortTimestamp(props.createdAt, props.timestampFormat), + }} + /> + ); +} + function SubagentTimelineLink(props: { readonly parentRef: ScopedThreadRef; readonly subagentId: NodeId; @@ -339,35 +403,46 @@ function SubagentTimelineLink(props: { readonly completedAt: DateTime.Utc | null; readonly threadId: ThreadId | null; readonly onOpenThread: (threadId: ThreadId) => void; + /** Draws a past event about the subagent: its status then, and when it happened instead of elapsed time. */ + readonly event?: { + readonly status: OrchestrationV2TurnItem["status"] | null; + readonly label: string; + readonly timestamp: string; + }; }) { const agent = useAtomValue( environmentThreadDetails.threadAtom(props.parentRef), (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null, ); const threadId = props.threadId; - const status = agent?.status ?? props.status; - const statusLabel = subagentStatusVisual(status).label; + const liveStatus = agent?.status ?? props.status; + const status = props.event ? props.event.status : liveStatus; + const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label; const result = (agent?.result ?? props.result)?.trim(); const progress = (agent?.progress ?? props.progress)?.trim(); - const settled = SETTLED_SUBAGENT_STATUSES.has(status); + const settled = SETTLED_SUBAGENT_STATUSES.has(status ?? liveStatus); const rawDetail = settled ? result || progress : progress || result; const detail = rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null; const failed = status === "failed"; const timing = { - status, + status: liveStatus, startedAt: isoOrNull(agent?.startedAt ?? props.startedAt), completedAt: isoOrNull(agent?.completedAt ?? props.completedAt), }; const content = ( <> - + {props.title} - {detail !== null && status !== "completed" ? ( + {detail !== null && (props.event !== undefined || status !== "completed") ? ( - + {props.event ? props.event.timestamp : } {threadId !== null ? ( : null} model={agent?.model ?? null} - status={status} + status={liveStatus} result={agent?.result ?? props.result} progress={agent?.progress ?? props.progress} />