feat(web-shell): open workspace files from the chat header (#14344)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-16 13:58:08 +08:00
1 parent 81f983b800
commit 4fda3367b6
8 files changed
+227 -32

No files matched your search

@@ -0,0 +1,71 @@
# Open workspace files from the chat header
[English](2026-10-16-header-workspace-files.md) | [简体中文](2026-10-16-header-workspace-files.zh-CN.md)
## Problem and current behavior
The folder beside the chat title identifies the current workspace through a
hover tooltip, but cannot be clicked. Opening the existing Files tree requires
opening the right panel and choosing Files.
## Proposed behavior
Clicking the leading folder opens the right panel and selects the current
workspace's Files tab. Repeated activation keeps that tab open without creating
duplicates. Hovering still shows the workspace name and full path.
## Implementation and decisions
`ChatContextHeader` accepts an optional `onOpenFiles` callback. When supplied,
the existing folder is a native button with keyboard activation and a visible
focus state; otherwise it remains an informational icon. The accessible name
combines the existing localized file-browsing action and workspace label.
`App` supplies its existing `openFilesPanel` callback only in chat view when
`filesAvailable` is true. That path already resolves the panel workspace owner,
checks trust and configuration, deduplicates tabs, selects Files, sizes the
panel and opens it. Reusing it preserves selected-workspace ownership and avoids
new filesystem or daemon behavior.
A floating Files drawer closed by Reference must return focus to the composer,
rather than its header trigger. Record that close intent and handle it in the
drawer close autofocus callback; ordinary dismissal still restores the trigger.
## Scope and constraints
The new entry is limited to the built-in header. Its drawer close path also
preserves composer focus after referencing a file. Custom headers, composer
workspace indicators, backend routes, file operations and public host APIs
remain outside this change.
The existing empty-state rule still hides the folder when the composer names the
workspace. Disabled Files and unresolved or untrusted owners retain the
informational tooltip without an action.
Affected implementation: `ChatContextHeader.tsx`, its CSS module, and `App.tsx`.
Validation covers their collocated tests and the existing Files browser E2E
suite. No new dependency is required.
## Risks and validation
The main risks are opening a different workspace, toggling the panel closed on
repeated activation, or losing the tooltip and keyboard access. Focused tests
cover action availability, correct ownership, selecting Files over another tab,
repeated activation, and tooltip retention. Browser E2E uses the existing
simulated daemon to inspect the actual tree and qualified content requests; it
does not claim real filesystem or Git backend validation. Run the repository
build, typecheck and bundle, plus focused Web Shell tests and browser checks.
## Acceptance criteria
- Clicking the header folder opens the right-side Files tree for its workspace.
- Clicking again keeps one Files tab open and active.
- Activating the folder while another tab is active selects Files.
- Keyboard users can focus and activate the button; the name and path tooltip
remains available.
- Unavailable workspaces and disabled Files expose no file-opening action.
- Referencing a file from a drawer returns focus to the composer for typing.
- Existing hidden-folder and custom-header behavior is preserved.
## Open questions
None.
@@ -0,0 +1,60 @@
# 从聊天标题栏打开工作区文件
[English](2026-10-16-header-workspace-files.md) | [简体中文](2026-10-16-header-workspace-files.zh-CN.md)
## 问题与当前行为
聊天标题旁的文件夹通过悬停提示展示当前工作区,但不能点击。打开现有文件树
需要先展开右侧面板,再选择“文件”。
## 预期行为
点击标题前的文件夹,打开右侧面板并选中当前工作区的“文件”标签。重复点击保持
该标签打开,不创建重复标签。悬停继续展示工作区名称与完整路径。
## 实现与决策
`ChatContextHeader` 接收可选的 `onOpenFiles` 回调。提供回调时,现有文件夹成为
原生按钮,支持键盘激活并显示焦点样式;没有回调时,仍为信息图标。无障碍名称
组合现有的本地化文件浏览操作说明与工作区标签。
`App` 仅在聊天视图且 `filesAvailable` 为真时传入现有 `openFilesPanel` 回调。
该路径已经负责解析面板所属工作区、检查信任和配置、去重标签、选中“文件”、
调整面板大小并打开面板。复用此路径可以保留所选工作区的归属,不增加文件系统
或 daemon 行为。
浮动文件抽屉通过“引用”操作关闭时,焦点必须返回输入区,而非标题栏入口。
记录此次关闭意图,并在抽屉的关闭自动聚焦回调中处理;普通关闭仍恢复入口焦点。
## 范围与约束
新增入口仅位于内置标题栏,同时在文件抽屉关闭路径中保留引用文件后的输入区
焦点。自定义标题栏、输入区工作区标识、后端路由、文件操作以及公开宿主 API
均不在本次范围内。空白任务页面仍遵守现有规则:输入区已显示
工作区时隐藏文件夹。禁用“文件”、工作区未解析或未受信任时,保留信息提示,
不提供点击操作。
受影响的实现为 `ChatContextHeader.tsx`、其 CSS 模块和 `App.tsx`。验证覆盖
相邻单元测试以及现有文件浏览器 E2E 测试。不新增依赖。
## 风险与验证
主要风险是打开错误工作区、重复点击导致面板关闭、丢失提示或键盘访问能力。
针对性测试覆盖操作可用性、正确归属、从其他标签切换至“文件”、重复激活和
提示保留。浏览器 E2E 使用现有模拟 daemon,检查实际文件树和带工作区限定的
内容请求;不将其作为真实文件系统或 Git 后端验证。执行仓库构建、类型检查、
bundle,以及针对性的 Web Shell 测试和浏览器检查。
## 验收标准
- 点击标题栏文件夹后,右侧打开对应工作区的文件树。
- 再次点击保持唯一的“文件”标签打开并选中。
- 其他标签处于激活状态时,点击文件夹切换至“文件”。
- 键盘用户可聚焦并激活按钮,名称和路径提示仍可访问。
- 不可用工作区及禁用“文件”的配置不提供打开文件操作。
- 从抽屉引用文件后,焦点返回输入区,可继续输入。
- 保留现有文件夹隐藏规则与自定义标题栏行为。
## 未决问题
无。
+25 -11
View File
@@ -17955,7 +17955,7 @@ describe('App session callbacks', () => {
expect(testState.latestArtifactPanelProps?.onReferenceFile).toBeUndefined();
});
it('opens one files tab per workspace when configured', () => {
it('opens and selects one files tab from the header folder', () => {
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
@@ -17969,16 +17969,12 @@ describe('App session callbacks', () => {
} as typeof mockWorkspace.capabilities;
testState.captureArtifactPanelOnly = true;
const { container } = renderApp({ rightPanel: { items: ['files'] } });
act(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle right panel"]',
)
?.click(),
);
expect(testState.latestArtifactPanelProps?.onOpenFiles).toBeDefined();
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
act(() => testState.latestArtifactPanelProps?.onOpenFiles?.());
const folder = container.querySelector<HTMLButtonElement>(
'button[data-testid="chat-header-workspace"]',
)!;
expect(folder).not.toBeNull();
act(() => folder.click());
act(() => folder.click());
expect(testState.latestArtifactPanelProps?.tabs).toEqual([
expect.objectContaining({
kind: 'files',
@@ -18008,6 +18004,21 @@ describe('App session callbacks', () => {
expect(testState.latestArtifactPanelProps?.tabs[1]).not.toHaveProperty(
'previewContent',
);
expect(testState.latestArtifactPanelProps?.activeTabId).toBe(
testState.latestArtifactPanelProps?.tabs[1]?.id,
);
act(() => folder.click());
expect(testState.latestArtifactPanelProps?.tabs).toHaveLength(2);
expect(testState.latestArtifactPanelProps?.activeTabId).toBe(
testState.latestArtifactPanelProps?.tabs[0]?.id,
);
act(() => testState.latestArtifactPanelProps?.onClose());
testState.latestArtifactPanelProps = null;
act(() => folder.click());
expect(testState.latestArtifactPanelProps?.tabs).toHaveLength(2);
expect(testState.latestArtifactPanelProps?.activeTabId).toBe(
testState.latestArtifactPanelProps?.tabs[0]?.id,
);
});
it.each(['disabled', 'untrusted', 'unknown'] as const)(
@@ -18050,6 +18061,9 @@ describe('App session callbacks', () => {
const { container } = renderApp({
rightPanel: { items: reason === 'disabled' ? ['review'] : ['files'] },
});
expect(
container.querySelector('button[data-testid="chat-header-workspace"]'),
).toBeNull();
act(() =>
container
.querySelector<HTMLButtonElement>(
+13
View File
@@ -5138,6 +5138,7 @@ export function App({
const artifactPanelOpenRef = useRef(artifactPanelOpen);
artifactPanelOpenRef.current = artifactPanelOpen;
const artifactPanelTriggerRef = useRef<HTMLElement | null>(null);
const focusComposerAfterArtifactPanelCloseRef = useRef(false);
const rememberArtifactPanelTrigger = useCallback(() => {
if (
!artifactPanelOpenRef.current &&
@@ -20432,6 +20433,7 @@ export function App({
);
if (!tag) return;
if (useFloatingArtifactPanel) {
focusComposerAfterArtifactPanelCloseRef.current = true;
closeArtifactPanel();
} else {
setArtifactPanelFullscreen(false);
@@ -21418,6 +21420,11 @@ export function App({
(Boolean(activeWorkspaceCwd) &&
emptyStateRightPanelItems.length > 0))
}
onOpenFiles={
mainView === 'chat' && filesAvailable
? openFilesPanel
: undefined
}
onToggleEnvironment={() =>
handleEnvironmentPanelOpenChange(
!environmentPanelVisible,
@@ -23517,6 +23524,12 @@ export function App({
onCloseAutoFocus={(event) => {
const trigger = artifactPanelTriggerRef.current;
artifactPanelTriggerRef.current = null;
if (focusComposerAfterArtifactPanelCloseRef.current) {
focusComposerAfterArtifactPanelCloseRef.current = false;
event.preventDefault();
editorRef.current?.focus();
return;
}
if (!trigger?.isConnected) return;
event.preventDefault();
trigger.focus();
@@ -34,10 +34,22 @@
align-items: center;
justify-content: center;
margin-right: 2px;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--foreground);
}
button.workspaceIcon {
cursor: pointer;
}
.workspaceIcon:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.workspaceIcon:hover {
background: var(--accent);
}
@@ -236,9 +236,38 @@ describe('ChatContextHeader', () => {
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(text);
});
it('keeps the workspace icon out of the clickable actions', () => {
it('opens workspace files from a button while retaining the tooltip', () => {
vi.useFakeTimers();
const onOpenFiles = vi.fn();
const view = mount({
workspaceName: 'api',
workspacePath: '/work/api',
onOpenFiles,
});
const button = view.querySelector<HTMLButtonElement>(
'button[data-testid="chat-header-workspace"]',
)!;
expect(button.type).toBe('button');
expect(button.getAttribute('aria-label')).toBe(
'Browse workspace files: Workspace: api',
);
act(() => {
button.dispatchEvent(new Event('pointermove', { bubbles: true }));
vi.advanceTimersByTime(300);
});
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
'api/work/api',
);
act(() => button.click());
expect(onOpenFiles).toHaveBeenCalledOnce();
});
it('keeps the workspace icon informational when files are unavailable', () => {
const view = mount({ workspaceName: 'api', workspacePath: '/work/api' });
expect(view.querySelectorAll('button')).toHaveLength(1);
expect(
view.querySelector('[data-testid="chat-header-workspace"]')?.tagName,
).toBe('SPAN');
});
});
@@ -42,6 +42,8 @@ interface ChatContextHeaderProps {
rightPanelAvailable: boolean;
onToggleEnvironment: () => void;
onToggleRightPanel: () => void;
/** Opens the current workspace file tree; informational when omitted. */
onOpenFiles?: () => void;
/** Opens the session token-usage panel; hidden when omitted. */
onOpenTokenUsage?: () => void;
/** Opens the session context panel; hidden when omitted. */
@@ -63,35 +65,38 @@ export function ChatContextHeader({
rightPanelAvailable,
onToggleEnvironment,
onToggleRightPanel,
onOpenFiles,
onOpenTokenUsage,
onOpenContextUsage,
onOpenLocalControlSettings,
}: ChatContextHeaderProps) {
const { t } = useI18n();
const workspaceLabel = workspaceName ?? t('sidebar.noWorkspace');
const WorkspaceIcon = onOpenFiles ? 'button' : 'span';
const workspaceAriaLabel = workspaceName
? t('workspace.paneLabel', { name: workspaceName })
: workspaceLabel;
return (
<header className={styles.header} data-testid="chat-context-header">
{!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"
<WorkspaceIcon
type={onOpenFiles ? 'button' : undefined}
role={onOpenFiles ? undefined : 'img'}
onClick={onOpenFiles}
className={styles.workspaceIcon}
data-testid="chat-header-workspace"
aria-label={
workspaceName
? t('workspace.paneLabel', { name: workspaceName })
: workspaceLabel
onOpenFiles
? `${t('files.description')}: ${workspaceAriaLabel}`
: workspaceAriaLabel
}
>
<FolderClosedIcon />
</span>
</WorkspaceIcon>
</TooltipTrigger>
<TooltipContent side="bottom">
<span className={styles.workspaceTooltip}>
@@ -232,16 +232,7 @@ async function openFiles(
await expect(
page.getByText('Workspace ready.', { exact: true }),
).toBeVisible();
await page
.getByRole('button', { name: 'Toggle right panel', exact: true })
.click();
await expect(
page.getByRole('button', { name: /Files.*Browse workspace files/ }),
).toBeVisible();
await page
.getByRole('button', { name: /Files.*Browse workspace files/ })
.click();
await page.getByTestId('chat-header-workspace').click();
await afterOpen?.();
const browser = page.getByTestId('workspace-file-browser');
await expect(