feat(web-shell): offer a details side task from pending AskUser questions (#16458)

* feat(web-shell): offer a details side task from pending AskUser questions

While an AskUserQuestion is pending, the Web Shell hides the composer and
the btw shortcut ignores the pending-approval state, so a user who does not
understand the options can only answer blindly or ignore the question.

Add an "Ask for details" action to the pending panel. It opens a side task
whose prefilled prompt snapshots the question and options and asks for a
verified explanation — background, per-option meaning, differences, impact
and risks, and a recommendation — delivered as a self-contained single-file
HTML in controlled ASD-STE100-style sentences under .qwen/tmp/, previewable
in the right panel. The side task inherits the parent context but never
answers the question or touches the main session; the question stays
pending until the user answers or ignores it.

The action is main-view-only and gated on the session_side_task capability,
matching /btw side. No daemon, protocol, or settings changes.

* fix(web-shell): one details side task per question, reachable on narrow panels

Post-publication audit of the AskUser details action found three issues:

- Repeat clicks provisioned duplicate side tasks for the same pending
  question, each burning a model turn. Give the details tab a stable id
  derived from the parent session and the permission request id and upsert
  it, reusing the artifact panel's open-or-focus pattern; /btw side keeps
  its unique per-invocation draft id.
- The question snapshot dropped multiSelect, so the explanation prompt did
  not say options can combine. Append a [multiSelect] marker (strict true).
- On a 320px two-question panel the actions row overflowed to the left and
  pushed the details button out of the panel (left: -103px in a real-browser
  probe; scrollWidth misses left overflow on a flex-end row). Let the row
  wrap and pin a narrow-viewport regression test asserting every action
  button stays inside the panel and stays clickable.

Each fix carries a red/green witness: the repeat-click App test, the
multiSelect unit test, and a flex-wrap mutation of the narrow visuals test.

* fix(web-shell): keep the details tab id within the daemon clientId budget

The dedup fix derived the details tab id from the parent session id AND the
request id. SideTaskPanel derives the pane's daemon clientId as
side-task:<parentSessionId>:<tabId>, which with two uuids reached 138 chars —
over the daemon's MAX_CLIENT_ID_LENGTH (128). The child session load then
400s, the pane never connects, and the prefilled prompt is never sent.
Caught by re-running the real-daemon scenario on the post-fix head; mocked
unit tests could not see it.

Derive the stable id from the permission request id alone (a uuid; tabs are
already bucketed per parent session), bringing the clientId to 101 chars.
Add a SideTaskPanel regression test asserting the derived clientId stays
within 128 for uuid-length inputs; the old id shape fails it with 138.

* fix(web-shell): retain the details question-to-child association across tab close

Review found that deduplicating only against open tabs loses the existing
child when the details tab closes: clicking "Ask for details" again for the
same pending request provisioned a second child, and reopening the child
from the side-task catalog first did not deduplicate either (its tab id is
side-task:<sessionId>, not the details-request id).

Persist the pending-request-to-child association per parent session in
localStorage, written when the details child is created. On click, focus
whichever open tab shows the known child (details tab or catalog tab);
with no tab open, reopen one attached to the existing child session — no
draft, no initial prompt, so nothing is provisioned or sent again. One
entry per parent supersedes the resolved question's association, keeping
the store bounded; the association also survives reloads.

Regression tests cover both reported paths (close + re-click keeps exactly
one createSideTaskSession call and reopens with the child's sessionId and
no initialPrompt; catalog reopen + click focuses the single existing tab).
Red witness: dropping the association lookup fails the close + re-click
test with two creation calls.

* fix(web-shell): close the details association lifecycle gaps

Re-review of the association persistence found three lifecycle gaps, all
fixed here with regression tests and mutation witnesses:

- A child exists before its explanation prompt is admitted. Reopening a
  closed details tab now retains the prompt on the tab; the pane's existing
  empty-session guard delivers it exactly once (only while the child has no
  user prompt) and never re-sends it.
- Archiving a details child left its persisted association pointing at an
  unloadable session (the daemon's load route 409s session_archived).
  archiveSideTasks now invalidates associations pointing at archived
  children, so the next click provisions a fresh child.
- Closing the draft tab before creation resolved skipped the association
  write, orphaning the child and provisioning a duplicate on the next click.
  The parent identity is captured when creation starts, and the write no
  longer depends on the tab still existing.

Also pin two invariants the deep-verification report found unasserted:
the derived clientId budget with uuid-length inputs through the real tab-id
derivation (App test), and the formatter's array guard against non-array
objects (formatter test).

* fix(web-shell): supply the unsent details prompt to a catalog-reopened tab

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): treat media-only user prompts as interaction in the side-task guard

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): validate details child archive state at the read site and address review suggestions

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): replace stale details tab on fresh draft and re-check pending after the liveness query

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): replace a details tab bound to a different child than the validated binding

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Shaojin WenandQwen-Coder authored and GitHub committed 2026-11-07 19:16:53 +08:00
1 parent 5b4dcbad19
commit 61aef512e7
15 files changed
+2389 -21

No files matched your search

