From 9bb787e173307358cfab20f7cabcbecc5f6f6a25 Mon Sep 17 00:00:00 2001 From: qiongyu1999 <2694684348@qq.com> Date: Sun, 31 May 2026 14:02:42 +0800 Subject: [PATCH] feat(web): move discovery questions into a right-side Questions tab Surface the active discovery question-form in a dedicated right-hand Questions tab instead of inline in the chat. The form streams in there (frame first, questions revealed one-by-one), with a Continue button and a "skip all" affordance backed by a 120s auto-continue countdown. The chat shows a banner that focuses the tab. Every question is optional; submission is gated only by checkbox selection caps. Answered/historical forms still render inline so the scrollback reads naturally. --- apps/web/src/artifacts/question-form.ts | 206 +++++++++++++--- apps/web/src/components/AssistantMessage.tsx | 66 ++++- apps/web/src/components/ChatPane.tsx | 4 + apps/web/src/components/FileWorkspace.tsx | 88 ++++++- apps/web/src/components/ProjectView.tsx | 92 +++++++ apps/web/src/components/QuestionForm.tsx | 123 ++++++---- apps/web/src/components/QuestionsPanel.tsx | 186 ++++++++++++++ apps/web/src/i18n/locales/en.ts | 6 + apps/web/src/i18n/locales/zh-CN.ts | 6 + apps/web/src/i18n/types.ts | 6 + apps/web/src/styles/viewer/composio.css | 226 ++++++++++++++++++ .../components/AssistantMessage.test.tsx | 5 +- .../tests/components/QuestionForm.test.tsx | 8 +- .../components/QuestionsPanel.reveal.test.tsx | 152 ++++++++++++ 14 files changed, 1076 insertions(+), 98 deletions(-) create mode 100644 apps/web/src/components/QuestionsPanel.tsx create mode 100644 apps/web/tests/components/QuestionsPanel.reveal.test.tsx diff --git a/apps/web/src/artifacts/question-form.ts b/apps/web/src/artifacts/question-form.ts index 3ae318359..603ff63e1 100644 --- a/apps/web/src/artifacts/question-form.ts +++ b/apps/web/src/artifacts/question-form.ts @@ -138,6 +138,48 @@ export function splitOnQuestionForms(input: string): FormSegment[] { return out; } +// First parseable form in a message, used to surface the active discovery +// form in the right-hand Questions tab instead of inline in the chat. +export function findFirstQuestionForm( + input: string, +): { form: QuestionForm; raw: string } | null { + for (const seg of splitOnQuestionForms(input)) { + if (seg.kind === 'form') return { form: seg.form, raw: seg.raw }; + } + return null; +} + +// Drop a trailing, not-yet-closed question-form block from streaming text so +// the chat doesn't flash raw `{…` markup before the JSON +// finishes. Returns the visible text plus whether such an open block existed +// (which means a form is mid-generation). +export function stripTrailingOpenQuestionForm( + input: string, +): { text: string; hadOpenForm: boolean } { + let cursor = 0; + while (cursor < input.length) { + const slice = input.slice(cursor); + const m = OPEN_RE.exec(slice); + if (!m) break; + const tagName = (m[1] ?? 'question-form').toLowerCase(); + const closeTag = ``; + const openStart = cursor + m.index; + const openEnd = openStart + m[0].length; + const closeIdx = findCloseTag(input, openEnd, closeTag); + if (closeIdx === -1) { + return { text: input.slice(0, openStart), hadOpenForm: true }; + } + cursor = closeIdx + closeTag.length; + } + return { text: input, hadOpenForm: false }; +} + +// True when a question-form open tag is present but its close tag hasn't +// streamed in yet — i.e. the model is still generating the form. +export function hasUnterminatedQuestionForm(input: string): boolean { + return stripTrailingOpenQuestionForm(input).hadOpenForm; +} + function findCloseTag(input: string, from: number, closeTag: string): number { const closeLower = closeTag.toLowerCase(); const tagLen = closeTag.length; @@ -181,38 +223,8 @@ function tryParseForm(body: string, attrs: Record): QuestionForm if (!rawQuestions) return null; const questions: FormQuestion[] = []; rawQuestions.forEach((q, i) => { - if (!q || typeof q !== 'object') return; - const qo = q as Record; - const id = - typeof qo.id === 'string' && qo.id.trim().length > 0 - ? qo.id.trim() - : `q${i + 1}`; - const label = typeof qo.label === 'string' ? qo.label : id; - const type = normalizeType(qo.type); - const options = parseOptions(qo.options); - const placeholder = typeof qo.placeholder === 'string' ? qo.placeholder : undefined; - const help = typeof qo.help === 'string' ? qo.help : undefined; - const required = qo.required === true; - const maxSelections = - typeof qo.maxSelections === 'number' && - Number.isInteger(qo.maxSelections) && - qo.maxSelections > 0 - ? qo.maxSelections - : undefined; - const cards = parseDirectionCards(qo.cards); - const defaultValue = parseDefaultValue(qo, options); - questions.push({ - id, - label, - type, - ...(options ? { options } : {}), - ...(placeholder ? { placeholder } : {}), - ...(help ? { help } : {}), - ...(required ? { required } : {}), - ...(defaultValue !== undefined ? { defaultValue } : {}), - ...(maxSelections !== undefined && type === 'checkbox' ? { maxSelections } : {}), - ...(cards ? { cards } : {}), - }); + const mapped = mapRawQuestion(q, i); + if (mapped) questions.push(mapped); }); if (questions.length === 0) return null; const id = attrs.id ?? (typeof obj.id === 'string' ? obj.id : 'discovery'); @@ -229,6 +241,136 @@ function tryParseForm(body: string, attrs: Record): QuestionForm }; } +function mapRawQuestion(q: unknown, index: number): FormQuestion | null { + if (!q || typeof q !== 'object') return null; + const qo = q as Record; + const id = + typeof qo.id === 'string' && qo.id.trim().length > 0 ? qo.id.trim() : `q${index + 1}`; + const label = typeof qo.label === 'string' ? qo.label : id; + const type = normalizeType(qo.type); + const options = parseOptions(qo.options); + const placeholder = typeof qo.placeholder === 'string' ? qo.placeholder : undefined; + const help = typeof qo.help === 'string' ? qo.help : undefined; + const required = qo.required === true; + const maxSelections = + typeof qo.maxSelections === 'number' && + Number.isInteger(qo.maxSelections) && + qo.maxSelections > 0 + ? qo.maxSelections + : undefined; + const cards = parseDirectionCards(qo.cards); + const defaultValue = parseDefaultValue(qo, options); + return { + id, + label, + type, + ...(options ? { options } : {}), + ...(placeholder ? { placeholder } : {}), + ...(help ? { help } : {}), + ...(required ? { required } : {}), + ...(defaultValue !== undefined ? { defaultValue } : {}), + ...(maxSelections !== undefined && type === 'checkbox' ? { maxSelections } : {}), + ...(cards ? { cards } : {}), + }; +} + +/** + * Tolerant parser for a still-streaming `` block. Unlike + * {@link tryParseForm} it does not require valid, complete JSON: it reads the + * title/id from the open tag's attrs (available the instant the tag streams in) + * and extracts however many *complete* question objects have arrived so far. + * This lets the Questions tab render a frame immediately and fill questions in + * progressively as the model streams them, instead of flashing a skeleton and + * then a finished table. Returns null only when no open tag is present. + */ +export function parsePartialQuestionForm(input: string): QuestionForm | null { + const m = OPEN_RE.exec(input); + if (!m) return null; + const tagName = (m[1] ?? 'question-form').toLowerCase(); + const closeTag = ``; + const openEnd = m.index + m[0].length; + const attrs = parseAttrs(m[2] ?? ''); + const closeIdx = findCloseTag(input, openEnd, closeTag); + const rawBody = closeIdx === -1 ? input.slice(openEnd) : input.slice(openEnd, closeIdx); + // Strip a leading fenced ```json wrapper; the closing fence may not have + // streamed in yet, so only the opening fence is removed. + const body = rawBody.replace(/^\s*```(?:json)?\s*/i, ''); + const id = attrs.id ?? extractJsonStringField(body, 'id') ?? 'discovery'; + const title = + attrs.title ?? extractJsonStringField(body, 'title') ?? 'A few quick questions'; + const description = extractJsonStringField(body, 'description'); + const questions = extractCompleteQuestions(body); + return { + id, + title, + questions, + ...(description ? { description } : {}), + }; +} + +// Pull complete `{...}` question objects out of a partial `"questions": [ … ]` +// array, stopping at the first object whose closing brace hasn't streamed in. +function extractCompleteQuestions(body: string): FormQuestion[] { + const keyMatch = /"questions"\s*:\s*\[/.exec(body); + if (!keyMatch) return []; + const out: FormQuestion[] = []; + let i = keyMatch.index + keyMatch[0].length; + let index = 0; + while (i < body.length) { + while (i < body.length && /[\s,]/.test(body[i] as string)) i++; + if (i >= body.length || body[i] === ']') break; + if (body[i] !== '{') break; + const objStr = extractBalancedObject(body, i); + if (!objStr) break; + try { + const mapped = mapRawQuestion(JSON.parse(objStr), index); + if (mapped) out.push(mapped); + } catch { + break; + } + i += objStr.length; + index++; + } + return out; +} + +// Return the substring for the balanced `{...}` object starting at `start`, +// or null if it never closes (string-aware so braces inside strings don't count). +function extractBalancedObject(s: string, start: number): string | null { + let depth = 0; + let inStr = false; + let esc = false; + for (let i = start; i < s.length; i++) { + const c = s[i] as string; + if (inStr) { + if (esc) esc = false; + else if (c === '\\') esc = true; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) return s.slice(start, i + 1); + } + } + return null; +} + +// Best-effort extraction of a top-level "field": "value" string from a partial +// JSON body — used for title/id/description before the full body parses. +function extractJsonStringField(body: string, field: string): string | undefined { + const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`); + const m = re.exec(body); + if (!m) return undefined; + try { + return JSON.parse(`"${m[1]}"`) as string; + } catch { + return m[1]; + } +} + function normalizeType(raw: unknown): QuestionType { if (typeof raw !== 'string') return 'text'; const lower = raw.toLowerCase().trim(); diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 26bed5f74..6d74d8883 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -30,6 +30,7 @@ import { } from "@open-design/contracts/analytics"; import { splitOnQuestionForms, + stripTrailingOpenQuestionForm, type QuestionForm, } from "../artifacts/question-form"; import { stripArtifact } from "../artifacts/strip"; @@ -311,6 +312,10 @@ interface Props { // Submit handler the form fires when the user picks answers — opaque // to AssistantMessage; ProjectView wires it into onSend. onSubmitForm?: (text: string) => void; + // Open the right-hand Questions tab. The active discovery form renders + // there (Claude-Design style) instead of inline; this assistant message + // shows a banner that focuses the tab on click. + onOpenQuestions?: () => void; onContinueRemainingTasks?: (todos: TodoItem[]) => void; onFeedback?: (change: ChatMessageFeedbackChange) => void; suppressDirectionForms?: boolean; @@ -342,6 +347,7 @@ export function AssistantMessage({ errorCardOwnerId = null, nextUserContent, onSubmitForm, + onOpenQuestions, onContinueRemainingTasks, onFeedback, suppressDirectionForms = false, @@ -540,6 +546,7 @@ export function AssistantMessage({ }); onSubmitForm?.(text); }} + onOpenQuestions={onOpenQuestions} onRequestOpenFile={onRequestOpenFile} /> ); @@ -1668,6 +1675,7 @@ function ProseBlock({ locallySubmitted, suppressDirectionForms, onSubmitForm, + onOpenQuestions, onRequestOpenFile, }: { text: string; @@ -1677,10 +1685,22 @@ function ProseBlock({ locallySubmitted: Set; suppressDirectionForms: boolean; onSubmitForm: (formId: string, text: string) => void; + onOpenQuestions?: () => void; onRequestOpenFile?: (name: string) => void; }) { const cleaned = useMemo(() => stripArtifact(text), [text]); - const segments = useMemo(() => splitOnQuestionForms(cleaned), [cleaned]); + // While the latest turn is still streaming a not-yet-closed question-form, + // drop the partial `{…` markup from the prose so the chat + // doesn't flash raw JSON; we surface a banner for it instead. The actual + // form streams into the right-hand Questions tab. + const { text: visibleText, hadOpenForm } = useMemo( + () => + isLastAssistant && streaming + ? stripTrailingOpenQuestionForm(cleaned) + : { text: cleaned, hadOpenForm: false }, + [cleaned, isLastAssistant, streaming], + ); + const segments = useMemo(() => splitOnQuestionForms(visibleText), [visibleText]); // Route relative file-link clicks (`template.html`, `subdir/hero.html`) // through the workspace tab opener. Without this, Electron's window-open // handler creates a new app window whose relative href can't resolve, and @@ -1749,17 +1769,40 @@ function ProseBlock({ key={seg.key} form={seg.form} isLastAssistant={isLastAssistant} - streaming={streaming} nextUserContent={nextUserContent} locallySubmitted={locallySubmitted} onSubmitForm={onSubmitForm} + onOpenQuestions={onOpenQuestions} /> ); })} + {hadOpenForm ? : null} ); } +// Chat-side banner that points to the right-hand Questions tab where the +// active discovery form lives. Clicking it focuses that tab. +function QuestionsBanner({ onOpen }: { onOpen?: () => void }) { + const t = useT(); + return ( + + ); +} + function isDirectionForm(form: QuestionForm): boolean { if (form.id.toLowerCase() === "direction") return true; if (form.title.toLowerCase().includes("visual direction")) return true; @@ -1769,17 +1812,17 @@ function isDirectionForm(form: QuestionForm): boolean { function FormBlock({ form, isLastAssistant, - streaming, nextUserContent, locallySubmitted, onSubmitForm, + onOpenQuestions, }: { form: QuestionForm; isLastAssistant: boolean; - streaming: boolean; nextUserContent?: string; locallySubmitted: Set; onSubmitForm: (formId: string, text: string) => void; + onOpenQuestions?: () => void; }) { // Reconstruct prior answers from a follow-up user message so older // forms in the scrollback render in their answered state. @@ -1788,15 +1831,18 @@ function FormBlock({ return parseSubmittedAnswers(form, nextUserContent); }, [form, nextUserContent]); const wasSubmittedLocally = locallySubmitted.has(form.id); - const interactive = - isLastAssistant && - !streaming && - !submittedFromHistory && - !wasSubmittedLocally; + // The live, still-unanswered form lives in the right-hand Questions tab — + // even mid-stream. In chat we only show a banner that focuses it, so the + // left side never renders the form itself. Answered / historical forms stay + // inline so the scrollback reads naturally. + const showBanner = isLastAssistant && !submittedFromHistory && !wasSubmittedLocally; + if (showBanner) { + return ; + } return ( onSubmitForm(form.id, text)} /> diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index ebb643e82..269d53669 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -263,6 +263,8 @@ interface Props { // Question-form submissions become a normal user message; the parent // routes that text through onSend (no attachments). onSubmitForm?: (text: string) => void; + // Focus the right-hand Questions tab from the chat banner. + onOpenQuestions?: () => void; onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void; onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void; // Header "+" button — kicks off ProjectView's create-conversation flow. @@ -371,6 +373,7 @@ export function ChatPane({ forceStreamingMessageIds, initialDraft, onSubmitForm, + onOpenQuestions, onContinueRemainingTasks, onAssistantFeedback, onNewConversation, @@ -1165,6 +1168,7 @@ export function ChatPane({ scrolledToFormRef.current = new Set(); onSubmitForm?.(text); }} + onOpenQuestions={onOpenQuestions} onContinueRemainingTasks={ m.id === lastAssistantId && onContinueRemainingTasks ? (todos) => onContinueRemainingTasks(m, todos) diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 3742822f5..aafaa8f03 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -42,6 +42,7 @@ import { type ProjectMetadata, type ProjectFile, } from '../types'; +import type { QuestionForm } from '../artifacts/question-form'; import { DesignFilesPanel } from './DesignFilesPanel'; import type { PluginFolderAgentAction } from './design-files/pluginFolderActions'; import { designSystemGithubEvidenceState, repoConnectCopy } from './design-system-github-evidence'; @@ -50,6 +51,7 @@ import { Icon } from './Icon'; import { LiveArtifactBadges } from './LiveArtifactBadges'; import { MissingBrandFontsBanner } from './MissingBrandFontsBanner'; import { PasteTextDialog } from './PasteTextDialog'; +import { QuestionsPanel } from './QuestionsPanel'; import { QuickSwitcher } from './QuickSwitcher'; import { SketchEditor } from './SketchEditor'; import { @@ -115,6 +117,25 @@ interface Props { artifactHtml?: string | null; conversationError?: string | null; onRetry?: (message: ChatMessage) => void; + // Active discovery question form, surfaced in the right-hand Questions tab + // instead of inline in the chat. Owned by ProjectView (derived from the + // latest assistant message). + questionForm?: QuestionForm | null; + // Tolerantly-parsed form shown while the block is still streaming, so the + // panel renders a frame and fills questions in progressively. + questionFormPreview?: QuestionForm | null; + // Stable per-occurrence id so the panel can remember a completed reveal + // across the streaming→persisted remount instead of re-animating. + questionFormKey?: string | null; + questionFormInteractive?: boolean; + // The turn is busy (streaming/queued) — keep Continue/Skip disabled while the + // form itself stays editable. + questionFormSubmitDisabled?: boolean; + questionFormSubmittedAnswers?: Record; + questionsGenerating?: boolean; + onSubmitQuestionForm?: (text: string) => void; + // Bumped nonce that focuses the Questions tab (banner click / new form). + focusQuestionsRequest?: { nonce: number } | null; } interface SketchState { @@ -130,6 +151,7 @@ interface SketchState { const DESIGN_FILES_TAB = '__design_files__'; const DESIGN_SYSTEM_TAB = '__design_system__'; +const QUESTIONS_TAB = '__questions__'; type TabDropEdge = 'before' | 'after'; type DesignSystemReviewDecision = NonNullable[string]['decision']; @@ -237,8 +259,18 @@ export function FileWorkspace({ artifactHtml, conversationError, onRetry, + questionForm = null, + questionFormPreview = null, + questionFormKey = null, + questionFormInteractive = false, + questionFormSubmitDisabled = false, + questionFormSubmittedAnswers, + questionsGenerating = false, + onSubmitQuestionForm, + focusQuestionsRequest = null, }: Props) { const t = useT(); + const showQuestionsTab = Boolean(questionForm || questionsGenerating); const analytics = useAnalytics(); // P1 page_view page_name=file_manager — once per project the user lands // inside the workspace. Re-fire when the projectId changes so a @@ -318,7 +350,7 @@ export function FileWorkspace({ // back to the last remaining tab. Skip transient activeTab values // (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs. useEffect(() => { - if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return; + if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === QUESTIONS_TAB) return; if (sketches[activeTab] && !sketches[activeTab]!.persisted) return; if (!persistedTabs.includes(activeTab)) { setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null); @@ -341,6 +373,24 @@ export function FileWorkspace({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [openRequest]); + // Focus the Questions tab when the parent bumps the nonce (banner click in + // chat, or a freshly generated form). The tab is transient — not added to + // the persisted tab list. + useEffect(() => { + if (!focusQuestionsRequest) return; + setActiveTab(QUESTIONS_TAB); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusQuestionsRequest?.nonce]); + + // If the Questions tab is active but the form is gone (answered, or a new + // assistant turn without a form), fall back to the default root tab. + useEffect(() => { + if (activeTab === QUESTIONS_TAB && !showQuestionsTab) { + setActiveTab(defaultRootTab); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, showQuestionsTab]); + function openFile(name: string) { setUploadError(null); onTabsStateChange({ @@ -525,7 +575,7 @@ export function FileWorkspace({ // The Design Files entry is already sticky-pinned, so we only scroll // for real workspace tabs. Issue #775. useEffect(() => { - if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return; + if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === QUESTIONS_TAB) return; const tabBar = tabsBarRef.current; if (!tabBar) return; const el = tabBar.querySelector('.ws-tab.active'); @@ -793,7 +843,7 @@ export function FileWorkspace({ } const activeFile = useMemo(() => { - if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return null; + if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === QUESTIONS_TAB) return null; const onDisk = visibleFiles.find((f) => f.name === activeTab); if (onDisk) return onDisk; if (isSketchName(activeTab) && sketches[activeTab]) { @@ -809,7 +859,7 @@ export function FileWorkspace({ }, [activeTab, visibleFiles, sketches]); const activeLiveArtifact = useMemo(() => { - if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return null; + if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === QUESTIONS_TAB) return null; return liveArtifactEntries.find((entry) => entry.tabId === activeTab) ?? null; }, [activeTab, liveArtifactEntries]); @@ -897,6 +947,23 @@ export function FileWorkspace({ {t('workspace.designFiles')} + {showQuestionsTab ? ( + + ) : null} {tabNames.map((name) => { const sketchEntry = sketches[name]; const dirtyMark = @@ -979,7 +1046,18 @@ export function FileWorkspace({ ) : null} - {activeTab === DESIGN_SYSTEM_TAB && designSystemProject ? ( + {activeTab === QUESTIONS_TAB ? ( + onSubmitQuestionForm?.(text)} + /> + ) : activeTab === DESIGN_SYSTEM_TAB && designSystemProject ? ( block, the panel renders it. The form is interactive + // only while it's the most recent turn and the user hasn't answered yet + // (an answer arrives as a following "[form answers …]" user message). + const lastAssistantIndex = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === 'assistant') return i; + } + return -1; + }, [messages]); + const lastAssistantContent = + lastAssistantIndex >= 0 ? messages[lastAssistantIndex]?.content ?? '' : ''; + const questionForm: QuestionForm | null = useMemo( + () => findFirstQuestionForm(lastAssistantContent)?.form ?? null, + [lastAssistantContent], + ); + const questionFormSubmittedAnswers = useMemo(() => { + if (!questionForm) return undefined; + for (let i = lastAssistantIndex + 1; i < messages.length; i++) { + const m = messages[i]; + if (m?.role !== 'user') continue; + const parsed = parseSubmittedAnswers(questionForm, m.content ?? ''); + if (parsed) return parsed; + } + return undefined; + }, [questionForm, lastAssistantIndex, messages]); + const questionsGenerating = + currentConversationStreaming && hasUnterminatedQuestionForm(lastAssistantContent); + // While the form is still streaming, parse it tolerantly so the Questions tab + // can show a frame (title) immediately and fill questions in as they arrive. + const questionFormPreview = useMemo( + () => (questionsGenerating ? parsePartialQuestionForm(lastAssistantContent) : null), + [questionsGenerating, lastAssistantContent], + ); + // The active (latest, unanswered) form stays editable the whole time it's on + // screen — while it streams in AND while the turn is still busy — so it never + // flickers between the locked (grey) and interactive (accent) styles. + // Submission is gated separately by the panel via `submitDisabled`/generating. + const questionFormActive = + (!!questionForm || questionsGenerating) && questionFormSubmittedAnswers === undefined; + const hasQuestions = Boolean(questionForm || questionsGenerating); + // Stable identity for the current form occurrence, used to remember that its + // one-by-one reveal already played. Keyed on the conversation + template id + // (not the message index) so the brief streaming→persisted message swap — + // which unmounts and re-focuses the Questions tab — doesn't replay the + // animation, while a genuinely new form in another conversation still does. + const questionFormKey = useMemo(() => { + const f = questionForm ?? questionFormPreview; + return activeConversationId && f ? `${activeConversationId}:${f.id}` : null; + }, [activeConversationId, questionForm, questionFormPreview]); + + // Auto-switch the workspace to the Questions tab when a new discovery form + // first appears, and let the chat banner re-focus it on click. The nonce + // bump is what FileWorkspace listens to. + const [questionsFocusNonce, setQuestionsFocusNonce] = useState(0); + const prevHasQuestionsRef = useRef(false); + useEffect(() => { + if (hasQuestions && !prevHasQuestionsRef.current) { + setQuestionsFocusNonce((n) => n + 1); + } + prevHasQuestionsRef.current = hasQuestions; + }, [hasQuestions]); + const focusQuestionsRequest = useMemo( + () => (questionsFocusNonce > 0 ? { nonce: questionsFocusNonce } : null), + [questionsFocusNonce], + ); + const openQuestionsTab = useCallback(() => { + setQuestionsFocusNonce((n) => n + 1); + }, []); + const currentConversationQueuedItems = activeConversationId ? queuedChatSends .filter((item) => item.conversationId === activeConversationId) @@ -4481,6 +4560,7 @@ export function ProjectView({ if (currentConversationActionDisabled) return; void handleSend(text, [], []); }} + onOpenQuestions={openQuestionsTab} onContinueRemainingTasks={handleContinueRemainingTasks} onAssistantFeedback={handleAssistantFeedback} onNewConversation={handleNewConversation} @@ -4584,6 +4664,18 @@ export function ProjectView({ artifactHtml={artifact?.html} conversationError={error} onRetry={handleRetry} + questionForm={questionForm} + questionFormPreview={questionFormPreview} + questionFormKey={questionFormKey} + questionFormInteractive={questionFormActive} + questionFormSubmitDisabled={currentConversationActionDisabled} + questionFormSubmittedAnswers={questionFormSubmittedAnswers} + questionsGenerating={questionsGenerating} + focusQuestionsRequest={focusQuestionsRequest} + onSubmitQuestionForm={(text) => { + if (currentConversationActionDisabled) return; + void handleSend(text, [], []); + }} /> {projectActionsToast ? ( diff --git a/apps/web/src/components/QuestionForm.tsx b/apps/web/src/components/QuestionForm.tsx index 68b42ccfc..995209ae0 100644 --- a/apps/web/src/components/QuestionForm.tsx +++ b/apps/web/src/components/QuestionForm.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { useT } from '../i18n'; import type { DirectionCard, FormOption, QuestionForm } from '../artifacts/question-form'; import { formatFormAnswers, formOptionValueForLabel } from '../artifacts/question-form'; @@ -13,16 +13,52 @@ interface Props { // begins with "[form answers — ]", we parse it back out and pass it // here so the rendered form reflects what was sent. submittedAnswers?: Record; + // When the form lives in the Questions tab the Continue button owns the + // submit, so hide the form's own footer button and report ready-state out. + hideInternalSubmit?: boolean; + onReadyChange?: (ready: boolean) => void; onSubmit?: (text: string, answers: Record) => void; } -export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit }: Props) { +// Lets a parent (the Questions tab Continue button) trigger submission. +export interface QuestionFormHandle { + submit: () => void; + // Submit with no answers — backs the "skip all" affordance. Every question + // is optional, so this just records each as "(skipped)" and moves on. + skipAll: () => void; +} + +export const QuestionFormView = forwardRef(function QuestionFormView( + { form, interactive, submittedAnswers, hideInternalSubmit = false, onReadyChange, onSubmit }, + ref, +) { const t = useT(); const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]); const [answers, setAnswers] = useState>(initial); const locked = !interactive || !onSubmit || submittedAnswers !== undefined; const currentAnswers = submittedAnswers ?? answers; + // When the form streams in question-by-question, backfill state for newly + // revealed questions without disturbing answers the user already touched. + useEffect(() => { + setAnswers((prev) => { + let changed = false; + const next = { ...prev }; + for (const q of form.questions) { + if (next[q.id] !== undefined) continue; + changed = true; + if (submittedAnswers && submittedAnswers[q.id] !== undefined) { + next[q.id] = canonicalizeQuestionValue(q, submittedAnswers[q.id]!); + } else if (q.defaultValue !== undefined) { + next[q.id] = canonicalizeQuestionValue(q, q.defaultValue); + } else { + next[q.id] = q.type === 'checkbox' ? [] : ''; + } + } + return changed ? next : prev; + }); + }, [form, submittedAnswers]); + function update(id: string, value: string | string[]) { if (locked) return; setAnswers((prev) => ({ ...prev, [id]: value })); @@ -41,39 +77,31 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit }); } - function missingRequired(): string | null { - for (const q of form.questions) { - if (!q.required) continue; - const v = currentAnswers[q.id]; - if (Array.isArray(v) ? v.length === 0 : !(typeof v === 'string' && v.trim().length > 0)) { - return q.label; - } - } - return null; - } - function handleSubmit() { if (locked || !onSubmit) return; if (!withinSelectionLimits) return; - const missing = missingRequired(); - if (missing) { - // Soft inline guard — surface via aria but don't alert; the disabled - // state of the submit button covers most cases. - return; - } onSubmit(formatFormAnswers(form, answers), answers); } - const required = form.questions.filter((q) => q.required); + function handleSkipAll() { + if (locked || !onSubmit) return; + const empty: Record = {}; + onSubmit(formatFormAnswers(form, empty), empty); + } + + // Every question is optional; the only hard constraint is per-question + // checkbox selection caps. "Ready" therefore just means we're within those. const withinSelectionLimits = form.questions.every((q) => { if (q.type !== 'checkbox' || q.maxSelections === undefined) return true; const v = currentAnswers[q.id]; return !Array.isArray(v) || v.length <= q.maxSelections; }); - const ready = withinSelectionLimits && required.every((q) => { - const v = currentAnswers[q.id]; - return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0; - }); + const ready = withinSelectionLimits; + + useImperativeHandle(ref, () => ({ submit: handleSubmit, skipAll: handleSkipAll })); + useEffect(() => { + onReadyChange?.(!locked && ready); + }, [onReadyChange, locked, ready]); return (
@@ -94,9 +122,6 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
{q.help ?
{q.help}
: null} {q.type === 'radio' && q.options ? ( @@ -204,29 +229,31 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit ); })}
-
- {locked ? ( - - {submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')} - - ) : ( - {t('qf.hint')} - )} - {!locked ? ( - - ) : null} -
+ {hideInternalSubmit ? null : ( +
+ {locked ? ( + + {submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')} + + ) : ( + {t('qf.hint')} + )} + {!locked ? ( + + ) : null} +
+ )}
); -} +}); function OptionCopy({ option }: { option: FormOption }) { return ( diff --git a/apps/web/src/components/QuestionsPanel.tsx b/apps/web/src/components/QuestionsPanel.tsx new file mode 100644 index 000000000..c245a4b1f --- /dev/null +++ b/apps/web/src/components/QuestionsPanel.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState } from 'react'; +import { useT } from '../i18n'; +import type { QuestionForm } from '../artifacts/question-form'; +import { QuestionFormView, type QuestionFormHandle } from './QuestionForm'; + +// Surface one new question every this many ms. The agent often emits the whole +// form artifact in a single chunk, so we can't rely on the parse count +// trickling in — we always play this reveal client-side so the frame shows +// first and each question slides in after it. +const REVEAL_INTERVAL_MS = 280; + +// Form occurrences whose one-by-one reveal has already played to completion. +// The Questions tab is conditionally mounted, so when the streaming assistant +// message is reconciled to its persisted copy the tab momentarily loses the +// form, unmounts the panel, then re-focuses and remounts it — which would +// otherwise reset `revealed` to 0 and replay the whole animation. Keyed by the +// host's stable per-occurrence id so a fresh form (new conversation) still +// animates while the same form never re-animates. +const revealedOccurrences = new Set(); + +// Once the form is actionable, the user has this long before we auto-continue +// for them — submitting whatever they picked (unanswered questions count as +// skipped) so generation never stalls waiting on a reply. +const SKIP_COUNTDOWN_SECONDS = 120; + +interface Props { + form: QuestionForm | null; + // Stable id for this form occurrence. Lets the reveal survive a remount + // (see `revealedOccurrences`) without re-animating. + formKey?: string | null; + // Whether the form is the active, unanswered one — it stays editable while + // streaming and while the turn is busy, so it never flickers locked/unlocked. + interactive: boolean; + // The turn is busy (streaming/queued); keep Continue/Skip disabled while the + // form itself stays editable. + submitDisabled?: boolean; + submittedAnswers?: Record; + // The assistant turn is still streaming the form — keep Continue disabled + // and show the generating hint. + generating: boolean; + onSubmit: (text: string) => void; +} + +export function QuestionsPanel({ + form, + formKey = null, + interactive, + submitDisabled = false, + submittedAnswers, + generating, + onSubmit, +}: Props) { + const t = useT(); + const formRef = useRef(null); + const [ready, setReady] = useState(false); + + const total = form?.questions.length ?? 0; + const answered = submittedAnswers !== undefined; + // If this occurrence already finished its reveal in a prior mount, show it in + // full immediately rather than replaying the animation on remount. + const [revealed, setRevealed] = useState(() => + formKey && revealedOccurrences.has(formKey) ? total : 0, + ); + + // Tick the visible question count up to the total, one at a time. This runs + // regardless of whether the questions arrived incrementally or in one burst, + // so the build-up is always visible. An already-answered (historical) form + // shows everything at once — no reason to re-animate something the user sent. + useEffect(() => { + if (answered) { + setRevealed(total); + return; + } + if (revealed >= total) { + // Reveal finished — remember it so a remount of this same occurrence + // shows the form in full instead of animating again. + if (formKey && total > 0) revealedOccurrences.add(formKey); + return; + } + const id = window.setTimeout( + () => setRevealed((n) => Math.min(n + 1, total)), + REVEAL_INTERVAL_MS, + ); + return () => window.clearTimeout(id); + }, [answered, total, revealed, formKey]); + + const fullyRevealed = revealed >= total; + const visibleCount = answered ? total : Math.min(revealed, total); + const visibleForm = form + ? { ...form, questions: form.questions.slice(0, visibleCount) } + : null; + // Still producing: the turn is streaming, OR we're mid reveal animation. + const building = generating || (!answered && !fullyRevealed); + + // Every question is optional, so submission only needs the form present, + // active, fully revealed, and not blocked by a busy/streaming turn. + const canSubmit = !!form && interactive && !building && !submitDisabled; + const canContinue = canSubmit && ready; + const canSkip = canSubmit; + + // Auto-skip countdown. It only runs while the form is actionable; pausing + // (busy turn, re-stream) resets it so we never auto-submit a half-ready form. + const [remaining, setRemaining] = useState(SKIP_COUNTDOWN_SECONDS); + const autoFiredRef = useRef(false); + + useEffect(() => { + if (!canSubmit) { + setRemaining(SKIP_COUNTDOWN_SECONDS); + autoFiredRef.current = false; + return; + } + const id = window.setInterval(() => { + setRemaining((s) => Math.max(0, s - 1)); + }, 1000); + return () => window.clearInterval(id); + }, [canSubmit]); + + // When the countdown elapses, continue with the current selections (anything + // untouched submits as skipped) and let generation proceed. + useEffect(() => { + if (canSubmit && remaining <= 0 && !autoFiredRef.current) { + autoFiredRef.current = true; + // Honour the user's picks when the form is submittable; otherwise fall + // back to skipping so a stray selection-cap can't stall generation. + if (ready) formRef.current?.submit(); + else formRef.current?.skipAll(); + } + }, [canSubmit, ready, remaining]); + + const countdown = `${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`; + + return ( +
+
+ {visibleForm ? ( + <> + onSubmit(text)} + /> + {building ? ( +
+ + + +
+ ) : null} + + ) : ( +
{t('questions.generating')}
+ )} +
+
+ + {building + ? t('questions.generating') + : canSkip + ? t('questions.autoSkipHint') + : null} + + + +
+
+ ); +} diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index f2cd3cd78..3591bc88e 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -2098,6 +2098,12 @@ export const en: Dict = { 'qf.cardSelected': 'selected', 'qf.cardRefs': 'Refs:', 'qf.cardSampleText': 'The quick brown fox · 0123', + 'questions.tabLabel': 'Questions', + 'questions.banner': 'Mind if I ask a couple of quick questions?', + 'questions.continue': 'Continue', + 'questions.generating': 'Generating questions…', + 'questions.skipAll': 'Skip all', + 'questions.autoSkipHint': 'Auto-continues when the timer ends', 'sketch.toolSelect': 'Select (no-op)', 'sketch.toolPen': 'Pen', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 115f85b82..9107da218 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -2048,6 +2048,12 @@ export const zhCN: Dict = { 'qf.cardSelected': '已选', 'qf.cardRefs': '参考:', 'qf.cardSampleText': '飞燕环宇 · 0123', + 'questions.tabLabel': '问题', + 'questions.banner': '想先跟你确认几个小问题~', + 'questions.continue': '继续', + 'questions.generating': '正在生成问题…', + 'questions.skipAll': '一键跳过', + 'questions.autoSkipHint': '倒计时结束后将自动继续', 'sketch.toolSelect': '选择(占位)', 'sketch.toolPen': '钢笔', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 967c33ef3..c1ae9e788 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -2427,6 +2427,12 @@ export interface Dict { 'qf.cardSelected': string; 'qf.cardRefs': string; 'qf.cardSampleText': string; + 'questions.tabLabel': string; + 'questions.banner': string; + 'questions.continue': string; + 'questions.generating': string; + 'questions.skipAll': string; + 'questions.autoSkipHint': string; // Pet (Codex-style floating companion) 'pet.title': string; diff --git a/apps/web/src/styles/viewer/composio.css b/apps/web/src/styles/viewer/composio.css index 105383c70..0de502e1e 100644 --- a/apps/web/src/styles/viewer/composio.css +++ b/apps/web/src/styles/viewer/composio.css @@ -1065,6 +1065,232 @@ opacity: 0.7; } +/* Chat-side banner pointing to the right-hand Questions tab. The active + discovery form renders in that tab (Claude-Design style); in chat we only + show this affordance. */ +.questions-banner { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + margin: 8px 0; + padding: 12px 14px; + text-align: left; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--accent-tint) 0%, var(--bg-panel) 100%); + box-shadow: var(--shadow-md); + cursor: pointer; + color: var(--text); + transition: border-color 120ms ease, box-shadow 120ms ease; +} +.questions-banner:hover { + border-color: var(--accent); +} +.questions-banner:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.questions-banner-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 999px; + background: var(--accent); + color: white; + flex-shrink: 0; +} +.questions-banner-label { + flex: 1; + min-width: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} +.questions-banner-cta { + display: inline-flex; + align-items: center; + color: var(--text-muted); + flex-shrink: 0; +} + +/* Right-hand Questions tab panel: the form fills the body, a sticky footer + carries the Continue button (disabled while the form streams in). */ +.questions-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +.questions-panel-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 20px; +} +.questions-panel-body .question-form { + margin: 0; + box-shadow: var(--shadow-md); +} +/* The frame appears first, then questions stream in one by one — each newly + revealed field slides up and fades so the build-up reads as live. */ +.questions-panel-body .question-form-body { + gap: 18px; + padding: 18px 16px; +} +.questions-panel-body .qf-field { + animation: qf-field-in 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} +@keyframes qf-field-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +/* Typing indicator at the tail of the body while more questions are streaming. */ +.questions-panel-typing { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 18px 2px; +} +.questions-panel-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--accent); + opacity: 0.3; + animation: questions-dot 1.1s ease-in-out infinite; +} +.questions-panel-dot:nth-child(2) { + animation-delay: 0.16s; +} +.questions-panel-dot:nth-child(3) { + animation-delay: 0.32s; +} +@keyframes questions-dot { + 0%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 50% { + opacity: 0.85; + transform: translateY(-3px); + } +} +@media (prefers-reduced-motion: reduce) { + .questions-panel-body .qf-field { + animation: none; + } + .questions-panel-dot { + animation: none; + opacity: 0.55; + } +} +.questions-panel-skeleton { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 13px; + color: var(--text-muted); +} +/* The panel owns the dedicated tab, so give its frame header a touch more + presence than the inline chat form. */ +.questions-panel-body .question-form-head { + padding: 16px 18px; +} +.questions-panel-body .question-form-title { + font-size: 15px; +} +.questions-panel-body .question-form-icon { + width: 30px; + height: 30px; + font-size: 15px; +} +.questions-panel-foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 14px 16px; + border-top: 1px solid var(--border); + background: var(--bg-panel); + box-shadow: 0 -8px 16px -12px rgba(0, 0, 0, 0.18); +} +.questions-panel-status { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--text-muted); +} +.questions-skip, +.questions-continue { + flex-shrink: 0; + padding: 10px 22px; + font-size: 14px; + font-weight: 600; + border-radius: 10px; + cursor: pointer; + transition: + transform 160ms cubic-bezier(0.23, 1, 0.32, 1), + box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1), + background 160ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 160ms cubic-bezier(0.23, 1, 0.32, 1); +} +.questions-skip { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg-panel); + border: 1px solid var(--border); + color: var(--text); +} +.questions-skip-timer { + font-family: ui-monospace, 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 1px 7px; + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-muted); +} +.questions-skip:disabled .questions-skip-timer { + opacity: 0.7; +} +.questions-skip:hover:not(:disabled) { + background: var(--bg-subtle); + border-color: var(--text-muted); +} +.questions-skip:disabled { + opacity: 0.45; + cursor: default; +} +.questions-continue { + border: 1px solid transparent; + color: #fff; + background: var(--accent); + box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 34%, transparent); +} +.questions-continue:hover:not(:disabled) { + transform: translateY(-1px); + background: var(--accent-hover); + box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 44%, transparent); +} +.questions-continue:disabled { + box-shadow: none; + opacity: 0.5; + cursor: default; +} + /* Design-system preview modal */ .ds-modal-backdrop { position: fixed; diff --git a/apps/web/tests/components/AssistantMessage.test.tsx b/apps/web/tests/components/AssistantMessage.test.tsx index d96ca1afa..a6c3c31c7 100644 --- a/apps/web/tests/components/AssistantMessage.test.tsx +++ b/apps/web/tests/components/AssistantMessage.test.tsx @@ -249,6 +249,9 @@ describe('AssistantMessage question forms', () => { '
', ].join('\n'); + // Render as a historical (not-last) message so the forms display inline — + // the active/last form now surfaces as a banner that points to the + // right-hand Questions tab, so dedup is only observable on inline forms. render( { })} streaming={false} projectId="proj-1" - isLast + isLast={false} />, ); diff --git a/apps/web/tests/components/QuestionForm.test.tsx b/apps/web/tests/components/QuestionForm.test.tsx index 9ca51de06..3e16e8cbb 100644 --- a/apps/web/tests/components/QuestionForm.test.tsx +++ b/apps/web/tests/components/QuestionForm.test.tsx @@ -201,8 +201,11 @@ describe('QuestionFormView', () => { , ); + // Every question is now optional (the Questions tab owns submission and + // offers skip-all), so the form never blocks submit on an unanswered + // required field — only on a checkbox selection-cap overflow. const submit = screen.getByRole('button', { name: 'Send answers' }); - expect((submit as HTMLButtonElement).disabled).toBe(true); + expect((submit as HTMLButtonElement).disabled).toBe(false); fireEvent.click(screen.getByLabelText('Editorial / magazine')); fireEvent.click(screen.getByLabelText('Soft gradients')); @@ -226,8 +229,9 @@ describe('QuestionFormView', () => { , ); + // Optional-by-default: an unanswered required select no longer disables submit. const submit = screen.getByRole('button', { name: 'Send answers' }); - expect((submit as HTMLButtonElement).disabled).toBe(true); + expect((submit as HTMLButtonElement).disabled).toBe(false); const select = container.querySelector('select'); if (!select) throw new Error('expected select control'); diff --git a/apps/web/tests/components/QuestionsPanel.reveal.test.tsx b/apps/web/tests/components/QuestionsPanel.reveal.test.tsx new file mode 100644 index 000000000..7e8c9d5b7 --- /dev/null +++ b/apps/web/tests/components/QuestionsPanel.reveal.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { QuestionsPanel } from '../../src/components/QuestionsPanel'; +import type { QuestionForm } from '../../src/artifacts/question-form'; + +const form: QuestionForm = { + id: 'discovery', + title: 'A few quick questions', + questions: [ + { id: 'q1', label: 'What is it about?', type: 'text' }, + { id: 'q2', label: 'Who is the audience?', type: 'text' }, + { id: 'q3', label: 'How long?', type: 'text' }, + { id: 'q4', label: 'What style?', type: 'text' }, + ], +}; + +function fieldCount() { + return document.querySelectorAll('.qf-field').length; +} + +// Each reveal schedules the next only after its effect re-runs, so the clock +// must be stepped one interval per question rather than all at once. +function revealAll() { + for (let i = 0; i < form.questions.length; i++) { + act(() => { + vi.advanceTimersByTime(280); + }); + } +} + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('QuestionsPanel staggered reveal', () => { + it('reveals questions one-by-one even when the complete form arrives at once', () => { + vi.useFakeTimers(); + act(() => { + render( + {}} + />, + ); + }); + + // Frame first: the title is present but no questions yet. + expect(document.querySelector('.question-form-title')?.textContent).toBe( + 'A few quick questions', + ); + expect(fieldCount()).toBe(0); + + // Each interval surfaces exactly one more question. + act(() => { + vi.advanceTimersByTime(280); + }); + expect(fieldCount()).toBe(1); + + act(() => { + vi.advanceTimersByTime(280); + }); + expect(fieldCount()).toBe(2); + + // One interval at a time — each reveal schedules the next after its effect + // re-runs, so we step the clock once per question. + act(() => { + vi.advanceTimersByTime(280); + }); + expect(fieldCount()).toBe(3); + + act(() => { + vi.advanceTimersByTime(280); + }); + expect(fieldCount()).toBe(4); + + // Stays capped at the total — no overshoot. + act(() => { + vi.advanceTimersByTime(280 * 3); + }); + expect(fieldCount()).toBe(4); + }); + + it('does not replay the reveal when the same occurrence remounts', () => { + vi.useFakeTimers(); + const props = { + form, + formKey: 'remount-test:discovery', + interactive: true, + generating: false, + onSubmit: () => {}, + } as const; + + const { unmount } = render(); + revealAll(); + expect(fieldCount()).toBe(4); + + // The streaming→persisted swap unmounts the panel and re-focuses the tab, + // remounting it. The same occurrence must come back fully revealed. + unmount(); + act(() => { + render(); + }); + expect(fieldCount()).toBe(4); + }); + + it('still animates a different occurrence after one has completed', () => { + vi.useFakeTimers(); + const base = { + form, + interactive: true, + generating: false, + onSubmit: () => {}, + } as const; + + const first = render(); + revealAll(); + expect(fieldCount()).toBe(4); + first.unmount(); + + // A brand-new form in another conversation has its own key, so the reveal + // plays again from the frame. + act(() => { + render(); + }); + expect(fieldCount()).toBe(0); + act(() => { + vi.advanceTimersByTime(280); + }); + expect(fieldCount()).toBe(1); + }); + + it('shows an already-answered form in full immediately (no re-animation)', () => { + vi.useFakeTimers(); + act(() => { + render( + {}} + />, + ); + }); + expect(fieldCount()).toBe(4); + }); +});