Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions packages/webui/src/client/components/InteractionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,20 @@ export function WebuiInteractionPanel({
);
const [submitting, setSubmitting] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
// The expiry countdown is the GOAL flow's auto-reply window, not a property
// of questionnaires in general: the runtime's
// `QuestionnaireAutoReplyScheduler` fires only for `purpose === 'goal'`
// (`QuestionnairePurpose.Goal === 1` on this wire), applying each step's
// recommended option. An ordinary questionnaire that happens to carry
// `expiresAt` gets no runtime reply, so showing it a countdown would be a
// promise nothing keeps — the TUI gates the same way
// (`goalAutoReplyDeadline` in `questionnaire-picker.ts`).
const goalAutoReplyWindow =
questionnaire?.purpose === 1 && questionnaire.expiresAt !== undefined;
const [remainingSeconds, setRemainingSeconds] = useState<number | undefined>(() =>
questionnaire?.expiresAt === undefined
? undefined
: Math.max(0, Math.ceil((questionnaire.expiresAt - Date.now()) / 1000)),
goalAutoReplyWindow
? Math.max(0, Math.ceil((questionnaire!.expiresAt! - Date.now()) / 1000))
: undefined,
);
useEffect(() => {
setSelections({});
Expand All @@ -67,15 +77,18 @@ export function WebuiInteractionPanel({
setCurrentStep(0);
}, [questionnaire?.id]);
useEffect(() => {
if (!questionnaire?.expiresAt) {
if (!goalAutoReplyWindow) {
setRemainingSeconds(undefined);
return undefined;
}
const update = () => setRemainingSeconds(Math.max(0, Math.ceil((questionnaire.expiresAt! - Date.now()) / 1000)));
const update = () => setRemainingSeconds(Math.max(0, Math.ceil((questionnaire!.expiresAt! - Date.now()) / 1000)));
update();
const timer = window.setInterval(update, 1_000);
return () => window.clearInterval(timer);
}, [questionnaire?.expiresAt]);
// `id` rides along so replacing one goal questionnaire with another at the
// same deadline re-derives the window instead of leaving the old interval
// comparing against a deadline that no longer exists.
}, [questionnaire?.id, questionnaire?.purpose, questionnaire?.expiresAt]);
const visiblePermissions = permissions.filter(
(permission) => permission.sessionId === sessionId,
);
Expand Down Expand Up @@ -202,9 +215,19 @@ export function WebuiInteractionPanel({
>
×
</button>
{questionnaire.steps.length > 1 && questionnaire.presentation.showProgress ? (
{/* `presentation` is optional at the reader: the wire type marks it
* required, but the runtime's view builder materialises it with a
* spread (`{...request.presentation}`), which yields `{}` — not a
* defaulted block — for a payload that never had one. Every
* producer-side normaliser defaults these three fields to true
* (`DEFAULT_PRESENTATION` in local-runtime, the TUI's
* event-normalizer), so `?? true` here is the same convention the
* `replaceComposer` read two siblings up already follows. The bare
* reads used to throw for a multi-step request with no presentation
* block — the render died before the boundary existed. */}
{questionnaire.steps.length > 1 && (questionnaire.presentation?.showProgress ?? true) ? (
<span className="webui-questionnaire-progress" data-testid="questionnaire-progress" aria-label={`${currentStep + 1}/${questionnaire.steps.length}`}>
<button type="button" data-testid="questionnaire-progress-prev" aria-label="上一步" onClick={() => setCurrentStep((value) => Math.max(0, value - 1))} disabled={submitting || !questionnaire.presentation.allowBackNavigation || currentStep === 0}>‹</button>
<button type="button" data-testid="questionnaire-progress-prev" aria-label="上一步" onClick={() => setCurrentStep((value) => Math.max(0, value - 1))} disabled={submitting || !(questionnaire.presentation?.allowBackNavigation ?? true) || currentStep === 0}>‹</button>
<span>{currentStep + 1}/{questionnaire.steps.length}</span>
<button type="button" data-testid="questionnaire-progress-next" aria-label="下一步" onClick={() => setCurrentStep((value) => Math.min(questionnaire.steps.length - 1, value + 1))} disabled={submitting || currentStep >= questionnaire.steps.length - 1}>›</button>
</span>
Expand All @@ -216,7 +239,21 @@ export function WebuiInteractionPanel({
>
智能体需要你的回答
</p>
{remainingSeconds !== undefined ? <span className="webui-questionnaire-countdown" data-testid="questionnaire-auto-reply-countdown" title={`将在 ${remainingSeconds} 秒后自动提交`}>⏱ {remainingSeconds}s</span> : null}
{remainingSeconds !== undefined ? (
<span
className="webui-questionnaire-countdown"
data-testid="questionnaire-auto-reply-countdown"
title={remainingSeconds > 0 ? `将在 ${remainingSeconds} 秒后自动采用推荐选项` : "已到期,正在采用推荐选项"}
>
{remainingSeconds > 0
? `⏱ ${remainingSeconds}s`
// The runtime's scheduler owns the actual reply (and a manual
// answer beats it via its CAS arbitration), so zero is a
// "hold on" state, not an error — the TUI shows the same
// "Time is up · applying the recommended option…" beat.
: "⏱ 时间到 · 正在采用推荐选项…"}
</span>
) : null}
<div data-testid="questionnaire-composer-body">
{questionnaire.steps.slice(currentStep, currentStep + 1).map((step) => {
const selected = optionIdsForStep(selections, step.id);
Expand Down Expand Up @@ -276,6 +313,16 @@ export function WebuiInteractionPanel({
</span>
<span>
{option.label}
{/* The TUI appends "(Recommended)" while the goal
auto-reply countdown runs, because that is the
option the runtime will apply if the reader does
nothing — the countdown alone does not say which
one it is. */}
{option.recommended && remainingSeconds !== undefined ? (
<small className="ml-1 text-text_default_secondary" data-testid={`questionnaire-recommended-${option.id}`}>
(推荐)
</small>
) : null}
{option.description ? (
<small className="ml-1 text-text_default_secondary">
{option.description}
Expand Down
160 changes: 160 additions & 0 deletions packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Unit tests for the questionnaire panel's goal auto-reply window and its
// optional-presentation hardening.
//
// Two contracts, both verified broken-then-fixed in a real browser against
// the built client (see the I-1 PR for the click-by-click record):
//
// 1. The expiry countdown belongs to the GOAL flow alone. The runtime's
// `QuestionnaireAutoReplyScheduler` (local-runtime) fires only for
// `purpose === 'goal'`, applying each step's recommended option with a
// CAS so a concurrent manual answer wins; the renderer is display-only
// by that module's own contract. Before the fix the WebUI showed the
// countdown for ANY request carrying `expiresAt` — a promise nothing
// keeps for an ordinary questionnaire — and its title said 自动提交,
// which is not what the runtime does (it applies the recommended
// option), and marked nothing on the options the TUI labels
// "(Recommended)".
//
// 2. `presentation` is declared required by the wire type, but the
// runtime's view builder materialises it with `{...request.
// presentation}` — `{}` for a payload that never had one — and every
// producer-side normaliser defaults the three fields to true. A
// multi-step request whose presentation block is absent entirely used
// to throw `Cannot read properties of undefined (reading
// 'showProgress')` and kill the whole interaction surface (the error
// boundary catches it now; before Q-1 it was a white page).
//
// `renderToStaticMarkup` runs the `useState` initialiser, so the countdown's
// first paint is assertable without a DOM; the interval itself only matters
// over time, which is the browser spec's half of the coverage.

import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { WebuiInteractionPanel } from "../../src/client/components/InteractionPanel.js";
import type { WebuiQuestionnaireRequest } from "../../src/server/port.js";

function request(over: Partial<WebuiQuestionnaireRequest> = {}): WebuiQuestionnaireRequest {
return {
schemaVersion: 2,
id: "q-goal",
title: "目标确认",
presentation: { replaceComposer: true, showProgress: true, allowBackNavigation: true },
steps: [
{
id: "s1",
question: "继续吗?",
selectionMode: 0,
allowOther: false,
otherPlaceholder: "",
required: true,
options: [
{ id: "rec", label: "推荐继续", recommended: true },
{ id: "alt", label: "换个方案" },
],
},
],
...over,
} as WebuiQuestionnaireRequest;
}

function render(questionnaire: WebuiQuestionnaireRequest): string {
return renderToStaticMarkup(
createElement(WebuiInteractionPanel, {
sessionId: "s1",
permissions: [],
questionnaire,
onPermission: vi.fn(async () => undefined),
onQuestionnaire: vi.fn(async () => undefined),
onDismiss: vi.fn(async () => undefined),
}),
);
}

describe("the goal auto-reply window", () => {
it("shows the countdown only for a goal questionnaire", () => {
const future = Date.now() + 30_000;
const goal = render(request({ purpose: 1, expiresAt: future }));
expect(goal).toContain('data-testid="questionnaire-auto-reply-countdown"');

// The other half: an ordinary questionnaire carrying the same expiry
// renders NO countdown — the runtime's auto-reply scheduler is goal-only,
// so a visible countdown here would promise a reply nothing keeps.
const ordinary = render(request({ expiresAt: future }));
expect(ordinary).not.toContain("questionnaire-auto-reply-countdown");
});

it("titles the countdown with what the runtime actually does", () => {
const html = render(request({ purpose: 1, expiresAt: Date.now() + 10_000 }));
// 自动采用推荐选项, not 自动提交: the runtime applies each step's
// recommended option (`GoalQuestionnaireService.runAutoReply`), it does
// not submit the reader's half-filled form.
expect(html).toContain("自动采用推荐选项");
expect(html).not.toContain("自动提交");
});

it("marks the recommended option only while the window is open", () => {
const goal = render(request({ purpose: 1, expiresAt: Date.now() + 10_000 }));
expect(goal).toContain('data-testid="questionnaire-recommended-rec"');
expect(goal).toContain("(推荐)");
// A different option on the same step carries no mark, and the same
// option carries none once no window is open — the mark answers "what
// happens if I do nothing", which only a live window makes true.
expect(goal).not.toContain('data-testid="questionnaire-recommended-alt"');
const unmarked = render(request({ expiresAt: Date.now() + 10_000 }));
expect(unmarked).not.toContain("questionnaire-recommended-");
});

it("says the window is over rather than parking at a bare zero", () => {
const html = render(request({ purpose: 1, expiresAt: Date.now() - 1 }));
// The scheduler owns the actual reply, so zero is a "hold on" state —
// the same beat the TUI renders ("Time is up · applying the recommended
// option…"), not an error and not a stuck ⏱ 0s with no explanation.
expect(html).toContain("时间到 · 正在采用推荐选项…");
expect(html).not.toMatch(/⏱\s*0s/u);
});
});

describe("a presentation block that never arrived", () => {
const twoSteps: WebuiQuestionnaireRequest = {
...request(),
steps: [
{ id: "m1", question: "一", selectionMode: 0, allowOther: false, otherPlaceholder: "", required: false },
{ id: "m2", question: "二", selectionMode: 0, allowOther: false, otherPlaceholder: "", required: false },
],
};

it("renders a multi-step request with no presentation block at all", () => {
// `presentation` entirely absent is the throw case: the progress row's
// guard reads `presentation.showProgress` after `steps.length > 1`
// short-circuits for single-step requests, so only the multi-step shape
// reaches the read. This render used to throw.
const { presentation, ...without } = twoSteps;
expect(presentation).toBeDefined();
const html = render(without as WebuiQuestionnaireRequest);
expect(html).toContain('data-testid="questionnaire-composer"');
});

it("defaults the progress row and back navigation to the producers' default", () => {
// `{...request.presentation}` in the runtime yields `{}` when the block
// was absent — and DEFAULT_PRESENTATION (local-runtime) plus the TUI's
// event-normalizer both default these fields to true, so the reader-side
// `?? true` matches every producer's notion of "unspecified".
const { presentation, ...without } = twoSteps;
const html = render(without as WebuiQuestionnaireRequest);
expect(html).toContain('data-testid="questionnaire-progress"');
// The prev button is disabled on the FIRST step either way; the contract
// under test is that the reader did not throw and the progress row is
// shown, which the assertion above pins.
expect(html).toContain("1/2");
});

it("honours an explicit showProgress: false when the block does arrive", () => {
const html = render({
...twoSteps,
presentation: { replaceComposer: true, showProgress: false, allowBackNavigation: true },
});
expect(html).not.toContain('data-testid="questionnaire-progress"');
});
});
5 changes: 5 additions & 0 deletions packages/webui/test/unit/webui-round3-acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,11 @@ describe("round-3 goal and questionnaire behavior", () => {
id: "q-1",
title: "Choose",
presentation: { replaceComposer: false, showProgress: true, allowBackNavigation: true },
// `purpose: 1` (QuestionnairePurpose.Goal): the countdown renders only
// inside the goal auto-reply window — the runtime's scheduler replies
// only for goal questionnaires, so any other expiry would be a promise
// nothing keeps.
purpose: 1,
expiresAt: Date.now() + 10_000,
steps: [
{ id: "one", question: "One", selectionMode: 0, required: true, allowOther: false, otherPlaceholder: "" , options: [{ id: "a", label: "A" }] },
Expand Down
4 changes: 3 additions & 1 deletion release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@
"packages/webui/test/unit/outside-close.test.ts",
"packages/webui/test/unit/plugin-market-catalogue.test.tsx",
"packages/webui/test/unit/webui-plan-mode.test.ts",
"packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx",
"packages/webui/test/unit/transcript-scroll.test.ts",
"packages/webui/test/unit/markdown-codeblock-c1-highlight.test.tsx",
"packages/webui/test/unit/markdown-codeblock-c2-line-numbers.test.tsx",
Expand Down
12 changes: 12 additions & 0 deletions test/webui-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export function installFixtureTransport() {
const pending = [];
const delayed = [];
const held = [];
// The questionnaire the pending-questionnaire poll will answer. Mutable for
// the same reason `setPage` is: the composer polls `getPendingQuestionnaire`
// on mount and on every session switch, so a test that wants to stage a
// questionnaire has no other way to say what the server answers on the NEXT
// poll. `undefined` keeps the old "no pending questionnaire" answer.
let questionnaire;
let activeTurn;
const requests = [];
const sockets = new Set();
Expand All @@ -76,6 +82,7 @@ export function installFixtureTransport() {
if (operation === "listSkills") return { skills: [] };
if (operation === "listPendingPermissions") return { requests: [] };
if (operation === "getActiveTurn") return activeTurn;
if (operation === "getPendingQuestionnaire") return questionnaire ? { request: questionnaire } : {};
if (operation === "dismissQuestionnaire") return { ok: true };
if (operation === "replyQuestionnaire") return { ok: true };
if (operation === "listQueueMessages") return { items: [] };
Expand Down Expand Up @@ -200,6 +207,11 @@ export function installFixtureTransport() {
// The server's authoritative view of what is running right now. A
// client whose `session.start` was missed reads this to recover.
setActiveTurn(turn) { activeTurn = turn ? clone(turn) : undefined; },
// The pending questionnaire the next poll answers. The goal auto-reply
// scheduler lives in the real runtime, not here: the fixture's transport
// is inert on purpose, so a staged questionnaire stays pending until the
// test answers, dismisses, or replaces it.
setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; },
resolve(operation, condition, result) {
const index = pending.findIndex((entry) => entry.operation === operation && matches(entry.body, condition));
if (index < 0) throw new Error(`No pending fixture request: ${operation} ${JSON.stringify(condition)}`);
Expand Down
Loading
Loading