+1 -1
View File
@@ -192,7 +192,7 @@ jobs:
continue-on-error: true
env:
WEB_SHELL_VISUALS_OUTPUT_DIR: '${{ runner.temp }}/web-shell-before'
run: 'npm run test:e2e:visuals --workspace=packages/web-shell -- screenshots.spec.ts workflow-page.spec.ts git-branch-picker.spec.ts session-workflow.spec.ts collaboration.spec.ts'
run: 'npm run test:e2e:visuals --workspace=packages/web-shell -- screenshots.spec.ts workflow-page.spec.ts git-branch-picker.spec.ts session-workflow.spec.ts collaboration.spec.ts ask-user-details.spec.ts'
- name: 'Compose before/after and keep only changed views'
env:
@@ -0,0 +1,189 @@
# Ask User "Ask for details" Side Task
[English](2026-11-06-ask-user-details-side-task.md) | [简体中文](2026-11-06-ask-user-details-side-task.zh-CN.md)
Status: implemented and verified (unit tests, mock-daemon visuals, and a
real-daemon + real-model E2E round; evidence in `.qwen/e2e-tests/`).
## Problem
When an `ask_user_question` permission is pending, Web Shell hides the main
composer (`composerHidden`) and the btw shortcut ignores `pendingApproval`.
The user can only pick an option, type a free-text answer, or Ignore. There is
no way to ask a follow-up question about the pending question itself — for
example what an option means, what it changes, or what the risks are — without
first committing to an answer or discarding the question.
## Current state
- `/btw <question>` runs a lightweight side question (`btwSessionStream`) that
does not enter the main conversation context. It is handled only by the main
view composer.
- `/btw side <question>` creates a side task when the daemon advertises
`session_side_task` and a workspace session exists (`sideTasksAvailable`):
`createSideTask(initialPrompt)` opens a right-panel tab, and
`createSideTaskSession` provisions a child session that inherits the parent
transcript context. The tab's `initialPrompt` is sent as the child session's
first prompt.
- Files written by a session surface as turn outputs; `.html` files are
renderable artifacts and open in the right panel's HTML preview.
- The pending AskUser panel (`AskUserQuestion`) offers Submit / Ignore /
Previous / Next and an "Other" free-text input, but no details action.
## Goals
- Let the user learn the details of a pending question before answering,
without answering it, discarding it, or polluting the main conversation.
- Reuse the existing side-task infrastructure end to end; no daemon changes.
- The explanation is delivered as a self-contained single-file HTML document
written in controlled, unambiguous sentences (ASD-STE100 sentence rules),
in the UI language.
## Non-goals
- No details action in split-view panes or inside side-task panes. This matches
`/btw side`, which is also main-view-only; a pane-scoped variant would need
per-pane parent-session scoping that the side-task panel does not have today.
- No new daemon routes, capabilities, or settings.
- No automatic answering: the side task must never submit the pending
permission or send a prompt to the main session.
## Proposed solution
1. `AskUserQuestion` accepts an optional `onAskDetails?: () => void`. When
provided, the actions row renders an "Ask for details" text button (muted
styling, left of Ignore). The button is outside the option radiogroup, so
option digit shortcuts and roving-tabindex navigation are unchanged. It is
disabled while a submission is in flight.
2. App wires the callback only for the main-view overlay, gated on
`sideTasksAvailable` (same gate as `/btw side`). The callback formats the
pending payload with `formatAskUserQuestions(rawInput)` — a language-neutral
snapshot of numbered questions with option labels/descriptions — and calls
`createSideTask(t('askUser.detailsPrompt', { questions }))`.
3. The localized prompt template instructs the side task to: read the relevant
code and context first; mark unverified claims; explain each question's
background and every option's meaning, differences, impact, and risks; give
a recommendation with reasons; not answer for the user or change the main
session; write a self-contained single-file HTML (inline CSS, no external
resources) under `.qwen/tmp/` using ASD-STE100 sentence rules (one idea per
sentence, active voice, short sentences, one term per concept, conditions
before actions, lists for three or more items, no metaphors or
exclamations); keep code, commands, paths, and config keys verbatim; reply
with the file path and a one-sentence summary.
4. The HTML file appears as a turn output in the side-task chat and opens in
the existing right-panel HTML preview. The pending question stays pending;
the user returns to the main view to answer or Ignore.
## Design decisions
- **Side task, not lightweight btw.** The details conversation may need code
reading, file writing, and follow-up turns; a side task is a full child
session with inherited context, while `btwSessionStream` is a single-shot
answer. The HTML deliverable also requires file tools.
- **Prefilled prompt, not an empty draft.** The user's intent ("explain this
pending question") is known at click time; prefilling removes a typing step
and pins the scope. The user can still steer with follow-up prompts.
- **Prompt-carried style rules.** The side-task model does not share the
user's personal memory, so the controlled-language and HTML requirements
must be self-contained in the prompt. Only the core sentence rules are
included; heavier verification rituals stay out of the product default.
- **Optional prop, conditional render.** Hosts without a side-task surface
(embedded hosts, split panes, daemons without the capability) simply do not
pass the callback, and the button does not exist. No disabled-state UI.
- **UI language for the explanation.** The prompt template is localized
(en/zh-CN; ru falls back to en via the existing i18n fallback), so the
explanation language follows the UI language rather than the daemon's
output-language setting.
- **One details task per pending question.** The details tab uses a stable id
derived from the permission request id (a uuid; tabs are already bucketed
per parent session) and is upserted, not appended: repeat clicks focus the
existing tab instead of provisioning duplicate child sessions. The
question-to-child association is also persisted per parent session
(localStorage), so it outlives the tab: closing the details tab and clicking
again reopens the same child, and a child reopened from the side-task
catalog under its own `side-task:<sessionId>` tab is focused rather than
duplicated. Four lifecycle edges are covered: the association is recorded
even when the draft tab closes before creation resolves (the parent identity
is captured when creation starts, since the server does not cancel it); the
reopened tab retains the explanation prompt, and the pane's empty-session
guard sends it only while the child has no user prompt yet (media-only
messages — image, file, or resource link — count; they submit with empty
text), so a prompt interrupted by a close is delivered exactly once and
never re-sent; a catalog-reopened tab
carries no prompt of its own, so clicking details again supplies the pending
prompt on the matched tab before focusing it, letting the same guard deliver
it to a still-empty child; and archiving a child invalidates its
association, because the daemon's load route rejects
archived sessions (409 `session_archived`) — the next click provisions a
fresh child instead of attaching an unloadable one. Archive can happen from
any surface (the side-task panel, the sidebar session row, the overview
panel, the activity catalog) or another browser tab, so the binding is
validated at the read site: a click confirms the child is still in the
parent's active side-task list and forgets it otherwise, rather than trusting
the persisted id. When the open same-id tab is bound to a different child
than the validated binding (a tab restored from another browser tab's copy),
the upsert replaces it so the pane attaches to the active child instead of
keeping an unloadable pane. One entry per parent supersedes the resolved question's
association, keeping the store bounded; re-recording a parent moves it to the
newest key slot so the oldest-first eviction never drops a live, frequently
used parent. The tab id stays short because the pane derives its daemon
clientId as `side-task:<parentSessionId>:<tabId>` and the daemon rejects
client ids over 128 characters; an App-level test pins that budget with
uuid-length inputs through the real derivation. `/btw side` keeps its unique
per-invocation draft id.
- **The child is named from the question.** The explanation prompt leads with
the question snapshot, so the pane's generic first-prompt naming (the first
200 code points) yields a question-derived title in the tab strip and the
side-task catalog, instead of the identical instruction boilerplate for every
details task.
- **The snapshot carries selection semantics.** Multi-select questions are
marked `[multiSelect]` so the explanation covers combinable options without
relying on the inherited transcript alone.
- **The actions row wraps.** A fifth button can push the row past narrow
viewports, and a flex-end row overflows to the left — invisible to
`scrollWidth`. The row wraps, and a 320px multi-question regression test
asserts every action button stays inside the panel and stays clickable.
## Constraints and risks
- The side task inherits the parent transcript at creation time; the pending
question is part of that context, and the prompt embeds a snapshot as well,
so the explanation stays scoped even if the parent transcript is long.
- `.qwen/tmp/` is the agent's scratch directory (git-ignored in this repo).
In workspaces where it is not ignored, the file is still a normal workspace
file the user can delete; nothing is committed automatically.
- The button must not disturb the panel's keyboard contract: it lives in the
actions row, is not an option, and does not register digit shortcuts.
- Side-task creation can fail (daemon error, policy flip). Failure handling is
inherited from the existing `/btw side` path (toasts, tab guards); the
details action adds no new failure mode.
## Validation plan
- Unit: `formatAskUserQuestions` formatting, trimming, malformed-entry
skipping (`askUserPermission.test.ts`).
- Unit: button hidden without the callback; click invokes the callback without
submitting; outside the option group and digit shortcuts; disabled while
submitting (`AskUserQuestion.test.tsx`).
- Unit: App wiring — with `session_side_task`, the pending-question details
action creates a side-task tab whose `initialPrompt` contains the question
text, the STE100 instruction, and the `.qwen/tmp/` target; no
`submitPermission`/`sendPrompt` on the main session; without the capability
the callback is not offered (`App.test.tsx`).
- Visuals: `client/e2e/visuals/ask-user-details.spec.ts` (mock daemon, real
Chromium, dark/light, zh-CN UI) — the button renders on the pending panel,
clicking it opens the side-task tab, and the question stays pending.
- E2E: `.qwen/e2e-tests/` — real daemon, pending AskUser, click details,
verify side task creation, HTML artifact preview, and that the main question
remains pending. Executed 2026-11-06 against a real daemon and model; all
steps passed (evidence: `.qwen/e2e-tests/ask-details-e2e-20261106/`).
## Acceptance criteria
- With the side-task capability, a pending AskUser panel shows "Ask for
details"; clicking it opens a side task that starts explaining the question
without any main-session answer being submitted.
- Without the capability (or in embedded/split surfaces), the panel is
unchanged.
- Existing AskUser keyboard behavior (digits, arrows, Enter, Esc, Ctrl+Enter)
is unchanged.
@@ -0,0 +1,139 @@
# Ask User「问细节」侧边任务
[English](2026-11-06-ask-user-details-side-task.md) | [简体中文](2026-11-06-ask-user-details-side-task.zh-CN.md)
状态:已实现并验证(单元测试、mock daemon 视觉验证、真实 daemon + 真实模型
E2E 轮;证据见 `.qwen/e2e-tests/`)。
## 问题
当 `ask_user_question` 权限待回答时,Web Shell 会隐藏主输入框
(`composerHidden`),btw 快捷键也会忽略 `pendingApproval` 状态。用户只能选择
选项、输入自由文本答案或忽略。此时没有任何途径就待答问题本身追问——例如某个
选项是什么意思、会改动什么、有什么风险——除非先提交一个答案或丢弃这个问题。
## 现状
- `/btw <问题>` 走轻量侧边提问(`btwSessionStream`),不进入主对话上下文。
它只由主视图输入框处理。
- `/btw side <问题>` 在 daemon 声明 `session_side_task` 且存在工作区会话
(`sideTasksAvailable`)时创建侧边任务:`createSideTask(initialPrompt)`
打开右侧面板 tab,`createSideTaskSession` 创建继承父会话转录上下文的子会话。
tab 的 `initialPrompt` 作为子会话的首条提示词发送。
- 会话写出的文件会作为回合产物展示;`.html` 文件是可渲染产物,可在右侧面板的
HTML 预览中打开。
- 待答 AskUser 面板(`AskUserQuestion`)提供提交 / 忽略 / 上一题 / 下一题与
「其他」自由输入,但没有问细节的入口。
## 目标
- 让用户在作答前了解待答问题的细节,且不提交答案、不丢弃问题、不污染主对话。
- 端到端复用现有侧边任务基础设施;不改 daemon。
- 解释以自包含单文件 HTML 交付,用受控、无歧义的句式(ASD-STE100 句式规则)
写作,语言跟随 UI 语言。
## 非目标
- 不在 split view 面板或侧边任务面板内提供问细节入口。这与 `/btw side` 一致
(后者也只在主视图可用);按面板区分的变体需要侧边任务面板目前不具备的
按面板父会话作用域能力。
- 不新增 daemon 路由、能力声明或设置项。
- 不自动作答:侧边任务绝不能提交待答权限,也不能向主会话发送提示词。
## 方案
1. `AskUserQuestion` 接受可选 `onAskDetails?: () => void`。提供时在操作行渲染
「问细节」文本按钮(弱化样式,位于忽略左侧)。按钮在选项 radiogroup 之外,
选项数字快捷键与 roving-tabindex 导航不受影响。提交进行中时按钮禁用。
2. App 只为主视图 overlay 接线该回调,门控与 `/btw side` 相同
(`sideTasksAvailable`)。回调用 `formatAskUserQuestions(rawInput)` 格式化
待答载荷——语言中立的编号问题快照,含选项 label/description——然后调用
`createSideTask(t('askUser.detailsPrompt', { questions }))`。
3. 本地化提示词模板要求侧边任务:先阅读相关代码与上下文;未核实的结论要标注;
解释每个问题的背景与每个选项的含义、差异、影响与风险;给出建议并说明理由;
不替用户作答、不改动主会话;用 ASD-STE100 句式规则(一句一个意思、主动
语态、短句、同一概念一个词、条件在动作前、三项以上用列表、不用比喻和感叹号)
写出自包含单文件 HTML(内联 CSS、不引用外部资源),保存到 `.qwen/tmp/` 下;
代码、命令、路径、配置键名原样保留;完成后回复文件路径和一句话摘要。
4. HTML 文件作为回合产物出现在侧边任务聊天中,用现有右侧面板 HTML 预览打开。
待答问题保持待答;用户回到主视图作答或忽略。
## 设计决策
- **用侧边任务,不用轻量 btw。** 细节对话可能需要读代码、写文件与多轮追问;
侧边任务是继承上下文的完整子会话,而 `btwSessionStream` 是单发回答。HTML
交付也需要文件工具。
- **预填提示词,不是空草稿。** 点击时用户意图(「解释这个待答问题」)已知;
预填省去一次输入并锁定范围。用户仍可用后续提示词引导。
- **样式规则写进提示词。** 侧边任务模型不共享用户的个人记忆,受控语言与 HTML
要求必须在提示词中自包含。只包含核心句式规则;更重的校验仪式不进入产品
默认提示词。
- **可选 prop,条件渲染。** 没有侧边任务界面的宿主(嵌入式宿主、split 面板、
无该能力的 daemon)不传回调,按钮就不存在。不做禁用态 UI。
- **解释语言跟随 UI 语言。** 提示词模板本地化(en/zh-CN;ru 经现有 i18n 回退
机制落到 en),解释语言跟随 UI 语言,而不是 daemon 的输出语言设置。
- **每个待答问题一个细节任务。** 细节 tab 用权限请求 id(uuid;tab 已按父会话
分桶)派生的稳定 id,按 upsert 而非追加:重复点击聚焦已有 tab,不会重复创建
子会话。「问题→子会话」关联还按父会话持久化(localStorage),因此比 tab 更
长寿:关闭细节 tab 后再点击会重新打开同一子会话;从侧边任务目录以
`side-task:<sessionId>` tab 重新打开的子会话会被聚焦而非重复。四个生命周期
边界均有覆盖:草稿 tab 在创建 resolve 前关闭时关联仍会记录(父会话身份在创建
发起时捕获,服务端不会因关 tab 取消创建);重开的 tab 保留解释提示词,面板的
空会话守卫只在子会话尚无用户提示词时发送(仅图片/文件/资源链接的消息也算——
它们以空文本提交),因此被关 tab 打断的提示词恰好送达一次、绝不重发;目录重开
的 tab 自身不带提示词,再次点击问细节会先把待发送的提示词补到匹配的 tab 上再聚焦,
由同一守卫送达仍然为空的子会话;归档子会话会失效其关联——daemon 的 load
路由拒绝归档会话(409 `session_archived`),
下次点击会新建子会话而不是附着无法加载的旧会话。归档可能来自任何入口(侧边任务
面板、侧栏会话行、概览面板、activity 目录)或另一个浏览器标签,因此绑定在读取侧
校验:点击时确认子会话仍在父会话的活跃侧边任务列表中,否则遗忘它,而不是直接信任
持久化的 id。当开着的同 id tab 绑定的子会话与校验后的绑定不一致(例如从另一浏览器
标签的副本恢复的 tab)时,upsert 会替换它,使面板附着活跃子会话而非保留无法加载的
旧面板。每个父会话一条关联,新问题取代已解决问题的关联,存储有界;重记某个
父会话会把它移到最新的键槽,使按最旧优先的淘汰不会丢掉仍在频繁使用的父会话。
tab id 必须短——
面板派生的 daemon clientId 形如 `side-task:<parentSessionId>:<tabId>`,daemon 拒绝超过
128 字符的 clientId;App 级测试用 uuid 长度输入经真实派生钉住该预算。
`/btw side` 保持每次调用唯一的草稿 id。
- **子会话以问题命名。** 解释提示词以问题快照开头,因此面板通用的首提示命名(前 200
个码点)在 tab 条与侧边任务目录里得到由问题派生的标题,而不是每个细节任务都相同的
指令 boilerplate。
- **快照携带选择语义。** 多选问题标记 `[multiSelect]`,让解释覆盖可组合的
选项,而不只依赖继承的转录。
- **操作行可换行。** 第五个按钮可能把行撑出窄视口,而 flex-end 行的溢出发生
在左侧——`scrollWidth` 测不到。操作行改为可换行,并用 320px 双问题回归
测试断言每个操作按钮都在面板内且可点击。
## 约束与风险
- 侧边任务在创建时继承父会话转录;待答问题属于该上下文,提示词又内嵌了快照,
即使父转录很长,解释也能保持聚焦。
- `.qwen/tmp/` 是 agent 的临时目录(本仓库 git 忽略)。在未忽略它的工作区里,
该文件也只是用户可删除的普通工作区文件;不会自动提交任何内容。
- 按钮不得破坏面板的键盘契约:它位于操作行,不是选项,不注册数字快捷键。
- 侧边任务创建可能失败(daemon 错误、策略翻转)。失败处理沿用现有 `/btw side`
路径(toast、tab 守卫);问细节入口不引入新的失败模式。
## 验证计划
- 单元:`formatAskUserQuestions` 的格式化、去空白、跳过畸形条目
(`askUserPermission.test.ts`)。
- 单元:无回调时按钮隐藏;点击触发回调且不提交;按钮在选项组与数字快捷键之外;
提交进行中禁用(`AskUserQuestion.test.tsx`)。
- 单元:App 接线——有 `session_side_task` 时,待答问题的问细节动作创建侧边
任务 tab,其 `initialPrompt` 含问题文本、STE100 指令与 `.qwen/tmp/` 目标;
主会话无 `submitPermission`/`sendPrompt`;无该能力时不提供回调
(`App.test.tsx`)。
- 视觉:`client/e2e/visuals/ask-user-details.spec.ts`(mock daemon、真实
Chromium、dark/light、zh-CN UI)——按钮在待答面板渲染,点击打开侧边任务
tab,问题保持待答。
- E2E:`.qwen/e2e-tests/` —— 真实 daemon、待答 AskUser、点击问细节,
验证侧边任务创建、HTML 产物预览,以及主问题保持待答。2026-11-06 已用真实
daemon 与模型执行,全部通过(证据:`.qwen/e2e-tests/ask-details-e2e-20261106/`)。
## 验收标准
- 有侧边任务能力时,待答 AskUser 面板显示「问细节」;点击后打开侧边任务并
开始解释问题,主会话不提交任何答案。
- 无该能力(或嵌入式/split 界面)时,面板保持不变。
- 现有 AskUser 键盘行为(数字、方向键、Enter、Esc、Ctrl+Enter)不变。
File diff suppressed because it is too large. Load diff
+293 -10
View File
@@ -378,7 +378,10 @@ import {
} from './settings';
import { SettingsMessage } from './components/messages/SettingsMessage';
import { WorkspaceDirectoriesSection } from './components/workspaces/WorkspaceDirectoriesSection';
import { isAskUserPermission } from './utils/askUserPermission';
import {
formatAskUserQuestions,
isAskUserPermission,
} from './utils/askUserPermission';
import { ToolApproval } from './components/messages/ToolApproval';
import { AskUserQuestion } from './components/messages/AskUserQuestion';
import { HelpDialog } from './components/dialogs/HelpDialog';
@@ -1976,6 +1979,8 @@ const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed';
const ENVIRONMENT_PANEL_OPEN_STORAGE_KEY =
'qwen-code-web-shell-environment-panel-open';
const RIGHT_PANEL_STATE_STORAGE_KEY = 'qwen-code-web-shell-right-panel-state';
const DETAILS_CHILDREN_STORAGE_KEY = 'qwen-code-web-shell-details-children';
const DETAILS_TAB_ID_PREFIX = 'side-task:details:';
function resolveSidebarOptions(sidebar: WebShellProps['sidebar']): {
enabled: boolean;
@@ -2094,6 +2099,67 @@ function writeStoredBooleanMap(
}
}
// Pending AskUser request -> details side-task child, per parent session.
// Survives closing the details tab (and reloads) so "Ask for details" reopens
// the existing child instead of provisioning a duplicate. One entry per
// parent: a session has at most one pending question, so a newer details
// task supersedes the resolved question's association.
type StoredDetailsChildren = Record<string, Record<string, string>>;
function readStoredDetailsChildren(key: string): StoredDetailsChildren {
if (typeof window === 'undefined') return {};
try {
const stored = window.localStorage.getItem(key);
if (!stored) return {};
const parsed = JSON.parse(stored) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
const storedStates = { ...(parsed as Record<string, unknown>) };
if (storedStates['v'] !== undefined && storedStates['v'] !== 1) return {};
delete storedStates['v'];
const result: StoredDetailsChildren = {};
for (const [sessionKey, value] of Object.entries(storedStates).slice(
-MAX_ARTIFACT_PANEL_SESSION_STATES,
)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
continue;
}
const entries: Record<string, string> = {};
for (const [requestId, childId] of Object.entries(
value as Record<string, unknown>,
)) {
if (typeof childId === 'string' && childId)
entries[requestId] = childId;
}
if (Object.keys(entries).length > 0) result[sessionKey] = entries;
}
return result;
} catch {
// localStorage can be unavailable in private or embedded contexts.
}
return {};
}
function writeStoredDetailsChildren(
key: string,
value: StoredDetailsChildren,
): void {
try {
while (Object.keys(value).length > MAX_ARTIFACT_PANEL_SESSION_STATES) {
const oldestSessionKey = Object.keys(value)[0];
if (!oldestSessionKey) break;
delete value[oldestSessionKey];
}
window.localStorage.setItem(key, JSON.stringify({ v: 1, ...value }));
} catch (error) {
console.warn(
'[web-shell] failed to persist details side-task associations:',
error,
);
}
}
interface ArtifactPanelPersistedState {
open: boolean;
activeTabId: string | null;
@@ -5216,6 +5282,13 @@ export function App({
const artifactPanelPersistedStatesRef = useRef(
initialArtifactPanelPersistedStates,
);
const [initialDetailsChildren] = useState(() =>
readStoredDetailsChildren(DETAILS_CHILDREN_STORAGE_KEY),
);
const detailsChildrenRef = useRef(initialDetailsChildren);
// Details creations that are still in flight, keyed by tab id: the parent
// identity must survive the draft tab closing before creation resolves.
const detailsParentByTabRef = useRef(new Map<string, string>());
const initialArtifactPanelOpen = Boolean(
panelStateKey && initialArtifactPanelPersistedStates[panelStateKey]?.open,
);
@@ -5690,22 +5763,87 @@ export function App({
!archivedSideTaskCatalog.loaded)));
const nextSideTaskTabIdRef = useRef(0);
const createSideTask = useCallback(
(initialPrompt?: string) => {
(
initialPrompt?: string,
detailsRequestId?: string,
existingChildSessionId?: string,
) => {
const parentSessionId = connection.sessionId;
if (!parentSessionId || !sideTasksAvailable) return false;
// This question already has a details child: focus whichever tab shows
// it (the details tab or a catalog reopen) instead of adding a second
// tab for the same session.
if (existingChildSessionId) {
const openTab = artifactPanelTabsRef.current.find(
(item): item is Extract<ArtifactPanelTab, { kind: 'side_task' }> =>
item.kind === 'side_task' &&
item.sessionId === existingChildSessionId,
);
if (openTab) {
// A catalog reopen carries no prompt. If the original details tab
// was closed before the pane admitted it, the child is still empty;
// supply the pending prompt so the pane's empty-session guard can
// deliver it (it never re-sends once the child has a user prompt).
const prompt = initialPrompt?.trim();
if (prompt && openTab.initialPrompt !== prompt) {
setArtifactPanelTabs((tabs) =>
tabs.map((tab) =>
tab.id === openTab.id ? { ...tab, initialPrompt: prompt } : tab,
),
);
}
setActiveArtifactPanelTabId(openTab.id);
setArtifactPanelOpen(true);
return true;
}
}
// A details side task is one-per-pending-question: repeat clicks focus
// the existing tab instead of provisioning a duplicate child session.
// /btw side keeps the unique draft id — each invocation is a new task.
// The id must stay short: SideTaskPanel derives the pane's clientId as
// `side-task:<parentSessionId>:<tabId>` and the daemon rejects client
// ids over 128 chars. The request id alone is unique (uuid); tabs are
// already bucketed per parent session.
const id = detailsRequestId
? `${DETAILS_TAB_ID_PREFIX}${detailsRequestId}`
: `side-task:draft:${Date.now()}:${++nextSideTaskTabIdRef.current}`;
const tab: ArtifactPanelTab = {
id: `side-task:draft:${Date.now()}:${++nextSideTaskTabIdRef.current}`,
id,
kind: 'side_task',
title: t('sideTask.title'),
parentSessionId,
workspaceCwd: connection.workspaceCwd,
nameFromFirstPrompt: true,
...(initialPrompt?.trim()
? { initialPrompt: initialPrompt.trim() }
: {}),
// Reopening an existing child attaches to it directly. Keep the
// prompt on the reopened tab: creation resolves before the pane
// admits the prompt, so a tab closed in that window leaves an empty
// child. SideTaskPanel's empty-session guard sends the prompt only
// while the child has no user prompt yet, and never re-sends it.
...(existingChildSessionId
? {
sessionId: existingChildSessionId,
...(initialPrompt?.trim()
? { initialPrompt: initialPrompt.trim() }
: {}),
}
: {
nameFromFirstPrompt: true,
...(initialPrompt?.trim()
? { initialPrompt: initialPrompt.trim() }
: {}),
}),
};
setArtifactPanelTabs((tabs) => [...tabs, tab]);
setActiveArtifactPanelTabId(tab.id);
setArtifactPanelTabs((tabs) => {
if (!tabs.some((item) => item.id === id)) return [...tabs, tab];
// A same-id tab that reaches this point is bound to a different child
// than the one being reopened: the openTab lookup above already focused
// any tab showing existingChildSessionId, so a survivor carries a stale
// sessionId (an archived child the read-site check forgot, or a binding
// copied from another browser tab). Replace it so the pane attaches to
// the right child instead of re-focusing an unloadable pane. The id
// stays the same (clientId budget).
return tabs.map((item) => (item.id === id ? tab : item));
});
setActiveArtifactPanelTabId(id);
setArtifactPanelOpen(true);
return true;
},
@@ -5772,6 +5910,11 @@ export function App({
(tabId: string, parentSessionId: string, title: string) => {
const pending = sideTaskCreationPromisesRef.current.get(tabId);
if (pending) return pending;
if (tabId.startsWith(DETAILS_TAB_ID_PREFIX)) {
// Capture the parent identity now: the draft tab may close before
// creation resolves, and the association must still be recorded.
detailsParentByTabRef.current.set(tabId, parentSessionId);
}
const creation = (async () => {
const owner = sessionOwnerGuard.capture();
const ownerCwd = connection.workspaceCwd;
@@ -5799,7 +5942,15 @@ export function App({
sessionId: session.sessionId,
displayName: session.displayName,
};
})().finally(() => sideTaskCreationPromisesRef.current.delete(tabId));
})()
.catch((error: unknown) => {
// A failed creation never reaches handleSideTaskCreated, so release
// the captured parent identity here or it leaks until reload. The
// success path keeps it: handleSideTaskCreated reads then deletes it.
detailsParentByTabRef.current.delete(tabId);
throw error;
})
.finally(() => sideTaskCreationPromisesRef.current.delete(tabId));
sideTaskCreationPromisesRef.current.set(tabId, creation);
return creation;
},
@@ -5871,6 +6022,33 @@ export function App({
break;
}
}
if (tabId.startsWith(DETAILS_TAB_ID_PREFIX)) {
// Remember which child explains this pending question so closing the
// tab (or reloading) cannot trick "Ask for details" into provisioning
// a duplicate. Record it even when the draft tab already closed:
// server-side creation is not cancelled by closing the tab. One entry
// per parent supersedes the resolved question's association.
const requestId = tabId.slice(DETAILS_TAB_ID_PREFIX.length);
const parentId =
(createdTab?.kind === 'side_task'
? createdTab.parentSessionId
: undefined) ?? detailsParentByTabRef.current.get(tabId);
detailsParentByTabRef.current.delete(tabId);
if (requestId && parentId) {
// Re-insert the parent so a re-recorded entry moves to the newest
// key slot: the writer evicts the oldest key, and a plain spread
// would leave a live, frequently re-recorded parent first in line
// (see utils/bounded-map.ts).
const nextParents = { ...detailsChildrenRef.current };
delete nextParents[parentId];
nextParents[parentId] = { [requestId]: sessionId };
detailsChildrenRef.current = nextParents;
writeStoredDetailsChildren(
DETAILS_CHILDREN_STORAGE_KEY,
detailsChildrenRef.current,
);
}
}
const sideTaskTab =
createdTab?.kind === 'side_task' ? createdTab : undefined;
if (!sideTaskTab) return;
@@ -8392,6 +8570,8 @@ export function App({
? pendingApproval
: null
: null;
const pendingAskUserApprovalRef = useRef(pendingAskUserApproval);
pendingAskUserApprovalRef.current = pendingAskUserApproval;
const pendingToolApproval =
pendingApproval && !isAskUserPermission(pendingApproval)
? canActOnPendingApproval
@@ -12202,6 +12382,30 @@ export function App({
},
[pushToast, t],
);
// Drop any details association pointing at one of these children. Shared by
// the panel archive path and the read-site check so every route that retires
// a child forgets it the same way; an archived child's load 409s
// (session_archived), so a surviving binding would trap later clicks.
const forgetDetailsChildren = useCallback((childIds: ReadonlySet<string>) => {
if (childIds.size === 0) return;
const next: StoredDetailsChildren = {};
let changed = false;
for (const [parentKey, entries] of Object.entries(
detailsChildrenRef.current,
)) {
const kept = Object.fromEntries(
Object.entries(entries).filter(([, childId]) => !childIds.has(childId)),
);
if (Object.keys(kept).length !== Object.keys(entries).length) {
changed = true;
}
if (Object.keys(kept).length > 0) next[parentKey] = kept;
}
if (changed) {
detailsChildrenRef.current = next;
writeStoredDetailsChildren(DETAILS_CHILDREN_STORAGE_KEY, next);
}
}, []);
const archiveSideTasks = useCallback(
async (sideTasks: readonly SideTaskListItem[]) => {
if (!sideTaskArchiveAvailable) return;
@@ -12264,6 +12468,11 @@ export function App({
),
}));
removeSideTaskTabs(archived);
// An archived child can no longer be attached (the daemon's load
// route 409s session_archived), so drop any details association
// pointing at it; the next click provisions a fresh child instead
// of reopening an unloadable one.
forgetDetailsChildren(archived);
setSideTaskCatalogRefreshNonce((nonce) => nonce + 1);
}
const errorMessages = [
@@ -12292,6 +12501,7 @@ export function App({
}
},
[
forgetDetailsChildren,
reportError,
removeSideTaskTabs,
sideTaskArchiveAvailable,
@@ -19869,6 +20079,74 @@ export function App({
[sessionActions],
);
// The composer is hidden while a question is pending, so the panel itself
// offers the /btw-side escape hatch: a side task that explains the question
// without answering it or touching the main session.
const handleAskUserDetails = useCallback(async () => {
const request = pendingAskUserApproval;
const parentSessionId = connection.sessionId;
const workspaceCwd = connection.workspaceCwd;
if (!request || !parentSessionId) return;
// The association outlives the details tab (and reloads): a closed tab
// must reopen its existing child, not provision a second one.
const existingChild =
detailsChildrenRef.current[parentSessionId]?.[request.id];
const questions = formatAskUserQuestions(request.rawInput);
const prompt = t('askUser.detailsPrompt', { questions });
let childToReuse: string | undefined = existingChild;
if (existingChild && workspaceCwd) {
// A child can be archived from any surface (sidebar, overview, activity
// catalog) or another browser tab, none of which touch this binding.
// Confirm it is still active before attaching: an archived child's load
// 409s (session_archived), which would otherwise trap every later click.
// On a query failure fall back to the persisted binding (no worse than
// trusting it outright).
try {
const page = await loadSessionCatalogOnce(
workspace.client,
{
routeKind: 'legacy',
workspaceCwd,
options: {
pageSize: SESSION_LIST_PAGE_SIZE,
archiveState: 'active',
sourceType: WEB_SHELL_SIDE_TASK_SOURCE_TYPE,
sourceId: parentSessionId,
},
},
{ fresh: true },
);
if (
!page.sessions.some((session) => session.sessionId === existingChild)
) {
forgetDetailsChildren(new Set([existingChild]));
childToReuse = undefined;
}
} catch {
// Keep the persisted binding when the check cannot run.
}
}
// The catalog query is async: the question may have been answered, ignored
// or cancelled, or the user may have switched sessions, during the
// round-trip. Re-check before opening a tab so we do not add a details tab
// for a resolved question or into another session's tab list.
if (
pendingAskUserApprovalRef.current?.id !== request.id ||
connectionRef.current.sessionId !== parentSessionId
) {
return;
}
createSideTask(prompt, request.id, childToReuse);
}, [
pendingAskUserApproval,
connection.sessionId,
connection.workspaceCwd,
createSideTask,
forgetDetailsChildren,
t,
workspace.client,
]);
// Runs once the daemon has settled the cancelled turn, so the transcript
// holds everything the turn produced.
const takeBackCancelledPrompt = useCallback(
@@ -24756,6 +25034,11 @@ export function App({
variant="floating"
keyboardActive={askUserOverlayVisible}
customInputLabel={askUserFreeTextLabel}
onAskDetails={
sideTasksAvailable
? handleAskUserDetails
: undefined
}
/>
</div>
)}
@@ -3,7 +3,7 @@
import { act, type ComponentProps, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n';
import { I18nProvider, getTranslator } from '../../i18n';
const {
connection,
@@ -179,6 +179,31 @@ it('creates a side task and reports the new session id', async () => {
expect(onTitleChange).toHaveBeenCalledWith('side-task:draft:1', 'Side task');
});
it('keeps the derived clientId within the daemon limit for a details tab', async () => {
// The daemon rejects client ids over 128 chars (MAX_CLIENT_ID_LENGTH).
// A details tab id carries a uuid request id; with a uuid parent session
// the derived `side-task:<parent>:<tabId>` must still fit. A longer id
// 400s the pane's load and the initial prompt is never sent.
const parentId = 'da424824-c6a7-4ce5-a5fd-9b12c9255bef';
const requestId = '06a69fb6-7293-4f61-a279-e7b3963b1cd3';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
renderSideTask({
tabId: `side-task:details:${requestId}`,
parentSessionId: parentId,
sessionId: 'side-session-1',
});
await Promise.resolve();
});
const clientId = providerProps.current?.['clientId'];
expect(typeof clientId).toBe('string');
expect((clientId as string).length).toBeLessThanOrEqual(128);
});
it('does not report a failed creation after the draft unmounts', async () => {
let rejectCreation: ((error: Error) => void) | undefined;
const createSession = vi.fn(
@@ -639,6 +664,127 @@ it('sends the /btw question as the first side-task prompt', async () => {
);
});
it('does not re-send a retained initial prompt to a child that already has a user prompt', async () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
transcript.blocks = [{ kind: 'user', text: 'the explanation already ran' }];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
renderSideTask({ initialPrompt: 'Explain the pending question' });
await Promise.resolve();
});
expect(sendPrompt).not.toHaveBeenCalled();
});
it.each([
['image', { images: [{ data: 'AQID', mimeType: 'image/png' }] }],
['file', { files: [{ name: 'notes.txt', mimeType: 'text/plain' }] }],
[
'resource link',
{
resourceLinks: [
{
type: 'resource_link',
uri: 'file:///tmp/notes.txt',
name: 'notes.txt',
},
],
},
],
])(
'does not re-send a retained initial prompt to a child whose only user prompt is %s-only',
async (_label, media) => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
// Media-only prompts submit with empty text; the child is not empty.
transcript.blocks = [{ kind: 'user', text: '', ...media }];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
renderSideTask({ initialPrompt: 'Explain the pending question' });
await Promise.resolve();
});
expect(sendPrompt).not.toHaveBeenCalled();
},
);
it.each([
['en', 'The main session is waiting'],
['zh-CN', '主会话正在等我'],
])(
'names a details child from the question, not the instruction boilerplate (%s)',
async (language, boilerplatePrefix) => {
// R1-1 / R2-4: both the EN and zh-CN details prompts lead with the question
// snapshot so the generic first-prompt naming (first 200 code points) yields
// a question-derived title rather than the identical instruction prefix.
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const question = 'Which cache backend should the session use?';
const questions = `1. ${question}\n - SQLite: local file database\n - Redis: in-memory store`;
const detailsPrompt = getTranslator(language as 'en' | 'zh-CN')(
'askUser.detailsPrompt',
{ questions },
);
await act(async () => {
renderSideTask({
initialPrompt: detailsPrompt,
modelManagement: { allowAdd: false },
});
await Promise.resolve();
});
expect(sendPrompt).toHaveBeenCalledWith(
detailsPrompt,
expect.objectContaining({ onAdmitted: expect.any(Function) }),
);
const title = renameSession.mock.calls[0]?.[0] as string;
expect(title).toContain(question);
expect(title.startsWith(boilerplatePrefix)).toBe(false);
},
);
it('sends a retained initial prompt once transcript loading completes', async () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
connection.loadingTranscript = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
renderSideTask({ initialPrompt: 'Explain the pending question' });
await Promise.resolve();
});
expect(sendPrompt).not.toHaveBeenCalled();
connection.loadingTranscript = false;
await act(async () => {
renderSideTask({ initialPrompt: 'Explain the pending question' });
await Promise.resolve();
});
expect(sendPrompt).toHaveBeenCalledTimes(1);
expect(sendPrompt).toHaveBeenCalledWith(
'Explain the pending question',
expect.objectContaining({ onAdmitted: expect.any(Function) }),
);
});
it('does not patch a different workspace after side-task admission', async () => {
connection.sessionId = 'side-session-1';
connection.workspaceCwd = '/work/other';
@@ -261,18 +261,32 @@ function SideTaskSession({
? block.text.trim().length > 0
: !block.images?.length),
);
const restoredEmptySession =
// Media-only prompts (image, file, or resource link) arrive with empty text
// (see userBlockBoundaryKey in DaemonSessionProvider). They leave the
// session unnamed, but they are still user interaction: a retained
// auto-prompt must not fire on top.
const hasUserInteraction = blocks.some((block) => {
if (block.kind !== 'user') return false;
if (
block.images?.length ||
block.files?.length ||
block.resourceLinks?.length
)
return true;
return typeof block.text !== 'string' || block.text.trim().length > 0;
});
const transcriptSettled =
connection.status === 'connected' &&
!connection.loadingTranscript &&
!connection.catchingUp &&
!transcriptHistory.loading &&
!transcriptHistory.hasMore &&
!transcriptHistory.capacityReached &&
!transcriptHistory.paginationError &&
!hasTextualUserPrompt;
!transcriptHistory.paginationError;
const restoredEmptySession = transcriptSettled && !hasUserInteraction;
const canNameFromFirstPrompt =
!hasTextualUserPrompt &&
(shouldNameFromFirstPrompt || restoredEmptySession);
(shouldNameFromFirstPrompt || (transcriptSettled && !hasTextualUserPrompt));
useEffect(() => {
const displayName = connection.displayName?.trim();
if (displayName) onTitleChange(tabId, displayName);
@@ -347,6 +347,7 @@
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
@@ -410,7 +411,8 @@
height: 14px;
}
.ignoreButton {
.ignoreButton,
.detailsButton {
display: inline-flex;
align-items: center;
gap: 5px;
@@ -426,11 +428,13 @@
line-height: 22px;
}
.ignoreButton:hover {
.ignoreButton:hover,
.detailsButton:hover {
color: var(--foreground);
}
.ignoreButton:disabled {
.ignoreButton:disabled,
.detailsButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@@ -100,6 +100,7 @@ afterEach(() => {
function rerender(
keyboardActive?: boolean,
req: PermissionRequest = request,
onAskDetails?: () => void,
): void {
act(() =>
root!.render(
@@ -109,6 +110,7 @@ function rerender(
onConfirm={onConfirm}
onError={onError}
keyboardActive={keyboardActive}
onAskDetails={onAskDetails}
/>
</I18nProvider>,
),
@@ -118,11 +120,12 @@ function rerender(
function render(
keyboardActive?: boolean,
req: PermissionRequest = request,
onAskDetails?: () => void,
): void {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
rerender(keyboardActive, req);
rerender(keyboardActive, req, onAskDetails);
}
function optionButtons(): HTMLButtonElement[] {
@@ -1586,3 +1589,75 @@ it('allows spaces in a custom answer while blocking modifier-Enter under hover-o
).toBe(true);
expect(onConfirm).not.toHaveBeenCalled();
});
describe('ask-for-details action', () => {
function detailsButton(): HTMLButtonElement | null {
return (
Array.from(container!.querySelectorAll<HTMLButtonElement>('button')).find(
(b) => b.textContent === 'Ask for details',
) ?? null
);
}
it('stays hidden when the host provides no details callback', () => {
render();
expect(detailsButton()).toBeNull();
});
it('opens the details side task without answering the question', () => {
const onAskDetails = vi.fn();
render(undefined, request, onAskDetails);
const button = detailsButton();
expect(button).not.toBeNull();
expect(button!.title).toBe(
'Open a side task that explains this question before you answer',
);
act(() => button!.click());
expect(onAskDetails).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
expect(submitButton()!.disabled).toBe(false);
});
it('stays out of the option group and its digit shortcuts', () => {
const onAskDetails = vi.fn();
render(undefined, request, onAskDetails);
const button = detailsButton()!;
expect(optionButtons()).not.toContain(button);
// Move the committed answer off the default (option 0) so a leaked digit
// would observably rewrite it; asserting option 0 stays selected otherwise
// passes even when the digit leaks, because '1' maps to the default.
const opts = optionButtons();
act(() => opts[0]!.focus());
pressKey(opts[0]!, 'ArrowDown'); // Red -> Blue
expect(optionButtons()[0]!.getAttribute('aria-checked')).toBe('false');
act(() => button.focus());
pressKey(button, '1', { cancelable: true });
expect(onAskDetails).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
// The digit did not leak into the option group's shortcuts: the committed
// answer is still Blue, not reset to option 0.
expect(optionButtons()[0]!.getAttribute('aria-checked')).toBe('false');
expect(optionButtons()[1]!.getAttribute('aria-checked')).toBe('true');
});
it('is disabled while a submission is in flight', async () => {
const pending = deferred<boolean>();
onConfirm.mockReturnValue(pending.promise);
const onAskDetails = vi.fn();
render(undefined, request, onAskDetails);
act(() => submitButton()!.click());
expect(detailsButton()!.disabled).toBe(true);
await act(async () => {
pending.resolve(true);
await pending.promise;
});
expect(onAskDetails).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,12 @@ interface AskUserQuestionProps {
onError: (error: unknown, fallback: string) => void;
variant?: 'inline' | 'floating';
customInputLabel?: string;
/**
* Opens a side task that explains the pending question before the user
* answers. Omitted when the host has no side-task surface, which hides the
* action entirely.
*/
onAskDetails?: () => void;
/**
* Whether this question should pull keyboard focus to its first option when it
* becomes the topmost one. Defaults to true. Split-view panes pass false so an
@@ -53,6 +59,7 @@ export function AskUserQuestion({
variant = 'inline',
keyboardActive = true,
customInputLabel,
onAskDetails,
}: AskUserQuestionProps) {
const submitShortcutLabel =
typeof navigator !== 'undefined' &&
@@ -929,6 +936,17 @@ export function AskUserQuestion({
<p className={styles.shortcuts} title={shortcutHint}>
{shortcutHint}
</p>
{onAskDetails && (
<button
type="button"
className={styles.detailsButton}
disabled={submitting}
title={t('askUser.askDetailsHint')}
onClick={onAskDetails}
>
{t('askUser.askDetails')}
</button>
)}
<button
type="button"
className={styles.ignoreButton}
@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, test } from '@playwright/test';
import {
createWebShellDaemonScenario,
permissionRequestEvent,
} from '../utils/mockDaemon';
import {
captureScreenshot,
gotoSession,
installScenario,
resolveBaseURL,
VISUAL_VIEWPORT,
type VisualTheme,
} from './harness';
test.use({ viewport: { ...VISUAL_VIEWPORT } });
const THEMES: readonly VisualTheme[] = ['dark', 'light'];
// Same wire shape the browser-notifications spec uses for a live AskUser
// question: toolCall._meta.toolName plus rawInput.questions.
function askUserQuestionEvent(sessionId: string) {
return permissionRequestEvent('ask-details-visual', {
id: 1,
sessionId,
toolCall: {
toolCallId: 'ask-details-visual',
title: 'Ask user 1 question',
kind: 'think',
_meta: { toolName: 'ask_user_question' },
rawInput: {
questions: [
{
header: 'Backend',
question: '缓存用哪种存储后端?',
options: [
{ label: 'SQLite', description: '本地文件数据库,无需额外服务' },
{ label: 'Redis', description: '内存存储,需要常驻服务' },
],
},
],
},
},
});
}
for (const theme of THEMES) {
test(`ask-user details action opens a side task (${theme})`, async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({ events: [] });
scenario.capabilities.features.push('session_side_task');
const daemon = await installScenario(
page,
scenario,
resolveBaseURL(testInfo),
);
await page.route(`**/session/${scenario.sessionId}/side-task`, (route) =>
route.fulfill({
json: {
sessionId: 'ask-details-side-task',
workspaceCwd: scenario.workspaceCwd,
attached: false,
clientId: 'side-task-fixture-client',
createdAt: '2026-11-06T00:00:00.000Z',
hasActivePrompt: false,
displayName: 'Side task',
parentSessionId: scenario.sessionId,
state: scenario.state,
},
}),
);
await gotoSession(page, scenario, daemon, theme, { language: 'zh-CN' });
await daemon.sendEvent(askUserQuestionEvent(scenario.sessionId));
const panel = page.locator('[data-web-shell-ask-panel]');
await expect(panel).toBeVisible();
const details = panel.getByRole('button', { name: '问细节' });
await expect(details).toBeVisible();
await expect(details).toHaveAttribute(
'title',
'打开侧边任务,在作答前解释这个问题',
);
await captureScreenshot(page, `ask-user-details-panel-${theme}`);
await details.click();
await expect(page.locator('button[title="侧边任务"]')).toBeVisible();
await captureScreenshot(page, `ask-user-details-side-task-${theme}`);
// The question stays pending: the details action never answers it.
await expect(panel).toBeVisible();
await expect(panel.getByText('缓存用哪种存储后端?')).toBeVisible();
});
}
test.describe('narrow viewport', () => {
test.use({ viewport: { width: 320, height: 700 } });
// A flex-end row without wrap overflows to the LEFT, which scrollWidth
// does not report — assert every action button's box stays inside the
// panel instead, and prove the details button is actually clickable.
test('details button stays reachable on a multi-question panel', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
events: [
permissionRequestEvent('ask-details-narrow', {
id: 1,
toolCall: {
toolCallId: 'ask-details-narrow',
title: 'Ask user 2 questions',
kind: 'think',
_meta: { toolName: 'ask_user_question' },
rawInput: {
questions: [
{
header: 'Backend',
question:
'Which storage backend should the session cache use?',
options: [
{ label: 'SQLite', description: 'Local file database' },
{ label: 'Redis', description: 'In-memory store' },
],
},
{
header: 'TTL',
question: 'How long should entries live?',
options: [
{ label: '5 minutes', description: 'Short' },
{ label: '1 hour', description: 'Long' },
],
},
],
},
},
}),
],
});
scenario.capabilities.features.push('session_side_task');
const daemon = await installScenario(
page,
scenario,
resolveBaseURL(testInfo),
);
await page.route(`**/session/${scenario.sessionId}/side-task`, (route) =>
route.fulfill({
json: {
sessionId: 'ask-details-narrow-side-task',
workspaceCwd: scenario.workspaceCwd,
attached: false,
clientId: 'side-task-fixture-client',
createdAt: '2026-11-06T00:00:00.000Z',
hasActivePrompt: false,
displayName: 'Side task',
parentSessionId: scenario.sessionId,
state: scenario.state,
},
}),
);
await gotoSession(page, scenario, daemon, 'dark', { language: 'en' });
const panel = page.locator('[data-web-shell-ask-panel]');
await expect(panel).toBeVisible();
const overflow = await panel.evaluate((el) => {
const panelRect = el.getBoundingClientRect();
const actions = el.querySelector('div[class*="actions"]')!;
return Array.from(actions.querySelectorAll('button'))
.map((b) => {
const r = b.getBoundingClientRect();
return {
text: (b.textContent || '').trim().slice(0, 20),
left: Math.round(r.left - panelRect.left),
right: Math.round(panelRect.right - r.right),
};
})
.filter((b) => b.left < 0 || b.right < 0);
});
expect(overflow).toEqual([]);
const details = panel.getByRole('button', { name: 'Ask for details' });
await expect(details).toBeVisible();
await details.click();
await expect(page.locator('button[title="Side task"]')).toBeVisible();
});
});
+17
View File
@@ -1377,6 +1377,15 @@ const EN: Messages = {
'askUser.submitFailed': 'Failed to submit answer',
'askUser.submitOptionUnavailable': 'Submit option is unavailable',
'askUser.ignore': 'Ignore',
'askUser.askDetails': 'Ask for details',
'askUser.askDetailsHint':
'Open a side task that explains this question before you answer',
'askUser.detailsPrompt': (v) =>
`${v?.questions ?? ''}
The main session is waiting for me to answer the questions above. I want to understand the details before answering. Read the relevant code and context first. Base every claim on what you verify, and mark anything unverified.
Write the explanation as a self-contained single-file HTML (inline CSS, no external resources) in English. Borrow the ASD-STE100 controlled-language sentence rules: one idea per sentence, active voice, short sentences (about 20 words max), one term per concept, conditions before actions, lists for three or more items, no metaphors or exclamations. Keep code, commands, paths, and config keys verbatim. Cover the background of each question, every option's meaning, differences, impact and risks, and a recommendation with reasons. Do not answer the questions for me and do not change the main session. Save the file under .qwen/tmp/ (for example .qwen/tmp/ask-details.html), then reply with its path and a one-sentence summary.`,
'askUser.multiHint': 'multiple choice',
'askUser.progress': (v) => `${v?.current ?? 0}/${v?.total ?? 0} questions`,
'askUser.selectAnswer': 'Select an answer',
@@ -6364,6 +6373,14 @@ const ZH: Messages = {
'askUser.submitFailed': '提交回答失败',
'askUser.submitOptionUnavailable': '提交选项不可用',
'askUser.ignore': '忽略',
'askUser.askDetails': '问细节',
'askUser.askDetailsHint': '打开侧边任务,在作答前解释这个问题',
'askUser.detailsPrompt': (v) =>
`${v?.questions ?? ''}
主会话正在等我回答上面的问题。我想先了解细节,再作答。请先阅读相关代码与上下文。结论以你核实到的事实为准,未核实的内容要标注。
请把解释写成自包含单文件 HTML(内联 CSS,不引用外部资源),用中文写作。借鉴 ASD-STE100 受控句式:一句一个意思、主动语态、每句不超过 20 字(代码与标识符按 1 词计)、同一概念只用一个词、条件写在动作之前、三项以上用列表、不用比喻和感叹号。代码、命令、路径、配置键名原样保留。内容覆盖:每个问题的背景;每个选项的含义、差异、影响与风险;给出建议并说明理由。不要替我作答,也不要改动主会话。文件保存到 .qwen/tmp/ 下(例如 .qwen/tmp/ask-details.html),完成后回复文件路径和一句话摘要。`,
'askUser.multiHint': '可多选',
'askUser.progress': (v) => `${v?.current ?? 0}/${v?.total ?? 0} 个问题`,
'askUser.selectAnswer': '选择一个答案',
@@ -6,7 +6,10 @@
import { describe, expect, it } from 'vitest';
import type { PermissionRequest } from '../adapters/types';
import { isAskUserPermission } from './askUserPermission';
import {
formatAskUserQuestions,
isAskUserPermission,
} from './askUserPermission';
const req = (r: Partial<PermissionRequest>): PermissionRequest =>
r as PermissionRequest;
@@ -38,3 +41,78 @@ describe('isAskUserPermission', () => {
).toBe(false);
});
});
describe('formatAskUserQuestions', () => {
it('formats questions with option labels and descriptions', () => {
expect(
formatAskUserQuestions({
questions: [
{
question: 'Pick a color',
options: [
{ label: 'Red', description: 'warm' },
{ label: 'Blue', description: 'cool' },
],
},
{
question: 'Pick a size',
options: [{ label: 'Small' }, { label: 'Large', description: '' }],
},
],
}),
).toBe(
'[1] Pick a color\n- Red: warm\n- Blue: cool\n\n' +
'[2] Pick a size\n- Small\n- Large',
);
});
it('trims whitespace and keeps questions without options', () => {
expect(
formatAskUserQuestions({
questions: [{ question: ' Why? ' }],
}),
).toBe('[1] Why?');
});
it('marks multi-select questions and only when the flag is true', () => {
expect(
formatAskUserQuestions({
questions: [
{ question: 'Pick any', multiSelect: true },
{ question: 'Pick one', multiSelect: false },
{ question: 'Default' },
{ question: 'Loose typing', multiSelect: 'yes' },
],
}),
).toBe(
'[1] Pick any [multiSelect]\n\n[2] Pick one\n\n[3] Default\n\n[4] Loose typing',
);
});
it('skips malformed entries and renumbers the survivors', () => {
expect(
formatAskUserQuestions({
questions: [
null,
'nope',
{ question: ' ' },
{ options: [{ label: 'orphan' }] },
{
question: 'Real question',
options: [null, { label: '' }, { label: 'Keep', description: 3 }],
},
],
}),
).toBe('[1] Real question\n- Keep');
});
it('returns an empty string without a questions array', () => {
expect(formatAskUserQuestions(undefined)).toBe('');
expect(formatAskUserQuestions({})).toBe('');
expect(formatAskUserQuestions({ questions: 'nope' })).toBe('');
expect(formatAskUserQuestions({ questions: [] })).toBe('');
// A non-array object must not reach the iterator: dropping the
// Array.isArray guard throws "questions is not iterable" here.
expect(formatAskUserQuestions({ questions: { a: 1 } })).toBe('');
});
});
@@ -25,3 +25,49 @@ export function isAskUserPermission(
if (!request.toolName) return true;
return isAskUserQuestionToolName(request.toolName);
}
/**
* Language-neutral snapshot of a pending AskUserQuestion payload — numbered
* questions with their option labels and descriptions — for embedding in the
* side-task details prompt. Malformed entries are skipped rather than throwing:
* the payload crosses the daemon wire and is not schema-validated here.
*/
export function formatAskUserQuestions(
rawInput: Record<string, unknown> | undefined,
): string {
const questions = Array.isArray(rawInput?.questions)
? rawInput.questions
: [];
const parts: string[] = [];
for (const entry of questions) {
if (typeof entry !== 'object' || entry === null) continue;
const { question, options, multiSelect } = entry as {
question?: unknown;
options?: unknown;
multiSelect?: unknown;
};
if (typeof question !== 'string' || !question.trim()) continue;
// Selection semantics matter to the explanation (options may combine);
// the marker keeps the snapshot self-contained without prose.
const lines = [
`[${parts.length + 1}] ${question.trim()}${multiSelect === true ? ' [multiSelect]' : ''}`,
];
if (Array.isArray(options)) {
for (const option of options) {
if (typeof option !== 'object' || option === null) continue;
const { label, description } = option as {
label?: unknown;
description?: unknown;
};
if (typeof label !== 'string' || !label.trim()) continue;
lines.push(
typeof description === 'string' && description.trim()
? `- ${label.trim()}: ${description.trim()}`
: `- ${label.trim()}`,
);
}
}
parts.push(lines.join('\n'));
}
return parts.join('\n\n');
}
@@ -259,6 +259,54 @@ describe('visual capture contracts', () => {
/@media \(hover: hover\) \{\s*\.sessionRow:has\(\.sessionBackgroundRunning:hover\) \.sessionActions \{/,
);
});
it('keeps the AskUser action row wrapping so the details button stays reachable', () => {
// The fifth action button ("Ask for details") can push the row past a
// 320px panel, and a flex-end row overflows to the LEFT, which scrollWidth
// does not report. `flex-wrap: wrap` is what keeps every button inside the
// panel and clickable. The only test that measures this is the visuals
// Playwright case, which no gating lane collects (vitest excludes e2e/**,
// playwright ignores **/visuals/**, and the visuals step is
// continue-on-error). Pin the rule here so a later refactor of `.actions`
// cannot drop it with every gate green.
const css = readFileSync(
join(HERE, 'components/messages/AskUserQuestion.module.css'),
'utf8',
).replace(/\/\*[\s\S]*?\*\//g, '');
// The base rule must wrap, and no later override (e.g. a narrow-width
// @container form) may turn wrapping off for the same selector. Matching
// each `.actions { … }` block rather than only the first one keeps a later
// override from slipping through green.
const actionBlocks = [...css.matchAll(/\.actions\s*\{([^}]*)\}/g)].map(
(m) => m[1],
);
expect(
actionBlocks.length,
'AskUserQuestion.module.css must declare .actions',
).toBeGreaterThan(0);
expect(actionBlocks[0], '.actions must wrap its buttons').toMatch(
/flex-wrap:\s*wrap;/,
);
for (const block of actionBlocks) {
expect(block, 'no .actions override may disable wrapping').not.toMatch(
/flex-wrap:\s*nowrap;/,
);
}
// Markup half: the wrap rule only protects the details button if that
// button is rendered inside the .actions row. Pin that it appears in the
// row region of the component, not orphaned by a markup-only refactor.
const tsx = readFileSync(
join(HERE, 'components/messages/AskUserQuestion.tsx'),
'utf8',
);
const rowAt = tsx.indexOf('className={styles.actions}');
expect(rowAt, 'the actions row must be rendered').toBeGreaterThan(-1);
const buttonAt = tsx.indexOf('className={styles.detailsButton}');
expect(
buttonAt,
'the details button must be rendered in the actions row',
).toBeGreaterThan(rowAt);
});
});
describe('smoke lane browser projects', () => {