fix(web): cap clarification overlay height and add mock agent ask commands (#956)
This commit is contained in:
@@ -74,6 +74,12 @@ func handlePrompt(e *emitter, prompt, model string) {
|
||||
rest := strings.TrimPrefix(cmd, "/e2e:")
|
||||
scenarioName, _, _ := strings.Cut(strings.TrimSpace(rest), " ")
|
||||
emitPredefinedScenario(e, scenarioName)
|
||||
// Friendly aliases for the two clarification e2e scenarios so the slash menu
|
||||
// exposes them without forcing users to type /e2e:clarification(-multi).
|
||||
case strings.EqualFold(cmd, "/ask-single"):
|
||||
emitPredefinedScenario(e, "clarification")
|
||||
case strings.EqualFold(cmd, "/ask-multiple"):
|
||||
emitPredefinedScenario(e, "clarification-multi")
|
||||
case strings.EqualFold(cmd, "/crash"):
|
||||
emitCrash(e, model)
|
||||
case strings.HasPrefix(cmd, "/todo"):
|
||||
|
||||
@@ -266,6 +266,8 @@ func mockAvailableCommands() []acp.AvailableCommand {
|
||||
{Name: "tool:search", Description: "Emit a search tool call"},
|
||||
{Name: "markdown", Description: "Render all markdown/text types"},
|
||||
{Name: "sleep", Description: "Sleep without output (default 10s)", Input: hint("seconds (e.g. 30)")},
|
||||
{Name: "ask-single", Description: "Ask a single clarification question"},
|
||||
{Name: "ask-multiple", Description: "Ask multiple clarification questions"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
useChatPanelHandlers,
|
||||
} from "@/components/task/chat/chat-input-area";
|
||||
import { ClarificationInputOverlay } from "@/components/task/chat/clarification-input-overlay";
|
||||
import { ResizeHandle } from "@/components/task/chat/resize-handle";
|
||||
import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay";
|
||||
|
||||
type QuickChatContentProps = {
|
||||
sessionId: string;
|
||||
@@ -73,6 +75,18 @@ export const QuickChatContent = memo(function QuickChatContent({
|
||||
}, [initialPrompt, taskId, handleSubmit, onInitialPromptSent]);
|
||||
|
||||
const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []);
|
||||
const {
|
||||
height: clarificationHeight,
|
||||
containerRef: clarificationContainerRef,
|
||||
resetHeight: clarificationResetHeight,
|
||||
resizeHandleProps: clarificationResizeProps,
|
||||
} = useResizableClarificationOverlay();
|
||||
|
||||
// Reset the dragged height when the overlay closes so a fresh
|
||||
// clarification starts auto-sized instead of inheriting a stale value.
|
||||
useEffect(() => {
|
||||
if (!pendingClarification) clarificationResetHeight();
|
||||
}, [pendingClarification, clarificationResetHeight]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
@@ -93,11 +107,19 @@ export const QuickChatContent = memo(function QuickChatContent({
|
||||
/>
|
||||
</div>
|
||||
{pendingClarification && (
|
||||
<div className="flex-shrink-0 border-t border-sky-400/30 bg-card px-1">
|
||||
<ClarificationInputOverlay
|
||||
messages={pendingClarificationGroup}
|
||||
onResolved={handleClarificationResolved}
|
||||
/>
|
||||
<div className="relative flex-shrink-0 border-t border-sky-400/30 bg-card">
|
||||
<ResizeHandle {...clarificationResizeProps} />
|
||||
<div
|
||||
ref={clarificationContainerRef}
|
||||
data-testid="clarification-overlay-container"
|
||||
className="px-1 overflow-y-auto overscroll-contain max-h-[50vh]"
|
||||
style={clarificationHeight === null ? undefined : { height: clarificationHeight }}
|
||||
>
|
||||
<ClarificationInputOverlay
|
||||
messages={pendingClarificationGroup}
|
||||
onResolved={handleClarificationResolved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QueuedGhostList sessionId={panelState.resolvedSessionId} isArchived={false} />
|
||||
|
||||
@@ -90,22 +90,23 @@ function ClarificationCard(props: CardProps) {
|
||||
data-testid="clarification-question-card"
|
||||
data-question-id={meta.questionId}
|
||||
data-question-index={String(index)}
|
||||
className="px-4 pt-3 pb-4"
|
||||
className="px-4 pt-1 pb-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 text-xs text-muted-foreground">
|
||||
<IconMessageQuestion className="h-4 w-4 text-blue-500" />
|
||||
{total > 1 && (
|
||||
<span data-testid="clarification-progress-chip">
|
||||
Question {index + 1} of {total}
|
||||
</span>
|
||||
)}
|
||||
{metadata.question.title && (
|
||||
<span className="text-muted-foreground/70">
|
||||
{total > 1 ? "· " : ""}
|
||||
{metadata.question.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(total > 1 || metadata.question.title) && (
|
||||
<div className="flex items-center gap-2 mb-2 text-xs text-muted-foreground">
|
||||
{total > 1 && (
|
||||
<span data-testid="clarification-progress-chip">
|
||||
Question {index + 1} of {total}
|
||||
</span>
|
||||
)}
|
||||
{metadata.question.title && (
|
||||
<span className="text-muted-foreground/70">
|
||||
{total > 1 ? "· " : ""}
|
||||
{metadata.question.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="markdown-body max-w-none text-sm font-medium [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 mb-3">
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||
{question.prompt}
|
||||
@@ -453,8 +454,9 @@ export function ClarificationInputOverlay({
|
||||
|
||||
return (
|
||||
<div className="relative" data-testid="clarification-overlay">
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-2 pb-1">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<IconMessageQuestion className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||
{total > 1 && (
|
||||
<ClarificationStepper
|
||||
total={total}
|
||||
|
||||
@@ -11,11 +11,13 @@ type ResizeHandleProps = {
|
||||
};
|
||||
|
||||
function getHandleColor(planModeEnabled?: boolean, isAgentBusy?: boolean, isStarting?: boolean) {
|
||||
if (isStarting) return "bg-primary/40 hover:bg-primary/70";
|
||||
if (isAgentBusy && planModeEnabled) return "bg-violet-400/60 hover:bg-violet-400";
|
||||
if (isAgentBusy) return "bg-primary/40 hover:bg-primary/70";
|
||||
if (planModeEnabled) return "bg-violet-400/60 hover:bg-violet-400";
|
||||
return "bg-border hover:bg-muted-foreground";
|
||||
if (isStarting) return "bg-primary/70 hover:bg-primary";
|
||||
if (isAgentBusy && planModeEnabled) return "bg-violet-400/80 hover:bg-violet-400";
|
||||
if (isAgentBusy) return "bg-primary/70 hover:bg-primary";
|
||||
if (planModeEnabled) return "bg-violet-400/80 hover:bg-violet-400";
|
||||
// Resting state: muted-foreground (light gray on dark bg) at high opacity so
|
||||
// the handle reads as a discoverable affordance, not invisible chrome.
|
||||
return "bg-muted-foreground/60 hover:bg-muted-foreground";
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
@@ -28,10 +30,11 @@ export function ResizeHandle({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize"
|
||||
className={cn(
|
||||
"absolute left-1/2 top-[-1px] -translate-x-1/2 -translate-y-1/2 z-10",
|
||||
"w-12 h-2 cursor-ns-resize",
|
||||
"flex items-center justify-center",
|
||||
"w-16 h-3 cursor-ns-resize",
|
||||
"flex items-center justify-center group",
|
||||
)}
|
||||
onMouseDown={onMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
@@ -39,7 +42,7 @@ export function ResizeHandle({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-0.5 rounded-full transition-colors",
|
||||
"w-12 h-1 rounded-full transition-all group-hover:h-1.5 group-hover:w-14",
|
||||
getHandleColor(planModeEnabled, isAgentBusy, isStarting),
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useIsTaskArchived } from "./task-archived-context";
|
||||
import { useChatPanelState } from "./chat/use-chat-panel-state";
|
||||
import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area";
|
||||
import { ClarificationInputOverlay } from "./chat/clarification-input-overlay";
|
||||
import { ResizeHandle } from "./chat/resize-handle";
|
||||
import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay";
|
||||
import { PanelSearchBar } from "@/components/search/panel-search-bar";
|
||||
import { SessionSearchHits } from "@/components/task/chat/session-search-hits";
|
||||
import { usePanelSearch } from "@/hooks/use-panel-search";
|
||||
@@ -157,6 +159,18 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
} = panelState;
|
||||
const { handleCancelTurn } = useChatPanelHandlers(resolvedSessionId, clearQueue, chatInputRef);
|
||||
const { clarificationKey, handleClarificationResolved } = useClarificationKey(agentMessageCount);
|
||||
const {
|
||||
height: clarificationHeight,
|
||||
containerRef: clarificationContainerRef,
|
||||
resetHeight: clarificationResetHeight,
|
||||
resizeHandleProps: clarificationResizeProps,
|
||||
} = useResizableClarificationOverlay();
|
||||
|
||||
// Reset the dragged height when the overlay closes so a fresh
|
||||
// clarification starts auto-sized instead of inheriting a stale value.
|
||||
useEffect(() => {
|
||||
if (!pendingClarification) clarificationResetHeight();
|
||||
}, [pendingClarification, clarificationResetHeight]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const { loadMore } = useLazyLoadMessages(resolvedSessionId);
|
||||
@@ -216,11 +230,19 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
<SessionSearchOverlay search={search} agentLabel={agentLabel} agentName={agentName} />
|
||||
</PanelBody>
|
||||
{pendingClarification && !isArchived && (
|
||||
<div className="flex-shrink-0 border-t border-sky-400/30 bg-card px-1">
|
||||
<ClarificationInputOverlay
|
||||
messages={pendingClarificationGroup}
|
||||
onResolved={handleClarificationResolved}
|
||||
/>
|
||||
<div className="relative flex-shrink-0 border-t border-sky-400/30 bg-card">
|
||||
<ResizeHandle {...clarificationResizeProps} />
|
||||
<div
|
||||
ref={clarificationContainerRef}
|
||||
data-testid="clarification-overlay-container"
|
||||
className="px-1 overflow-y-auto overscroll-contain max-h-[50vh]"
|
||||
style={clarificationHeight === null ? undefined : { height: clarificationHeight }}
|
||||
>
|
||||
<ClarificationInputOverlay
|
||||
messages={pendingClarificationGroup}
|
||||
onResolved={handleClarificationResolved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QueuedGhostList sessionId={resolvedSessionId} isArchived={isArchived} />
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
* Helper to create a regular task, navigate to it, and wait for idle.
|
||||
* Mirrors clarification.spec.ts's `seedClarificationTask`, but uses a non-
|
||||
* blocking scenario so we reach the idle input and can drive the slash
|
||||
* commands `/ask-single` and `/ask-multiple` from inside the session.
|
||||
*/
|
||||
async function seedTaskAndWaitForIdle(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
title: string,
|
||||
): Promise<SessionPage> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle();
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("Mock agent clarification slash commands", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
// Smoke test that the /ask-single alias routes to the clarification scenario.
|
||||
// The underlying clarification behaviour (option click, multi-question carousel,
|
||||
// submit, etc.) is exhaustively covered by clarification.spec.ts — this only
|
||||
// proves the alias is wired up.
|
||||
test("/ask-single alias triggers the clarification overlay", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Ask Single Alias");
|
||||
|
||||
await session.sendMessage("/ask-single");
|
||||
|
||||
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
|
||||
await session.clarificationOption("PostgreSQL").click();
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("/ask-multiple alias triggers the multi-question carousel", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const session = await seedTaskAndWaitForIdle(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Ask Multiple Alias",
|
||||
);
|
||||
|
||||
await session.sendMessage("/ask-multiple");
|
||||
|
||||
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(session.clarificationSteps()).toHaveCount(3);
|
||||
await expect(session.clarificationOverlay()).toContainText("Which database");
|
||||
|
||||
await session.clarificationOption("PostgreSQL").click();
|
||||
await session.clarificationOption("Go").click();
|
||||
await session.clarificationOption("Docker").click();
|
||||
|
||||
await session.clarificationSubmit().click();
|
||||
await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 });
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Clarification overlay resizable layout", () => {
|
||||
test("starts content-sized, drag grows the overlay, double-click resets", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// Tall viewport so the content-sized overlay has plenty of room to grow
|
||||
// without bumping into the 50vh safety cap on the first drag.
|
||||
await testPage.setViewportSize({ width: 1280, height: 1000 });
|
||||
|
||||
const session = await seedTaskAndWaitForIdle(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Clarification Resize",
|
||||
);
|
||||
|
||||
await session.sendMessage("/ask-multiple");
|
||||
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const container = testPage.getByTestId("clarification-overlay-container");
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
const initial = await container.evaluate((el) => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
overflowY: computed.overflowY,
|
||||
height: el.getBoundingClientRect().height,
|
||||
// Inline style is what disambiguates "auto-sized" from "user-dragged".
|
||||
inlineHeight: (el as HTMLElement).style.height,
|
||||
};
|
||||
});
|
||||
|
||||
expect(initial.overflowY).toBe("auto");
|
||||
// Default state: no inline height → container sizes to its content.
|
||||
expect(initial.inlineHeight).toBe("");
|
||||
// Sanity check: content-sized overlay is at least tall enough for the
|
||||
// question card but well under the 50vh cap.
|
||||
expect(initial.height).toBeGreaterThan(200);
|
||||
expect(initial.height).toBeLessThan(1000 * 0.5);
|
||||
|
||||
const handle = container.locator("xpath=..").locator("button[aria-label='Resize']");
|
||||
await expect(handle).toBeVisible();
|
||||
|
||||
// Drag the handle upward by 120px → overlay should grow by ~120px.
|
||||
const handleBox = await handle.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
const dragDistance = 120;
|
||||
const startX = handleBox!.x + handleBox!.width / 2;
|
||||
const startY = handleBox!.y + handleBox!.height / 2;
|
||||
await testPage.mouse.move(startX, startY);
|
||||
await testPage.mouse.down();
|
||||
await testPage.mouse.move(startX, startY - dragDistance, { steps: 10 });
|
||||
await testPage.mouse.up();
|
||||
|
||||
const afterDrag = await container.evaluate((el) => ({
|
||||
height: el.getBoundingClientRect().height,
|
||||
inlineHeight: (el as HTMLElement).style.height,
|
||||
}));
|
||||
// Drag flipped the container to an explicit pixel height.
|
||||
expect(afterDrag.inlineHeight).toMatch(/^\d+(\.\d+)?px$/);
|
||||
// Allow ±10px tolerance for rounding / mouse event coalescing.
|
||||
expect(afterDrag.height).toBeGreaterThan(initial.height + dragDistance - 10);
|
||||
|
||||
// Double-click the handle → back to auto-sized.
|
||||
await handle.dblclick();
|
||||
const afterReset = await container.evaluate((el) => ({
|
||||
height: el.getBoundingClientRect().height,
|
||||
inlineHeight: (el as HTMLElement).style.height,
|
||||
}));
|
||||
expect(afterReset.inlineHeight).toBe("");
|
||||
expect(Math.abs(afterReset.height - initial.height)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useResizableClarificationOverlay } from "./use-resizable-clarification-overlay";
|
||||
|
||||
const MIN_HEIGHT = 120;
|
||||
|
||||
function setInnerHeight(h: number) {
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: h, writable: true });
|
||||
}
|
||||
|
||||
// Stub getBoundingClientRect on the (detached) container ref so the hook's
|
||||
// "measure current rendered height on drag start" branch has a value to read.
|
||||
function attachContainerWithHeight(ref: React.RefObject<HTMLDivElement | null>, height: number) {
|
||||
const el = document.createElement("div");
|
||||
el.getBoundingClientRect = () => ({
|
||||
height,
|
||||
width: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: height,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
(ref as { current: HTMLDivElement }).current = el;
|
||||
}
|
||||
|
||||
function dispatchMouseMove(clientY: number) {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", { clientY, bubbles: true }));
|
||||
}
|
||||
|
||||
function dispatchMouseUp() {
|
||||
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
}
|
||||
|
||||
// Fake MouseEvent for handleMouseDown — only clientY and preventDefault are read.
|
||||
function fakeReactMouseEvent(clientY: number) {
|
||||
return { clientY, preventDefault: () => {} } as unknown as React.MouseEvent;
|
||||
}
|
||||
|
||||
describe("useResizableClarificationOverlay", () => {
|
||||
beforeEach(() => {
|
||||
setInnerHeight(1000);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
});
|
||||
|
||||
it("starts with height=null (auto-sized) and exposes a containerRef + handle props", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
expect(result.current.height).toBeNull();
|
||||
expect(result.current.containerRef.current).toBeNull();
|
||||
expect(typeof result.current.resizeHandleProps.onMouseDown).toBe("function");
|
||||
expect(typeof result.current.resizeHandleProps.onDoubleClick).toBe("function");
|
||||
});
|
||||
|
||||
it("drag UP from a measured 300px grows height proportionally", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 300);
|
||||
|
||||
// Mouse down at y=500, drag up to y=400 (delta=100), expect height ≈ 400.
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
act(() => dispatchMouseMove(400));
|
||||
|
||||
expect(result.current.height).toBe(400);
|
||||
});
|
||||
|
||||
it("clamps below MIN_HEIGHT when dragging down past it", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 200);
|
||||
|
||||
// Drag down (clientY grows) 500px → would target -300, must clamp to MIN_HEIGHT.
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(200)));
|
||||
act(() => dispatchMouseMove(700));
|
||||
|
||||
expect(result.current.height).toBe(MIN_HEIGHT);
|
||||
});
|
||||
|
||||
it("clamps above 50% of viewport when dragging up past it", () => {
|
||||
setInnerHeight(800); // 50% = 400 cap
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 300);
|
||||
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
act(() => dispatchMouseMove(0)); // delta=500 → 800 requested, must clamp to 400
|
||||
|
||||
expect(result.current.height).toBe(400);
|
||||
});
|
||||
|
||||
it("does not update height after mouseup", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 250);
|
||||
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
act(() => dispatchMouseMove(450)); // delta=50 → 300
|
||||
expect(result.current.height).toBe(300);
|
||||
|
||||
act(() => dispatchMouseUp());
|
||||
act(() => dispatchMouseMove(200)); // would target 550 if still dragging
|
||||
|
||||
expect(result.current.height).toBe(300);
|
||||
});
|
||||
|
||||
it("sets document.body cursor/userSelect during drag and restores on mouseup", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 200);
|
||||
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(400)));
|
||||
expect(document.body.style.cursor).toBe("ns-resize");
|
||||
expect(document.body.style.userSelect).toBe("none");
|
||||
|
||||
act(() => dispatchMouseUp());
|
||||
expect(document.body.style.cursor).toBe("");
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to MIN_HEIGHT as the drag origin when the container ref is empty", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
// No containerRef attached → fallback path.
|
||||
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
act(() => dispatchMouseMove(400)); // delta=100 → MIN_HEIGHT + 100 = 220
|
||||
|
||||
expect(result.current.height).toBe(MIN_HEIGHT + 100);
|
||||
});
|
||||
|
||||
it("resetHeight and the handle's onDoubleClick both revert to null (auto)", () => {
|
||||
const { result } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 250);
|
||||
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
act(() => dispatchMouseMove(400));
|
||||
act(() => dispatchMouseUp());
|
||||
// startHeight=250 + delta=100 (500→400) = 350
|
||||
expect(result.current.height).toBe(350);
|
||||
|
||||
act(() => result.current.resetHeight());
|
||||
expect(result.current.height).toBeNull();
|
||||
|
||||
// Drag again, then double-click resets back to auto-sized.
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
act(() => dispatchMouseMove(380));
|
||||
act(() => dispatchMouseUp());
|
||||
expect(result.current.height).not.toBeNull();
|
||||
|
||||
act(() => result.current.resizeHandleProps.onDoubleClick());
|
||||
expect(result.current.height).toBeNull();
|
||||
});
|
||||
|
||||
it("cleans up body cursor and stops listening on unmount mid-drag", () => {
|
||||
const { result, unmount } = renderHook(() => useResizableClarificationOverlay());
|
||||
attachContainerWithHeight(result.current.containerRef, 250);
|
||||
|
||||
act(() => result.current.resizeHandleProps.onMouseDown(fakeReactMouseEvent(500)));
|
||||
expect(document.body.style.cursor).toBe("ns-resize");
|
||||
|
||||
unmount();
|
||||
|
||||
// Unmount mid-drag must restore body styles.
|
||||
expect(document.body.style.cursor).toBe("");
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
|
||||
// Listeners removed: a synthetic mousemove must not throw / update anything.
|
||||
expect(() => dispatchMouseMove(200)).not.toThrow();
|
||||
expect(result.current.height).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const MIN_HEIGHT = 120;
|
||||
const MAX_VIEWPORT_RATIO = 0.5;
|
||||
|
||||
function viewportHeight(): number {
|
||||
return typeof window === "undefined" ? 800 : window.innerHeight;
|
||||
}
|
||||
|
||||
function clampHeight(value: number): number {
|
||||
const max = Math.round(viewportHeight() * MAX_VIEWPORT_RATIO);
|
||||
return Math.min(Math.max(value, MIN_HEIGHT), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the user-resizable clarification overlay.
|
||||
*
|
||||
* Default behaviour: the container sizes to its content (`height === null` →
|
||||
* caller omits the inline height style). Once the user drags the resize
|
||||
* handle, the height switches to an explicit pixel value clamped between
|
||||
* MIN_HEIGHT and 50% of the viewport. Double-clicking the handle returns to
|
||||
* the auto-sized default.
|
||||
*
|
||||
* Callers MUST invoke `resetHeight()` when the overlay closes so a fresh
|
||||
* clarification starts with auto-sized height instead of inheriting a
|
||||
* dragged value.
|
||||
*/
|
||||
export function useResizableClarificationOverlay() {
|
||||
// null = auto-size to content; number = user-driven pixel height.
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const startY = useRef(0);
|
||||
const startHeight = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Capture the *currently rendered* height so the drag continues from
|
||||
// wherever the overlay sits — whether it's auto-sized or already pinned.
|
||||
const measured = containerRef.current?.getBoundingClientRect().height ?? MIN_HEIGHT;
|
||||
isDragging.current = true;
|
||||
startY.current = e.clientY;
|
||||
startHeight.current = measured;
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
const resetHeight = useCallback(() => setHeight(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
// Dragging the handle UP grows the overlay (it expands toward the top
|
||||
// of the screen), so a smaller clientY → larger height.
|
||||
const delta = startY.current - e.clientY;
|
||||
setHeight(clampHeight(startHeight.current + delta));
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
if (isDragging.current) {
|
||||
isDragging.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
height,
|
||||
containerRef,
|
||||
resetHeight,
|
||||
resizeHandleProps: { onMouseDown: handleMouseDown, onDoubleClick: resetHeight },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user