diff --git a/src/components/chat/ask-question-card.test.tsx b/src/components/chat/ask-question-card.test.tsx index c35aaaa298..a608d12a14 100644 --- a/src/components/chat/ask-question-card.test.tsx +++ b/src/components/chat/ask-question-card.test.tsx @@ -536,3 +536,118 @@ describe("AskQuestionCard", () => { expect(container).toBeEmptyDOMElement() }) }) + +describe("AskQuestionCard collapse & floating", () => { + it("collapses to a header-only bar and keeps the selection across the round-trip", () => { + const onAnswer = renderCard(single) + fireEvent.click(screen.getByRole("radio", { name: /Incremental/ })) + fireEvent.click(screen.getByRole("button", { name: "Collapse" })) + // Header-only: options and the footer are unmounted. + expect(screen.queryByRole("radio")).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Submit" }) + ).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Expand" })) + // The pick survived the collapse/expand round-trip. + expect(screen.getByRole("radio", { name: /Incremental/ })).toBeChecked() + fireEvent.click(screen.getByRole("button", { name: "Submit" })) + expect(onAnswer).toHaveBeenCalledWith("q-1", { + answers: [{ questionId: "qa", labels: ["Incremental"] }], + declined: false, + }) + }) + + it("pops out to a fixed floating window and docks back into the flow", () => { + const onAnswer = vi.fn() + const { container } = renderWith(single, onAnswer) + fireEvent.click(screen.getByRole("radio", { name: /Incremental/ })) + fireEvent.click(screen.getByRole("button", { name: "Pop out" })) + // Portaled to body — nothing left in the flow mount point, and the card is + // viewport-fixed at its bottom-right default. + expect(container).toBeEmptyDOMElement() + const card = screen.getByRole("group", { + name: "The agent needs your input", + }) + expect(card.parentElement).toBe(document.body) + expect(card).toHaveClass("fixed") + // Default anchor is the bottom-right corner, expressed as CSS insets. + expect(card.style.right).toBe("12px") + expect(card.style.bottom).toBe("12px") + // Docking back keeps the selection and the answer flow intact. + fireEvent.click(screen.getByRole("button", { name: "Back to panel" })) + expect( + screen.getByRole("radio", { name: /Incremental/ }) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Submit" })) + expect(onAnswer).toHaveBeenCalledWith("q-1", { + answers: [{ questionId: "qa", labels: ["Incremental"] }], + declined: false, + }) + }) + + it("drags the floating window by its header, clamped inside the viewport", () => { + renderCard(single) + fireEvent.click(screen.getByRole("button", { name: "Pop out" })) + const card = screen.getByRole("group", { + name: "The agent needs your input", + }) + const handle = screen.getByTestId("ask-question-drag-handle") + // jsdom has no PointerEvent, so fireEvent.pointerDown degrades to a plain + // Event and drops clientX/clientY (drag coords would all be NaN). Build + // MouseEvents by hand — same recipe as chat-input.test.tsx. + const dragEvent = (type: string, x: number, y: number) => + fireEvent( + handle, + new MouseEvent(type, { bubbles: true, clientX: x, clientY: y }) + ) + // First drag from the default anchor: jsdom reads the card's box as 0, so + // the card lands on the raw delta, clamped by the 8px margin. + dragEvent("pointerdown", 400, 400) + dragEvent("pointermove", 340, 380) + dragEvent("pointerup", 340, 380) + expect(parseFloat(card.style.left)).toBe(8) + expect(parseFloat(card.style.top)).toBe(8) + // Dragging far past the far corner clamps to the opposite edge margin. + dragEvent("pointerdown", 400, 400) + dragEvent("pointermove", 5000, 5000) + dragEvent("pointerup", 5000, 5000) + expect(parseFloat(card.style.left)).toBe(window.innerWidth - 8) + expect(parseFloat(card.style.top)).toBe(window.innerHeight - 8) + // And far past the top-left corner clamps to the 8px margin, never + // offscreen. + dragEvent("pointerdown", 400, 400) + dragEvent("pointermove", -5000, -5000) + dragEvent("pointerup", -5000, -5000) + expect(parseFloat(card.style.left)).toBe(8) + expect(parseFloat(card.style.top)).toBe(8) + }) + + it("collapses inside the floating window to a pill and restores", () => { + renderCard(single) + fireEvent.click(screen.getByRole("button", { name: "Pop out" })) + fireEvent.click(screen.getByRole("button", { name: "Collapse" })) + const card = screen.getByRole("group", { + name: "The agent needs your input", + }) + expect(card.parentElement).toBe(document.body) + expect(screen.queryByRole("radio")).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Expand" })) + expect( + screen.getByRole("radio", { name: /Incremental/ }) + ).toBeInTheDocument() + }) + + it("offers no collapse/float controls in the read-only view", () => { + render( + + + + ) + expect( + screen.queryByRole("button", { name: "Collapse" }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Pop out" }) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/chat/ask-question-card.tsx b/src/components/chat/ask-question-card.tsx index d74af39c3a..acfff06a99 100644 --- a/src/components/chat/ask-question-card.tsx +++ b/src/components/chat/ask-question-card.tsx @@ -1,12 +1,16 @@ "use client" import { useMemo, useRef, useState } from "react" +import { createPortal } from "react-dom" import { useTranslations } from "next-intl" import { Check, + ChevronDown, ChevronRight, + ChevronUp, Loader2, MessageCircleQuestionMark, + PictureInPicture2, } from "lucide-react" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" @@ -49,6 +53,23 @@ type SeedSelections = Record * so it can live inside the same `RadioGroup` as the real options. */ const OTHER_VALUE = "__other__" +/** Keep a floating coordinate inside the viewport with an 8px safety margin, + * tolerant of a degenerate (0×0) measurement on tiny windows. */ +function clampFloatingPos( + left: number, + top: number, + width: number, + height: number +): { left: number; top: number } { + const margin = 8 + const maxLeft = Math.max(margin, window.innerWidth - width - margin) + const maxTop = Math.max(margin, window.innerHeight - height - margin) + return { + left: Math.min(Math.max(left, margin), maxLeft), + top: Math.min(Math.max(top, margin), maxTop), + } +} + interface QState { /** Selected real-option labels (verbatim). For single-select, ≤ 1. */ chosen: string[] @@ -106,6 +127,31 @@ export function AskQuestionCard({ // than relying on the caller to supply a fresh React key. const [renderedId, setRenderedId] = useState(question.question_id) + // Panel-presence states (live card only): `collapsed` hides the body/footer, + // `floating` detaches the card into a draggable viewport-fixed window so it + // stops squeezing the conversation panel. Both intentionally survive a + // question-set swap — the user's chosen layout must not reset just because + // a new set renders into this instance. + const [collapsed, setCollapsed] = useState(false) + const [floating, setFloating] = useState(false) + // Dragged coordinates. Null while the floating window still sits at its + // default anchor — expressed directly as CSS `right/bottom` insets on the + // style prop — so no measure-then-position effect is needed. The first drag + // converts the anchor into left/top numbers (see `handleDragStart`). + const [floatPos, setFloatPos] = useState<{ + left: number + top: number + } | null>(null) + const cardRef = useRef(null) + // Drag start snapshot: pointer origin plus the card's left/top at that + // moment, so each move applies a plain delta. + const dragOrigin = useRef<{ + x: number + y: number + left: number + top: number + } | null>(null) + // How many questions are answered — drives the progress bar, the counter, and // the submit gate (every question must be answered). const answeredCount = useMemo( @@ -247,6 +293,47 @@ export function AskQuestionCard({ const skip = () => void run({ answers: [], declined: true }) + // Floating-window drag: the header row is the handle; pointer capture keeps + // the drag alive outside the card. Clamped on every move so the card can + // never leave the viewport. + const handleDragStart = (e: React.PointerEvent) => { + if (e.pointerType === "mouse" && e.button !== 0) return + // First drag from the default anchor: convert the CSS right/bottom inset + // into left/top coordinates from the card's live box. In environments + // without layout reads (jsdom) the box reads as 0 and the first move lands + // on the raw delta — the per-move clamps still keep the card onscreen. + const rect = cardRef.current?.getBoundingClientRect() + dragOrigin.current = { + x: e.clientX, + y: e.clientY, + left: floatPos?.left ?? rect?.left ?? 0, + top: floatPos?.top ?? rect?.top ?? 0, + } + try { + e.currentTarget.setPointerCapture(e.pointerId) + } catch { + // Capture is an optimization; the drag still works when the moves + // target the handle directly (jsdom, engines without capture). + } + } + + const handleDragMove = (e: React.PointerEvent) => { + const origin = dragOrigin.current + if (!origin) return + setFloatPos( + clampFloatingPos( + origin.left + (e.clientX - origin.x), + origin.top + (e.clientY - origin.y), + cardRef.current?.offsetWidth ?? 0, + cardRef.current?.offsetHeight ?? 0 + ) + ) + } + + const handleDragEnd = () => { + dragOrigin.current = null + } + const isMulti = questions.length > 1 // The read-only/answered view passes an empty subtitle; with no second line the // header row centers the icon, title and count instead of top-aligning them. @@ -435,19 +522,36 @@ export function AskQuestionCard({ // Submit that posts an empty affirmative answer rather than a decline. if (questions.length === 0) return null - return ( - // Capped to the viewport (header + footer pinned, body scrolls) so a tall set - // never covers the whole message list and always keeps Submit/Skip reachable. + const card = ( + // Docked: capped to the viewport (header + footer pinned, body scrolls) so + // a tall set never covers the whole message list and keeps Submit/Skip + // reachable. Floating: a fixed-size window portaled to body, so the + // conversation panel keeps its full height. // `overflow-hidden` clips the full-bleed progress bar to the rounded corners.
- {isMulti && ( + {isMulti && !collapsed && ( )} -
- {/* Header */} +
+ {/* Header — also the floating window's drag handle. The control cluster + opts out of pointerdown so pressing a button never starts a drag. */}
@@ -469,73 +582,103 @@ export function AskQuestionCard({

{title ?? t("title")}

- {resolvedSubtitle && ( + {resolvedSubtitle && !collapsed && (

{resolvedSubtitle}

)}
{isMulti && ( - + {`${answeredCount}/${questions.length}`} )} + {!readOnly && ( +
e.stopPropagation()} + > + + +
+ )}
- {isMulti ? ( - - - {questions.map((q, i) => { - const done = isAnswered(state[q.id]) - return ( - - {done ? ( - - ) : ( - - {i + 1} - - )} - {q.header} - - ) - })} - - {questions.map((q) => ( - - {questionHeading(q)} - {renderOptions(q)} - - ))} - - ) : ( -
- {questions.map((q) => ( -
- {questionHeading(q)} - {renderOptions(q)} -
- ))} -
- )} + {!collapsed && + (isMulti ? ( + + + {questions.map((q, i) => { + const done = isAnswered(state[q.id]) + return ( + + {done ? ( + + ) : ( + + {i + 1} + + )} + {q.header} + + ) + })} + + {questions.map((q) => ( + + {questionHeading(q)} + {renderOptions(q)} + + ))} + + ) : ( +
+ {questions.map((q) => ( +
+ {questionHeading(q)} + {renderOptions(q)} +
+ ))} +
+ ))} - {/* Footer — dropped in the read-only/answered view */} - {!readOnly && ( + {/* Footer — dropped in the read-only/answered view and while collapsed */} + {!readOnly && !collapsed && (
) + + // Floating mode escapes any transformed ancestor via the body portal — a + // fixed child of a transformed container would position against that + // ancestor instead of the viewport. + if (floating && typeof document !== "undefined") { + return createPortal(card, document.body) + } + return card } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e30777fc22..2727c26d59 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3451,7 +3451,11 @@ "skip": "تخطّي", "next": "التالي", "submit": "إرسال", - "submitError": "تعذّر الإرسال. حاول مرة أخرى." + "submitError": "تعذّر الإرسال. حاول مرة أخرى.", + "collapse": "طيّ", + "expand": "توسيع", + "float": "نافذة عائمة", + "dock": "العودة إلى اللوحة" }, "planApproval": { "title": "لدى الوكيل خطة — راجعها", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c0178e4a8..bc814285f8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3451,7 +3451,11 @@ "skip": "Überspringen", "next": "Weiter", "submit": "Senden", - "submitError": "Senden fehlgeschlagen. Bitte versuche es erneut." + "submitError": "Senden fehlgeschlagen. Bitte versuche es erneut.", + "collapse": "Einklappen", + "expand": "Ausklappen", + "float": "Abtrennen", + "dock": "Andocken" }, "planApproval": { "title": "Der Agent hat einen Plan – bitte prüfen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1afa5ba65d..7558f8f6a6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3451,7 +3451,11 @@ "skip": "Skip", "next": "Next", "submit": "Submit", - "submitError": "Couldn't submit. Please try again." + "submitError": "Couldn't submit. Please try again.", + "collapse": "Collapse", + "expand": "Expand", + "float": "Pop out", + "dock": "Back to panel" }, "planApproval": { "title": "The agent has a plan — review it", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 17faed1050..f07de31c08 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3451,7 +3451,11 @@ "skip": "Omitir", "next": "Siguiente", "submit": "Enviar", - "submitError": "No se pudo enviar. Inténtalo de nuevo." + "submitError": "No se pudo enviar. Inténtalo de nuevo.", + "collapse": "Plegar", + "expand": "Desplegar", + "float": "Ventana flotante", + "dock": "Volver al panel" }, "planApproval": { "title": "El agente tiene un plan: revísalo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index fd18122dec..f2d275349a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3451,7 +3451,11 @@ "skip": "Ignorer", "next": "Suivant", "submit": "Envoyer", - "submitError": "Échec de l'envoi. Veuillez réessayer." + "submitError": "Échec de l'envoi. Veuillez réessayer.", + "collapse": "Replier", + "expand": "Déplier", + "float": "Détacher", + "dock": "Réintégrer le panneau" }, "planApproval": { "title": "L'agent a un plan — à vérifier", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6d2222b05e..77537c29c5 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3451,7 +3451,11 @@ "skip": "スキップ", "next": "次へ", "submit": "送信", - "submitError": "送信できませんでした。もう一度お試しください。" + "submitError": "送信できませんでした。もう一度お試しください。", + "collapse": "折りたたむ", + "expand": "展開", + "float": "ポップアウト", + "dock": "パネルに戻す" }, "planApproval": { "title": "エージェントが計画を提示しました — 確認してください", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 847a5a904e..a466d798fd 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3451,7 +3451,11 @@ "skip": "건너뛰기", "next": "다음", "submit": "제출", - "submitError": "제출하지 못했습니다. 다시 시도해 주세요." + "submitError": "제출하지 못했습니다. 다시 시도해 주세요.", + "collapse": "접기", + "expand": "펼치기", + "float": "분리 창", + "dock": "패널로 돌아가기" }, "planApproval": { "title": "에이전트가 계획을 제시했습니다 — 검토하세요", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ab8205a606..a0c3f9fece 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3451,7 +3451,11 @@ "skip": "Pular", "next": "Próxima", "submit": "Enviar", - "submitError": "Falha ao enviar. Tente novamente." + "submitError": "Falha ao enviar. Tente novamente.", + "collapse": "Recolher", + "expand": "Expandir", + "float": "Destacar", + "dock": "Voltar ao painel" }, "planApproval": { "title": "O agente tem um plano — revise", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 402b191571..10ccf4800b 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3451,7 +3451,11 @@ "skip": "跳过", "next": "下一题", "submit": "提交", - "submitError": "提交失败,请重试。" + "submitError": "提交失败,请重试。", + "collapse": "折叠", + "expand": "展开", + "float": "浮窗", + "dock": "停回面板" }, "planApproval": { "title": "智能体已给出计划——请审阅", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a097a4ffe4..3677146e06 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3451,7 +3451,11 @@ "skip": "略過", "next": "下一題", "submit": "提交", - "submitError": "提交失敗,請重試。" + "submitError": "提交失敗,請重試。", + "collapse": "摺疊", + "expand": "展開", + "float": "浮動視窗", + "dock": "停靠回面板" }, "planApproval": { "title": "智慧代理已提出計畫——請審閱",