Skip to content

Commit c0b86ef

Browse files
0skiclaude
andauthored
feat(webapp): Add MiddleTruncate component for long task names (#2946)
Closes # ## ✅ Checklist - [ ] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [ ] The PR title follows the convention. - [ ] I ran and tested the code works --- ## Testing Tested the MiddleTruncate component in the TasksDropdown by: 1. Verifying that long task names (e.g., "namespace:category:subcategory:task-name") are truncated in the middle 2. Confirming the full text appears in a tooltip on hover 3. Testing responsive behavior - truncation adjusts when the container is resized 4. Verifying that short task names that fit within the container are displayed in full without truncation --- ## Changelog Added a new `MiddleTruncate` primitive component that intelligently truncates text in the middle while preserving the beginning and end portions. This is particularly useful for long hierarchical identifiers like task slugs. **Key features:** - Truncates text in the middle with an ellipsis (…) when it exceeds available width - Shows full text in a tooltip on hover when truncated - Responsive - recalculates truncation on container resize using ResizeObserver - Maintains minimum character visibility (4 chars minimum on each side for readability) - Integrated into TasksDropdown to handle long task names **Changes:** - Created new `MiddleTruncate.tsx` component with binary search algorithm for optimal character distribution - Updated TasksDropdown to use MiddleTruncate for task slug display - Increased TasksDropdown popover width from 240px to 360px to provide better space for truncated text --- ## Screenshots 💯 https://github.com/user-attachments/assets/a7a2191a-2e36-437e-ab3f-517fe7620b93 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2946"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e29e1c8 commit c0b86ef

File tree

2 files changed

+171
-2
lines changed

2 files changed

+171
-2
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useRef, useState, useLayoutEffect, useCallback } from "react";
2+
import { cn } from "~/utils/cn";
3+
import { SimpleTooltip } from "./Tooltip";
4+
5+
type 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+
// Incrementally find the 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+
const prevStartChars = startChars;
88+
const prevEndChars = endChars;
89+
90+
if (startChars < minChars && text.length > minChars * 2 + 1) {
91+
startChars = minChars;
92+
}
93+
if (endChars < minChars && text.length > minChars * 2 + 1) {
94+
endChars = minChars;
95+
}
96+
97+
// Re-measure after enforcing minChars to prevent overflow
98+
if (startChars !== prevStartChars || endChars !== prevEndChars) {
99+
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
100+
if (measure.offsetWidth > targetWidth) {
101+
// Revert to previous values if minChars enforcement causes overflow
102+
startChars = prevStartChars;
103+
endChars = prevEndChars;
104+
}
105+
}
106+
107+
// If combined chars would exceed text length, show full text
108+
if (startChars + endChars >= text.length) {
109+
setDisplayText(text);
110+
setIsTruncated(false);
111+
return;
112+
}
113+
114+
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
115+
setDisplayText(result);
116+
setIsTruncated(true);
117+
}, [text]);
118+
119+
useLayoutEffect(() => {
120+
calculateTruncation();
121+
122+
// Recalculate on resize (guard for jsdom/older browsers)
123+
if (typeof ResizeObserver === "undefined") {
124+
return;
125+
}
126+
127+
const resizeObserver = new ResizeObserver(() => {
128+
calculateTruncation();
129+
});
130+
131+
const container = containerRef.current;
132+
if (container?.parentElement) {
133+
resizeObserver.observe(container.parentElement);
134+
}
135+
136+
return () => {
137+
resizeObserver.disconnect();
138+
};
139+
}, [calculateTruncation]);
140+
141+
const content = (
142+
<span
143+
ref={containerRef}
144+
className={cn("block", isTruncated && "min-w-[360px]", className)}
145+
>
146+
{/* Hidden span for measuring text width */}
147+
<span
148+
ref={measureRef}
149+
className="invisible absolute whitespace-nowrap"
150+
aria-hidden="true"
151+
/>
152+
{displayText}
153+
</span>
154+
);
155+
156+
if (isTruncated) {
157+
return (
158+
<SimpleTooltip
159+
button={content}
160+
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
161+
side="top"
162+
asChild
163+
/>
164+
);
165+
}
166+
167+
return content;
168+
}

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

Lines changed: 3 additions & 2 deletions
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,
@@ -634,7 +635,7 @@ function TasksDropdown({
634635
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
635636
{trigger}
636637
<SelectPopover
637-
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
638+
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
638639
hideOnEscape={() => {
639640
if (onClose) {
640641
onClose();
@@ -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)