mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): preview files from expanded tool details (#11982)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
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 请求。
|
||||
|
||||
## 未决问题
|
||||
|
||||
无。保持既有渲染器的格式与大小限制。文件系统变化在展开、窗口聚焦或点击时发现,不新增持续监听器。
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
+104
-5
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user