From e572a1a52bb94f361fad3970f47cfa45e52a2be5 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:07:10 -0400 Subject: [PATCH] fix(web): show full Claude prompt suggestions on overflow - Reuse horizontal-overflow detection for composer and activity popover tooltips - Keep the concise tooltip label when suggestions fit --- apps/web/src/components/ChatView.browser.tsx | 128 ++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 33 ++++- .../components/chat/ThreadActivityPopover.tsx | 68 +--------- apps/web/src/hooks/useHorizontalOverflow.ts | 74 ++++++++++ 4 files changed, 234 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/hooks/useHorizontalOverflow.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 83da9a01..9efba9ab 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1325,6 +1325,70 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { }; } +const CLAUDE_TEST_PROVIDER: ServerConfig["providers"][number] = { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.117", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [], + slashCommands: [], + skills: [], +}; + +/** A Claude thread whose last turn completed and left a prompt suggestion behind. */ +function createSnapshotWithPromptSuggestion(suggestion: string): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-prompt-suggestion-target" as MessageId, + targetText: "prompt suggestion thread", + }); + const modelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-7", + }; + const turnId = "turn-prompt-suggestion" as TurnId; + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + modelSelection, + latestTurn: { + turnId, + state: "completed", + requestedAt: isoAt(1_000), + startedAt: isoAt(1_001), + completedAt: isoAt(1_010), + assistantMessageId: null, + }, + activities: [ + { + id: EventId.make("activity-prompt-suggestion"), + tone: "info" as const, + kind: "prompt-suggestion.updated", + summary: "Prompt suggestion updated", + payload: { suggestion }, + turnId, + createdAt: isoAt(1_011), + }, + ], + session: { + ...thread.session, + providerName: "claudeAgent", + status: "ready", + updatedAt: isoAt(1_010), + }, + updatedAt: isoAt(1_011), + }) + : thread, + ), + }; +} + function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -9954,6 +10018,70 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + // Mount a Claude thread that ended with a prompt suggestion, wait for the + // chip to show it, then hover to open its tooltip. + const mountPromptSuggestionChip = async (suggestion: string) => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPromptSuggestion(suggestion), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [...nextFixture.serverConfig.providers, CLAUDE_TEST_PROVIDER], + }; + }, + }); + const chip = await waitForElement( + () => document.querySelector('[data-prompt-suggestion="true"]'), + "Unable to find the prompt suggestion chip.", + ); + const text = await waitForElement( + () => { + const found = chip.querySelector('[data-prompt-suggestion-text="true"]'); + return found?.textContent === suggestion ? found : null; + }, + () => + `Prompt suggestion chip never showed "${suggestion}"; it shows "${ + chip.querySelector('[data-prompt-suggestion-text="true"]')?.textContent ?? "" + }".`, + ); + await page.getByRole("button", { name: /^Use Claude suggested prompt:/ }).hover(); + const tooltip = await waitForElement( + () => document.querySelector('[data-prompt-suggestion-tooltip="true"]'), + "Hovering the prompt suggestion chip never opened its tooltip.", + ); + return { mounted, chip, text, tooltip }; + }; + + it("shows the full prompt suggestion in the tooltip when the chip clips it", async () => { + const suggestion = + "Run the full browser suite against the composer changes, then update the changelog entry for the suggestion chip"; + const { mounted, chip, text, tooltip } = await mountPromptSuggestionChip(suggestion); + try { + expect(text.scrollWidth).toBeGreaterThan(text.clientWidth + 1); + expect(tooltip.textContent).toContain(suggestion); + expect(tooltip.textContent).toContain("Claude suggested this prompt"); + // The full text wraps inside a capped-width tooltip instead of running + // off as one long line. + const tooltipRect = tooltip.getBoundingClientRect(); + expect(tooltipRect.width).toBeLessThanOrEqual(400); + expect(tooltipRect.height).toBeGreaterThan(chip.getBoundingClientRect().height); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the short tooltip label when the prompt suggestion fits the chip", async () => { + const suggestion = "Run the tests"; + const { mounted, text, tooltip } = await mountPromptSuggestionChip(suggestion); + try { + expect(text.scrollWidth).toBeLessThanOrEqual(text.clientWidth + 1); + expect(tooltip.textContent).toBe("Claude suggested this prompt"); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the slash-command menu visible above the composer", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 24a68db0..36adda4d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -183,6 +183,7 @@ import { } from "@threadlines/shared/fileAttachments"; import { searchProviderSkills } from "../../providerSkillSearch"; import { resolveComposerSkillReferences } from "../../providerSkillReferences"; +import { useHorizontalOverflow } from "../../hooks/useHorizontalOverflow"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { ComposerVoiceControls, type ComposerVoiceControlsProps } from "./ComposerVoiceControls"; @@ -1403,6 +1404,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const latestPromptSuggestionDisplayText = latestPromptSuggestion ? formatPromptSuggestionDisplayText(latestPromptSuggestion) : null; + const promptSuggestionOverflow = useHorizontalOverflow( + latestPromptSuggestionDisplayText ?? "", + latestPromptSuggestionDisplayText !== null, + ); const composerFooterHasWideActions = showPlanFollowUpPrompt; const composerFooterActionLayoutKey = useMemo(() => { @@ -3043,11 +3048,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onClick={() => applyPromptSuggestion(latestPromptSuggestion)} > - {latestPromptSuggestionDisplayText} + + {latestPromptSuggestionDisplayText} + } /> - Claude suggested this prompt + {/* The chip clips long suggestions, so the tooltip carries the full text + whenever it is clipped; a suggestion that fits keeps the short label. */} + + {promptSuggestionOverflow.overflows ? ( + + {latestPromptSuggestionDisplayText} + + Claude suggested this prompt + + + ) : ( + "Claude suggested this prompt" + )} + ) : null} diff --git a/apps/web/src/components/chat/ThreadActivityPopover.tsx b/apps/web/src/components/chat/ThreadActivityPopover.tsx index bc66715f..0237c1e9 100644 --- a/apps/web/src/components/chat/ThreadActivityPopover.tsx +++ b/apps/web/src/components/chat/ThreadActivityPopover.tsx @@ -1,13 +1,11 @@ import { memo, - useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, - type RefCallback, type RefObject, } from "react"; import { @@ -27,6 +25,7 @@ import { proposedPlanTitle } from "../../proposedPlan"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { type ActivePlanState, type LatestProposedPlanState } from "../../session-logic"; import { cn } from "~/lib/utils"; +import { useHorizontalOverflow } from "../../hooks/useHorizontalOverflow"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { SpineNode, SpineRow, spineAccentRowStyle, type SpineNodeKind } from "../ui/threadline"; @@ -80,7 +79,6 @@ const ACTIVITY_POPOVER_PREFERRED_MIN_WIDTH_PX = 320; const ACTIVITY_POPOVER_MAX_WIDTH_PX = 480; const ACTIVITY_POPOVER_VIEWPORT_WIDTH_RATIO = 0.36; const ACTIVITY_POPOVER_BOUNDARY_GUTTER_PX = 12; -const OVERFLOW_MEASUREMENT_EPSILON_PX = 1; type ActivityPopoverWidthStyle = CSSProperties & { "--thread-activity-popover-width": string; @@ -125,70 +123,6 @@ function resolveActivityPopoverWidth(input: { return Math.round(Math.min(preferredWidth, usableWidth)); } -function hasHorizontalOverflow(element: HTMLElement): boolean { - return element.scrollWidth - element.clientWidth > OVERFLOW_MEASUREMENT_EPSILON_PX; -} - -function useHorizontalOverflow( - contentKey: string, - enabled: boolean, -): { - elementRef: RefCallback; - overflows: boolean; -} { - const [element, setElement] = useState(null); - const [overflows, setOverflows] = useState(false); - const elementRef = useCallback>((node) => { - setElement(node); - }, []); - - useLayoutEffect(() => { - if (!enabled || typeof window === "undefined") { - return; - } - - if (!element) { - setOverflows(false); - return; - } - - let frameId: number | null = null; - - const measure = () => { - frameId = null; - const nextOverflows = hasHorizontalOverflow(element); - setOverflows((current) => (current === nextOverflows ? current : nextOverflows)); - }; - - const scheduleMeasure = () => { - if (frameId !== null) { - window.cancelAnimationFrame(frameId); - } - frameId = window.requestAnimationFrame(measure); - }; - - measure(); - - const resizeObserver = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); - resizeObserver?.observe(element); - if (element.parentElement) { - resizeObserver?.observe(element.parentElement); - } - window.addEventListener("resize", scheduleMeasure); - - return () => { - if (frameId !== null) { - window.cancelAnimationFrame(frameId); - } - resizeObserver?.disconnect(); - window.removeEventListener("resize", scheduleMeasure); - }; - }, [contentKey, element, enabled]); - - return { elementRef, overflows }; -} - function useActivityPopoverAnchorLayout(open: boolean): { triggerRef: RefObject; layoutKey: string; diff --git a/apps/web/src/hooks/useHorizontalOverflow.ts b/apps/web/src/hooks/useHorizontalOverflow.ts new file mode 100644 index 00000000..b99c6f3d --- /dev/null +++ b/apps/web/src/hooks/useHorizontalOverflow.ts @@ -0,0 +1,74 @@ +import { useCallback, useLayoutEffect, useState, type RefCallback } from "react"; + +const OVERFLOW_MEASUREMENT_EPSILON_PX = 1; + +function hasHorizontalOverflow(element: HTMLElement): boolean { + return element.scrollWidth - element.clientWidth > OVERFLOW_MEASUREMENT_EPSILON_PX; +} + +/** + * Report whether a single-line element's content is wider than the element, + * i.e. whether `truncate` is currently clipping it. Attach `elementRef` to the + * clipped element. Re-measures when `contentKey` changes, when the element or + * its parent resizes, and on window resize. Pass `enabled: false` to skip + * measuring while the element is not clipped (e.g. an expanded state). + */ +export function useHorizontalOverflow( + contentKey: string, + enabled: boolean, +): { + elementRef: RefCallback; + overflows: boolean; +} { + const [element, setElement] = useState(null); + const [overflows, setOverflows] = useState(false); + const elementRef = useCallback>((node) => { + setElement(node); + }, []); + + useLayoutEffect(() => { + if (!enabled || typeof window === "undefined") { + return; + } + + if (!element) { + setOverflows(false); + return; + } + + let frameId: number | null = null; + + const measure = () => { + frameId = null; + const nextOverflows = hasHorizontalOverflow(element); + setOverflows((current) => (current === nextOverflows ? current : nextOverflows)); + }; + + const scheduleMeasure = () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + frameId = window.requestAnimationFrame(measure); + }; + + measure(); + + const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); + resizeObserver?.observe(element); + if (element.parentElement) { + resizeObserver?.observe(element.parentElement); + } + window.addEventListener("resize", scheduleMeasure); + + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + }; + }, [contentKey, element, enabled]); + + return { elementRef, overflows }; +}