feat(workbench): extend TruncatedCell to trace edges and selection rails (#750)

Follow-up to #749, which deliberately scoped TruncatedCell to the
non-virtualized columnar tables and left trace edges + single-column
selection rails as-is. Wire it into those too so every truncated data
cell in the workbench has the same full-content reveal (popover +
copy + modal-for-long).

Component:
- Add `align` prop ("start" | "end"). `end` hugs the right edge and
  right-justifies the display, for right-aligned columns like the trace
  `to_stage` cell. Defaults to "start" (unchanged).

Wiring:
- Trace propagation edges: from_stage / to_stage (to_stage align=end).
- Trace pipeline stage rail: stage label + summary.
- Trace turns rail: prompt + surface excerpts.
- Runs rail: session_id. Replay rail: prompt excerpt. Packs rail:
  pack_id. CORE-Logos pack rail: pack_id. Logos alignment rail: edge
  (rich source→target display, plain-text value for copy/reveal).
- Contemplation run rail: run_id; stage-evidence ids.
- Demos rail: title; scenario trace hash.
- Proposal detail: replay-equivalence hash, evidence hashes;
  proposal chain provenance.

Two row elements that were real <button>s (trace stage rail,
contemplation run row) are converted to <div role="button"> with an
Enter/Space onKeyDown handler — the codebase's established selectable-row
pattern (ProposalTable, RowShell) — so nesting TruncatedCell's button
trigger is valid HTML rather than a button-in-button.

Intentionally left as-is (documented):
- RunsRoute TurnRefRow is an <a>/<Link> row; nesting an interactive
  trigger inside an anchor is invalid HTML. The surface excerpt is
  reachable by following the link.
- Digests everywhere keep DigestBadge (already copy + full-value title).
- Section headings (e.g. the trace stage-detail <h3>) are not data cells.

Evidence: workbench-ui `pnpm build` (tsc -b) green; full vitest suite
535 passed / 60 files, incl. a new align assertion on TruncatedCell.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Shay 2026-06-14 17:11:47 -07:00 committed by GitHub
parent 99a52746ae
commit de2d5da4f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 178 additions and 50 deletions

View file

@ -8,6 +8,7 @@ import { Panel } from "../../design/components/Panel/Panel";
import { SearchInput } from "../../design/components/SearchInput/SearchInput"; import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { SplitPane } from "../../design/components/SplitPane/SplitPane"; import { SplitPane } from "../../design/components/SplitPane/SplitPane";
import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { EmptyState } from "../../design/components/states/EmptyState"; import { EmptyState } from "../../design/components/states/EmptyState";
import { ErrorState } from "../../design/components/states/ErrorState"; import { ErrorState } from "../../design/components/states/ErrorState";
import { LoadingState } from "../../design/components/states/LoadingState"; import { LoadingState } from "../../design/components/states/LoadingState";
@ -57,10 +58,17 @@ function RunRow({
onSelect: () => void; onSelect: () => void;
}) { }) {
return ( return (
<button <div
type="button" role="button"
tabIndex={0}
aria-current={selected ? "true" : undefined} aria-current={selected ? "true" : undefined}
onClick={onSelect} onClick={onSelect}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
}
}}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${ className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${
selected selected
? "border-l-2 border-l-[var(--color-selected-border)] bg-[var(--color-selected-bg)] pl-[10px]" ? "border-l-2 border-l-[var(--color-selected-border)] bg-[var(--color-selected-bg)] pl-[10px]"
@ -68,8 +76,13 @@ function RunRow({
}`} }`}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-mono text-xs text-[var(--color-text-muted)]"> <span className="block min-w-0">
{run.run_id} <TruncatedCell
value={run.run_id}
label="run id"
mono
className="text-xs text-[var(--color-text-muted)]"
/>
</span> </span>
<span className="mt-1 block text-sm text-[var(--color-text-primary)]"> <span className="mt-1 block text-sm text-[var(--color-text-primary)]">
{run.prompt ?? "Prompt not recorded"} {run.prompt ?? "Prompt not recorded"}
@ -82,7 +95,7 @@ function RunRow({
<span className="justify-self-end text-xs text-[var(--color-text-secondary)]"> <span className="justify-self-end text-xs text-[var(--color-text-secondary)]">
{boolLabel(run.learning_arc_closed)} {boolLabel(run.learning_arc_closed)}
</span> </span>
</button> </div>
); );
} }
@ -108,8 +121,13 @@ function StageEvidence({ scene }: { scene: ContemplationScene }) {
{rows.map((row) => ( {rows.map((row) => (
<div key={row.label} className="contents"> <div key={row.label} className="contents">
<dt className="text-[var(--color-text-secondary)]">{row.label}</dt> <dt className="text-[var(--color-text-secondary)]">{row.label}</dt>
<dd className="m-0 truncate font-mono text-[var(--color-text-primary)]" title={row.value}> <dd className="m-0 min-w-0">
{row.value} <TruncatedCell
value={row.value}
label={row.label}
mono
className="text-[var(--color-text-primary)]"
/>
</dd> </dd>
</div> </div>
))} ))}

View file

@ -8,6 +8,7 @@ import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
import { Button } from "../../design/components/primitives/Button"; import { Button } from "../../design/components/primitives/Button";
import { Panel } from "../../design/components/Panel/Panel"; import { Panel } from "../../design/components/Panel/Panel";
import { SearchInput } from "../../design/components/SearchInput/SearchInput"; import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import { EmptyState } from "../../design/components/states/EmptyState"; import { EmptyState } from "../../design/components/states/EmptyState";
import { ErrorState } from "../../design/components/states/ErrorState"; import { ErrorState } from "../../design/components/states/ErrorState";
@ -63,9 +64,11 @@ function DemoRow({
: "border-l-2 border-l-transparent pl-[10px]" : "border-l-2 border-l-transparent pl-[10px]"
}`} }`}
> >
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]"> <TruncatedCell
{demo.title} value={demo.title}
</span> label="demo title"
className="text-sm font-semibold text-[var(--color-text-primary)]"
/>
<span className="font-mono text-xs text-[var(--color-text-muted)]"> <span className="font-mono text-xs text-[var(--color-text-muted)]">
{demo.scenario_count} scenarios {demo.scenario_count} scenarios
</span> </span>
@ -225,9 +228,12 @@ function ResultRow({ scenario }: { scenario: DemoScenarioRunResult }) {
</p> </p>
) : null} ) : null}
{scenario.trace_hash ? ( {scenario.trace_hash ? (
<code className="truncate font-mono text-xs text-[var(--color-text-muted)]"> <TruncatedCell
{scenario.trace_hash} value={scenario.trace_hash}
</code> label="trace hash"
mono
className="text-xs text-[var(--color-text-muted)]"
/>
) : null} ) : null}
{scenario.problems.length > 0 ? ( {scenario.problems.length > 0 ? (
<ul className="m-0 grid list-none gap-1 p-0"> <ul className="m-0 grid list-none gap-1 p-0">

View file

@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { DagViewer } from "../../design/components/Dag/Dag"; import { DagViewer } from "../../design/components/Dag/Dag";
import type { DagEdgeInput, DagNodeInput } from "../../design/components/Dag/layout"; import type { DagEdgeInput, DagNodeInput } from "../../design/components/Dag/layout";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import type { LogosAlignmentRow } from "../../types/api"; import type { LogosAlignmentRow } from "../../types/api";
// Read-only CORE-Logos alignment tab (LG-4). The trilingual resonance graph // Read-only CORE-Logos alignment tab (LG-4). The trilingual resonance graph
@ -70,9 +71,16 @@ function EdgeCard({
}`} }`}
> >
<div className="flex items-center justify-between gap-2 font-mono text-xs text-[var(--color-text-primary)]"> <div className="flex items-center justify-between gap-2 font-mono text-xs text-[var(--color-text-primary)]">
<span className="truncate"> <TruncatedCell
{row.source_id} <span aria-hidden className="text-[var(--color-text-muted)]"></span> {row.target_id} value={`${row.source_id}${row.target_id}`}
</span> display={
<>
{row.source_id}{" "}
<span aria-hidden className="text-[var(--color-text-muted)]"></span> {row.target_id}
</>
}
label="alignment edge"
/>
<span className="text-[var(--color-text-secondary)]">{row.weight.toFixed(2)}</span> <span className="text-[var(--color-text-secondary)]">{row.weight.toFixed(2)}</span>
</div> </div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[var(--color-text-secondary)]"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[var(--color-text-secondary)]">

View file

@ -14,6 +14,7 @@ import { Panel } from "../../design/components/Panel/Panel";
import { SplitPane } from "../../design/components/SplitPane/SplitPane"; import { SplitPane } from "../../design/components/SplitPane/SplitPane";
import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; import { TabBar, type Tab } from "../../design/components/TabBar/TabBar";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { EmptyState } from "../../design/components/states/EmptyState"; import { EmptyState } from "../../design/components/states/EmptyState";
import { ErrorState } from "../../design/components/states/ErrorState"; import { ErrorState } from "../../design/components/states/ErrorState";
import { LoadingState } from "../../design/components/states/LoadingState"; import { LoadingState } from "../../design/components/states/LoadingState";
@ -165,8 +166,13 @@ function PackRow({
}`} }`}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-mono text-sm text-[var(--color-text-primary)]"> <span className="block min-w-0">
{pack.pack_id} <TruncatedCell
value={pack.pack_id}
label="pack id"
mono
className="text-sm text-[var(--color-text-primary)]"
/>
</span> </span>
<span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-secondary)]"> <span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span>{roleLabel(pack.role)}</span> <span>{roleLabel(pack.role)}</span>

View file

@ -9,6 +9,7 @@ import { Panel } from "../../design/components/Panel/Panel";
import { SearchInput } from "../../design/components/SearchInput/SearchInput"; import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { SplitPane } from "../../design/components/SplitPane/SplitPane"; import { SplitPane } from "../../design/components/SplitPane/SplitPane";
import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; import { TabBar, type Tab } from "../../design/components/TabBar/TabBar";
import { TreeView } from "../../design/components/TreeView/TreeView"; import { TreeView } from "../../design/components/TreeView/TreeView";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
@ -81,8 +82,13 @@ function PackRow({
}`} }`}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm text-[var(--color-text-primary)]"> <span className="block min-w-0">
{pack.pack_id} <TruncatedCell
value={pack.pack_id}
label="pack id"
mono
className="text-sm text-[var(--color-text-primary)]"
/>
</span> </span>
<span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-secondary)]"> <span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span>{sourceLabel(pack.source)}</span> <span>{sourceLabel(pack.source)}</span>

