mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
53fb175855
commit
9bb787e173
14 changed files with 1076 additions and 98 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
186
apps/web/src/components/QuestionsPanel.tsx
Normal file
186
apps/web/src/components/QuestionsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '钢笔',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
152
apps/web/tests/components/QuestionsPanel.reveal.test.tsx
Normal file
152
apps/web/tests/components/QuestionsPanel.reveal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue