qwen-code/packages/web-shell
Shaojin Wen 170ce7917d
feat(web-shell): show Settings and Daemon Status as an in-place panel (#6341)
* feat(web-shell): show Settings and Daemon Status as an in-place panel

The Settings and Daemon Status buttons opened centered modal overlays that
dimmed the whole app. Render them as a full-height panel that replaces the
chat surface instead — a Back button or Escape returns to the chat — with the
content left-aligned and filling the chat pane width rather than centered in a
narrow column. On the Daemon Status page, give the time-series charts a taller
plot and the overview cards a wider track so a wide window is actually used;
the tab grouping (overview / metrics / diagnostics) is kept.

* fix(web-shell): preserve composer draft and refine panel focus/escape

Address review feedback on the in-place Settings / Daemon Status panel:

- Keep the chat view (message list + composer) mounted and just hidden while a
  panel is shown, so typing a prompt, opening Settings/Status, then going Back
  no longer discards the unsent draft and attachments (the composer subtree was
  being unmounted and remounted empty).
- Focus the Back button when a panel opens and restore focus to the composer
  when it closes, replacing the focus management DialogShell used to provide.
- Reload workspace settings after the fast-model command resolves so the still-
  mounted Settings panel doesn't keep showing the previous value.
- Don't close the panel when Escape is handled inside the sidebar (its search
  input clears on Escape without stopping the event).
- Guard the overview card grid with min(100%, 340px) so a 340px track can't
  overflow a narrow panel.

* fix(web-shell): reset panel scroll when switching Settings/Status

Add key={activePanel} on the panel body so switching directly between Settings
and Daemon Status (activePanel goes 'settings' -> 'status' without passing
through null) remounts the scroll container and opens at the top instead of
inheriting the previous panel's scrollTop.

* fix(web-shell): restore new-chat vertical centering

The chatViewWrap added to keep the composer mounted while a panel is shown was
filling the pane, which cancelled the empty (new-chat) state's vertical
centering (welcome header + composer stuck to the top instead of centered).
Shrink the wrapper to its content in the empty state so
`.appChatEmpty .chatPane { justify-content: center }` centers it again, matching
how `.content` behaves there.

* fix(web-shell): restore session-org test destructuring dropped in merge

My earlier merge of origin/main 3-way-combined WebShellSidebar.test.tsx
incorrectly, dropping the `{ }` destructuring from the 7 session-organization
tests. renderSidebar returns `{ container, rerender }` (not the element), so
`const container = renderSidebar(...)` made `container.querySelector` throw.
Restore the file to origin/main so the tests pass again.

* fix(web-shell): surface pending approvals over Settings/Status panel

The in-place Settings/Daemon Status panel hides the chat footer with
display:none, and the ToolApproval / AskUserQuestion overlays live in
that footer. A gated tool call that arrived while a panel was open
rendered the approval into a hidden container, so the turn hung with no
visible prompt (reported [Critical]).

Close the panel when an actionable approval is pending so it surfaces.
Only actionable approvals count (pendingToolApproval / pendingAskUserApproval
already gate on canActOnPendingApproval), so a non-owner in a shared
session isn't yanked out of Settings by someone else's prompt. Leave
focus for the overlay rather than the composer on this path: ToolApproval
uses a window-level key handler that ignores editable targets, so
focusing the composer would swallow its shortcuts.

Also refocus the Back button on panel->panel switches (not just on open),
so focus no longer relies on the Back button being the same DOM node
across the keyed panel body, and expose SvgLineChart's plot height as
--chart-height (fallback 140px) so a constrained caller can shrink it.

Adds App-level tests: the panel auto-closes on a pending tool approval,
and stays open when the only block is a resolved (non-actionable) one.

* test(web-shell): cover AskUserQuestion auto-close; robust panel selector

Follow-up to the approval auto-close fix, addressing review suggestions:

- Add data-testid="inline-panel" to the panel <section> and query by it in
  App.test instead of querySelector('section'), which would false-positive if
  any future component renders a <section>.
