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 {...@@ -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}
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
19/**29/**
20 * Elapsed time for the current activation. Live agents self-tick via DOM30 * 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 */
23export function AgentElapsed({34export 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]);
5266
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";
20import { ChevronDownIcon } from "lucide-react";19import { 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};
3230
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 one4 * 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. Around7 * 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 unblocks8 * 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, and9 * 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 reports10 * 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 else12 * 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 hover13 * 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 );
233234
234 // Everything the host reported, at a glance. The row stays one line; the tooltip is where the235 // 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-led236 // 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;
346347
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 <TooltipTrigger358 variant="ghost"
365 render={359 size="sm"
366 <ThreadDetailsControl360 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;
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
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 <TooltipTrigger414 // and the block keeps space below, so stacked pull requests read as separate pairs.
394 render={415 <div className="pb-2">
395 <ThreadDetailsControl416 {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 <PullRequestChecksPopover420 <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 <TooltipTrigger431 <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 <TooltipTrigger455 <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";
27import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";27import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
28import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
28import { groupBy } from "effect/Array";29import { groupBy } from "effect/Array";
29import * as DateTime from "effect/DateTime";30import * as DateTime from "effect/DateTime";
30import { useNavigate } from "@tanstack/react-router";31import { 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
2929
30export 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}`;30export 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
35export 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]`;32export 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
37export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground";34export 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
4037
41export 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}`;38export 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
43export 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}`;43export 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
45export 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}`;45export 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_
47export 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}`;47export 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
49export const THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS = "h-4 w-px shrink-0 bg-border/65";49export 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 {...@@ -6,7 +6,6 @@ import type {
6 PullRequestRef,6 PullRequestRef,
7 ScopedThreadRef,7 ScopedThreadRef,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { ChevronDownIcon } from "lucide-react";
10import { useState } from "react";9import { useState } from "react";
1110
12import { useOpenLink } from "~/browser/useOpenLink";11import { 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>