mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +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;
|
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 {
|
function findCloseTag(input: string, from: number, closeTag: string): number {
|
||||||
const closeLower = closeTag.toLowerCase();
|
const closeLower = closeTag.toLowerCase();
|
||||||
const tagLen = closeTag.length;
|
const tagLen = closeTag.length;
|
||||||
|
|
@ -181,38 +223,8 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
|
||||||
if (!rawQuestions) return null;
|
if (!rawQuestions) return null;
|
||||||
const questions: FormQuestion[] = [];
|
const questions: FormQuestion[] = [];
|
||||||
rawQuestions.forEach((q, i) => {
|
rawQuestions.forEach((q, i) => {
|
||||||
if (!q || typeof q !== 'object') return;
|
const mapped = mapRawQuestion(q, i);
|
||||||
const qo = q as Record<string, unknown>;
|
if (mapped) questions.push(mapped);
|
||||||
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 } : {}),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
if (questions.length === 0) return null;
|
if (questions.length === 0) return null;
|
||||||
const id = attrs.id ?? (typeof obj.id === 'string' ? obj.id : 'discovery');
|
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 {
|
function normalizeType(raw: unknown): QuestionType {
|
||||||
if (typeof raw !== 'string') return 'text';
|
if (typeof raw !== 'string') return 'text';
|
||||||
const lower = raw.toLowerCase().trim();
|
const lower = raw.toLowerCase().trim();
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
} from "@open-design/contracts/analytics";
|
} from "@open-design/contracts/analytics";
|
||||||
import {
|
import {
|
||||||
splitOnQuestionForms,
|
splitOnQuestionForms,
|
||||||
|
stripTrailingOpenQuestionForm,
|
||||||
type QuestionForm,
|
type QuestionForm,
|
||||||
} from "../artifacts/question-form";
|
} from "../artifacts/question-form";
|
||||||
import { stripArtifact } from "../artifacts/strip";
|
import { stripArtifact } from "../artifacts/strip";
|
||||||
|
|
@ -311,6 +312,10 @@ interface Props {
|
||||||
// Submit handler the form fires when the user picks answers — opaque
|
// Submit handler the form fires when the user picks answers — opaque
|
||||||
// to AssistantMessage; ProjectView wires it into onSend.
|
// to AssistantMessage; ProjectView wires it into onSend.
|
||||||
onSubmitForm?: (text: string) => void;
|
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;
|
onContinueRemainingTasks?: (todos: TodoItem[]) => void;
|
||||||
onFeedback?: (change: ChatMessageFeedbackChange) => void;
|
onFeedback?: (change: ChatMessageFeedbackChange) => void;
|
||||||
suppressDirectionForms?: boolean;
|
suppressDirectionForms?: boolean;
|
||||||
|
|
@ -342,6 +347,7 @@ export function AssistantMessage({
|
||||||
errorCardOwnerId = null,
|
errorCardOwnerId = null,
|
||||||
nextUserContent,
|
nextUserContent,
|
||||||
onSubmitForm,
|
onSubmitForm,
|
||||||
|
onOpenQuestions,
|
||||||
onContinueRemainingTasks,
|
onContinueRemainingTasks,
|
||||||
onFeedback,
|
onFeedback,
|
||||||
suppressDirectionForms = false,
|
suppressDirectionForms = false,
|
||||||
|
|
@ -540,6 +546,7 @@ export function AssistantMessage({
|
||||||
});
|
});
|
||||||
onSubmitForm?.(text);
|
onSubmitForm?.(text);
|
||||||
}}
|
}}
|
||||||
|
onOpenQuestions={onOpenQuestions}
|
||||||
onRequestOpenFile={onRequestOpenFile}
|
onRequestOpenFile={onRequestOpenFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -1668,6 +1675,7 @@ function ProseBlock({
|
||||||
locallySubmitted,
|
locallySubmitted,
|
||||||
suppressDirectionForms,
|
suppressDirectionForms,
|
||||||
onSubmitForm,
|
onSubmitForm,
|
||||||
|
onOpenQuestions,
|
||||||
onRequestOpenFile,
|
onRequestOpenFile,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -1677,10 +1685,22 @@ function ProseBlock({
|
||||||
locallySubmitted: Set<string>;
|
locallySubmitted: Set<string>;
|
||||||
suppressDirectionForms: boolean;
|
suppressDirectionForms: boolean;
|
||||||
onSubmitForm: (formId: string, text: string) => void;
|
onSubmitForm: (formId: string, text: string) => void;
|
||||||
|
onOpenQuestions?: () => void;
|
||||||
onRequestOpenFile?: (name: string) => void;
|
onRequestOpenFile?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const cleaned = useMemo(() => stripArtifact(text), [text]);
|
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`)
|
// Route relative file-link clicks (`template.html`, `subdir/hero.html`)
|
||||||
// through the workspace tab opener. Without this, Electron's window-open
|
// through the workspace tab opener. Without this, Electron's window-open
|
||||||
// handler creates a new app window whose relative href can't resolve, and
|
// handler creates a new app window whose relative href can't resolve, and
|
||||||
|
|
@ -1749,17 +1769,40 @@ function ProseBlock({
|
||||||
key={seg.key}
|
key={seg.key}
|
||||||
form={seg.form}
|
form={seg.form}
|
||||||
isLastAssistant={isLastAssistant}
|
isLastAssistant={isLastAssistant}
|
||||||
streaming={streaming}
|
|
||||||
nextUserContent={nextUserContent}
|
nextUserContent={nextUserContent}
|
||||||
locallySubmitted={locallySubmitted}
|
locallySubmitted={locallySubmitted}
|
||||||
onSubmitForm={onSubmitForm}
|
onSubmitForm={onSubmitForm}
|
||||||
|
onOpenQuestions={onOpenQuestions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{hadOpenForm ? <QuestionsBanner onOpen={onOpenQuestions} /> : null}
|
||||||
</div>
|
</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 {
|
function isDirectionForm(form: QuestionForm): boolean {
|
||||||
if (form.id.toLowerCase() === "direction") return true;
|
if (form.id.toLowerCase() === "direction") return true;
|
||||||
if (form.title.toLowerCase().includes("visual direction")) return true;
|
if (form.title.toLowerCase().includes("visual direction")) return true;
|
||||||
|
|
@ -1769,17 +1812,17 @@ function isDirectionForm(form: QuestionForm): boolean {
|
||||||
function FormBlock({
|
function FormBlock({
|
||||||
form,
|
form,
|
||||||
isLastAssistant,
|
isLastAssistant,
|
||||||
streaming,
|
|
||||||
nextUserContent,
|
nextUserContent,
|
||||||
locallySubmitted,
|
locallySubmitted,
|
||||||
onSubmitForm,
|
onSubmitForm,
|
||||||
|
onOpenQuestions,
|
||||||
}: {
|
}: {
|
||||||
form: QuestionForm;
|
form: QuestionForm;
|
||||||
isLastAssistant: boolean;
|
isLastAssistant: boolean;
|
||||||
streaming: boolean;
|
|
||||||
nextUserContent?: string;
|
nextUserContent?: string;
|
||||||
locallySubmitted: Set<string>;
|
locallySubmitted: Set<string>;
|
||||||
onSubmitForm: (formId: string, text: string) => void;
|
onSubmitForm: (formId: string, text: string) => void;
|
||||||
|
onOpenQuestions?: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Reconstruct prior answers from a follow-up user message so older
|
// Reconstruct prior answers from a follow-up user message so older
|
||||||
// forms in the scrollback render in their answered state.
|
// forms in the scrollback render in their answered state.
|
||||||
|
|
@ -1788,15 +1831,18 @@ function FormBlock({
|
||||||
return parseSubmittedAnswers(form, nextUserContent);
|
return parseSubmittedAnswers(form, nextUserContent);
|
||||||
}, [form, nextUserContent]);
|
}, [form, nextUserContent]);
|
||||||
const wasSubmittedLocally = locallySubmitted.has(form.id);
|
const wasSubmittedLocally = locallySubmitted.has(form.id);
|
||||||
const interactive =
|
// The live, still-unanswered form lives in the right-hand Questions tab —
|
||||||
isLastAssistant &&
|
// even mid-stream. In chat we only show a banner that focuses it, so the
|
||||||
!streaming &&
|
// left side never renders the form itself. Answered / historical forms stay
|
||||||
!submittedFromHistory &&
|
// inline so the scrollback reads naturally.
|
||||||
!wasSubmittedLocally;
|
const showBanner = isLastAssistant && !submittedFromHistory && !wasSubmittedLocally;
|
||||||
|
if (showBanner) {
|
||||||
|
return <QuestionsBanner onOpen={onOpenQuestions} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<QuestionFormView
|
<QuestionFormView
|
||||||
form={form}
|
form={form}
|
||||||
interactive={interactive}
|
interactive={false}
|
||||||
submittedAnswers={submittedFromHistory ?? undefined}
|
submittedAnswers={submittedFromHistory ?? undefined}
|
||||||
onSubmit={(text) => onSubmitForm(form.id, text)}
|
onSubmit={(text) => onSubmitForm(form.id, text)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,8 @@ interface Props {
|
||||||
// Question-form submissions become a normal user message; the parent
|
// Question-form submissions become a normal user message; the parent
|
||||||
// routes that text through onSend (no attachments).
|
// routes that text through onSend (no attachments).
|
||||||
onSubmitForm?: (text: string) => void;
|
onSubmitForm?: (text: string) => void;
|
||||||
|
// Focus the right-hand Questions tab from the chat banner.
|
||||||
|
onOpenQuestions?: () => void;
|
||||||
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
|
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
|
||||||
onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void;
|
onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void;
|
||||||
// Header "+" button — kicks off ProjectView's create-conversation flow.
|
// Header "+" button — kicks off ProjectView's create-conversation flow.
|
||||||
|
|
@ -371,6 +373,7 @@ export function ChatPane({
|
||||||
forceStreamingMessageIds,
|
forceStreamingMessageIds,
|
||||||
initialDraft,
|
initialDraft,
|
||||||
onSubmitForm,
|
onSubmitForm,
|
||||||
|
onOpenQuestions,
|
||||||
onContinueRemainingTasks,
|
onContinueRemainingTasks,
|
||||||
onAssistantFeedback,
|
onAssistantFeedback,
|
||||||
onNewConversation,
|
onNewConversation,
|
||||||
|
|
@ -1165,6 +1168,7 @@ export function ChatPane({
|
||||||
scrolledToFormRef.current = new Set();
|
scrolledToFormRef.current = new Set();
|
||||||
onSubmitForm?.(text);
|
onSubmitForm?.(text);
|
||||||
}}
|
}}
|
||||||
|
onOpenQuestions={onOpenQuestions}
|
||||||
onContinueRemainingTasks={
|
onContinueRemainingTasks={
|
||||||
m.id === lastAssistantId && onContinueRemainingTasks
|
m.id === lastAssistantId && onContinueRemainingTasks
|
||||||
? (todos) => onContinueRemainingTasks(m, todos)
|
? (todos) => onContinueRemainingTasks(m, todos)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
type ProjectMetadata,
|
type ProjectMetadata,
|
||||||
type ProjectFile,
|
type ProjectFile,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import type { QuestionForm } from '../artifacts/question-form';
|
||||||
import { DesignFilesPanel } from './DesignFilesPanel';
|
import { DesignFilesPanel } from './DesignFilesPanel';
|
||||||
import type { PluginFolderAgentAction } from './design-files/pluginFolderActions';
|
import type { PluginFolderAgentAction } from './design-files/pluginFolderActions';
|
||||||
import { designSystemGithubEvidenceState, repoConnectCopy } from './design-system-github-evidence';
|
import { designSystemGithubEvidenceState, repoConnectCopy } from './design-system-github-evidence';
|
||||||
|
|
@ -50,6 +51,7 @@ import { Icon } from './Icon';
|
||||||
import { LiveArtifactBadges } from './LiveArtifactBadges';
|
import { LiveArtifactBadges } from './LiveArtifactBadges';
|
||||||
import { MissingBrandFontsBanner } from './MissingBrandFontsBanner';
|
import { MissingBrandFontsBanner } from './MissingBrandFontsBanner';
|
||||||
import { PasteTextDialog } from './PasteTextDialog';
|
import { PasteTextDialog } from './PasteTextDialog';
|
||||||
|
import { QuestionsPanel } from './QuestionsPanel';
|
||||||
import { QuickSwitcher } from './QuickSwitcher';
|
import { QuickSwitcher } from './QuickSwitcher';
|
||||||
import { SketchEditor } from './SketchEditor';
|
import { SketchEditor } from './SketchEditor';
|
||||||
import {
|
import {
|
||||||
|
|
@ -115,6 +117,25 @@ interface Props {
|
||||||
artifactHtml?: string | null;
|
artifactHtml?: string | null;
|
||||||
conversationError?: string | null;
|
conversationError?: string | null;
|
||||||
onRetry?: (message: ChatMessage) => void;
|
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 {
|
interface SketchState {
|
||||||
|
|
@ -130,6 +151,7 @@ interface SketchState {
|
||||||
|
|
||||||
const DESIGN_FILES_TAB = '__design_files__';
|
const DESIGN_FILES_TAB = '__design_files__';
|
||||||
const DESIGN_SYSTEM_TAB = '__design_system__';
|
const DESIGN_SYSTEM_TAB = '__design_system__';
|
||||||
|
const QUESTIONS_TAB = '__questions__';
|
||||||
type TabDropEdge = 'before' | 'after';
|
type TabDropEdge = 'before' | 'after';
|
||||||
type DesignSystemReviewDecision =
|
type DesignSystemReviewDecision =
|
||||||
NonNullable<ProjectMetadata['designSystemReview']>[string]['decision'];
|
NonNullable<ProjectMetadata['designSystemReview']>[string]['decision'];
|
||||||
|
|
@ -237,8 +259,18 @@ export function FileWorkspace({
|
||||||
artifactHtml,
|
artifactHtml,
|
||||||
conversationError,
|
conversationError,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
questionForm = null,
|
||||||
|
questionFormPreview = null,
|
||||||
|
questionFormKey = null,
|
||||||
|
questionFormInteractive = false,
|
||||||
|
questionFormSubmitDisabled = false,
|
||||||
|
questionFormSubmittedAnswers,
|
||||||
|
questionsGenerating = false,
|
||||||
|
onSubmitQuestionForm,
|
||||||
|
focusQuestionsRequest = null,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const showQuestionsTab = Boolean(questionForm || questionsGenerating);
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
// P1 page_view page_name=file_manager — once per project the user lands
|
// P1 page_view page_name=file_manager — once per project the user lands
|
||||||
// inside the workspace. Re-fire when the projectId changes so a
|
// 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
|
// back to the last remaining tab. Skip transient activeTab values
|
||||||
// (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs.
|
// (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs.
|
||||||
useEffect(() => {
|
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 (sketches[activeTab] && !sketches[activeTab]!.persisted) return;
|
||||||
if (!persistedTabs.includes(activeTab)) {
|
if (!persistedTabs.includes(activeTab)) {
|
||||||
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
|
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
|
||||||
|
|
@ -341,6 +373,24 @@ export function FileWorkspace({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openRequest]);
|
}, [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) {
|
function openFile(name: string) {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
onTabsStateChange({
|
onTabsStateChange({
|
||||||
|
|
@ -525,7 +575,7 @@ export function FileWorkspace({
|
||||||
// The Design Files entry is already sticky-pinned, so we only scroll
|
// The Design Files entry is already sticky-pinned, so we only scroll
|
||||||
// for real workspace tabs. Issue #775.
|
// for real workspace tabs. Issue #775.
|
||||||
useEffect(() => {
|
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;
|
const tabBar = tabsBarRef.current;
|
||||||
if (!tabBar) return;
|
if (!tabBar) return;
|
||||||
const el = tabBar.querySelector<HTMLElement>('.ws-tab.active');
|
const el = tabBar.querySelector<HTMLElement>('.ws-tab.active');
|
||||||
|
|
@ -793,7 +843,7 @@ export function FileWorkspace({
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFile = useMemo<ProjectFile | null>(() => {
|
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);
|
const onDisk = visibleFiles.find((f) => f.name === activeTab);
|
||||||
if (onDisk) return onDisk;
|
if (onDisk) return onDisk;
|
||||||
if (isSketchName(activeTab) && sketches[activeTab]) {
|
if (isSketchName(activeTab) && sketches[activeTab]) {
|
||||||
|
|
@ -809,7 +859,7 @@ export function FileWorkspace({
|
||||||
}, [activeTab, visibleFiles, sketches]);
|
}, [activeTab, visibleFiles, sketches]);
|
||||||
|
|
||||||
const activeLiveArtifact = useMemo<LiveArtifactWorkspaceEntry | null>(() => {
|
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;
|
return liveArtifactEntries.find((entry) => entry.tabId === activeTab) ?? null;
|
||||||
}, [activeTab, liveArtifactEntries]);
|
}, [activeTab, liveArtifactEntries]);
|
||||||
|
|
||||||
|
|
@ -897,6 +947,23 @@ export function FileWorkspace({
|
||||||
</span>
|
</span>
|
||||||
<span className="ws-tab-label">{t('workspace.designFiles')}</span>
|
<span className="ws-tab-label">{t('workspace.designFiles')}</span>
|
||||||
</button>
|
</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) => {
|
{tabNames.map((name) => {
|
||||||
const sketchEntry = sketches[name];
|
const sketchEntry = sketches[name];
|
||||||
const dirtyMark =
|
const dirtyMark =
|
||||||
|
|
@ -979,7 +1046,18 @@ export function FileWorkspace({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<DesignSystemProjectPanel
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
system={designSystemProject}
|
system={designSystemProject}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/ma
|
||||||
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
|
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
|
||||||
import { validateHtmlArtifact } from '../artifacts/validate';
|
import { validateHtmlArtifact } from '../artifacts/validate';
|
||||||
import { createArtifactParser } from '../artifacts/parser';
|
import { createArtifactParser } from '../artifacts/parser';
|
||||||
|
import {
|
||||||
|
findFirstQuestionForm,
|
||||||
|
hasUnterminatedQuestionForm,
|
||||||
|
parsePartialQuestionForm,
|
||||||
|
type QuestionForm,
|
||||||
|
} from '../artifacts/question-form';
|
||||||
|
import { parseSubmittedAnswers } from './QuestionForm';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
import { streamMessage } from '../providers/anthropic';
|
import { streamMessage } from '../providers/anthropic';
|
||||||
import {
|
import {
|
||||||
|
|
@ -743,6 +750,78 @@ export function ProjectView({
|
||||||
|| failedMessagesConversationId === activeConversationId
|
|| failedMessagesConversationId === activeConversationId
|
||||||
|| currentConversationAwaitingActiveRunAttach;
|
|| currentConversationAwaitingActiveRunAttach;
|
||||||
const currentConversationActionDisabled = currentConversationBusy || currentConversationSendDisabled;
|
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
|
const currentConversationQueuedItems = activeConversationId
|
||||||
? queuedChatSends
|
? queuedChatSends
|
||||||
.filter((item) => item.conversationId === activeConversationId)
|
.filter((item) => item.conversationId === activeConversationId)
|
||||||
|
|
@ -4481,6 +4560,7 @@ export function ProjectView({
|
||||||
if (currentConversationActionDisabled) return;
|
if (currentConversationActionDisabled) return;
|
||||||
void handleSend(text, [], []);
|
void handleSend(text, [], []);
|
||||||
}}
|
}}
|
||||||
|
onOpenQuestions={openQuestionsTab}
|
||||||
onContinueRemainingTasks={handleContinueRemainingTasks}
|
onContinueRemainingTasks={handleContinueRemainingTasks}
|
||||||
onAssistantFeedback={handleAssistantFeedback}
|
onAssistantFeedback={handleAssistantFeedback}
|
||||||
onNewConversation={handleNewConversation}
|
onNewConversation={handleNewConversation}
|
||||||
|
|
@ -4584,6 +4664,18 @@ export function ProjectView({
|
||||||
artifactHtml={artifact?.html}
|
artifactHtml={artifact?.html}
|
||||||
conversationError={error}
|
conversationError={error}
|
||||||
onRetry={handleRetry}
|
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>
|
</div>
|
||||||
{projectActionsToast ? (
|
{projectActionsToast ? (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import type { DirectionCard, FormOption, QuestionForm } from '../artifacts/question-form';
|
import type { DirectionCard, FormOption, QuestionForm } from '../artifacts/question-form';
|
||||||
import { formatFormAnswers, formOptionValueForLabel } 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
|
// begins with "[form answers — <id>]", we parse it back out and pass it
|
||||||
// here so the rendered form reflects what was sent.
|
// here so the rendered form reflects what was sent.
|
||||||
submittedAnswers?: Record<string, string | string[]>;
|
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;
|
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 t = useT();
|
||||||
const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]);
|
const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]);
|
||||||
const [answers, setAnswers] = useState<Record<string, string | string[]>>(initial);
|
const [answers, setAnswers] = useState<Record<string, string | string[]>>(initial);
|
||||||
const locked = !interactive || !onSubmit || submittedAnswers !== undefined;
|
const locked = !interactive || !onSubmit || submittedAnswers !== undefined;
|
||||||
const currentAnswers = submittedAnswers ?? answers;
|
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[]) {
|
function update(id: string, value: string | string[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
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() {
|
function handleSubmit() {
|
||||||
if (locked || !onSubmit) return;
|
if (locked || !onSubmit) return;
|
||||||
if (!withinSelectionLimits) 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);
|
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) => {
|
const withinSelectionLimits = form.questions.every((q) => {
|
||||||
if (q.type !== 'checkbox' || q.maxSelections === undefined) return true;
|
if (q.type !== 'checkbox' || q.maxSelections === undefined) return true;
|
||||||
const v = currentAnswers[q.id];
|
const v = currentAnswers[q.id];
|
||||||
return !Array.isArray(v) || v.length <= q.maxSelections;
|
return !Array.isArray(v) || v.length <= q.maxSelections;
|
||||||
});
|
});
|
||||||
const ready = withinSelectionLimits && required.every((q) => {
|
const ready = withinSelectionLimits;
|
||||||
const v = currentAnswers[q.id];
|
|
||||||
return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0;
|
useImperativeHandle(ref, () => ({ submit: handleSubmit, skipAll: handleSkipAll }));
|
||||||
});
|
useEffect(() => {
|
||||||
|
onReadyChange?.(!locked && ready);
|
||||||
|
}, [onReadyChange, locked, ready]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`question-form${locked ? ' question-form-locked' : ''}`} data-form-id={form.id}>
|
<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">
|
<div key={q.id} className="qf-field">
|
||||||
<label className="qf-label">
|
<label className="qf-label">
|
||||||
<span>{q.label}</span>
|
<span>{q.label}</span>
|
||||||
{q.required ? (
|
|
||||||
<span className="qf-required" aria-label={t('qf.required')}>*</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
</label>
|
||||||
{q.help ? <div className="qf-help">{q.help}</div> : null}
|
{q.help ? <div className="qf-help">{q.help}</div> : null}
|
||||||
{q.type === 'radio' && q.options ? (
|
{q.type === 'radio' && q.options ? (
|
||||||
|
|
@ -204,29 +229,31 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="question-form-foot">
|
{hideInternalSubmit ? null : (
|
||||||
{locked ? (
|
<div className="question-form-foot">
|
||||||
<span className="qf-locked-note">
|
{locked ? (
|
||||||
{submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')}
|
<span className="qf-locked-note">
|
||||||
</span>
|
{submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')}
|
||||||
) : (
|
</span>
|
||||||
<span className="qf-hint">{t('qf.hint')}</span>
|
) : (
|
||||||
)}
|
<span className="qf-hint">{t('qf.hint')}</span>
|
||||||
{!locked ? (
|
)}
|
||||||
<button
|
{!locked ? (
|
||||||
type="button"
|
<button
|
||||||
className="primary"
|
type="button"
|
||||||
onClick={handleSubmit}
|
className="primary"
|
||||||
disabled={!ready}
|
onClick={handleSubmit}
|
||||||
title={ready ? t('qf.submitTitle') : t('qf.submitDisabledTitle')}
|
disabled={!ready}
|
||||||
>
|
title={ready ? t('qf.submitTitle') : t('qf.submitDisabledTitle')}
|
||||||
{form.submitLabel ?? t('qf.submitDefault')}
|
>
|
||||||
</button>
|
{form.submitLabel ?? t('qf.submitDefault')}
|
||||||
) : null}
|
</button>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function OptionCopy({ option }: { option: FormOption }) {
|
function OptionCopy({ option }: { option: FormOption }) {
|
||||||
return (
|
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.cardSelected': 'selected',
|
||||||
'qf.cardRefs': 'Refs:',
|
'qf.cardRefs': 'Refs:',
|
||||||
'qf.cardSampleText': 'The quick brown fox · 0123',
|
'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.toolSelect': 'Select (no-op)',
|
||||||
'sketch.toolPen': 'Pen',
|
'sketch.toolPen': 'Pen',
|
||||||
|
|
|
||||||
|
|
@ -2048,6 +2048,12 @@ export const zhCN: Dict = {
|
||||||
'qf.cardSelected': '已选',
|
'qf.cardSelected': '已选',
|
||||||
'qf.cardRefs': '参考:',
|
'qf.cardRefs': '参考:',
|
||||||
'qf.cardSampleText': '飞燕环宇 · 0123',
|
'qf.cardSampleText': '飞燕环宇 · 0123',
|
||||||
|
'questions.tabLabel': '问题',
|
||||||
|
'questions.banner': '想先跟你确认几个小问题~',
|
||||||
|
'questions.continue': '继续',
|
||||||
|
'questions.generating': '正在生成问题…',
|
||||||
|
'questions.skipAll': '一键跳过',
|
||||||
|
'questions.autoSkipHint': '倒计时结束后将自动继续',
|
||||||
|
|
||||||
'sketch.toolSelect': '选择(占位)',
|
'sketch.toolSelect': '选择(占位)',
|
||||||
'sketch.toolPen': '钢笔',
|
'sketch.toolPen': '钢笔',
|
||||||
|
|
|
||||||
|
|
@ -2427,6 +2427,12 @@ export interface Dict {
|
||||||
'qf.cardSelected': string;
|
'qf.cardSelected': string;
|
||||||
'qf.cardRefs': string;
|
'qf.cardRefs': string;
|
||||||
'qf.cardSampleText': 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 (Codex-style floating companion)
|
||||||
'pet.title': string;
|
'pet.title': string;
|
||||||
|
|
|
||||||
|
|
@ -1065,6 +1065,232 @@
|
||||||
opacity: 0.7;
|
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 */
|
/* Design-system preview modal */
|
||||||
.ds-modal-backdrop {
|
.ds-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,9 @@ describe('AssistantMessage question forms', () => {
|
||||||
'</question-form>',
|
'</question-form>',
|
||||||
].join('\n');
|
].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(
|
render(
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={baseMessage({
|
message={baseMessage({
|
||||||
|
|
@ -261,7 +264,7 @@ describe('AssistantMessage question forms', () => {
|
||||||
})}
|
})}
|
||||||
streaming={false}
|
streaming={false}
|
||||||
projectId="proj-1"
|
projectId="proj-1"
|
||||||
isLast
|
isLast={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,11 @@ describe('QuestionFormView', () => {
|
||||||
<QuestionFormView form={checkboxObjectForm} interactive onSubmit={onSubmit} />,
|
<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' });
|
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('Editorial / magazine'));
|
||||||
fireEvent.click(screen.getByLabelText('Soft gradients'));
|
fireEvent.click(screen.getByLabelText('Soft gradients'));
|
||||||
|
|
@ -226,8 +229,9 @@ describe('QuestionFormView', () => {
|
||||||
<QuestionFormView form={selectObjectForm} interactive onSubmit={onSubmit} />,
|
<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' });
|
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');
|
const select = container.querySelector('select');
|
||||||
if (!select) throw new Error('expected select control');
|
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