feat(web-shell): preview files from expanded tool details (#11982)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-16 06:17:07 +00:00
1 parent 5f5f4ea013
commit 712874d0f5
15 files changed
+785 -16

No files matched your search

@@ -0,0 +1,73 @@
# Web Shell tool file preview
[English](web-shell-tool-file-preview.md) | [简体中文](web-shell-tool-file-preview.zh-CN.md)
## Problem and scope
Expanded file tool cards show read output or an edit diff, but cannot open the
complete current file beside the conversation. Add a right-aligned View file or
View image action for read, edit, write, display_image, and zoom_image tools.
Reuse the existing right panel and file/image renderers. No daemon route or tool
execution changes are required.
## Behavior and constraints
- Show the action only in interactive, expanded details with an open callback, a
raw file path, a connected daemon, and one available trusted workspace owner.
- Reuse the existing path extraction from arguments, structured content, or
locations. Never parse shortened display titles or guess a shell command's file.
- Check the scoped file stat on expansion, status change, and window focus. Only
regular files receive an action. Check again on click. Missing paths, deleted
files, directories, unavailable workspaces, and failed checks hide the action
silently, without a toast or placeholder. There is no polling or new file watcher.
- Use the existing workspace authority guard to discard stale results after an
owner change or unmount; never fall back to the primary workspace.
- Open the current disk file in the existing right panel. Keep the historical read
output and edit diff inline. The action tooltip says it views the current file.
Images use the existing image preview. Repeated opening reuses its existing tab
and refreshes its contents through the renderer’s existing version input.
- Preserve the source session/workspace through the existing turn-output callback
in the primary transcript, split panes, and resolved subagent details. Legacy
inline subagent rows without a known workspace show no action. Document/readonly
exports perform no availability checks and show no action.
- A file can disappear after validation. Opt this tool action into silent failure
for the existing attachment opener's additional stat, retaining normal errors
for other attachment entry points. Errors after a preview has already opened
retain the preview renderer's existing handling.
## Implementation
Forward the existing turn-output open callback through MessageItem, ToolGroup,
and ToolLine. Add a small ToolFilePreviewButton mounted in the expanded card
header. Reuse getToolFilePath, useArtifactWorkspaceTarget, the shared Button, and
attachment open requests. Include callback and locations changes in the existing
memo comparisons. Add matching English and Chinese labels. Extend only the
attachment request/opener with a silent-unavailable option used by this action.
Increment a reused file tab’s preview version and pass it to the existing renderer
version input so an active tab reloads changed text and image bytes.
Affected areas: message and tool rendering, the existing artifact path selector,
attachment opening in App, i18n, focused tests, and these design documents.
## Validation and acceptance
Use the global qwen CLI to confirm the missing entry on the baseline, then the
local built CLI for real browser verification of reading/editing text and reading
an image. Capture and visually inspect before/after screenshots. Add focused
coverage for hidden collapsed/readonly/missing-path states, stat failures and
directories, click-time deletion, stale asynchronous work, owner isolation,
callback propagation, existing panel tab reuse, and refreshing an active preview
after disk changes. Mock-daemon browser tests may
exercise deterministic failure conditions; identify those separately from real
file-tool verification. Run build, typecheck, bundle, and relevant package tests.
Acceptance: supported existing files open in the correct right panel; image files
show images; historical tool detail remains accessible; unavailable targets have
no visible entry or availability-error prompt; no background stat requests for
collapsed tools or exports.
## Open questions
None. Existing renderer format/size limits remain unchanged. Filesystem changes
are observed when expanding, focusing the window, or clicking, rather than by a
new continuous watcher.
@@ -0,0 +1,42 @@
# Web Shell 工具文件预览
[English](web-shell-tool-file-preview.md) | [简体中文](web-shell-tool-file-preview.zh-CN.md)
## 问题与范围
展开后的文件工具卡片会显示读取结果或编辑 diff,但无法在会话旁打开完整的当前文件。
为读取、编辑、写入、display_image 和 zoom_image 工具增加靠右的“查看文件”或“查看图片”操作。
复用现有右侧面板与文件、图片渲染器,无需修改 daemon 路由或工具执行。
## 行为与约束
- 仅在交互模式的展开详情中,存在打开回调、原始文件路径、已连接的 daemon,且能唯一确定可用且受信任的工作区归属时显示操作。
- 复用已有路径提取逻辑,从参数、结构化内容或 locations 获取路径。不得解析缩短后的显示标题,也不猜测 shell 命令中的文件。
- 在展开、工具状态变化、窗口重新聚焦时检查所属工作区的文件 stat。仅普通文件显示操作;点击时再次检查。缺少路径、文件已删除、目录、工作区不可用或检查失败均静默隐藏,不弹提示、不显示占位。不开启轮询或新文件监听器。
- 复用现有工作区权限归属保护,在归属变化或卸载后丢弃过期结果;不得回退到主工作区。
- 在现有右侧面板中打开磁盘上的当前文件。历史读取结果和编辑 diff 仍保留在会话内;按钮提示说明查看的是当前文件。图片复用现有图片预览,重复打开复用已有标签页,并通过渲染器现有的版本参数刷新内容。
- 主会话、分屏和已解析的子任务详情通过现有轮次输出回调保留来源会话、工作区。无法确定工作区的旧式内联子任务行不显示入口。文档及只读导出不检查可用性,也不显示入口。
- 文件可能在检查完成后消失。仅为此工具入口启用现有附件打开逻辑中再次 stat 的静默失败,保留其他附件入口的正常报错。预览已经打开后发生的错误继续沿用渲染器现有处理。
## 实现
通过 MessageItem、ToolGroup 和 ToolLine 传递已有轮次输出打开回调。在展开卡片标题栏挂载小型 ToolFilePreviewButton。
复用 getToolFilePath、useArtifactWorkspaceTarget、共享 Button 和附件打开请求。
已有 memo 比较需涵盖回调及 locations 变化。补充对应中英文文案。
仅在附件请求及打开逻辑上增加供此入口使用的静默不可用选项。
复用文件标签时递增预览版本,并传入渲染器现有的版本参数,使活动标签重新加载修改后的文本和图片字节。
涉及区域:消息与工具渲染、现有产物路径选择器、App 中的附件打开逻辑、i18n、定向测试和本设计文档。
## 验证与验收
先使用全局 qwen CLI 确认基线没有入口,再使用本地构建 CLI 在真实浏览器中验证文本读取、编辑及图片读取。
捕获并目视检查前后截图。定向测试覆盖折叠、只读、缺少路径时隐藏,stat 失败和目录,点击时删除,过期异步操作,工作区隔离,回调传递、现有面板标签复用,以及磁盘内容变化后活动预览的刷新。
可使用模拟 daemon 浏览器测试覆盖确定性的异常条件,但须与真实文件工具验证分别说明。
运行 build、typecheck、bundle 和相关包测试。
验收标准:受支持的现存文件在正确的右侧面板打开;图片显示为图片;历史工具详情仍可查看;不可用目标不显示入口、不弹可用性错误提示;折叠工具和导出不产生后台 stat 请求。
## 未决问题
无。保持既有渲染器的格式与大小限制。文件系统变化在展开、窗口聚焦或点击时发现,不新增持续监听器。
+79
View File
@@ -7151,6 +7151,85 @@ describe('artifact panel fullscreen', () => {
).toBeNull();
});
it('reloads the current file when reopening its active tool preview tab', async () => {
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'primary', cwd: '/tmp/project', primary: true, trusted: true },
],
} as typeof mockWorkspace.capabilities;
mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({
content: 'before external edit',
truncated: false,
});
const { container } = renderApp();
await flush();
const open = async () => {
await act(async () => {
testState.latestMessageListProps?.onTurnOutputOpen?.({
id: 'file:/tmp/project/notes.txt',
kind: 'attachment',
title: 'notes.txt',
turnId: 'read-1',
workspacePath: '/tmp/project/notes.txt',
workspaceCwd: '/tmp/project',
silentUnavailable: true,
});
});
await flush();
};
await open();
expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledTimes(1);
mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({
content: 'after external edit',
truncated: false,
});
await open();
expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledTimes(2);
expect(
container.querySelectorAll('button[title="notes.txt"]'),
).toHaveLength(1);
expect(container.textContent).toContain('after external edit');
expect(container.textContent).not.toContain('before external edit');
});
it.each([false, true])(
'handles a missing tool preview file with silentUnavailable=%s',
async (silentUnavailable) => {
const onToast = vi.fn();
mockWorkspace.capabilities = {
workspaceCwd: '/tmp/project',
workspaces: [
{ id: 'primary', cwd: '/tmp/project', primary: true, trusted: true },
],
} as typeof mockWorkspace.capabilities;
const { container } = renderApp({ onToast });
await flush();
mockWorkspaceActions.stat.mockRejectedValueOnce(
new Error('file deleted'),
);
await act(async () => {
testState.latestMessageListProps?.onTurnOutputOpen?.({
id: 'file:/tmp/project/notes.txt',
kind: 'attachment',
title: 'notes.txt',
turnId: 'read-1',
workspacePath: '/tmp/project/notes.txt',
workspaceCwd: '/tmp/project',
silentUnavailable,
});
});
expect(mockWorkspaceActions.stat).toHaveBeenCalledWith(
'/tmp/project/notes.txt',
);
expect(
container.querySelector('aside[aria-label="Right panel"]'),
).toBeNull();
if (silentUnavailable) expect(onToast).not.toHaveBeenCalled();
else expect(onToast).toHaveBeenCalledWith('error', 'file deleted');
},
);
it('loads a daemon attachment before opening its preview', async () => {
const { container } = renderApp();
await flush();
+10 -2
View File
@@ -5438,6 +5438,7 @@ export function App({
workspaceCwd = connection.workspaceCwd,
sourceSessionId = connection.sessionId,
sourcePreview = false,
silentUnavailable = false,
) => {
if (
onWorkspaceFileOpen &&
@@ -5483,7 +5484,11 @@ export function App({
};
setArtifactPanelTabs((tabs) =>
tabs.some((item) => item.id === tab.id)
? tabs.map((item) => (item.id === tab.id ? tab : item))
? tabs.map((item) =>
item.id === tab.id && item.kind === 'file'
? { ...tab, previewVersion: (item.previewVersion ?? 0) + 1 }
: item,
)
: [tab, ...tabs],
);
setActiveArtifactPanelTabId(tab.id);
@@ -5533,7 +5538,8 @@ export function App({
})
.catch((error: unknown) => {
if (!owner.isCurrent()) return;
pushToast('error', formatError(error, 'Failed to preview file'));
if (!silentUnavailable)
pushToast('error', formatError(error, 'Failed to preview file'));
});
return;
}
@@ -6647,6 +6653,8 @@ export function App({
},
request.workspaceCwd,
request.sourceSessionId,
false,
request.silentUnavailable,
);
return;
}
@@ -34,6 +34,7 @@ import {
} from './messages/AssistantMessage';
import { SystemMessage } from './messages/SystemMessage';
import { ToolGroup } from './messages/ToolGroup';
import type { TurnOutputOpenRequest } from './artifacts/TurnOutputs';
import { isSummaryRunId } from './summaryRunId';
import { PlanMessage } from './messages/PlanMessage';
import { BtwMessage } from './messages/BtwMessage';
@@ -51,6 +52,7 @@ interface MessageItemProps {
/** Click an uploaded image in a user message to preview it in the right panel. */
onImagePreview?: (src: string, alt?: string) => void;
onAttachmentPreview?: (file: AttachmentPreviewRequest) => void;
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
onInsightReportOpen?: (path: string) => void;
workspaceCwd?: string;
showRetryHint?: boolean;
@@ -87,6 +89,7 @@ export const MessageItem = memo(function MessageItem({
onLocateBackgroundSource,
onImagePreview,
onAttachmentPreview,
onTurnOutputOpen,
onInsightReportOpen,
workspaceCwd,
showRetryHint = false,
@@ -222,6 +225,7 @@ export const MessageItem = memo(function MessageItem({
return (
<ToolGroup
tools={message.tools}
onTurnOutputOpen={onTurnOutputOpen}
thoughts={message.thoughts}
compactSummary={compactMode && isSummaryRunId(message.id)}
pendingApproval={pendingApproval}
@@ -413,6 +417,7 @@ function areMessageItemPropsEqual(
return false;
if (prev.onImagePreview !== next.onImagePreview) return false;
if (prev.onAttachmentPreview !== next.onAttachmentPreview) return false;
if (prev.onTurnOutputOpen !== next.onTurnOutputOpen) return false;
if (prev.workspaceCwd !== next.workspaceCwd) return false;
if (prev.showRetryHint !== next.showRetryHint) return false;
if (prev.onRetryClick !== next.onRetryClick) return false;
@@ -5649,6 +5649,7 @@ export const MessageList = memo(
onShowContextDetail={onShowContextDetail}
onImagePreview={onImagePreview}
onAttachmentPreview={onAttachmentPreview}
onTurnOutputOpen={onTurnOutputOpen}
onInsightReportOpen={onInsightReportOpen}
onEditUserMessage={
onEditUserMessage && userMessageEditTarget
@@ -178,6 +178,7 @@ export type ArtifactPanelTab =
id: string;
kind: 'file';
title: string;
previewVersion?: number;
workspacePath: string;
workspaceCwd?: string;
workspaceId?: string;
@@ -1129,6 +1130,7 @@ export function ArtifactPanel({
<WorkspaceFilePreview
key={activeTab.id}
workspacePath={activeTab.workspacePath}
artifactVersion={String(activeTab.previewVersion ?? 0)}
workspaceActions={activeWorkspaceActions!}
previewContent={activeTab.previewContent}
previewData={activeTab.previewData}
@@ -15,7 +15,16 @@ const {
latestMessageListProps,
latestOpenSubagent,
messages,
previewTest,
previewCapabilities,
} = vi.hoisted(() => ({
previewTest: { enabled: false },
previewCapabilities: {
workspaceCwd: '/work/project',
workspaces: [
{ id: 'primary', cwd: '/work/project', trusted: true, primary: true },
],
},
animationFrameBlocks: [{ id: 'frame-block' }],
connection: {
sessionId: 'subagent-session',
@@ -26,6 +35,7 @@ const {
messagesFromBlocks: vi.fn(),
workspaceActions: {
readFile: vi.fn(),
stat: vi.fn().mockResolvedValue({ type: 'file' }),
},
workspaceClient: {
resolveSubagentSession: vi.fn(),
@@ -58,7 +68,11 @@ const {
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DaemonSessionProvider: ({ children }: { children: ReactNode }) => children,
useConnection: () => connection,
useWorkspace: () => ({ client: workspaceClient }),
useWorkspace: () => ({
client: workspaceClient,
status: 'connected',
capabilities: previewCapabilities,
}),
useWorkspaceActions: () => workspaceActions,
}));
@@ -86,6 +100,9 @@ vi.mock('../../WebShellContexts', async () => {
vi.mock('../MessageList', async () => {
const React = await import('react');
const { ToolFilePreviewButton } = await import(
'../messages/ToolFilePreviewButton'
);
const { CompactModeContext } = await import('../../WebShellContexts');
const { useSubagentDetails } = await import('../../subagentDetailsContext');
return {
@@ -93,10 +110,27 @@ vi.mock('../MessageList', async () => {
latestMessageListProps.current = props;
latestOpenSubagent.current = useSubagentDetails()?.onOpen;
const compactMode = React.useContext(CompactModeContext);
return React.createElement('div', {
'data-testid': 'subagent-transcript',
'data-compact-mode': String(compactMode),
});
return React.createElement(
'div',
{
'data-testid': 'subagent-transcript',
'data-compact-mode': String(compactMode),
},
previewTest.enabled
? React.createElement(ToolFilePreviewButton, {
tool: {
callId: 'read-1',
toolName: 'read_file',
status: 'completed',
args: { file_path: '/work/project/note.txt' },
},
workspaceCwd: props['workspaceCwd'] as string,
onOpen: props['onTurnOutputOpen'] as Parameters<
typeof ToolFilePreviewButton
>[0]['onOpen'],
})
: null,
);
},
};
});
@@ -117,6 +151,9 @@ afterEach(() => {
latestMessageListProps.current = undefined;
latestOpenSubagent.current = undefined;
messagesFromBlocks.mockClear();
previewTest.enabled = false;
connection.sessionId = 'subagent-session';
workspaceActions.stat.mockReset().mockResolvedValue({ type: 'file' });
workspaceClient.resolveSubagentSession.mockReset();
vi.useRealTimers();
});
@@ -352,3 +389,65 @@ it('resets the retry budget after a running poll recovers', async () => {
await act(async () => vi.advanceTimersByTimeAsync(6_000));
expect(workspaceClient.resolveSubagentSession).toHaveBeenCalledTimes(8);
});
it.each([false, true])(
'handles a pending file preview across a subagent rerender with session change=%s',
async (changeSession) => {
previewTest.enabled = true;
workspaceClient.resolveSubagentSession.mockResolvedValue({
sessionId: 'subagent-session',
status: 'running',
});
const onRightPanelOpen = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const renderDetail = async () => {
await act(async () => {
root!.render(
<I18nProvider language="en">
<SubagentDetail
sessionId="parent-session"
rootToolCallId="agent-1"
initialRootTool={
(messages[0] as Extract<Message, { role: 'tool_group' }>)
.tools[0]
}
workspaceCwd="/work/project"
onRightPanelOpen={onRightPanelOpen}
/>
</I18nProvider>,
);
});
};
await renderDetail();
const button = container.querySelector<HTMLButtonElement>(
'button[title="View the current file"]',
);
expect(button).not.toBeNull();
let resolveStat!: (value: { type: string }) => void;
workspaceActions.stat.mockReturnValueOnce(
new Promise((resolve) => {
resolveStat = resolve;
}),
);
await act(async () => button!.click());
if (changeSession) connection.sessionId = 'another-subagent-session';
await renderDetail();
expect(workspaceActions.stat).toHaveBeenCalledTimes(2);
await act(async () => resolveStat({ type: 'file' }));
if (changeSession) {
expect(onRightPanelOpen).not.toHaveBeenCalled();
} else {
expect(onRightPanelOpen).toHaveBeenCalledTimes(1);
expect(onRightPanelOpen).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'attachment',
workspacePath: '/work/project/note.txt',
workspaceCwd: '/work/project',
sourceSessionId: 'subagent-session',
}),
);
}
},
);
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
DaemonSessionProvider,
useConnection,
@@ -172,12 +172,15 @@ function SubagentDetailContent({
};
}, [artifacts, connection.sessionId, onArtifactsChange]);
const handleRightPanelOpen = (request: TurnOutputOpenRequest) => {
onRightPanelOpen?.({
...request,
sourceSessionId: connection.sessionId,
});
};
const handleRightPanelOpen = useCallback(
(request: TurnOutputOpenRequest) => {
onRightPanelOpen?.({
...request,
sourceSessionId: connection.sessionId,
});
},
[connection.sessionId, onRightPanelOpen],
);
useEffect(() => {
if (isRunning) return;
@@ -99,6 +99,7 @@ export type TurnOutputOpenRequest = (
| {
id: string;
kind: 'attachment';
silentUnavailable?: boolean;
title: string;
turnId: string;
mimeType?: string;
@@ -306,7 +306,7 @@ function getFileChange(
};
}
function getToolFilePath(tool: ACPToolCall): string | undefined {
export function getToolFilePath(tool: ACPToolCall): string | undefined {
const fromArgs = getStringContentField(
tool.args,
'file_path',
@@ -0,0 +1,288 @@
// @vitest-environment jsdom
import { act, createContext, useContext } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ACPToolCall } from '../../adapters/types';
import { I18nProvider } from '../../i18n';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import { MessageItem } from '../MessageItem';
import { ToolLine } from './ToolGroup';
const state = vi.hoisted(() => {
const primaryStat = vi.fn();
const secondaryStat = vi.fn();
return {
primaryStat,
secondaryStat,
primaryActions: { stat: primaryStat },
workspace: {
status: 'connected',
capabilities: {
workspaceCwd: '/primary',
workspaces: [
{ id: 'primary', cwd: '/primary', trusted: true, primary: true },
{ id: 'secondary', cwd: '/secondary', trusted: true, primary: false },
],
},
client: { workspaceByCwd: vi.fn(() => ({ fileStat: secondaryStat })) },
},
};
});
const WorkspaceContext = createContext(state.workspace);
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useWorkspace: () => useContext(WorkspaceContext),
useWorkspaceActions: () => state.primaryActions,
}));
const fileStat = { type: 'file', sizeBytes: 10, modifiedMs: 1 };
const tool: ACPToolCall = {
callId: 'read-1',
toolName: 'read_file',
status: 'completed',
title: 'ReadFile: wrong/shortened/title.txt',
args: { file_path: '/secondary/src/current.ts' },
};
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
state.primaryStat.mockReset().mockResolvedValue(fileStat);
state.secondaryStat.mockReset().mockResolvedValue(fileStat);
state.workspace.status = 'connected';
state.workspace.capabilities = {
workspaceCwd: '/primary',
workspaces: [
{ id: 'primary', cwd: '/primary', trusted: true, primary: true },
{ id: 'secondary', cwd: '/secondary', trusted: true, primary: false },
],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
async function render(
props: Partial<Parameters<typeof ToolLine>[0]> = {},
mode: 'interactive' | 'readonly' | 'document' = 'interactive',
) {
const onOpen = props.onTurnOutputOpen ?? vi.fn();
await act(async () => {
root.render(
<WorkspaceContext.Provider value={{ ...state.workspace }}>
<I18nProvider language="en">
<TranscriptRenderModeProvider value={mode}>
<ToolLine
tool={tool}
forceExpanded
workspaceCwd="/secondary"
onTurnOutputOpen={onOpen}
{...props}
/>
</TranscriptRenderModeProvider>
</I18nProvider>
</WorkspaceContext.Provider>,
);
});
return onOpen;
}
const viewButton = () =>
container.querySelector<HTMLButtonElement>(
'button[title="View the current file"]',
);
async function clickView() {
expect(viewButton()).not.toBeNull();
await act(async () => viewButton()!.click());
}
describe('tool file preview', () => {
it.each([
'read_file',
'ReadFile',
'read',
'edit',
'write',
'write_file',
'EditFile',
'WriteFile',
])(
'opens the raw path from %s in its secondary workspace',
async (toolName) => {
const onOpen = await render({ tool: { ...tool, toolName } });
expect(viewButton()?.textContent).toContain('View file');
await clickView();
expect(state.workspace.client.workspaceByCwd).toHaveBeenCalledWith(
'/secondary',
);
expect(state.primaryStat).not.toHaveBeenCalled();
expect(onOpen).toHaveBeenCalledWith({
id: 'file:/secondary:/secondary/src/current.ts',
kind: 'attachment',
title: 'current.ts',
turnId: 'read-1',
workspacePath: '/secondary/src/current.ts',
workspaceCwd: '/secondary',
silentUnavailable: true,
});
},
);
it.each(['read_file', 'display_image', 'zoom_image'])(
'offers an image preview for %s',
async (toolName) => {
const onOpen = await render({
tool: {
...tool,
toolName,
args: { file_path: '/secondary/photo.PNG' },
},
});
expect(viewButton()?.textContent).toContain('View image');
await clickView();
expect(onOpen).toHaveBeenCalledWith(
expect.objectContaining({
workspacePath: '/secondary/photo.PNG',
kind: 'attachment',
}),
);
},
);
it.each(['readonly', 'document'] as const)(
'does not check files in %s mode',
async (mode) => {
await render({}, mode);
expect(viewButton()).toBeNull();
expect(state.secondaryStat).not.toHaveBeenCalled();
},
);
it.each([
{ forceExpanded: false },
{ detailsVisible: false },
{ workspaceCwd: undefined },
{ onTurnOutputOpen: undefined },
{ tool: { ...tool, args: undefined } },
{ tool: { ...tool, toolName: 'run_shell_command' } },
])(
'has no entry or requests without an eligible visible target %j',
async (props) => {
await render(props);
expect(viewButton()).toBeNull();
expect(state.secondaryStat).not.toHaveBeenCalled();
},
);
it.each(['deleted', 'directory', 'unknown', 'untrusted', 'disconnected'])(
'silently hides a %s target',
async (condition) => {
if (condition === 'deleted')
state.secondaryStat.mockRejectedValue(new Error('missing'));
if (condition === 'directory')
state.secondaryStat.mockResolvedValue({
...fileStat,
type: 'directory',
});
if (condition === 'unknown') state.workspace.capabilities.workspaces = [];
if (condition === 'untrusted')
state.workspace.capabilities.workspaces[1].trusted = false;
if (condition === 'disconnected') state.workspace.status = 'error';
await render();
expect(viewButton()).toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(state.primaryStat).not.toHaveBeenCalled();
},
);
it('rechecks on click and silently hides a deleted file', async () => {
const onOpen = await render();
state.secondaryStat.mockRejectedValue(new Error('deleted'));
await clickView();
expect(onOpen).not.toHaveBeenCalled();
expect(viewButton()).toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it('rechecks on window focus', async () => {
await render();
state.secondaryStat.mockRejectedValue(new Error('deleted'));
await act(async () => {
window.dispatchEvent(new Event('focus'));
});
expect(viewButton()).toBeNull();
});
it('discards an in-flight click after its workspace becomes untrusted', async () => {
const onOpen = await render();
let resolve!: (value: typeof fileStat) => void;
state.secondaryStat.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
await act(async () => viewButton()!.click());
state.workspace.capabilities = {
...state.workspace.capabilities,
workspaces: state.workspace.capabilities.workspaces.map((entry) => ({
...entry,
trusted: false,
})),
};
await render({ onTurnOutputOpen: onOpen });
await act(async () => resolve(fileStat));
expect(onOpen).not.toHaveBeenCalled();
expect(viewButton()).toBeNull();
});
it('updates location fallback and callback through ToolLine memoization', async () => {
const onOpen = vi.fn();
await render({
tool: {
...tool,
args: undefined,
locations: [{ file: '/secondary/first.ts' }],
},
onTurnOutputOpen: onOpen,
});
const nextOpen = vi.fn();
await render({
tool: {
...tool,
args: undefined,
locations: [{ file: '/secondary/next.ts' }],
},
onTurnOutputOpen: nextOpen,
});
await clickView();
expect(onOpen).not.toHaveBeenCalled();
expect(nextOpen).toHaveBeenCalledWith(
expect.objectContaining({ workspacePath: '/secondary/next.ts' }),
);
});
it('forwards callback updates through MessageItem and ToolGroup', async () => {
const message = { id: 'group', role: 'tool_group' as const, tools: [tool] };
const first = vi.fn();
const second = vi.fn();
const renderItem = async (onOpen: typeof first) => {
await act(async () =>
root.render(
<WorkspaceContext.Provider value={{ ...state.workspace }}>
<I18nProvider language="en">
<MessageItem
message={message}
workspaceCwd="/secondary"
onTurnOutputOpen={onOpen}
/>
</I18nProvider>
</WorkspaceContext.Provider>,
),
);
};
await renderItem(first);
await act(async () =>
container
.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')!
.click(),
);
await renderItem(second);
await clickView();
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from 'react';
import { SquareArrowOutUpRightIcon } from 'lucide-react';
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
import type { ACPToolCall } from '../../adapters/types';
import { useI18n } from '../../i18n';
import { useTranscriptRenderMode } from '../../transcriptRenderMode';
import type { TurnOutputOpenRequest } from '../artifacts/TurnOutputs';
import { getImageMimeTypeFromPath } from '../artifacts/artifactUtils';
import { getToolFilePath } from '../artifacts/turnOutputSelectors';
import { useArtifactWorkspaceTarget } from '../artifacts/useArtifactWorkspaceTarget';
import { Button } from '../ui/button';
export function ToolFilePreviewButton({
tool,
workspaceCwd,
onOpen,
}: {
tool: ACPToolCall;
workspaceCwd?: string;
onOpen?: (request: TurnOutputOpenRequest) => void;
}) {
const renderMode = useTranscriptRenderMode();
const filePath = getToolFilePath(tool);
if (
renderMode !== 'interactive' ||
!onOpen ||
!workspaceCwd ||
!filePath?.trim()
)
return null;
return (
<AvailableFileButton
key={JSON.stringify([workspaceCwd, filePath, tool.callId])}
filePath={filePath}
workspaceCwd={workspaceCwd}
tool={tool}
onOpen={onOpen}
/>
);
}
function AvailableFileButton({
filePath,
workspaceCwd,
tool,
onOpen,
}: {
filePath: string;
workspaceCwd: string;
tool: ACPToolCall;
onOpen: (request: TurnOutputOpenRequest) => void;
}) {
const { t } = useI18n();
const { status } = useWorkspace();
const target = useArtifactWorkspaceTarget(workspaceCwd);
const actions = target?.actions;
const [availableFor, setAvailableFor] = useState<typeof actions>();
const requests = useRef({ sequence: 0 }).current;
const current = useRef({ actions, status, onOpen });
current.current = { actions, status, onOpen };
useEffect(() => {
setAvailableFor(undefined);
if (!actions || status !== 'connected') return;
const check = async () => {
const request = ++requests.sequence;
try {
const stat = await actions.stat(filePath);
if (request === requests.sequence) {
setAvailableFor(stat.type === 'file' ? actions : undefined);
}
} catch {
if (request === requests.sequence) setAvailableFor(undefined);
}
};
void check();
window.addEventListener('focus', check);
return () => {
requests.sequence++;
window.removeEventListener('focus', check);
};
}, [actions, filePath, requests, status, tool.status]);
if (!actions || status !== 'connected' || availableFor !== actions)
return null;
const open = async () => {
const request = ++requests.sequence;
try {
const stat = await actions.stat(filePath);
if (
request !== requests.sequence ||
current.current.actions !== actions ||
current.current.status !== 'connected' ||
current.current.onOpen !== onOpen
)
return;
if (stat.type !== 'file') {
setAvailableFor(undefined);
return;
}
onOpen({
id: `file:${workspaceCwd}:${filePath}`,
kind: 'attachment',
title: filePath.split(/[/\\]/).pop() || filePath,
turnId: tool.callId,
workspacePath: filePath,
workspaceCwd,
silentUnavailable: true,
});
} catch {
if (request === requests.sequence) setAvailableFor(undefined);
}
};
return (
<Button
type="button"
variant="ghost"
size="xs"
className="ml-auto text-muted-foreground"
title={t('tool.viewCurrentFile')}
onClick={() => void open()}
>
{t(
getImageMimeTypeFromPath(filePath) ? 'tool.viewImage' : 'tool.viewFile',
)}
<SquareArrowOutUpRightIcon aria-hidden="true" />
</Button>
);
}
@@ -84,6 +84,8 @@ import {
import flashStyles from '../MessageLocateFlash.module.css';
import styles from './tools/ToolChrome.module.css';
import { getMcpAppDisplay, McpApp } from './McpApp';
import type { TurnOutputOpenRequest } from '../artifacts/TurnOutputs';
import { ToolFilePreviewButton } from './ToolFilePreviewButton';
interface ToolGroupProps {
tools: ACPToolCall[];
@@ -100,6 +102,7 @@ interface ToolGroupProps {
}>;
pendingApproval?: PermissionRequest | null;
workspaceCwd?: string;
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
isLocateFlashing?: boolean;
/** Powers the translate action on completed thinking rows (zh-CN). */
generateContent?: SessionContentGenerator;
@@ -375,11 +378,13 @@ function ExpandedEditContent({ tool }: { tool: ACPToolCall }) {
function ToolExpandedCard({
title,
action,
detail,
status,
children,
}: {
title: string;
action?: ReactNode;
detail?: string;
status?: ACPToolCall['status'];
children?: ReactNode;
@@ -390,6 +395,7 @@ function ToolExpandedCard({
<span className={styles.expandedCardTitleRow}>
{status && <StatusIcon status={status} />}
<span className={styles.expandedCardTitle}>{title}</span>
{action}
</span>
{detail && <span className={styles.expandedCardDetail}>{detail}</span>}
</div>
@@ -433,6 +439,7 @@ interface ToolLineProps {
tool: ACPToolCall;
approval?: PermissionRequest | null;
workspaceCwd?: string;
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
summaryOnly?: boolean;
forceExpanded?: boolean;
detailsVisible?: boolean;
@@ -1048,6 +1055,7 @@ function areToolLinePropsEqual(
): boolean {
if (prev.approval?.id !== next.approval?.id) return false;
if (prev.workspaceCwd !== next.workspaceCwd) return false;
if (prev.onTurnOutputOpen !== next.onTurnOutputOpen) return false;
if (prev.summaryOnly !== next.summaryOnly) return false;
if (prev.forceExpanded !== next.forceExpanded) return false;
if (prev.detailsVisible !== next.detailsVisible) return false;
@@ -1066,6 +1074,7 @@ function areToolLinePropsEqual(
a.rawOutput === b.rawOutput &&
a.args === b.args &&
a.content === b.content &&
a.locations === b.locations &&
a.title === b.title &&
areSubToolsEqual(a.subTools, b.subTools)
);
@@ -1150,6 +1159,7 @@ export const ToolLine = memo(function ToolLine({
tool,
approval,
workspaceCwd,
onTurnOutputOpen,
summaryOnly = false,
forceExpanded = false,
detailsVisible = true,
@@ -1419,6 +1429,18 @@ export const ToolLine = memo(function ToolLine({
name === 'search' ||
name === 'glob';
const isRead = name === 'read' || name === 'read_file' || name === 'readfile';
const filePreviewAction =
detailsVisible &&
(isRead ||
isEditToolName(name) ||
name === 'display_image' ||
name === 'zoom_image') ? (
<ToolFilePreviewButton
tool={tool}
workspaceCwd={workspaceCwd}
onOpen={onTurnOutputOpen}
/>
) : undefined;
// Every regular tool row expands on demand. Content controls only what the
// expanded card shows, never whether the user can open or close it —
// except while an opted-in host owns this pending Edit's diff preview.
@@ -1573,6 +1595,7 @@ export const ToolLine = memo(function ToolLine({
title={displayName}
detail={expandedCardDetail}
status={tool.status}
action={filePreviewAction}
>
{result && (
<div
@@ -1622,7 +1645,11 @@ export const ToolLine = memo(function ToolLine({
result={result}
/>
) : isRead ? (
<ToolExpandedCard title={displayName} status={tool.status}>
<ToolExpandedCard
title={displayName}
status={tool.status}
action={filePreviewAction}
>
<ExpandedReadContent tool={tool} />
</ToolExpandedCard>
) : (
@@ -1630,6 +1657,7 @@ export const ToolLine = memo(function ToolLine({
title={displayName}
detail={expandedCardDetail}
status={tool.status}
action={filePreviewAction}
>
{isShellToolName(name) && <ExpandedBashOutput tool={tool} />}
{(name === 'write_file' || name === 'writefile') && (
@@ -1774,6 +1802,7 @@ export const ToolGroup = memo(function ToolGroup({
thoughts,
pendingApproval,
workspaceCwd,
onTurnOutputOpen,
isLocateFlashing = false,
generateContent,
}: ToolGroupProps) {
@@ -2041,6 +2070,7 @@ export const ToolGroup = memo(function ToolGroup({
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
onTurnOutputOpen={onTurnOutputOpen}
summaryOnly={!singleTool || compactToolLines}
forceExpanded={
documentMode || (!!singleTool && !compactToolLines)
@@ -2082,6 +2112,7 @@ export const ToolGroup = memo(function ToolGroup({
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
onTurnOutputOpen={onTurnOutputOpen}
forceExpanded={documentMode}
/>
))}
+6
View File
@@ -3271,6 +3271,9 @@ const EN: Messages = {
'tools.updating': 'Updating...',
'tool.collapse': '▲ Collapse',
'tool.expand': 'Expand',
'tool.viewFile': 'View file',
'tool.viewImage': 'View image',
'tool.viewCurrentFile': 'View the current file',
'tool.collapseHint': 'Collapse',
'tool.status.failed': 'Failed',
'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`,
@@ -6919,6 +6922,9 @@ const ZH: Messages = {
'tools.updating': '更新中...',
'tool.collapse': '▲ 收起',
'tool.expand': '展开',
'tool.viewFile': '查看文件',
'tool.viewImage': '查看图片',
'tool.viewCurrentFile': '查看当前文件',
'tool.collapseHint': '收起',
'tool.status.failed': '执行失败',
'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`,