feat(web-shell): show a reduced header on the new-task page (#14159)

* feat(web-shell): show a reduced header on the new-task page

The new-task page rendered no header at all. Show a reduced one with
only workspace/daemon-scoped entries (right-panel toggle, Local Control
QR, workspace location); the workspace icon stays hidden because the
composer below already names the workspace. The right panel offers only
workspace-scoped entries (files, terminal, web preview) before a
session exists, and its state on the new-task page now persists under a
per-workspace draft key, surviving reloads and session switches, and
migrates onto the session the first prompt creates.

* fix(web-shell): preserve new-task panel ownership and migration

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-15 07:25:44 +00:00
1 parent 1c5cefe5af
commit a0e6678d66
10 files changed
+1135 -131

No files matched your search

@@ -35,7 +35,7 @@ session, so it sat among controls that act on the current one.
- Once the session exists, the chat header reports the session's workspace as a
single leading icon, naming the folder on hover and offering no click
behavior.
- That header icon is always present in the main chat header: the same folder
- That header icon is always present in the session chat header: the same folder
glyph whether or not a workspace exists, with the tooltip carrying the
difference.
@@ -45,8 +45,9 @@ session, so it sat among controls that act on the current one.
`composerToolbarActions`, and no item is added to the `header.items`
customization contract. The parent visibility check is aligned with the existing
standalone selector callback requirement solely to prevent an empty row.
- No new daemon route, protocol field, or persistence. The icon is derived from
workspace state the shell already holds.
- No new daemon route or protocol field. The icon is derived from workspace
state the shell already holds; draft panels reuse the existing persisted tab
format.
- No change to the split-pane composer or its workspace chip, where the chip's
job is telling panes apart. The shared workspace menu now prefers opening
above its trigger in all placements, including embedded hosts.
@@ -75,7 +76,11 @@ defaulting to `toolbar`.
toolbar, because the chat header owns the workspace.
`App` passes `below` while the chat is empty (`isChatEmptyState`) and `header`
otherwise, so exactly one surface reports the workspace at a time.
otherwise. A reduced built-in header may also render in the empty state when a
workspace is resolved and the host enables the right-panel entry, Local Control,
or multi-host location. It omits session-scoped controls. Its workspace icon is
hidden only when the composer actually shows its workspace selector, including
the host toolbar filter; otherwise the icon remains as the workspace indicator.
`ChatContextHeader` gains `workspaceName` / `workspacePath` and renders a
leading `role="img"` folder icon before its content. Hovering it opens the
@@ -84,16 +89,17 @@ workspace name and its full path, because an icon-only trigger cannot name the
workspace by itself; the accessible name is `workspace.paneLabel`. The
no-workspace state keeps the same glyph and only swaps the tooltip and the
accessible name for `sidebar.noWorkspace`: the icon is the one place that always
answers "which workspace is this session in?", so it stays put. The icon is not
answers "which workspace is this session in?" in a session. In the reduced
empty-state header it defers to a visible composer workspace selector. The icon is not
a button, so it adds no action to the header. It matches the title color and
shows a rounded accent background on hover; keyboard triggering is not supported.
## Design decisions
- **The switch follows the empty state, not the message count.** The header
renders only when the chat is not empty, so keying both surfaces on the same
flag is what keeps the workspace from being reported twice.
- **The header icon is unconditional; the composer chips are not.** A host that
keeps session controls out of the empty state. The reduced empty-state header
hides its workspace icon only when the composer selector is visible.
- **The session header icon is unconditional; the composer chips are not.** A host that
enables the `workspace` composer action gets the whole treatment, but the
icon no longer needs a second workspace or a creation capability: reporting
the workspace is useful with one registered workspace, where switchability
@@ -105,6 +111,25 @@ shows a rounded accent background on hover; keyboard triggering is not supported
receives `workspaceCwd`, so such a host owns this indicator too and no icon
is injected into its header.
## New-task panel lifecycle
The reduced header exposes only host-configured `files`, `terminal`, and
`webPreview` entries. Their workspace and the draft storage key follow the
composer selection. A missing workspace keeps the panel toggle unavailable;
session-scoped entries remain hidden until a session exists.
Draft panel state uses `draft\0<cwd>` in the existing panel storage map.
Drafts and sessions each retain the latest 20 entries, so browsing workspaces
cannot evict session panel state. Reloading or switching away restores each
workspace's own draft. Only the first prompt's allocation consumes that draft;
ordinary session selection never does. The allocation marker expires at the
next key transition even if no draft is adopted.
Adoption preserves open tabs, their stored order, panel width, and fullscreen.
Tabs deferred while a capability is unavailable migrate too and hydrate when
the capability arrives, without requiring a session switch. Custom header hosts
keep their empty page headerless and are never called without a session.
## Constraints
- The row renders only when at least one chip is actually available. Standalone
@@ -119,8 +144,9 @@ shows a rounded accent background on hover; keyboard triggering is not supported
- With a single workspace and no creation capability the composer still hides
the workspace selector, while the header shows the workspace icon — the
surfaces can differ in whether the _control_ appears even though both report
the same workspace.
reduced empty-state header must retain the icon in this case, including when
the host hides the composer workspace action. Otherwise neither surface
reports the selected workspace.
- The header icon also appears in shells with no workspace concept at all
(standalone, Live), reporting `sidebar.noWorkspace`, which is a new visual
element in those shells.
@@ -132,14 +158,22 @@ shows a rounded accent background on hover; keyboard triggering is not supported
toolbar), keeps git in the toolbar for `header`, and omits the row when
neither chip is available. `ChatContextHeader` reports the workspace
name/path, falls back to the name for the tooltip, reports the
missing-workspace state, and adds no button.
missing-workspace state, and adds no button. Empty-state header tests cover
selector visibility, host toolbar filtering, single-workspace fallback,
unresolved workspaces, and omission of session-scoped actions.
- Panel regression tests cover workspace isolation, allocation-marker expiry,
draft quotas, adoption geometry, and deferred tab order/hydration. Browser E2E
verifies the preview entry with a stable selector and captures the actual UI
against a mock daemon; this does not verify a real Git backend.
- Typecheck, lint, formatting, and the existing `App`, `ChatEditor`, and
`ChatContextHeader` suites.
## Acceptance criteria
- In a new chat the workspace and git chips appear directly under the composer
box and are absent from the toolbar.
- In a new chat the available workspace and git chips appear directly under the
composer box and are absent from the toolbar. A configured reduced header
exposes workspace actions only after the workspace resolves, and keeps the
workspace icon when no composer workspace selector is visible.
- In a session the header shows a single folder icon that names the workspace
on hover, and the composer has no workspace control.
- A session without a workspace keeps the same folder icon in the same place,
@@ -20,12 +20,12 @@ Related: [web-shell-composer-workspace-selector.md](web-shell-composer-workspace
- 会话还不存在时,工作区和 git 两个 chip 落在紧贴输入框外框下方、自己单独的一行里,让输入框本身只关于 prompt。
- 会话存在之后,对话 header 用**一个**前置 icon 报告该会话的工作区,hover 时给出文件夹名,且不提供点击行为。
- 这个 header icon 在主对话 header 中常驻:有工作区、没工作区都用同一个文件夹字形,差别只在 hover 出来的内容。
- 这个 header icon 在会话主对话 header 中常驻:有工作区、没工作区都用同一个文件夹字形,差别只在 hover 出来的内容。
## Non-goals
- 不改变控件是否可用:宿主仍通过 `composerToolbarActions` 选择,且**没有**向 `header.items` 定制契约新增条目。父层可见性判断仅对齐选择器已有的无工作区切换回调要求,避免空底栏。
- 不新增 daemon 路由、协议字段或持久化。icon 完全由 shell 已持有的工作区状态推导。
- 不新增 daemon 路由或协议字段。icon 完全由 shell 已持有的工作区状态推导;草稿面板复用既有页签持久化格式。
- 不动分屏输入框及其工作区 chip —— 那里的 chip 是用来区分 pane 的。共享工作区菜单在所有位置(含嵌入宿主)统一优先向上弹出。
- 不在 header icon 上做工作区切换。为已存在的会话选择工作区仍然是侧边栏的职责。
- 不新增 i18n key:icon 复用 `workspace.paneLabel` 与 `sidebar.noWorkspace`。
@@ -38,17 +38,25 @@ Related: [web-shell-composer-workspace-selector.md](web-shell-composer-workspace
- `below` 把两个 chip 移到输入框外壳内、紧贴边框盒子之后的一行里。这一行**始终显示文字标签**,因为它有自己的宽度预算 —— 工具条那套标签自适应测量管不到工具条之外。底栏使用柔和背景和底部圆角,背景向上延伸至输入框背后,形成相连的整体。底栏 Git 控件使用中性色文字,去掉边框、彩色填充和右侧箭头,并加宽水平留白。悬停 Git 时使用共享 tooltip 展示完整分支名,长连续名称自动换行;工作区菜单与 Git 一样向上弹出。
- `header` 从输入框里去掉工作区选择器、把 git 留在工具条,因为工作区归 header 管。
`App` 在对话为空(`isChatEmptyState`)时传 `below`,否则传 `header`,因此任一时刻只有一个位置在报告工作区。
`App` 在对话为空(`isChatEmptyState`)时传 `below`,否则传 `header`。工作区已确定且宿主启用了右侧面板入口、Local Control 或多主机位置时,空态也可显示精简内置 header,其中不包含会话级控件。只有输入框实际显示工作区选择器时(包括宿主工具条过滤条件),精简 header 才隐藏工作区图标;否则保留图标报告工作区。
`ChatContextHeader` 新增 `workspaceName` / `workspacePath`,并在 content 之前渲染一个 `role="img"` 的文件夹 icon。hover 它时弹出应用自己的 tooltip(就是 pane 上那个工作区 chip 用的同一套),展示工作区名与完整路径 —— 只有一个 icon 的触发点没法自己说出工作区是谁;无障碍名用 `workspace.paneLabel`。无工作区态**用的是同一个字形**,只把 tooltip 与无障碍名换成 `sidebar.noWorkspace`:这个 icon 是"这条会话属于哪个工作区"的常驻答案,所以它不挪位置。该 icon 不是按钮,因此不给 header 增加任何动作。其颜色与标题一致,悬停时显示圆角强调背景;暂不支持键盘触发。
`ChatContextHeader` 新增 `workspaceName` / `workspacePath`,并在 content 之前渲染一个 `role="img"` 的文件夹 icon。hover 它时弹出应用自己的 tooltip(就是 pane 上那个工作区 chip 用的同一套),展示工作区名与完整路径 —— 只有一个 icon 的触发点没法自己说出工作区是谁;无障碍名用 `workspace.paneLabel`。无工作区态**用的是同一个字形**,只把 tooltip 与无障碍名换成 `sidebar.noWorkspace`:这个 icon 在会话中是"这条会话属于哪个工作区"的常驻答案。精简空态 header 中,如果输入框已显示工作区选择器,图标就让位于它。该 icon 不是按钮,因此不给 header 增加任何动作。其颜色与标题一致,悬停时显示圆角强调背景;暂不支持键盘触发。
## Design decisions
- **切换点跟随空会话,而不是消息条数。** header 只在对话非空时渲染,用同一个标志位驱动两个位置,才能保证工作区不会被同时报告两次。
- **header icon 无条件显示,输入框里的 chip 不是。** 打开 `workspace` 输入框动作的宿主会拿到整套行为,但 icon 不再要求"多工作区"或"具备创建能力":只有一个注册工作区时,报告工作区本身就有用,而"可切换"是它过去唯一隐藏的理由。
- **切换点跟随空会话,而不是消息条数。** header 在空态不显示会话级控件。精简空态 header 仅在输入框选择器可见时隐藏工作区图标。
- **会话 header icon 无条件显示,输入框里的 chip 不是。** 打开 `workspace` 输入框动作的宿主会拿到整套行为,但 icon 不再要求"多工作区"或"具备创建能力":只有一个注册工作区时,报告工作区本身就有用,而"可切换"是它过去唯一隐藏的理由。
- **只读 icon。** hover 报告工作区,没有任何可点项,因此 header 保持既有动作集合,工作区切换仍属于"会话创建前"的动作。
- **自行渲染 header 的宿主保留其 header。** `renderChatHeader` 本就收到 `workspaceCwd`,所以这类宿主同样自己负责这个指示,不会被注入 icon。
## New-task panel lifecycle
精简 header 只开放宿主配置的 `files`、`terminal` 和 `webPreview`。这些入口的工作区与草稿存储 key 跟随输入框选择。工作区未确定时不开放面板开关;会话级入口在会话存在后才显示。
草稿面板状态使用既有面板存储表中的 `draft\0<cwd>`。草稿与会话各保留最近 20 条,因此浏览不同工作区不会挤掉会话面板存档。刷新或切走后恢复各工作区自己的草稿。只有首条消息分配会话时才消费该草稿;普通会话选择不会消费。分配标记在下一次 key 切换时过期,即使没有草稿被接管也会清理。
接管保留已打开页签、存档顺序、面板宽度与全屏状态。能力不可用时延迟恢复的页签也会迁移,并在能力到达后补齐,无需切换会话。自定义 header 的宿主仍保持空态无 header,不会在无会话时被调用。
## Constraints
- 底栏仅在至少一个入口实际可用时渲染。无工作区选择需同时提供支持标记和切换回调;保留受控可见性、选中值、回调及禁用状态。
@@ -58,17 +66,18 @@ Related: [web-shell-composer-workspace-selector.md](web-shell-composer-workspace
## Risks
- 只有一个工作区、又不具备创建工作区能力时,输入框仍会隐藏工作区选择器,而 header 会显示工作区 icon —— 两个位置在"控件是否出现"上可能不一致,尽管报告的是同一个工作区。
- 只有一个工作区、又不具备创建工作区能力时,输入框仍会隐藏工作区选择器,而 header 会显示工作区 icon —— 精简空态 header 在这种情况下必须保留图标,包括宿主隐藏输入框工作区动作时,否则两个位置都不会报告所选工作区。
- 完全没有工作区概念的 shell(standalone、Live)也会出现这个 header icon,hover 内容为「无工作区」,这对那些 shell 是一处新的视觉元素。
## Validation plan
- 单测:`ChatEditor` 默认把两个 chip 留在工具条;`below` 时把它们移入输入框下方那一行(工具条里不再留任何一个);`header` 时 git 仍留在工具条;两个 chip 都不可用时整行不渲染。`ChatContextHeader` 报告工作区名/路径、缺路径时回退到名字、报告无工作区态,且不新增按钮。
- 单测:`ChatEditor` 默认把两个 chip 留在工具条;`below` 时把它们移入输入框下方那一行(工具条里不再留任何一个);`header` 时 git 仍留在工具条;两个 chip 都不可用时整行不渲染。`ChatContextHeader` 报告工作区名/路径、缺路径时回退到名字、报告无工作区态,且不新增按钮。空态 header 测试覆盖选择器可见性、宿主工具条过滤、单工作区兜底、工作区未确定以及隐藏会话级入口。
- 面板回归测试覆盖工作区隔离、分配标记过期、草稿额度、接管时的几何状态以及延迟页签的顺序与恢复。浏览器 E2E 使用稳定选择器验证预览入口,并通过模拟 daemon 捕获实际 UI;不验证真实 Git 后端。
- 类型检查、lint、格式化,以及既有的 `App`、`ChatEditor`、`ChatContextHeader` 测试套件。
## Acceptance criteria
- 新建对话时,工作区与 git 两个 chip 出现在输入框正下方,且不再出现在工具条里。
- 新建对话时,可用的工作区与 git chip 出现在输入框正下方,且不再出现在工具条里。已配置的精简 header 仅在工作区确定后开放工作区动作;输入框工作区选择器不可见时保留工作区图标。
- 会话中,header 只显示一个文件夹 icon,hover 时给出工作区名,且输入框里不再有工作区控件。
- 没有工作区的会话在同一位置显示同一个文件夹 icon,只是 hover 内容说明没有工作区。
- 分屏与自行配置输入框的宿主保持原有排布。
+12 -1
View File
@@ -59,6 +59,16 @@ Pierre 目录树,点击文件后左侧显示内容、右侧保留目录树。M
嵌入式宿主通过 `rightPanel={{ items: ['files', 'review', 'sideTask'] }}` 启用。
此入口需要有效、可信且无歧义的 daemon 工作区归属。
新建任务页的内置 `header` 只提供工作区级入口:右侧面板开关、启用时的
Local Control,以及多主机位置。面板开关遵循 `header.items`,并在工作区确定后
才显示;`rightPanel.items` 此时仅保留 `files`、`terminal` 和 `webPreview`,
`review`、`sideTask`、`trajectory` 要等首条消息创建会话后才出现。
自定义 `renderChatHeader` 的宿主在新建任务页仍不调用该渲染器。
输入框没有显示工作区选择器时,精简 header 保留只读工作区图标。
面板跟随输入框选择的工作区;各工作区草稿独立保存,刷新和会话切换后可恢复,
首条消息创建会话时迁移面板并删除草稿。草稿与会话各保留最近 20 份面板存档,
草稿不会挤占会话的额度。
Excel(`.xlsx`)支持内联预览,也可通过右键「预览」在独立页签打开;重复打开同一文件复用页签,其他 Excel 格式仍仅支持下载。
文件树源码预览在读取前限制为 4 MiB,更大的文件请通过右键菜单下载。
导入失败仅在凭据可恢复的独立页面中提供重载;嵌入宿主或仅内存凭据时提供关闭文件,保留对话和宿主页面。
@@ -96,7 +106,8 @@ Artifact 发布的网页链接会在对应轮次留下产物卡片;开启网
内容的记录不能自动还原;任意开发网站也不会自动打包成离线网页。
嵌入式接入方通过 `rightPanel={{ items: ['review', 'sideTask', 'webPreview'] }}`
开启入口。宿主 CSP 需要允许预览来源的 `frame-src`,同时用
开启入口。新建任务页也可使用此入口;网页预览的草稿按输入框已选工作区保存,
首条消息创建会话后随面板迁移。宿主 CSP 需要允许预览来源的 `frame-src`,同时用
`frame-ancestors` 限制宿主页面只能被受信任的宿主嵌入,不能允许开发页面
反过来嵌入宿主。预览容器会限制直接子页面跳转,但开发应用自己的后代
iframe 仍需宿主的防嵌入策略保护。
+710
View File
@@ -17062,6 +17062,716 @@ describe('App session callbacks', () => {
).toBeNull();
});
describe('new-task panel regression coverage', () => {
const storageKey = 'qwen-code-web-shell-right-panel-state';
const draftKey = 'draft\0/tmp/project';
const sessionKey = '/tmp/project\0session-1';
const filesTab = {
id: 'files:project',
kind: 'files',
title: 'Files',
workspaceCwd: '/tmp/project',
};
const terminalTab = {
id: 'terminal:stored',
kind: 'terminal',
title: 'Terminal',
workspaceCwd: '/tmp/project',
};
function setupDraft(twoWorkspaces = false) {
mockConnection.sessionId = undefined;
testState.messages = [];
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'project', cwd: '/tmp/project', primary: true, trusted: true },
...(twoWorkspaces
? [
{
id: 'other',
cwd: '/tmp/other',
primary: false,
trusted: true,
},
]
: []),
],
} as typeof mockWorkspace.capabilities;
mockSessionActions.createSession.mockImplementation(async () => {
mockConnection.sessionId = 'session-1';
return { sessionId: 'session-1' };
});
}
function seedDeferredDraft() {
window.localStorage.setItem(
storageKey,
JSON.stringify({
v: 1,
[draftKey]: {
open: true,
activeTabId: terminalTab.id,
tabs: [filesTab, terminalTab],
},
}),
);
}
async function submitFirstPrompt() {
await act(async () => {
testState.latestChatEditorProps?.onSubmit(
'hello',
undefined,
undefined,
editorCommit,
);
await Promise.resolve();
});
await flush();
}
it.each([false, true])(
'keeps the workspace icon when the composer does not report it (toolbar hidden=%s)',
async (hideToolbar) => {
setupDraft(hideToolbar);
const { container } = renderApp({
rightPanel: { items: ['files'] },
...(hideToolbar
? { composerToolbarActions: ['model'] as const }
: {}),
});
await flush();
expect(
container.querySelector('[data-testid="chat-header-workspace"]'),
).not.toBeNull();
},
);
it('honors the host hiding the right-panel header item on the new-task page', async () => {
setupDraft();
const { container } = renderApp({
header: { items: ['title'] },
rightPanel: { items: ['files'] },
});
await flush();
expect(
container.querySelector('[data-testid="chat-context-header"]'),
).toBeNull();
});
it('does not offer workspace panels until the new-task workspace resolves as trusted', async () => {
setupDraft();
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'project', cwd: '/tmp/project', primary: true, trusted: false },
],
features: [],
} as typeof mockWorkspace.capabilities;
const { container, rerender } = renderApp({
rightPanel: { items: ['files', 'terminal', 'webPreview'] },
});
await flush();
expect(
container.querySelector('button[aria-label="Toggle right panel"]'),
).toBeNull();
mockWorkspace.capabilities = {
...mockWorkspace.capabilities,
workspaces: mockWorkspace.capabilities.workspaces.map((entry) => ({
...entry,
trusted: true,
})),
};
rerender();
await flush();
expect(
container.querySelector('button[aria-label="Toggle right panel"]'),
).not.toBeNull();
});
it('preserves deferred draft tab order when the first prompt creates a session', async () => {
setupDraft();
seedDeferredDraft();
renderApp({ rightPanel: { items: ['files', 'terminal'] } });
await flush();
await flush();
await submitFirstPrompt();
const stored = JSON.parse(
window.localStorage.getItem(storageKey) ?? '{}',
);
expect(
stored[sessionKey]?.tabs.map((tab: { kind: string }) => tab.kind),
).toEqual(['files', 'terminal']);
expect(stored[draftKey]).toBeUndefined();
});
it('hydrates a deferred terminal after the first prompt without switching sessions', async () => {
setupDraft();
seedDeferredDraft();
const { container, rerender } = renderApp({
rightPanel: { items: ['files', 'terminal'] },
});
await flush();
await flush();
expect(
container.querySelector('[data-terminal-id="terminal:stored"]'),
).toBeNull();
await submitFirstPrompt();
mockConnection.capabilities.features = ['web_terminal'];
rerender();
await flush();
await flush();
expect(
container.querySelector('[data-terminal-id="terminal:stored"]'),
).not.toBeNull();
});
it('keeps draft tabs and workspace owners separate when the pending workspace changes', async () => {
setupDraft(true);
mockConnection.capabilities.features = ['web_terminal'];
testState.captureArtifactPanelOnly = true;
const { container } = renderApp({
rightPanel: { items: ['files', 'terminal'] },
});
await flush();
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
await flush();
act(() =>
testState.latestChatEditorProps?.onSelectWorkspace?.('/tmp/other'),
);
await flush();
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).toBeNull();
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
act(() => testState.latestArtifactPanelProps?.onOpenTerminal?.());
await flush();
expect(testState.latestArtifactPanelProps?.tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'files',
workspaceCwd: '/tmp/other',
}),
expect.objectContaining({
kind: 'terminal',
workspaceCwd: '/tmp/other',
}),
]),
);
const stored = JSON.parse(
window.localStorage.getItem(storageKey) ?? '{}',
);
expect(stored[draftKey]?.tabs).toContainEqual(
expect.objectContaining({
kind: 'files',
workspaceCwd: '/tmp/project',
}),
);
expect(stored['draft\0/tmp/other']?.tabs).toContainEqual(
expect.objectContaining({ kind: 'files', workspaceCwd: '/tmp/other' }),
);
});
it('retains twenty real session panel states while adding a workspace draft', async () => {
setupDraft();
const sessions = Object.fromEntries(
Array.from({ length: 20 }, (_, index) => [
`/tmp/project\0session-${index}`,
{ open: true, activeTabId: filesTab.id, tabs: [filesTab] },
]),
);
window.localStorage.setItem(
storageKey,
JSON.stringify({ v: 1, ...sessions }),
);
const { container } = renderApp({ rightPanel: { items: ['files'] } });
await flush();
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
await flush();
const stored = JSON.parse(
window.localStorage.getItem(storageKey) ?? '{}',
);
for (const key of Object.keys(sessions))
expect(stored[key]).toBeDefined();
expect(stored[draftKey]).toBeDefined();
});
it('retains session capacity across mixed-store reloads and evicts only the oldest draft', async () => {
setupDraft(true);
const sessions = Object.fromEntries(
Array.from({ length: 20 }, (_, index) => [
`/tmp/project\0session-${index}`,
{ open: true, activeTabId: filesTab.id, tabs: [filesTab] },
]),
);
const drafts = Object.fromEntries(
Array.from({ length: 19 }, (_, index) => [
`draft\0/tmp/draft-${index}`,
{ open: false, activeTabId: null, tabs: [] },
]),
);
window.localStorage.setItem(
storageKey,
JSON.stringify({
v: 1,
...sessions,
...drafts,
[draftKey]: {
open: true,
activeTabId: filesTab.id,
tabs: [filesTab],
},
}),
);
const first = renderApp({ rightPanel: { items: ['files'] } });
await flush();
first.unmount();
const { container } = renderApp({ rightPanel: { items: ['files'] } });
await flush();
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).not.toBeNull();
for (const key of Object.keys(sessions))
expect(
JSON.parse(window.localStorage.getItem(storageKey) ?? '{}')[key],
).toBeDefined();
act(() =>
testState.latestChatEditorProps?.onSelectWorkspace?.('/tmp/other'),
);
await flush();
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
await flush();
const stored = JSON.parse(
window.localStorage.getItem(storageKey) ?? '{}',
);
for (const key of Object.keys(sessions))
expect(stored[key]).toBeDefined();
expect(
Object.keys(stored).filter((key) => key.startsWith('draft\0')),
).toHaveLength(20);
expect(stored['draft\0/tmp/draft-0']).toBeUndefined();
expect(stored['draft\0/tmp/other']).toBeDefined();
});
it('does not consume a workspace draft when selecting an earlier standalone allocation', async () => {
setupDraft();
mockConnection.sessionContext = { kind: 'standalone' };
mockWorkspace.capabilities.features = ['standalone_sessions_v1'];
const { container, rerender } = renderApp({
rightPanel: { items: ['files'] },
});
await flush();
await submitFirstPrompt();
expect(mockConnection.sessionId).toBe('session-1');
mockConnection.sessionId = undefined;
mockConnection.sessionContext = undefined;
rerender();
await flush();
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
await flush();
expect(
JSON.parse(window.localStorage.getItem(storageKey) ?? '{}')[draftKey],
).toBeDefined();
mockConnection.sessionId = 'session-1';
mockConnection.sessionContext = { kind: 'standalone' };
rerender();
await flush();
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).toBeNull();
expect(
JSON.parse(window.localStorage.getItem(storageKey) ?? '{}')[draftKey],
).toBeDefined();
});
it('retains the Files panel width when the draft becomes the first session', async () => {
setupDraft();
testState.captureArtifactPanelOnly = true;
vi.spyOn(
HTMLElement.prototype,
'getBoundingClientRect',
).mockImplementation(function () {
return this.dataset['testid'] === 'chat-pane-container'
? new DOMRect(0, 0, 1200, 800)
: new DOMRect();
});
const { container } = renderApp({ rightPanel: { items: ['files'] } });
await flush();
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
await flush();
const width = testState.latestArtifactPanelProps?.panelWidth;
expect(width).toBeGreaterThanOrEqual(740);
await submitFirstPrompt();
expect(testState.latestArtifactPanelProps?.panelWidth).toBe(width);
});
it('retains fullscreen when the draft becomes the first session', async () => {
setupDraft();
window.localStorage.setItem(
storageKey,
JSON.stringify({
v: 1,
[draftKey]: {
open: true,
activeTabId: filesTab.id,
tabs: [filesTab],
},
}),
);
testState.captureArtifactPanelOnly = true;
renderApp({ rightPanel: { items: ['files'] } });
await flush();
act(() => testState.latestArtifactPanelProps?.onToggleFullscreen?.());
expect(testState.latestArtifactPanelProps?.fullscreen).toBe(true);
await submitFirstPrompt();
expect(testState.latestArtifactPanelProps?.fullscreen).toBe(true);
});
});
it('shows a reduced chat header on the empty new-task page', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'project', cwd: '/tmp/project', primary: true, trusted: true },
{ id: 'other', cwd: '/tmp/other', primary: false, trusted: true },
],
} as typeof mockWorkspace.capabilities;
const { container } = renderApp({
header: {
showMobileAccess: true,
items: [
'title',
'environment',
'rightPanel',
'tokenUsage',
'contextUsage',
],
},
rightPanel: { items: ['files', 'terminal', 'webPreview'] },
});
await flush();
const header = container.querySelector(
'[data-testid="chat-context-header"]',
);
expect(header).not.toBeNull();
// The composer below already names the workspace, so the chip is hidden.
expect(
header?.querySelector('[data-testid="chat-header-workspace"]'),
).toBeNull();
// Session-scoped entries stay hidden until a session exists.
expect(header?.textContent).not.toContain('New session');
expect(
header?.querySelector(
'button[aria-label="Toggle environment information"]',
),
).toBeNull();
expect(
header?.querySelector('button[aria-label="Session token usage"]'),
).toBeNull();
expect(
header?.querySelector('button[aria-label="Context Usage"]'),
).toBeNull();
// Daemon- and workspace-level entries remain.
expect(
header?.querySelector('[aria-label="Mobile access"]'),
).not.toBeNull();
expect(
header?.querySelector('button[aria-label="Toggle right panel"]'),
).not.toBeNull();
});
it('hides the new-task header when nothing workspace-scoped is available', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="chat-context-header"]'),
).toBeNull();
});
it('offers only workspace-scoped right panel entries on the empty new-task page', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
mockConnection.capabilities.features = ['web_terminal'];
const { container } = renderApp({
rightPanel: {
items: [
'files',
'review',
'sideTask',
'terminal',
'webPreview',
'trajectory',
],
},
});
await flush();
const toggle = container.querySelector<HTMLButtonElement>(
'[data-testid="chat-context-header"] button[aria-label="Toggle right panel"]',
);
expect(toggle).not.toBeNull();
act(() => toggle?.click());
const actions = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-testid="right-panel-empty-actions"] button',
),
);
const labels = actions.map((button) => button.textContent ?? '');
expect(labels.some((text) => text.includes('Files'))).toBe(true);
expect(labels.some((text) => text.includes('Web preview'))).toBe(true);
expect(labels.some((text) => text.includes('Terminal'))).toBe(true);
// Session-scoped entries stay hidden on the new-task page.
expect(labels.some((text) => text.includes('Changes'))).toBe(false);
expect(actions).toHaveLength(3);
});
it('keeps the empty new-task page headerless for custom header hosts', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
const renderChatHeader = vi.fn(() => (
<header data-testid="custom-chat-header">Custom session header</header>
));
const { container } = renderApp({ renderChatHeader });
await flush();
expect(renderChatHeader).not.toHaveBeenCalled();
expect(
container.querySelector('[data-testid="chat-context-header"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="custom-chat-header"]'),
).toBeNull();
});
it('migrates the new-task panel into the session the first prompt creates', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{
id: 'project-workspace',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
mockSessionActions.createSession.mockImplementation(async () => {
mockConnection.sessionId = 'session-1';
return { sessionId: 'session-1' };
});
const { container } = renderApp({ rightPanel: { items: ['files'] } });
await flush();
act(() => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="chat-context-header"] button[aria-label="Toggle right panel"]',
)
?.click();
});
act(() => {
Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-testid="right-panel-empty-actions"] button',
),
)
.find((button) => button.textContent?.includes('Files'))
?.click();
});
expect(
testState.latestArtifactPanelProps?.tabs?.some(
(tab: { kind: string }) => tab.kind === 'files',
),
).toBe(true);
await act(async () => {
testState.latestChatEditorProps?.onSubmit(
'hello',
undefined,
undefined,
editorCommit,
);
await Promise.resolve();
});
await flush();
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).not.toBeNull();
// The panel carries into the created session...
expect(
testState.latestArtifactPanelProps?.tabs?.some(
(tab: { kind: string }) => tab.kind === 'files',
),
).toBe(true);
// ...and the workspace's draft entry is gone from storage.
const stored = JSON.parse(
window.localStorage.getItem('qwen-code-web-shell-right-panel-state') ??
'{}',
);
expect(Object.keys(stored).some((key) => key.startsWith('draft'))).toBe(
false,
);
expect(Object.keys(stored).some((key) => key.endsWith('session-1'))).toBe(
true,
);
});
it('restores the new-task panel across a reload', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{
id: 'project-workspace',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
const first = renderApp({ rightPanel: { items: ['files'] } });
await flush();
act(() => {
first.container
.querySelector<HTMLButtonElement>(
'[data-testid="chat-context-header"] button[aria-label="Toggle right panel"]',
)
?.click();
});
act(() => {
Array.from(
first.container.querySelectorAll<HTMLButtonElement>(
'[data-testid="right-panel-empty-actions"] button',
),
)
.find((button) => button.textContent?.includes('Files'))
?.click();
});
await flush();
first.unmount();
const second = renderApp({ rightPanel: { items: ['files'] } });
await flush();
await flush();
expect(
second.container.querySelector('aside[aria-label="Right panel"]'),
).not.toBeNull();
expect(testState.latestArtifactPanelProps?.tabs).toContainEqual(
expect.objectContaining({ kind: 'files' }),
);
});
it('keeps the new-task panel across a switch to another session and back', async () => {
mockConnection.sessionId = undefined;
testState.messages = [];
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{
id: 'project-workspace',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
const { container, rerender } = renderApp({
rightPanel: { items: ['files'] },
});
await flush();
act(() => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="chat-context-header"] button[aria-label="Toggle right panel"]',
)
?.click();
});
act(() => {
Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-testid="right-panel-empty-actions"] button',
),
)
.find((button) => button.textContent?.includes('Files'))
?.click();
});
await flush();
// Switching to an existing session is not a materialization: the session
// shows its own (empty) panel and the draft stays stored.
mockConnection.sessionId = 'session-1';
rerender();
await flush();
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).toBeNull();
mockConnection.sessionId = undefined;
rerender();
await flush();
await flush();
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).not.toBeNull();
expect(testState.latestArtifactPanelProps?.tabs).toContainEqual(
expect.objectContaining({ kind: 'files' }),
);
});
it('opens environment information without restoring composer Git information', () => {
mockConnection.gitBranch = 'main';
mockConnection.gitStatus = {
+205 -67
View File
@@ -1807,6 +1807,21 @@ const DEFAULT_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [
'review',
'sideTask',
];
/**
* Right-panel entries that work without a session; the empty new-task page
* offers only these.
*/
const EMPTY_STATE_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [
'files',
'terminal',
'webPreview',
];
/**
* Key prefix backing the empty new-task page's panel state: one draft entry
* per workspace, migrated to the session's key when the first prompt
* materializes one.
*/
const DRAFT_PANEL_KEY_PREFIX = 'draft\0';
/**
* One trajectory tab per session, so re-opening an already-open one reveals it
* rather than stacking a second. Shared with the entry, which hides itself
@@ -2531,6 +2546,15 @@ function findReviewChangesByToolCallIds(
);
}
function getEvictedArtifactPanelStateKeys(keys: Iterable<string>): string[] {
const entries = [...keys];
return [false, true].flatMap((draft) =>
entries
.filter((key) => key.startsWith(DRAFT_PANEL_KEY_PREFIX) === draft)
.slice(0, -MAX_ARTIFACT_PANEL_SESSION_STATES),
);
}
function readStoredArtifactPanelStates(
key: string,
): Record<string, ArtifactPanelPersistedState> {
@@ -2546,9 +2570,7 @@ function readStoredArtifactPanelStates(
if (storedStates['v'] !== undefined && storedStates['v'] !== 1) return {};
delete storedStates['v'];
const result: Record<string, ArtifactPanelPersistedState> = {};
for (const [sessionKey, value] of Object.entries(storedStates).slice(
-MAX_ARTIFACT_PANEL_SESSION_STATES,
)) {
for (const [sessionKey, value] of Object.entries(storedStates)) {
if (!value || typeof value !== 'object') continue;
const state = value as Record<string, unknown>;
if (typeof state['open'] !== 'boolean') continue;
@@ -2565,6 +2587,11 @@ function readStoredArtifactPanelStates(
: [],
};
}
for (const evictedKey of getEvictedArtifactPanelStateKeys(
Object.keys(result),
)) {
delete result[evictedKey];
}
return result;
} catch {
// localStorage can be unavailable in private or embedded contexts.
@@ -2578,10 +2605,10 @@ function writeStoredArtifactPanelStates(
): void {
if (typeof window === 'undefined') return;
try {
while (Object.keys(states).length > MAX_ARTIFACT_PANEL_SESSION_STATES) {
const oldestSessionKey = Object.keys(states)[0];
if (!oldestSessionKey) break;
delete states[oldestSessionKey];
for (const evictedKey of getEvictedArtifactPanelStateKeys(
Object.keys(states),
)) {
delete states[evictedKey];
}
window.localStorage.setItem(key, JSON.stringify({ v: 1, ...states }));
} catch (error) {
@@ -3475,6 +3502,11 @@ export function App({
const contextUsageHeaderItemVisible =
chatHeaderItems.includes('contextUsage');
const rightPanelItems = rightPanel?.items ?? DEFAULT_RIGHT_PANEL_ITEMS;
// Workspace-scoped entries are the only ones usable before a session
// exists; the empty new-task page offers just those.
const emptyStateRightPanelItems = rightPanelItems.filter((item) =>
EMPTY_STATE_RIGHT_PANEL_ITEMS.includes(item),
);
const environmentPanelItems =
environmentPanel?.items ?? DEFAULT_ENVIRONMENT_PANEL_ITEMS;
// The environment panel is only reachable through the chat header toggle,
@@ -4447,6 +4479,17 @@ export function App({
const headerWorkspaceName = activeWorkspaceCwd
? workspaceLabelForCwd(activeWorkspaceCwd, ordinaryWorkspaces)
: undefined;
// The artifact panel keys its state per session; the empty new-task page
// gets a per-workspace draft key so its files/terminal/web-preview tabs
// persist and restore like a session's.
const panelWorkspaceCwd = connection.sessionId
? connection.workspaceCwd
: activeWorkspaceCwd;
const panelStateKey =
logicalSessionKey ??
(workspaceContextActive && activeWorkspaceCwd
? `${DRAFT_PANEL_KEY_PREFIX}${activeWorkspaceCwd}`
: undefined);
const workspaceWorkflowsEnabled =
workspaces.find(
(entry) =>
@@ -4853,8 +4896,7 @@ export function App({
initialArtifactPanelPersistedStates,
);
const initialArtifactPanelOpen = Boolean(
logicalSessionKey &&
initialArtifactPanelPersistedStates[logicalSessionKey]?.open,
panelStateKey && initialArtifactPanelPersistedStates[panelStateKey]?.open,
);
const [artifactPanelOpen, setArtifactPanelOpen] = useState(
initialArtifactPanelOpen,
@@ -5128,18 +5170,17 @@ export function App({
}, [artifactPanelTabs, blocks, logicalSessionKey]);
useLayoutEffect(() => {
if (
!logicalSessionKey ||
artifactPanelRestoredSessionKeyRef.current !== logicalSessionKey
!panelStateKey ||
artifactPanelRestoredSessionKeyRef.current !== panelStateKey
)
return;
const previousPersistedState =
artifactPanelPersistedStatesRef.current[logicalSessionKey];
delete artifactPanelPersistedStatesRef.current[logicalSessionKey];
artifactPanelPersistedStatesRef.current[panelStateKey];
delete artifactPanelPersistedStatesRef.current[panelStateKey];
const serializedTabs = serializeArtifactPanelTabs(artifactPanelTabs);
const deferredTabs =
artifactPanelDeferredPersistedTabsRef.current.get(logicalSessionKey) ??
[];
artifactPanelPersistedStatesRef.current[logicalSessionKey] = {
artifactPanelDeferredPersistedTabsRef.current.get(panelStateKey) ?? [];
artifactPanelPersistedStatesRef.current[panelStateKey] = {
open: artifactPanelOpen,
activeTabId:
activeArtifactPanelTabId ??
@@ -5162,7 +5203,7 @@ export function App({
activeArtifactPanelTabId,
artifactPanelOpen,
artifactPanelTabs,
logicalSessionKey,
panelStateKey,
]);
const [reviewChanges, setReviewChanges] = useState<
readonly TurnOutputFileChange[]
@@ -5209,7 +5250,14 @@ export function App({
const artifactPanelStateBySessionRef = useRef(
new Map<string, ArtifactPanelSessionState>(),
);
const artifactPanelSessionIdRef = useRef(logicalSessionKey);
const artifactPanelSessionIdRef = useRef(panelStateKey);
// Set when a session materialized out of the empty new-task state and
// adopted the panel as-is; cleared on the next real session switch.
const artifactPanelAdoptedSessionRef = useRef<string | undefined>(undefined);
// Set when the lazy first-prompt creation allocates a session id: the panel
// effect then treats the draft→session transition as a migration (panel
// carries over, draft entry dropped) instead of a session switch.
const draftMaterializedSessionIdRef = useRef<string | undefined>(undefined);
artifactPanelSessionStateRef.current = {
open: artifactPanelOpen,
tabs: artifactPanelTabs,
@@ -5228,10 +5276,7 @@ export function App({
workspaceContextActive &&
rightPanelItems.includes('files') &&
Boolean(
resolveArtifactWorkspaceOwner(
workspace.capabilities,
connection.workspaceCwd,
),
resolveArtifactWorkspaceOwner(workspace.capabilities, panelWorkspaceCwd),
);
const webPreviewAvailable =
workspaceContextActive && rightPanelItems.includes('webPreview');
@@ -5348,13 +5393,13 @@ export function App({
id,
kind: 'terminal',
title: count === 0 ? base : `${base} (${count + 1})`,
workspaceCwd: connection.workspaceCwd,
workspaceCwd: panelWorkspaceCwd,
initialized: true,
};
setArtifactPanelTabs((tabs) => [...tabs, tab]);
setActiveArtifactPanelTabId(tab.id);
setArtifactPanelOpen(true);
}, [connection.workspaceCwd, t]);
}, [panelWorkspaceCwd, t]);
const sideTaskCreationPromisesRef = useRef(
new Map<string, Promise<{ sessionId: string; displayName?: string }>>(),
);
@@ -6548,7 +6593,11 @@ export function App({
true;
useLayoutEffect(() => {
const previousSessionId = artifactPanelSessionIdRef.current;
const nextSessionId = logicalSessionKey;
const nextSessionId = panelStateKey;
const materializedSessionId = draftMaterializedSessionIdRef.current;
if (previousSessionId !== nextSessionId) {
draftMaterializedSessionIdRef.current = undefined;
}
setArtifactPanelRestoring(false);
if (
previousSessionId &&
@@ -6564,27 +6613,28 @@ export function App({
pendingRestore:
artifactPanelRestoredSessionKeyRef.current !== previousSessionId,
});
if (
artifactPanelStateBySessionRef.current.size >
MAX_ARTIFACT_PANEL_SESSION_STATES
) {
const oldestSessionId = Array.from(
artifactPanelStateBySessionRef.current.keys(),
).find((sessionId) => sessionId !== nextSessionId);
if (oldestSessionId) {
const oldestState =
artifactPanelStateBySessionRef.current.get(oldestSessionId);
for (const tab of oldestState?.tabs ?? []) {
if (tab.kind === 'terminal') {
releaseDetachedWebTerminal(
workspace.baseUrl,
tab.id,
tab.workspaceCwd,
);
}
const nextState = nextSessionId
? artifactPanelStateBySessionRef.current.get(nextSessionId)
: undefined;
if (nextSessionId && nextState) {
artifactPanelStateBySessionRef.current.delete(nextSessionId);
artifactPanelStateBySessionRef.current.set(nextSessionId, nextState);
}
for (const evictedKey of getEvictedArtifactPanelStateKeys(
artifactPanelStateBySessionRef.current.keys(),
)) {
const evictedState =
artifactPanelStateBySessionRef.current.get(evictedKey);
for (const tab of evictedState?.tabs ?? []) {
if (tab.kind === 'terminal') {
releaseDetachedWebTerminal(
workspace.baseUrl,
tab.id,
tab.workspaceCwd,
);
}
artifactPanelStateBySessionRef.current.delete(oldestSessionId);
}
artifactPanelStateBySessionRef.current.delete(evictedKey);
}
}
}
@@ -6601,15 +6651,74 @@ export function App({
nextSavedState?.width ?? getDefaultReviewPanelWidth(),
);
setArtifactPanelFullscreen(false);
artifactPanelAdoptedSessionRef.current = undefined;
}
artifactPanelSessionIdRef.current = nextSessionId;
if (!nextSessionId) {
artifactPanelRestoredSessionKeyRef.current = undefined;
setArtifactPanelRestoring(false);
setArtifactPanelOpen(false);
setArtifactPanelTabs([]);
setActiveArtifactPanelTabId(null);
// Steady no-key states keep an already-open panel usable; only a real
// key change (leaving a session, or a draft losing its workspace
// context) drops the live panel content — persisted entries survive.
if (previousSessionId !== nextSessionId) {
setArtifactPanelOpen(false);
setArtifactPanelTabs([]);
setActiveArtifactPanelTabId(null);
}
return;
}
if (
previousSessionId !== undefined &&
previousSessionId.startsWith(DRAFT_PANEL_KEY_PREFIX) &&
materializedSessionId !== undefined &&
materializedSessionId === connection.sessionId
) {
// The first prompt consumed the draft: the panel carries into the new
// session, and the workspace's draft resets to empty for the next
// new-task visit.
artifactPanelStateBySessionRef.current.delete(previousSessionId);
const previousDraftPersisted =
artifactPanelPersistedStatesRef.current[previousSessionId];
delete artifactPanelPersistedStatesRef.current[previousSessionId];
// Deferred tabs belong to the draft too: move them under the session
// key so a capability flip can still restore them later.
const deferredDraftTabs =
artifactPanelDeferredPersistedTabsRef.current.get(previousSessionId) ??
[];
artifactPanelDeferredPersistedTabsRef.current.delete(previousSessionId);
if (deferredDraftTabs.length > 0) {
setBoundedMapEntry(
artifactPanelDeferredPersistedTabsRef.current,
nextSessionId,
deferredDraftTabs,
MAX_ARTIFACT_PANEL_SESSION_STATES * 2,
);
}
// Persist the carried-over state under the session key immediately: the
// persist writer already ran earlier in this commit (before this
// effect) and would otherwise leave the session unrecorded until the
// next panel change.
const carriedState = artifactPanelSessionStateRef.current;
if (carriedState) {
setArtifactPanelWidth(carriedState.width);
setArtifactPanelFullscreen(artifactPanelFullscreenRef.current);
artifactPanelPersistedStatesRef.current[nextSessionId] = {
open: carriedState.open,
activeTabId: carriedState.activeTabId,
tabs: mergePersistedArtifactPanelTabs(
serializeArtifactPanelTabs(carriedState.tabs),
deferredDraftTabs,
previousDraftPersisted?.tabs,
),
};
}
writeStoredArtifactPanelStates(
RIGHT_PANEL_STATE_STORAGE_KEY,
artifactPanelPersistedStatesRef.current,
);
artifactPanelAdoptedSessionRef.current = nextSessionId;
artifactPanelRestoredSessionKeyRef.current = nextSessionId;
return;
}
const newlyAvailableDeferredTab = (
@@ -6630,6 +6739,14 @@ export function App({
) {
return;
}
if (
artifactPanelAdoptedSessionRef.current === nextSessionId &&
!newlyAvailableDeferredTab
) {
// An adopted new-task panel stays untouched through the session's
// first load (transcript/artifact flips included).
return;
}
const saveCurrentState = () => {
if (artifactPanelRestoredSessionKeyRef.current !== nextSessionId) return;
const currentState = artifactPanelSessionStateRef.current;
@@ -6749,7 +6866,7 @@ export function App({
artifactPanelDeferredPersistedTabsRef.current,
nextSessionId,
deferredPersistedTabs,
MAX_ARTIFACT_PANEL_SESSION_STATES,
MAX_ARTIFACT_PANEL_SESSION_STATES * 2,
);
} else {
artifactPanelDeferredPersistedTabsRef.current.delete(nextSessionId);
@@ -7085,7 +7202,7 @@ export function App({
getDefaultReviewPanelWidth,
hydratePendingArtifactPanelTab,
hydrateRestoredAttachmentTab,
logicalSessionKey,
panelStateKey,
resetEmptyArtifactPanel,
sessionAgentTraceSupported,
sessionActions,
@@ -7138,7 +7255,7 @@ export function App({
const openFilesPanel = useCallback(() => {
const owner = resolveArtifactWorkspaceOwner(
workspace.capabilities,
connection.workspaceCwd,
panelWorkspaceCwd,
);
if (!filesAvailable || !owner) return;
rememberArtifactPanelTrigger();
@@ -7158,7 +7275,7 @@ export function App({
);
setArtifactPanelOpen(true);
}, [
connection.workspaceCwd,
panelWorkspaceCwd,
filesAvailable,
getMaxArtifactPanelWidth,
rememberArtifactPanelTrigger,
@@ -11010,6 +11127,7 @@ export function App({
await onSessionCreatedRef.current?.(sessionId);
},
onSessionAllocated: (sessionId) => {
draftMaterializedSessionIdRef.current = sessionId;
notify?.(sessionId);
preparingSessionIdRef.current = sessionId;
allocatedSessionId = sessionId;
@@ -19537,6 +19655,18 @@ export function App({
!showFloatingTodos &&
!pendingApproval &&
!btwMessage;
// Custom header renderers require a session. The built-in empty-state
// header exposes only configured workspace actions with a resolved owner.
const emptyStateChatHeader =
isChatEmptyState &&
Boolean(header) &&
!renderChatHeader &&
workspaceContextActive &&
Boolean(activeWorkspaceCwd) &&
mainView === 'chat' &&
((rightPanelHeaderItemVisible && emptyStateRightPanelItems.length > 0) ||
showMobileAccess ||
workspaceHostsEnabled);
const handleAgentCollaborationError = useCallback(
(message: string) => pushToast('error', message),
[pushToast],
@@ -19735,6 +19865,14 @@ export function App({
effectiveSessionContext?.kind,
isChatEmptyState,
]);
const composerNamesWorkspace =
composerWorkspaceSelectEnabled &&
visibleComposerToolbarActions.includes('workspace') &&
composerWorkspaces !== undefined &&
(composerWorkspaces.length > 1 ||
scratchWorkspaceRegistrationSupported ||
dynamicWorkspaceRegistrationSupported ||
(standaloneSessionsSupported && !lockedWorkspaceCwd));
const useMobileWelcomeMiddleLayout =
isChatEmptyState && mobileWelcomeFooterMiddle;
const showMobileWelcomeFooterMiddle =
@@ -20264,7 +20402,7 @@ export function App({
activeTabId: activeArtifactPanelTabId,
reviewChanges,
selectedReviewPath,
workspaceCwd: connection.workspaceCwd || '',
workspaceCwd: panelWorkspaceCwd || '',
loading: artifactsLoading,
restoring: artifactPanelRestoring,
error: artifactsError,
@@ -20309,7 +20447,7 @@ export function App({
trajectoryTabId: connection.sessionId
? trajectoryTabId(connection.sessionId)
: undefined,
items: rightPanelItems,
items: isChatEmptyState ? emptyStateRightPanelItems : rightPanelItems,
sideTaskAvailable: sideTasksAvailable,
sideTasks: visibleSideTasks,
sideTasksLoading,
@@ -21171,7 +21309,7 @@ export function App({
aria-hidden={artifactPanelFullscreen || undefined}
>
{chatHeaderEnabled &&
!isChatEmptyState &&
(!isChatEmptyState || emptyStateChatHeader) &&
!activePanel &&
(mainView === 'chat' || mainView === 'cockpit') && (
<div className={styles.chatHeaderRow}>
@@ -21245,28 +21383,34 @@ export function App({
location={
workspaceHostsEnabled && mainView === 'chat' ? (
<WorkspaceLocation
cwd={connection.workspaceCwd}
cwd={panelWorkspaceCwd}
hostOrigin={focusedHostOrigin}
/>
) : undefined
}
content={
titleHeaderItemVisible
titleHeaderItemVisible && !isChatEmptyState
? (sessionDisplayName ?? t('session.new'))
: null
}
workspaceName={headerWorkspaceName}
workspacePath={activeWorkspaceCwd}
hideWorkspaceIcon={isChatEmptyState && composerNamesWorkspace}
environmentOpen={environmentPanelVisible}
environmentAvailable={
mainView === 'chat' && environmentHeaderItemVisible
!isChatEmptyState &&
mainView === 'chat' &&
environmentHeaderItemVisible
}
environmentRunning={environmentRunning}
rightPanelOpen={artifactPanelOpen}
rightPanelAvailable={
mainView === 'chat' &&
rightPanelHeaderItemVisible &&
!artifactPanelOpen
!artifactPanelOpen &&
(!isChatEmptyState ||
(Boolean(activeWorkspaceCwd) &&
emptyStateRightPanelItems.length > 0))
}
onToggleEnvironment={() =>
handleEnvironmentPanelOpenChange(
@@ -21344,19 +21488,13 @@ export function App({
>
{sidebarOptions.enabled &&
sidebarOptions.showCompactToggle &&
(!chatHeaderEnabled || isChatEmptyState) &&
(!chatHeaderEnabled ||
(isChatEmptyState && !emptyStateChatHeader)) &&
!activePanel &&
mainView === 'chat' && (
<button
type="button"
className={[
styles.hamburgerButton,
!chatHeaderEnabled || isChatEmptyState
? styles.hamburgerButtonFloating
: undefined,
]
.filter(Boolean)
.join(' ')}
className={`${styles.hamburgerButton} ${styles.hamburgerButtonFloating}`}
onClick={() => {
setForceMobileDrawer(false);
setMobileDrawerOpen((open) => !open);
@@ -23,12 +23,17 @@ interface ChatContextHeaderProps {
/**
* Workspace shown as the header's leading icon. Omitting it means the shell
* has no workspace: the icon stays the same folder and only its tooltip says
* so, because the icon is the one place that always answers "which workspace
* is this session in?".
* so. In a session this icon always reports the workspace; the empty-state
* header can defer to a visible composer workspace selector.
*/
workspaceName?: string;
/** Full workspace path, shown as the leading icon's hover tooltip. */
workspacePath?: string;
/**
* Hides the leading workspace icon when the new-task composer's workspace
* selector is visible and already names the workspace.
*/
hideWorkspaceIcon?: boolean;
environmentOpen: boolean;
environmentAvailable: boolean;
/** Whether the panel holds an in-progress subagent or background task. */
@@ -50,6 +55,7 @@ export function ChatContextHeader({
location,
workspaceName,
workspacePath,
hideWorkspaceIcon,
environmentOpen,
environmentAvailable,
environmentRunning,
@@ -66,40 +72,42 @@ export function ChatContextHeader({
return (
<header className={styles.header} data-testid="chat-context-header">
{/* role="img" so the icon is announced as its aria-label; a bare span
(generic role) does not surface one reliably. The same folder glyph
serves both states — the icon cannot name the workspace, or say that
there is none, so hovering it does. */}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className={styles.workspaceIcon}
data-testid="chat-header-workspace"
aria-label={
workspaceName
? t('workspace.paneLabel', { name: workspaceName })
: workspaceLabel
}
>
<FolderClosedIcon />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<span className={styles.workspaceTooltip}>
<span className={styles.workspaceTooltipName}>
{workspaceLabel}
{!hideWorkspaceIcon && (
// role="img" so the icon is announced as its aria-label; a bare span
// (generic role) does not surface one reliably. The same folder glyph
// serves both states — the icon cannot name the workspace, or say that
// there is none, so hovering it does.
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className={styles.workspaceIcon}
data-testid="chat-header-workspace"
aria-label={
workspaceName
? t('workspace.paneLabel', { name: workspaceName })
: workspaceLabel
}
>
<FolderClosedIcon />
</span>
{workspaceName && workspacePath ? (
<span className={styles.workspaceTooltipPath}>
{workspacePath}
</TooltipTrigger>
<TooltipContent side="bottom">
<span className={styles.workspaceTooltip}>
<span className={styles.workspaceTooltipName}>
{workspaceLabel}
</span>
) : null}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{workspaceName && workspacePath ? (
<span className={styles.workspaceTooltipPath}>
{workspacePath}
</span>
) : null}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className={styles.content}>{content}</div>
{location && (
<div className="mr-2 flex min-w-0 max-w-[40%] shrink items-center">
@@ -1299,6 +1299,7 @@ export function ArtifactPanel({
<button
type="button"
className={styles.emptyAction}
data-testid="right-panel-open-web-preview"
onClick={onOpenWebPreview}
>
<span className={styles.emptyActionIcon} aria-hidden="true">
@@ -177,9 +177,7 @@ test.beforeEach(async ({ page }, testInfo) => {
await page
.getByRole('button', { name: 'Toggle right panel', exact: true })
.click();
await page
.getByRole('button', { name: /Web preview.*Preview a running/ })
.click();
await page.getByTestId('right-panel-open-web-preview').click();
});
async function openPreview(page: Page, url: string) {
@@ -0,0 +1,95 @@
import { expect, test } from '@playwright/test';
import {
createWebShellDaemonScenario,
installMockDaemon,
} from './utils/mockDaemon';
// Simulated daemon: this covers browser state, not real filesystem or Git operations.
test('keeps workspace tools available before and after the first prompt @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
sessions: [],
capabilities: {
workspaces: [
{
id: 'project',
cwd: '/tmp/qwen-web-shell-e2e',
primary: true,
trusted: true,
},
],
},
});
scenario.capabilities.features.push('web_terminal');
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (/^(?:\/workspaces\/[^/]+)?\/list$/.test(url.pathname)) {
await route.fulfill({
json: {
kind: 'list',
path: url.searchParams.get('path') ?? '.',
entries: [{ name: 'README.md', kind: 'file', ignored: false }],
truncated: false,
},
});
return;
}
await route.fallback();
});
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/?language=en');
const header = page.getByTestId('chat-context-header');
await expect(header).toBeVisible();
await expect(header.getByTestId('chat-header-workspace')).toBeVisible();
await expect(
header.getByRole('button', { name: 'Toggle environment information' }),
).toHaveCount(0);
await expect(
header.getByRole('button', { name: 'Session token usage' }),
).toHaveCount(0);
await header
.getByRole('button', { name: 'Toggle right panel', exact: true })
.click();
const panel = page.getByRole('complementary', { name: 'Right panel' });
const actions = panel.getByTestId('right-panel-empty-actions');
await expect(actions.getByRole('button')).toHaveCount(3);
await expect(actions.getByRole('button', { name: /Files/ })).toBeVisible();
await expect(actions.getByRole('button', { name: /Terminal/ })).toBeVisible();
await expect(
actions.getByTestId('right-panel-open-web-preview'),
).toBeVisible();
if (process.env['PR_SCREENSHOT_DIR']) {
await page.screenshot({
path: `${process.env['PR_SCREENSHOT_DIR']}/new-task-tools.png`,
fullPage: true,
animations: 'disabled',
});
}
await actions.getByRole('button', { name: /Files/ }).click();
await expect(
panel.getByRole('button', { name: 'Close Files', exact: true }),
).toBeVisible();
await page.reload();
await expect(
panel.getByRole('button', { name: 'Close Files', exact: true }),
).toBeVisible();
await page.locator('[data-web-shell-composer-editor] .cm-content').click();
await page.keyboard.type('Keep my workspace panel');
await page.locator('[data-web-shell-composer-submit]').click();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
await expect(page).toHaveURL(/\/session\//);
await expect(
panel.getByRole('button', { name: 'Close Files', exact: true }),
).toBeVisible();
if (process.env['PR_SCREENSHOT_DIR']) {
await page.screenshot({
path: `${process.env['PR_SCREENSHOT_DIR']}/first-prompt-files.png`,
fullPage: true,
animations: 'disabled',
});
}
});
+4 -4
View File
@@ -1806,7 +1806,7 @@ const EN: Messages = {
'sideTask.new': 'New',
'sideTask.create': 'New side task',
'webPreview.title': 'Web preview',
'webPreview.openHint': 'Preview a running web app',
'webPreview.openHint': 'Preview web apps',
'webPreview.address': 'Development URL',
'webPreview.open': 'Open',
'webPreview.refresh': 'Refresh preview',
@@ -3339,7 +3339,7 @@ const EN: Messages = {
'tokenUsage.updatedAt': (v) => `Updated ${v?.time ?? ''}`,
'tokenUsage.unavailable': 'Token usage is unavailable for this session.',
'trajectory.title': 'Trajectory',
'trajectory.description': 'See where a run spent its time and tokens',
'trajectory.description': 'View time and tokens',
'trajectory.empty': 'No records in this session yet.',
'trajectory.noTiming':
'No request or tool durations are recorded for these records.',
@@ -6094,7 +6094,7 @@ const ZH: Messages = {
'sideTask.new': '新增',
'sideTask.create': '新建侧边任务',
'webPreview.title': '网页预览',
'webPreview.openHint': '预览正在运行的网页应用',
'webPreview.openHint': '预览网页应用',
'webPreview.address': '开发地址',
'webPreview.open': '打开',
'webPreview.refresh': '刷新预览',
@@ -7506,7 +7506,7 @@ const ZH: Messages = {
'tokenUsage.updatedAt': (v) => `更新于 ${v?.time ?? ''}`,
'tokenUsage.unavailable': '当前会话无法读取 Token 消耗。',
'trajectory.title': '轨迹',
'trajectory.description': '查看这次运行把时间和 token 花在了哪里',
'trajectory.description': '查看时间和 token',
'trajectory.empty': '这个会话还没有记录。',
'trajectory.noTiming': '这些记录没有请求或工具的耗时数据。',
'trajectory.truncated': '只显示最近的记录,更早的历史没有加载。',