From f60367b8cb5e4a7e5e12dd01dc3889618151b1be Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 6 Oct 2026 23:49:18 -0700 Subject: [PATCH] fix(web): thread details card gives titles room to read (#16746) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/components/chat/AgentElapsed.test.ts | 15 ++ apps/web/src/components/chat/AgentElapsed.tsx | 22 +- .../components/chat/ThreadDetailsControl.tsx | 9 +- .../components/chat/ThreadDetailsPrRow.tsx | 199 ++++++++---------- ...ThreadRelationshipsControl.agents.test.tsx | 5 +- .../chat/ThreadRelationshipsControl.tsx | 26 ++- .../chat/threadDetailsPanelStyles.ts | 8 +- .../pullRequest/PullRequestChecksPopover.tsx | 7 +- 8 files changed, 150 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/components/chat/AgentElapsed.test.ts diff --git a/apps/web/src/components/chat/AgentElapsed.test.ts b/apps/web/src/components/chat/AgentElapsed.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c79b64f16c66f6494eeb24f3692460896eec5e4f --- /dev/null +++ b/apps/web/src/components/chat/AgentElapsed.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from "vite-plus/test"; + +import { formatCompactElapsedSeconds } from "./AgentElapsed"; + +it.each([ + [45, "45s"], + [90, "1m"], + [59 * 60 + 59, "59m"], + [60 * 60, "1h"], + [90 * 60, "1.5h"], + [119 * 60, "1.9h"], + [14 * 60 * 60 + 40 * 60, "14h"], +])("formats %ss as %s", (seconds, label) => { + expect(formatCompactElapsedSeconds(seconds)).toBe(label); +}); diff --git a/apps/web/src/components/chat/AgentElapsed.tsx b/apps/web/src/components/chat/AgentElapsed.tsx index 801a23f2f4485d50739a65d19ebfe21bbf45c9bb..e313fba63e5c36891a74d81b261e0466ca226d89 100644 --- a/apps/web/src/components/chat/AgentElapsed.tsx +++ b/apps/web/src/components/chat/AgentElapsed.tsx @@ -16,15 +16,29 @@ function formatElapsedSeconds(totalSeconds: number): string { return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; } +/** Short form for tight rows: "45s", "12m", "1.5h", "14h". Rounds down. */ +export function formatCompactElapsedSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + if (minutes >= 600) return `${Math.floor(minutes / 60)}h`; + return `${Math.floor(minutes / 6) / 10}h`; +} + /** * Elapsed time for the current activation. Live agents self-tick via DOM * writes (zero React commits per tick); settled agents freeze at completedAt. + * `compact` drops the smaller unit for narrow rows such as Lineage. */ export function AgentElapsed({ agent, + compact = false, }: { agent: Pick; + compact?: boolean; }) { + const format = compact ? formatCompactElapsedSeconds : formatElapsedSeconds; const textRef = useRef(null); const live = isOrchestrationV2WorkActive(agent.status); const startedAt = agent.startedAt; @@ -40,15 +54,15 @@ export function AgentElapsed({ { status: agent.status, startedAt, completedAt }, Date.now(), ); - textRef.current.textContent = - elapsedMs === null ? "" : formatElapsedSeconds(elapsedMs / 1000); + const text = elapsedMs === null ? "" : format(elapsedMs / 1000); + if (textRef.current.textContent !== text) textRef.current.textContent = text; } }; update(); if (!live) return; const id = setInterval(update, 1000); return () => clearInterval(id); - }, [live, startedAt, completedAt, agent.status]); + }, [live, startedAt, completedAt, agent.status, format]); const elapsedMs = deriveSubagentElapsedMs(agent, 0); if (elapsedMs === null) { @@ -56,7 +70,7 @@ export function AgentElapsed({ } return ( - {formatElapsedSeconds(elapsedMs / 1000)} + {format(elapsedMs / 1000)} ); } diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx index 3169f80184886aa99df6da21dca45b8c02f7c226..f14a52c14496cafb2a3fdf8435fe03e8aad3a34e 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx @@ -11,10 +11,9 @@ import { THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, - THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, - THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS, THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, + THREAD_DETAILS_PANEL_META_ACTION_CLASS, THREAD_DETAILS_PANEL_CHEVRON_CLASS, } from "./threadDetailsPanelStyles"; import { ChevronDownIcon } from "lucide-react"; @@ -24,10 +23,9 @@ const parts = { select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, - action: THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS, "link-primary": THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, - checks: THREAD_DETAILS_PANEL_SPLIT_CHECKS_CLASS, icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS, + meta: THREAD_DETAILS_PANEL_META_ACTION_CLASS, }; /** Panel controls own their fixed density; toolbar controls use the standard Button variants. */ @@ -45,7 +43,7 @@ export function ThreadDetailsControl({ panel?: boolean; part?: keyof typeof parts; multiline?: boolean; - tone?: "default" | "muted" | "destructive"; + tone?: "default" | "muted" | "primary" | "destructive"; }) { const control = useRender({ defaultTagName: "button", @@ -58,6 +56,7 @@ export function ThreadDetailsControl({ parts[part], multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto", tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80", + tone === "primary" && "text-primary hover:text-primary", tone === "destructive" && "text-destructive hover:text-destructive data-pressed:text-destructive", className, diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 9fc86281b15f2594831d49812d6d330a24814d24..f00d911e043feff2275569d18cb6990213336edb 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -4,10 +4,11 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; * The thread details panel's pull request row: what the thread's pull request is, and the one * thing worth doing to it right now. * - * The row itself opens the pull request in the right panel, exactly as it always has. Around - * that, the host's richer answer collapses into a single trailing slot, ranked by what unblocks - * the merge next: "Resolve" on conflicts, "Ready" on a draft, "Fix" under failing checks, and - * "Merge" only once the branch is clean and its checks pass. While checks run the slot reports + * The row itself opens the pull request in the right panel, exactly as it always has. Once the + * host answers, the title gets the full first line and a second line carries the number, checks, + * watch, and a single action slot, ranked by what unblocks the merge next: "Resolve" on + * conflicts, "Ready" on a draft, "Fix" under failing checks, and "Merge" only once the branch is + * clean and its checks pass. While checks run the slot reports * that instead — a merge offered mid-run would race the very runs that gate it. Everything else * the host knows (title, state, checks tally, diff size) lives in the row's tooltip, so a hover * answers what previously took opening the panel. @@ -231,7 +232,7 @@ export function ThreadDetailsPrRow({ ); - // Everything the host reported, at a glance. The row stays one line; the tooltip is where the + // Everything the host reported, at a glance. The rows stay short; the tooltip is where the // rest of the answer lives — styled like the sidebar's thread tooltip, title above icon-led // detail rows, so the two read as one family. const rowTooltip = @@ -344,83 +345,88 @@ export function ThreadDetailsPrRow({ } : null; - const rowContent = ( - <> - {icon} - {label} - - ); - // The server ends a watch when the pull request closes, so only an open one shows the eye. - // It takes the row's rounded end when nothing follows it. - const watchIsLast = - detail === null || - ((checksRollup === null || conflicting || detail.isDraft) && trailingAction === null); - const watchSegment = - onStopWatching && (detail?.state ?? pr?.state ?? "open") === "open" ? ( - <> -