| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 3 | import { ThreadHoverCardPopup } from "../ThreadHoverCard"; |
| 4 | import { ThreadDetailsSection } from "./ThreadDetailsSection"; |
| 5 | import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; |
| 6 | import { SubagentTooltipContent } from "./SubagentTooltipContent"; |
| 7 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 8 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 9 | import { |
| 10 | deriveWorkflowGroups, |
| 11 | projectedSubagentsToRuntime, |
| 12 | type RuntimeSubagent, |
| 13 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 14 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; |
| 15 | import { |
| 16 | deriveThreadRelationshipGraph, |
| 17 | immediateThreadRelationships, |
| 18 | isParentThreadRelationship, |
| 19 | threadRelationshipRowStatus, |
| 20 | orderWebThreadLineageRows, |
| 21 | resolveMergeBackTargetThreadId, |
| 22 | type ThreadRelationshipEdge, |
| 23 | type ThreadRelationshipWalkRow, |
| 24 | } from "@t3tools/client-runtime/state/thread-relationships"; |
| 25 | import { |
| 26 | canDetachThreadProviderSession, |
| 27 | resolveLatestMergeBackRun, |
| 28 | } from "@t3tools/client-runtime/state/thread-workflows"; |
| 29 | import { |
| 30 | NodeId, |
| 31 | type EnvironmentId, |
| 32 | type OrchestrationV2Subagent, |
| 33 | type OrchestrationV2ThreadShell, |
| 34 | type ThreadId, |
| 35 | } from "@t3tools/contracts"; |
| 36 | import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; |
| 37 | import { groupBy } from "effect/Array"; |
| 38 | import * as DateTime from "effect/DateTime"; |
| 39 | import { useNavigate } from "@tanstack/react-router"; |
| 40 | import { |
| 41 | ArrowRightIcon, |
| 42 | BotIcon, |
| 43 | CornerLeftUpIcon, |
| 44 | GitForkIcon, |
| 45 | LoaderCircleIcon, |
| 46 | MoreHorizontalIcon, |
| 47 | PlusIcon, |
| 48 | SquareIcon, |
| 49 | UnplugIcon, |
| 50 | } from "lucide-react"; |
| 51 | import { useMemo, useState, type ReactNode } from "react"; |
| 52 | |
| 53 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 54 | import { buildThreadRouteParams } from "../../threadRoutes"; |
| 55 | import { |
| 56 | useProjects, |
| 57 | useServerConfigs, |
| 58 | useThreadProjection, |
| 59 | useThreadShells, |
| 60 | } from "../../state/entities"; |
| 61 | import { threadEnvironment } from "../../state/threads"; |
| 62 | import { orchestrationEnvironment } from "../../state/orchestration"; |
| 63 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 64 | import { AgentElapsed } from "./AgentElapsed"; |
| 65 | import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; |
| 66 | import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; |
| 67 | |
| 68 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 69 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 70 | import { toastManager } from "../ui/toast"; |
| 71 | import { |
| 72 | THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, |
| 73 | THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, |
| 74 | THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, |
| 75 | } from "./threadDetailsPanelStyles"; |
| 76 | |
| 77 | // Lineage paging: a busy thread can accumulate dozens of forks and subagents, |
| 78 | // and the panel it lives in already scrolls. Show a workable window, keep the |
| 79 | // rest behind Show more, and bound what is shown so the sections below Lineage |
| 80 | // stay reachable. |
| 81 | const THREAD_LINEAGE_INITIAL_COUNT = 6; |
| 82 | const THREAD_LINEAGE_PAGE_COUNT = 12; |
| 83 | |
| 84 | export function resolveThreadLineageWindow<Row>( |
| 85 | rows: ReadonlyArray<Row>, |
| 86 | visibleCount: number, |
| 87 | ): { readonly visibleRows: ReadonlyArray<Row>; readonly hiddenCount: number } { |
| 88 | const visibleRows = rows.slice(0, visibleCount); |
| 89 | return { visibleRows, hiddenCount: rows.length - visibleRows.length }; |
| 90 | } |
| 91 | |
| 92 | export function ThreadLineageRowList(props: { |
| 93 | readonly hiddenCount: number; |
| 94 | readonly onShowMore: () => void; |
| 95 | readonly children: ReactNode; |
| 96 | }) { |
| 97 | return ( |
| 98 | <> |
| 99 | {/* |
| 100 | Bounded rather than free-growing so Lineage cannot push the rest of the |
| 101 | thread details panel out of view. Plain overflow, not a ScrollArea |
| 102 | component: this sits inside an already scrolling panel, where a |
| 103 | max-height-only virtual viewport measures badly. Every row is a focusable |
| 104 | button, so keyboard users reach and scroll the region through the rows |
| 105 | themselves and the container needs no extra tab stop of its own. An |
| 106 | expanded workflow asks for a taller window, so it raises the bound while |
| 107 | its tree is open. |
| 108 | */} |
| 109 | <ul |
| 110 | aria-label="Related threads" |
| 111 | className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0 has-data-workflow-expanded:max-h-[min(28rem,55dvh)]" |
| 112 | > |
| 113 | {props.children} |
| 114 | </ul> |
| 115 | {props.hiddenCount > 0 ? ( |
| 116 | <button |
| 117 | type="button" |
| 118 | onClick={props.onShowMore} |
| 119 | className={`flex h-8 w-full cursor-pointer items-center rounded-lg ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-sm font-medium text-muted-foreground/70 hover:bg-black/[0.055] hover:text-foreground/80 dark:hover:bg-white/[0.075]`} |
| 120 | > |
| 121 | <PlusIcon aria-hidden className="size-4 shrink-0" /> |
| 122 | Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more |
| 123 | </button> |
| 124 | ) : null} |
| 125 | </> |
| 126 | ); |
| 127 | } |
| 128 | |
| 129 | function ThreadLineageGroup(props: { |
| 130 | readonly label: string | null; |
| 131 | readonly rows: ReadonlyArray<ThreadRelationshipWalkRow>; |
| 132 | readonly expanded: boolean; |
| 133 | readonly children: (rows: ReadonlyArray<ThreadRelationshipWalkRow>) => ReactNode; |
| 134 | }) { |
| 135 | const [expanded, setExpanded] = useState(props.expanded); |
| 136 | const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT); |
| 137 | const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount); |
| 138 | const failedCount = props.rows.filter( |
| 139 | ({ edge }) => edge.status === "failed" || edge.status === "error", |
| 140 | ).length; |
| 141 | if (props.rows.length === 0) return null; |
| 142 | return ( |
| 143 | <div> |
| 144 | {props.label ? ( |
| 145 | <CollapsibleSectionHeader |
| 146 | variant="panel" |
| 147 | expanded={expanded} |
| 148 | onClick={() => setExpanded(!expanded)} |
| 149 | accessory={ |
| 150 | failedCount > 0 ? <SectionHeaderStatus>{failedCount} failed</SectionHeaderStatus> : null |
| 151 | } |
| 152 | > |
| 153 | {props.label} |
| 154 | {!expanded && ` (${props.rows.length})`} |
| 155 | </CollapsibleSectionHeader> |
| 156 | ) : null} |
| 157 | {expanded ? ( |
| 158 | <ThreadLineageRowList |
| 159 | hiddenCount={hiddenCount} |
| 160 | onShowMore={() => setVisibleCount((count) => count + THREAD_LINEAGE_PAGE_COUNT)} |
| 161 | > |
| 162 | {props.children(visibleRows)} |
| 163 | </ThreadLineageRowList> |
| 164 | ) : null} |
| 165 | </div> |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId) { |
| 170 | if (edge.kind === "transfer") return "Context transfer"; |
| 171 | if (edge.kind === "subagent") { |
| 172 | return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent"; |
| 173 | } |
| 174 | return edge.sourceThreadId === currentThreadId ? "Fork" : "Parent thread"; |
| 175 | } |
| 176 | |
| 177 | function relationshipThreadTitle(input: { |
| 178 | readonly title: string; |
| 179 | readonly isSubagent: boolean; |
| 180 | }): string { |
| 181 | if (!input.isSubagent) return input.title; |
| 182 | return formatSubagentDisplayTitle(input.title); |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * The row's timer and hover card follow current child work, including queued |
| 187 | * follow-ups whose provider turn has not started yet. |
| 188 | */ |
| 189 | function currentSubagent<Agent extends RuntimeSubagent>( |
| 190 | agent: Agent | undefined, |
| 191 | childThread: OrchestrationV2ThreadShell | null | undefined, |
| 192 | ): Agent | undefined { |
| 193 | const liveStatus = |
| 194 | childThread?.activityRunStatus ?? (childThread?.status === "queued" ? "queued" : null); |
| 195 | if (!agent || !childThread) return agent; |
| 196 | const newerRun = |
| 197 | childThread.latestRunRequestedAt && |
| 198 | DateTime.toEpochMillis(childThread.latestRunRequestedAt) > |
| 199 | Date.parse(agent.completedAt ?? agent.startedAt ?? agent.updatedAt); |
| 200 | if (!liveStatus && !newerRun) return agent; |
| 201 | const status = liveStatus ?? childThread.status; |
| 202 | const startedAt = liveStatus ? childThread.activityRunStartedAt : childThread.latestRunStartedAt; |
| 203 | const completedAt = liveStatus ? null : childThread.latestRunCompletedAt; |
| 204 | return { |
| 205 | ...agent, |
| 206 | status: |
| 207 | status === "preparing" || status === "starting" || status === "queued" |
| 208 | ? "pending" |
| 209 | : status === "rolled_back" |
| 210 | ? "interrupted" |
| 211 | : status, |
| 212 | startedAt: startedAt ? DateTime.formatIso(startedAt) : null, |
| 213 | completedAt: completedAt ? DateTime.formatIso(completedAt) : null, |
| 214 | // The task's output belongs to its recorded run, not newer child work. |
| 215 | progress: null, |
| 216 | result: null, |
| 217 | error: null, |
| 218 | }; |
| 219 | } |
| 220 | |
| 221 | export function ThreadRelationshipsPanel(props: { |
| 222 | readonly environmentId: EnvironmentId; |
| 223 | readonly threadId: ThreadId; |
| 224 | }) { |
| 225 | const ref = scopeThreadRef(props.environmentId, props.threadId); |
| 226 | const projection = useThreadProjection(ref)?.projection ?? null; |
| 227 | const providers = useServerConfigs().get(props.environmentId)?.providers; |
| 228 | const runtimeSubagents = useMemo( |
| 229 | () => projectedSubagentsToRuntime(projection?.subagents ?? []), |
| 230 | [projection?.subagents], |
| 231 | ); |
| 232 | const subagentsByThreadId = useMemo(() => { |
| 233 | const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent])); |
| 234 | return new Map( |
| 235 | (projection?.subagents ?? []) |
| 236 | .filter((subagent) => subagent.childThreadId !== null) |
| 237 | .map((subagent) => [ |
| 238 | subagent.childThreadId, |
| 239 | { |
| 240 | ...byId.get(subagent.id)!, |
| 241 | id: subagent.id, |
| 242 | threadId: subagent.threadId, |
| 243 | runId: subagent.runId, |
| 244 | nativeTaskRef: subagent.nativeTaskRef, |
| 245 | nativeStatus: subagent.status, |
| 246 | driver: subagent.driver, |
| 247 | providerInstanceId: subagent.providerInstanceId, |
| 248 | origin: subagent.origin, |
| 249 | modelSelection: subagent.modelSelection, |
| 250 | }, |
| 251 | ]), |
| 252 | ); |
| 253 | }, [projection?.subagents, runtimeSubagents]); |
| 254 | // A workflow coordinator is already a subagent row here; the grouped model |
| 255 | // is what lets that row unfold into the phases and members it ran. |
| 256 | const workflowGroupsById = useMemo( |
| 257 | () => |
| 258 | new Map(deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group])), |
| 259 | [runtimeSubagents], |
| 260 | ); |
| 261 | const threadShells = useThreadShells(); |
| 262 | const projects = useProjects().filter((project) => project.environmentId === props.environmentId); |
| 263 | const archived = useArchivedThreadSnapshots([props.environmentId]); |
| 264 | const archivedShells = archived.snapshots.find( |
| 265 | (entry) => entry.environmentId === props.environmentId, |
| 266 | )?.snapshot.threads; |
| 267 | const graph = useMemo(() => { |
| 268 | const shells: ReadonlyArray<OrchestrationV2ThreadShell> = [ |
| 269 | ...threadShells |
| 270 | .filter((thread) => thread.environmentId === props.environmentId) |
| 271 | .map((thread) => thread.source), |
| 272 | ...(archivedShells ?? []), |
| 273 | ]; |
| 274 | return deriveThreadRelationshipGraph({ threads: shells, projection }); |
| 275 | }, [archivedShells, projection, props.environmentId, threadShells]); |
| 276 | const currentThread = projection?.thread ?? graph.nodes.get(props.threadId)?.thread; |
| 277 | const currentProject = projects.find((project) => project.id === currentThread?.projectId); |
| 278 | const navigate = useNavigate(); |
| 279 | const mergeBack = useAtomCommand(threadEnvironment.mergeBack); |
| 280 | const stopSession = useAtomCommand(threadEnvironment.stopSession); |
| 281 | const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); |
| 282 | const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow); |
| 283 | const canOperateWorkflow = useAtomValue( |
| 284 | orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), |
| 285 | ); |
| 286 | const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); |
| 287 | const [stoppingId, setStoppingId] = useState<string | null>(null); |
| 288 | const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); |
| 289 | const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); |
| 290 | const relationshipRows = useMemo( |
| 291 | () => |
| 292 | orderWebThreadLineageRows({ |
| 293 | graph, |
| 294 | rows: immediateThreadRelationships(graph, props.threadId), |
| 295 | currentThreadId: props.threadId, |
| 296 | mergeTargetThreadId, |
| 297 | }).map((row) => { |
| 298 | const agent = |
| 299 | row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) |
| 300 | ? subagentsByThreadId.get(row.threadId) |
| 301 | : undefined; |
| 302 | const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id); |
| 303 | return workflowGroup === undefined |
| 304 | ? row |
| 305 | : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } }; |
| 306 | }), |
| 307 | [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById], |
| 308 | ); |
| 309 | const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; |
| 310 | const canDetach = projection ? canDetachThreadProviderSession(projection) : false; |
| 311 | |
| 312 | const { |
| 313 | related = [], |
| 314 | active = [], |
| 315 | previous = [], |
| 316 | } = groupBy(relationshipRows, ({ edge }) => { |
| 317 | if (edge.kind !== "subagent" || isParentThreadRelationship(edge, props.threadId)) |
| 318 | return "related"; |
| 319 | return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes( |
| 320 | edge.status ?? "", |
| 321 | ) |
| 322 | ? "previous" |
| 323 | : "active"; |
| 324 | }); |
| 325 | const groups = [ |
| 326 | { id: "related", label: null, rows: related, expanded: true }, |
| 327 | { id: "active", label: null, rows: active, expanded: true }, |
| 328 | { id: "previous", label: "Previous agents", rows: previous, expanded: false }, |
| 329 | ]; |
| 330 | // Subagents without a child thread yet have no row, so count them separately. |
| 331 | const runningCount = |
| 332 | (projection?.subagents.filter( |
| 333 | (agent) => agent.childThreadId === null && agent.status === "running", |
| 334 | ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; |
| 335 | |
| 336 | if (relationshipRows.length === 0 && runningCount === 0) { |
| 337 | return null; |
| 338 | } |
| 339 | |
| 340 | const openThread = (threadId: ThreadId) => { |
| 341 | void navigate({ |
| 342 | to: "/$environmentId/$threadId", |
| 343 | params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)), |
| 344 | }); |
| 345 | }; |
| 346 | |
| 347 | const merge = async () => { |
| 348 | if (!latestMergeBackRun || mergeTargetThreadId === null || busyAction !== null) return; |
| 349 | setBusyAction("merge"); |
| 350 | const result = await mergeBack({ |
| 351 | environmentId: props.environmentId, |
| 352 | input: { |
| 353 | sourceThreadId: props.threadId, |
| 354 | targetThreadId: mergeTargetThreadId, |
| 355 | runId: latestMergeBackRun.id, |
| 356 | }, |
| 357 | }); |
| 358 | setBusyAction(null); |
| 359 | if (result._tag === "Success") openThread(mergeTargetThreadId); |
| 360 | }; |
| 361 | |
| 362 | const detach = async () => { |
| 363 | if (!canDetach || busyAction !== null) return; |
| 364 | setBusyAction("detach"); |
| 365 | await stopSession({ |
| 366 | environmentId: props.environmentId, |
| 367 | input: { threadId: props.threadId }, |
| 368 | }); |
| 369 | setBusyAction(null); |
| 370 | }; |
| 371 | |
| 372 | const stopSubagent = async ( |
| 373 | childThreadId: ThreadId, |
| 374 | agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">, |
| 375 | ) => { |
| 376 | if (stoppingId !== null) return; |
| 377 | setStoppingId(childThreadId); |
| 378 | const result = await interruptTurn({ |
| 379 | environmentId: props.environmentId, |
| 380 | input: |
| 381 | agent.origin === "provider_native" |
| 382 | ? { threadId: agent.threadId, subagentId: agent.id } |
| 383 | : { threadId: childThreadId }, |
| 384 | }); |
| 385 | setStoppingId(null); |
| 386 | if (result._tag === "Failure") { |
| 387 | toastManager.add({ type: "error", title: "Could not stop subagent" }); |
| 388 | } |
| 389 | }; |
| 390 | |
| 391 | const stopWorkflow = async (subagentId: string) => { |
| 392 | if (stoppingId !== null) return; |
| 393 | setStoppingId(subagentId); |
| 394 | const result = await stopWorkflowCommand({ |
| 395 | environmentId: props.environmentId, |
| 396 | input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) }, |
| 397 | }); |
| 398 | setStoppingId(null); |
| 399 | if (result._tag === "Failure") { |
| 400 | toastManager.add({ type: "error", title: "Could not stop workflow" }); |
| 401 | } |
| 402 | }; |
| 403 | |
| 404 | const parentTitle = |
| 405 | mergeTargetThreadId === null |
| 406 | ? null |
| 407 | : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null); |
| 408 | |
| 409 | return ( |
| 410 | <ThreadDetailsSection |
| 411 | headingId="thread-details-lineage-heading" |
| 412 | title={runningCount > 0 ? `Lineage · ${runningCount} running` : "Lineage"} |
| 413 | data-thread-relationships-panel |
| 414 | actions={ |
| 415 | canDetach ? ( |
| 416 | <Menu> |
| 417 | <MenuTrigger |
| 418 | render={ |
| 419 | <ThreadDetailsControl |
| 420 | size="icon-xs" |
| 421 | variant="ghost" |
| 422 | part="icon" |
| 423 | aria-label="More thread actions" |
| 424 | disabled={busyAction !== null} |
| 425 | /> |
| 426 | } |
| 427 | > |
| 428 | <MoreHorizontalIcon className="size-3.5" /> |
| 429 | </MenuTrigger> |
| 430 | <MenuPopup align="end" className="min-w-60 max-w-(--available-width)"> |
| 431 | <MenuItem onClick={() => void detach()}> |
| 432 | <UnplugIcon className="size-3.5" /> |
| 433 | Disconnect agent session |
| 434 | </MenuItem> |
| 435 | </MenuPopup> |
| 436 | </Menu> |
| 437 | ) : null |
| 438 | } |
| 439 | > |
| 440 | {groups.map((group) => ( |
| 441 | <ThreadLineageGroup key={`${scopedThreadKey(ref)}:${group.id}`} {...group}> |
| 442 | {(visibleRows) => |
| 443 | visibleRows.map(({ threadId, edge }) => { |
| 444 | const node = graph.nodes.get(threadId); |
| 445 | const isSubagent = edge.kind === "subagent"; |
| 446 | const isMergeTarget = threadId === mergeTargetThreadId; |
| 447 | const isParent = isParentThreadRelationship(edge, props.threadId); |
| 448 | const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge }); |
| 449 | const RelationshipIcon = isParent |
| 450 | ? CornerLeftUpIcon |
| 451 | : isSubagent |
| 452 | ? BotIcon |
| 453 | : GitForkIcon; |
| 454 | const relationship = relationshipLabel(edge, props.threadId); |
| 455 | const projectedAgent = |
| 456 | isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; |
| 457 | const workflowGroup = |
| 458 | projectedAgent === undefined |
| 459 | ? undefined |
| 460 | : workflowGroupsById.get(projectedAgent.id); |
| 461 | const agent = workflowGroup |
| 462 | ? projectedAgent |
| 463 | : currentSubagent(projectedAgent, node?.thread); |
| 464 | const failed = rowStatus === "failed" || rowStatus === "error"; |
| 465 | const canStop = |
| 466 | agent && |
| 467 | (agent.origin === "app_owned" || |
| 468 | (agent.origin === "provider_native" && |
| 469 | agent.driver === "claudeAgent" && |
| 470 | agent.runId != null && |
| 471 | agent.nativeTaskRef?.strength === "strong" && |
| 472 | agent.nativeTaskRef.nativeId !== null && |
| 473 | ["pending", "running", "waiting"].includes(agent.nativeStatus))) && |
| 474 | agent.startedAt && |
| 475 | ["pending", "running", "waiting"].includes(agent.status); |
| 476 | const threadTitle = relationshipThreadTitle({ |
| 477 | // A workflow's name is shorter than its child thread's title. |
| 478 | title: |
| 479 | workflowGroup?.workflow.workflowName ?? |
| 480 | node?.thread?.title ?? |
| 481 | agent?.title ?? |
| 482 | threadId, |
| 483 | isSubagent, |
| 484 | }); |
| 485 | const provider = providers?.find( |
| 486 | (entry) => |
| 487 | entry.instanceId === |
| 488 | (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), |
| 489 | ); |
| 490 | const providerDriver = agent?.driver ?? provider?.driver; |
| 491 | const canStopWorkflow = |
| 492 | canOperateWorkflow && |
| 493 | workflowGroup?.workflow.status === "running" && |
| 494 | agent?.origin === "provider_native" && |
| 495 | providerDriver === "claudeAgent"; |
| 496 | const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) |
| 497 | ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" |
| 498 | : ""; |
| 499 | const project = projects.find((project) => project.id === node?.thread?.projectId); |
| 500 | const relationshipHint = node?.missing |
| 501 | ? "This related thread is unavailable" |
| 502 | : `Open ${relationship.toLowerCase()} in this chat`; |
| 503 | const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; |
| 504 | const relationshipTooltip = agent ? ( |
| 505 | <SubagentTooltipContent |
| 506 | title={threadTitle} |
| 507 | model={agent.model} |
| 508 | providerInstanceId={agent.providerInstanceId} |
| 509 | origin={agent.origin} |
| 510 | modelSelection={agent.modelSelection} |
| 511 | provider={provider} |
| 512 | providers={providers} |
| 513 | driver={providerDriver} |
| 514 | elapsed={<AgentElapsed agent={agent} />} |
| 515 | status={agent.status} |
| 516 | result={agent.result} |
| 517 | progress={agent.progress} |
| 518 | parentThread={currentThread ?? undefined} |
| 519 | childThread={node?.thread ?? undefined} |
| 520 | parentProject={currentProject} |
| 521 | childProject={project} |
| 522 | /> |
| 523 | ) : ( |
| 524 | relationshipHint |
| 525 | ); |
| 526 | const relationshipContent = ( |
| 527 | <> |
| 528 | <ThreadRelationshipIcon |
| 529 | driver={isSubagent && !isParent ? providerDriver : undefined} |
| 530 | provider={provider} |
| 531 | fallbackIcon={RelationshipIcon} |
| 532 | status={rowStatus} |
| 533 | /> |
| 534 | <span className="min-w-0 flex-1"> |
| 535 | <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85"> |
| 536 | {threadTitle} |
| 537 | </span> |
| 538 | </span> |
| 539 | {agent ? null : ( |
| 540 | <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> |
| 541 | )} |
| 542 | {agent && |
| 543 | !failed && |
| 544 | (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( |
| 545 | <span |
| 546 | className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStopWorkflow ? "min-w-6 text-end" : ""} ${trailingVisibilityClass}`} |
| 547 | > |
| 548 | <AgentElapsed agent={agent} compact /> |
| 549 | </span> |
| 550 | ) : !isMergeTarget ? ( |
| 551 | <span |
| 552 | className={ |
| 553 | workflowGroup |
| 554 | ? "sr-only" |
| 555 | : `shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}` |
| 556 | } |
| 557 | > |
| 558 | {threadRelationshipStatusLabel(rowStatus)} |
| 559 | </span> |
| 560 | ) : null} |
| 561 | </> |
| 562 | ); |
| 563 | // A workflow pairs this row with a disclosure, so it renders as the |
| 564 | // leading half of a split row; everything else renders it directly. |
| 565 | const relationshipLink = ( |
| 566 | <Tooltip> |
| 567 | <TooltipTrigger |
| 568 | delay={200} |
| 569 | render={ |
| 570 | <ThreadDetailsControl |
| 571 | size="sm" |
| 572 | variant="ghost" |
| 573 | part={workflowGroup ? "primary" : "row"} |
| 574 | disabled={node?.missing === true} |
| 575 | onClick={() => openThread(threadId)} |
| 576 | /> |
| 577 | } |
| 578 | > |
| 579 | {relationshipContent} |
| 580 | </TooltipTrigger> |
| 581 | <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup> |
| 582 | </Tooltip> |
| 583 | ); |
| 584 | if (workflowGroup && agent) { |
| 585 | return ( |
| 586 | <ThreadLineageWorkflowRow |
| 587 | key={threadId} |
| 588 | group={workflowGroup} |
| 589 | providerInstanceId={agent.providerInstanceId} |
| 590 | provider={provider} |
| 591 | providers={providers} |
| 592 | driver={providerDriver} |
| 593 | onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)} |
| 594 | isThreadAvailable={(memberThreadId) => |
| 595 | graph.nodes.get(memberThreadId as ThreadId)?.missing === false |
| 596 | } |
| 597 | onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} |
| 598 | stopping={stoppingId === agent.id} |
| 599 | stopDisabled={stoppingId !== null} |
| 600 | header={relationshipLink} |
| 601 | /> |
| 602 | ); |
| 603 | } |
| 604 | return ( |
| 605 | <li key={threadId} className="group relative flex h-8 items-center rounded-lg"> |
| 606 | {isMergeTarget ? ( |
| 607 | <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}> |
| 608 | <Tooltip> |
| 609 | <TooltipTrigger |
| 610 | delay={200} |
| 611 | render={ |
| 612 | <ThreadDetailsControl |
| 613 | size="sm" |
| 614 | variant="ghost" |
| 615 | part="primary" |
| 616 | aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`} |
| 617 | disabled={node?.missing === true} |
| 618 | onClick={() => openThread(threadId)} |
| 619 | /> |
| 620 | } |
| 621 | > |
| 622 | {relationshipContent} |
| 623 | </TooltipTrigger> |
| 624 | <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup> |
| 625 | </Tooltip> |
| 626 | <span |
| 627 | aria-hidden="true" |
| 628 | className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} |
| 629 | /> |
| 630 | <Tooltip> |
| 631 | <TooltipTrigger |
| 632 | render={ |
| 633 | <ThreadDetailsControl |
| 634 | size="sm" |
| 635 | variant="ghost" |
| 636 | part="secondary" |
| 637 | aria-label={ |
| 638 | parentTitle |
| 639 | ? `Merge back to ${parentTitle}` |
| 640 | : "Merge back to source conversation" |
| 641 | } |
| 642 | disabled={!canMerge || busyAction !== null} |
| 643 | onClick={() => void merge()} |
| 644 | > |
| 645 | {busyAction === "merge" ? ( |
| 646 | <LoaderCircleIcon className="size-3 animate-spin" /> |
| 647 | ) : ( |
| 648 | <PullRequestGlyph.merged className="size-3" /> |
| 649 | )} |
| 650 | </ThreadDetailsControl> |
| 651 | } |
| 652 | /> |
| 653 | <TooltipPopup side="left"> |
| 654 | {latestMergeBackRun === null |
| 655 | ? "Complete a run in this fork before merging it back" |
| 656 | : parentTitle |
| 657 | ? `Merge this conversation back into ${parentTitle}` |
| 658 | : "Merge this conversation back into its source"} |
| 659 | </TooltipPopup> |
| 660 | </Tooltip> |
| 661 | <span className="shrink-0 border border-transparent ps-1 pe-2.5 text-2xs font-medium text-muted-foreground"> |
| 662 | {threadRelationshipStatusLabel(rowStatus)} |
| 663 | </span> |
| 664 | </div> |
| 665 | ) : ( |
| 666 | relationshipLink |
| 667 | )} |
| 668 | {canStop && agent ? ( |
| 669 | <div className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"> |
| 670 | <Tooltip> |
| 671 | <TooltipTrigger |
| 672 | render={ |
| 673 | <ThreadDetailsControl |
| 674 | size="icon-xs" |
| 675 | variant="ghost" |
| 676 | part="icon" |
| 677 | tone="destructive" |
| 678 | aria-label={`Stop subagent ${threadTitle}`} |
| 679 | disabled={stoppingId !== null} |
| 680 | onClick={() => void stopSubagent(threadId, agent)} |
| 681 | /> |
| 682 | } |
| 683 | > |
| 684 | {stoppingId === threadId ? ( |
| 685 | <LoaderCircleIcon aria-hidden className="size-3 animate-spin" /> |
| 686 | ) : ( |
| 687 | <SquareIcon aria-hidden className="size-3 fill-current" /> |
| 688 | )} |
| 689 | </TooltipTrigger> |
| 690 | <TooltipPopup side="left">Stop subagent</TooltipPopup> |
| 691 | </Tooltip> |
| 692 | </div> |
| 693 | ) : null} |
| 694 | </li> |
| 695 | ); |
| 696 | }) |
| 697 | } |
| 698 | </ThreadLineageGroup> |
| 699 | ))} |
| 700 | </ThreadDetailsSection> |
| 701 | ); |
| 702 | } |