Skip to content

Commit 7d21b92

Browse files
samejrclaude
andcommitted
fix(webapp): stop smart column text re-truncating and the dialog growing
The truncated cell text visibly ate itself a character at a time on load. Two separate causes: Runaway loop. MiddleTruncate measures its parent, and the cell sat in a max-width box inside an auto-width table column: eliding text narrowed the box, which narrowed the column, which re-triggered truncation. Sampling showed it never settled -- 226 chars down to 62 and still shrinking after six seconds. The box is now a definite width, which its own content cannot influence, and the decision to use it comes from the raw string length rather than from layout. Late first pass. Truncation needs layout, so the server rendered the whole string and it only shortened once React hydrated, ~3.6s in. MiddleTruncate now accepts an optional initialCharBudget and seeds its first render with a deterministic character-count truncation, so SSR and the pre-hydration client agree and the measured pass only refines it (99 chars to 102 here, versus 226 to 102 before). Callers that don't pass it are unaffected. Also: - Size the dialog from the form column alone. The sample and preview panels fill their column but no longer contribute height to it, so wrapped preview text scrolls instead of pushing the dialog taller (verified stable at 732px with four long rows). - Give all three columns one title row so their labels line up (they sat 3px apart because only two of them had a min-height). - Swap the smart column row's remove and edit buttons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 999aadf commit 7d21b92

4 files changed

Lines changed: 88 additions & 45 deletions

File tree

apps/webapp/app/components/primitives/MiddleTruncate.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,22 @@ type MiddleTruncateProps = {
99
tooltipDelay?: number;
1010
/** Merged onto the tooltip body, for callers whose text needs a bigger or scrollable box. */
1111
tooltipContentClassName?: string;
12+
/**
13+
* Roughly how many characters fit, used only for the very first render. Truncation needs
14+
* layout, so the server (and the pre-hydration client) can only render the full string --
15+
* long values visibly snapped shorter once React hydrated. Seeding from a character count
16+
* is deterministic, so it matches on both sides and the measured pass just refines it.
17+
*/
18+
initialCharBudget?: number;
1219
};
1320

