feat(chat): add quick-open file icon in tool card header

Add a small external-link icon next to the tool display name in the
collapsed tool card header (Write/Edit/MultiEdit/ApplyPatch), so the
target file can be opened in the side panel (web/desktop) or editor
(VS Code) without expanding the card.

On web/desktop (no runtime.editor), the icon falls back to
useUIStore.openContextFile{AtLine} + mobileActions.openFiles(), matching
the existing openEntryFile pattern. The existing handleMainClick only
opens the file when runtime.editor is available, so this icon is the
first way to open a file from the tool header in the browser.

Path resolution reuses getPrimaryToolPath + toAbsoluteFilePath; the diff
tools also resolve the first changed line and primary diff via the
existing getFirstChangedLineFromMetadata / getPrimaryDiffFromMetadata
helpers. The icon stops click propagation so the card-toggle-on-click
behavior is preserved.

Adds the chat.toolPart.openFile i18n key across all 11 locales.
This commit is contained in:
Roberto Berto
2026-08-25 14:32:33 +00:00
parent fe80d246ea
commit 14c40c9615
12 changed files with 84 additions and 1 deletions
@@ -1996,6 +1996,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return null;
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
const runtime = React.useContext(RuntimeAPIContext);
const mobileActions = useMobileAppActions();
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
if (!runtime?.editor) {
@@ -2068,6 +2069,61 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
handleMainClick(event);
};
// Quick-open target for the file-link icon in the tool header. Resolves the
// primary file path (and, for diff tools, the first changed line + diff) so
// the user can open the file in the side panel (web/desktop) or editor
// (VS Code) without expanding the tool card. Reuses the same path helpers as
// handleMainClick above; the difference is the web fallback — handleMainClick
// only opens when runtime.editor is available, this icon also falls back to
// useUIStore.openContextFile{AtLine} so the file opens in the right pane.
const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => {
if (isTaskTool) return null;
const toolName = normalizedPartTool || part.tool;
const filePath = getPrimaryToolPath(toolName, input, metadata);
if (typeof filePath !== 'string') return null;
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
let line: number | undefined;
let toolDiff: string | undefined;
if (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') {
line = getFirstChangedLineFromMetadata(toolName, metadata, filePath);
toolDiff = getPrimaryDiffFromMetadata(toolName, metadata, filePath);
}
return { absolutePath, line, toolDiff, toolName };
}, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]);
const openQuickTarget = () => {
if (!quickOpenTarget) return;
const { absolutePath, line, toolDiff, toolName } = quickOpenTarget;
if (runtime?.editor) {
if (runtime.runtime.isVSCode && toolDiff && (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch')) {
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
void runtime.editor.openDiff('', absolutePath, label, { line, patch: toolDiff });
return;
}
runtime.editor.openFile(absolutePath, line);
return;
}
const uiStore = useUIStore.getState();
if (typeof line === 'number' && Number.isFinite(line)) {
uiStore.openContextFileAtLine(currentDirectory, absolutePath, Math.max(1, Math.trunc(line)), 1);
} else {
uiStore.openContextFile(currentDirectory, absolutePath);
}
mobileActions?.openFiles();
};
const handleQuickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
openQuickTarget();
};
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
openQuickTarget();
};
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
@@ -2161,7 +2217,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div>
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="flex items-center gap-1 min-w-0 flex-1">
<MinDurationShineText
active={Boolean(isActive && !isError)}
minDurationMs={300}
@@ -2171,6 +2227,22 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
>
{displayName}
</MinDurationShineText>
{quickOpenTarget ? (
<button
type="button"
onClick={handleQuickOpen}
onKeyDown={handleQuickOpenKeyDown}
className={cn(
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
)}
style={{ color: 'var(--tools-icon)' }}
title={t('chat.toolPart.openFile')}
aria-label={t('chat.toolPart.openFile')}
>
<Icon name="external-link" className="h-3 w-3" />
</button>
) : null}
</div>
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? (
<span className={cn('flex-shrink-0 tabular-nums text-muted-foreground/80', TOOL_ROW_DESCRIPTION_CLASS)}>