Skip to content

fix(webui): questionnaire goal countdown honesty + missing-presentation hardening (I-1) - #25

Merged
stevenjj33 merged 1 commit into
webuifrom
fix/webui-questionnaire-countdown
Oct 5, 2026
Merged

stevenjj33 merged 1 commit into
webuifrom
fix/webui-questionnaire-countdown

Conversation

@stevenjj33

Copy link
Copy Markdown
Collaborator

Change

Roadmap I-1 (多步问卷交互): from 「组件在、没人逐项实测」 to item-by-item verified in a real browser, plus two defects found by that verification and fixed. Both were reproduced against the built client before any change was made.

Suspect 2a — countdown auto-submit: investigated, verdict is NOT a client gap

  • Client-side: the countdown effect only ticks seconds; no submit call exists.
  • Server-side (source-verified): the runtime owns expiry handling — QuestionnaireAutoReplyScheduler (packages/local-runtime/src/questionnaire/auto-reply-scheduler.ts) fires only for purpose === 'goal', and GoalQuestionnaireService.runAutoReply applies each step's recommended option (falls back to the first option, skips a step with none) through the real reply path, with version/CAS arbitration so a concurrent manual reply/dismiss wins. Its own contract: "The renderer may display a countdown, but only this runtime-owned scheduler mutates questionnaire state."
  • Real-browser reproduction (built client + controlled questionnaire payloads): goal request with 6s expiry → ⏱ 6s shown, title 「将在 6 秒后自动提交」; at zero it parked at ⏱ 0s; no replyQuestionnaire was sent by the client — as the architecture intends.
  • What WAS broken was the display's honesty, now fixed in InteractionPanel.tsx:
    1. the countdown rendered for any questionnaire with expiresAt — reproduced with a purpose-less request (countdown appeared) even though the runtime answers goal-only. Now gated to purpose === 1 (QuestionnairePurpose.Goal on this wire), matching the scheduler and the TUI's goalAutoReplyDeadline.
    2. the title said 自动提交; the runtime applies the recommended option. Now 「将在 N 秒后自动采用推荐选项」.
    3. zero parked at a bare ⏱ 0s. Now 「⏱ 时间到 · 正在采用推荐选项…」 — the TUI's "Time is up · applying the recommended option…" beat.
    4. the TUI labels the option that will be applied "(Recommended)"; the WebUI marked nothing. The recommended option now carries (推荐) while the window is open.
  • No client-side auto-submit was added, deliberately: the scheduler owns the reply, and a second submit from the renderer would race the very CAS that exists to let a manual answer win. (The task's "same onQuestionnaire path + dedup" requirement is thereby satisfied vacuously by not duplicating the runtime's action; noted here explicitly.)

Suspect 2b — missing presentation: confirmed throw, fixed

  • Reproduced: a 2-step questionnaire with no presentation block threw TypeError: Cannot read properties of undefined (reading 'showProgress') — the render died and the error boundary replaced the whole surface (a white page before Q-1). A single-step request survives only because steps.length > 1 short-circuits the read.
  • Why it can happen despite the required wire type: the runtime's view builder (toQuestionnaireRequestView) materialises the block with {...request.presentation} — {} when absent — and every producer-side normaliser (DEFAULT_PRESENTATION in local-runtime, the TUI's event-normalizer) defaults the three fields to true.
  • Fix: presentation?.showProgress ?? true and presentation?.allowBackNavigation ?? true — the same convention the replaceComposer read two siblings up already followed. After the fix the reproduced payload renders with the progress row (default true) instead of throwing.

Validation — the item-by-item browser record (the hard requirement)

Environment for every row below: built client (dist-webui from this branch), headless Chromium 153, 1440×900, zh-CN; the fixture transport serves controlled getPendingQuestionnaire payloads; staging a questionnaire and switching sessions (A↔B) triggers the composer's poll. "Clicks" = real Playwright clicks/typing, not evaluate calls, except where noted.

# Item Actions Observed
1 分步前进 stage 3-step questionnaire; click option A; click 下一步 progress row ‹1/3› → ‹2/3›; 提交 absent on non-final steps
2 必填拦截 (same card) required step unanswered 下一步 disabled AND 提交 disabled; after selecting option A both enabled
3 多选 on multi step click 甲 and 丙 可多选 hint shown; both rows data-selected=true; toggling works
4 前进后回退 click 上一步 back to ‹1/3›, first step's selection retained
5 自定义回答 on allowOther step click the other-input, type text row data-selected=true, typed text accepted
6 提交回传 click 提交 replyQuestionnaire sent once; payload verified exactly: [{s1:[o1a]},{s2:[o2a,o2c]},{s3:selectedOther+otherText}]
7 关闭 click × dismissQuestionnaire +1
8 跳过 click 跳过 dismissQuestionnaire +1; no replyQuestionnaire ever sent
9 goal 倒计时(修后) stage purpose=1 + 4s expiry; wait countdown ⏱ Ns, title 「自动采用推荐选项」, recommended option marked (推荐), other unmarked; at zero 「⏱ 时间到 · 正在采用推荐选项…」
10 普通过期(修后) stage purpose-less + expiry no countdown (was shown before the fix)
11 presentation 缺失(修后) stage 2-step, presentation stripped renders, progress ‹1/2›, no error boundary (threw before the fix)