21+
/** Deterministic, layout-free middle truncation used to seed the first render. */
22+
function seedTruncation(text: string, budget: number | undefined): string {
23+
if (budget === undefined || text.length <= budget) return text;
24+
const keep = Math.max(1, Math.floor((budget - 1) / 2));
25+
return `${text.slice(0, keep)}${text.slice(-keep)}`;
26+
}
27+
1428
/**
1529
* A component that truncates text in the middle, showing the beginning and end.
1630
* Shows the full text in a tooltip on hover when truncated.
@@ -22,11 +36,13 @@ export function MiddleTruncate({
2236
className,
2337
tooltipDelay,
2438
tooltipContentClassName,
39+
initialCharBudget,
2540
}: MiddleTruncateProps) {
41+
const seed = seedTruncation(text, initialCharBudget);
2642
const containerRef = useRef<HTMLSpanElement>(null);
2743
const measureRef = useRef<HTMLSpanElement>(null);
28-
const [displayText, setDisplayText] = useState(text);
29-
const [isTruncated, setIsTruncated] = useState(false);
44+
const [displayText, setDisplayText] = useState(seed);
45+
const [isTruncated, setIsTruncated] = useState(seed !== text);
3046

3147
const calculateTruncation = useCallback(() => {
3248
const container = containerRef.current;

apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({
5858

5959
const DEFAULT_SOURCE: SmartColumnSource = "payload";
6060

61+
/** One title row for all three columns, so their labels and content line up. */
62+
const TITLE_ROW_CLASS = "flex min-h-6 items-center";
63+
64+
/**
65+
* The sample and preview panels fill their column but contribute no height to it, so the
66+
* dialog is sized by the form alone. Without this, wrapped preview text pushed the whole
67+
* dialog taller as you typed.
68+
*/
69+
const PANEL_FRAME_CLASS = "relative min-h-0 flex-1";
70+
6171
export function AddSmartColumnDialog({
6272
open,
6373
editing,
@@ -180,7 +190,9 @@ export function AddSmartColumnDialog({
180190
<div className="grid min-h-0 grid-cols-1 items-stretch gap-2.5 md:grid-cols-3">
181191
<div className="flex flex-col gap-4 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
182192
<InputGroup fullWidth>
183-
<Label>Source</Label>
193+
<div className={TITLE_ROW_CLASS}>
194+
<Label>Source</Label>
195+
</div>
184196
<RadioGroup
185197
className="flex flex-col gap-2"
186198
value={source}
@@ -247,7 +259,7 @@ export function AddSmartColumnDialog({
247259
</div>
248260

249261
<div className="flex min-h-0 flex-col gap-1.5">
250-
<div className="flex min-h-6 items-center justify-between gap-2">
262+
<div className={cn(TITLE_ROW_CLASS, "justify-between gap-2")}>
251263
<Label>Sample {source}</Label>
252264
{usable.length > 1 && (
253265
<SampleRunPicker
@@ -258,38 +270,42 @@ export function AddSmartColumnDialog({
258270
/>
259271
)}
260272
</div>
261-
<div className="flex-1 overflow-auto rounded-lg border border-grid-dimmed bg-charcoal-900 p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
262-
{!sampleLoaded ? (
263-
<Paragraph variant="extra-small" className="text-text-dimmed">
264-
Loading…
265-
</Paragraph>
266-
) : activeSample ? (
267-
<SmartColumnSample
268-
value={activeSample.value}
269-
activePath={path.trim()}
270-
onSelectPath={setPath}
271-
/>
272-
) : runCount === 0 ? (
273-
<Paragraph variant="extra-small" className="text-text-dimmed">
274-
No runs to sample yet.
275-
</Paragraph>
276-
) : anyOffloaded ? (
277-
<Paragraph variant="extra-small" className="text-text-dimmed">
278-
Recent {source}s are too large to sample here.
279-
</Paragraph>
280-
) : (
281-
<Paragraph variant="extra-small" className="text-text-dimmed">
282-
No recent run has a {source} to sample.
283-
</Paragraph>
284-
)}
273+
<div className={PANEL_FRAME_CLASS}>
274+
<div className="absolute inset-0 overflow-auto rounded-lg border border-grid-dimmed bg-charcoal-900 p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
275+
{!sampleLoaded ? (
276+
<Paragraph variant="extra-small" className="text-text-dimmed">
277+
Loading…
278+
</Paragraph>
279+
) : activeSample ? (
280+
<SmartColumnSample
281+
value={activeSample.value}
282+
activePath={path.trim()}
283+
onSelectPath={setPath}
284+
/>
285+
) : runCount === 0 ? (
286+
<Paragraph variant="extra-small" className="text-text-dimmed">
287+
No runs to sample yet.
288+
</Paragraph>
289+
) : anyOffloaded ? (
290+
<Paragraph variant="extra-small" className="text-text-dimmed">
291+
Recent {source}s are too large to sample here.
292+
</Paragraph>
293+
) : (
294+
<Paragraph variant="extra-small" className="text-text-dimmed">
295+
No recent run has a {source} to sample.
296+
</Paragraph>
297+
)}
298+
</div>
285299
</div>
286300
</div>
287301

288302
<div className="flex min-h-0 flex-col gap-1.5">
289-
<div className="flex min-h-6 items-center">
303+
<div className={TITLE_ROW_CLASS}>
290304
<Label>Preview</Label>
291305
</div>
292-
<SmartColumnPreview rows={perRun} def={previewDef} loaded={sampleLoaded} />
306+
<div className={PANEL_FRAME_CLASS}>
307+
<SmartColumnPreview rows={perRun} def={previewDef} loaded={sampleLoaded} />
308+
</div>
293309
</div>
294310
</div>
295311
</div>
@@ -354,7 +370,7 @@ function SmartColumnPreview({
354370
const alignClass = numeric ? "justify-end text-right tabular-nums" : "justify-start text-left";
355371

356372
return (
357-
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-grid-dimmed">
373+
<div className="absolute inset-0 flex flex-col overflow-hidden rounded-lg border border-grid-dimmed">
358374
<div className="flex flex-none items-center gap-1 border-b border-grid-dimmed bg-background-dimmed px-2.5 py-1.5">
359375
<span className="truncate text-xs font-medium text-text-bright">
360376
{def.label || "Column"}

apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -366,15 +366,6 @@ function ColumnRow({
366366
{isSmart && <SmartColumnIcon className="size-3.5 flex-none text-text-dimmed" />}
367367
</label>
368368
<div className="flex flex-none items-center gap-0.5 pr-1">
369-
{onEdit && (
370-
<Button
371-
variant="minimal/small"
372-
onClick={onEdit}
373-
aria-label={`Edit ${col.def.label}`}
374-
LeadingIcon={<PencilSquareIcon className="size-4" />}
375-
className={ROW_ACTION_CLASS}
376-
/>
377-
)}
378369
{onRemove && (
379370
<Button
380371
variant="minimal/small"
@@ -384,6 +375,15 @@ function ColumnRow({
384375
className={cn(ROW_ACTION_CLASS, "group-hover/button:text-error")}
385376
/>
386377
)}
378+
{onEdit && (
379+
<Button
380+
variant="minimal/small"
381+
onClick={onEdit}
382+
aria-label={`Edit ${col.def.label}`}
383+
LeadingIcon={<PencilSquareIcon className="size-4" />}
384+
className={ROW_ACTION_CLASS}
385+
/>
386+
)}
387387
{/* Button forwards no onKeyDown, so the arrow-key reorder listens on the wrapper
388388
and catches the event bubbling up from the focused button. */}
389389
<span

apps/webapp/app/components/runs/v3/smartColumnCell.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,19 @@ function toFiniteNumber(value: unknown): number {
3333
return NaN;
3434
}
3535

36-
/** How wide a truncated text cell may get before the middle is elided. */
37-
const TEXT_CELL_MAX_WIDTH = "max-w-[600px]";
36+
/**
37+
* Long text renders in a fixed-width box, not a max-width one. MiddleTruncate measures its
38+
* parent, and a runs table column is auto-width: a max-width box narrows as the text is
39+
* elided, which shrinks the column, which re-triggers truncation, and so on -- the text
40+
* visibly ate itself a character at a time and never settled. A definite width can't be
41+
* influenced by its own content, so the measurement converges on the first pass.
42+
*/
43+
const TEXT_CELL_WIDTH = "w-[600px]";
44+
/**
45+
* Whether a value is long enough to need the fixed box, decided from the raw string so the
46+
* choice never depends on layout (which is what made the loop possible). ~600px of 13px text.
47+
*/
48+
const TEXT_CELL_CHAR_BUDGET = 100;
3849
/** Long values are common enough that an instant tooltip would fire while just scanning rows. */
3950
const TEXT_CELL_TOOLTIP_DELAY_MS = 500;
4051
/** A whole payload string can be arbitrarily long, so the tooltip is capped and scrolls. */
@@ -61,14 +72,14 @@ function renderSmartValue(
6172
return <Badge variant="extra-small">{stringifySmartValue(value)}</Badge>;
6273
default: {
6374
const text = stringifySmartValue(value);
64-
if (!truncate) return text;
65-
// MiddleTruncate measures against its parent, so it needs the width cap around it.
75+
if (!truncate || text.length <= TEXT_CELL_CHAR_BUDGET) return text;
6676
return (
67-
<span className={cn("block min-w-0", TEXT_CELL_MAX_WIDTH)}>
77+
<span className={cn("block", TEXT_CELL_WIDTH)}>
6878
<MiddleTruncate
6979
text={text}
7080
tooltipDelay={TEXT_CELL_TOOLTIP_DELAY_MS}
7181
tooltipContentClassName={TEXT_CELL_TOOLTIP_CLASS}
82+
initialCharBudget={TEXT_CELL_CHAR_BUDGET}
7283
/>
7384
</span>
7485
);

0 commit comments

Comments
 (0)