From f242519623ab5945eeae2dabbc2c0343e24db215 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 11:09:22 +0800 Subject: [PATCH 1/3] Add Copy file path to file preview tab menus (#1473) * feat(app): add Copy file path to file preview tab menus * fix(app): restore copyAgentId translations accidentally replaced by placeholder --- .../app/src/components/split-container.tsx | 8 +++ packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + .../workspace/workspace-desktop-tabs-row.tsx | 9 ++++ .../screens/workspace/workspace-screen.tsx | 25 +++++++++ .../workspace/workspace-tab-menu.test.ts | 51 +++++++++++++++++++ .../screens/workspace/workspace-tab-menu.ts | 20 ++++++++ 11 files changed, 125 insertions(+) diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 2b9cac093..fe300b13d 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -86,6 +86,7 @@ interface SplitContainerProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; @@ -364,6 +365,7 @@ export function SplitContainer({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, @@ -580,6 +582,7 @@ export function SplitContainer({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} @@ -722,6 +725,7 @@ function SplitNodeView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, @@ -774,6 +778,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} @@ -819,6 +824,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} @@ -870,6 +876,7 @@ function SplitPaneView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, @@ -1011,6 +1018,7 @@ function SplitPaneView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 0480b043f..eb693b3ac 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -433,6 +433,7 @@ export const ar: TranslationResources = { openFor: "فتح القائمة لـ{{label}}", copyResumeCommand: "نسخ أمر السيرة الذاتية", copyAgentId: "نسخ معرف الوكيل", + copyFilePath: "Copy file path", rename: "إعادة تسمية", closeAbove: "إغلاق علامات التبويب أعلاه", closeBelow: "إغلاق علامات التبويب أدناه", @@ -465,6 +466,7 @@ export const ar: TranslationResources = { copyFailed: "فشل النسخ", agentIdCopiedLabel: "AgentID", resumeCommandCopiedLabel: "أمر الاستئناف", + filePathCopiedLabel: "File path", resumeIdUnavailable: "السيرة الذاتية ID غير متوفرة", resumeCommandUnavailable: "أمر الاستئناف غير متوفر", reloadingAgent: "وكيل إعادة التحميل...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bcbf724a0..14c7e0d63 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -432,6 +432,7 @@ export const en = { openFor: "Open menu for {{label}}", copyResumeCommand: "Copy resume command", copyAgentId: "Copy agent id", + copyFilePath: "Copy file path", rename: "Rename", closeAbove: "Close tabs above", closeBelow: "Close tabs below", @@ -464,6 +465,7 @@ export const en = { copyFailed: "Copy failed", agentIdCopiedLabel: "Agent ID", resumeCommandCopiedLabel: "resume command", + filePathCopiedLabel: "File path", resumeIdUnavailable: "Resume ID not available", resumeCommandUnavailable: "Resume command not available", reloadingAgent: "Reloading agent...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6f7b09aa2..68a536f17 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -436,6 +436,7 @@ export const es: TranslationResources = { openFor: "Menú abierto para{{label}}", copyResumeCommand: "Copiar comando de reanudación", copyAgentId: "Copiar ID del agente", + copyFilePath: "Copy file path", rename: "Rebautizar", closeAbove: "Cerrar pestañas arriba", closeBelow: "Cerrar pestañas a continuación", @@ -469,6 +470,7 @@ export const es: TranslationResources = { copyFailed: "Copia fallida", agentIdCopiedLabel: "AgentID", resumeCommandCopiedLabel: "reanudar el comando", + filePathCopiedLabel: "File path", resumeIdUnavailable: "ReanudarIDno disponible", resumeCommandUnavailable: "Comando de reanudación no disponible", reloadingAgent: "Agente de recarga...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index de23eaae3..e16ca8637 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -436,6 +436,7 @@ export const fr: TranslationResources = { openFor: "Ouvrir le menu pour{{label}}", copyResumeCommand: "Copier la commande de reprise", copyAgentId: "Copier l'identifiant de l'agent", + copyFilePath: "Copy file path", rename: "Rebaptiser", closeAbove: "Fermer les onglets ci-dessus", closeBelow: "Fermer les onglets ci-dessous", @@ -469,6 +470,7 @@ export const fr: TranslationResources = { copyFailed: "Échec de la copie", agentIdCopiedLabel: "AgentID", resumeCommandCopiedLabel: "reprendre la commande", + filePathCopiedLabel: "File path", resumeIdUnavailable: "ReprendreIDnon disponible", resumeCommandUnavailable: "Commande de reprise non disponible", reloadingAgent: "Agent de rechargement...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 61487037e..88c460942 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -436,6 +436,7 @@ export const ru: TranslationResources = { openFor: "Открыть меню для{{label}}", copyResumeCommand: "Копировать команду возобновления", copyAgentId: "Скопировать идентификатор агента", + copyFilePath: "Copy file path", rename: "Переименовать", closeAbove: "Закрыть вкладки выше", closeBelow: "Закройте вкладки ниже", @@ -468,6 +469,7 @@ export const ru: TranslationResources = { copyFailed: "Не удалось скопировать", agentIdCopiedLabel: "AgentID", resumeCommandCopiedLabel: "команда возобновления", + filePathCopiedLabel: "File path", resumeIdUnavailable: "Резюме ID недоступно", resumeCommandUnavailable: "Команда возобновления недоступна", reloadingAgent: "Перезагрузка агента...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f1c131e5c..c2687b79a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -433,6 +433,7 @@ export const zhCN: TranslationResources = { openFor: "打开 {{label}} 的菜单", copyResumeCommand: "复制恢复命令", copyAgentId: "复制 Agent ID", + copyFilePath: "Copy file path", rename: "重命名", closeAbove: "关闭上方标签", closeBelow: "关闭下方标签", @@ -465,6 +466,7 @@ export const zhCN: TranslationResources = { copyFailed: "复制失败", agentIdCopiedLabel: "Agent ID", resumeCommandCopiedLabel: "恢复命令", + filePathCopiedLabel: "File path", resumeIdUnavailable: "恢复 ID 不可用", resumeCommandUnavailable: "恢复命令不可用", reloadingAgent: "正在重新加载 Agent...", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 77a692592..043e4033d 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -205,6 +205,7 @@ interface WorkspaceDesktopTabsRowProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; @@ -519,6 +520,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, @@ -590,6 +592,7 @@ export function WorkspaceDesktopTabsRow({ () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), closeBelow: t("workspace.tabs.menu.closeBelow"), @@ -684,6 +687,7 @@ export function WorkspaceDesktopTabsRow({ normalizedWorkspaceId={normalizedWorkspaceId} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} @@ -714,6 +718,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, + onCopyFilePath, onCopyResumeCommand, onNavigateTab, onReloadAgent, @@ -894,6 +899,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, @@ -919,6 +925,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; @@ -944,6 +951,7 @@ function ResolvedDesktopTabChip({ tabCount, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -960,6 +968,7 @@ function ResolvedDesktopTabChip({ onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, + onCopyFilePath, onCopyResumeCommand, labels, onReloadAgent, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index fc6344b29..34a2258af 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -365,6 +365,7 @@ interface MobileWorkspaceTabSwitcherProps { onSelectSwitcherTab: (key: string) => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; @@ -552,6 +553,7 @@ function MobileWorkspaceTabOption({ onPress, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -569,6 +571,7 @@ function MobileWorkspaceTabOption({ onPress: () => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; @@ -581,6 +584,7 @@ function MobileWorkspaceTabOption({ () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), closeBelow: t("workspace.tabs.menu.closeBelow"), @@ -602,6 +606,7 @@ function MobileWorkspaceTabOption({ menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -668,6 +673,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onSelectSwitcherTab, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -723,6 +729,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onPress={onPress} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} onCloseTab={onCloseTab} @@ -740,6 +747,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -2600,6 +2608,19 @@ function WorkspaceScreenContent({ [toast, t], ); + const handleCopyFilePath = useCallback( + async (path: string) => { + if (!path) return; + try { + await Clipboard.setStringAsync(path); + toast.copied(t("workspace.tabs.toasts.filePathCopiedLabel")); + } catch { + toast.error(t("workspace.tabs.toasts.copyFailed")); + } + }, + [toast, t], + ); + const handleCopyResumeCommand = useCallback( async (agentId: string) => { if (!agentId) return; @@ -3422,6 +3443,7 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeftInPane} @@ -3456,6 +3478,7 @@ function WorkspaceScreenContent({ handleCloseTabById, handleCopyResumeCommand, handleCopyAgentId, + handleCopyFilePath, handleReloadAgent, handleRenameTab, handleCloseTabsToLeftInPane, @@ -3535,6 +3558,7 @@ function WorkspaceScreenContent({ onSelectSwitcherTab={handleSelectSwitcherTab} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} onCloseTab={handleCloseTabById} @@ -3556,6 +3580,7 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 6c1c2e108..68325ee74 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -15,6 +15,7 @@ describe("buildWorkspaceTabMenuEntries", () => { it("uses desktop tab ordering labels for desktop menus", () => { const onCopyResumeCommand = vi.fn(); const onCopyAgentId = vi.fn(); + const onCopyFilePath = vi.fn(); const onReloadAgent = vi.fn(); const onRenameTab = vi.fn(); const onCloseTab = vi.fn(); @@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -59,6 +61,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), onCloseTab: vi.fn(), @@ -93,6 +96,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-draft_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), onCloseTab: vi.fn(), @@ -120,6 +124,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), onCloseTab: vi.fn(), @@ -148,6 +153,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab, onCloseTab: vi.fn(), @@ -181,6 +187,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-terminal_abc", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab, onCloseTab: vi.fn(), @@ -193,6 +200,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(labels[0]).toBe("Rename"); expect(labels).not.toContain("Copy resume command"); expect(labels).not.toContain("Copy agent id"); + expect(labels).not.toContain("Copy file path"); expect(labels).not.toContain("Reload agent"); const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); @@ -203,6 +211,48 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(onRenameTab).toHaveBeenCalledWith(terminalTab); }); + it("includes copy file path for file tabs", () => { + const onCopyFilePath = vi.fn(); + const fileTab: WorkspaceTabDescriptor = { + key: "file_abc", + tabId: "file_abc", + kind: "file", + target: { kind: "file", path: "/some/path.ts", lineStart: 1, lineEnd: 10 }, + }; + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab: fileTab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-file_abc", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onCopyFilePath, + onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label); + expect(labels[0]).toBe("Copy file path"); + expect(labels).not.toContain("Copy resume command"); + expect(labels).not.toContain("Copy agent id"); + expect(labels).not.toContain("Rename"); + expect(labels).not.toContain("Reload agent"); + + const copyFilePathEntry = entries.find( + (entry) => entry.kind === "item" && entry.key === "copy-file-path", + ); + if (!copyFilePathEntry || copyFilePathEntry.kind !== "item") { + throw new Error("Copy file path entry missing"); + } + copyFilePathEntry.onSelect(); + expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts"); + }); + it("uses the same rename entry shape for agent and terminal tabs", () => { const terminalTab: WorkspaceTabDescriptor = { key: "terminal_abc", @@ -218,6 +268,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase, onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), onCloseTab: vi.fn(), diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 5f7587452..9375582fb 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,6 +8,7 @@ export type WorkspaceTabMenuSurface = "desktop" | "mobile"; export interface WorkspaceTabMenuLabels { copyResumeCommand: string; copyAgentId: string; + copyFilePath: string; rename: string; closeAbove: string; closeBelow: string; @@ -22,6 +23,7 @@ export interface WorkspaceTabMenuLabels { export const DEFAULT_WORKSPACE_TAB_MENU_LABELS: WorkspaceTabMenuLabels = { copyResumeCommand: i18n.t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: i18n.t("workspace.tabs.menu.copyAgentId"), + copyFilePath: i18n.t("workspace.tabs.menu.copyFilePath"), rename: i18n.t("workspace.tabs.menu.rename"), closeAbove: i18n.t("workspace.tabs.menu.closeAbove"), closeBelow: i18n.t("workspace.tabs.menu.closeBelow"), @@ -66,6 +68,7 @@ interface BuildWorkspaceTabMenuEntriesInput { menuTestIDBase: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; @@ -81,6 +84,7 @@ interface BuildWorkspaceDesktopTabActionsInput { tabCount: number; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; @@ -148,6 +152,7 @@ export function buildWorkspaceTabMenuEntries( menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, @@ -186,6 +191,20 @@ export function buildWorkspaceTabMenuEntries( }); } + if (tab.target.kind === "file") { + const filePath = tab.target.path; + entries.push({ + kind: "item", + key: "copy-file-path", + label: labels.copyFilePath, + icon: "copy", + testID: `${menuTestIDBase}-copy-file-path`, + onSelect: () => { + void onCopyFilePath(filePath); + }, + }); + } + if (tab.target.kind === "agent" || tab.target.kind === "terminal") { entries.push({ kind: "item", @@ -278,6 +297,7 @@ export function buildWorkspaceDesktopTabActions( menuTestIDBase: contextMenuTestId, onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, + onCopyFilePath: input.onCopyFilePath, onReloadAgent: input.onReloadAgent, onRenameTab: input.onRenameTab, onCloseTab: input.onCloseTab, From f05544ef8f4211dba6e56e53cbd1bd268b8ad10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=B4=E5=A5=B9=E5=91=BD=40?= <908280968@qq.com> Date: Fri, 12 Jun 2026 13:06:20 +0800 Subject: [PATCH 2/3] Authenticate injected agent MCP when daemon password is set Fixes #914 Fixes #690 --- .../src/server/agent/agent-manager.test.ts | 41 ++++++++++ .../server/src/server/agent/agent-manager.ts | 14 ++++ .../src/server/agent/agent-mcp.e2e.test.ts | 78 ++++++++++++++++++- .../server/agent/runtime-mcp-config.test.ts | 51 ++++++++++++ .../src/server/agent/runtime-mcp-config.ts | 9 +++ packages/server/src/server/auth.test.ts | 55 +++++++++++++ packages/server/src/server/auth.ts | 40 ++++++++++ packages/server/src/server/bootstrap.ts | 30 ++++++- packages/server/src/server/session.ts | 8 +- 9 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/server/agent/runtime-mcp-config.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index dfbee5a47..3b765b3ab 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -992,6 +992,47 @@ test("createAgent injects paseo MCP server only into provider launch config", as }); }); +test("createAgent injects the MCP auth token as a bearer header into the launch config", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: "cap-token", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(manager.getMcpAuthToken()).toBe("cap-token"); + expect(client.lastConfig?.mcpServers?.paseo).toEqual({ + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, + headers: { Authorization: "Bearer cap-token" }, + }); + + rmSync(workdir, { recursive: true, force: true }); +}); + test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5855af4e0..13156ad13 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -196,6 +196,7 @@ export interface AgentManagerOptions { durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; mcpBaseUrl?: string; + mcpAuthToken?: string; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -482,6 +483,7 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; + private readonly mcpAuthToken: string | null; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -496,6 +498,7 @@ export class AgentManager { this.onAgentAttention = options?.onAgentAttention; this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged; this.mcpBaseUrl = options?.mcpBaseUrl ?? null; + this.mcpAuthToken = options?.mcpAuthToken ?? null; this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -554,6 +557,16 @@ export class AgentManager { this.mcpBaseUrl = url; } + /** + * Capability token the daemon's own MCP clients must present to the Agent MCP + * endpoint when a daemon password is configured. Read by the per-client + * session to authenticate its own MCP connection. Stays in the daemon — never + * sent to remote clients. + */ + getMcpAuthToken(): string | null { + return this.mcpAuthToken; + } + setAppendSystemPrompt(prompt: string | null | undefined): void { this.appendSystemPrompt = prompt ?? ""; } @@ -3567,6 +3580,7 @@ export class AgentManager { config: storedConfig, agentId, mcpBaseUrl: this.mcpBaseUrl, + mcpAuthToken: this.mcpAuthToken, }), ); return { storedConfig, launchConfig }; diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index a1b639a8c..933d625f9 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -9,6 +9,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import pino from "pino"; import { withTimeout } from "../../utils/promise-timeout.js"; +import { hashDaemonPassword } from "../auth.js"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import type { @@ -78,8 +79,11 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null { return null; } -async function createMcpClient(url: string): Promise { - const transport = new StreamableHTTPClientTransport(new URL(url)); +async function createMcpClient(url: string, authToken?: string): Promise { + const transport = new StreamableHTTPClientTransport( + new URL(url), + authToken ? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } } : undefined, + ); const rawClient = await experimental_createMCPClient({ transport }); const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient); return { callTool: boundCallTool, close: () => rawClient.close() }; @@ -177,6 +181,76 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); + test("password-protected daemon authorizes the agent MCP via the capability token", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-")); + const port = await getAvailablePort(); + + const daemonConfig: PaseoDaemonConfig = { + listen: `127.0.0.1:${port}`, + paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + auth: { password: hashDaemonPassword("daemon-secret") }, + }; + + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); + await daemon.start(); + + const mcpUrl = `http://127.0.0.1:${port}/mcp/agents`; + const capabilityToken = daemon.agentManager.getMcpAuthToken(); + expect(typeof capabilityToken).toBe("string"); + + let agentId: string | null = null; + let client: McpClient | null = null; + try { + // Remote auth is not weakened: a request without credentials is rejected + // before any MCP processing. + const unauthorized = await fetch(mcpUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(unauthorized.status).toBe(401); + + // The injected capability token authenticates the full MCP handshake: + // creating (and connecting) the client and driving a tool call both go + // through the password-gated /mcp/agents route. (The exact bearer header + // injected into a child agent's config is covered by the + // runtime-mcp-config unit test.) + client = await createMcpClient(mcpUrl, capabilityToken!); + const result = await client.callTool({ + name: "create_agent", + args: { + cwd: agentCwd, + title: "Password MCP", + provider: "claude/claude-test-model", + mode: "bypassPermissions", + initialPrompt: "reply with done and stop", + background: true, + }, + }); + const payload = getStructuredContent(result); + agentId = typeof payload?.agentId === "string" ? payload.agentId : null; + expect(agentId).toBeTruthy(); + } finally { + if (agentId) { + await client?.callTool({ name: "kill_agent", args: { agentId } }); + } + await client?.close(); + await daemon.stop(); + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + await rm(agentCwd, { recursive: true, force: true }); + } + }, 30_000); + test("create_agent auto-injects paseo MCP by default and can be disabled", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); diff --git a/packages/server/src/server/agent/runtime-mcp-config.test.ts b/packages/server/src/server/agent/runtime-mcp-config.test.ts new file mode 100644 index 000000000..4704d131b --- /dev/null +++ b/packages/server/src/server/agent/runtime-mcp-config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import type { AgentSessionConfig } from "./agent-sdk-types.js"; +import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; + +const BASE_CONFIG: AgentSessionConfig = { + provider: "claude", + cwd: "/tmp/agent", +}; + +describe("withRuntimePaseoMcpServer", () => { + test("injects the paseo MCP server with a bearer header when a token is provided", () => { + const result = withRuntimePaseoMcpServer({ + config: BASE_CONFIG, + agentId: "agent-1", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: "cap-token", + }); + + expect(result.mcpServers?.paseo).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer cap-token" }, + }); + }); + + test("omits the header when no token is available", () => { + const result = withRuntimePaseoMcpServer({ + config: BASE_CONFIG, + agentId: "agent-1", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: null, + }); + + expect(result.mcpServers?.paseo).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + }); + }); + + test("does not inject when no MCP base URL is configured", () => { + const result = withRuntimePaseoMcpServer({ + config: BASE_CONFIG, + agentId: "agent-1", + mcpBaseUrl: null, + mcpAuthToken: "cap-token", + }); + + expect(result.mcpServers).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index 8df9ebabb..af04e8021 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -30,6 +30,12 @@ export function withRuntimePaseoMcpServer(params: { config: AgentSessionConfig; agentId: string; mcpBaseUrl: string | null; + /** + * Capability token authenticating the injected connection to the daemon's + * Agent MCP endpoint. The daemon password is gated off this route, so without + * this header the agent's MCP requests are rejected when a password is set. + */ + mcpAuthToken: string | null; }): AgentSessionConfig { const storedConfig = stripInternalPaseoMcpServer(params.config); if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) { @@ -42,6 +48,9 @@ export function withRuntimePaseoMcpServer(params: { [PASEO_MCP_SERVER_NAME]: { type: "http", url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`, + ...(params.mcpAuthToken + ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } + : {}), }, ...storedConfig.mcpServers, }, diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 1bcf05392..f53ce43a6 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -5,6 +5,7 @@ import { extractWsBearerProtocol, extractWsBearerToken, hashDaemonPassword, + isAgentMcpRequestAuthorized, isBearerTokenValidAsync, isBearerTokenValid, shouldBypassBearerAuth, @@ -61,8 +62,62 @@ describe("daemon bearer validator", () => { expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); // Guarded by its own single-use download token, not the daemon password. expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); + // Guarded by its own per-daemon-run capability token (see + // isAgentMcpRequestAuthorized), not the daemon password. + expect(shouldBypassBearerAuth("POST", "/mcp/agents")).toBe(true); // Everything else stays behind the daemon password. expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false); }); }); + +describe("agent MCP request authorizer", () => { + const CAPABILITY_TOKEN = "cap-token-abc123"; + + test("allows any request when no daemon password is configured", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: undefined, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: undefined, + }), + ).toBe(true); + }); + + test("accepts the injected capability token", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: `Bearer ${CAPABILITY_TOKEN}`, + }), + ).toBe(true); + }); + + test("still accepts a valid daemon-password bearer", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: "Bearer correct-password", + }), + ).toBe(true); + }); + + test("rejects requests presenting neither the token nor a valid password", async () => { + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: undefined, + }), + ).toBe(false); + expect( + await isAgentMcpRequestAuthorized({ + password: CORRECT_PASSWORD_HASH, + capabilityToken: CAPABILITY_TOKEN, + authorizationHeader: "Bearer wrong-token", + }), + ).toBe(false); + }); +}); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 4e2e7ee7f..718920d05 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -1,4 +1,5 @@ import { compare, compareSync, hashSync } from "bcryptjs"; +import { timingSafeEqual } from "node:crypto"; import type { RequestHandler } from "express"; export const DAEMON_PASSWORD_BCRYPT_COST = 12; @@ -132,6 +133,16 @@ const BEARER_AUTH_BYPASS_PATHS = new Set([ // rejects requests without a valid token (400/403), so dropping the bearer // here does not make the route unauthenticated. "/api/files/download", + // The daemon injects its own agents' Paseo MCP connections at this endpoint + // (and connects its own per-client MCP client here). Those connections cannot + // carry the daemon password — it is only known in plaintext when set via env, + // never when set via the app — so the route authenticates them with a + // per-daemon-run capability token instead (see isAgentMcpRequestAuthorized). + // The token is injected only into local agent configs/sessions and never sent + // to remote clients, and the route still rejects callers presenting neither + // the token nor a valid daemon password, so dropping the global bearer here + // does not make the endpoint unauthenticated. + "/mcp/agents", ]); export function shouldBypassBearerAuth(method: string, path: string): boolean { @@ -140,3 +151,32 @@ export function shouldBypassBearerAuth(method: string, path: string): boolean { } return BEARER_AUTH_BYPASS_PATHS.has(path); } + +/** + * Authorizes a request to the Agent MCP endpoint (/mcp/agents), which is exempt + * from the global daemon-password middleware. Accepts either the per-daemon-run + * capability token the daemon injects into its own agents' configs and MCP + * client, or a valid daemon-password bearer (so existing password-authenticated + * callers keep working). When no daemon password is configured the endpoint is + * open, matching the global middleware's behavior. + */ +export async function isAgentMcpRequestAuthorized(input: { + password: string | undefined; + capabilityToken: string | null; + authorizationHeader: string | undefined; +}): Promise { + if (!input.password) { + return true; + } + const token = extractHttpBearerToken(input.authorizationHeader); + if (input.capabilityToken !== null && token !== null) { + // Constant-time compare; length-guard first because timingSafeEqual throws + // on differing buffer lengths. + const provided = Buffer.from(token); + const expected = Buffer.from(input.capabilityToken); + if (provided.length === expected.length && timingSafeEqual(provided, expected)) { + return true; + } + } + return isBearerTokenValidAsync({ password: input.password, token }); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 47932dbc9..d19a3b259 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -131,7 +131,11 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; -import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js"; +import { + createRequireBearerMiddleware, + isAgentMcpRequestAuthorized, + type DaemonAuthConfig, +} from "./auth.js"; type AgentMcpTransportMap = Map; @@ -325,6 +329,15 @@ export async function createPaseoDaemon( ttlMs: downloadTokenTtlMs, }); + // Capability token authenticating the daemon's own agents to the loopback + // Agent MCP endpoint (/mcp/agents). Random per daemon run, injected only into + // local agent configs and the daemon's own MCP client — never sent to remote + // clients — so it cannot be replayed off-box. This lets the injected MCP + // authenticate even when the daemon password is set via the app (hash only, + // no plaintext available). Mirrors the /api/files/download capability-token + // pattern. + const agentMcpAuthToken = randomUUID(); + const listenTarget = parseListenString(config.listen); const app = express(); @@ -550,6 +563,7 @@ export async function createPaseoDaemon( onWorkspaceStateMayHaveChanged: ({ cwd }) => { workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, + mcpAuthToken: agentMcpAuthToken, logger, }); @@ -800,6 +814,20 @@ export async function createPaseoDaemon( req: express.Request, res: express.Response, ): Promise => { + // This route is exempt from the global daemon-password middleware, so it + // authenticates here using the injected capability token (or a valid + // daemon password). Without this, a password-protected daemon would be + // wide open on its agent control plane. + if ( + !(await isAgentMcpRequestAuthorized({ + password: config.auth?.password, + capabilityToken: agentMcpAuthToken, + authorizationHeader: req.header("authorization"), + })) + ) { + res.status(401).json({ error: "Unauthorized" }); + return; + } if (config.mcpDebug) { logger.debug( { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b7eee2636..db84704fb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1220,7 +1220,13 @@ export class Session { ); return; } - const transport = new StreamableHTTPClientTransport(new URL(this.mcpBaseUrl)); + const authToken = this.agentManager.getMcpAuthToken(); + const transport = new StreamableHTTPClientTransport( + new URL(this.mcpBaseUrl), + authToken + ? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } } + : undefined, + ); this.agentMcpClient = await experimental_createMCPClient({ transport, From e4c966b21ec06325bce16863c79755ca32ce6e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=B4=E5=A5=B9=E5=91=BD=40?= <908280968@qq.com> Date: Fri, 12 Jun 2026 13:10:24 +0800 Subject: [PATCH 3/3] fix(app): add missing i18n keys for host settings (#1478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): add missing i18n keys for host settings The client UI i18n migration (#1282) referenced translation keys that were never added to any locale file, so i18next rendered the raw key string in Settings → Host (e.g. a button labeled "settings.host.daemon.restart.confirm"). Add the six missing keys to all six locales with real translations: - settings.host.daemon.rename.submit - settings.host.daemon.restart.confirm - settings.host.orchestration.systemPrompt.{edit,reset,save,saving} The seventh broken reference, in pair-device-modal.tsx, used the wrong namespace (settings.integrations.pairDevices.rowTitle); the key is defined at settings.host.pairDevices.rowTitle. Point the reference there. Reported in https://github.com/getpaseo/paseo/pull/1282#issuecomment-4686672699 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): correct machine-translated Save/Saving labels (es/fr/ar) #1282 left several Save/Saving labels machine-translated into the "economize money" sense rather than the persist-data sense, so users saw "Ahorro..." (es), "Économie..." (fr), and "توفير..." (ar) where "Saving..." was meant. Fix renameModal.saving and settings.project.actions.{save,saving} in the three affected locales. ar's actions.save also moves from the conjugated "يحفظ" to the standard button form "حفظ". Wording matches each locale's already-correct siblings (e.g. es "Proyecto guardado") and the keys added in the previous commit. ru and zh-CN were already correct and are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../app/src/desktop/components/pair-device-modal.tsx | 2 +- packages/app/src/i18n/resources/ar.ts | 12 +++++++++--- packages/app/src/i18n/resources/en.ts | 6 ++++++ packages/app/src/i18n/resources/es.ts | 12 +++++++++--- packages/app/src/i18n/resources/fr.ts | 10 ++++++++-- packages/app/src/i18n/resources/ru.ts | 6 ++++++ packages/app/src/i18n/resources/zh-CN.ts | 6 ++++++ 7 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/app/src/desktop/components/pair-device-modal.tsx b/packages/app/src/desktop/components/pair-device-modal.tsx index a3dab342e..4303a1e93 100644 --- a/packages/app/src/desktop/components/pair-device-modal.tsx +++ b/packages/app/src/desktop/components/pair-device-modal.tsx @@ -14,7 +14,7 @@ const SNAP_POINTS: string[] = ["82%", "94%"]; export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { const { t } = useTranslation(); const header = useMemo( - () => ({ title: t("settings.integrations.pairDevices.rowTitle") }), + () => ({ title: t("settings.host.pairDevices.rowTitle") }), [t], ); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index eb693b3ac..4ecf9c623 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1294,7 +1294,7 @@ export const ar: TranslationResources = { }, renameModal: { rename: "إعادة تسمية", - saving: "توفير...", + saving: "جارٍ الحفظ...", }, sidebarCallout: { dismiss: "رفض", @@ -1611,6 +1611,10 @@ export const ar: TranslationResources = { sheetTitle: "إلحاق موجه النظام", accessibilityLabel: "إلحاق موجه النظام", placeholder: "اجعل الردود موجزة دائمًا.", + edit: "تحرير", + reset: "إعادة ضبط", + save: "حفظ", + saving: "جارٍ الحفظ...", }, }, agents: { @@ -1624,10 +1628,12 @@ export const ar: TranslationResources = { editLabel: "تحرير التسمية", title: "إعادة تسمية المضيف", placeholder: "بلدي Host", + submit: "إعادة تسمية", }, restart: { title: "إعادة تشغيل البرنامج الخفي", hint: "إعادة تشغيل عملية البرنامج الخفي. سيتم إعادة الاتصال بالتطبيق تلقائيًا", + confirm: "إعادة التشغيل", confirmTitle: "أعد تشغيل{{name}}", confirmMessage: "سيؤدي هذا إلى إعادة تشغيل البرنامج الخفي. سيستمر العملاء الذين يعملون عليه؛ سيتم إعادة الاتصال بالتطبيق تلقائيًا.", @@ -1793,9 +1799,9 @@ export const ar: TranslationResources = { actions: { reload: "إعادة تحميل", tryAgain: "حاول ثانية", - save: "يحفظ", + save: "حفظ", saved: "تم حفظ المشروع", - saving: "توفير...", + saving: "جارٍ الحفظ...", cancel: "يلغي", }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 14c7e0d63..e508191f8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1618,6 +1618,10 @@ export const en = { sheetTitle: "Append system prompt", accessibilityLabel: "Append system prompt", placeholder: "Always keep replies concise.", + edit: "Edit", + reset: "Reset", + save: "Save", + saving: "Saving...", }, }, agents: { @@ -1631,10 +1635,12 @@ export const en = { editLabel: "Edit label", title: "Rename host", placeholder: "My Host", + submit: "Rename", }, restart: { title: "Restart daemon", hint: "Restarts the daemon process. The app will reconnect automatically", + confirm: "Restart", confirmTitle: "Restart {{name}}", confirmMessage: "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 68a536f17..67bb1e5cc 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1329,7 +1329,7 @@ export const es: TranslationResources = { }, renameModal: { rename: "Rebautizar", - saving: "Ahorro...", + saving: "Guardando...", }, sidebarCallout: { dismiss: "Despedir", @@ -1648,6 +1648,10 @@ export const es: TranslationResources = { sheetTitle: "Agregar mensaje del sistema", accessibilityLabel: "Agregar mensaje del sistema", placeholder: "Mantenga siempre respuestas concisas.", + edit: "Editar", + reset: "Restablecer", + save: "Guardar", + saving: "Guardando...", }, }, agents: { @@ -1661,10 +1665,12 @@ export const es: TranslationResources = { editLabel: "Editar etiqueta", title: "Cambiar nombre de host", placeholder: "MiHost", + submit: "Cambiar nombre", }, restart: { title: "Reiniciar demonio", hint: "Reinicia el proceso del demonio. La aplicación se volverá a conectar automáticamente", + confirm: "Reiniciar", confirmTitle: "Reiniciar{{name}}", confirmMessage: "Esto reiniciará el demonio. Los agentes que se ejecutan en él seguirán funcionando; la aplicación se volverá a conectar automáticamente.", @@ -1834,9 +1840,9 @@ export const es: TranslationResources = { actions: { reload: "Recargar", tryAgain: "Intentar otra vez", - save: "Ahorrar", + save: "Guardar", saved: "Proyecto guardado", - saving: "Ahorro...", + saving: "Guardando...", cancel: "Cancelar", }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e16ca8637..5f21ebeaf 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1332,7 +1332,7 @@ export const fr: TranslationResources = { }, renameModal: { rename: "Rebaptiser", - saving: "Économie...", + saving: "Sauvegarde...", }, sidebarCallout: { dismiss: "Rejeter", @@ -1654,6 +1654,10 @@ export const fr: TranslationResources = { sheetTitle: "Ajouter une invite système", accessibilityLabel: "Ajouter une invite système", placeholder: "Gardez toujours des réponses concises.", + edit: "Modifier", + reset: "Réinitialiser", + save: "Sauvegarder", + saving: "Sauvegarde...", }, }, agents: { @@ -1667,10 +1671,12 @@ export const fr: TranslationResources = { editLabel: "Modifier l'étiquette", title: "Renommer l'hôte", placeholder: "MonHost", + submit: "Renommer", }, restart: { title: "Redémarrer le démon", hint: "Redémarre le processus démon. L'application se reconnectera automatiquement", + confirm: "Redémarrer", confirmTitle: "Redémarrer{{name}}", confirmMessage: "Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.", @@ -1844,7 +1850,7 @@ export const fr: TranslationResources = { tryAgain: "Essayer à nouveau", save: "Sauvegarder", saved: "Projet enregistré", - saving: "Économie...", + saving: "Sauvegarde...", cancel: "Annuler", }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 88c460942..baa9152f6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1642,6 +1642,10 @@ export const ru: TranslationResources = { sheetTitle: "Добавить системное приглашение", accessibilityLabel: "Добавить системное приглашение", placeholder: "Всегда отвечайте кратко.", + edit: "Редактировать", + reset: "Сбросить", + save: "Сохранить", + saving: "Сохранение...", }, }, agents: { @@ -1655,10 +1659,12 @@ export const ru: TranslationResources = { editLabel: "Изменить ярлык", title: "Переименовать хост", placeholder: "Мой Host", + submit: "Переименовать", }, restart: { title: "Перезапустить демон", hint: "Перезапускает процесс демона. Приложение автоматически переподключится", + confirm: "Перезапустить", confirmTitle: "Перезапустите{{name}}", confirmMessage: "Это перезапустит демон. Агенты, работающие на нем, будут продолжать работать; приложение автоматически повторно подключится.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c2687b79a..f9f3c702e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1593,6 +1593,10 @@ export const zhCN: TranslationResources = { sheetTitle: "追加 system prompt", accessibilityLabel: "追加 system prompt", placeholder: "始终保持回复简洁。", + edit: "编辑", + reset: "重置", + save: "保存", + saving: "保存中...", }, }, agents: { @@ -1606,10 +1610,12 @@ export const zhCN: TranslationResources = { editLabel: "编辑标签", title: "重命名 Host", placeholder: "我的 Host", + submit: "重命名", }, restart: { title: "重启 Daemon", hint: "重启 Daemon 进程。应用会自动重新连接", + confirm: "重启", confirmTitle: "重启 {{name}}", confirmMessage: "这会重启 Daemon。其上运行的 Agent 会继续运行;应用会自动重新连接。", restarting: "正在重启...",