Skip to content

Commit b2fbde5

Browse files
committed
feat(webapp): show beginning and end of task names in filter dropdown
When task names are too long to fit in the dropdown, truncate in the middle (showing beginning...end) instead of just cutting off the end. This helps users identify tasks with colon-namespaced names where the unique part may be at the end. Show full name in tooltip on hover.
1 parent 825219a commit b2fbde5

File tree

2 files changed

+150
-1
lines changed

2 files changed

+150
-1
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { useRef, useState, useLayoutEffect, useCallback } from "react";
2+
import { cn } from "~/utils/cn";
3+
import { SimpleTooltip } from "./Tooltip";
4+
5+
interface MiddleTruncateProps {
6+
text: string;
7+
className?: string;
8+
}
9+
10+
/**
11+
* A component that truncates text in the middle, showing the beginning and end.
12+
* Shows the full text in a tooltip on hover when truncated.
13+
*
14+
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
15+
*/
16+
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
17+
const containerRef = useRef<HTMLSpanElement>(null);
18+
const measureRef = useRef<HTMLSpanElement>(null);
19+
const [displayText, setDisplayText] = useState(text);
20+
const [isTruncated, setIsTruncated] = useState(false);
21+
22+
const calculateTruncation = useCallback(() => {
23+
const container = containerRef.current;
24+
const measure = measureRef.current;
25+
if (!container || !measure) return;
26+
27+
const parent = container.parentElement;
28+
if (!parent) return;
29+
30+
// Get the available width from the parent container
31+
const parentStyle = getComputedStyle(parent);
32+
const availableWidth =
33+
parent.clientWidth -
34+
parseFloat(parentStyle.paddingLeft) -
35+
parseFloat(parentStyle.paddingRight);
36+
37+
// Measure full text width
38+
measure.textContent = text;
39+
const fullTextWidth = measure.offsetWidth;
40+
41+
// If text fits, no truncation needed
42+
if (fullTextWidth <= availableWidth) {
43+
setDisplayText(text);
44+
setIsTruncated(false);
45+
return;
46+
}
47+
48+
// Text needs truncation - find optimal split
49+
const ellipsis = "…";
50+
measure.textContent = ellipsis;
51+
const ellipsisWidth = measure.offsetWidth;
52+
53+
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
54+
55+
if (targetWidth <= 0) {
56+
setDisplayText(ellipsis);
57+
setIsTruncated(true);
58+
return;
59+
}
60+
61+
// Binary search for optimal character counts
62+
let startChars = 0;
63+
let endChars = 0;
64+
65+
// Alternate adding characters from start and end
66+
while (startChars + endChars < text.length) {
67+
// Try adding to start
68+
const testStart = text.slice(0, startChars + 1);
69+
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
70+
measure.textContent = testStart + ellipsis + testEnd;
71+
72+
if (measure.offsetWidth > targetWidth) break;
73+
startChars++;
74+
75+
if (startChars + endChars >= text.length) break;
76+
77+
// Try adding to end
78+
const newTestEnd = text.slice(-(endChars + 1));
79+
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
80+
81+
if (measure.offsetWidth > targetWidth) break;
82+
endChars++;
83+
}
84+
85+
// Ensure minimum characters on each side for readability
86+
const minChars = 4;
87+
if (startChars < minChars && text.length > minChars * 2 + 1) {
88+
startChars = minChars;
89+
}
90+
if (endChars < minChars && text.length > minChars * 2 + 1) {
91+
endChars = minChars;
92+
}
93+
94+
// If combined chars would exceed text length, show full text
95+
if (startChars + endChars >= text.length) {
96+
setDisplayText(text);
97+
setIsTruncated(false);
98+
return;
99+
}
100+
101+
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
102+
setDisplayText(result);
103+
setIsTruncated(true);
104+
}, [text]);
105+
106+
useLayoutEffect(() => {
107+
calculateTruncation();
108+
109+
// Recalculate on resize
110+
const resizeObserver = new ResizeObserver(() => {
111+
calculateTruncation();
112+
});
113+
114+
const container = containerRef.current;
115+
if (container?.parentElement) {
116+
resizeObserver.observe(container.parentElement);
117+
}
118+
119+
return () => {
120+
resizeObserver.disconnect();
121+
};
122+
}, [calculateTruncation]);
123+
124+
const content = (
125+
<span ref={containerRef} className={cn("block", className)}>
126+
{/* Hidden span for measuring text width */}
127+
<span
128+
ref={measureRef}
129+
className="invisible absolute whitespace-nowrap"
130+
aria-hidden="true"
131+
/>
132+
{displayText}
133+
</span>
134+
);
135+
136+
if (isTruncated) {
137+
return (
138+
<SimpleTooltip
139+
button={content}
140+
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
141+
side="top"
142+
asChild
143+
/>
144+
);
145+
}
146+
147+
return content;
148+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { DateTime } from "~/components/primitives/DateTime";
3131
import { FormError } from "~/components/primitives/FormError";
3232
import { Input } from "~/components/primitives/Input";
3333
import { Label } from "~/components/primitives/Label";
34+
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
3435
import { Paragraph } from "~/components/primitives/Paragraph";
3536
import {
3637
ComboBox,
@@ -654,7 +655,7 @@ function TasksDropdown({
654655
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
655656
}
656657
>
657-
{item.slug}
658+
<MiddleTruncate text={item.slug} />
658659
</SelectItem>
659660
))}
660661
</SelectList>

0 commit comments

Comments
 (0)