Automated:

  • pnpm test:webui — 81 files / 1624 tests passed, including new questionnaire-goal-autoreply.test.tsx (7 cases).
  • npx playwright test — 85/85 passed, including new questionnaire.spec.mjs (5 cases, harness-imported test per the serverId guard).
  • pnpm typecheck:webui-full, pnpm check:source (inventory regenerated), pnpm verify — passed, 20 gates on darwin.
  • One pre-existing unit fixture updated: webui-round3-acceptance.test.tsx's countdown request gains purpose: 1 — its request carried an expiry with no purpose, exactly the dishonest shape this PR removes; the assertion (countdown renders inside the goal window) is unchanged in intent. No browser spec was modified.

NOT RUN / boundaries

  • "The scheduler really submits at expiry" is source-verified (path cited above) but not end-to-end browser-verified: the fixture has no runtime, and the real runtime path needs a logged-in pnpm dev:server session. No live-service acceptance claimed.
  • Windows contract left to CI.

Boundaries (per the task brief)

  • 设置页 untouched. 沙箱配置入口 is NOT in this PR: it needs DESKTOP_SETTINGS_TABS in SettingsModal.tsx, the same array E-zone (安天齐, PR fix(webui): report a failed revert/reapply instead of relabelling it a missing capability #20 in flight), K-zone (izzy) and P-zone (unclaimed) are unlocking tabs in. It should be its own PR, coordinated with those three first, so the same tab is not unlocked twice.
  • Roadmap rows marked ✅ (权限三档、工具审批交互、会话记忆及清除、传输安全) — untouched.
  • Branched from 1304e4d (webui HEAD at 10-05 16:06), not from any in-flight branch.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes (if any): none.

Shared-source port: not needed.

…ve a missing presentation block

Two defects, both reproduced first against the built client before any
change:

1. A multi-step questionnaire whose presentation block is absent threw
   'Cannot read properties of undefined (reading showProgress)' and
   replaced the whole interaction surface with the error boundary (a
   white page before Q-1). The wire type declares presentation required,
   but the runtime's view builder materialises it with a spread — {}
   for a payload that never had one — and every producer-side
   normaliser (DEFAULT_PRESENTATION in local-runtime, the TUI's
   event-normalizer) defaults the three fields to true. The progress
   row now reads them through '?? true', the same convention the
   replaceComposer read two siblings up already followed.

2. The expiry countdown was shown for ANY questionnaire carrying
   expiresAt, titled 'will auto-submit in N seconds', and parked at a
   bare zero when the window closed. All three claims were dishonest:
   the runtime's QuestionnaireAutoReplyScheduler replies only for
   purpose === 'goal' (applying each step's recommended option, with a
   CAS so a concurrent manual answer wins), and the renderer is
   display-only by that module's own contract. The countdown is now
   gated to the goal window, titled 'automatically apply the
   recommended option', marks that option (推荐) while the window is
   open — the TUI's '(Recommended)' affordance — and says the window is
   over at zero instead of parking at 0s. No client-side auto-submit is
   added on purpose: the scheduler owns it, and a second submit from
   the renderer would race the CAS that exists to let a manual answer
   win.

webui-round3-acceptance's countdown fixture gains purpose: 1 so it
still exercises the window it always meant to (its request carried an
expiry with no purpose, which is exactly the dishonest shape removed).

Tests: questionnaire-goal-autoreply.test.tsx (7 cases — goal gating,
honest title, recommended mark, zero state, missing-presentation
render, explicit showProgress:false honoured) and
questionnaire.spec.mjs (5 browser cases — required-gated stepping
forward/back, multi-select, custom answer, submit payload on the wire,
close/skip routing, goal countdown contract, missing-presentation
render). The browser fixture gains setQuestionnaire so a request can
be staged for the composer's poll.
@stevenjj33 stevenjj33 added the bug Something isn't working label Oct 5, 2026
@stevenjj33
stevenjj33 merged commit 2c0384b into webui Oct 5, 2026
9 checks passed
@stevenjj33
stevenjj33 deleted the fix/webui-questionnaire-countdown branch October 5, 2026 09:34
modacker pushed a commit that referenced this pull request Oct 5, 2026
#25 tightened the goal auto-reply window to `purpose === 1 && expiresAt
!== undefined`, which matches what the runtime actually acts on. The
guard still let a non-numeric `expiresAt` through, and the countdown
clamps to zero: `null` rendered as "⏱ 0s" and `NaN` rendered the literal
string "NaNs" into the panel. Both claim a deadline the payload never
carried.

`goalAutoReplyDeadline` in the terminal client guards with
`typeof expiresAt === "number" && Number.isFinite(expiresAt)`, and this
panel's comment already points at that function as its reference. Take
the same pair. Zero and a past timestamp stay valid — they are real
deadlines that have passed.

Also corrects the premise in the MCP status suite. `LocalMcpPublicServerStatus`
does classify every server, but that is the MCP tool surface; the plugin
page's `listConfiguredServers` → `configuredSummary` returns no `status` at
all and hardcodes `configJson` to `"{}"`, so every real row reads 未知状态
and the badge is inert until that path carries the field. The suite now
pins today's honest answer with the real server shape, so the day the field
arrives the change is visible rather than silent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant