mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): open workspace files from the chat header (#14344)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
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 测试和浏览器检查。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 点击标题栏文件夹后,右侧打开对应工作区的文件树。
|
||||
- 再次点击保持唯一的“文件”标签打开并选中。
|
||||
- 其他标签处于激活状态时,点击文件夹切换至“文件”。
|
||||
- 键盘用户可聚焦并激活按钮,名称和路径提示仍可访问。
|
||||
- 不可用工作区及禁用“文件”的配置不提供打开文件操作。
|
||||
- 从抽屉引用文件后,焦点返回输入区,可继续输入。
|
||||
- 保留现有文件夹隐藏规则与自定义标题栏行为。
|
||||
|
||||
## 未决问题
|
||||
|
||||
无。
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user