authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-06 23:49:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 23:49:18-07:00
logf60367b8cb5e4a7e5e12dd01dc3889618151b1be
treef686dcf3736f12e3af1f280c76b9f5f1b50ef170
parent0f4f02abb6ab5428c06a7e4b6c76b3ec84c62498
signature Signed by PGP key B5690EEEBB952194

fix(web): thread details card gives titles room to read (#16746)

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 @@
1import { expect, it } from "vite-plus/test";
2
3import { formatCompactElapsedSeconds } from "./AgentElapsed";
4
5it.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 {
1616 return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
1717}
1818
19/** Short form for tight rows: "45s", "12m", "1.5h", "14h". Rounds down. */
20export 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
1929/**
2030 * Elapsed time for the current activation. Live agents self-tick via DOM
2131 * writes (zero React commits per tick); settled agents freeze at completedAt.
32 * `compact` drops the smaller unit for narrow rows such as Lineage.
2233 */
2334export function AgentElapsed({
2435 agent,
36 compact = false,
2537}: {
2638 agent: Pick<RuntimeSubagent, "status" | "startedAt" | "completedAt">;
39 compact?: boolean;
2740}) {
41 const format = compact ? formatCompactElapsedSeconds : formatElapsedSeconds;
2842 const textRef = useRef<HTMLSpanElement>(null);
2943 const live = isOrchestrationV2WorkActive(agent.status);
3044 const startedAt = agent.startedAt;
......@@ -40,15 +54,15 @@ export function AgentElapsed({
4054 { status: agent.status, startedAt, completedAt },
4155 Date.now(),
4256 );
43 textRef.current.textContent =
44 elapsedMs === null ? "" : formatElapsedSeconds(elapsedMs / 1000);
57 const text = elapsedMs === null ? "" : format(elapsedMs / 1000);
58 if (textRef.current.textContent !== text) textRef.current.textContent = text;
4559 }
4660 };
4761 update();
4862 if (!live) return;
4963 const id = setInterval(update, 1000);
5064 return () => clearInterval(id);
51 }, [live, startedAt, completedAt, agent.status]);
65 }, [live, startedAt, completedAt, agent.status, format]);
5266
5367 const elapsedMs = deriveSubagentElapsedMs(agent, 0);
5468 if (elapsedMs === null) {
......@@ -56,7 +70,7 @@ export function AgentElapsed({
5670 }
5771 return (
5872 <span ref={textRef} className="tabular-nums">
59 {formatElapsedSeconds(elapsedMs / 1000)}
73 {format(elapsedMs / 1000)}
6074 </span>
6175 );
6276}
apps/web/src/components/chat/ThreadDetailsControl.tsx+4-5
......@@ -11,10 +11,9 @@ import {
1111 THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
1212 THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
1313 THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
14 THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS,
1514 THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS,
16 THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS,
1715 THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,
16 THREAD_DETAILS_PANEL_META_ACTION_CLASS,
1817 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
1918} from "./threadDetailsPanelStyles";
2019import { ChevronDownIcon } from "lucide-react";
......@@ -24,10 +23,9 @@ const parts = {
2423 select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
2524 primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
2625 secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
27 action: THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS,
2826 "link-primary": THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS,
29 checks: THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS,
3027 icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,
28 meta: THREAD_DETAILS_PANEL_META_ACTION_CLASS,
3129};
3230
3331/** Panel controls own their fixed density; toolbar controls use the standard Button variants. */
......@@ -45,7 +43,7 @@ export function ThreadDetailsControl({
4543 panel?: boolean;
4644 part?: keyof typeof parts;
4745 multiline?: boolean;
48 tone?: "default" | "muted" | "destructive";
46 tone?: "default" | "muted" | "primary" | "destructive";
4947}) {
5048 const control = useRender({
5149 defaultTagName: "button",
......@@ -58,6 +56,7 @@ export function ThreadDetailsControl({
5856 parts[part],
5957 multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto",
6058 tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80",
59 tone === "primary" && "text-primary hover:text-primary",
6160 tone === "destructive" &&
6261 "text-destructive hover:text-destructive data-pressed:text-destructive",
6362 className,
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+87-112
......@@ -4,10 +4,11 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
44 * The thread details panel's pull request row: what the thread's pull request is, and the one
55 * thing worth doing to it right now.
66 *
7 * The row itself opens the pull request in the right panel, exactly as it always has. Around
8 * that, the host's richer answer collapses into a single trailing slot, ranked by what unblocks
9 * the merge next: "Resolve" on conflicts, "Ready" on a draft, "Fix" under failing checks, and
10 * "Merge" only once the branch is clean and its checks pass. While checks run the slot reports
7 * The row itself opens the pull request in the right panel, exactly as it always has. Once the
8 * host answers, the title gets the full first line and a second line carries the number, checks,
9 * watch, and a single action slot, ranked by what unblocks the merge next: "Resolve" on
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
1112 * that instead — a merge offered mid-run would race the very runs that gate it. Everything else
1213 * the host knows (title, state, checks tally, diff size) lives in the row's tooltip, so a hover
1314 * answers what previously took opening the panel.
......@@ -231,7 +232,7 @@ export function ThreadDetailsPrRow({
231232 <PullRequestGlyph.pullRequest className={THREAD_DETAILS_PANEL_ICON_CLASS} />
232233 );
233234
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
235236 // rest of the answer lives — styled like the sidebar's thread tooltip, title above icon-led
236237 // detail rows, so the two read as one family.
237238 const rowTooltip =
......@@ -344,83 +345,88 @@ export function ThreadDetailsPrRow({
344345 }
345346 : null;
346347
347 const rowContent = (
348 <>
349 {icon}
350 <span className="min-w-0 flex-1 truncate text-left">{label}</span>
351 </>
352 );
353
354348 // 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.
356 const watchIsLast =
357 detail === null ||
358 ((checksRollup === null || conflicting || detail.isDraft) && trailingAction === null);
359 const watchSegment =
360 onStopWatching && (detail?.state ?? pr?.state ?? "open") === "open" ? (
361 <>
362 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
363 <Tooltip>
364 <TooltipTrigger
365 render={
366 <ThreadDetailsControl
367 type="button"
368 variant="ghost"
369 size="sm"
370 part={watchIsLast ? "secondary" : "checks"}
371 className="group/watch"
372 aria-label={`Stop watching #${number}`}
373 onClick={onStopWatching}
374 />
375 }
376 >
377 <EyeIcon aria-hidden className="size-4 group-hover/watch:hidden" />
378 <EyeOffIcon aria-hidden className="hidden size-4 group-hover/watch:block" />
379 </TooltipTrigger>
380 <TooltipPopup side="top">
381 Watching: the agent wakes when checks finish, someone comments, or the branch conflicts.
382 Click to stop.
383 </TooltipPopup>
384 </Tooltip>
385 </>
349 const watching =
350 onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open";
351 const watchControl = (part: "secondary" | "icon") =>
352 watching ? (
353 <Tooltip>
354 <TooltipTrigger
355 render={
356 <ThreadDetailsControl
357 type="button"
358 variant="ghost"
359 size="sm"
360 part={part}
361 className="group/watch"
362 aria-label={`Stop watching #${number}`}
363 onClick={onStopWatching}
364 />
365 }
366 >
367 <EyeIcon
368 aria-hidden
369 className={cn(part === "icon" ? "size-3.5" : "size-4", "group-hover/watch:hidden")}
370 />
371 <EyeOffIcon
372 aria-hidden
373 className={cn(
374 part === "icon" ? "size-3.5" : "size-4",
375 "hidden group-hover/watch:block",
376 )}
377 />
378 </TooltipTrigger>
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>
386384 ) : null;
387385
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
388409 return (
389410 <>
390 {detail ? (
391 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
392 <Tooltip>
393 <TooltipTrigger
394 render={
395 <ThreadDetailsControl
396 type="button"
397 variant="ghost"
398 size="sm"
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} />
411 {detail !== null && (showChecks || trailingAction !== null || watching) ? (
412 // Two lines so the title keeps the full row width: the number, checks, watch, and the
413 // next action sit underneath, aligned with the title text. The second line hugs its title
414 // and the block keeps space below, so stacked pull requests read as separate pairs.
415 <div className="pb-2">
416 {openRow(detail.title, "row")}
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">
418 <span className="me-1 shrink-0 tabular-nums">#{number}</span>
419 {showChecks && checksRollup !== null ? (
413420 <PullRequestChecksPopover
414421 checksState={checksRollup}
415422 checks={detail.checks}
416423 variant="count"
417 render={<ThreadDetailsControl part="checks" />}
424 render={<ThreadDetailsControl part="meta" />}
418425 />
419 </>
420 ) : null}
421 {trailingAction ? (
422 <>
423 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
426 ) : null}
427 {watchControl("icon")}
428 <span className="flex-1" />
429 {trailingAction ? (
424430 <Tooltip>
425431 <TooltipTrigger
426432 render={
......@@ -428,8 +434,8 @@ export function ThreadDetailsPrRow({
428434 type="button"
429435 variant="ghost"
430436 size="sm"
431 part="action"
432 tone={trailingAction.destructive ? "destructive" : "default"}
437 part="meta"
438 tone={trailingAction.destructive ? "destructive" : "primary"}
433439 disabled={actionPending || handoff !== null}
434440 onClick={trailingAction.onClick}
435441 />
......@@ -440,48 +446,17 @@ export function ThreadDetailsPrRow({
440446 </TooltipTrigger>
441447 <TooltipPopup side="top">{trailingAction.tooltip}</TooltipPopup>
442448 </Tooltip>
443 </>
444 ) : null}
449 ) : null}
450 </div>
445451 </div>
446 ) : watchSegment ? (
452 ) : watching ? (
447453 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
448 <Tooltip>
449 <TooltipTrigger
450 render={
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}
454 {openRow(label, "link-primary")}
455 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
456 {watchControl("secondary")}
466457 </div>
467458 ) : (
468 <Tooltip>
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>
459 openRow(label, "row")
485460 )}
486461 {rowAction === "merge" ? (
487462 <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
238238 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
239239 );
240240 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");
242243 expect(text()).not.toContain("(1)");
243 expect(text()).toContain("Done");
244 expect(text()).not.toContain("Done");
244245 expect(text()).not.toContain("running");
245246 expect(text()).not.toContain("Worker");
246247 await act(async () =>
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+15-11
......@@ -25,6 +25,7 @@ import {
2525 resolveLatestMergeBackRun,
2626} from "@t3tools/client-runtime/state/thread-workflows";
2727import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
28import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
2829import { groupBy } from "effect/Array";
2930import * as DateTime from "effect/DateTime";
3031import { useNavigate } from "@tanstack/react-router";
......@@ -382,6 +383,7 @@ export function ThreadRelationshipsPanel(props: {
382383 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined,
383384 node?.thread,
384385 );
386 const failed = status === "failed" || status === "error";
385387 const canStop =
386388 agent?.origin === "app_owned" &&
387389 agent.startedAt &&
......@@ -435,19 +437,21 @@ export function ThreadRelationshipsPanel(props: {
435437 {threadTitle}
436438 </span>
437439 </span>
438 {agent ? (
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 ) : (
440 {agent ? null : (
447441 <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
448442 )}
449 {!isMergeTarget ? (
450 <span className="shrink-0 text-2xs text-muted-foreground">
443 {/* One trailing item keeps room for the title: the icon dot already
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 >
451455 {threadRelationshipStatusLabel(status)}
452456 </span>
453457 ) : 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
2929
3030export 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}`;
3131
32/** The trailing half of a link split row when it carries a word ("Merge") rather than an icon. */
33export 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
3532export 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]`;
3633
3734export 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
4037
4138export 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}`;
4239
40/** A small text control on a row's second line, such as a pull request's checks or next action. */
41export 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
4343export 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}`;
4444
4545export 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_
4747export 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}`;
4848
4949export const THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS = "h-4 w-px shrink-0 bg-border/65";
50
51export 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 {
66 PullRequestRef,
77 ScopedThreadRef,
88} from "@t3tools/contracts";
9import { ChevronDownIcon } from "lucide-react";
109import { useState } from "react";
1110
1211import { useOpenLink } from "~/browser/useOpenLink";
......@@ -156,6 +155,9 @@ export function PullRequestChecksPopover({
156155 const presentation = pullRequestChecksStatePresentation(checksState);
157156 // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads.
158157 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;
159161 const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks);
160162 return (
161163 <Popover>
......@@ -189,8 +191,9 @@ export function PullRequestChecksPopover({
189191 <span className="tabular-nums">
190192 {runningCount}/{checks.length}
191193 </span>
194 ) : failedCount > 0 ? (
195 <span className="tabular-nums">{failedCount} failed</span>
192196 ) : null}
193 <ChevronDownIcon aria-hidden className="size-3" />
194197 </>
195198 ) : null}
196199 </PopoverTrigger>