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.
This commit is contained in:
qiongyu1999 2026-05-31 14:02:42 +08:00
parent 53fb175855
commit 9bb787e173
14 changed files with 1076 additions and 98 deletions

View file

@ -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 `<question-form>{…` 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 = `</${tagName}>`;
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<string, string>): QuestionForm
if (!rawQuestions) return null;
const questions: FormQuestion[] = [];
rawQuestions.forEach((q, i) => {
if (!q || typeof q !== 'object') return;
const qo = q as Record<string, unknown>;
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<string, string>): QuestionForm
};
}
function mapRawQuestion(q: unknown, index: number): FormQuestion | null {
if (!q || typeof q !== 'object') return null;
const qo = q as Record<string, unknown>;
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 `<question-form>` 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 = `</${tagName}>`;
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();

View file

@ -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<string>;
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 `<question-form>{…` 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 ? <QuestionsBanner onOpen={onOpenQuestions} /> : null}
</div>
);
}
// 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 (
<button
type="button"
className="questions-banner"
data-testid="questions-banner"
onClick={() => onOpen?.()}
>
<span className="questions-banner-icon" aria-hidden>
<Icon name="help-circle" size={15} />
</span>
<span className="questions-banner-label">{t("questions.banner")}</span>
<span className="questions-banner-cta" aria-hidden>
<Icon name="chevron-right" size={14} />
</span>
</button>
);
}
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<string>;
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 <QuestionsBanner onOpen={onOpenQuestions} />;
}
return (
<QuestionFormView
form={form}
interactive={interactive}
interactive={false}
submittedAnswers={submittedFromHistory ?? undefined}
onSubmit={(text) => onSubmitForm(form.id, text)}
/>

View file

@ -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)

View file

