authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 18:12:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 22:12:05-03:00
logdab26f582cf7826fec67fbed64b46f59f3421280
tree5886944359b1c57f25237e52ef5d0f28f43ed09e
parent2a455570e6ee70a59241c30126b50c7df1696a6a
signature Signed by PGP key B5690EEEBB952194

fix(web): subagent finish notifications look like subagent cards (#15281)


2 files changed, 111 insertions(+), 11 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+28-3
...@@ -265,6 +265,7 @@ import {...@@ -265,6 +265,7 @@ import {
265 isV2LifecycleItem,265 isV2LifecycleItem,
266 SubagentAvatar,266 SubagentAvatar,
267 SubagentElapsed,267 SubagentElapsed,
268 SubagentNotificationLink,
268 V2LifecycleRow,269 V2LifecycleRow,
269 type HandoffTimelineRun,270 type HandoffTimelineRun,
270} from "./V2LifecycleRow";271} from "./V2LifecycleRow";
...@@ -4946,12 +4947,36 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWor...@@ -4946,12 +4947,36 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWor
4946 );4947 );
4947}4948}
49484949
4949const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {4950type WorkEntryRowProps = {
4950 workEntry: TimelineWorkEntry;4951 workEntry: TimelineWorkEntry;
4951 workspaceRoot: string | undefined;4952 workspaceRoot: string | undefined;
4952 displayLabel?: string | undefined;4953 displayLabel?: string | undefined;
4953 onToggleEntry?: ((collapsed: boolean) => void) | undefined;4954 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
4954}) {4955};
4956
4957const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) {
4958 const ctx = use(TimelineRowCtx);
4959 const item = props.workEntry.projectedItem?.item;
4960 const childThreadId =
4961 item?.type === "notification" ? notificationChildThreadId(item.source) : undefined;
4962 if (item?.type !== "notification" || childThreadId === undefined) {
4963 return <WorkEntryLogRow {...props} />;
4964 }
4965 return (
4966 <SubagentNotificationLink
4967 parentRef={scopeThreadRef(ctx.activeThreadEnvironmentId, item.threadId)}
4968 childThreadId={childThreadId}
4969 outcome={item.outcome}
4970 createdAt={props.workEntry.createdAt}
4971 timestampFormat={ctx.timestampFormat}
4972 providerStatuses={ctx.providerStatuses}
4973 onOpenThread={ctx.onOpenThread}
4974 fallback={<WorkEntryLogRow {...props} />}
4975 />
4976 );
4977});
4978
4979function WorkEntryLogRow(props: WorkEntryRowProps) {
4955 const { workEntry, workspaceRoot, displayLabel } = props;4980 const { workEntry, workspaceRoot, displayLabel } = props;
4956 const ctx = use(TimelineRowCtx);4981 const ctx = use(TimelineRowCtx);
4957 const { threadRef, onImageExpand, timestampFormat } = ctx;4982 const { threadRef, onImageExpand, timestampFormat } = ctx;
...@@ -5285,7 +5310,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -5285,7 +5310,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
5285 ) : null}5310 ) : null}
5286 </WorkLogRow>5311 </WorkLogRow>
5287 );5312 );
5288});5313}
52895314
5290function QuestionAnswerHistory({5315function QuestionAnswerHistory({
5291 answer,5316 answer,
apps/web/src/components/chat/V2LifecycleRow.tsx+83-8
...@@ -15,6 +15,7 @@ import { Fragment } from "react";...@@ -15,6 +15,7 @@ import { Fragment } from "react";
15import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";15import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
16import {16import {
17 ProviderDriverKind,17 ProviderDriverKind,
18 type OrchestrationV2Notification,
18 type OrchestrationV2TurnItem,19 type OrchestrationV2TurnItem,
19 type ProviderInstanceId,20 type ProviderInstanceId,
20 type ServerProvider,21 type ServerProvider,
...@@ -326,6 +327,69 @@ export function SubagentAvatar({...@@ -326,6 +327,69 @@ export function SubagentAvatar({
326 );327 );
327}328}
328329
330/** The status a notification reported, frozen at the time it arrived. */
331const NOTIFICATION_OUTCOME_STATUS: Record<
332 OrchestrationV2Notification["outcome"],
333 OrchestrationV2TurnItem["status"] | null
334> = {
335 completed: "completed",
336 failed: "failed",
337 cancelled: "cancelled",
338 updated: null,
339 unknown: null,
340};
341
342const NOTIFICATION_OUTCOME_LABEL: Record<OrchestrationV2Notification["outcome"], string> = {
343 completed: "Finished",
344 failed: "Failed",
345 cancelled: "Stopped",
346 updated: "Updated",
347 unknown: "Finished",
348};
349
350/** A notification about one subagent, drawn as that subagent's card. Renders `fallback` when the parent has no record of it. */
351export function SubagentNotificationLink(props: {
352 readonly parentRef: ScopedThreadRef;
353 readonly childThreadId: ThreadId;
354 readonly outcome: OrchestrationV2Notification["outcome"];
355 readonly createdAt: string;
356 readonly timestampFormat: TimestampFormat;
357 readonly providerStatuses: ReadonlyArray<ServerProvider>;
358 readonly onOpenThread: (threadId: ThreadId) => void;
359 readonly fallback: ReactNode;
360}) {
361 const agent = useAtomValue(
362 environmentThreadDetails.threadAtom(props.parentRef),
363 (thread) =>
364 thread?.projection.subagents.find((agent) => agent.childThreadId === props.childThreadId) ??
365 null,
366 );
367 if (agent === null) return props.fallback;
368 return (
369 <SubagentTimelineLink
370 parentRef={props.parentRef}
371 subagentId={agent.id}
372 status={agent.status}
373 driver={agent.driver}
374 provider={props.providerStatuses.find(
375 (provider) => provider.instanceId === agent.providerInstanceId,
376 )}
377 title={formatSubagentDisplayTitle(agent.title ?? "Subagent")}
378 result={agent.result}
379 progress={agent.progress}
380 startedAt={agent.startedAt}
381 completedAt={agent.completedAt}
382 threadId={props.childThreadId}
383 onOpenThread={props.onOpenThread}
384 event={{
385 status: NOTIFICATION_OUTCOME_STATUS[props.outcome],
386 label: NOTIFICATION_OUTCOME_LABEL[props.outcome],
387 timestamp: formatShortTimestamp(props.createdAt, props.timestampFormat),
388 }}
389 />
390 );
391}
392
329function SubagentTimelineLink(props: {393function SubagentTimelineLink(props: {
330 readonly parentRef: ScopedThreadRef;394 readonly parentRef: ScopedThreadRef;
331 readonly subagentId: NodeId;395 readonly subagentId: NodeId;
...@@ -339,35 +403,46 @@ function SubagentTimelineLink(props: {...@@ -339,35 +403,46 @@ function SubagentTimelineLink(props: {
339 readonly completedAt: DateTime.Utc | null;403 readonly completedAt: DateTime.Utc | null;
340 readonly threadId: ThreadId | null;404 readonly threadId: ThreadId | null;
341 readonly onOpenThread: (threadId: ThreadId) => void;405 readonly onOpenThread: (threadId: ThreadId) => void;
406 /** Draws a past event about the subagent: its status then, and when it happened instead of elapsed time. */
407 readonly event?: {
408 readonly status: OrchestrationV2TurnItem["status"] | null;
409 readonly label: string;
410 readonly timestamp: string;
411 };
342}) {412}) {
343 const agent = useAtomValue(413 const agent = useAtomValue(
344 environmentThreadDetails.threadAtom(props.parentRef),414 environmentThreadDetails.threadAtom(props.parentRef),
345 (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null,415 (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null,
346 );416 );
347 const threadId = props.threadId;417 const threadId = props.threadId;
348 const status = agent?.status ?? props.status;418 const liveStatus = agent?.status ?? props.status;
349 const statusLabel = subagentStatusVisual(status).label;419 const status = props.event ? props.event.status : liveStatus;
420 const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label;
350 const result = (agent?.result ?? props.result)?.trim();421 const result = (agent?.result ?? props.result)?.trim();
351 const progress = (agent?.progress ?? props.progress)?.trim();422 const progress = (agent?.progress ?? props.progress)?.trim();
352 const settled = SETTLED_SUBAGENT_STATUSES.has(status);423 const settled = SETTLED_SUBAGENT_STATUSES.has(status ?? liveStatus);
353 const rawDetail = settled ? result || progress : progress || result;424 const rawDetail = settled ? result || progress : progress || result;
354 const detail =425 const detail =
355 rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null;426 rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null;
356 const failed = status === "failed";427 const failed = status === "failed";
357 const timing = {428 const timing = {
358 status,429 status: liveStatus,
359 startedAt: isoOrNull(agent?.startedAt ?? props.startedAt),430 startedAt: isoOrNull(agent?.startedAt ?? props.startedAt),
360 completedAt: isoOrNull(agent?.completedAt ?? props.completedAt),431 completedAt: isoOrNull(agent?.completedAt ?? props.completedAt),
361 };432 };
362 const content = (433 const content = (
363 <>434 <>
364 <SubagentAvatar driver={props.driver} provider={props.provider} status={status} />435 <SubagentAvatar
436 driver={props.driver}
437 provider={props.provider}
438 status={status ?? undefined}
439 />
365 <span className="min-w-0 flex-1">440 <span className="min-w-0 flex-1">
366 <span className="flex items-baseline gap-2">441 <span className="flex items-baseline gap-2">
367 <span className="min-w-0 truncate text-xs font-medium text-foreground">442 <span className="min-w-0 truncate text-xs font-medium text-foreground">
368 {props.title}443 {props.title}
369 </span>444 </span>
370 {detail !== null && status !== "completed" ? (445 {detail !== null && (props.event !== undefined || status !== "completed") ? (
371 <span446 <span
372 className={cn(447 className={cn(
373 "shrink-0 text-3xs",448 "shrink-0 text-3xs",
...@@ -394,7 +469,7 @@ function SubagentTimelineLink(props: {...@@ -394,7 +469,7 @@ function SubagentTimelineLink(props: {
394 </span>469 </span>
395 </span>470 </span>
396 <span className="shrink-0 font-mono text-3xs text-muted-foreground/80">471 <span className="shrink-0 font-mono text-3xs text-muted-foreground/80">
397 <SubagentElapsed agent={timing} />472 {props.event ? props.event.timestamp : <SubagentElapsed agent={timing} />}
398 </span>473 </span>
399 {threadId !== null ? (474 {threadId !== null ? (
400 <ChevronRightIcon475 <ChevronRightIcon
...@@ -437,7 +512,7 @@ function SubagentTimelineLink(props: {...@@ -437,7 +512,7 @@ function SubagentTimelineLink(props: {
437 {...props}512 {...props}
438 elapsed={agent ? <AgentElapsed agent={projectedSubagentsToRuntime([agent])[0]!} /> : null}513 elapsed={agent ? <AgentElapsed agent={projectedSubagentsToRuntime([agent])[0]!} /> : null}
439 model={agent?.model ?? null}514 model={agent?.model ?? null}
440 status={status}515 status={liveStatus}
441 result={agent?.result ?? props.result}516 result={agent?.result ?? props.result}
442 progress={agent?.progress ?? props.progress}517 progress={agent?.progress ?? props.progress}
443 />518 />