Merge remote-tracking branch 'origin/main'

This commit is contained in:
Mohamed Boudra
2026-06-12 12:12:46 +07:00
21 changed files with 492 additions and 13 deletions
@@ -87,6 +87,7 @@ interface SplitContainerProps {
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
@@ -365,6 +366,7 @@ export function SplitContainer({
onCloseTab, onCloseTab,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTabsToLeft, onCloseTabsToLeft,
@@ -581,6 +583,7 @@ export function SplitContainer({
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent} onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab} onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
@@ -723,6 +726,7 @@ function SplitNodeView({
onCloseTab, onCloseTab,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTabsToLeft, onCloseTabsToLeft,
@@ -775,6 +779,7 @@ function SplitNodeView({
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent} onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab} onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
@@ -820,6 +825,7 @@ function SplitNodeView({
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent} onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab} onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
@@ -871,6 +877,7 @@ function SplitPaneView({
onCloseTab, onCloseTab,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTabsToLeft, onCloseTabsToLeft,
@@ -1012,6 +1019,7 @@ function SplitPaneView({
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent} onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab} onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToLeft={handleCloseTabsToLeft}
@@ -14,7 +14,7 @@ const SNAP_POINTS: string[] = ["82%", "94%"];
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const header = useMemo<SheetHeader>( const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }), () => ({ title: t("settings.host.pairDevices.rowTitle") }),
[t], [t],
); );
+11 -3
View File
@@ -433,6 +433,7 @@ export const ar: TranslationResources = {
openFor: "فتح القائمة لـ{{label}}", openFor: "فتح القائمة لـ{{label}}",
copyResumeCommand: "نسخ أمر السيرة الذاتية", copyResumeCommand: "نسخ أمر السيرة الذاتية",
copyAgentId: "نسخ معرف الوكيل", copyAgentId: "نسخ معرف الوكيل",
copyFilePath: "Copy file path",
rename: "إعادة تسمية", rename: "إعادة تسمية",
closeAbove: "إغلاق علامات التبويب أعلاه", closeAbove: "إغلاق علامات التبويب أعلاه",
closeBelow: "إغلاق علامات التبويب أدناه", closeBelow: "إغلاق علامات التبويب أدناه",
@@ -467,6 +468,7 @@ export const ar: TranslationResources = {
copyFailed: "فشل النسخ", copyFailed: "فشل النسخ",
agentIdCopiedLabel: "AgentID", agentIdCopiedLabel: "AgentID",
resumeCommandCopiedLabel: "أمر الاستئناف", resumeCommandCopiedLabel: "أمر الاستئناف",
filePathCopiedLabel: "File path",
resumeIdUnavailable: "السيرة الذاتية ID غير متوفرة", resumeIdUnavailable: "السيرة الذاتية ID غير متوفرة",
resumeCommandUnavailable: "أمر الاستئناف غير متوفر", resumeCommandUnavailable: "أمر الاستئناف غير متوفر",
reloadingAgent: "وكيل إعادة التحميل...", reloadingAgent: "وكيل إعادة التحميل...",
@@ -1294,7 +1296,7 @@ export const ar: TranslationResources = {
}, },
renameModal: { renameModal: {
rename: "إعادة تسمية", rename: "إعادة تسمية",
saving: "توفير...", saving: "جارٍ الحفظ...",
}, },
sidebarCallout: { sidebarCallout: {
dismiss: "رفض", dismiss: "رفض",
@@ -1612,6 +1614,10 @@ export const ar: TranslationResources = {
sheetTitle: "إلحاق موجه النظام", sheetTitle: "إلحاق موجه النظام",
accessibilityLabel: "إلحاق موجه النظام", accessibilityLabel: "إلحاق موجه النظام",
placeholder: "اجعل الردود موجزة دائمًا.", placeholder: "اجعل الردود موجزة دائمًا.",
edit: "تحرير",
reset: "إعادة ضبط",
save: "حفظ",
saving: "جارٍ الحفظ...",
}, },
}, },
agents: { agents: {
@@ -1646,10 +1652,12 @@ export const ar: TranslationResources = {
editLabel: "تحرير التسمية", editLabel: "تحرير التسمية",
title: "إعادة تسمية المضيف", title: "إعادة تسمية المضيف",
placeholder: "بلدي Host", placeholder: "بلدي Host",
submit: "إعادة تسمية",
}, },
restart: { restart: {
title: "إعادة تشغيل البرنامج الخفي", title: "إعادة تشغيل البرنامج الخفي",
hint: "إعادة تشغيل عملية البرنامج الخفي. سيتم إعادة الاتصال بالتطبيق تلقائيًا", hint: "إعادة تشغيل عملية البرنامج الخفي. سيتم إعادة الاتصال بالتطبيق تلقائيًا",
confirm: "إعادة التشغيل",
confirmTitle: "أعد تشغيل{{name}}", confirmTitle: "أعد تشغيل{{name}}",
confirmMessage: confirmMessage:
"سيؤدي هذا إلى إعادة تشغيل البرنامج الخفي. سيستمر العملاء الذين يعملون عليه؛ سيتم إعادة الاتصال بالتطبيق تلقائيًا.", "سيؤدي هذا إلى إعادة تشغيل البرنامج الخفي. سيستمر العملاء الذين يعملون عليه؛ سيتم إعادة الاتصال بالتطبيق تلقائيًا.",
@@ -1815,9 +1823,9 @@ export const ar: TranslationResources = {
actions: { actions: {
reload: "إعادة تحميل", reload: "إعادة تحميل",
tryAgain: "حاول ثانية", tryAgain: "حاول ثانية",
save: "يحفظ", save: "حفظ",
saved: "تم حفظ المشروع", saved: "تم حفظ المشروع",
saving: "توفير...", saving: "جارٍ الحفظ...",
cancel: "يلغي", cancel: "يلغي",
}, },
}, },
+8
View File
@@ -432,6 +432,7 @@ export const en = {
openFor: "Open menu for {{label}}", openFor: "Open menu for {{label}}",
copyResumeCommand: "Copy resume command", copyResumeCommand: "Copy resume command",
copyAgentId: "Copy agent id", copyAgentId: "Copy agent id",
copyFilePath: "Copy file path",
rename: "Rename", rename: "Rename",
closeAbove: "Close tabs above", closeAbove: "Close tabs above",
closeBelow: "Close tabs below", closeBelow: "Close tabs below",
@@ -466,6 +467,7 @@ export const en = {
copyFailed: "Copy failed", copyFailed: "Copy failed",
agentIdCopiedLabel: "Agent ID", agentIdCopiedLabel: "Agent ID",
resumeCommandCopiedLabel: "resume command", resumeCommandCopiedLabel: "resume command",
filePathCopiedLabel: "File path",
resumeIdUnavailable: "Resume ID not available", resumeIdUnavailable: "Resume ID not available",
resumeCommandUnavailable: "Resume command not available", resumeCommandUnavailable: "Resume command not available",
reloadingAgent: "Reloading agent...", reloadingAgent: "Reloading agent...",
@@ -1619,6 +1621,10 @@ export const en = {
sheetTitle: "Append system prompt", sheetTitle: "Append system prompt",
accessibilityLabel: "Append system prompt", accessibilityLabel: "Append system prompt",
placeholder: "Always keep replies concise.", placeholder: "Always keep replies concise.",
edit: "Edit",
reset: "Reset",
save: "Save",
saving: "Saving...",
}, },
}, },
agents: { agents: {
@@ -1653,10 +1659,12 @@ export const en = {
editLabel: "Edit label", editLabel: "Edit label",
title: "Rename host", title: "Rename host",
placeholder: "My Host", placeholder: "My Host",
submit: "Rename",
}, },
restart: { restart: {
title: "Restart daemon", title: "Restart daemon",
hint: "Restarts the daemon process. The app will reconnect automatically", hint: "Restarts the daemon process. The app will reconnect automatically",
confirm: "Restart",
confirmTitle: "Restart {{name}}", confirmTitle: "Restart {{name}}",
confirmMessage: confirmMessage:
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.", "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.",
+11 -3
View File
@@ -436,6 +436,7 @@ export const es: TranslationResources = {
openFor: "Menú abierto para{{label}}", openFor: "Menú abierto para{{label}}",
copyResumeCommand: "Copiar comando de reanudación", copyResumeCommand: "Copiar comando de reanudación",
copyAgentId: "Copiar ID del agente", copyAgentId: "Copiar ID del agente",
copyFilePath: "Copy file path",
rename: "Rebautizar", rename: "Rebautizar",
closeAbove: "Cerrar pestañas arriba", closeAbove: "Cerrar pestañas arriba",
closeBelow: "Cerrar pestañas a continuación", closeBelow: "Cerrar pestañas a continuación",
@@ -471,6 +472,7 @@ export const es: TranslationResources = {
copyFailed: "Copia fallida", copyFailed: "Copia fallida",
agentIdCopiedLabel: "AgentID", agentIdCopiedLabel: "AgentID",
resumeCommandCopiedLabel: "reanudar el comando", resumeCommandCopiedLabel: "reanudar el comando",
filePathCopiedLabel: "File path",
resumeIdUnavailable: "ReanudarIDno disponible", resumeIdUnavailable: "ReanudarIDno disponible",
resumeCommandUnavailable: "Comando de reanudación no disponible", resumeCommandUnavailable: "Comando de reanudación no disponible",
reloadingAgent: "Agente de recarga...", reloadingAgent: "Agente de recarga...",
@@ -1329,7 +1331,7 @@ export const es: TranslationResources = {
}, },
renameModal: { renameModal: {
rename: "Rebautizar", rename: "Rebautizar",
saving: "Ahorro...", saving: "Guardando...",
}, },
sidebarCallout: { sidebarCallout: {
dismiss: "Despedir", dismiss: "Despedir",
@@ -1649,6 +1651,10 @@ export const es: TranslationResources = {
sheetTitle: "Agregar mensaje del sistema", sheetTitle: "Agregar mensaje del sistema",
accessibilityLabel: "Agregar mensaje del sistema", accessibilityLabel: "Agregar mensaje del sistema",
placeholder: "Mantenga siempre respuestas concisas.", placeholder: "Mantenga siempre respuestas concisas.",
edit: "Editar",
reset: "Restablecer",
save: "Guardar",
saving: "Guardando...",
}, },
}, },
agents: { agents: {
@@ -1683,10 +1689,12 @@ export const es: TranslationResources = {
editLabel: "Editar etiqueta", editLabel: "Editar etiqueta",
title: "Cambiar nombre de host", title: "Cambiar nombre de host",
placeholder: "MiHost", placeholder: "MiHost",
submit: "Cambiar nombre",
}, },
restart: { restart: {
title: "Reiniciar demonio", title: "Reiniciar demonio",
hint: "Reinicia el proceso del demonio. La aplicación se volverá a conectar automáticamente", hint: "Reinicia el proceso del demonio. La aplicación se volverá a conectar automáticamente",
confirm: "Reiniciar",
confirmTitle: "Reiniciar{{name}}", confirmTitle: "Reiniciar{{name}}",
confirmMessage: confirmMessage:
"Esto reiniciará el demonio. Los agentes que se ejecutan en él seguirán funcionando; la aplicación se volverá a conectar automáticamente.", "Esto reiniciará el demonio. Los agentes que se ejecutan en él seguirán funcionando; la aplicación se volverá a conectar automáticamente.",
@@ -1856,9 +1864,9 @@ export const es: TranslationResources = {
actions: { actions: {
reload: "Recargar", reload: "Recargar",
tryAgain: "Intentar otra vez", tryAgain: "Intentar otra vez",
save: "Ahorrar", save: "Guardar",
saved: "Proyecto guardado", saved: "Proyecto guardado",
saving: "Ahorro...", saving: "Guardando...",
cancel: "Cancelar", cancel: "Cancelar",
}, },
}, },
+10 -2
View File
@@ -436,6 +436,7 @@ export const fr: TranslationResources = {
openFor: "Ouvrir le menu pour{{label}}", openFor: "Ouvrir le menu pour{{label}}",
copyResumeCommand: "Copier la commande de reprise", copyResumeCommand: "Copier la commande de reprise",
copyAgentId: "Copier l'identifiant de l'agent", copyAgentId: "Copier l'identifiant de l'agent",
copyFilePath: "Copy file path",
rename: "Rebaptiser", rename: "Rebaptiser",
closeAbove: "Fermer les onglets ci-dessus", closeAbove: "Fermer les onglets ci-dessus",
closeBelow: "Fermer les onglets ci-dessous", closeBelow: "Fermer les onglets ci-dessous",
@@ -471,6 +472,7 @@ export const fr: TranslationResources = {
copyFailed: "Échec de la copie", copyFailed: "Échec de la copie",
agentIdCopiedLabel: "AgentID", agentIdCopiedLabel: "AgentID",
resumeCommandCopiedLabel: "reprendre la commande", resumeCommandCopiedLabel: "reprendre la commande",
filePathCopiedLabel: "File path",
resumeIdUnavailable: "ReprendreIDnon disponible", resumeIdUnavailable: "ReprendreIDnon disponible",
resumeCommandUnavailable: "Commande de reprise non disponible", resumeCommandUnavailable: "Commande de reprise non disponible",
reloadingAgent: "Agent de rechargement...", reloadingAgent: "Agent de rechargement...",
@@ -1332,7 +1334,7 @@ export const fr: TranslationResources = {
}, },
renameModal: { renameModal: {
rename: "Rebaptiser", rename: "Rebaptiser",
saving: "Économie...", saving: "Sauvegarde...",
}, },
sidebarCallout: { sidebarCallout: {
dismiss: "Rejeter", dismiss: "Rejeter",
@@ -1655,6 +1657,10 @@ export const fr: TranslationResources = {
sheetTitle: "Ajouter une invite système", sheetTitle: "Ajouter une invite système",
accessibilityLabel: "Ajouter une invite système", accessibilityLabel: "Ajouter une invite système",
placeholder: "Gardez toujours des réponses concises.", placeholder: "Gardez toujours des réponses concises.",
edit: "Modifier",
reset: "Réinitialiser",
save: "Sauvegarder",
saving: "Sauvegarde...",
}, },
}, },
agents: { agents: {
@@ -1689,10 +1695,12 @@ export const fr: TranslationResources = {
editLabel: "Modifier l'étiquette", editLabel: "Modifier l'étiquette",
title: "Renommer l'hôte", title: "Renommer l'hôte",
placeholder: "MonHost", placeholder: "MonHost",
submit: "Renommer",
}, },
restart: { restart: {
title: "Redémarrer le démon", title: "Redémarrer le démon",
hint: "Redémarre le processus démon. L'application se reconnectera automatiquement", hint: "Redémarre le processus démon. L'application se reconnectera automatiquement",
confirm: "Redémarrer",
confirmTitle: "Redémarrer{{name}}", confirmTitle: "Redémarrer{{name}}",
confirmMessage: confirmMessage:
"Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.", "Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.",
@@ -1866,7 +1874,7 @@ export const fr: TranslationResources = {
tryAgain: "Essayer à nouveau", tryAgain: "Essayer à nouveau",
save: "Sauvegarder", save: "Sauvegarder",
saved: "Projet enregistré", saved: "Projet enregistré",
saving: "Économie...", saving: "Sauvegarde...",
cancel: "Annuler", cancel: "Annuler",
}, },
}, },
+8
View File
@@ -436,6 +436,7 @@ export const ru: TranslationResources = {
openFor: "Открыть меню для{{label}}", openFor: "Открыть меню для{{label}}",
copyResumeCommand: "Копировать команду возобновления", copyResumeCommand: "Копировать команду возобновления",
copyAgentId: "Скопировать идентификатор агента", copyAgentId: "Скопировать идентификатор агента",
copyFilePath: "Copy file path",
rename: "Переименовать", rename: "Переименовать",
closeAbove: "Закрыть вкладки выше", closeAbove: "Закрыть вкладки выше",
closeBelow: "Закройте вкладки ниже", closeBelow: "Закройте вкладки ниже",
@@ -470,6 +471,7 @@ export const ru: TranslationResources = {
copyFailed: "Не удалось скопировать", copyFailed: "Не удалось скопировать",
agentIdCopiedLabel: "AgentID", agentIdCopiedLabel: "AgentID",
resumeCommandCopiedLabel: "команда возобновления", resumeCommandCopiedLabel: "команда возобновления",
filePathCopiedLabel: "File path",
resumeIdUnavailable: "Резюме ID недоступно", resumeIdUnavailable: "Резюме ID недоступно",
resumeCommandUnavailable: "Команда возобновления недоступна", resumeCommandUnavailable: "Команда возобновления недоступна",
reloadingAgent: "Перезагрузка агента...", reloadingAgent: "Перезагрузка агента...",
@@ -1643,6 +1645,10 @@ export const ru: TranslationResources = {
sheetTitle: "Добавить системное приглашение", sheetTitle: "Добавить системное приглашение",
accessibilityLabel: "Добавить системное приглашение", accessibilityLabel: "Добавить системное приглашение",
placeholder: "Всегда отвечайте кратко.", placeholder: "Всегда отвечайте кратко.",
edit: "Редактировать",
reset: "Сбросить",
save: "Сохранить",
saving: "Сохранение...",
}, },
}, },
agents: { agents: {
@@ -1677,10 +1683,12 @@ export const ru: TranslationResources = {
editLabel: "Изменить ярлык", editLabel: "Изменить ярлык",
title: "Переименовать хост", title: "Переименовать хост",
placeholder: "Мой Host", placeholder: "Мой Host",
submit: "Переименовать",
}, },
restart: { restart: {
title: "Перезапустить демон", title: "Перезапустить демон",
hint: "Перезапускает процесс демона. Приложение автоматически переподключится", hint: "Перезапускает процесс демона. Приложение автоматически переподключится",
confirm: "Перезапустить",
confirmTitle: "Перезапустите{{name}}", confirmTitle: "Перезапустите{{name}}",
confirmMessage: confirmMessage:
"Это перезапустит демон. Агенты, работающие на нем, будут продолжать работать; приложение автоматически повторно подключится.", "Это перезапустит демон. Агенты, работающие на нем, будут продолжать работать; приложение автоматически повторно подключится.",
+8
View File
@@ -433,6 +433,7 @@ export const zhCN: TranslationResources = {
openFor: "打开 {{label}} 的菜单", openFor: "打开 {{label}} 的菜单",
copyResumeCommand: "复制恢复命令", copyResumeCommand: "复制恢复命令",
copyAgentId: "复制 Agent ID", copyAgentId: "复制 Agent ID",
copyFilePath: "Copy file path",
rename: "重命名", rename: "重命名",
closeAbove: "关闭上方标签", closeAbove: "关闭上方标签",
closeBelow: "关闭下方标签", closeBelow: "关闭下方标签",
@@ -467,6 +468,7 @@ export const zhCN: TranslationResources = {
copyFailed: "复制失败", copyFailed: "复制失败",
agentIdCopiedLabel: "Agent ID", agentIdCopiedLabel: "Agent ID",
resumeCommandCopiedLabel: "恢复命令", resumeCommandCopiedLabel: "恢复命令",
filePathCopiedLabel: "File path",
resumeIdUnavailable: "恢复 ID 不可用", resumeIdUnavailable: "恢复 ID 不可用",
resumeCommandUnavailable: "恢复命令不可用", resumeCommandUnavailable: "恢复命令不可用",
reloadingAgent: "正在重新加载 Agent...", reloadingAgent: "正在重新加载 Agent...",
@@ -1594,6 +1596,10 @@ export const zhCN: TranslationResources = {
sheetTitle: "追加 system prompt", sheetTitle: "追加 system prompt",
accessibilityLabel: "追加 system prompt", accessibilityLabel: "追加 system prompt",
placeholder: "始终保持回复简洁。", placeholder: "始终保持回复简洁。",
edit: "编辑",
reset: "重置",
save: "保存",
saving: "保存中...",
}, },
}, },
agents: { agents: {
@@ -1628,10 +1634,12 @@ export const zhCN: TranslationResources = {
editLabel: "编辑标签", editLabel: "编辑标签",
title: "重命名 Host", title: "重命名 Host",
placeholder: "我的 Host", placeholder: "我的 Host",
submit: "重命名",
}, },
restart: { restart: {
title: "重启 Daemon", title: "重启 Daemon",
hint: "重启 Daemon 进程。应用会自动重新连接", hint: "重启 Daemon 进程。应用会自动重新连接",
confirm: "重启",
confirmTitle: "重启 {{name}}", confirmTitle: "重启 {{name}}",
confirmMessage: "这会重启 Daemon。其上运行的 Agent 会继续运行;应用会自动重新连接。", confirmMessage: "这会重启 Daemon。其上运行的 Agent 会继续运行;应用会自动重新连接。",
restarting: "正在重启...", restarting: "正在重启...",
@@ -399,6 +399,7 @@ interface WorkspaceDesktopTabsRowProps {
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void; onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
@@ -713,6 +714,7 @@ export function WorkspaceDesktopTabsRow({
onCloseTab, onCloseTab,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTabsToLeft, onCloseTabsToLeft,
@@ -784,6 +786,7 @@ export function WorkspaceDesktopTabsRow({
() => ({ () => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"), copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
rename: t("workspace.tabs.menu.rename"), rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"), closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"), closeBelow: t("workspace.tabs.menu.closeBelow"),
@@ -881,6 +884,7 @@ export function WorkspaceDesktopTabsRow({
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent} onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab} onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
@@ -911,6 +915,7 @@ export function WorkspaceDesktopTabsRow({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onCopyResumeCommand, onCopyResumeCommand,
onNavigateTab, onNavigateTab,
onReloadAgent, onReloadAgent,
@@ -1003,6 +1008,7 @@ function ResolvedDesktopTabChip({
normalizedWorkspaceId, normalizedWorkspaceId,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTabsToLeft, onCloseTabsToLeft,
@@ -1028,6 +1034,7 @@ function ResolvedDesktopTabChip({
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void; onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
@@ -1053,6 +1060,7 @@ function ResolvedDesktopTabChip({
tabCount, tabCount,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, onCloseTab,
@@ -1069,6 +1077,7 @@ function ResolvedDesktopTabChip({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onCopyResumeCommand, onCopyResumeCommand,
labels, labels,
onReloadAgent, onReloadAgent,
@@ -392,6 +392,7 @@ interface MobileWorkspaceTabSwitcherProps {
onSelectSwitcherTab: (key: string) => void; onSelectSwitcherTab: (key: string) => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
@@ -579,6 +580,7 @@ function MobileWorkspaceTabOption({
onPress, onPress,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, onCloseTab,
@@ -596,6 +598,7 @@ function MobileWorkspaceTabOption({
onPress: () => void; onPress: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
@@ -608,6 +611,7 @@ function MobileWorkspaceTabOption({
() => ({ () => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"), copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
rename: t("workspace.tabs.menu.rename"), rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"), closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"), closeBelow: t("workspace.tabs.menu.closeBelow"),
@@ -629,6 +633,7 @@ function MobileWorkspaceTabOption({
menuTestIDBase, menuTestIDBase,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, onCloseTab,
@@ -695,6 +700,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onSelectSwitcherTab, onSelectSwitcherTab,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, onCloseTab,
@@ -750,6 +756,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onPress={onPress} onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent} onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab} onRenameTab={onRenameTab}
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
@@ -767,6 +774,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
normalizedWorkspaceId, normalizedWorkspaceId,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, onCloseTab,
@@ -2712,6 +2720,19 @@ function WorkspaceScreenContent({
[toast, t], [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( const handleCopyResumeCommand = useCallback(
async (agentId: string) => { async (agentId: string) => {
if (!agentId) return; if (!agentId) return;
@@ -3534,6 +3555,7 @@ function WorkspaceScreenContent({
onCloseTab={handleCloseTabById} onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand} onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId} onCopyAgentId={handleCopyAgentId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent} onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab} onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToLeft={handleCloseTabsToLeftInPane}
@@ -3568,6 +3590,7 @@ function WorkspaceScreenContent({
handleCloseTabById, handleCloseTabById,
handleCopyResumeCommand, handleCopyResumeCommand,
handleCopyAgentId, handleCopyAgentId,
handleCopyFilePath,
handleReloadAgent, handleReloadAgent,
handleRenameTab, handleRenameTab,
handleCloseTabsToLeftInPane, handleCloseTabsToLeftInPane,
@@ -3648,6 +3671,7 @@ function WorkspaceScreenContent({
onSelectSwitcherTab={handleSelectSwitcherTab} onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand} onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId} onCopyAgentId={handleCopyAgentId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent} onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab} onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById} onCloseTab={handleCloseTabById}
@@ -3669,6 +3693,7 @@ function WorkspaceScreenContent({
onCloseTab={handleCloseTabById} onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand} onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId} onCopyAgentId={handleCopyAgentId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent} onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab} onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToLeft={handleCloseTabsToLeft}
@@ -15,6 +15,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
it("uses desktop tab ordering labels for desktop menus", () => { it("uses desktop tab ordering labels for desktop menus", () => {
const onCopyResumeCommand = vi.fn(); const onCopyResumeCommand = vi.fn();
const onCopyAgentId = vi.fn(); const onCopyAgentId = vi.fn();
const onCopyFilePath = vi.fn();
const onReloadAgent = vi.fn(); const onReloadAgent = vi.fn();
const onRenameTab = vi.fn(); const onRenameTab = vi.fn();
const onCloseTab = vi.fn(); const onCloseTab = vi.fn();
@@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-agent_123", menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, onCloseTab,
@@ -59,6 +61,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-agent_123", menuTestIDBase: "workspace-tab-menu-agent_123",
onCopyResumeCommand: vi.fn(), onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(), onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(), onReloadAgent: vi.fn(),
onRenameTab: vi.fn(), onRenameTab: vi.fn(),
onCloseTab: vi.fn(), onCloseTab: vi.fn(),
@@ -93,6 +96,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-draft_123", menuTestIDBase: "workspace-tab-menu-draft_123",
onCopyResumeCommand: vi.fn(), onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(), onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(), onReloadAgent: vi.fn(),
onRenameTab: vi.fn(), onRenameTab: vi.fn(),
onCloseTab: vi.fn(), onCloseTab: vi.fn(),
@@ -120,6 +124,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-agent_123", menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand: vi.fn(), onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(), onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(), onReloadAgent: vi.fn(),
onRenameTab: vi.fn(), onRenameTab: vi.fn(),
onCloseTab: vi.fn(), onCloseTab: vi.fn(),
@@ -148,6 +153,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-agent_123", menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand: vi.fn(), onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(), onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(), onReloadAgent: vi.fn(),
onRenameTab, onRenameTab,
onCloseTab: vi.fn(), onCloseTab: vi.fn(),
@@ -181,6 +187,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-terminal_abc", menuTestIDBase: "workspace-tab-context-terminal_abc",
onCopyResumeCommand: vi.fn(), onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(), onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(), onReloadAgent: vi.fn(),
onRenameTab, onRenameTab,
onCloseTab: vi.fn(), onCloseTab: vi.fn(),
@@ -193,6 +200,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(labels[0]).toBe("Rename"); expect(labels[0]).toBe("Rename");
expect(labels).not.toContain("Copy resume command"); expect(labels).not.toContain("Copy resume command");
expect(labels).not.toContain("Copy agent id"); expect(labels).not.toContain("Copy agent id");
expect(labels).not.toContain("Copy file path");
expect(labels).not.toContain("Reload agent"); expect(labels).not.toContain("Reload agent");
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
@@ -203,6 +211,48 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(onRenameTab).toHaveBeenCalledWith(terminalTab); 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", () => { it("uses the same rename entry shape for agent and terminal tabs", () => {
const terminalTab: WorkspaceTabDescriptor = { const terminalTab: WorkspaceTabDescriptor = {
key: "terminal_abc", key: "terminal_abc",
@@ -218,6 +268,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase, menuTestIDBase,
onCopyResumeCommand: vi.fn(), onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(), onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(), onReloadAgent: vi.fn(),
onRenameTab: vi.fn(), onRenameTab: vi.fn(),
onCloseTab: vi.fn(), onCloseTab: vi.fn(),
@@ -8,6 +8,7 @@ export type WorkspaceTabMenuSurface = "desktop" | "mobile";
export interface WorkspaceTabMenuLabels { export interface WorkspaceTabMenuLabels {
copyResumeCommand: string; copyResumeCommand: string;
copyAgentId: string; copyAgentId: string;
copyFilePath: string;
rename: string; rename: string;
closeAbove: string; closeAbove: string;
closeBelow: string; closeBelow: string;
@@ -22,6 +23,7 @@ export interface WorkspaceTabMenuLabels {
export const DEFAULT_WORKSPACE_TAB_MENU_LABELS: WorkspaceTabMenuLabels = { export const DEFAULT_WORKSPACE_TAB_MENU_LABELS: WorkspaceTabMenuLabels = {
copyResumeCommand: i18n.t("workspace.tabs.menu.copyResumeCommand"), copyResumeCommand: i18n.t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: i18n.t("workspace.tabs.menu.copyAgentId"), copyAgentId: i18n.t("workspace.tabs.menu.copyAgentId"),
copyFilePath: i18n.t("workspace.tabs.menu.copyFilePath"),
rename: i18n.t("workspace.tabs.menu.rename"), rename: i18n.t("workspace.tabs.menu.rename"),
closeAbove: i18n.t("workspace.tabs.menu.closeAbove"), closeAbove: i18n.t("workspace.tabs.menu.closeAbove"),
closeBelow: i18n.t("workspace.tabs.menu.closeBelow"), closeBelow: i18n.t("workspace.tabs.menu.closeBelow"),
@@ -66,6 +68,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
menuTestIDBase: string; menuTestIDBase: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
@@ -81,6 +84,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
tabCount: number; tabCount: number;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void; onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void; onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
@@ -148,6 +152,7 @@ export function buildWorkspaceTabMenuEntries(
menuTestIDBase, menuTestIDBase,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCopyFilePath,
onReloadAgent, onReloadAgent,
onRenameTab, onRenameTab,
onCloseTab, 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") { if (tab.target.kind === "agent" || tab.target.kind === "terminal") {
entries.push({ entries.push({
kind: "item", kind: "item",
@@ -278,6 +297,7 @@ export function buildWorkspaceDesktopTabActions(
menuTestIDBase: contextMenuTestId, menuTestIDBase: contextMenuTestId,
onCopyResumeCommand: input.onCopyResumeCommand, onCopyResumeCommand: input.onCopyResumeCommand,
onCopyAgentId: input.onCopyAgentId, onCopyAgentId: input.onCopyAgentId,
onCopyFilePath: input.onCopyFilePath,
onReloadAgent: input.onReloadAgent, onReloadAgent: input.onReloadAgent,
onRenameTab: input.onRenameTab, onRenameTab: input.onRenameTab,
onCloseTab: input.onCloseTab, onCloseTab: input.onCloseTab,
@@ -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<AgentSession> {
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 () => { test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -196,6 +196,7 @@ export interface AgentManagerOptions {
durableTimelineStore?: AgentTimelineStore; durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
mcpBaseUrl?: string; mcpBaseUrl?: string;
mcpAuthToken?: string;
appendSystemPrompt?: string; appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number; agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts; rescueTimeouts?: AgentManagerRescueTimeouts;
@@ -482,6 +483,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>(); private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer; private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null; private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private appendSystemPrompt: string; private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback; private onAgentAttention?: AgentAttentionCallback;
private onAgentArchived?: AgentArchivedCallback; private onAgentArchived?: AgentArchivedCallback;
@@ -496,6 +498,7 @@ export class AgentManager {
this.onAgentAttention = options?.onAgentAttention; this.onAgentAttention = options?.onAgentAttention;
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged; this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.mcpAuthToken = options?.mcpAuthToken ?? null;
this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.appendSystemPrompt = options.appendSystemPrompt ?? "";
this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
this.rescueTimeouts = { this.rescueTimeouts = {
@@ -554,6 +557,16 @@ export class AgentManager {
this.mcpBaseUrl = url; 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 { setAppendSystemPrompt(prompt: string | null | undefined): void {
this.appendSystemPrompt = prompt ?? ""; this.appendSystemPrompt = prompt ?? "";
} }
@@ -3567,6 +3580,7 @@ export class AgentManager {
config: storedConfig, config: storedConfig,
agentId, agentId,
mcpBaseUrl: this.mcpBaseUrl, mcpBaseUrl: this.mcpBaseUrl,
mcpAuthToken: this.mcpAuthToken,
}), }),
); );
return { storedConfig, launchConfig }; return { storedConfig, launchConfig };
@@ -9,6 +9,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import pino from "pino"; import pino from "pino";
import { withTimeout } from "../../utils/promise-timeout.js"; import { withTimeout } from "../../utils/promise-timeout.js";
import { hashDaemonPassword } from "../auth.js";
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import type { import type {
@@ -78,8 +79,11 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
return null; return null;
} }
async function createMcpClient(url: string): Promise<McpClient> { async function createMcpClient(url: string, authToken?: string): Promise<McpClient> {
const transport = new StreamableHTTPClientTransport(new URL(url)); const transport = new StreamableHTTPClientTransport(
new URL(url),
authToken ? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } } : undefined,
);
const rawClient = await experimental_createMCPClient({ transport }); const rawClient = await experimental_createMCPClient({ transport });
const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient); const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient);
return { callTool: boundCallTool, close: () => rawClient.close() }; return { callTool: boundCallTool, close: () => rawClient.close() };
@@ -177,6 +181,76 @@ describe("agent MCP end-to-end (offline)", () => {
} }
}, 30_000); }, 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 () => { 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 paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -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();
});
});
@@ -30,6 +30,12 @@ export function withRuntimePaseoMcpServer(params: {
config: AgentSessionConfig; config: AgentSessionConfig;
agentId: string; agentId: string;
mcpBaseUrl: string | null; 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 { }): AgentSessionConfig {
const storedConfig = stripInternalPaseoMcpServer(params.config); const storedConfig = stripInternalPaseoMcpServer(params.config);
if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) { if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) {
@@ -42,6 +48,9 @@ export function withRuntimePaseoMcpServer(params: {
[PASEO_MCP_SERVER_NAME]: { [PASEO_MCP_SERVER_NAME]: {
type: "http", type: "http",
url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`, url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`,
...(params.mcpAuthToken
? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } }
: {}),
}, },
...storedConfig.mcpServers, ...storedConfig.mcpServers,
}, },
+55
View File
@@ -5,6 +5,7 @@ import {
extractWsBearerProtocol, extractWsBearerProtocol,
extractWsBearerToken, extractWsBearerToken,
hashDaemonPassword, hashDaemonPassword,
isAgentMcpRequestAuthorized,
isBearerTokenValidAsync, isBearerTokenValidAsync,
isBearerTokenValid, isBearerTokenValid,
shouldBypassBearerAuth, shouldBypassBearerAuth,
@@ -61,8 +62,62 @@ describe("daemon bearer validator", () => {
expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true);
// Guarded by its own single-use download token, not the daemon password. // Guarded by its own single-use download token, not the daemon password.
expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); 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. // Everything else stays behind the daemon password.
expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false);
expect(shouldBypassBearerAuth("POST", "/api/files/upload")).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);
});
});
+40
View File
@@ -1,4 +1,5 @@
import { compare, compareSync, hashSync } from "bcryptjs"; import { compare, compareSync, hashSync } from "bcryptjs";
import { timingSafeEqual } from "node:crypto";
import type { RequestHandler } from "express"; import type { RequestHandler } from "express";
export const DAEMON_PASSWORD_BCRYPT_COST = 12; 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 // rejects requests without a valid token (400/403), so dropping the bearer
// here does not make the route unauthenticated. // here does not make the route unauthenticated.
"/api/files/download", "/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 { 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); 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<boolean> {
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 });
}
+29 -1
View File
@@ -132,7 +132,11 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js"; import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.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<string, StreamableHTTPServerTransport>; type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
@@ -330,6 +334,15 @@ export async function createPaseoDaemon(
ttlMs: downloadTokenTtlMs, 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 listenTarget = parseListenString(config.listen);
const app = express(); const app = express();
@@ -555,6 +568,7 @@ export async function createPaseoDaemon(
onWorkspaceStateMayHaveChanged: ({ cwd }) => { onWorkspaceStateMayHaveChanged: ({ cwd }) => {
workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
}, },
mcpAuthToken: agentMcpAuthToken,
logger, logger,
}); });
@@ -805,6 +819,20 @@ export async function createPaseoDaemon(
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
): Promise<void> => { ): Promise<void> => {
// 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) { if (config.mcpDebug) {
logger.debug( logger.debug(
{ {
+7 -1
View File
@@ -1220,7 +1220,13 @@ export class Session {
); );
return; 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({ this.agentMcpClient = await experimental_createMCPClient({
transport, transport,