diff --git a/docs/design/2026-10-16-header-workspace-files.md b/docs/design/2026-10-16-header-workspace-files.md new file mode 100644 index 00000000000..d66ee8087e3 --- /dev/null +++ b/docs/design/2026-10-16-header-workspace-files.md @@ -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. diff --git a/docs/design/2026-10-16-header-workspace-files.zh-CN.md b/docs/design/2026-10-16-header-workspace-files.zh-CN.md new file mode 100644 index 00000000000..c3bfca56f7c --- /dev/null +++ b/docs/design/2026-10-16-header-workspace-files.zh-CN.md @@ -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 测试和浏览器检查。 + +## 验收标准 + +- 点击标题栏文件夹后,右侧打开对应工作区的文件树。 +- 再次点击保持唯一的“文件”标签打开并选中。 +- 其他标签处于激活状态时,点击文件夹切换至“文件”。 +- 键盘用户可聚焦并激活按钮,名称和路径提示仍可访问。 +- 不可用工作区及禁用“文件”的配置不提供打开文件操作。 +- 从抽屉引用文件后,焦点返回输入区,可继续输入。 +- 保留现有文件夹隐藏规则与自定义标题栏行为。 + +## 未决问题 + +无。 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index d5445eee1bb..2f475535cb0 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -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( - 'button[aria-label="Toggle right panel"]', - ) - ?.click(), - ); - expect(testState.latestArtifactPanelProps?.onOpenFiles).toBeDefined(); - act(() => testState.latestArtifactPanelProps?.onOpenFiles?.()); - act(() => testState.latestArtifactPanelProps?.onOpenFiles?.()); + const folder = container.querySelector( + '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( diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b256f72d05d..3b125fc3479 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -5138,6 +5138,7 @@ export function App({ const artifactPanelOpenRef = useRef(artifactPanelOpen); artifactPanelOpenRef.current = artifactPanelOpen; const artifactPanelTriggerRef = useRef(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(); diff --git a/packages/web-shell/client/components/ChatContextHeader.module.css b/packages/web-shell/client/components/ChatContextHeader.module.css index 4434627ba6d..805dca25272 100644 --- a/packages/web-shell/client/components/ChatContextHeader.module.css +++ b/packages/web-shell/client/components/ChatContextHeader.module.css @@ -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); } diff --git a/packages/web-shell/client/components/ChatContextHeader.test.tsx b/packages/web-shell/client/components/ChatContextHeader.test.tsx index 5ae5ce4f385..3de9a4602b0 100644 --- a/packages/web-shell/client/components/ChatContextHeader.test.tsx +++ b/packages/web-shell/client/components/ChatContextHeader.test.tsx @@ -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( + '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'); }); }); diff --git a/packages/web-shell/client/components/ChatContextHeader.tsx b/packages/web-shell/client/components/ChatContextHeader.tsx index c41bb61e92a..105a2839b73 100644 --- a/packages/web-shell/client/components/ChatContextHeader.tsx +++ b/packages/web-shell/client/components/ChatContextHeader.tsx @@ -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 (
{!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. - - + diff --git a/packages/web-shell/client/e2e/web-shell.files.spec.ts b/packages/web-shell/client/e2e/web-shell.files.spec.ts index 6c0edf3a696..77fdffa7514 100644 --- a/packages/web-shell/client/e2e/web-shell.files.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.files.spec.ts @@ -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(