1import { ThreadHoverCardPopup } from "../ThreadHoverCard";
2import { AgentElapsed } from "./AgentElapsed";
3import { projectedSubagentsToRuntime } from "@t3tools/client-runtime/state/subagentRuntime";
4import type { ReactNode } from "react";
5import { useThreadShell, useProject } from "../../state/entities";
6import { SubagentTooltipContent } from "./SubagentTooltipContent";
7import { useAtomValue } from "@effect/atom-react";
8import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environment";
9import { environmentThreadDetails } from "../../state/threads";
10import { MiddleTruncate } from "../ui/middle-truncate";
11import * as DateTime from "effect/DateTime";
12import { WorkLogRow } from "./WorkLog";
13import {
14 resolveHandoffEndpoints,
15 type HandoffEndpoints,
16 type HandoffTimelineRun,
17} from "@t3tools/client-runtime/handoff";
18import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
19import {
20 ProviderDriverKind,
21 type OrchestrationV2Notification,
22 type OrchestrationV2TurnItem,
23 type OrchestrationV2Subagent,
24 type ProviderInstanceId,
25 type ServerProvider,
26 type ThreadId,
27 type EnvironmentId,
28 type NodeId,
29 type ScopedThreadRef,
30} from "@t3tools/contracts";
31import type { TimestampFormat } from "@t3tools/contracts/settings";
32import {
33 BotIcon,
34 ChevronRightIcon,
35 ArrowRightLeftIcon,
36 ArrowRightIcon,
37 GitForkIcon,
38 MessageSquareIcon,
39 MinusIcon,
40 XIcon,
41} from "lucide-react";
42
43import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
44import { getProviderInstanceEntry } from "../../providerInstances";
45import { formatShortTimestamp } from "../../timestampFormat";
46import { getTriggerDisplayModelName } from "./providerIconUtils";
47import { ProviderInstanceIcon, providerTextColor } from "./ProviderInstanceIcon";
48import { cn } from "~/lib/utils";
49import { TimelineSystemDivider } from "./TimelineSystemDivider";
50import { Button, InlineButton } from "../ui/button";
51import { KnifeMark } from "../KnifeMark";
52
53const LIFECYCLE_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
54 "run_interrupt_request",
55 "run_interrupt_result",
56 "compaction",
57 "handoff",
58 "fork",
59 "subagent",
60 "thread_created",
61]);
62
63export function isV2LifecycleItem(item: OrchestrationV2TurnItem): boolean {
64 return LIFECYCLE_TYPES.has(item.type);
65}
66
67export type { HandoffTimelineRun } from "@t3tools/client-runtime/handoff";
68
69export function V2LifecycleRow(props: {
70 readonly item: OrchestrationV2TurnItem;
71 readonly environmentId: EnvironmentId;
72 readonly resourceSummary?: boolean | undefined;
73 readonly createdAt: string;
74 readonly timestampFormat: TimestampFormat;
75 readonly providerStatuses: ReadonlyArray<ServerProvider>;
76 readonly runs: ReadonlyArray<HandoffTimelineRun>;
77 readonly onOpenThread: (threadId: ThreadId) => void;
78}) {
79 const { item } = props;
80 if (item.type === "run_interrupt_request") {
81 return (
82 <div className="flex justify-end px-1 py-1" data-v2-item-type={item.type}>
83 <div className="flex max-w-[80%] items-center gap-2 text-xs text-destructive">
84 <span aria-hidden="true" className="font-mono">
85 ■
86 </span>
87 <span className="font-medium">Interrupt requested</span>
88 <span aria-hidden="true" className="opacity-50">
89 ·
90 </span>
91 <span className="font-medium">{item.message}</span>
92 <span className="text-3xs text-muted-foreground">
93 {formatShortTimestamp(props.createdAt, props.timestampFormat)}
94 </span>
95 </div>
96 </div>
97 );
98 }
99 if (item.type === "run_interrupt_result") {
100 return (
101 <TimelineSystemDivider
102 label="Run interrupted"
103 detail={item.message}
104 tone="danger"
105 icon={XIcon}
106 />
107 );
108 }
109 if (item.type === "compaction") {
110 const tokenDetail =
111 item.beforeTokenCount === undefined && item.afterTokenCount === undefined
112 ? null
113 : `${item.beforeTokenCount ?? "?"} → ${item.afterTokenCount ?? "?"} tokens`;
114 const label =
115 item.status === "failed"
116 ? "Context compaction failed"
117 : item.status === "cancelled" || item.status === "interrupted"
118 ? "Context compaction stopped"
119 : item.status === "pending" || item.status === "running" || item.status === "waiting"
120 ? "Compacting context"
121 : "Context compacted";
122 return (
123 <TimelineSystemDivider label={label} detail={item.summary ?? tokenDetail} icon={MinusIcon} />
124 );
125 }
126 if (item.type === "handoff") {
127 return (
128 <HandoffRow
129 endpoints={resolveHandoffEndpoints(item, props.runs)}
130 providers={props.providerStatuses}
131 failed={item.status === "failed"}
132 />
133 );
134 }
135 if (item.type === "fork") {
136 const relatedThreadId = item.source.type === "run" ? item.source.threadId : item.targetThreadId;
137 return (
138 <TimelineSystemDivider
139 label={item.source.type === "run" ? "Forked from conversation" : "Conversation fork"}
140 icon={GitForkIcon}
141 actionLabel={item.source.type === "run" ? "Open source conversation" : "Open fork"}
142 onAction={() => props.onOpenThread(relatedThreadId)}
143 />
144 );
145 }
146 if (item.type === "thread_created") {
147 if (props.resourceSummary) {
148 return (
149 <div
150 data-v2-item-type={item.type}
151 className="flex min-w-0 items-center gap-3 rounded-lg border border-border/60 p-3"
152 >
153 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted/60">
154 <MessageSquareIcon className="size-4 text-secondary-label" aria-hidden />
155 </span>
156 <span className="min-w-0 flex-1 truncate text-sm font-medium">
157 {item.title ?? "Created thread"}
158 </span>
159 <Button
160 size="xs"
161 variant="outline"
162 aria-label={`Open ${item.title ?? "created thread"}`}
163 onClick={() => props.onOpenThread(item.targetThreadId)}
164 >
165 Open chat
166 </Button>
167 </div>
168 );
169 }
170 return (
171 <WorkLogRow
172 data-v2-item-type={item.type}
173 icon={<KnifeMark className="size-4 text-icon-muted" aria-hidden />}
174 label={<>Created thread{item.title ? ` · ${item.title}` : ""}</>}
175 trailing={
176 <InlineButton
177 aria-label={`Open ${item.title ?? "created thread"}`}
178 onClick={() => props.onOpenThread(item.targetThreadId)}
179 >
180 Open chat
181 </InlineButton>
182 }
183 />
184 );
185 }
186 if (item.type === "subagent") {
187 return (
188 <SubagentTimelineLink
189 parentRef={scopeThreadRef(props.environmentId, item.threadId)}
190 subagentId={item.subagentId}
191 providerInstanceId={item.providerInstanceId}
192 origin={item.origin}
193 status={item.status}
194 driver={item.driver}
195 provider={props.providerStatuses.find(
196 (provider) => provider.instanceId === item.providerInstanceId,
197 )}
198 providers={props.providerStatuses}
199 title={formatSubagentDisplayTitle(item.title ?? "Subagent")}
200 result={item.result}
201 progress={item.progress}
202 startedAt={item.startedAt}
203 completedAt={item.completedAt}
204 threadId={item.childThreadId}
205 onOpenThread={props.onOpenThread}
206 />
207 );
208 }
209 return null;
210}
211
212/**
213 * In-flight states all present as Working, the way the agents fleet view did:
214 * detail belongs in the activity line, and a waiting or queued subagent is
215 * still the fleet doing its job. Only settled states differentiate. Idle reads
216 * as settled rather than in progress, since a resting child looks done unless
217 * resumed.
218 */
219const STATUS_VISUALS: Record<
220 OrchestrationV2TurnItem["status"],
221 { dotClass: string; label: string }
222> = {
223 pending: { dotClass: "bg-info", label: "Queued" },
224 running: { dotClass: "bg-info", label: "Running" },
225 waiting: { dotClass: "bg-info", label: "Waiting" },
226 idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" },
227 completed: { dotClass: "bg-success", label: "Completed" },
228 failed: { dotClass: "bg-destructive", label: "Failed" },
229 cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" },
230 interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" },
231};
232
233function subagentStatusVisual(status: OrchestrationV2TurnItem["status"]) {
234 return STATUS_VISUALS[status];
235}
236
237const SETTLED_SUBAGENT_STATUSES = new Set<OrchestrationV2TurnItem["status"]>([
238 "completed",
239 "failed",
240 "cancelled",
241 "interrupted",
242]);
243
244/** The server's placeholder when a child ends without output; the status dot already says it. */
245const GENERIC_CHILD_END = /^Child task ended with status\b/i;
246
247/** One line of a markdown result: drop list bullets, code ticks, and link targets. */
248function plainDetail(text: string): string {
249 return text
250 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
251 .replace(/`/g, "")
252 .replace(/^[ \t]*[-*][ \t]+/gm, "")
253 .replace(/\s+/g, " ")
254 .trim();
255}
256
257function isoOrNull(value: DateTime.Utc | null | undefined): string | null {
258 return value ? DateTime.formatIso(value) : null;
259}
260
261export function SubagentElapsed({ agent }: { agent: Parameters<typeof AgentElapsed>[0]["agent"] }) {
262 return <AgentElapsed agent={agent} />;
263}
264
265/** Round provider tile with the agents panel's status dot; rings let a header stack overlap. */
266export function SubagentAvatar({
267 driver,
268 provider,
269 status,
270 className,
271}: {
272 driver?: ProviderDriverKind | undefined;
273 provider?: ServerProvider | undefined;
274 /** Omitted inside an overlapped stack, where a covered dot would only add noise. */
275 status?: OrchestrationV2TurnItem["status"] | undefined;
276 className?: string;
277}) {
278 return (
279 <span
280 aria-hidden
281 className={cn(
282 "relative inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border/70 bg-muted ring-2 ring-background",
283 className,
284 )}
285 >
286 {driver ? (
287 <ProviderInstanceIcon
288 driverKind={driver}
289 displayName={provider?.displayName ?? driver}
290 acpRegistryIconUrl={provider?.iconUrl}
291 className="z-auto"
292 iconClassName="size-3.5"
293 />
294 ) : (
295 <BotIcon className="size-3.5 text-muted-foreground" />
296 )}
297 {status ? (
298 <span
299 className={cn(
300 "absolute -right-px -bottom-px size-2 rounded-full ring-2 ring-background",
301 subagentStatusVisual(status).dotClass,
302 )}
303 />
304 ) : null}
305 </span>
306 );
307}
308
309/** The status a notification reported, frozen at the time it arrived. */
310const NOTIFICATION_OUTCOME_STATUS: Record<
311 OrchestrationV2Notification["outcome"],
312 OrchestrationV2TurnItem["status"] | null
313> = {
314 completed: "completed",
315 failed: "failed",
316 cancelled: "cancelled",
317 updated: null,
318 unknown: null,
319};
320
321const NOTIFICATION_OUTCOME_LABEL: Record<OrchestrationV2Notification["outcome"], string> = {
322 completed: "Finished",
323 failed: "Failed",
324 cancelled: "Stopped",
325 updated: "Updated",
326 unknown: "Finished",
327};
328
329/** A notification about one subagent, drawn as that subagent's card. Renders `fallback` when the parent has no record of it. */
330export function SubagentNotificationLink(props: {
331 readonly parentRef: ScopedThreadRef;
332 readonly childThreadId: ThreadId;
333 readonly outcome: OrchestrationV2Notification["outcome"];
334 readonly createdAt: string;
335 readonly timestampFormat: TimestampFormat;
336 readonly providerStatuses: ReadonlyArray<ServerProvider>;
337 readonly onOpenThread: (threadId: ThreadId) => void;
338 readonly fallback: ReactNode;
339}) {
340 const agent = useAtomValue(
341 environmentThreadDetails.threadAtom(props.parentRef),
342 (thread) =>
343 thread?.projection.subagents.find((agent) => agent.childThreadId === props.childThreadId) ??
344 null,
345 );
346 if (agent === null) return props.fallback;
347 return (
348 <SubagentTimelineLink
349 parentRef={props.parentRef}
350 subagentId={agent.id}
351 providerInstanceId={agent.providerInstanceId}
352 origin={agent.origin}
353 status={agent.status}
354 driver={agent.driver}
355 provider={props.providerStatuses.find(
356 (provider) => provider.instanceId === agent.providerInstanceId,
357 )}
358 providers={props.providerStatuses}
359 title={formatSubagentDisplayTitle(agent.title ?? "Subagent")}
360 result={agent.result}
361 progress={agent.progress}
362 startedAt={agent.startedAt}
363 completedAt={agent.completedAt}
364 threadId={props.childThreadId}
365 onOpenThread={props.onOpenThread}
366 event={{
367 status: NOTIFICATION_OUTCOME_STATUS[props.outcome],
368 label: NOTIFICATION_OUTCOME_LABEL[props.outcome],
369 timestamp: formatShortTimestamp(props.createdAt, props.timestampFormat),
370 }}
371 />
372 );
373}
374
375function SubagentTimelineLink(props: {
376 readonly parentRef: ScopedThreadRef;
377 readonly subagentId: NodeId;
378 readonly providerInstanceId: ProviderInstanceId;
379 readonly origin: OrchestrationV2Subagent["origin"];
380 readonly driver: ProviderDriverKind;
381 readonly provider: ServerProvider | undefined;
382 readonly providers: ReadonlyArray<ServerProvider>;
383 readonly title: string;
384 readonly result: string | null;
385 readonly progress: string | undefined;
386 readonly status: OrchestrationV2TurnItem["status"];
387 readonly startedAt: DateTime.Utc | null;
388 readonly completedAt: DateTime.Utc | null;
389 readonly threadId: ThreadId | null;
390 readonly onOpenThread: (threadId: ThreadId) => void;
391 /** Draws a past event about the subagent: its status then, and when it happened instead of elapsed time. */
392 readonly event?: {
393 readonly status: OrchestrationV2TurnItem["status"] | null;
394 readonly label: string;
395 readonly timestamp: string;
396 };
397}) {
398 const agent = useAtomValue(
399 environmentThreadDetails.threadAtom(props.parentRef),
400 (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null,
401 );
402 const threadId = props.threadId;
403 const liveStatus = agent?.status ?? props.status;
404 const status = props.event ? props.event.status : liveStatus;
405 const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label;
406 const result = (agent?.result ?? props.result)?.trim();
407 const progress = (agent?.progress ?? props.progress)?.trim();
408 const settled = SETTLED_SUBAGENT_STATUSES.has(status ?? liveStatus);
409 const rawDetail = settled ? result || progress : progress || result;
410 const detail =
411 rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null;
412 const failed = status === "failed";
413 const timing = {
414 status: liveStatus,
415 startedAt: isoOrNull(agent?.startedAt ?? props.startedAt),
416 completedAt: isoOrNull(agent?.completedAt ?? props.completedAt),
417 };
418 const content = (
419 <>
420 <SubagentAvatar
421 driver={props.driver}
422 provider={props.provider}
423 status={status ?? undefined}
424 />
425 <span className="min-w-0 flex-1">
426 <span className="flex items-baseline gap-2">
427 <span className="min-w-0 truncate text-xs font-medium text-foreground">
428 {props.title}
429 </span>
430 {detail !== null && (props.event !== undefined || status !== "completed") ? (
431 <span
432 className={cn(
433 "shrink-0 text-3xs",
434 failed ? "text-destructive" : "text-muted-foreground",
435 )}
436 >
437 {statusLabel}
438 </span>
439 ) : null}
440 </span>
441 <span
442 className={cn(
443 "block text-2xs leading-relaxed",
444 failed ? "text-destructive" : "text-muted-foreground",
445 )}
446 >
447 {detail === null ? (
448 statusLabel
449 ) : detail.includes("/") && !detail.includes(" ") ? (
450 <MiddleTruncate value={detail} showTitle={false} className="flex" />
451 ) : (
452 <span className="block truncate">{detail}</span>
453 )}
454 </span>
455 </span>
456 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
457 {props.event ? props.event.timestamp : <SubagentElapsed agent={timing} />}
458 </span>
459 {threadId !== null ? (
460 <ChevronRightIcon
461 aria-hidden
462 className="size-3.5 shrink-0 text-muted-foreground/60 transition-colors group-hover/subagent:text-foreground"
463 />
464 ) : null}
465 </>
466 );
467 const className =
468 "group/subagent flex w-full min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5 text-left";
469 return (
470 <Tooltip>
471 <TooltipTrigger
472 delay={200}
473 render={
474 threadId === null ? (
475 <div data-v2-item-type="subagent" aria-description={statusLabel} className={className}>
476 {content}
477 </div>
478 ) : (
479 <button
480 type="button"
481 data-v2-item-type="subagent"
482 aria-label={`Open ${props.title}`}
483 aria-description={statusLabel}
484 onClick={() => props.onOpenThread(threadId)}
485 className={cn(
486 className,
487 "cursor-pointer transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
488 )}
489 >
490 {content}
491 </button>
492 )
493 }
494 />
495 <ThreadHoverCardPopup>
496 <SubagentTimelineTooltip
497 {...props}
498 elapsed={agent ? <AgentElapsed agent={projectedSubagentsToRuntime([agent])[0]!} /> : null}
499 model={agent?.model ?? null}
500 modelSelection={agent?.modelSelection}
501 status={liveStatus}
502 result={agent?.result ?? props.result}
503 progress={agent?.progress ?? props.progress}
504 />
505 </ThreadHoverCardPopup>
506 </Tooltip>
507 );
508}
509
510function SubagentTimelineTooltip(
511 props: Parameters<typeof SubagentTimelineLink>[0] & {
512 model: string | null;
513 modelSelection: OrchestrationV2Subagent["modelSelection"];
514 elapsed: ReactNode;
515 },
516) {
517 const environmentId = props.parentRef.environmentId;
518 const parent = useThreadShell(props.parentRef)?.source;
519 const child = useThreadShell(
520 props.threadId ? scopeThreadRef(environmentId, props.threadId) : null,
521 )?.source;
522 const parentProject = useProject(
523 parent ? scopeProjectRef(environmentId, parent.projectId) : null,
524 );
525 const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null);
526 return (
527 <SubagentTooltipContent
528 title={formatSubagentDisplayTitle(child?.title ?? props.title)}
529 model={props.model}
530 providerInstanceId={props.providerInstanceId}
531 origin={props.origin}
532 modelSelection={props.modelSelection}
533 provider={props.provider}
534 providers={props.providers}
535 driver={props.driver}
536 elapsed={props.elapsed}
537 status={props.status}
538 result={props.result}
539 progress={props.progress}
540 parentThread={parent}
541 childThread={child}
542 parentProject={parentProject ?? undefined}
543 childProject={childProject ?? undefined}
544 />
545 );
546}
547
548export function HandoffRow(props: {
549 readonly endpoints: HandoffEndpoints;
550 readonly providers: ReadonlyArray<ServerProvider>;
551 readonly pending?: boolean;
552 readonly failed?: boolean;
553}) {
554 const { from: fromEndpoints, to } = props.endpoints;
555 return (
556 <TimelineSystemDivider
557 label={props.pending ? "Context handoff pending" : "Context handoff"}
558 icon={ArrowRightLeftIcon}
559 showDetailSeparator={false}
560 tone={props.failed ? "danger" : "neutral"}
561 detail={
562 <span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5">
563 {fromEndpoints.map((endpoint, index) => (
564 <span
565 key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}
566 className="inline-flex min-w-0 items-center"
567 >
568 <HandoffEndpoint
569 providers={props.providers}
570 instanceId={endpoint.instanceId}
571 model={endpoint.model}
572 />
573 {index < fromEndpoints.length - 1 ? (
574 <span aria-hidden="true" className="ml-0.5">
575 ,
576 </span>
577 ) : null}
578 </span>
579 ))}
580 {fromEndpoints.length > 0 ? (
581 <ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
582 ) : null}
583 <HandoffEndpoint
584 providers={props.providers}
585 instanceId={to.instanceId}
586 model={to.model}
587 />
588 </span>
589 }
590 />
591 );
592}
593
594function HandoffEndpoint(props: {
595 readonly providers: ReadonlyArray<ServerProvider>;
596 readonly instanceId: ProviderInstanceId;
597 readonly model?: string | undefined;
598}) {
599 const entry = getProviderInstanceEntry(props.providers, props.instanceId);
600 const model = props.model?.trim();
601 const providerModel =
602 model === undefined || model.length === 0
603 ? undefined
604 : entry?.models.find((candidate) => candidate.slug === model);
605 const label =
606 providerModel !== undefined
607 ? getTriggerDisplayModelName(providerModel)
608 : model !== undefined && model.length > 0
609 ? model
610 : (entry?.displayName ?? props.instanceId);
611 const labelColor = providerTextColor(
612 entry?.driverKind ?? ProviderDriverKind.make(props.instanceId),
613 );
614 return (
615 <Tooltip>
616 <TooltipTrigger
617 render={
618 <span
619 tabIndex={0}
620 className="inline-flex min-w-0 items-center gap-1 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
621 >
622 <ProviderInstanceIcon
623 driverKind={entry?.driverKind ?? ProviderDriverKind.make(props.instanceId)}
624 displayName={entry?.displayName ?? props.instanceId}
625 acpRegistryAgentId={entry?.acpRegistryAgentId}
626 acpRegistryIconUrl={entry?.acpRegistryIconUrl}
627 iconClassName="size-3"
628 />
629 <span
630 className={cn("truncate font-medium", labelColor.className)}
631 style={labelColor.style}
632 >
633 {label}
634 </span>
635 </span>
636 }
637 />
638 <TooltipPopup>
639 {entry?.displayName ?? props.instanceId} · {label}
640 </TooltipPopup>
641 </Tooltip>
642 );
643}