Files
nexu-io--open-design/apps/web/src/components/QuestionsPanel.tsx
T
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

522 lines
20 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { questionsFormTrackingId } from '@open-design/contracts/analytics';
import type { RunContextSelection, WorkspaceContextItem } from '@open-design/contracts';
import { useT } from '../i18n';
import { useAnalytics } from '../analytics/provider';
import { trackQuestionsFormClick, trackQuestionsFormSurfaceView } from '../analytics/events';
import type { QuestionForm } from '../artifacts/question-form';
import { uploadProjectFiles } from '../providers/registry';
import type { ChatAttachment } from '../types';
import {
QuestionFormView,
type QuestionFormFileSubmission,
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>();
// Form occurrences whose surface_view already fired. Same remount caveat as
// `revealedOccurrences`: the tab unmounts/remounts mid-stream, so dedupe the
// exposure by the stable per-occurrence key instead of component lifetime.
const viewedFormOccurrences = 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 = 10 * 60;
const QUESTION_FORM_DRAFT_STORAGE_PREFIX = 'open-design:question-form-draft:';
type QuestionFormAnswers = Record<string, string | string[]>;
interface QuestionFormSubmitPayload {
attachments?: ChatAttachment[];
context?: RunContextSelection;
}
interface Props {
// Owning project, carried on every questions_form event. The Questions tab
// host (FileWorkspace) always supplies it; it is optional only so bare
// unit-test renders stay valid — tracking is skipped when absent.
projectId?: string;
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, payload?: QuestionFormSubmitPayload) => void;
}
export function QuestionsPanel({
projectId,
form,
formKey = null,
interactive,
submitDisabled = false,
submittedAnswers,
generating,
onSubmit,
}: Props) {
const t = useT();
const analytics = useAnalytics();
const formRef = useRef<QuestionFormHandle>(null);
// What drove the next submit: the Continue CTA, the Skip button, or the
// auto-continue countdown. Read (and reset) inside submitAndClearDraft so
// the single submit chokepoint can label the click event.
const submitSourceRef = useRef<'continue' | 'skip_button' | 'countdown'>('continue');
const [ready, setReady] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
// Once the user commits an answer we optimistically lock the panel — disabling
// Continue/Skip and showing a busy spinner — for the window before the
// parent's busy/answered signals catch up (and across the async file upload).
// Without it, a form-answer send that is still being dispatched leaves the CTA
// enabled, so an impatient user (or the countdown) can re-fire it and pile
// duplicate sends into the queue. The ref is the synchronous chokepoint guard;
// the state drives rendering.
const submittingRef = useRef(false);
const [submitting, setSubmitting] = useState(false);
const releaseSubmitLock = useCallback(() => {
submittingRef.current = false;
setSubmitting(false);
}, []);
const [draftAnswers, setDraftAnswers] = useState<QuestionFormAnswers | undefined>(() =>
readQuestionFormDraft(formKey),
);
const total = form?.questions.length ?? 0;
const answered = submittedAnswers !== undefined;
// Exposure of the active (unanswered) form — once per occurrence, surviving
// the tab's unmount/remount cycle via the module-level set.
useEffect(() => {
if (!form || answered || !projectId) return;
const key = formKey ?? `${projectId}:${form.id}`;
if (viewedFormOccurrences.has(key)) return;
viewedFormOccurrences.add(key);
trackQuestionsFormSurfaceView(analytics.track, {
page_name: 'chat_panel',
area: 'questions_form',
project_id: projectId,
form_id: questionsFormTrackingId(form.id),
});
}, [form, answered, formKey, projectId, analytics.track]);
useEffect(() => {
setDraftAnswers(readQuestionFormDraft(formKey));
}, [formKey]);
useEffect(() => {
if (answered) clearQuestionFormDraft(formKey);
}, [answered, formKey]);
const updateDraftAnswers = useCallback(
(answers: QuestionFormAnswers) => {
setUploadError(null);
setDraftAnswers(answers);
writeQuestionFormDraft(formKey, answers);
},
[formKey],
);
// Tracks taskType/brand chip picks. Other questions (text, checkbox, …)
// intentionally don't emit a click.
const handleAnswerChange = useCallback(
(questionId: string, value: string | string[]) => {
if (!form || !projectId || typeof value !== 'string' || value.length === 0) return;
const element =
questionId === 'taskType'
? ('task_type_chip' as const)
: questionId === 'brand'
? ('brand_bg_chip' as const)
: null;
if (!element) return;
trackQuestionsFormClick(analytics.track, {
page_name: 'chat_panel',
area: 'questions_form',
element,
chip_id: questionsFormTrackingId(value),
form_id: questionsFormTrackingId(form.id),
project_id: projectId,
});
},
[analytics.track, form, projectId],
);
// The single submit chokepoint — Continue, Skip and the countdown all land
// here, so the submit/skip click is reported exactly once.
const submitAndClearDraft = useCallback(
async (
text: string,
answers: QuestionFormAnswers,
fileSubmissions: QuestionFormFileSubmission[] = [],
) => {
// Single-submit invariant: a form occurrence commits exactly one answer.
// A repeat click, or the countdown racing a click, must not enqueue a
// second send (see the "76 queued" repro). Released below if an upload
// fails so the user can retry.
if (submittingRef.current) return;
submittingRef.current = true;
setSubmitting(true);
let attachments: ChatAttachment[] = [];
let context: RunContextSelection | undefined;
let submittedText = text;
if (fileSubmissions.length > 0) {
if (!projectId) {
submitSourceRef.current = 'continue';
setUploadError(t('questions.uploadNeedsProject'));
releaseSubmitLock();
return;
}
setUploadingFiles(true);
setUploadError(null);
const flatFiles = fileSubmissions.flatMap((submission) =>
submission.files.map((file) => ({
file,
questionLabel: submission.questionLabel,
})),
);
const result = await uploadProjectFiles(projectId, flatFiles.map((entry) => entry.file)).catch(
(err) => ({
uploaded: [],
failed: flatFiles.map((entry) => ({
name: entry.file.name,
error: err instanceof Error ? err.message : String(err),
})),
error: err instanceof Error ? err.message : String(err),
}),
);
setUploadingFiles(false);
if (result.failed.length > 0 || result.uploaded.length !== flatFiles.length) {
const failed = result.failed.length || Math.max(1, flatFiles.length - result.uploaded.length);
const uploaded = result.uploaded.length;
const detail = result.error ? ` (${result.error})` : '';
submitSourceRef.current = 'continue';
setUploadError(
(uploaded > 0
? t('questions.uploadPartialFailed', { uploaded, failed })
: t('questions.uploadFailed', { failed })) + detail,
);
releaseSubmitLock();
return;
}
attachments = result.uploaded.map((attachment, index) => ({
...attachment,
order: index,
}));
context = { workspaceItems: workspaceItemsForQuestionUploads(attachments) };
submittedText = appendQuestionUploadSummary(submittedText, fileSubmissions, attachments);
}
const source = submitSourceRef.current;
submitSourceRef.current = 'continue';
if (form && projectId) {
const total = form.questions.length;
const answeredCount = form.questions.filter((q) => {
const v = answers[q.id];
return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0;
}).length;
trackQuestionsFormClick(analytics.track, {
page_name: 'chat_panel',
area: 'questions_form',
...(source === 'continue'
? { element: 'submit' as const }
: {
element: 'skip' as const,
skip_source: (source === 'countdown' ? 'countdown' : 'button') as
| 'button'
| 'countdown',
}),
answered_count: answeredCount,
skipped_count: total - answeredCount,
form_id: questionsFormTrackingId(form.id),
project_id: projectId,
});
}
clearQuestionFormDraft(formKey);
setDraftAnswers(undefined);
setUploadError(null);
if (attachments.length > 0 || context) {
onSubmit(submittedText, {
...(attachments.length > 0 ? { attachments } : {}),
...(context ? { context } : {}),
});
} else {
onSubmit(submittedText);
}
},
[analytics.track, form, formKey, onSubmit, projectId, releaseSubmitLock, t],
);
// 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);
// Submission needs the form present, active, fully revealed, and not blocked
// by a busy/streaming turn. Required-field readiness is tracked separately by
// `ready` (from QuestionForm) and gates Continue via `canContinue`.
const canSubmit =
!!form && interactive && !building && !submitDisabled && !uploadingFiles && !submitting;
const canContinue = canSubmit && ready;
const canSkip = canSubmit;
// Release the optimistic lock once the world catches up: either the turn is
// now busy (`submitDisabled` takes over the disable) or the answer has landed
// (`answered`). This also recovers a form that never actually started a turn,
// so it stays actionable instead of latching disabled forever.
useEffect(() => {
if (submitting && (submitDisabled || answered)) releaseSubmitLock();
}, [submitting, submitDisabled, answered, releaseSubmitLock]);
// 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;
// Either branch reports as skip_source=countdown; answered_count tells
// apart a countdown submit that carried picks from a pure skip.
submitSourceRef.current = 'countdown';
// 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}
draftAnswers={draftAnswers}
hideInternalSubmit
onReadyChange={setReady}
onDraftChange={updateDraftAnswers}
onAnswerChange={handleAnswerChange}
onSubmit={(text, answers, files) => void submitAndClearDraft(text, answers, files)}
/>
{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${uploadError ? ' questions-panel-error' : ''}`}>
{building
? t('questions.generating')
: uploadingFiles
? t('questions.uploadingFiles')
: uploadError
? uploadError
: submitting
? t('questions.submitting')
: canSkip
? t('questions.autoSkipHint')
: null}
</span>
<button
type="button"
className="questions-skip"
disabled={!canSkip}
onClick={() => {
submitSourceRef.current = 'skip_button';
formRef.current?.skipAll();
}}
>
{t('questions.skipAll')}
{canSkip ? <span className="questions-skip-timer">{countdown}</span> : null}
</button>
<button
type="button"
className="questions-continue"
disabled={!canContinue}
aria-busy={submitting ? 'true' : undefined}
onClick={() => {
submitSourceRef.current = 'continue';
formRef.current?.submit();
}}
>
{submitting ? (
<>
<span className="questions-continue-spinner" aria-hidden />
{t('questions.submitting')}
</>
) : (
t('questions.continue')
)}
</button>
</div>
</div>
);
}
function questionFormDraftStorageKey(formKey: string | null | undefined): string | null {
return formKey ? `${QUESTION_FORM_DRAFT_STORAGE_PREFIX}${formKey}` : null;
}
function workspaceItemsForQuestionUploads(attachments: ChatAttachment[]): WorkspaceContextItem[] {
return attachments.map((attachment) => ({
id: `file:${attachment.path}`,
kind: 'file',
label: attachment.path.split('/').filter(Boolean).pop() || attachment.name,
path: attachment.path,
}));
}
function appendQuestionUploadSummary(
text: string,
fileSubmissions: QuestionFormFileSubmission[],
attachments: ChatAttachment[],
): string {
if (attachments.length === 0) return text;
const labelsByFileName = new Map<string, string[]>();
for (const submission of fileSubmissions) {
for (const file of submission.files) {
const labels = labelsByFileName.get(file.name) ?? [];
labels.push(submission.questionLabel);
labelsByFileName.set(file.name, labels);
}
}
const lines = ['[uploaded design files]'];
attachments.forEach((attachment, index) => {
const labels = labelsByFileName.get(attachment.name) ?? [];
const prefix = labels[0] ? `${labels[0]}: ` : '';
lines.push(`- ${prefix}${attachment.name} -> ${attachment.path}`);
if (labels.length > 1) {
lines.push(` (also selected for: ${labels.slice(1).join(', ')})`);
}
if (!labels[0]) {
lines[lines.length - 1] = `- Attachment ${index + 1}: ${attachment.name} -> ${attachment.path}`;
}
});
return `${text}\n\n${lines.join('\n')}`;
}
function readQuestionFormDraft(formKey: string | null | undefined): QuestionFormAnswers | undefined {
const key = questionFormDraftStorageKey(formKey);
if (!key || typeof window === 'undefined') return undefined;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return undefined;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const out: QuestionFormAnswers = {};
for (const [id, value] of Object.entries(parsed)) {
if (typeof value === 'string') {
out[id] = value;
} else if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
out[id] = value;
}
}
return Object.keys(out).length > 0 ? out : undefined;
} catch {
return undefined;
}
}
function writeQuestionFormDraft(
formKey: string | null | undefined,
answers: QuestionFormAnswers,
): void {
const key = questionFormDraftStorageKey(formKey);
if (!key || typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(key, JSON.stringify(answers));
} catch {
// Losing an in-progress draft is preferable to blocking form input when
// browser storage is unavailable.
}
}
function clearQuestionFormDraft(formKey: string | null | undefined): void {
const key = questionFormDraftStorageKey(formKey);
if (!key || typeof window === 'undefined') return;
try {
window.sessionStorage.removeItem(key);
} catch {
// Ignore storage failures; the submitted answer message is authoritative.
}
}