| author | |
| committer | |
| log | f60367b8cb5e4a7e5e12dd01dc3889618151b1be |
| tree | f686dcf3736f12e3af1f280c76b9f5f1b50ef170 |
| parent | 0f4f02abb6ab5428c06a7e4b6c76b3ec84c62498 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>8 files changed, 150 insertions(+), 141 deletions(-)
apps/web/src/components/chat/AgentElapsed.test.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import { expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { formatCompactElapsedSeconds } from "./AgentElapsed"; | ||
| 4 | |||
| 5 | it.each([ | ||
| 6 | [45, "45s"], | ||
| 7 | [90, "1m"], | ||
| 8 | [59 * 60 + 59, "59m"], | ||
| 9 | [60 * 60, "1h"], | ||
| 10 | [90 * 60, "1.5h"], | ||
| 11 | [119 * 60, "1.9h"], | ||
| 12 | [14 * 60 * 60 + 40 * 60, "14h"], | ||
| 13 | ])("formats %ss as %s", (seconds, label) => { | ||
| 14 | expect(formatCompactElapsedSeconds(seconds)).toBe(label); | ||
| 15 | }); | ||
apps/web/src/components/chat/AgentElapsed.tsx+18-4| ... | @@ -16,15 +16,29 @@ function formatElapsedSeconds(totalSeconds: number): string { | ... | @@ -16,15 +16,29 @@ function formatElapsedSeconds(totalSeconds: number): string { |
| 16 | return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; | 16 | return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | /** Short form for tight rows: "45s", "12m", "1.5h", "14h". Rounds down. */ | ||
| 20 | export function formatCompactElapsedSeconds(totalSeconds: number): string { | ||
| 21 | const seconds = Math.max(0, Math.floor(totalSeconds)); | ||
| 22 | if (seconds < 60) return `${seconds}s`; | ||
| 23 | const minutes = Math.floor(seconds / 60); | ||
| 24 | if (minutes < 60) return `${minutes}m`; | ||
| 25 | if (minutes >= 600) return `${Math.floor(minutes / 60)}h`; | ||
| 26 | return `${Math.floor(minutes / 6) / 10}h`; | ||
| 27 | } | ||
| 28 | |||
| 19 | /** | 29 | /** |
| 20 | * Elapsed time for the current activation. Live agents self-tick via DOM | 30 | * Elapsed time for the current activation. Live agents self-tick via DOM |
| 21 | * writes (zero React commits per tick); settled agents freeze at completedAt. | 31 | * writes (zero React commits per tick); settled agents freeze at completedAt. |
| 32 | * `compact` drops the smaller unit for narrow rows such as Lineage. | ||
| 22 | */ | 33 | */ |
| 23 | export function AgentElapsed({ | 34 | export function AgentElapsed({ |
| 24 | agent, | 35 | agent, |
| 36 | compact = false, | ||
| 25 | }: { | 37 | }: { |
| 26 | agent: Pick<RuntimeSubagent, "status" | "startedAt" | "completedAt">; | 38 | agent: Pick<RuntimeSubagent, "status" | "startedAt" | "completedAt">; |
| 39 | compact?: boolean; | ||
| 27 | }) { | 40 | }) { |
| 41 | const format = compact ? formatCompactElapsedSeconds : formatElapsedSeconds; | ||
| 28 | const textRef = useRef<HTMLSpanElement>(null); | 42 | const textRef = useRef<HTMLSpanElement>(null); |
| 29 | const live = isOrchestrationV2WorkActive(agent.status); | 43 | const live = isOrchestrationV2WorkActive(agent.status); |
| 30 | const startedAt = agent.startedAt; | 44 | const startedAt = agent.startedAt; |
| ... | @@ -40,15 +54,15 @@ export function AgentElapsed({ | ... | @@ -40,15 +54,15 @@ export function AgentElapsed({ |
| 40 | { status: agent.status, startedAt, completedAt }, | 54 | { status: agent.status, startedAt, completedAt }, |
| 41 | Date.now(), | 55 | Date.now(), |
| 42 | ); | 56 | ); |
| 43 | textRef.current.textContent = | 57 | const text = elapsedMs === null ? "" : format(elapsedMs / 1000); |
| 44 | elapsedMs === null ? "" : formatElapsedSeconds(elapsedMs / 1000); | 58 | if (textRef.current.textContent !== text) textRef.current.textContent = text; |
| 45 | } | 59 | } |
| 46 | }; | 60 | }; |
| 47 | update(); | 61 | update(); |
| 48 | if (!live) return; | 62 | if (!live) return; |
| 49 | const id = setInterval(update, 1000); | 63 | const id = setInterval(update, 1000); |
| 50 | return () => clearInterval(id); | 64 | return () => clearInterval(id); |
| 51 | }, [live, startedAt, completedAt, agent.status]); | 65 | }, [live, startedAt, completedAt, agent.status, format]); |
| 52 | 66 | ||
| 53 | const elapsedMs = deriveSubagentElapsedMs(agent, 0); | 67 | const elapsedMs = deriveSubagentElapsedMs(agent, 0); |
| 54 | if (elapsedMs === null) { | 68 | if (elapsedMs === null) { |
| ... | @@ -56,7 +70,7 @@ export function AgentElapsed({ | ... | @@ -56,7 +70,7 @@ export function AgentElapsed({ |
| 56 | } | 70 | } |
| 57 | return ( | 71 | return ( |
| 58 | <span ref={textRef} className="tabular-nums"> | 72 | <span ref={textRef} className="tabular-nums"> |
| 59 | {formatElapsedSeconds(elapsedMs / 1000)} | 73 | {format(elapsedMs / 1000)} |
| 60 | </span> | 74 | </span> |
| 61 | ); | 75 | ); |
| 62 | } | 76 | } |
apps/web/src/components/chat/ThreadDetailsControl.tsx+4-5| ... | @@ -11,10 +11,9 @@ import { | ... | @@ -11,10 +11,9 @@ import { |
| 11 | THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, | 11 | THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, |
| 12 | THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, | 12 | THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, |
| 13 | THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, | 13 | THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, |
| 14 | THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS, | ||
| 15 | THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, | 14 | THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, |
| 16 | THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS, | ||
| 17 | THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, | 15 | THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, |
| 16 | THREAD_DETAILS_PANEL_META_ACTION_CLASS, | ||
| 18 | THREAD_DETAILS_PANEL_CHEVRON_CLASS, | 17 | THREAD_DETAILS_PANEL_CHEVRON_CLASS, |
| 19 | } from "./threadDetailsPanelStyles"; | 18 | } from "./threadDetailsPanelStyles"; |
| 20 | import { ChevronDownIcon } from "lucide-react"; | 19 | import { ChevronDownIcon } from "lucide-react"; |
| ... | @@ -24,10 +23,9 @@ const parts = { | ... | @@ -24,10 +23,9 @@ const parts = { |
| 24 | select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, | 23 | select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, |
| 25 | primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, | 24 | primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, |
| 26 | secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, | 25 | secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, |
| 27 | action: THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS, | ||
| 28 | "link-primary": THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, | 26 | "link-primary": THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, |
| 29 | checks: THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS, | ||
| 30 | icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, | 27 | icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, |
| 28 | meta: THREAD_DETAILS_PANEL_META_ACTION_CLASS, | ||
| 31 | }; | 29 | }; |
| 32 | 30 | ||
| 33 | /** Panel controls own their fixed density; toolbar controls use the standard Button variants. */ | 31 | /** Panel controls own their fixed density; toolbar controls use the standard Button variants. */ |
| ... | @@ -45,7 +43,7 @@ export function ThreadDetailsControl({ | ... | @@ -45,7 +43,7 @@ export function ThreadDetailsControl({ |
| 45 | panel?: boolean; | 43 | panel?: boolean; |
| 46 | part?: keyof typeof parts; | 44 | part?: keyof typeof parts; |
| 47 | multiline?: boolean; | 45 | multiline?: boolean; |
| 48 | tone?: "default" | "muted" | "destructive"; | 46 | tone?: "default" | "muted" | "primary" | "destructive"; |
| 49 | }) { | 47 | }) { |
| 50 | const control = useRender({ | 48 | const control = useRender({ |
| 51 | defaultTagName: "button", | 49 | defaultTagName: "button", |
| ... | @@ -58,6 +56,7 @@ export function ThreadDetailsControl({ | ... | @@ -58,6 +56,7 @@ export function ThreadDetailsControl({ |
| 58 | parts[part], | 56 | parts[part], |
| 59 | multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto", | 57 | multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto", |
| 60 | tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80", | 58 | tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80", |
| 59 | tone === "primary" && "text-primary hover:text-primary", | ||
| 61 | tone === "destructive" && | 60 | tone === "destructive" && |
| 62 | "text-destructive hover:text-destructive data-pressed:text-destructive", | 61 | "text-destructive hover:text-destructive data-pressed:text-destructive", |
| 63 | className, | 62 | className, |
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+87-112| ... | @@ -4,10 +4,11 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; | ... | @@ -4,10 +4,11 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 4 | * The thread details panel's pull request row: what the thread's pull request is, and the one | 4 | * The thread details panel's pull request row: what the thread's pull request is, and the one |
| 5 | * thing worth doing to it right now. | 5 | * thing worth doing to it right now. |
| 6 | * | 6 | * |
| 7 | * The row itself opens the pull request in the right panel, exactly as it always has. Around | 7 | * The row itself opens the pull request in the right panel, exactly as it always has. Once the |
| 8 | * that, the host's richer answer collapses into a single trailing slot, ranked by what unblocks | 8 | * host answers, the title gets the full first line and a second line carries the number, checks, |
| 9 | * the merge next: "Resolve" on conflicts, "Ready" on a draft, "Fix" under failing checks, and | 9 | * watch, and a single action slot, ranked by what unblocks the merge next: "Resolve" on |
| 10 | * "Merge" only once the branch is clean and its checks pass. While checks run the slot reports | 10 | * conflicts, "Ready" on a draft, "Fix" under failing checks, and "Merge" only once the branch is |
| 11 | * clean and its checks pass. While checks run the slot reports | ||
| 11 | * that instead — a merge offered mid-run would race the very runs that gate it. Everything else | 12 | * that instead — a merge offered mid-run would race the very runs that gate it. Everything else |
| 12 | * the host knows (title, state, checks tally, diff size) lives in the row's tooltip, so a hover | 13 | * the host knows (title, state, checks tally, diff size) lives in the row's tooltip, so a hover |
| 13 | * answers what previously took opening the panel. | 14 | * answers what previously took opening the panel. |
| ... | @@ -231,7 +232,7 @@ export function ThreadDetailsPrRow({ | ... | @@ -231,7 +232,7 @@ export function ThreadDetailsPrRow({ |
| 231 | <PullRequestGlyph.pullRequest className={THREAD_DETAILS_PANEL_ICON_CLASS} /> | 232 | <PullRequestGlyph.pullRequest className={THREAD_DETAILS_PANEL_ICON_CLASS} /> |
| 232 | ); | 233 | ); |
| 233 | 234 | ||
| 234 | // Everything the host reported, at a glance. The row stays one line; the tooltip is where the | 235 | // Everything the host reported, at a glance. The rows stay short; the tooltip is where the |
| 235 | // rest of the answer lives — styled like the sidebar's thread tooltip, title above icon-led | 236 | // rest of the answer lives — styled like the sidebar's thread tooltip, title above icon-led |
| 236 | // detail rows, so the two read as one family. | 237 | // detail rows, so the two read as one family. |
| 237 | const rowTooltip = | 238 | const rowTooltip = |
| ... | @@ -344,83 +345,88 @@ export function ThreadDetailsPrRow({ | ... | @@ -344,83 +345,88 @@ export function ThreadDetailsPrRow({ |
| 344 | } | 345 | } |
| 345 | : null; | 346 | : null; |
| 346 | 347 | ||
| 347 | const rowContent = ( | ||
| 348 | <> | ||
| 349 | {icon} | ||
| 350 | <span className="min-w-0 flex-1 truncate text-left">{label}</span> | ||
| 351 | </> | ||
| 352 | ); | ||
| 353 | |||
| 354 | // The server ends a watch when the pull request closes, so only an open one shows the eye. | 348 | // The server ends a watch when the pull request closes, so only an open one shows the eye. |
| 355 | // It takes the row's rounded end when nothing follows it. | 349 | const watching = |
| 356 | const watchIsLast = | 350 | onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open"; |
| 357 | detail === null || | 351 | const watchControl = (part: "secondary" | "icon") => |
| 358 | ((checksRollup === null || conflicting || detail.isDraft) && trailingAction === null); | 352 | watching ? ( |
| 359 | const watchSegment = | 353 | <Tooltip> |
| 360 | onStopWatching && (detail?.state ?? pr?.state ?? "open") === "open" ? ( | 354 | <TooltipTrigger |
| 361 | <> | 355 | render={ |
| 362 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> | 356 | <ThreadDetailsControl |
| 363 | <Tooltip> | 357 | type="button" |
| 364 | <TooltipTrigger | 358 | variant="ghost" |
| 365 | render={ | 359 | size="sm" |
| 366 | <ThreadDetailsControl | 360 | part={part} |
| 367 | type="button" | 361 | className="group/watch" |
| 368 | variant="ghost" | 362 | aria-label={`Stop watching #${number}`} |
| 369 | size="sm" | 363 | onClick={onStopWatching} |
| 370 | part={watchIsLast ? "secondary" : "checks"} | 364 | /> |
| 371 | className="group/watch" | 365 | } |
| 372 | aria-label={`Stop watching #${number}`} | 366 | > |
| 373 | onClick={onStopWatching} | 367 | <EyeIcon |
| 374 | /> | 368 | aria-hidden |
| 375 | } | 369 | className={cn(part === "icon" ? "size-3.5" : "size-4", "group-hover/watch:hidden")} |
| 376 | > | 370 | /> |
| 377 | <EyeIcon aria-hidden className="size-4 group-hover/watch:hidden" /> | 371 | <EyeOffIcon |
| 378 | <EyeOffIcon aria-hidden className="hidden size-4 group-hover/watch:block" /> | 372 | aria-hidden |
| 379 | </TooltipTrigger> | 373 | className={cn( |
| 380 | <TooltipPopup side="top"> | 374 | part === "icon" ? "size-3.5" : "size-4", |
| 381 | Watching: the agent wakes when checks finish, someone comments, or the branch conflicts. | 375 | "hidden group-hover/watch:block", |
| 382 | Click to stop. | 376 | )} |
| 383 | </TooltipPopup> | 377 | /> |
| 384 | </Tooltip> | 378 | </TooltipTrigger> |
| 385 | </> | 379 | <TooltipPopup side="top"> |
| 380 | Watching: the agent wakes when checks finish, someone comments, or the branch conflicts. | ||
| 381 | Click to stop. | ||
| 382 | </TooltipPopup> | ||
| 383 | </Tooltip> | ||
| 386 | ) : null; | 384 | ) : null; |
| 387 | 385 | ||
| 386 | const openRow = (text: string, part: "row" | "link-primary") => ( | ||
| 387 | <Tooltip> | ||
| 388 | <TooltipTrigger | ||
| 389 | render={ | ||
| 390 | <ThreadDetailsControl | ||
| 391 | type="button" | ||
| 392 | variant="ghost" | ||
| 393 | size="sm" | ||
| 394 | part={part} | ||
| 395 | aria-label={openAriaLabel} | ||
| 396 | onClick={onOpen} | ||
| 397 | /> | ||
| 398 | } | ||
| 399 | > | ||
| 400 | {icon} | ||
| 401 | <span className="min-w-0 flex-1 truncate text-left">{text}</span> | ||
| 402 | </TooltipTrigger> | ||
| 403 | {rowTooltip} | ||
| 404 | </Tooltip> | ||
| 405 | ); | ||
| 406 | |||
| 407 | const showChecks = detail !== null && checksRollup !== null && !conflicting && !detail.isDraft; | ||
| 408 | |||
| 388 | return ( | 409 | return ( |
| 389 | <> | 410 | <> |
| 390 | {detail ? ( | 411 | {detail !== null && (showChecks || trailingAction !== null || watching) ? ( |
| 391 | <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}> | 412 | // Two lines so the title keeps the full row width: the number, checks, watch, and the |
| 392 | <Tooltip> | 413 | // next action sit underneath, aligned with the title text. The second line hugs its title |
| 393 | <TooltipTrigger | 414 | // and the block keeps space below, so stacked pull requests read as separate pairs. |
| 394 | render={ | 415 | <div className="pb-2"> |
| 395 | <ThreadDetailsControl | 416 | {openRow(detail.title, "row")} |
| 396 | type="button" | 417 | <div className="-mt-1 flex h-6 min-w-0 items-center gap-0.5 ps-9 pe-1 text-xs text-muted-foreground"> |
| 397 | variant="ghost" | 418 | <span className="me-1 shrink-0 tabular-nums">#{number}</span> |
| 398 | size="sm" | 419 | {showChecks && checksRollup !== null ? ( |
| 399 | part="link-primary" | ||
| 400 | aria-label={openAriaLabel} | ||
| 401 | onClick={onOpen} | ||
| 402 | /> | ||
| 403 | } | ||
| 404 | > | ||
| 405 | {rowContent} | ||
| 406 | </TooltipTrigger> | ||
| 407 | {rowTooltip} | ||
| 408 | </Tooltip> | ||
| 409 | {watchSegment} | ||
| 410 | {checksRollup !== null && !conflicting && !detail.isDraft ? ( | ||
| 411 | <> | ||
| 412 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> | ||
| 413 | <PullRequestChecksPopover | 420 | <PullRequestChecksPopover |
| 414 | checksState={checksRollup} | 421 | checksState={checksRollup} |
| 415 | checks={detail.checks} | 422 | checks={detail.checks} |
| 416 | variant="count" | 423 | variant="count" |
| 417 | render={<ThreadDetailsControl part="checks" />} | 424 | render={<ThreadDetailsControl part="meta" />} |
| 418 | /> | 425 | /> |
| 419 | </> | 426 | ) : null} |
| 420 | ) : null} | 427 | {watchControl("icon")} |
| 421 | {trailingAction ? ( | 428 | <span className="flex-1" /> |
| 422 | <> | 429 | {trailingAction ? ( |
| 423 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> | ||
| 424 | <Tooltip> | 430 | <Tooltip> |
| 425 | <TooltipTrigger | 431 | <TooltipTrigger |
| 426 | render={ | 432 | render={ |
| ... | @@ -428,8 +434,8 @@ export function ThreadDetailsPrRow({ | ... | @@ -428,8 +434,8 @@ export function ThreadDetailsPrRow({ |
| 428 | type="button" | 434 | type="button" |
| 429 | variant="ghost" | 435 | variant="ghost" |
| 430 | size="sm" | 436 | size="sm" |
| 431 | part="action" | 437 | part="meta" |
| 432 | tone={trailingAction.destructive ? "destructive" : "default"} | 438 | tone={trailingAction.destructive ? "destructive" : "primary"} |
| 433 | disabled={actionPending || handoff !== null} | 439 | disabled={actionPending || handoff !== null} |
| 434 | onClick={trailingAction.onClick} | 440 | onClick={trailingAction.onClick} |
| 435 | /> | 441 | /> |
| ... | @@ -440,48 +446,17 @@ export function ThreadDetailsPrRow({ | ... | @@ -440,48 +446,17 @@ export function ThreadDetailsPrRow({ |
| 440 | </TooltipTrigger> | 446 | </TooltipTrigger> |
| 441 | <TooltipPopup side="top">{trailingAction.tooltip}</TooltipPopup> | 447 | <TooltipPopup side="top">{trailingAction.tooltip}</TooltipPopup> |
| 442 | </Tooltip> | 448 | </Tooltip> |
| 443 | </> | 449 | ) : null} |
| 444 | ) : null} | 450 | </div> |
| 445 | </div> | 451 | </div> |
| 446 | ) : watchSegment ? ( | 452 | ) : watching ? ( |
| 447 | <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}> | 453 | <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}> |
| 448 | <Tooltip> | 454 | {openRow(label, "link-primary")} |
| 449 | <TooltipTrigger | 455 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> |
| 450 | render={ | 456 | {watchControl("secondary")} |
| 451 | <ThreadDetailsControl | ||
| 452 | type="button" | ||
| 453 | variant="ghost" | ||
| 454 | size="sm" | ||
| 455 | part="link-primary" | ||
| 456 | aria-label={openAriaLabel} | ||
| 457 | onClick={onOpen} | ||
| 458 | /> | ||
| 459 | } | ||
| 460 | > | ||
| 461 | {rowContent} | ||
| 462 | </TooltipTrigger> | ||
| 463 | {rowTooltip} | ||
| 464 | </Tooltip> | ||
| 465 | {watchSegment} | ||
| 466 | </div> | 457 | </div> |
| 467 | ) : ( | 458 | ) : ( |
| 468 | <Tooltip> | 459 | openRow(label, "row") |
| 469 | <TooltipTrigger | ||
| 470 | render={ | ||
| 471 | <ThreadDetailsControl | ||
| 472 | type="button" | ||
| 473 | variant="ghost" | ||
| 474 | size="sm" | ||
| 475 | part="row" | ||
| 476 | aria-label={openAriaLabel} | ||
| 477 | onClick={onOpen} | ||
| 478 | /> | ||
| 479 | } | ||
| 480 | > | ||
| 481 | {rowContent} | ||
| 482 | </TooltipTrigger> | ||
| 483 | {rowTooltip} | ||
| 484 | </Tooltip> | ||
| 485 | )} | 460 | )} |
| 486 | {rowAction === "merge" ? ( | 461 | {rowAction === "merge" ? ( |
| 487 | <AlertDialog open={confirmingMerge} onOpenChange={(open) => setConfirmingMerge(open)}> | 462 | <AlertDialog open={confirmingMerge} onOpenChange={(open) => setConfirmingMerge(open)}> |
apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+3-2| ... | @@ -238,9 +238,10 @@ it("shows the matching child agent details and refreshes them when the agent set | ... | @@ -238,9 +238,10 @@ it("shows the matching child agent details and refreshes them when the agent set |
| 238 | renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), | 238 | renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), |
| 239 | ); | 239 | ); |
| 240 | expect(text()).toContain("Checker"); | 240 | expect(text()).toContain("Checker"); |
| 241 | expect(text()).toContain("2m 15s"); | 241 | // A started agent's row shows only its compact time; the icon carries the status. |
| 242 | expect(text()).toContain("Checker 2m"); | ||
| 242 | expect(text()).not.toContain("(1)"); | 243 | expect(text()).not.toContain("(1)"); |
| 243 | expect(text()).toContain("Done"); | 244 | expect(text()).not.toContain("Done"); |
| 244 | expect(text()).not.toContain("running"); | 245 | expect(text()).not.toContain("running"); |
| 245 | expect(text()).not.toContain("Worker"); | 246 | expect(text()).not.toContain("Worker"); |
| 246 | await act(async () => | 247 | await act(async () => |
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+15-11| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | resolveLatestMergeBackRun, | 25 | resolveLatestMergeBackRun, |
| 26 | } from "@t3tools/client-runtime/state/thread-workflows"; | 26 | } from "@t3tools/client-runtime/state/thread-workflows"; |
| 27 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; | 27 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; |
| 28 | import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; | ||
| 28 | import { groupBy } from "effect/Array"; | 29 | import { groupBy } from "effect/Array"; |
| 29 | import * as DateTime from "effect/DateTime"; | 30 | import * as DateTime from "effect/DateTime"; |
| 30 | import { useNavigate } from "@tanstack/react-router"; | 31 | import { useNavigate } from "@tanstack/react-router"; |
| ... | @@ -382,6 +383,7 @@ export function ThreadRelationshipsPanel(props: { | ... | @@ -382,6 +383,7 @@ export function ThreadRelationshipsPanel(props: { |
| 382 | isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, | 383 | isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, |
| 383 | node?.thread, | 384 | node?.thread, |
| 384 | ); | 385 | ); |
| 386 | const failed = status === "failed" || status === "error"; | ||
| 385 | const canStop = | 387 | const canStop = |
| 386 | agent?.origin === "app_owned" && | 388 | agent?.origin === "app_owned" && |
| 387 | agent.startedAt && | 389 | agent.startedAt && |
| ... | @@ -435,19 +437,21 @@ export function ThreadRelationshipsPanel(props: { | ... | @@ -435,19 +437,21 @@ export function ThreadRelationshipsPanel(props: { |
| 435 | {threadTitle} | 437 | {threadTitle} |
| 436 | </span> | 438 | </span> |
| 437 | </span> | 439 | </span> |
| 438 | {agent ? ( | 440 | {agent ? null : ( |
| 439 | agent.startedAt ? ( | ||
| 440 | <span | ||
| 441 | className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStop ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""}`} | ||
| 442 | > | ||
| 443 | <AgentElapsed agent={agent} /> | ||
| 444 | </span> | ||
| 445 | ) : null | ||
| 446 | ) : ( | ||
| 447 | <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> | 441 | <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> |
| 448 | )} | 442 | )} |
| 449 | {!isMergeTarget ? ( | 443 | {/* One trailing item keeps room for the title: the icon dot already |
| 450 | <span className="shrink-0 text-2xs text-muted-foreground"> | 444 | carries status, so an agent with a known time shows only that. */} |
| 445 | {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? ( | ||
| 446 | <span | ||
| 447 | className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStop ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""}`} | ||
| 448 | > | ||
| 449 | <AgentElapsed agent={agent} compact /> | ||
| 450 | </span> | ||
| 451 | ) : !isMergeTarget ? ( | ||
| 452 | <span | ||
| 453 | className={`shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"}`} | ||
| 454 | > | ||
| 451 | {threadRelationshipStatusLabel(status)} | 455 | {threadRelationshipStatusLabel(status)} |
| 452 | </span> | 456 | </span> |
| 453 | ) : null} | 457 | ) : null} |
apps/web/src/components/chat/threadDetailsPanelStyles.ts+3-5| ... | @@ -29,9 +29,6 @@ export const THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS = `group/thread-details | ... | @@ -29,9 +29,6 @@ export const THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS = `group/thread-details |
| 29 | 29 | ||
| 30 | export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; | 30 | export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; |
| 31 | 31 | ||
| 32 | /** The trailing half of a link split row when it carries a word ("Merge") rather than an icon. */ | ||
| 33 | export const THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} shrink-0 justify-center rounded-s-none text-primary ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; | ||
| 34 | |||
| 35 | export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-8 w-full min-w-0 justify-start rounded-lg border border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80 sm:h-8 sm:text-[13px]`; | 32 | export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-8 w-full min-w-0 justify-start rounded-lg border border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80 sm:h-8 sm:text-[13px]`; |
| 36 | 33 | ||
| 37 | export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; | 34 | export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; |
| ... | @@ -40,6 +37,9 @@ export const THREAD_DETAILS_PANEL_CHEVRON_CLASS = "size-4 shrink-0 text-muted-fo | ... | @@ -40,6 +37,9 @@ export const THREAD_DETAILS_PANEL_CHEVRON_CLASS = "size-4 shrink-0 text-muted-fo |
| 40 | 37 | ||
| 41 | export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rounded-md border-transparent bg-transparent p-0 sm:size-6 ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; | 38 | export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rounded-md border-transparent bg-transparent p-0 sm:size-6 ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; |
| 42 | 39 | ||
| 40 | /** A small text control on a row's second line, such as a pull request's checks or next action. */ | ||
| 41 | export const THREAD_DETAILS_PANEL_META_ACTION_CLASS = `h-6 gap-1 rounded-md border-transparent px-1.5 text-xs font-medium text-muted-foreground ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; | ||
| 42 | |||
| 43 | export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; | 43 | export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; |
| 44 | 44 | ||
| 45 | export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; | 45 | export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; |
| ... | @@ -47,5 +47,3 @@ export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_ | ... | @@ -47,5 +47,3 @@ export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_ |
| 47 | export const THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-8 justify-center rounded-s-none px-0 ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; | 47 | export const THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-8 justify-center rounded-s-none px-0 ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; |
| 48 | 48 | ||
| 49 | export const THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS = "h-4 w-px shrink-0 bg-border/65"; | 49 | export const THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS = "h-4 w-px shrink-0 bg-border/65"; |
| 50 | |||
| 51 | export const THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} justify-center gap-1.5 rounded-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; |
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx+5-2| ... | @@ -6,7 +6,6 @@ import type { | ... | @@ -6,7 +6,6 @@ import type { |
| 6 | PullRequestRef, | 6 | PullRequestRef, |
| 7 | ScopedThreadRef, | 7 | ScopedThreadRef, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { ChevronDownIcon } from "lucide-react"; | ||
| 10 | import { useState } from "react"; | 9 | import { useState } from "react"; |
| 11 | 10 | ||
| 12 | import { useOpenLink } from "~/browser/useOpenLink"; | 11 | import { useOpenLink } from "~/browser/useOpenLink"; |
| ... | @@ -156,6 +155,9 @@ export function PullRequestChecksPopover({ | ... | @@ -156,6 +155,9 @@ export function PullRequestChecksPopover({ |
| 156 | const presentation = pullRequestChecksStatePresentation(checksState); | 155 | const presentation = pullRequestChecksStatePresentation(checksState); |
| 157 | // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads. | 156 | // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads. |
| 158 | const runningCount = checks?.filter((check) => check.status === "pending").length ?? 0; | 157 | const runningCount = checks?.filter((check) => check.status === "pending").length ?? 0; |
| 158 | const failedCount = | ||
| 159 | checks?.filter((check) => check.status === "failure" || check.status === "cancelled").length ?? | ||
| 160 | 0; | ||
| 159 | const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks); | 161 | const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks); |
| 160 | return ( | 162 | return ( |
| 161 | <Popover> | 163 | <Popover> |
| ... | @@ -189,8 +191,9 @@ export function PullRequestChecksPopover({ | ... | @@ -189,8 +191,9 @@ export function PullRequestChecksPopover({ |
| 189 | <span className="tabular-nums"> | 191 | <span className="tabular-nums"> |
| 190 | {runningCount}/{checks.length} | 192 | {runningCount}/{checks.length} |
| 191 | </span> | 193 | </span> |
| 194 | ) : failedCount > 0 ? ( | ||
| 195 | <span className="tabular-nums">{failedCount} failed</span> | ||
| 192 | ) : null} | 196 | ) : null} |
| 193 | <ChevronDownIcon aria-hidden className="size-3" /> | ||
| 194 | </> | 197 | </> |
| 195 | ) : null} | 198 | ) : null} |
| 196 | </PopoverTrigger> | 199 | </PopoverTrigger> |