- Add a companion test for the pendingAskUserApproval branch of the auto-close
  effect. The ask-user block carries toolCall.input.questions so
  isAskUserPermission() classifies it as an AskUserQuestion; mutation-checked
  (restricting the effect to pendingToolApproval fails only this test).
- Document why handleFastModelSelect's post-sendPrompt reload is not racy:
  sendPrompt awaits waitForAcceptedPromptCompletion, so the /model --fast turn
  has already applied when reloadWorkspaceSettings() runs. Also note why the
  command path needs the explicit reload that the setWorkspaceSetting pickers
  (vision/voice) get for free via the settingsVersion signal.

* fix(web-shell): close inline panel when resuming a session

The /resume <id> command and the ResumeDialog onSelect both call
loadSession() without closePanel(), unlike createNewSession and
loadSidebarSession. Loading a session means the user wants that chat, so
leaving a Settings/Daemon Status panel open would hide it. Add closePanel()
to both paths for consistency (a no-op when no panel is open).

Currently reachable only in theory — the composer that submits /resume is
display:none while a panel is shown — but the guard keeps the invariant if
a non-composer entry point (sidebar/shortcut) is ever added.

Adds a mutation-checked test (removing closePanel from the /resume handler
fails it) and gives the ChatEditor test mock a focus() method, since the
panel-close focus effect now runs editorRef.focus() on this no-approval path.

* fix(web-shell): keep composer dormant while an approval overlay is up

Follow-up to the approval auto-close fix. When an approval arrives while a
Settings/Status panel is open, the auto-close clears activePanel, so
interactionBlocked flips false and useComposerCore's dialogOpen effect
refocuses the still-mounted composer. ToolApproval ignores approval
shortcuts from editable targets, so the now-visible approval stops
responding to Enter/Escape/number keys until focus moves away.

Key the ChatEditor dialogOpen prop off the pending approval too, so the
composer stays blurred while an approval owns the keyboard. Consolidate the
"an approval overlay is active" condition — previously duplicated in the
auto-close effect and the panel focus guard — into a single
approvalOverlayActive value so the three consumers can't drift.

Uses the actionable-approval condition (pendingToolApproval /
pendingAskUserApproval) rather than raw pendingApproval, matching the
auto-close gating and avoiding a blurred composer when a non-actionable
approval renders no overlay.

Adds a mutation-checked test asserting dialogOpen is true while an approval
is pending with no panel open.

* test(web-shell): cover the Daemon Status panel branch

A review note observed that all five panel tests open via /settings, so the
activePanel === 'status' branch (DaemonStatusDialog) had no coverage — a
regression in the 'status' literal would go undetected. Add a test that
opens Daemon Status through the sidebar and asserts the panel opens and
auto-closes on a pending approval, exercising that branch and confirming the
auto-close is panel-type-agnostic.

Gives the WebShellSidebar test mock an onOpenDaemonStatus button, since
there is no slash command to open the status panel. Mutation-checked:
neutering setActivePanel('status') fails only this test.

* fix(web-shell): dismiss stacked dialogs on approval, focus overlay, a11y

Addresses a review round on the inline Settings/Status panel.

[Critical] When an approval arrives while a DialogShell sub-dialog (model
picker / approval-mode picker) is open over the panel, the auto-close
removed the panel but left the sub-dialog backdrop covering the footer
approval overlay, so the turn hung. Worse, the approval-mode picker stayed
usable — selecting "yolo" auto-approved (handleSetMode) a tool call the
user never saw. Dismiss the panel AND both sub-dialogs when an actionable
approval is pending.