@ -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<string, string | string[]>;
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<ProjectMetadata['designSystemReview']>[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<HTMLElement>('.ws-tab.active');
@ -793,7 +843,7 @@ export function FileWorkspace({
}
const activeFile = useMemo<ProjectFile | null>(() => {
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<LiveArtifactWorkspaceEntry | null>(() => {
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({
</span>
<span className="ws-tab-label">{t('workspace.designFiles')}</span>
</button>
{showQuestionsTab ? (
<button
type="button"
className={`ws-tab questions-tab ${activeTab === QUESTIONS_TAB ? 'active' : ''}`}
role="tab"
aria-selected={activeTab === QUESTIONS_TAB}
tabIndex={0}
data-testid="questions-tab"
onClick={() => setActiveTab(QUESTIONS_TAB)}
title={t('questions.tabLabel')}
>
<span className="tab-icon" aria-hidden>
<Icon name="help-circle" size={13} />
</span>
<span className="ws-tab-label">{t('questions.tabLabel')}</span>
</button>
) : null}
{tabNames.map((name) => {
const sketchEntry = sketches[name];
const dirtyMark =
@ -979,7 +1046,18 @@ export function FileWorkspace({
</button>
</div>
) : null}
{activeTab === DESIGN_SYSTEM_TAB && designSystemProject ? (
{activeTab === QUESTIONS_TAB ? (
<QuestionsPanel
key={questionFormKey ?? undefined}
formKey={questionFormKey}
form={questionForm ?? questionFormPreview}
interactive={questionFormInteractive}
submitDisabled={questionFormSubmitDisabled}
submittedAnswers={questionFormSubmittedAnswers}
generating={questionsGenerating}
onSubmit={(text) => onSubmitQuestionForm?.(text)}
/>
) : activeTab === DESIGN_SYSTEM_TAB && designSystemProject ? (
<DesignSystemProjectPanel
projectId={projectId}
system={designSystemProject}

View file

@ -13,6 +13,13 @@ import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/ma
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
import { validateHtmlArtifact } from '../artifacts/validate';
import { createArtifactParser } from '../artifacts/parser';
import {
findFirstQuestionForm,
hasUnterminatedQuestionForm,
parsePartialQuestionForm,
type QuestionForm,
} from '../artifacts/question-form';
import { parseSubmittedAnswers } from './QuestionForm';
import { useI18n } from '../i18n';
import { streamMessage } from '../providers/anthropic';
import {
@ -743,6 +750,78 @@ export function ProjectView({
|| failedMessagesConversationId === activeConversationId
|| currentConversationAwaitingActiveRunAttach;
const currentConversationActionDisabled = currentConversationBusy || currentConversationSendDisabled;
// The discovery question form lives in the right-hand Questions tab. We
// derive it from the latest assistant message: if that message embeds a
// <question-form> 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, [], []);
}}
/>
</div>
{projectActionsToast ? (

View file

@ -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 — <id>]", we parse it back out and pass it
// here so the rendered form reflects what was sent.
submittedAnswers?: Record<string, string | string[]>;
// 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<string, string | string[]>) => 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<QuestionFormHandle, Props>(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<Record<string, string | string[]>>(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<string, string | string[]> = {};
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 (
<div className={`question-form${locked ? ' question-form-locked' : ''}`} data-form-id={form.id}>
@ -94,9 +122,6 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
<div key={q.id} className="qf-field">
<label className="qf-label">
<span>{q.label}</span>
{q.required ? (
<span className="qf-required" aria-label={t('qf.required')}>*</span>
) : null}
</label>
{q.help ? <div className="qf-help">{q.help}</div> : null}
{q.type === 'radio' && q.options ? (
@ -204,29 +229,31 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
);
})}
</div>
<div className="question-form-foot">
{locked ? (
<span className="qf-locked-note">
{submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')}
</span>
) : (
<span className="qf-hint">{t('qf.hint')}</span>
)}
{!locked ? (
<button
type="button"
className="primary"
onClick={handleSubmit}
disabled={!ready}
title={ready ? t('qf.submitTitle') : t('qf.submitDisabledTitle')}
>
{form.submitLabel ?? t('qf.submitDefault')}
</button>
) : null}
</div>
{hideInternalSubmit ? null : (
<div className="question-form-foot">
{locked ? (
<span className="qf-locked-note">
{submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')}
</span>
) : (
<span className="qf-hint">{t('qf.hint')}</span>
)}
{!locked ? (
<button
type="button"
className="primary"
onClick={handleSubmit}
disabled={!ready}
title={ready ? t('qf.submitTitle') : t('qf.submitDisabledTitle')}
>
{form.submitLabel ?? t('qf.submitDefault')}
</button>
) : null}
</div>
)}
</div>
);
}
});
function OptionCopy({ option }: { option: FormOption }) {
return (

View file

@ -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<string>();
// 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<string, string | string[]>;
// 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<QuestionFormHandle>(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 (
<div className="questions-panel" data-testid="questions-panel">
<div className="questions-panel-body">
{visibleForm ? (
<>
<QuestionFormView
ref={formRef}
form={visibleForm}
interactive={interactive}
submittedAnswers={submittedAnswers}
hideInternalSubmit
onReadyChange={setReady}
onSubmit={(text) => onSubmit(text)}
/>
{building ? (
<div className="questions-panel-typing" aria-hidden>
<span className="questions-panel-dot" />
<span className="questions-panel-dot" />
<span className="questions-panel-dot" />
</div>
) : null}
</>
) : (
<div className="questions-panel-skeleton">{t('questions.generating')}</div>
)}
</div>
<div className="questions-panel-foot">
<span className="questions-panel-status">
{building
? t('questions.generating')
: canSkip
? t('questions.autoSkipHint')
: null}
</span>
<button
type="button"
className="questions-skip"
disabled={!canSkip}
onClick={() => formRef.current?.skipAll()}
>
{t('questions.skipAll')}
{canSkip ? <span className="questions-skip-timer">{countdown}</span> : null}
</button>
<button
type="button"
className="questions-continue"
disabled={!canContinue}
onClick={() => formRef.current?.submit()}
>
{t('questions.continue')}
</button>
</div>
</div>
);
}

View file

@ -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',

View file

@ -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': '钢笔',

View file

@ -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;

View file

@ -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;

View file

@ -249,6 +249,9 @@ describe('AssistantMessage question forms', () => {
'</question-form>',
].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(
<AssistantMessage
message={baseMessage({
@ -261,7 +264,7 @@ describe('AssistantMessage question forms', () => {
})}
streaming={false}
projectId="proj-1"
isLast
isLast={false}
/>,
);

View file

@ -201,8 +201,11 @@ describe('QuestionFormView', () => {
<QuestionFormView form={checkboxObjectForm} interactive onSubmit={onSubmit} />,
);
// 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', () => {
<QuestionFormView form={selectObjectForm} interactive onSubmit={onSubmit} />,
);
// 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');

View file

@ -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(
<QuestionsPanel
form={form}
interactive
generating={false}
onSubmit={() => {}}
/>,
);
});
// 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(<QuestionsPanel {...props} />);
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(<QuestionsPanel {...props} />);
});
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(<QuestionsPanel {...base} formKey="distinct-a:discovery" />);
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(<QuestionsPanel {...base} formKey="distinct-b:discovery" />);
});
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(
<QuestionsPanel
form={form}
interactive={false}
generating={false}
submittedAnswers={{ q1: 'x', q2: 'y', q3: 'z', q4: 'w' }}
onSubmit={() => {}}
/>,
);
});
expect(fieldCount()).toBe(4);
});
});