Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<HTMLElement>('[data-prompt-suggestion="true"]'),
"Unable to find the prompt suggestion chip.",
);
const text = await waitForElement(
() => {
const found = chip.querySelector<HTMLElement>('[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<HTMLElement>('[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,
Expand Down
33 changes: 31 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -3043,11 +3048,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onClick={() => applyPromptSuggestion(latestPromptSuggestion)}
>
<SparklesIcon className="size-3.5 shrink-0 text-muted-foreground/65" />
<span className="truncate">{latestPromptSuggestionDisplayText}</span>
<span
ref={promptSuggestionOverflow.elementRef}
data-prompt-suggestion-text="true"
className="truncate"
>
{latestPromptSuggestionDisplayText}
</span>
</button>
}
/>
<TooltipPopup side="top">Claude suggested this prompt</TooltipPopup>
{/* The chip clips long suggestions, so the tooltip carries the full text
whenever it is clipped; a suggestion that fits keeps the short label. */}
<TooltipPopup
side="top"
align="start"
className="max-w-96"
data-prompt-suggestion-tooltip="true"
>
{promptSuggestionOverflow.overflows ? (
<span className="block text-pretty">
{latestPromptSuggestionDisplayText}
<span className="mt-1 block text-muted-foreground">
Claude suggested this prompt
</span>
</span>
) : (
"Claude suggested this prompt"
)}
</TooltipPopup>
</Tooltip>
</div>
) : null}
Expand Down
68 changes: 1 addition & 67 deletions apps/web/src/components/chat/ThreadActivityPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefCallback,
type RefObject,
} from "react";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLSpanElement>;
overflows: boolean;
} {
const [element, setElement] = useState<HTMLSpanElement | null>(null);
const [overflows, setOverflows] = useState(false);
const elementRef = useCallback<RefCallback<HTMLSpanElement>>((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<HTMLButtonElement | null>;
layoutKey: string;
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/hooks/useHorizontalOverflow.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
overflows: boolean;
} {
const [element, setElement] = useState<HTMLElement | null>(null);
const [overflows, setOverflows] = useState(false);
const elementRef = useCallback<RefCallback<HTMLElement>>((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 };
}
Loading