View file

@ -1,4 +1,5 @@
import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { DagViewer, type DagEdgeInput, type DagNodeInput } from "../../design/components/Dag"; import { DagViewer, type DagEdgeInput, type DagNodeInput } from "../../design/components/Dag";
import type { ProposalDetail } from "../../types/api"; import type { ProposalDetail } from "../../types/api";
import { chainRecords, isRecord, jsonSource, stringField } from "./proposalView"; import { chainRecords, isRecord, jsonSource, stringField } from "./proposalView";
@ -68,9 +69,12 @@ export function ProposalChainViewer({ proposal }: { proposal: ProposalDetail })
{index + 1}. {nodeLabel(record, index)} {index + 1}. {nodeLabel(record, index)}
</span> </span>
{provenance(record) ? ( {provenance(record) ? (
<span className="truncate text-xs text-[var(--color-text-muted)]"> <TruncatedCell
{provenance(record)} value={provenance(record) as string}
</span> label="provenance"
align="end"
className="text-xs text-[var(--color-text-muted)]"
/>
) : null} ) : null}
</div> </div>
<StableJsonViewer source={jsonSource(record)} /> <StableJsonViewer source={jsonSource(record)} />

View file

@ -8,6 +8,7 @@ import type { ProposalSummary, MathProposalDetail, ProposalState, DownstreamEffe
import { Button } from "../../design/components/primitives/Button"; import { Button } from "../../design/components/primitives/Button";
import { EmptyState } from "../../design/components/states/EmptyState"; import { EmptyState } from "../../design/components/states/EmptyState";
import { SearchInput } from "../../design/components/SearchInput/SearchInput"; import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { useListNavigation } from "../../design/hooks/useListNavigation"; import { useListNavigation } from "../../design/hooks/useListNavigation";
import { ErrorState } from "../../design/components/states/ErrorState"; import { ErrorState } from "../../design/components/states/ErrorState";
import { LoadingState } from "../../design/components/states/LoadingState"; import { LoadingState } from "../../design/components/states/LoadingState";
@ -396,8 +397,13 @@ function MathProposalDetailView({
</div> </div>
<div> <div>
<dt className="text-[var(--color-text-muted)]">Replay Equivalence Hash</dt> <dt className="text-[var(--color-text-muted)]">Replay Equivalence Hash</dt>
<dd className="m-0 text-[var(--color-text-primary)] font-mono truncate mt-0.5" title={proposal.replay_equivalence_hash}> <dd className="m-0 mt-0.5 min-w-0">
{proposal.replay_equivalence_hash} <TruncatedCell
value={proposal.replay_equivalence_hash}
label="replay equivalence hash"
mono
className="text-[var(--color-text-primary)]"
/>
</dd> </dd>
</div> </div>
</dl> </dl>
@ -454,9 +460,14 @@ function MathProposalDetailView({
{hashes.map((hash: string, i: number) => ( {hashes.map((hash: string, i: number) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-muted)] font-mono">#{i + 1}</span> <span className="text-xs text-[var(--color-text-muted)] font-mono">#{i + 1}</span>
<code className="bg-[var(--color-surface-inset)] px-2 py-1 rounded font-mono text-xs text-[var(--color-text-primary)] truncate flex-1"> <span className="min-w-0 flex-1 rounded bg-[var(--color-surface-inset)] px-2 py-1">
{hash} <TruncatedCell
</code> value={hash}
label={`evidence hash #${i + 1}`}
mono
className="text-xs text-[var(--color-text-primary)]"
/>
</span>
</div> </div>
))} ))}
</div> </div>

View file

@ -7,6 +7,7 @@ import { Panel } from "../../design/components/Panel/Panel";
import { SearchInput } from "../../design/components/SearchInput/SearchInput"; import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { SplitPane } from "../../design/components/SplitPane/SplitPane"; import { SplitPane } from "../../design/components/SplitPane/SplitPane";
import { Timestamp } from "../../design/components/Timestamp/Timestamp"; import { Timestamp } from "../../design/components/Timestamp/Timestamp";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import { EmptyState } from "../../design/components/states/EmptyState"; import { EmptyState } from "../../design/components/states/EmptyState";
import { ErrorState } from "../../design/components/states/ErrorState"; import { ErrorState } from "../../design/components/states/ErrorState";
@ -75,8 +76,13 @@ function TurnRow({
<span className="block text-xs text-[var(--color-text-secondary)]"> <span className="block text-xs text-[var(--color-text-secondary)]">
<Timestamp iso={turn.timestamp} format="relative" /> <Timestamp iso={turn.timestamp} format="relative" />
</span> </span>
<span className="mt-1 block truncate text-sm text-[var(--color-text-primary)]"> <span className="mt-1 block min-w-0">
{turn.prompt_excerpt || `Turn #${turn.turn_id}`} <TruncatedCell
value={turn.prompt_excerpt || `Turn #${turn.turn_id}`}
label="prompt"
wrap="pre-wrap"
className="text-sm text-[var(--color-text-primary)]"
/>
</span> </span>
</span> </span>
<span className="justify-self-end font-mono text-xs text-[var(--color-text-muted)]"> <span className="justify-self-end font-mono text-xs text-[var(--color-text-muted)]">

View file

@ -11,6 +11,7 @@ import { SplitPane } from "../../design/components/SplitPane/SplitPane";
import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; import { TabBar, type Tab } from "../../design/components/TabBar/TabBar";
import { Timestamp } from "../../design/components/Timestamp/Timestamp"; import { Timestamp } from "../../design/components/Timestamp/Timestamp";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import { Button } from "../../design/components/primitives/Button"; import { Button } from "../../design/components/primitives/Button";
import { EmptyState } from "../../design/components/states/EmptyState"; import { EmptyState } from "../../design/components/states/EmptyState";
@ -155,8 +156,13 @@ function RunRow({
<span className="block text-sm text-[var(--color-text-primary)]"> <span className="block text-sm text-[var(--color-text-primary)]">
{sourceLabel(run.source)} {sourceLabel(run.source)}
</span> </span>
<span className="mt-1 block truncate font-mono text-xs text-[var(--color-text-muted)]"> <span className="mt-1 block min-w-0">
{run.session_id} <TruncatedCell
value={run.session_id}
label="session id"
mono
className="text-xs text-[var(--color-text-muted)]"
/>
</span> </span>
<span className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-secondary)]"> <span className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span>{run.turn_count} turns</span> <span>{run.turn_count} turns</span>

View file

@ -19,6 +19,7 @@ import { SplitPane } from "../../design/components/SplitPane/SplitPane";
import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; import { TabBar, type Tab } from "../../design/components/TabBar/TabBar";
import { Timestamp } from "../../design/components/Timestamp/Timestamp"; import { Timestamp } from "../../design/components/Timestamp/Timestamp";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import { import {
EpistemicStateBadge, EpistemicStateBadge,
@ -212,7 +213,9 @@ function PipelineStageRail({
{stages.map((stage, index) => { {stages.map((stage, index) => {
const selected = stage.stage_id === selectedStageId; const selected = stage.stage_id === selectedStageId;
return ( return (
<button <div
role="button"
tabIndex={0}
aria-pressed={selected} aria-pressed={selected}
className={`grid min-h-16 grid-cols-[2rem_minmax(0,1fr)] gap-2 rounded border px-2 py-2 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${ className={`grid min-h-16 grid-cols-[2rem_minmax(0,1fr)] gap-2 rounded border px-2 py-2 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${
selected selected
@ -221,23 +224,36 @@ function PipelineStageRail({
}`} }`}
key={stage.stage_id} key={stage.stage_id}
onClick={() => onSelect(stage.stage_id)} onClick={() => onSelect(stage.stage_id)}
type="button" onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(stage.stage_id);
}
}}
> >
<span className="font-mono text-xs text-[var(--color-text-muted)]"> <span className="font-mono text-xs text-[var(--color-text-muted)]">
{String(index + 1).padStart(2, "0")} {String(index + 1).padStart(2, "0")}
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="flex items-center justify-between gap-2"> <span className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs font-semibold text-[var(--color-text-primary)]"> <TruncatedCell
{stage.label} value={stage.label}
</span> label="stage"
mono
className="text-xs font-semibold text-[var(--color-text-primary)]"
/>
<PipelineStageStatus status={stage.status} /> <PipelineStageStatus status={stage.status} />
</span> </span>
<span className="mt-1 block truncate text-xs text-[var(--color-text-secondary)]"> <span className="mt-1 block min-w-0">
{stage.summary} <TruncatedCell
value={stage.summary}
label="summary"
wrap="pre-wrap"
className="text-xs text-[var(--color-text-secondary)]"
/>
</span> </span>
</span> </span>
</button> </div>
); );
})} })}
</section> </section>
@ -259,11 +275,20 @@ function PipelineTransitionList({ record }: { record: CognitivePipelineRecord })
className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] px-2 py-1 font-mono text-[11px]" className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] px-2 py-1 font-mono text-[11px]"
key={`${edge.from_stage}:${edge.to_stage}`} key={`${edge.from_stage}:${edge.to_stage}`}
> >
<span className="truncate text-[var(--color-text-secondary)]">{edge.from_stage}</span> <TruncatedCell
value={edge.from_stage}
label="from stage"
mono
className="text-[var(--color-text-secondary)]"
/>
<span className="text-[var(--color-text-muted)]">{edge.label ?? "propagate"}</span> <span className="text-[var(--color-text-muted)]">{edge.label ?? "propagate"}</span>
<span className="truncate text-right text-[var(--color-text-primary)]"> <TruncatedCell
{edge.to_stage} value={edge.to_stage}
</span> label="to stage"
mono
align="end"
className="text-[var(--color-text-primary)]"
/>
</li> </li>
))} ))}
</ol> </ol>
@ -563,11 +588,21 @@ function TraceRow({
<span className="block text-xs text-[var(--color-text-secondary)]"> <span className="block text-xs text-[var(--color-text-secondary)]">
<Timestamp iso={turn.timestamp} format="relative" /> <Timestamp iso={turn.timestamp} format="relative" />
</span> </span>
<span className="mt-1 block truncate text-sm text-[var(--color-text-primary)]"> <span className="mt-1 block min-w-0">
{firstLine(turn.prompt_excerpt) || `Turn #${turn.turn_id}`} <TruncatedCell
value={firstLine(turn.prompt_excerpt) || `Turn #${turn.turn_id}`}
label="prompt"
wrap="pre-wrap"
className="text-sm text-[var(--color-text-primary)]"
/>
</span> </span>
<span className="mt-1 block truncate text-xs text-[var(--color-text-muted)]"> <span className="mt-1 block min-w-0">
{turn.surface_excerpt} <TruncatedCell
value={turn.surface_excerpt}
label="surface"
wrap="pre-wrap"
className="text-xs text-[var(--color-text-muted)]"
/>
</span> </span>
</span> </span>
<span className="justify-self-end"> <span className="justify-self-end">

View file

@ -45,6 +45,12 @@ describe("TruncatedCell", () => {
expect(screen.getByRole("dialog", { name: "source" })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: "source" })).toBeInTheDocument();
}); });
it("right-justifies content when align is end", () => {
render(<TruncatedCell value="to_stage" label="to stage" align="end" />);
const display = screen.getByText("to_stage");
expect(display).toHaveClass("text-right");
});
it("does not bubble clicks to a surrounding row handler", async () => { it("does not bubble clicks to a surrounding row handler", async () => {
const onRowClick = vi.fn(); const onRowClick = vi.fn();
render( render(

View file

@ -44,6 +44,12 @@ export interface TruncatedCellProps {
wrap?: "break-all" | "pre-wrap"; wrap?: "break-all" | "pre-wrap";
/** Class applied to the inline display span. */ /** Class applied to the inline display span. */
className?: string; className?: string;
/**
* Horizontal alignment of the cell content. `end` hugs the right edge (for
* right-aligned table columns) and right-justifies the display text.
* Defaults to `start`.
*/
align?: "start" | "end";
} }
function shouldOfferModal(value: string) { function shouldOfferModal(value: string) {
@ -94,6 +100,7 @@ export function TruncatedCell({
mono, mono,
wrap = "break-all", wrap = "break-all",
className, className,
align = "start",
}: TruncatedCellProps) { }: TruncatedCellProps) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const offerModal = shouldOfferModal(value); const offerModal = shouldOfferModal(value);
@ -101,8 +108,17 @@ export function TruncatedCell({
const stop = (event: { stopPropagation: () => void }) => event.stopPropagation(); const stop = (event: { stopPropagation: () => void }) => event.stopPropagation();
return ( return (
<span className={cn("group/cell flex min-w-0 items-center gap-1", mono && "font-mono")}> <span
<span className={cn("truncate", className)} title={value}> className={cn(
"group/cell flex min-w-0 items-center gap-1",
align === "end" && "justify-end",
mono && "font-mono",
)}
>
<span
className={cn("truncate", align === "end" && "text-right", className)}
title={value}
>
{display ?? value} {display ?? value}
</span> </span>
<Popover.Root> <Popover.Root>