mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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 内容说明没有工作区。
|
||||
- 分屏与自行配置输入框的宿主保持原有排布。
|
||||
|
||||
@@ -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 仍需宿主的防嵌入策略保护。
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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': '只显示最近的记录,更早的历史没有加载。',
|
||||
|
||||
Reference in new issue
Block a user