[Critical] After the panel closes for an approval, focus fell to <body>
(the Back button unmounted; ToolApproval has no autofocus). Move focus onto
the ToolApproval overlay wrapper (tabindex=-1, so its window-listener
shortcuts keep working and Enter doesn't confirm early) once it is visible.
AskUserQuestion keeps managing its own focus.

Suggestions:
- Guard reloadWorkspaceSettings() rejection (was unhandled).
- aria-hidden the display:none chat view so AT can't wander into it.
- Close the panel before /model --fast so its response shows in the chat
  in context instead of piling up behind the hidden panel.
- Remove the dead .panelBodyInner wrapper (redundant width:100%).
- Rename data-mobile-drawer -> data-sidebar-shell (it wraps the desktop
  sidebar for all viewports) across App.tsx + standalone.css.

Tests (+5, mutation-checked): sub-dialog dismissal on approval, overlay
focus, Escape closes panel / sidebar-Escape does not, dialogOpen while a
panel replaces the chat. Adds an observable DialogShell test mock.

* fix(web-shell): restore composer focus after approval resolves; cleanups

[Critical] The panel focus effect consumed prevActivePanelRef to null on the
approval auto-close (correctly skipping editor focus then). When the approval
later resolved with no panel to return to, neither branch fired and focus was
left on <body> — the visible composer took no keyboard input. Track the
approvalOverlayActive transition too and restore composer focus on
approval-resolve-after-panel-close. (useComposerCore's dialogOpen effect also
covers this; the extra branch makes the panel effect self-contained.)

Suggestions:
- Log the reloadWorkspaceSettings() rejection instead of swallowing it.
- Remove the dead :global([data-dialog-fullscreen]) .grid rule (the
  allowFullscreen source was removed when Daemon Status became a panel).

Tests (+4, focus-restore mutation-checked): focus restored after an approval
resolves post-auto-close; Back-button closes the panel and restores focus;
fast-model pick closes the panel, sends /model --fast, and reloads settings;
chat view is aria-hidden while a panel is shown. Adds interactive
SettingsMessage / ModelDialog mocks and stable editor-focus / settings-reload
spies.

* fix(web-shell): make Settings/Status panel and Scheduled Tasks mutually exclusive

Opening Scheduled Tasks (mainView — a position:absolute z-index overlay) and
then Daemon Status (activePanel) rendered the Daemon Status panel *behind* the
Scheduled Tasks overlay, so the button looked like it did nothing. These are
mutually-exclusive full-pane views, so opening one must close the other.

Centralize into openPanel() / openScheduledTasks() helpers (last-opened wins)
and route all six open sites (the /settings and /schedule commands, the three
sidebar handlers, and the StatusBar) through them.

Mutation-checked tests: opening Daemon Status closes the Scheduled Tasks page
(the reported repro), and opening Scheduled Tasks closes an open panel.
2026-07-06 05:06:46 +00:00
..
client feat(web-shell): show Settings and Daemon Status as an in-place panel (#6341) 2026-07-06 05:06:46 +00:00
docs/examples/qwencode-viz feat(web-shell): support compact echarts full data blocks (#6232) 2026-07-04 15:36:54 +00:00
package.json feat(web-shell): support compact echarts full data blocks (#6232) 2026-07-04 15:36:54 +00:00
README.md feat(web-shell): support compact echarts full data blocks (#6232) 2026-07-04 15:36:54 +00:00
tsconfig.build.json feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
tsconfig.json feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
tsconfig.lib.json feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
vite.config.ts feat(web-shell): add a Scheduled Tasks management page (#6348) 2026-07-06 03:47:17 +00:00
vite.lib.config.ts refactor(web-shell): restructure chat UI (#5775) 2026-06-23 22:43:19 +08:00

@qwen-code/web-shell

Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI可以作为 React 组件嵌入到其他项目中。

环境要求

  • React^18.0.0 || ^19.0.0
  • React DOM^18.0.0 || ^19.0.0
  • @qwen-code/webui>=0.0.1
  • @qwen-code/sdk>=0.1.8
  • 浏览器环境需要能访问 Qwen Code daemon serve 的 HTTP 接口。

组件包会自动注入自身样式,样式已通过 CSS Modules 和组件作用域隔离; 接入方不需要额外引入全局 CSS。

安装

npm install @qwen-code/web-shell

Peer dependencies 需要同时安装:

npm install react react-dom @qwen-code/webui @qwen-code/sdk

接入方式

WebShell 提供两种接入形态:

1. 独立接入(自带 Provider

适合只需要嵌入一个终端视图的场景。组件内部自建 DaemonWorkspaceProvider + DaemonSessionProvider

import { WebShellWithProviders } from '@qwen-code/web-shell';

export function QwenCodePanel() {
  return (
    <WebShellWithProviders
      baseUrl="http://127.0.0.1:4170"
      token="your-bearer-token"
      sessionId="838e1811-9f84-4848-9915-d9a7f01ff5c6"
      onSessionIdChange={(sessionId) => {
        console.log('current session:', sessionId);
      }}
      theme="dark"
      language="zh-CN"
    />
  );
}

2. 共享 Provider 接入(纯消费者)

适合同一个 React 应用中多个视图共享同一个 daemon session 的场景(如 chat + terminal。宿主自行提供 ProviderWebShell 只消费 hooks。

import {
  DaemonWorkspaceProvider,
  DaemonSessionProvider,
} from '@qwen-code/webui/daemon-react-sdk';
import { WebShell } from '@qwen-code/web-shell';

export function App() {
  return (
    <DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token="...">
      <DaemonSessionProvider sessionId="...">
        <ChatPanel />
        <WebShell theme="dark" language="zh-CN" />
      </DaemonSessionProvider>
    </DaemonWorkspaceProvider>
  );
}

注意:不要在已有 DaemonSessionProvider 下使用 WebShellWithProviders,否则会创建嵌套的重复 Provider。

Props

WebShellWithProviders

包含 WebShell 的所有 Props加上 Provider 配置:

属性 类型 说明
baseUrl string daemon API 地址,未传时使用 window.location.origin
token string daemon API Bearer token
sessionId string 要连接的 session id未传或 undefined 时保持空页面

WebShell

属性 类型 说明
onSessionIdChange (sessionId: string | undefined) => void 当前 session id 变化或清空时触发
theme 'dark' | 'light' UI 主题,默认 dark
onThemeChange (theme: WebShellTheme) => void /theme 命令切换主题后触发
language 'en' | 'zh-CN' | 'zh' | 'zh-cn' UI 语言
onLanguageChange (language: WebShellLanguage) => void /language ui 切换 UI 语言后触发

可选图表 Renderer

WebShell 支持宿主通过 customization.markdown.renderCodeBlock 接管特定 fenced code block 的渲染。图表类场景可以注册内置的 echarts-fulldata renderer

import { createEchartsFullDataRenderer } from '@qwen-code/web-shell';

<WebShellWithProviders
  markdown={{
    renderCodeBlock: createEchartsFullDataRenderer({
      loadEcharts: () => window.echarts,
      resolveDataRef: async (ref, meta) =>
        loadControlledChartDataset(ref, meta),
    }),
  }}
/>;

renderer 会把 echarts-fulldata code block 替换为图表卡片,并内置图表/数据 icon 切换ECharts runtime 由宿主通过 loadEcharts 提供。若启用 data.kind="ref" envelope数据只能通过宿主提供的 resolveDataRef 解析, renderer 不会自己读取 URL 或本地路径。

如果需要让模型主动输出 echarts-fulldata block宿主应在自己的 skills 来源中 提供对应 skill并且只在确认当前 Web Shell 宿主已经注册 renderer 时启用。 @qwen-code/web-shell 不内置或自动加载这个 skill可从 packages/web-shell/docs/examples/qwencode-viz/SKILL.md 复制模板到宿主的 .qwen/skills/qwencode-viz/SKILL.md,或通过宿主自己的 skill 注入机制提供等价 说明。

echarts-fulldata 的 block body 可以是旧版纯 JSON ECharts option也可以是 { "version": 1, "data": ..., "option": ... } envelope。新版 inline envelope 使用 data.dimensions: string[]data.source array-of-arraysrenderer 会先 normalize 成原生 ECharts option并注入 option.dataset,再渲染图表和数据视图。 新版 ref envelope 必须使用受控 artifact://session-file:// ref并提供 data.formatcsvjson)和 data.dimensions,这些元信息会传给宿主的 resolveDataRef(ref, meta)。 宿主应使用 JSON.parse 解析,不能用 evalnew Function 或 script injection 执行模型生成内容。

架构说明

@qwen-code/sdk/daemon         ← 协议层SSE, REST, normalizer
@qwen-code/webui/daemon-react-sdk  ← React adapterProvider, hooks, store
@qwen-code/web-shell          ← 终端 UI 组件
  • WebShell 必须在 DaemonWorkspaceProviderDaemonSessionProvider 之下使用。
  • WebShellWithProviders 是内置 Provider 的便捷 wrapper。
  • 同一个 React 树共享一个 DaemonSessionProvider 时只开一条 SSE。

已支持的斜杠命令

下面列出当前 web-shell 已支持的命令。支持方式分为两类:

  • 本地实现web-shell 前端直接打开弹窗、调用 daemon REST API或切换本地状态。
  • ACP 透传web-shell 将命令发送给 daemon由 daemon/ACP 执行。
命令 支持方式 说明
/help 本地实现 打开帮助弹窗,支持键盘浏览命令和快捷键。
/theme 本地实现 打开主题选择弹窗;支持 /theme light/theme dark
/language 本地实现 + ACP 透传 /language ui <lang> 会切换 web-shell UI 语言并同步给 daemon其他语言能力由 daemon 执行。包含 uioutput 子命令。
/model 本地实现 + 部分透传 无参数打开模型弹窗;普通参数直接切换模型;/model --fast <model> 透传给 daemon。
/plan 本地实现 切换到 plan approval mode并可继续发送后续 prompt。
/approval-mode 本地实现 打开审批模式弹窗或直接切换审批模式。
/mode 本地实现 web-shell 本地别名,用于切换审批模式。
/mcp 本地实现 打开 MCP 管理弹窗。
/skills 本地实现 + ACP 透传 无参数打开 skills 弹窗;带参数时透传给 daemon 执行。
/tools 本地实现 打开 tools 弹窗,列表展示工具名称、启用状态和 description
/memory 本地实现 打开 memory 弹窗,支持 showrefreshadd useradd project 等分支。
/agents 本地实现 打开 agents 弹窗,支持 managecreate usercreate project 等分支。
/copy 本地实现 复制最后一条 assistant 输出;支持 code、语言名、LaTeX、inline LaTeX 等选择器。
/release 本地实现 释放 live session 连接,不删除历史会话记录。
/clear 本地实现 清空当前 web-shell transcript store。
/new 本地实现 创建新的 daemon session。
/reset 本地实现 /new 一样创建新的 daemon session。
/rename <name> 本地实现 修改当前 daemon session 的展示名称。
/resume 本地实现 无参数打开恢复会话弹窗;带 session id 时直接加载。
/status ACP 透传 daemon 支持,包含 paths 子命令。
/auth ACP 透传 连接 LLM provider。
/bug ACP 透传 提交错误报告。
/compress ACP 透传 通过摘要替换来压缩上下文。
/context ACP 透传 显示上下文窗口使用情况,包含 detail 子命令。
/diff ACP 透传 显示工作区相对 HEAD 的变更统计。
/docs ACP 透传 打开 Qwen Code 文档。
/doctor ACP 透传 执行安装与环境诊断,包含 memory 子命令。
/export ACP 透传 导出当前会话记录,包含 htmlmdjsonjsonl 子命令。
/goal ACP 透传 设置目标,并持续工作直到条件满足。
/init ACP 透传 分析项目并创建定制的 QWEN.md
/stats ACP 透传 显示统计信息,包含 modeltools 子命令。
/summary ACP 透传 生成当前会话摘要。
/tasks ACP 透传 列出后台任务。
/insight ACP 透传 查看 insight 相关信息。