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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ import {
isV2LifecycleItem,
SubagentAvatar,
SubagentElapsed,
SubagentNotificationLink,
V2LifecycleRow,
type HandoffTimelineRun,
} from "./V2LifecycleRow";
Expand Down Expand Up @@ -4932,12 +4933,36 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWor
);
}

const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
type WorkEntryRowProps = {
workEntry: TimelineWorkEntry;
workspaceRoot: string | undefined;
displayLabel?: string | undefined;
onToggleEntry?: ((collapsed: boolean) => 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 <WorkEntryLogRow {...props} />;
}
return (
<SubagentNotificationLink
parentRef={scopeThreadRef(ctx.activeThreadEnvironmentId, item.threadId)}
childThreadId={childThreadId}
outcome={item.outcome}
createdAt={props.workEntry.createdAt}
timestampFormat={ctx.timestampFormat}
providerStatuses={ctx.providerStatuses}
onOpenThread={ctx.onOpenThread}
fallback={<WorkEntryLogRow {...props} />}
/>
);
});

function WorkEntryLogRow(props: WorkEntryRowProps) {
const { workEntry, workspaceRoot, displayLabel } = props;
const ctx = use(TimelineRowCtx);
const { threadRef, onImageExpand, timestampFormat } = ctx;
Expand Down Expand Up @@ -5271,7 +5296,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
) : null}
</WorkLogRow>
);
});
}

function QuestionAnswerHistory({
answer,
Expand Down
91 changes: 83 additions & 8 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<OrchestrationV2Notification["outcome"], string> = {
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<ServerProvider>;
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 (
<SubagentTimelineLink
parentRef={props.parentRef}
subagentId={agent.id}
status={agent.status}
driver={agent.driver}
provider={props.providerStatuses.find(
(provider) => 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;
Expand All @@ -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 = (
<>
<SubagentAvatar driver={props.driver} provider={props.provider} status={status} />
<SubagentAvatar
driver={props.driver}
provider={props.provider}
status={status ?? undefined}
/>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="min-w-0 truncate text-xs font-medium text-foreground">
{props.title}
</span>
{detail !== null && status !== "completed" ? (
{detail !== null && (props.event !== undefined || status !== "completed") ? (
<span
className={cn(
"shrink-0 text-3xs",
Expand All @@ -394,7 +469,7 @@ function SubagentTimelineLink(props: {
</span>
</span>
<span className="shrink-0 font-mono text-3xs text-muted-foreground/80">
<SubagentElapsed agent={timing} />
{props.event ? props.event.timestamp : <SubagentElapsed agent={timing} />}
</span>
{threadId !== null ? (
<ChevronRightIcon
Expand Down Expand Up @@ -437,7 +512,7 @@ function SubagentTimelineLink(props: {
{...props}
elapsed={agent ? <AgentElapsed agent={projectedSubagentsToRuntime([agent])[0]!} /> : null}
model={agent?.model ?? null}
status={status}
status={liveStatus}
result={agent?.result ?? props.result}
progress={agent?.progress ?? props.progress}
/>
Expand Down
Loading