From 50fee91ebb1c14845cddc1a738a8f57d6da23d00 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BC=98=E7=83=A8?= <2514160406@qq.com>
Date: Tue, 1 Sep 2026 14:41:49 +0800
Subject: [PATCH] feat(ask): float, drag, or collapse the blocking question
card
The ask_user_question card sits over the composer and squeezes the
message list. Let the user reclaim the panel:
- Float: detach the card into a viewport-fixed, draggable mini window
via createPortal, clamped to the window on every move.
- Collapse: shrink the live card to its header with a chevron; keep
the pending question visible so an agent blocked on an answer is
never hidden outright.
- Both states survive a question-set swap on the same instance, and
are skipped in the read-only/answered view.
- 13 locales gain collapse/expand/float/dock strings.
Refs: #285
---
.../chat/ask-question-card.test.tsx | 115 ++++++++
src/components/chat/ask-question-card.tsx | 279 ++++++++++++++----
src/i18n/messages/ar.json | 6 +-
src/i18n/messages/de.json | 6 +-
src/i18n/messages/en.json | 6 +-
src/i18n/messages/es.json | 6 +-
src/i18n/messages/fr.json | 6 +-
src/i18n/messages/ja.json | 6 +-
src/i18n/messages/ko.json | 6 +-
src/i18n/messages/pt.json | 6 +-
src/i18n/messages/zh-CN.json | 6 +-
src/i18n/messages/zh-TW.json | 6 +-
12 files changed, 380 insertions(+), 74 deletions(-)
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.