From 62ab2deb27bf258262edbb7d260724b1a738eec2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 17:26:44 +0700 Subject: [PATCH] Run and watch workspace scripts from the phone header menu (#7122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): run and watch workspace scripts from the compact header menu The compact header redesign dropped the mobile scripts button, so phones had no way to run, stop, or restart workspace scripts. Scripts are now a page of the ⋯ sheet: the root row shows what is running, tinted by health, and the ⋯ carries a dot while a running service is unhealthy. Desktop keeps its popover; both surfaces share one set of script actions and one status model. Script status also froze on native whenever the sidebar was closed and no agent tab was viewed. DirectorySynchronizer releases its subscriptions, including script_status_update, when nothing demands the directory, and the workspace screen never demanded the workspace it shows. Web hid this because the favicon sync holds demand for the whole session. The focused workspace now holds directory demand, which also lets Restart's second half (start once the daemon reports the script stopped) complete. * perf(app): keep workspace script status live without syncing the directory The focused workspace held full directory demand so the scripts menu saw script_status_update on phones with the sidebar closed and no agent tab. That fetched the host's agent and workspace lists on every focus and streamed every agent and workspace update for the whole host. Script status now has its own event subscription, which a workspace screen can hold alone. It fetches the workspace's scripts once when the subscription lands, because the subscription only carries changes. Directory demand also holds it, so the sidebar and agent tabs see the same updates as before. --- .../browser/compact-workspace-header.spec.ts | 96 ++++ ...compact-workspace-scripts-preconditions.md | 15 + ...ative-compact-workspace-scripts.android.ad | 45 ++ .../helpers/compact-workspace-scripts.ts | 61 +++ packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 4 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + .../src/runtime/directory-sync/index.test.ts | 55 +++ .../app/src/runtime/directory-sync/index.ts | 68 ++- packages/app/src/runtime/host-runtime.ts | 8 + .../src/screens/workspace/scripts/index.ts | 10 + .../workspace/scripts/script-marks.tsx | 78 ++++ .../workspace/scripts/script-status.test.ts | 89 ++++ .../workspace/scripts/script-status.ts | 63 +++ .../scripts/use-workspace-script-actions.ts | 191 ++++++++ .../workspace-scripts-button.test.tsx | 15 +- .../workspace-scripts-button.tsx | 333 ++------------ .../scripts/workspace-scripts-menu-pages.tsx | 416 ++++++++++++++++++ .../workspace/workspace-header-menu.tsx | 50 ++- .../screens/workspace/workspace-screen.tsx | 33 +- 26 files changed, 1345 insertions(+), 309 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md create mode 100644 packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad create mode 100644 packages/app/e2e/support/helpers/compact-workspace-scripts.ts create mode 100644 packages/app/src/screens/workspace/scripts/index.ts create mode 100644 packages/app/src/screens/workspace/scripts/script-marks.tsx create mode 100644 packages/app/src/screens/workspace/scripts/script-status.test.ts create mode 100644 packages/app/src/screens/workspace/scripts/script-status.ts create mode 100644 packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts rename packages/app/src/screens/workspace/{ => scripts}/workspace-scripts-button.test.tsx (96%) rename packages/app/src/screens/workspace/{ => scripts}/workspace-scripts-button.tsx (63%) create mode 100644 packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx diff --git a/packages/app/e2e/browser/compact-workspace-header.spec.ts b/packages/app/e2e/browser/compact-workspace-header.spec.ts index c245d2ae10..6033f147e8 100644 --- a/packages/app/e2e/browser/compact-workspace-header.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts @@ -3,6 +3,18 @@ import { test, expect } from "../support/fixtures"; import { seedWorkspace } from "../support/helpers/seed-client"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { getServerId } from "../support/helpers/server-id"; +import { expectOpenTabCount } from "../support/helpers/sheet"; +import { + closeCompactWorkspaceMenu, + goBackInMenu, + menuRow, + openCompactWorkspaceMenu, + openRunningScriptPage, + openScriptsPage, + runScriptFromScriptsPage, + scriptRow, + scriptsMenuRow, +} from "../support/helpers/compact-workspace-scripts"; const labels = { long: { @@ -94,3 +106,87 @@ for (const width of [320, 390]) { }); } } + +const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"'; +const WEB_SERVER = + "node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\""; + +async function openWorkspaceWithScripts(page: Page) { + const workspace = await seedWorkspace({ + repoPrefix: "compact-scripts-", + repo: { + paseoConfig: { + scripts: { + lint: { type: "script", command: KEEP_ALIVE }, + web: { type: "service", command: WEB_SERVER }, + }, + }, + }, + }); + try { + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Scripts menu check", + modeId: "load-test", + model: "e2e-fast-stream", + }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + return workspace; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const workspace = await openWorkspaceWithScripts(page); + try { + await expectOpenTabCount(page, 1); + + await test.step("nothing running: the Scripts row has no value", async () => { + await openCompactWorkspaceMenu(page); + await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts"); + await closeCompactWorkspaceMenu(page); + }); + + await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => { + await runScriptFromScriptsPage(page, "lint"); + await expectOpenTabCount(page, 2); + await openCompactWorkspaceMenu(page); + await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/); + await closeCompactWorkspaceMenu(page); + }); + + await test.step("two running scripts are counted on the root row", async () => { + await runScriptFromScriptsPage(page, "web"); + await expectOpenTabCount(page, 3); + await openCompactWorkspaceMenu(page); + await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/); + await closeCompactWorkspaceMenu(page); + }); + + await test.step("stopping a script keeps its page open in the stopped state", async () => { + await openRunningScriptPage(page, "web"); + await expect(menuRow(page, "View service")).toBeVisible(); + await menuRow(page, "Stop").click(); + await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 }); + await expect(menuRow(page, "Stop")).toHaveCount(0); + + await goBackInMenu(page); + await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/); + await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/); + await closeCompactWorkspaceMenu(page); + }); + + await test.step("the root row names the one script still running", async () => { + await openScriptsPage(page); + await goBackInMenu(page); + await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/); + }); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md new file mode 100644 index 0000000000..56dbd6355e --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md @@ -0,0 +1,15 @@ +# Native compact workspace scripts flow + +`native-compact-workspace-scripts.android.ad` covers live script status in the compact ⋯ → Scripts sheet when nothing else keeps the host directory live. Start on a phone with a workspace open, the sidebar closed, and no agent tab selected. A viewed agent tab or the open sidebar hides the failure, because either one keeps the directory subscribed. + +The workspace's `paseo.json` needs a long-running plain script, stopped before the run. `SCRIPT` defaults to `worker`: + +```json +{ "scripts": { "worker": { "command": "node -e \"setInterval(() => {}, 1000)\"" } } } +``` + +The flow starts the script, checks that Stop flips its page to Run, runs it again, and checks that Restart brings it back to running with no other interaction. It ends with the script stopped and the sheet dismissed, so it can be replayed. + +```sh +agent-device replay packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad --platform android --serial emulator-5590 --metro-port 8137 +``` diff --git a/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad new file mode 100644 index 0000000000..7a24b7ee8b --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad @@ -0,0 +1,45 @@ +context platform=android timeout=120000 retries=0 +env APP_ID=sh.paseo.debug +env SCRIPT=worker + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 45000 +wait stable 1000 15000 +press "id=\"workspace-header-menu-trigger\"" +wait "id=\"workspace-header-scripts\"" 10000 +press "id=\"workspace-header-scripts\"" +wait "id=\"workspace-scripts-row-${SCRIPT}\"" 10000 +press "id=\"workspace-scripts-row-${SCRIPT}\"" +wait stable 1000 15000 +wait "id=\"workspace-header-menu-trigger\"" 10000 +press "id=\"workspace-header-menu-trigger\"" +wait "id=\"workspace-header-scripts\"" 10000 +press "id=\"workspace-header-scripts\"" +wait "text" "Running" 10000 +press "id=\"workspace-scripts-row-${SCRIPT}\"" +wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000 +press "id=\"workspace-script-${SCRIPT}-stop\"" +wait "id=\"workspace-script-${SCRIPT}-run\"" 10000 +press "id=\"workspace-script-${SCRIPT}-run\"" +wait stable 1000 15000 +wait "id=\"workspace-header-menu-trigger\"" 10000 +press "id=\"workspace-header-menu-trigger\"" +wait "id=\"workspace-header-scripts\"" 10000 +press "id=\"workspace-header-scripts\"" +wait "text" "Running" 10000 +press "id=\"workspace-scripts-row-${SCRIPT}\"" +wait "id=\"workspace-script-${SCRIPT}-restart\"" 10000 +press "id=\"workspace-script-${SCRIPT}-restart\"" +wait stable 1000 15000 +wait "id=\"workspace-header-menu-trigger\"" 10000 +press "id=\"workspace-header-menu-trigger\"" +wait "id=\"workspace-header-scripts\"" 10000 +press "id=\"workspace-header-scripts\"" +wait "text" "Running" 15000 +press "id=\"workspace-scripts-row-${SCRIPT}\"" +wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000 +press "id=\"workspace-script-${SCRIPT}-stop\"" +wait "id=\"workspace-script-${SCRIPT}-run\"" 10000 +back +wait stable 1000 15000 +close diff --git a/packages/app/e2e/support/helpers/compact-workspace-scripts.ts b/packages/app/e2e/support/helpers/compact-workspace-scripts.ts new file mode 100644 index 0000000000..b01a527bc6 --- /dev/null +++ b/packages/app/e2e/support/helpers/compact-workspace-scripts.ts @@ -0,0 +1,61 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** The compact ⋯ sheet. Its trigger shares "Workspace actions" with the sidebar row kebabs. */ +export function compactWorkspaceMenu(page: Page): Locator { + return page.getByTestId("workspace-header-menu-content"); +} + +export async function openCompactWorkspaceMenu(page: Page): Promise { + await page.getByTestId("workspace-header-menu-trigger").click(); + await expect(compactWorkspaceMenu(page)).toBeVisible({ timeout: 10_000 }); +} + +export function menuRow(page: Page, name: string | RegExp): Locator { + return compactWorkspaceMenu(page).getByRole("menuitem", { name }); +} + +/** The root row that leads to the Scripts page; its accessible name carries its value. */ +export function scriptsMenuRow(page: Page): Locator { + return menuRow(page, /^Scripts/); +} + +/** A row on the Scripts page. A running script's name is followed by "Running". */ +export function scriptRow(page: Page, scriptName: string): Locator { + return menuRow(page, new RegExp(`^${scriptName}`)); +} + +export async function openScriptsPage(page: Page): Promise { + await openCompactWorkspaceMenu(page); + await scriptsMenuRow(page).click(); + await expect(scriptsMenuRow(page)).toHaveCount(0); + await expect(backButton(page)).toBeVisible(); +} + +export async function openRunningScriptPage(page: Page, scriptName: string): Promise { + await openScriptsPage(page); + await scriptRow(page, scriptName).click(); + await expect(menuRow(page, "Stop")).toBeVisible(); +} + +/** Taps a stopped script on the Scripts page, which runs it and closes the sheet. */ +export async function runScriptFromScriptsPage(page: Page, scriptName: string): Promise { + await openScriptsPage(page); + await scriptRow(page, scriptName).click(); + await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 }); +} + +export async function goBackInMenu(page: Page): Promise { + await backButton(page).click(); +} + +export async function closeCompactWorkspaceMenu(page: Page): Promise { + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .first() + .click({ position: { x: 12, y: 12 } }); + await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 }); +} + +function backButton(page: Page): Locator { + return compactWorkspaceMenu(page).getByRole("button", { name: "Back" }); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 7434471ec6..5e09ac5957 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -729,6 +729,7 @@ export const ar: TranslationResources = { header: { actions: { workspaceActions: "إجراءات Workspace", + workspaceActionsUnhealthy: "إجراءات Workspace، توجد خدمة غير سليمة", newAgent: "وكيل جديد", newTerminal: "محطة جديدة", newBrowser: "علامة تبويب متصفح جديدة", @@ -776,6 +777,8 @@ export const ar: TranslationResources = { exitCode: "الخروج من{{code}}", startFailed: "فشل بدء تشغيل{{scriptName}}", stopFailed: "فشل إيقاف{{scriptName}}", + running: "قيد التشغيل", + runningCount: "{{count}} قيد التشغيل", }, }, tree: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 97ced3f535..8c6a870580 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -724,6 +724,7 @@ export const en = { header: { actions: { workspaceActions: "Workspace actions", + workspaceActionsUnhealthy: "Workspace actions, a service is unhealthy", newAgent: "New agent", newTerminal: "New terminal", newBrowser: "New browser tab", @@ -771,6 +772,8 @@ export const en = { exitCode: "exit {{code}}", startFailed: "Failed to start {{scriptName}}", stopFailed: "Failed to stop {{scriptName}}", + running: "Running", + runningCount: "{{count}} running", }, }, tree: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 124e931497..62667ba9f9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -736,6 +736,7 @@ export const es: TranslationResources = { header: { actions: { workspaceActions: "AccionesWorkspace", + workspaceActionsUnhealthy: "Acciones del workspace, hay un servicio con problemas", newAgent: "Nuevo agente", newTerminal: "Nueva terminal", newBrowser: "Nueva pestaña del navegador", @@ -784,6 +785,8 @@ export const es: TranslationResources = { exitCode: "salir de{{code}}", startFailed: "No se pudo iniciar{{scriptName}}", stopFailed: "No se pudo detener{{scriptName}}", + running: "En ejecución", + runningCount: "{{count}} en ejecución", }, }, tree: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8a8e01786d..8e70ece6da 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -735,6 +735,8 @@ export const fr: TranslationResources = { header: { actions: { workspaceActions: "Actions de l’espace de travail", + workspaceActionsUnhealthy: + "Actions de l’espace de travail, un service est en mauvaise santé", newAgent: "Nouvel agent", newTerminal: "Nouveau terminal", newBrowser: "Nouvel onglet de navigateur", @@ -783,6 +785,8 @@ export const fr: TranslationResources = { exitCode: "sortie {{code}}", startFailed: "Impossible de démarrer {{scriptName}}", stopFailed: "Impossible d’arrêter {{scriptName}}", + running: "En cours", + runningCount: "{{count}} en cours", }, }, tree: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9f6d4eced3..d7b08a9c84 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -733,6 +733,7 @@ export const ja: TranslationResources = { header: { actions: { workspaceActions: "ワークスペースアクション", + workspaceActionsUnhealthy: "ワークスペースアクション、異常なサービスがあります", newAgent: "新しいエージェント", newTerminal: "新しいターミナル", newBrowser: "新しいブラウザタブ", @@ -780,6 +781,8 @@ export const ja: TranslationResources = { exitCode: "終了コード: {{code}}", startFailed: "{{scriptName}}の起動に失敗しました", stopFailed: "{{scriptName}}の停止に失敗しました", + running: "実行中", + runningCount: "{{count}} 件実行中", }, }, tree: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a78d44f74b..c82240f0c4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -729,6 +729,7 @@ export const ko: TranslationResources = { header: { actions: { workspaceActions: "워크스페이스 작업", + workspaceActionsUnhealthy: "워크스페이스 작업, 비정상 서비스가 있습니다", newAgent: "새 에이전트", newTerminal: "새 터미널", newBrowser: "새 브라우저 탭", @@ -776,6 +777,8 @@ export const ko: TranslationResources = { exitCode: "종료 {{code}}", startFailed: "{{scriptName}}을(를) 시작하지 못했습니다", stopFailed: "{{scriptName}}를 중지하지 못했습니다.", + running: "실행 중", + runningCount: "{{count}}개 실행 중", }, }, tree: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e5a2e3bfe4..622c13e798 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -733,6 +733,7 @@ export const ptBR: TranslationResources = { header: { actions: { workspaceActions: "Ações do workspace", + workspaceActionsUnhealthy: "Ações do workspace, há um serviço com problema", newAgent: "Novo agente", newTerminal: "Novo terminal", newBrowser: "Nova aba de navegador", @@ -780,6 +781,8 @@ export const ptBR: TranslationResources = { exitCode: "saída {{code}}", startFailed: "Falha ao iniciar {{scriptName}}", stopFailed: "Falha ao parar {{scriptName}}", + running: "Em execução", + runningCount: "{{count}} em execução", }, }, tree: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ad0bac05c6..733596de39 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -736,6 +736,7 @@ export const ru: TranslationResources = { header: { actions: { workspaceActions: "Действия с рабочим пространством", + workspaceActionsUnhealthy: "Действия с рабочим пространством, есть неисправный сервис", newAgent: "Новый агент", newTerminal: "Новый терминал", newBrowser: "Новая вкладка браузера", @@ -784,6 +785,8 @@ export const ru: TranslationResources = { exitCode: "Код выхода: {{code}}", startFailed: "Не удалось запустить скрипт {{scriptName}}", stopFailed: "Не удалось остановить скрипт {{scriptName}}", + running: "Запущен", + runningCount: "Запущено: {{count}}", }, }, tree: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7b78bfbea6..c8627ca769 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -725,6 +725,7 @@ export const zhCN: TranslationResources = { header: { actions: { workspaceActions: "Workspace 操作", + workspaceActionsUnhealthy: "Workspace 操作,有服务异常", newAgent: "新建 Agent", newTerminal: "新建 Terminal", newBrowser: "新建浏览器标签", @@ -772,6 +773,8 @@ export const zhCN: TranslationResources = { exitCode: "exit {{code}}", startFailed: "启动 {{scriptName}} 失败", stopFailed: "停止 {{scriptName}} 失败", + running: "运行中", + runningCount: "{{count}} 个运行中", }, }, tree: { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 5a247590d5..2dea552239 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -31,6 +31,7 @@ import { subscriptionFixture } from "../subscription-fixture"; type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; +type WorkspaceScriptsResult = Awaited>; class FakeDirectoryClient { supportsBackgroundWorkspaces(): boolean { @@ -181,6 +182,14 @@ class FakeDirectoryClient { }; } + listWorkspaceScriptsCalls = 0; + workspaceScripts: WorkspaceScriptsResult["scripts"] = []; + + async listWorkspaceScripts(workspaceId: string): Promise { + this.listWorkspaceScriptsCalls += 1; + return { requestId: "scripts", workspaceId, scripts: this.workspaceScripts, error: null }; + } + getLastServerInfoMessage() { return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null; } @@ -482,6 +491,52 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("keeps a workspace's script status live without demanding the directory", async () => { + const serverId = "script-status-demand"; + const { client, directory } = createDirectory(serverId); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + const workspace = normalizeWorkspaceDescriptor({ + ...createWorkspaceEntry("workspace-1"), + scripts: [], + }); + directory.acceptWorkspaces([workspace]); + const script = { + scriptName: "worker", + type: "script" as const, + hostname: "worker.paseo.localhost", + port: null, + proxyUrl: null, + lifecycle: "running" as const, + health: null, + exitCode: null, + terminalId: "terminal-1", + }; + const lifecycle = () => + useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] + ?.lifecycle; + client.workspaceScripts = [script]; + const source = {}; + + directory.setScriptStatusDemand(source, workspace.id); + + await expect.poll(lifecycle).toBe("running"); + client.emit({ + type: "script_status_update", + payload: { workspaceId: workspace.id, scripts: [{ ...script, lifecycle: "stopped" }] }, + }); + expect(lifecycle()).toBe("stopped"); + expect(client.fetchAgentsCalls).toBe(0); + expect(client.fetchWorkspacesCalls).toBe(0); + + directory.setScriptStatusDemand(source, null); + client.emit({ + type: "script_status_update", + payload: { workspaceId: workspace.id, scripts: [script] }, + }); + expect(lifecycle()).toBe("stopped"); + directory.dispose(); + }); + it("subscribes to live agent updates when the directory is demanded", async () => { const serverId = "demanded-directory-subscription"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 6589914825..28b217fea6 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -150,6 +150,7 @@ export class DirectorySync { private agentSubscription: OwnedSubscription | null = null; private workspaceSubscription: OwnedSubscription | null = null; private eventSubscription: ReturnType | null = null; + private scriptStatusSubscription: ReturnType | null = null; private readonly abortSessionWaits = new Set<() => void>(); private cacheLoad: Promise | null = null; private cacheAccepted = false; @@ -157,6 +158,7 @@ export class DirectorySync { private workspaceRevision = 0; private readonly routeDemandIds = new Set(); private readonly fullDemandSources = new Set(); + private readonly scriptStatusDemand = new Map(); private demandRefresh: Promise | null = null; private satisfiedDemandSource: DirectorySourceToken | null = null; private cursors: DirectoryCheckpoint = {}; @@ -189,10 +191,12 @@ export class DirectorySync { } this.flushAbortedTransactions(); this.releaseSubscriptions(); + this.releaseScriptStatusEvents(); workspaceLabels.disconnect(this.serverId); this.connection = connection; this.abortPendingSessionWaits(); if (!connection.client || connection.status !== "online") return true; + if (this.scriptStatusDemand.size > 0) this.observeScriptStatusEvents(); // Reattach labels here because route-only demand can satisfy the epoch before full demand requests them. void this.connectWorkspaceLabels().catch(() => undefined); if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); @@ -226,10 +230,32 @@ export class DirectorySync { } } + /** + * Keeps one workspace's script status live without demanding the directory: a + * script_status_update subscription, plus one scripts fetch to cover what changed before it. + */ + setScriptStatusDemand(source: object, workspaceId: string | null): void { + if (!workspaceId) { + this.scriptStatusDemand.delete(source); + if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents(); + return; + } + this.scriptStatusDemand.set(source, workspaceId); + if (!this.getOnlineConnection()) return; + // A new subscription fetches every demanded workspace once its snapshot lands. + if (!this.scriptStatusSubscription) this.observeScriptStatusEvents(); + else + void this.scriptStatusSubscription.ready + .then(() => this.fetchScriptStatus(workspaceId)) + .catch(() => undefined); + } + dispose(): void { this.flushAbortedTransactions(); this.abortPendingSessionWaits(); + this.scriptStatusDemand.clear(); this.releaseSubscriptions(); + this.releaseScriptStatusEvents(); this.fullDemandSources.clear(); this.routeDemandIds.clear(); workspaceLabels.disconnect(this.serverId); @@ -247,6 +273,12 @@ export class DirectorySync { this.workspaceSubscription = null; this.eventSubscription = null; this.satisfiedDemandSource = null; + if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents(); + } + + private releaseScriptStatusEvents(): void { + void this.scriptStatusSubscription?.release().catch(() => undefined); + this.scriptStatusSubscription = null; } private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void { @@ -276,22 +308,51 @@ export class DirectorySync { private observeDirectoryEvents(): void { if (this.eventSubscription) return; const { client, source } = this.requireOnline(); - this.eventSubscription = client.observeEvents(["project.update", "script_status_update"]); + this.eventSubscription = client.observeEvents(["project.update"]); this.eventSubscription.subscribe({ snapshot: () => {}, update: (message) => { if (!this.isCurrent(client, source)) return; if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload); - if (message.type === "script_status_update") - this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); }, }); } + // Script status has its own subscription so a workspace screen can hold it without the directory. + private observeScriptStatusEvents(): void { + if (this.scriptStatusSubscription) return; + const { client, source } = this.requireOnline(); + this.scriptStatusSubscription = client.observeEvents(["script_status_update"]); + this.scriptStatusSubscription.subscribe({ + snapshot: () => { + for (const workspaceId of new Set(this.scriptStatusDemand.values())) + void this.fetchScriptStatus(workspaceId).catch(() => undefined); + }, + update: (message) => { + if (!this.isCurrent(client, source) || message.type !== "script_status_update") return; + this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); + }, + }); + } + + private async fetchScriptStatus(workspaceId: string): Promise { + const { client, source } = this.requireOnline(); + const result = await client.listWorkspaceScripts(workspaceId); + if (!this.isCurrent(client, source) || result.error || !result.scripts) return; + this.receiveWorkspaceDelta(source, { + kind: "script_status", + update: { workspaceId, scripts: result.scripts }, + }); + } + private hasDemand(): boolean { return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0; } + private wantsScriptStatus(): boolean { + return this.hasDemand() || this.scriptStatusDemand.size > 0; + } + private requestDemandRefresh(force = false): Promise { if (this.demandRefresh) return this.demandRefresh; if (!this.hasDemand()) return Promise.resolve(); @@ -299,6 +360,7 @@ export class DirectorySync { return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); } this.observeDirectoryEvents(); + this.observeScriptStatusEvents(); const source = this.connection.source; if ( !force && diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 2ecbe60448..55298ce5a1 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2606,6 +2606,14 @@ export class HostRuntimeStore { return () => directory.setDemand(source, false); } + acquireScriptStatusDemand(serverId: string, workspaceId: string): () => void { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) return () => undefined; + const source = {}; + directory.setScriptStatusDemand(source, workspaceId); + return () => directory.setScriptStatusDemand(source, null); + } + async prepareAgentRoute(serverId: string, agentId: string): Promise { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); diff --git a/packages/app/src/screens/workspace/scripts/index.ts b/packages/app/src/screens/workspace/scripts/index.ts new file mode 100644 index 0000000000..efff482e80 --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/index.ts @@ -0,0 +1,10 @@ +/** + * Workspace scripts: the desktop header popover, and the pages the compact header menu carries. + * Both share one set of actions and one reading of script status. + */ +export { WorkspaceScriptsButton } from "./workspace-scripts-button"; +export { + useWorkspaceScriptsMenuPages, + type UseWorkspaceScriptsMenuPagesInput, + type WorkspaceScriptsMenuPages, +} from "./workspace-scripts-menu-pages"; diff --git a/packages/app/src/screens/workspace/scripts/script-marks.tsx b/packages/app/src/screens/workspace/scripts/script-marks.tsx new file mode 100644 index 0000000000..ae4f9a9edb --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/script-marks.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; +import { Globe, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links"; +import { scriptStatus, scriptToneColorMapping } from "./script-status"; + +const ThemedGlobe = withUnistyles(Globe); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); + +/** A globe for a service, a terminal for a plain script, in the colour of what it is doing. */ +export function ScriptIcon({ + script, + size, +}: { + script: WorkspaceDescriptor["scripts"][number]; + size: number; +}): ReactElement { + const Icon = script.type === "service" ? ThemedGlobe : ThemedSquareTerminal; + return ( + + ); +} + +export function ExitCodeBadge({ code }: { code: number }): ReactElement { + const { t } = useTranslation(); + const exitTextStyle = + code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError]; + return ( + + {t("workspace.scripts.states.exitCode", { code })} + + ); +} + +/** The label of a URL route, as the user picks between them. */ +export function routeLabelKey( + kind: WorkspaceScriptLinkKind, +): + | "workspace.scripts.routes.public" + | "workspace.scripts.routes.paseo" + | "workspace.scripts.routes.direct" { + switch (kind) { + case "public": + return "workspace.scripts.routes.public"; + case "paseo": + return "workspace.scripts.routes.paseo"; + case "direct": + return "workspace.scripts.routes.direct"; + } +} + +const styles = StyleSheet.create((theme) => ({ + scriptIcon: { + flexShrink: 0, + }, + exitBadge: { + paddingHorizontal: theme.spacing[1.5], + paddingVertical: 1, + borderRadius: 2, + backgroundColor: theme.colors.surface2, + }, + exitBadgeText: { + fontSize: 10, + lineHeight: 12, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + exitBadgeTextError: { + color: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/screens/workspace/scripts/script-status.test.ts b/packages/app/src/screens/workspace/scripts/script-status.test.ts new file mode 100644 index 0000000000..7ca5dc81b3 --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/script-status.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; +import { scriptStatus, summarizeScripts } from "./script-status"; + +function service( + scriptName: string, + state: Pick, +): WorkspaceScriptPayload { + return { ...script(scriptName, state.lifecycle), type: "service", health: state.health }; +} + +function script( + scriptName: string, + lifecycle: WorkspaceScriptPayload["lifecycle"], +): WorkspaceScriptPayload { + return { + scriptName, + type: "script", + hostname: scriptName, + port: null, + proxyUrl: null, + lifecycle, + health: null, + exitCode: null, + terminalId: null, + }; +} + +describe("scriptStatus", () => { + it("colours services by their health and plain scripts by whether they run", () => { + expect(scriptStatus(script("lint", "stopped"))).toBe("idle"); + expect(scriptStatus(script("lint", "running"))).toBe("running"); + expect(scriptStatus(service("web", { lifecycle: "stopped", health: "unhealthy" }))).toBe( + "idle", + ); + expect(scriptStatus(service("web", { lifecycle: "running", health: null }))).toBe("running"); + expect(scriptStatus(service("web", { lifecycle: "running", health: "healthy" }))).toBe( + "healthy", + ); + expect(scriptStatus(service("web", { lifecycle: "running", health: "unhealthy" }))).toBe( + "unhealthy", + ); + }); +}); + +describe("summarizeScripts", () => { + it("is idle with nothing running when nothing runs", () => { + expect(summarizeScripts([])).toEqual({ tone: "idle", running: [] }); + expect(summarizeScripts([script("lint", "stopped")])).toEqual({ tone: "idle", running: [] }); + }); + + it("lists the running scripts by name", () => { + expect( + summarizeScripts([ + script("lint", "stopped"), + service("web", { lifecycle: "running", health: null }), + ]), + ).toEqual({ tone: "running", running: ["web"] }); + }); + + it("is unhealthy when any running service is", () => { + expect( + summarizeScripts([ + service("web", { lifecycle: "running", health: "healthy" }), + service("api", { lifecycle: "running", health: "unhealthy" }), + ]), + ).toEqual({ tone: "unhealthy", running: ["web", "api"] }); + }); + + it("is healthy only when every running service reports healthy", () => { + expect( + summarizeScripts([ + script("lint", "running"), + service("web", { lifecycle: "running", health: "healthy" }), + service("api", { lifecycle: "stopped", health: "unhealthy" }), + ]), + ).toEqual({ tone: "healthy", running: ["lint", "web"] }); + expect( + summarizeScripts([ + service("web", { lifecycle: "running", health: "healthy" }), + service("api", { lifecycle: "running", health: null }), + ]), + ).toEqual({ tone: "running", running: ["web", "api"] }); + expect(summarizeScripts([script("lint", "running")])).toEqual({ + tone: "running", + running: ["lint"], + }); + }); +}); diff --git a/packages/app/src/screens/workspace/scripts/script-status.ts b/packages/app/src/screens/workspace/scripts/script-status.ts new file mode 100644 index 0000000000..ff7398dda4 --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/script-status.ts @@ -0,0 +1,63 @@ +import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; +import type { Theme } from "@/styles/theme"; + +/** + * What a script, or a set of them, is doing, as the one colour its ▶ or row icon is drawn in. + * + * Health is a service's own report, so it only colours running services. A plain script that is + * running is just running. + */ +export type ScriptTone = "idle" | "running" | "healthy" | "unhealthy"; + +export interface ScriptsSummary { + tone: ScriptTone; + /** Names of the running scripts, in the order the workspace lists them. */ + running: string[]; +} + +export function scriptStatus(script: WorkspaceScriptPayload): ScriptTone { + if (script.lifecycle !== "running") return "idle"; + if (script.type !== "service") return "running"; + if (script.health === "healthy") return "healthy"; + if (script.health === "unhealthy") return "unhealthy"; + return "running"; +} + +/** + * One unhealthy service makes the whole set unhealthy. The set reads healthy only when every + * running service says so; a service that has not reported yet keeps it at plain running. + */ +export function summarizeScripts(scripts: readonly WorkspaceScriptPayload[]): ScriptsSummary { + const running = scripts.filter((script) => script.lifecycle === "running"); + if (running.length === 0) return { tone: "idle", running: [] }; + return { tone: aggregateTone(running), running: running.map((script) => script.scriptName) }; +} + +function aggregateTone(running: readonly WorkspaceScriptPayload[]): ScriptTone { + const serviceTones = running + .filter((script) => script.type === "service") + .map((script) => scriptStatus(script)); + if (serviceTones.includes("unhealthy")) return "unhealthy"; + if (serviceTones.length > 0 && serviceTones.every((tone) => tone === "healthy")) { + return "healthy"; + } + return "running"; +} + +const idleColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const runningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); +const healthyColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); +const unhealthyColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); + +export function scriptToneColorMapping(tone: ScriptTone): (theme: Theme) => { color: string } { + switch (tone) { + case "idle": + return idleColorMapping; + case "running": + return runningColorMapping; + case "healthy": + return healthyColorMapping; + case "unhealthy": + return unhealthyColorMapping; + } +} diff --git a/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts new file mode 100644 index 0000000000..fb7809990f --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import * as Clipboard from "expo-clipboard"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { useToast } from "@/contexts/toast-context"; +import { + resolveWorkspaceScriptLink, + type WorkspaceScriptLinkKind, + type WorkspaceScriptLinkTarget, +} from "@/utils/workspace-script-links"; +import { useWorkspaceServiceRoutePreferencesStore } from "@/workspace-service-routes/store"; + +type WorkspaceScript = WorkspaceDescriptor["scripts"][number]; + +export interface WorkspaceScriptLinks { + /** The URL this host's route preference picks, or null when the script serves none. */ + selected: WorkspaceScriptLinkTarget | null; + targets: WorkspaceScriptLinkTarget[]; +} + +export interface WorkspaceScriptActions { + start: (scriptName: string) => void; + stop: (scriptName: string) => void; + /** Stops the script, then starts it again once the daemon reports it stopped. */ + restart: (scriptName: string) => void; + copyUrl: (target: WorkspaceScriptLinkTarget) => void; + selectRoute: (kind: WorkspaceScriptLinkKind) => void; + resolveLinks: (script: WorkspaceScript) => WorkspaceScriptLinks; + isStartPending: boolean; + isStopPending: boolean; +} + +export interface UseWorkspaceScriptActionsInput { + serverId: string; + workspaceId: string; + scripts: WorkspaceDescriptor["scripts"]; + onScriptTerminalStarted?: (terminalId: string) => void; +} + +/** + * Run, stop, restart and URL handling for a workspace's scripts, shared by every surface that + * lists them. + * + * A restart outlives the press that asked for it — the second half waits for the daemon to + * report the script stopped — so mount this with whatever owns the menu, not inside a menu page + * that unmounts when the menu closes. + */ +export function useWorkspaceScriptActions({ + serverId, + workspaceId, + scripts, + onScriptTerminalStarted, +}: UseWorkspaceScriptActionsInput): WorkspaceScriptActions { + const { t } = useTranslation(); + const toast = useToast(); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; + const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( + (state) => state.byServerId[serverId] ?? null, + ); + const setPreferredRoute = useWorkspaceServiceRoutePreferencesStore( + (state) => state.setPreferredRoute, + ); + const pendingRestartRef = useRef>(new Set()); + + const startScriptMutation = useMutation({ + mutationFn: async (scriptName: string) => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const result = await client.startWorkspaceScript(workspaceId, scriptName); + if (result.error) { + throw new Error(result.error); + } + return result; + }, + onError: (error, scriptName) => { + toast.show( + error instanceof Error + ? error.message + : t("workspace.scripts.states.startFailed", { scriptName }), + { + variant: "error", + }, + ); + }, + onSuccess: (result) => { + if (result.terminalId) { + onScriptTerminalStarted?.(result.terminalId); + } + }, + }); + const startScript = startScriptMutation.mutate; + + const stopScriptMutation = useMutation({ + mutationFn: async (scriptName: string) => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const terminalId = scripts.find((s) => s.scriptName === scriptName)?.terminalId; + if (!terminalId) { + throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); + } + const result = await client.killTerminal(terminalId); + if (!result.success) { + throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); + } + }, + onError: (error, scriptName) => { + pendingRestartRef.current.delete(scriptName); + toast.show( + error instanceof Error + ? error.message + : t("workspace.scripts.states.stopFailed", { scriptName }), + { + variant: "error", + }, + ); + }, + }); + const stopScript = stopScriptMutation.mutate; + + // Restart = kill the script terminal, then start again once the daemon + // reports the script as stopped (it tears the runtime entry down on exit). + useEffect(() => { + const pending = pendingRestartRef.current; + if (pending.size === 0) return; + for (const script of scripts) { + if (!pending.has(script.scriptName) || script.lifecycle === "running") continue; + pending.delete(script.scriptName); + startScript(script.scriptName); + } + }, [scripts, startScript]); + + const restart = useCallback( + (scriptName: string) => { + pendingRestartRef.current.add(scriptName); + stopScript(scriptName); + }, + [stopScript], + ); + + const copyUrl = useCallback( + (target: WorkspaceScriptLinkTarget) => { + void Clipboard.setStringAsync(target.url); + toast.copied(target.label); + }, + [toast], + ); + + const selectRoute = useCallback( + (kind: WorkspaceScriptLinkKind) => setPreferredRoute(serverId, kind), + [serverId, setPreferredRoute], + ); + + const resolveLinks = useCallback( + (script: WorkspaceScript): WorkspaceScriptLinks => { + const link = resolveWorkspaceScriptLink({ script, activeConnection }); + const selected = + link.targets.find((target) => target.kind === preferredRouteKind) ?? link.primary; + return { selected, targets: link.targets }; + }, + [activeConnection, preferredRouteKind], + ); + + return useMemo( + () => ({ + start: startScript, + stop: stopScript, + restart, + copyUrl, + selectRoute, + resolveLinks, + isStartPending: startScriptMutation.isPending, + isStopPending: stopScriptMutation.isPending, + }), + [ + startScript, + stopScript, + restart, + copyUrl, + selectRoute, + resolveLinks, + startScriptMutation.isPending, + stopScriptMutation.isPending, + ], + ); +} diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx similarity index 96% rename from packages/app/src/screens/workspace/workspace-scripts-button.test.tsx rename to packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx index 82e0132cd3..290f424e97 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx @@ -8,7 +8,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; -import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; +import { WorkspaceScriptsButton } from "./workspace-scripts-button"; void testI18n; @@ -235,7 +235,6 @@ const LIVE_TERMINAL_IDS: string[] = ["terminal-script-1"]; interface RenderScriptsOptions { hideLabels?: boolean; - presentation?: "split" | "ghost"; } function renderScripts( @@ -264,7 +263,6 @@ function renderScripts( scripts={nextScripts} liveTerminalIds={LIVE_TERMINAL_IDS} hideLabels={options.hideLabels} - presentation={options.presentation} /> ); @@ -431,17 +429,6 @@ describe("WorkspaceScriptsButton", () => { ); }); - it("removes the trigger caret in ghost presentation", () => { - current = renderScripts([script({ scriptName: "dev" })], { - hideLabels: true, - presentation: "ghost", - }); - - const trigger = document.querySelector('[data-testid="workspace-scripts-button"]'); - expect(trigger?.querySelector('[data-icon="Play"]')?.getAttribute("data-size")).toBe("16"); - expect(trigger?.querySelector('[data-icon="ChevronDown"]')).toBeNull(); - }); - it("keeps the trigger caret in split presentation", () => { current = renderScripts([script({ scriptName: "dev" })]); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx similarity index 63% rename from packages/app/src/screens/workspace/workspace-scripts-button.tsx rename to packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx index 59ddd9a0fb..3a1c472518 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx @@ -1,13 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, type ReactElement } from "react"; +import { useCallback, useMemo, type ReactElement } from "react"; import type { GestureResponderEvent } from "react-native"; import { Pressable, Text, View } from "react-native"; -import * as Clipboard from "expo-clipboard"; -import { useMutation } from "@tanstack/react-query"; import { ChevronDown, Copy, Eye, - Globe, Play, RotateCw, Square, @@ -16,8 +13,6 @@ import { import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, @@ -27,17 +22,20 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { MENU_ITEM_HEIGHT } from "@/components/ui/menu/menu-geometry"; -import { useToast } from "@/contexts/toast-context"; import { openServiceUrl } from "@/utils/open-service-url"; -import { - resolveWorkspaceScriptLink, - type WorkspaceScriptLinkKind, - type WorkspaceScriptLinkTarget, +import type { + WorkspaceScriptLinkKind, + WorkspaceScriptLinkTarget, } from "@/utils/workspace-script-links"; import type { Theme } from "@/styles/theme"; -import { useWorkspaceServiceRoutePreferencesStore } from "@/workspace-service-routes/store"; import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; import { extraMutedIconColorMapping } from "@/components/ui/icon-color"; +import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks"; +import { scriptToneColorMapping } from "./script-status"; +import { + useWorkspaceScriptActions, + type WorkspaceScriptActions, +} from "./use-workspace-script-actions"; type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal"; @@ -50,37 +48,23 @@ interface WorkspaceScriptsButtonProps { onViewTerminal?: (terminalId: string) => void; onOpenUrlInBrowserTab?: (url: string) => void; hideLabels?: boolean; - presentation?: "split" | "ghost"; } const ThemedPlay = withUnistyles(Play); const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedGlobe = withUnistyles(Globe); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedEye = withUnistyles(Eye); const ThemedCopy = withUnistyles(Copy); const ThemedRotateCw = withUnistyles(RotateCw); const ThemedSquare = withUnistyles(Square); -const GHOST_TRIGGER_ICON_SIZE = 16; - const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const blueColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.blue[500], -}); -const greenColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.green[500], -}); -const redColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.red[500], -}); const playFillTransparent = { fill: "transparent" }; -const ghostPlayStroke = { strokeWidth: 1.5 }; interface ScriptRowActionButtonProps { accessibilityLabel: string; @@ -163,23 +147,7 @@ interface ServiceLinkRowProps { targets: WorkspaceScriptLinkTarget[]; scriptName: string; onSelectKind: (kind: WorkspaceScriptLinkKind) => void; - onCopy: (url: string, label: string) => void; -} - -function routeLabelKey( - kind: WorkspaceScriptLinkKind, -): - | "workspace.scripts.routes.public" - | "workspace.scripts.routes.paseo" - | "workspace.scripts.routes.direct" { - switch (kind) { - case "public": - return "workspace.scripts.routes.public"; - case "paseo": - return "workspace.scripts.routes.paseo"; - case "direct": - return "workspace.scripts.routes.direct"; - } + onCopy: (target: WorkspaceScriptLinkTarget) => void; } function ServiceRouteOption({ @@ -298,12 +266,12 @@ function ServiceLinkRow({ }: ServiceLinkRowProps): ReactElement { const { t } = useTranslation(); const closeMenu = useDropdownMenuClose(); - const { label, url } = selectedTarget; + const { label } = selectedTarget; const handleCopy = useCallback(() => { closeMenu(); - onCopy(url, label); - }, [url, label, onCopy, closeMenu]); + onCopy(selectedTarget); + }, [selectedTarget, onCopy, closeMenu]); return ( @@ -333,84 +301,29 @@ function ServiceLinkRow({ ); } -function ExitCodeBadge({ code }: { code: number }): ReactElement { - const { t } = useTranslation(); - const exitTextStyle = - code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError]; - return ( - - {t("workspace.scripts.states.exitCode", { code })} - - ); -} - interface ScriptRowProps { script: WorkspaceDescriptor["scripts"][number]; liveTerminalIdSet: Set; - activeConnection: ReturnType extends infer R - ? R extends { activeConnection: infer A } - ? A - : null - : null; - isStartPending: boolean; - isStopPending: boolean; - onStartScript: (scriptName: string) => void; - onStopScript: (scriptName: string) => void; - onRestartScript: (scriptName: string) => void; - onCopyUrl: (url: string, label: string) => void; - preferredRouteKind: WorkspaceScriptLinkKind | null; - onSelectRouteKind: (kind: WorkspaceScriptLinkKind) => void; + actions: WorkspaceScriptActions; onViewTerminal?: (terminalId: string) => void; onOpenUrlInBrowserTab?: (url: string) => void; } -function resolveScriptIconColorMapping(args: { - script: WorkspaceDescriptor["scripts"][number]; - isService: boolean; - isRunning: boolean; -}): (theme: Theme) => { color: string } { - const { script, isService, isRunning } = args; - if (isService) { - if (isRunning && script.health === "healthy") return greenColorMapping; - if (isRunning && script.health === "unhealthy") return redColorMapping; - if (isRunning) return blueColorMapping; - return mutedColorMapping; - } - if (isRunning) return blueColorMapping; - return mutedColorMapping; -} - function ScriptRow({ script, liveTerminalIdSet, - activeConnection, - isStartPending, - isStopPending, - onStartScript, - onStopScript, - onRestartScript, - onCopyUrl, - preferredRouteKind, - onSelectRouteKind, + actions, onViewTerminal, onOpenUrlInBrowserTab, }: ScriptRowProps): ReactElement { const { t } = useTranslation(); const isRunning = script.lifecycle === "running"; - const isService = (script.type ?? "service") === "service"; const exitCode = script.exitCode ?? null; - const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); - const selectedLink = - isService && isRunning - ? (serviceLink.targets.find((target) => target.kind === preferredRouteKind) ?? - serviceLink.primary) - : null; + const { selected: selectedLink, targets: linkTargets } = actions.resolveLinks(script); const liveTerminalId = script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null; - - const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning }); - const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal; const showExitBadge = !isRunning && exitCode !== null; + const { start, stop, restart } = actions; const closeMenu = useDropdownMenuClose(); const handleOpenService = useCallback(() => { @@ -424,16 +337,16 @@ function ScriptRow({ }, [liveTerminalId, onViewTerminal]); const handleRun = useCallback(() => { - onStartScript(script.scriptName); - }, [onStartScript, script.scriptName]); + start(script.scriptName); + }, [start, script.scriptName]); const handleStop = useCallback(() => { - onStopScript(script.scriptName); - }, [onStopScript, script.scriptName]); + stop(script.scriptName); + }, [stop, script.scriptName]); const handleRestart = useCallback(() => { - onRestartScript(script.scriptName); - }, [onRestartScript, script.scriptName]); + restart(script.scriptName); + }, [restart, script.scriptName]); const scriptNameStyle = useMemo( () => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName), @@ -471,7 +384,7 @@ function ScriptRow({ scriptName: script.scriptName, })} testID={`workspace-scripts-stop-${script.scriptName}`} - disabled={isStopPending} + disabled={actions.isStopPending} icon="stop" onPress={handleStop} tooltipLabel={t("workspace.scripts.actions.stop")} @@ -482,7 +395,7 @@ function ScriptRow({ scriptName: script.scriptName, })} testID={`workspace-scripts-start-${script.scriptName}`} - disabled={isStartPending} + disabled={actions.isStartPending} icon="start" onPress={handleRun} tooltipLabel={t("workspace.scripts.actions.run")} @@ -497,7 +410,7 @@ function ScriptRow({ })} > - + {script.scriptName} @@ -511,7 +424,7 @@ function ScriptRow({ scriptName: script.scriptName, })} testID={`workspace-scripts-restart-${script.scriptName}`} - disabled={isStopPending} + disabled={actions.isStopPending} icon="restart" onPress={handleRestart} tooltipLabel={t("workspace.scripts.actions.restart")} @@ -523,10 +436,10 @@ function ScriptRow({ ) : null} @@ -543,142 +456,35 @@ export function WorkspaceScriptsButton({ onViewTerminal, onOpenUrlInBrowserTab, hideLabels, - presentation = "split", }: WorkspaceScriptsButtonProps): ReactElement | null { const { t } = useTranslation(); - const toast = useToast(); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; - const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( - (state) => state.byServerId[serverId] ?? null, - ); - const setPreferredRoute = useWorkspaceServiceRoutePreferencesStore( - (state) => state.setPreferredRoute, - ); + const actions = useWorkspaceScriptActions({ + serverId, + workspaceId, + scripts, + onScriptTerminalStarted, + }); const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]); - const pendingRestartRef = useRef>(new Set()); - - const startScriptMutation = useMutation({ - mutationFn: async (scriptName: string) => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - const result = await client.startWorkspaceScript(workspaceId, scriptName); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - onError: (error, scriptName) => { - toast.show( - error instanceof Error - ? error.message - : t("workspace.scripts.states.startFailed", { scriptName }), - { - variant: "error", - }, - ); - }, - onSuccess: (result) => { - if (result.terminalId) { - onScriptTerminalStarted?.(result.terminalId); - } - }, - }); - const startScript = startScriptMutation.mutate; - - const stopScriptMutation = useMutation({ - mutationFn: async (scriptName: string) => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - const terminalId = scripts.find((s) => s.scriptName === scriptName)?.terminalId; - if (!terminalId) { - throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); - } - const result = await client.killTerminal(terminalId); - if (!result.success) { - throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); - } - }, - onError: (error, scriptName) => { - pendingRestartRef.current.delete(scriptName); - toast.show( - error instanceof Error - ? error.message - : t("workspace.scripts.states.stopFailed", { scriptName }), - { - variant: "error", - }, - ); - }, - }); - - // Restart = kill the script terminal, then start again once the daemon - // reports the script as stopped (it tears the runtime entry down on exit). - useEffect(() => { - const pending = pendingRestartRef.current; - if (pending.size === 0) return; - for (const script of scripts) { - if (!pending.has(script.scriptName) || script.lifecycle === "running") continue; - pending.delete(script.scriptName); - startScript(script.scriptName); - } - }, [scripts, startScript]); const triggerStyle = useCallback( ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ - presentation === "ghost" ? styles.ghostButton : styles.splitButtonPrimary, - (hovered || pressed || open) && - (presentation === "ghost" ? styles.ghostButtonHovered : styles.splitButtonPrimaryHovered), + styles.splitButtonPrimary, + (hovered || pressed || open) && styles.splitButtonPrimaryHovered, ], - [presentation], - ); - - const handleStartScript = useCallback( - (scriptName: string) => startScriptMutation.mutate(scriptName), - [startScriptMutation], - ); - - const handleStopScript = useCallback( - (scriptName: string) => stopScriptMutation.mutate(scriptName), - [stopScriptMutation], - ); - - const handleRestartScript = useCallback( - (scriptName: string) => { - pendingRestartRef.current.add(scriptName); - stopScriptMutation.mutate(scriptName); - }, - [stopScriptMutation], - ); - - const handleCopyUrl = useCallback( - (url: string, label: string) => { - void Clipboard.setStringAsync(url); - toast.copied(label); - }, - [toast], - ); - - const handleSelectRouteKind = useCallback( - (kind: WorkspaceScriptLinkKind) => setPreferredRoute(serverId, kind), - [serverId, setPreferredRoute], + [], ); if (scripts.length === 0) { return null; } + // The trigger only says whether anything runs; health is for the rows inside. const hasAnyRunning = scripts.some((s) => s.lifecycle === "running"); - const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping; - const triggerIconSize = presentation === "ghost" ? GHOST_TRIGGER_ICON_SIZE : 14; - const triggerPlayProps = - presentation === "ghost" ? { ...playFillTransparent, ...ghostPlayStroke } : playFillTransparent; + const triggerPlayMapping = scriptToneColorMapping(hasAnyRunning ? "running" : "idle"); return ( - + - + {!hideLabels && ( {t("workspace.scripts.title")} )} - {presentation === "split" ? ( - - ) : null} + @@ -750,21 +542,6 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.borderAccent, overflow: "hidden", }, - ghostButtonFrame: { - flexDirection: "row", - alignItems: "stretch", - }, - ghostButton: { - width: theme.spacing[8], - height: theme.spacing[8], - padding: 0, - borderRadius: theme.borderRadius.lg, - alignItems: "center", - justifyContent: "center", - }, - ghostButtonHovered: { - backgroundColor: theme.colors.surface2, - }, splitButtonPrimary: { paddingHorizontal: { xs: theme.spacing[3], @@ -798,9 +575,6 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], minHeight: MENU_ITEM_HEIGHT, }, - scriptIcon: { - flexShrink: 0, - }, scriptName: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, @@ -844,21 +618,6 @@ const styles = StyleSheet.create((theme) => ({ hostLabelActive: { color: theme.colors.foreground, }, - exitBadge: { - paddingHorizontal: theme.spacing[1.5], - paddingVertical: 1, - borderRadius: 2, - backgroundColor: theme.colors.surface2, - }, - exitBadgeText: { - fontSize: 10, - lineHeight: 12, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, - }, - exitBadgeTextError: { - color: theme.colors.palette.red[300], - }, iconActionButton: { padding: 2, }, diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx new file mode 100644 index 0000000000..3d14d9faff --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx @@ -0,0 +1,416 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { View } from "react-native"; +import { Copy, Eye, Play, RotateCw, Square, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/dropdown-menu"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { Theme } from "@/styles/theme"; +import { openServiceUrl } from "@/utils/open-service-url"; +import type { + WorkspaceScriptLinkKind, + WorkspaceScriptLinkTarget, +} from "@/utils/workspace-script-links"; +import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks"; +import { scriptToneColorMapping, summarizeScripts, type ScriptsSummary } from "./script-status"; +import { + useWorkspaceScriptActions, + type WorkspaceScriptActions, +} from "./use-workspace-script-actions"; + +type WorkspaceScript = WorkspaceDescriptor["scripts"][number]; + +const ThemedPlay = withUnistyles(Play); +const ThemedEye = withUnistyles(Eye); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedSquare = withUnistyles(Square); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const playFillTransparent = { fill: "transparent" }; + +const ROW_ICON_SIZE = 16; +const TRAILING_ICON_SIZE = 14; + +const OPEN_SERVICE_ICON = ; +const VIEW_TERMINAL_ICON = ( + +); +const COPY_URL_ICON = ; +const RESTART_ICON = ; +const STOP_ICON = ; +const RUN_ICON = ( + +); + +const SCRIPTS_PAGE_ID = "workspace-scripts"; + +function scriptPageId(scriptName: string): string { + return `${SCRIPTS_PAGE_ID}/${scriptName}`; +} + +export interface UseWorkspaceScriptsMenuPagesInput { + serverId: string; + workspaceId: string; + scripts: WorkspaceDescriptor["scripts"]; + liveTerminalIds: readonly string[]; + onScriptTerminalStarted: (terminalId: string) => void; + onViewTerminal: (terminalId: string) => void; + onOpenUrlInBrowserTab: (url: string) => void; +} + +export interface WorkspaceScriptsMenuPages { + /** The root row that leads to the scripts page. Null when the workspace has no scripts. */ + trigger: ReactElement | null; + pages: MenuPageDefinition[]; + /** A running service reports unhealthy, so the menu's own trigger should say so. */ + needsAttention: boolean; +} + +/** + * Workspace scripts as pages of a sheet menu: a Scripts row on the root, a page listing every + * script, and one page per script. + * + * Every script gets a page whether or not it runs, so stopping one from its own page flips that + * page to its stopped state instead of pulling it out from under the user. + * + * The actions live here rather than in a page, so call this from whatever owns the menu: a restart + * finishes after the sheet has closed. + */ +export function useWorkspaceScriptsMenuPages({ + serverId, + workspaceId, + scripts, + liveTerminalIds, + onScriptTerminalStarted, + onViewTerminal, + onOpenUrlInBrowserTab, +}: UseWorkspaceScriptsMenuPagesInput): WorkspaceScriptsMenuPages { + const { t } = useTranslation(); + const actions = useWorkspaceScriptActions({ + serverId, + workspaceId, + scripts, + onScriptTerminalStarted, + }); + const summary = useMemo(() => summarizeScripts(scripts), [scripts]); + + const trigger = useMemo( + () => (scripts.length === 0 ? null : ), + [scripts.length, summary], + ); + + const pages = useMemo(() => { + if (scripts.length === 0) return []; + const liveTerminalIdSet = new Set(liveTerminalIds); + return [ + { + id: SCRIPTS_PAGE_ID, + title: t("workspace.scripts.title"), + content: , + }, + ...scripts.map((script) => ({ + id: scriptPageId(script.scriptName), + title: script.scriptName, + content: ( + + ), + })), + ]; + }, [actions, liveTerminalIds, onOpenUrlInBrowserTab, onViewTerminal, scripts, t]); + + return { trigger, pages, needsAttention: summary.tone === "unhealthy" }; +} + +/** The root row: ▶ in the colour of the whole set, and what is running as its value. */ +function ScriptsMenuTrigger({ summary }: { summary: ScriptsSummary }): ReactElement { + const { t } = useTranslation(); + const leading = useMemo( + () => ( + + ), + [summary.tone], + ); + const [onlyRunning] = summary.running; + const value = + summary.running.length > 1 + ? t("workspace.scripts.states.runningCount", { count: summary.running.length }) + : onlyRunning; + return ( + + {t("workspace.scripts.title")} + + ); +} + +function ScriptsPage({ + scripts, + actions, +}: { + scripts: WorkspaceDescriptor["scripts"]; + actions: WorkspaceScriptActions; +}): ReactElement { + return ( + <> + {scripts.map((script) => + script.lifecycle === "running" ? ( + + ) : ( + + ), + )} + + ); +} + +function RunningScriptRow({ script }: { script: WorkspaceScript }): ReactElement { + const { t } = useTranslation(); + const leading = useMemo(() => , [script]); + return ( + + {script.scriptName} + + ); +} + +/** Pressing a stopped script runs it. Starting opens its terminal tab, so the sheet closes. */ +function StoppedScriptRow({ + script, + actions, +}: { + script: WorkspaceScript; + actions: WorkspaceScriptActions; +}): ReactElement { + const { start } = actions; + const handleSelect = useCallback(() => start(script.scriptName), [start, script.scriptName]); + const exitCode = script.exitCode ?? null; + const leading = useMemo(() => , [script]); + const trailing = useMemo( + () => ( + + {exitCode === null ? null : } + + + ), + [exitCode], + ); + return ( + + {script.scriptName} + + ); +} + +interface ScriptPageProps { + script: WorkspaceScript; + actions: WorkspaceScriptActions; + liveTerminalId: string | null; + onViewTerminal: (terminalId: string) => void; + onOpenUrlInBrowserTab: (url: string) => void; +} + +function ScriptPage(props: ScriptPageProps): ReactElement { + return props.script.lifecycle === "running" ? ( + + ) : ( + + ); +} + +/** + * Rows that open something — the service, the terminal, the clipboard toast — close the sheet. + * Stop and the URL choice stay: the page is where their result shows. + */ +function RunningScriptPage({ + script, + actions, + liveTerminalId, + onViewTerminal, + onOpenUrlInBrowserTab, +}: ScriptPageProps): ReactElement { + const { t } = useTranslation(); + const { scriptName } = script; + const { selected, targets } = actions.resolveLinks(script); + const { stop, restart, copyUrl, selectRoute } = actions; + + const handleOpenService = useCallback(() => { + if (!selected) return; + void openServiceUrl(selected.url, { openInApp: onOpenUrlInBrowserTab }); + }, [selected, onOpenUrlInBrowserTab]); + const handleViewTerminal = useCallback(() => { + if (liveTerminalId) onViewTerminal(liveTerminalId); + }, [liveTerminalId, onViewTerminal]); + const handleCopyUrl = useCallback(() => { + if (selected) copyUrl(selected); + }, [selected, copyUrl]); + const handleRestart = useCallback(() => restart(scriptName), [restart, scriptName]); + const handleStop = useCallback(() => stop(scriptName), [stop, scriptName]); + + return ( + <> + {selected ? ( + + {t("workspace.scripts.actions.openService")} + + ) : null} + {liveTerminalId ? ( + + {t("workspace.scripts.actions.view")} + + ) : null} + {selected ? ( + + {t("workspace.scripts.actions.copyUrl")} + + ) : null} + + {t("workspace.scripts.actions.restart")} + + + {t("workspace.scripts.actions.stop")} + + {selected && targets.length > 1 ? ( + <> + + {targets.map((target) => ( + + ))} + + ) : null} + + ); +} + +/** A pick-one URL route. Choosing one keeps the page open so the check moves under the thumb. */ +function ServiceRouteOption({ + scriptName, + target, + selected, + onSelect, +}: { + scriptName: string; + target: WorkspaceScriptLinkTarget; + selected: boolean; + onSelect: (kind: WorkspaceScriptLinkKind) => void; +}): ReactElement { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onSelect(target.kind), [onSelect, target.kind]); + return ( + + {t(routeLabelKey(target.kind))} + + ); +} + +function StoppedScriptPage({ + script, + actions, +}: { + script: WorkspaceScript; + actions: WorkspaceScriptActions; +}): ReactElement { + const { t } = useTranslation(); + const { start } = actions; + const handleRun = useCallback(() => start(script.scriptName), [start, script.scriptName]); + const exitCode = script.exitCode ?? null; + const trailing = useMemo( + () => (exitCode === null ? null : ), + [exitCode], + ); + return ( + + {t("workspace.scripts.actions.run")} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index 2fb7176e10..74a95c7548 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -27,6 +27,10 @@ import { } from "@getpaseo/protocol/terminal-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { Theme } from "@/styles/theme"; +import { + useWorkspaceScriptsMenuPages, + type UseWorkspaceScriptsMenuPagesInput, +} from "@/screens/workspace/scripts"; const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedCopy = withUnistyles(Copy); @@ -49,6 +53,16 @@ function WorkspaceHeaderMenuTriggerIcon() { ); } +/** The ⋯ with a dot on it, for when something inside the menu needs looking at. */ +function WorkspaceHeaderMenuAttentionTriggerIcon() { + return ( + + + + + ); +} + /** * The compact header buttons draw at 32pt, under the 44pt touch minimum, and sit flush against * each other — so the slop can only grow vertically. Widening it would put two buttons' slop over @@ -192,6 +206,7 @@ function HeaderMenuProfileItem({ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { normalizedServerId: string; + scripts: UseWorkspaceScriptsMenuPagesInput; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; onCreateTerminalWithProfile: (profile: TerminalProfile) => void; @@ -201,9 +216,13 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace /** * Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer; * the rarer launches stay here alongside the workspace actions. + * + * The header has no room for a scripts button either, so scripts are a page of this menu, and the + * ⋯ carries a dot while a running service is unhealthy. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, + scripts, showCreateBrowserTab, createTerminalDisabled, onCreateTerminalWithProfile, @@ -222,6 +241,7 @@ export function WorkspaceHeaderMenuMobile({ const handleEditProfiles = useCallback(() => { router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); }, [normalizedServerId, router]); + const scriptsMenu = useWorkspaceScriptsMenuPages(scripts); return ( @@ -230,16 +250,26 @@ export function WorkspaceHeaderMenuMobile({ style={triggerStyle} hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP} accessibilityRole="button" - accessibilityLabel={t("workspace.header.actions.workspaceActions")} + accessibilityLabel={ + scriptsMenu.needsAttention + ? t("workspace.header.actions.workspaceActionsUnhealthy") + : t("workspace.header.actions.workspaceActions") + } > - + {scriptsMenu.needsAttention ? ( + + ) : ( + + )} + {scriptsMenu.trigger} {showCreateBrowserTab ? ( ({ headerMenuProfileIconWrapper: { width: 16, height: 16, }, -}); + // Same red as the Scripts row's unhealthy ▶, so the dot and the row it leads to agree. + attentionDot: { + position: "absolute", + top: -1, + right: -2, + width: ATTENTION_DOT_SIZE, + height: ATTENTION_DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.palette.red[500], + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bfe131d008..d8a37b7156 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -47,7 +47,7 @@ import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button"; -import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; +import { WorkspaceScriptsButton } from "@/screens/workspace/scripts"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; @@ -1026,6 +1026,15 @@ function WorkspaceScreenContent({ .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) .catch(() => undefined); }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); + // The focused workspace keeps its script status live even when no sidebar or viewed agent + // timeline demands the directory. Retained, hidden workspaces release it. + useEffect(() => { + if (!isRouteFocused || !normalizedServerId || !normalizedWorkspaceId) return; + return getHostRuntimeStore().acquireScriptStatusDemand( + normalizedServerId, + normalizedWorkspaceId, + ); + }, [isRouteFocused, normalizedServerId, normalizedWorkspaceId]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); @@ -3373,6 +3382,26 @@ function WorkspaceScreenContent({ isWorkspaceHeaderSubtitleDistinct, ], ); + const compactHeaderScripts = useMemo( + () => ({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + scripts: workspaceScripts, + liveTerminalIds, + onScriptTerminalStarted: handleScriptTerminalStarted, + onViewTerminal: handleViewScriptTerminal, + onOpenUrlInBrowserTab: handleOpenUrlInBrowserTab, + }), + [ + normalizedServerId, + normalizedWorkspaceId, + workspaceScripts, + liveTerminalIds, + handleScriptTerminalStarted, + handleViewScriptTerminal, + handleOpenUrlInBrowserTab, + ], + ); const compactWorkspaceHeader = useMemo( () => isMobile ? ( @@ -3408,6 +3437,7 @@ function WorkspaceScreenContent({ />