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:
@@ -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)}>
|
||||
|
||||
@@ -2157,6 +2157,7 @@ export const dict = {
|
||||
'chat.toolPart.showRawJson': 'Rohe JSON anzeigen',
|
||||
'chat.toolPart.showFormattedJson': 'Formatierte JSON anzeigen',
|
||||
'chat.toolPart.showNavigableJson': 'Navigierbare JSON anzeigen',
|
||||
'chat.toolPart.openFile': 'Datei öffnen',
|
||||
'chat.toolPart.openFileAtFirstChange': 'Datei bei erster Änderung öffnen',
|
||||
'chat.toolPart.openFileDiff': 'Datei-Unterschied öffnen',
|
||||
'chat.toolPart.copyOutput': 'Ausgabe kopieren',
|
||||
|
||||
@@ -2334,6 +2334,7 @@ export const dict = {
|
||||
'chat.toolPart.showRawJson': 'Show raw JSON',
|
||||
'chat.toolPart.showFormattedJson': 'Show formatted JSON',
|
||||
'chat.toolPart.showNavigableJson': 'Show navigable JSON',
|
||||
'chat.toolPart.openFile': 'Open file',
|
||||
'chat.toolPart.openFileAtFirstChange': 'Open file at first change',
|
||||
'chat.toolPart.openFileDiff': 'Open file diff',
|
||||
'chat.toolPart.copyOutput': 'Copy output',
|
||||
|
||||
@@ -2300,6 +2300,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.toolPart.showRawJson": "Mostrar JSON sin formato",
|
||||
"chat.toolPart.showFormattedJson": "Mostrar JSON formateado",
|
||||
"chat.toolPart.showNavigableJson": "Mostrar JSON navegable",
|
||||
"chat.toolPart.openFile": "Abrir archivo",
|
||||
"chat.toolPart.openFileAtFirstChange": "Abrir archivo en el primer cambio",
|
||||
"chat.toolPart.openFileDiff": "Abrir diferencias del archivo",
|
||||
"chat.toolPart.copyOutput": "Copiar salida",
|
||||
|
||||
@@ -3063,6 +3063,7 @@ export const dict = {
|
||||
'chat.toolPart.showRawJson': 'Afficher le JSON brut',
|
||||
'chat.toolPart.showFormattedJson': 'Afficher le JSON formaté',
|
||||
'chat.toolPart.showNavigableJson': 'Afficher le JSON navigable',
|
||||
'chat.toolPart.openFile': 'Ouvrir le fichier',
|
||||
'chat.toolPart.openFileAtFirstChange': 'Ouvrir le fichier à la première modification',
|
||||
'chat.toolPart.openFileDiff': 'Ouvrir les différences du fichier',
|
||||
'chat.toolPart.copyOutput': 'Copier la sortie',
|
||||
|
||||
@@ -2333,6 +2333,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolPart.showRawJson': '生JSONを表示',
|
||||
'chat.toolPart.showFormattedJson': '整形JSONを表示',
|
||||
'chat.toolPart.showNavigableJson': 'ナビゲーション可能なJSONを表示',
|
||||
'chat.toolPart.openFile': 'ファイルを開く',
|
||||
'chat.toolPart.openFileAtFirstChange': '最初の変更箇所でファイルを開く',
|
||||
'chat.toolPart.openFileDiff': 'ファイル差分を開く',
|
||||
'chat.toolPart.copyOutput': '出力をコピー',
|
||||
|
||||
@@ -2334,6 +2334,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolPart.showRawJson': '원시 JSON 표시',
|
||||
'chat.toolPart.showFormattedJson': '형식화된 JSON 표시',
|
||||
'chat.toolPart.showNavigableJson': '탐색 가능한 JSON 표시',
|
||||
'chat.toolPart.openFile': '파일 열기',
|
||||
'chat.toolPart.openFileAtFirstChange': '첫 번째 변경 위치에서 파일 열기',
|
||||
'chat.toolPart.openFileDiff': '파일 diff 열기',
|
||||
'chat.toolPart.copyOutput': '출력 복사',
|
||||
|
||||
@@ -1416,6 +1416,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolPart.showRawJson': 'Pokaż surowy JSON',
|
||||
'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON',
|
||||
'chat.toolPart.showNavigableJson': 'Pokaż nawigowalny JSON',
|
||||
'chat.toolPart.openFile': 'Otwórz plik',
|
||||
'chat.toolPart.openFileAtFirstChange': 'Otwórz plik przy pierwszej zmianie',
|
||||
'chat.toolPart.openFileDiff': 'Otwórz różnice pliku',
|
||||
'chat.toolPart.copyOutput': 'Kopiuj wyjście',
|
||||
|
||||
@@ -2300,6 +2300,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.toolPart.showRawJson": "Mostrar JSON bruto",
|
||||
"chat.toolPart.showFormattedJson": "Mostrar JSON formatado",
|
||||
"chat.toolPart.showNavigableJson": "Mostrar JSON navegável",
|
||||
"chat.toolPart.openFile": "Abrir arquivo",
|
||||
"chat.toolPart.openFileAtFirstChange": "Abrir arquivo na primeira alteração",
|
||||
"chat.toolPart.openFileDiff": "Abrir diferenças do arquivo",
|
||||
"chat.toolPart.copyOutput": "Copiar saída",
|
||||
|
||||
@@ -2300,6 +2300,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.toolPart.showRawJson": "Показати сирий JSON",
|
||||
"chat.toolPart.showFormattedJson": "Показати форматований JSON",
|
||||
"chat.toolPart.showNavigableJson": "Показати навігаційний JSON",
|
||||
"chat.toolPart.openFile": "Відкрити файл",
|
||||
"chat.toolPart.openFileAtFirstChange": "Відкрити файл на першій зміні",
|
||||
"chat.toolPart.openFileDiff": "Відкрити diff файлу",
|
||||
"chat.toolPart.copyOutput": "Скопіювати вивід",
|
||||
|
||||
@@ -2300,6 +2300,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolPart.showRawJson': '显示原始 JSON',
|
||||
'chat.toolPart.showFormattedJson': '显示格式化 JSON',
|
||||
'chat.toolPart.showNavigableJson': '显示可导航 JSON',
|
||||
'chat.toolPart.openFile': '打开文件',
|
||||
'chat.toolPart.openFileAtFirstChange': '在首次更改处打开文件',
|
||||
'chat.toolPart.openFileDiff': '打开文件差异',
|
||||
'chat.toolPart.copyOutput': '复制输出',
|
||||
|
||||
@@ -2304,6 +2304,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolPart.showRawJson': '顯示原始 JSON',
|
||||
'chat.toolPart.showFormattedJson': '顯示格式化 JSON',
|
||||
'chat.toolPart.showNavigableJson': '顯示可導覽 JSON',
|
||||
'chat.toolPart.openFile': '開啟檔案',
|
||||
'chat.toolPart.openFileAtFirstChange': '在首次變更處開啟檔案',
|
||||
'chat.toolPart.openFileDiff': '開啟檔案差異',
|
||||
'chat.toolPart.copyOutput': '複製輸出',
|
||||
|
||||
Reference in New Issue
Block a user