From 7250009ab7ee72142a08344b8a0b7a12af666e53 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 15:12:29 +0200 Subject: [PATCH] feat(app): copy terminal IDs from tab menus (#2371) --- .../e2e/workspace-terminal-tab-rename.spec.ts | 52 +++++++++++++++++-- .../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/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.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 | 24 ++++++++- .../screens/workspace/workspace-tab-menu.ts | 21 ++++++++ 14 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts index 78d36e6cd..6ee477f7c 100644 --- a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures"; +import { test, expect, type Page } from "./fixtures"; import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher"; import { renameModalInput, renameModalSubmit } from "./helpers/rename"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; @@ -36,7 +36,52 @@ async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise { + if (terminalId) { + await workspace.client.killTerminal(terminalId).catch(() => undefined); + } + await workspace.cleanup(); +} + +async function readClipboard(page: Page): Promise { + return page.evaluate(() => navigator.clipboard.readText()); +} + test.describe("Workspace terminal tab rename", () => { + test("right-click copy terminal id writes the terminal id to the clipboard", async ({ + context, + page, + }) => { + test.setTimeout(60_000); + + const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" }); + let terminalId: string | null = null; + + try { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewTerminal(page); + terminalId = await waitForCreatedTerminalId(workspace); + + const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); + await expect(tab).toBeVisible({ timeout: 15_000 }); + + await tab.click({ button: "right" }); + const copyTerminalId = page.getByTestId( + `workspace-tab-context-terminal_${terminalId}-copy-terminal-id`, + ); + await expect(copyTerminalId).toBeVisible({ timeout: 10_000 }); + await copyTerminalId.click(); + + await expect.poll(() => readClipboard(page)).toBe(terminalId); + } finally { + await cleanupTerminal(workspace, terminalId); + } + }); + test("right-click rename persists the terminal title and updates the tab label", async ({ page, }) => { @@ -74,10 +119,7 @@ test.describe("Workspace terminal tab rename", () => { .poll(() => fetchTerminalTitle(workspace, terminalId!)) .toBe("My Renamed Terminal"); } finally { - if (terminalId) { - await workspace.client.killTerminal(terminalId).catch(() => undefined); - } - await workspace.cleanup(); + await cleanupTerminal(workspace, terminalId); } }); }); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 59e684968..79e3df7a7 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -95,6 +95,7 @@ interface SplitContainerProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -373,6 +374,7 @@ export function SplitContainer({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -592,6 +594,7 @@ export function SplitContainer({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -738,6 +741,7 @@ function SplitNodeView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -795,6 +799,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -844,6 +849,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -899,6 +905,7 @@ function SplitPaneView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -1044,6 +1051,7 @@ function SplitPaneView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index bfff7e129..ee1ecbaba 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -492,6 +492,7 @@ export const ar: TranslationResources = { openFor: "فتح القائمة لـ{{label}}", copyResumeCommand: "نسخ أمر السيرة الذاتية", copyAgentId: "نسخ معرف الوكيل", + copyTerminalId: "نسخ معرف المحطة", copyFilePath: "Copy file path", rename: "إعادة تسمية", closeAbove: "إغلاق علامات التبويب أعلاه", @@ -529,6 +530,7 @@ export const ar: TranslationResources = { toasts: { copyFailed: "فشل النسخ", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "معرف المحطة", resumeCommandCopiedLabel: "أمر الاستئناف", filePathCopiedLabel: "File path", resumeIdUnavailable: "السيرة الذاتية ID غير متوفرة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b6fa7cd7a..63f6a4f7e 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -491,6 +491,7 @@ export const en = { openFor: "Open menu for {{label}}", copyResumeCommand: "Copy resume command", copyAgentId: "Copy agent id", + copyTerminalId: "Copy terminal id", copyFilePath: "Copy file path", rename: "Rename", closeAbove: "Close tabs above", @@ -528,6 +529,7 @@ export const en = { toasts: { copyFailed: "Copy failed", agentIdCopiedLabel: "Agent ID", + terminalIdCopiedLabel: "Terminal ID", resumeCommandCopiedLabel: "resume command", filePathCopiedLabel: "File path", resumeIdUnavailable: "Resume ID not available", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7372e60a3..fc4d6ea60 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -496,6 +496,7 @@ export const es: TranslationResources = { openFor: "Menú abierto para{{label}}", copyResumeCommand: "Copiar comando de reanudación", copyAgentId: "Copiar ID del agente", + copyTerminalId: "Copiar ID del terminal", copyFilePath: "Copy file path", rename: "Rebautizar", closeAbove: "Cerrar pestañas arriba", @@ -534,6 +535,7 @@ export const es: TranslationResources = { toasts: { copyFailed: "Copia fallida", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "ID del terminal", resumeCommandCopiedLabel: "reanudar el comando", filePathCopiedLabel: "File path", resumeIdUnavailable: "ReanudarIDno disponible", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 00dfc8245..7a69a1996 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -496,6 +496,7 @@ export const fr: TranslationResources = { openFor: "Ouvrir le menu pour{{label}}", copyResumeCommand: "Copier la commande de reprise", copyAgentId: "Copier l'identifiant de l'agent", + copyTerminalId: "Copier l'identifiant du terminal", copyFilePath: "Copy file path", rename: "Rebaptiser", closeAbove: "Fermer les onglets ci-dessus", @@ -534,6 +535,7 @@ export const fr: TranslationResources = { toasts: { copyFailed: "Échec de la copie", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "Identifiant du terminal", resumeCommandCopiedLabel: "reprendre la commande", filePathCopiedLabel: "File path", resumeIdUnavailable: "ReprendreIDnon disponible", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 391994a47..888d8864b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -496,6 +496,7 @@ export const ja: TranslationResources = { openFor: "{{label}}のメニューを開く", copyResumeCommand: "再開コマンドをコピー", copyAgentId: "エージェントIDをコピー", + copyTerminalId: "ターミナルIDをコピー", copyFilePath: "ファイルパスをコピー", rename: "名前を変更", closeAbove: "上のタブを閉じる", @@ -534,6 +535,7 @@ export const ja: TranslationResources = { toasts: { copyFailed: "コピーに失敗しました", agentIdCopiedLabel: "エージェントID", + terminalIdCopiedLabel: "ターミナルID", resumeCommandCopiedLabel: "再開コマンド", filePathCopiedLabel: "ファイルパス", resumeIdUnavailable: "再開IDが利用できません", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index dea623dcf..c1d6827e5 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -496,6 +496,7 @@ export const ptBR: TranslationResources = { openFor: "Abrir menu de {{label}}", copyResumeCommand: "Copiar comando de retomada", copyAgentId: "Copiar ID do agente", + copyTerminalId: "Copiar ID do terminal", copyFilePath: "Copiar caminho do arquivo", rename: "Renomear", closeAbove: "Fechar abas acima", @@ -533,6 +534,7 @@ export const ptBR: TranslationResources = { toasts: { copyFailed: "Falha ao copiar", agentIdCopiedLabel: "ID do agente", + terminalIdCopiedLabel: "ID do terminal", resumeCommandCopiedLabel: "comando de retomada", filePathCopiedLabel: "Caminho do arquivo", resumeIdUnavailable: "ID de retomada indisponível", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c799196fa..a46d91034 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -496,6 +496,7 @@ export const ru: TranslationResources = { openFor: "Открыть меню для{{label}}", copyResumeCommand: "Копировать команду возобновления", copyAgentId: "Скопировать идентификатор агента", + copyTerminalId: "Скопировать идентификатор терминала", copyFilePath: "Copy file path", rename: "Переименовать", closeAbove: "Закрыть вкладки выше", @@ -533,6 +534,7 @@ export const ru: TranslationResources = { toasts: { copyFailed: "Не удалось скопировать", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "Идентификатор терминала", resumeCommandCopiedLabel: "команда возобновления", filePathCopiedLabel: "File path", resumeIdUnavailable: "Резюме ID недоступно", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 5f805fc5c..63f4b9730 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -492,6 +492,7 @@ export const zhCN: TranslationResources = { openFor: "打开 {{label}} 的菜单", copyResumeCommand: "复制恢复命令", copyAgentId: "复制 Agent ID", + copyTerminalId: "复制 Terminal ID", copyFilePath: "Copy file path", rename: "重命名", closeAbove: "关闭上方标签", @@ -529,6 +530,7 @@ export const zhCN: TranslationResources = { toasts: { copyFailed: "复制失败", agentIdCopiedLabel: "Agent ID", + terminalIdCopiedLabel: "Terminal ID", resumeCommandCopiedLabel: "恢复命令", filePathCopiedLabel: "File path", resumeIdUnavailable: "恢复 ID 不可用", 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 5689f318d..7206b7f9e 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -419,6 +419,7 @@ interface WorkspaceDesktopTabsRowProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -764,6 +765,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -847,6 +849,7 @@ export function WorkspaceDesktopTabsRow({ () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), @@ -945,6 +948,7 @@ export function WorkspaceDesktopTabsRow({ normalizedWorkspaceId={normalizedWorkspaceId} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -976,6 +980,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onCopyResumeCommand, onNavigateTab, @@ -1097,6 +1102,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -1123,6 +1129,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -1149,6 +1156,7 @@ function ResolvedDesktopTabChip({ tabCount, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -1166,6 +1174,7 @@ function ResolvedDesktopTabChip({ onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onCopyResumeCommand, labels, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d3e1e0e87..58be94ce3 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -424,6 +424,7 @@ interface MobileWorkspaceTabSwitcherProps { onSelectSwitcherTab: (key: string) => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -612,6 +613,7 @@ function MobileWorkspaceTabOption({ onPress, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -630,6 +632,7 @@ function MobileWorkspaceTabOption({ onPress: () => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -643,6 +646,7 @@ function MobileWorkspaceTabOption({ () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), @@ -665,6 +669,7 @@ function MobileWorkspaceTabOption({ menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -733,6 +738,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onSelectSwitcherTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -789,6 +795,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onPress={onPress} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -807,6 +814,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -2772,6 +2780,19 @@ function WorkspaceScreenContent({ [toast, t], ); + const handleCopyTerminalId = useCallback( + async (terminalId: string) => { + if (!terminalId) return; + try { + await Clipboard.setStringAsync(terminalId); + toast.copied(t("workspace.tabs.toasts.terminalIdCopiedLabel")); + } catch { + toast.error(t("workspace.tabs.toasts.copyFailed")); + } + }, + [toast, t], + ); + const handleCopyFilePath = useCallback( async (path: string) => { if (!path) return; @@ -3645,6 +3666,7 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyTerminalId={handleCopyTerminalId} onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} @@ -3681,6 +3703,7 @@ function WorkspaceScreenContent({ handleCloseTabById, handleCopyResumeCommand, handleCopyAgentId, + handleCopyTerminalId, handleCopyFilePath, handleReloadAgent, handleRenameTab, @@ -3762,6 +3785,7 @@ function WorkspaceScreenContent({ onSelectSwitcherTab={handleSelectSwitcherTab} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyTerminalId={handleCopyTerminalId} onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} @@ -3784,6 +3808,7 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyTerminalId={handleCopyTerminalId} onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} 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 56de7207c..348419e36 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -34,6 +34,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId: vi.fn(), onCopyFilePath, onReloadAgent, onRenameTab, @@ -64,6 +65,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -99,6 +101,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-draft_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -127,6 +130,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -156,6 +160,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab, @@ -174,8 +179,9 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(onRenameTab).toHaveBeenCalledWith(tab); }); - it("includes rename as the first entry for terminal tabs", () => { + it("includes copy id and rename for terminal tabs", () => { const onRenameTab = vi.fn(); + const onCopyTerminalId = vi.fn(); const terminalTab: WorkspaceTabDescriptor = { key: "terminal_abc", tabId: "terminal_abc", @@ -190,6 +196,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-terminal_abc", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId, onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab, @@ -200,12 +207,22 @@ describe("buildWorkspaceTabMenuEntries", () => { }); const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label); - expect(labels[0]).toBe("Rename"); + expect(labels[0]).toBe("Copy terminal id"); + expect(labels[1]).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 copyTerminalIdEntry = entries.find( + (entry) => entry.kind === "item" && entry.key === "copy-terminal-id", + ); + if (!copyTerminalIdEntry || copyTerminalIdEntry.kind !== "item") { + throw new Error("Copy terminal id entry missing"); + } + copyTerminalIdEntry.onSelect(); + expect(onCopyTerminalId).toHaveBeenCalledWith("terminal-abc"); + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); if (!renameEntry || renameEntry.kind !== "item") { throw new Error("Rename entry missing"); @@ -230,6 +247,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-file_abc", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath, onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -272,6 +290,7 @@ describe("buildWorkspaceTabMenuEntries", () => { tabCount: 1, onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -302,6 +321,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase, onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: 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 4845b3160..d6e41e3b3 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; + copyTerminalId: string; copyFilePath: string; rename: string; closeAbove: string; @@ -23,6 +24,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"), + copyTerminalId: i18n.t("workspace.tabs.menu.copyTerminalId"), copyFilePath: i18n.t("workspace.tabs.menu.copyFilePath"), rename: i18n.t("workspace.tabs.menu.rename"), closeAbove: i18n.t("workspace.tabs.menu.closeAbove"), @@ -68,6 +70,7 @@ interface BuildWorkspaceTabMenuEntriesInput { menuTestIDBase: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -84,6 +87,7 @@ interface BuildWorkspaceDesktopTabActionsInput { tabCount: number; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -161,6 +165,7 @@ export function buildWorkspaceTabMenuEntries( menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -200,6 +205,21 @@ export function buildWorkspaceTabMenuEntries( }); } + if (tab.target.kind === "terminal") { + const { terminalId } = tab.target; + entries.push({ + kind: "item", + key: "copy-terminal-id", + label: labels.copyTerminalId, + icon: "copy", + hint: terminalId.slice(0, 7), + testID: `${menuTestIDBase}-copy-terminal-id`, + onSelect: () => { + void onCopyTerminalId(terminalId); + }, + }); + } + if (tab.target.kind === "file") { const filePath = tab.target.path; entries.push({ @@ -306,6 +326,7 @@ export function buildWorkspaceDesktopTabActions( menuTestIDBase: contextMenuTestId, onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, + onCopyTerminalId: input.onCopyTerminalId, onCopyFilePath: input.onCopyFilePath, onReloadAgent: input.onReloadAgent, onRenameTab: input.onRenameTab,