mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Run and watch workspace scripts from the phone header menu (#7122)
* 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.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+15
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await openScriptsPage(page);
|
||||
await scriptRow(page, scriptName).click();
|
||||
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function goBackInMenu(page: Page): Promise<void> {
|
||||
await backButton(page).click();
|
||||
}
|
||||
|
||||
export async function closeCompactWorkspaceMenu(page: Page): Promise<void> {
|
||||
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" });
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { subscriptionFixture } from "../subscription-fixture";
|
||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
type WorkspaceScriptsResult = Awaited<ReturnType<DaemonClient["listWorkspaceScripts"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
@@ -181,6 +182,14 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
listWorkspaceScriptsCalls = 0;
|
||||
workspaceScripts: WorkspaceScriptsResult["scripts"] = [];
|
||||
|
||||
async listWorkspaceScripts(workspaceId: string): Promise<WorkspaceScriptsResult> {
|
||||
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);
|
||||
|
||||
@@ -150,6 +150,7 @@ export class DirectorySync {
|
||||
private agentSubscription: OwnedSubscription<FetchAgentsPayload> | null = null;
|
||||
private workspaceSubscription: OwnedSubscription<FetchWorkspacesPayload> | null = null;
|
||||
private eventSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
|
||||
private scriptStatusSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
|
||||
private readonly abortSessionWaits = new Set<() => void>();
|
||||
private cacheLoad: Promise<void> | null = null;
|
||||
private cacheAccepted = false;
|
||||
@@ -157,6 +158,7 @@ export class DirectorySync {
|
||||
private workspaceRevision = 0;
|
||||
private readonly routeDemandIds = new Set<string>();
|
||||
private readonly fullDemandSources = new Set<object>();
|
||||
private readonly scriptStatusDemand = new Map<object, string>();
|
||||
private demandRefresh: Promise<void> | 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<void> {
|
||||
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<void> {
|
||||
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 &&
|
||||
|
||||
@@ -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<void> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
|
||||
@@ -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";
|
||||
@@ -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 (
|
||||
<Icon
|
||||
size={size}
|
||||
uniProps={scriptToneColorMapping(scriptStatus(script))}
|
||||
style={styles.scriptIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExitCodeBadge({ code }: { code: number }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const exitTextStyle =
|
||||
code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError];
|
||||
return (
|
||||
<View style={styles.exitBadge}>
|
||||
<Text style={exitTextStyle}>{t("workspace.scripts.states.exitCode", { code })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
},
|
||||
}));
|
||||
@@ -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, "lifecycle" | "health">,
|
||||
): 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Set<string>>(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,
|
||||
],
|
||||
);
|
||||
}
|
||||
+1
-14
@@ -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}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
@@ -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" })]);
|
||||
|
||||
+46
-287
@@ -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 (
|
||||
<View style={styles.hostRow}>
|
||||
@@ -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 (
|
||||
<View style={styles.exitBadge}>
|
||||
<Text style={exitTextStyle}>{t("workspace.scripts.states.exitCode", { code })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScriptRowProps {
|
||||
script: WorkspaceDescriptor["scripts"][number];
|
||||
liveTerminalIdSet: Set<string>;
|
||||
activeConnection: ReturnType<typeof useHostRuntimeSnapshot> 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({
|
||||
})}
|
||||
>
|
||||
<View style={styles.scriptHeader}>
|
||||
<ScriptIcon size={14} uniProps={iconColorMapping} style={styles.scriptIcon} />
|
||||
<ScriptIcon script={script} size={14} />
|
||||
<Text style={scriptNameStyle} numberOfLines={1}>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
@@ -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({
|
||||
<View style={styles.hostList}>
|
||||
<ServiceLinkRow
|
||||
selectedTarget={selectedLink}
|
||||
targets={serviceLink.targets}
|
||||
targets={linkTargets}
|
||||
scriptName={script.scriptName}
|
||||
onSelectKind={onSelectRouteKind}
|
||||
onCopy={onCopyUrl}
|
||||
onSelectKind={actions.selectRoute}
|
||||
onCopy={actions.copyUrl}
|
||||
/>
|
||||
</View>
|
||||
) : 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<Set<string>>(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 (
|
||||
<View style={styles.row}>
|
||||
<View style={presentation === "ghost" ? styles.ghostButtonFrame : styles.splitButton}>
|
||||
<View style={styles.splitButton}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-scripts-button"
|
||||
@@ -687,17 +493,11 @@ export function WorkspaceScriptsButton({
|
||||
accessibilityLabel={t("workspace.scripts.accessibility.trigger")}
|
||||
>
|
||||
<View style={styles.splitButtonContent}>
|
||||
<ThemedPlay
|
||||
size={triggerIconSize}
|
||||
uniProps={triggerPlayMapping}
|
||||
{...triggerPlayProps}
|
||||
/>
|
||||
<ThemedPlay size={14} uniProps={triggerPlayMapping} {...playFillTransparent} />
|
||||
{!hideLabels && (
|
||||
<Text style={styles.splitButtonText}>{t("workspace.scripts.title")}</Text>
|
||||
)}
|
||||
{presentation === "split" ? (
|
||||
<ThemedChevronDown size={16} uniProps={extraMutedIconColorMapping} />
|
||||
) : null}
|
||||
<ThemedChevronDown size={16} uniProps={extraMutedIconColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -711,15 +511,7 @@ export function WorkspaceScriptsButton({
|
||||
key={script.scriptName}
|
||||
script={script}
|
||||
liveTerminalIdSet={liveTerminalIdSet}
|
||||
activeConnection={activeConnection}
|
||||
isStartPending={startScriptMutation.isPending}
|
||||
isStopPending={stopScriptMutation.isPending}
|
||||
onStartScript={handleStartScript}
|
||||
onStopScript={handleStopScript}
|
||||
onRestartScript={handleRestartScript}
|
||||
onCopyUrl={handleCopyUrl}
|
||||
preferredRouteKind={preferredRouteKind}
|
||||
onSelectRouteKind={handleSelectRouteKind}
|
||||
actions={actions}
|
||||
onViewTerminal={onViewTerminal}
|
||||
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
@@ -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,
|
||||
},
|
||||
@@ -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 = <ThemedEye size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const VIEW_TERMINAL_ICON = (
|
||||
<ThemedSquareTerminal size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />
|
||||
);
|
||||
const COPY_URL_ICON = <ThemedCopy size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const RESTART_ICON = <ThemedRotateCw size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const STOP_ICON = <ThemedSquare size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const RUN_ICON = (
|
||||
<ThemedPlay size={ROW_ICON_SIZE} uniProps={mutedColorMapping} {...playFillTransparent} />
|
||||
);
|
||||
|
||||
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 : <ScriptsMenuTrigger summary={summary} />),
|
||||
[scripts.length, summary],
|
||||
);
|
||||
|
||||
const pages = useMemo<MenuPageDefinition[]>(() => {
|
||||
if (scripts.length === 0) return [];
|
||||
const liveTerminalIdSet = new Set(liveTerminalIds);
|
||||
return [
|
||||
{
|
||||
id: SCRIPTS_PAGE_ID,
|
||||
title: t("workspace.scripts.title"),
|
||||
content: <ScriptsPage scripts={scripts} actions={actions} />,
|
||||
},
|
||||
...scripts.map((script) => ({
|
||||
id: scriptPageId(script.scriptName),
|
||||
title: script.scriptName,
|
||||
content: (
|
||||
<ScriptPage
|
||||
script={script}
|
||||
actions={actions}
|
||||
liveTerminalId={
|
||||
script.terminalId && liveTerminalIdSet.has(script.terminalId)
|
||||
? script.terminalId
|
||||
: null
|
||||
}
|
||||
onViewTerminal={onViewTerminal}
|
||||
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
];
|
||||
}, [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(
|
||||
() => (
|
||||
<ThemedPlay
|
||||
size={ROW_ICON_SIZE}
|
||||
uniProps={scriptToneColorMapping(summary.tone)}
|
||||
{...playFillTransparent}
|
||||
/>
|
||||
),
|
||||
[summary.tone],
|
||||
);
|
||||
const [onlyRunning] = summary.running;
|
||||
const value =
|
||||
summary.running.length > 1
|
||||
? t("workspace.scripts.states.runningCount", { count: summary.running.length })
|
||||
: onlyRunning;
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
id={SCRIPTS_PAGE_ID}
|
||||
value={value}
|
||||
leading={leading}
|
||||
testID="workspace-header-scripts"
|
||||
>
|
||||
{t("workspace.scripts.title")}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptsPage({
|
||||
scripts,
|
||||
actions,
|
||||
}: {
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
actions: WorkspaceScriptActions;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{scripts.map((script) =>
|
||||
script.lifecycle === "running" ? (
|
||||
<RunningScriptRow key={script.scriptName} script={script} />
|
||||
) : (
|
||||
<StoppedScriptRow key={script.scriptName} script={script} actions={actions} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningScriptRow({ script }: { script: WorkspaceScript }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const leading = useMemo(() => <ScriptIcon script={script} size={ROW_ICON_SIZE} />, [script]);
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
id={scriptPageId(script.scriptName)}
|
||||
value={t("workspace.scripts.states.running")}
|
||||
leading={leading}
|
||||
testID={`workspace-scripts-row-${script.scriptName}`}
|
||||
>
|
||||
{script.scriptName}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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(() => <ScriptIcon script={script} size={ROW_ICON_SIZE} />, [script]);
|
||||
const trailing = useMemo(
|
||||
() => (
|
||||
<View style={styles.trailing}>
|
||||
{exitCode === null ? null : <ExitCodeBadge code={exitCode} />}
|
||||
<ThemedPlay
|
||||
size={TRAILING_ICON_SIZE}
|
||||
uniProps={mutedColorMapping}
|
||||
{...playFillTransparent}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[exitCode],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
disabled={actions.isStartPending}
|
||||
onSelect={handleSelect}
|
||||
testID={`workspace-scripts-row-${script.scriptName}`}
|
||||
>
|
||||
{script.scriptName}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
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" ? (
|
||||
<RunningScriptPage {...props} />
|
||||
) : (
|
||||
<StoppedScriptPage script={props.script} actions={props.actions} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? (
|
||||
<DropdownMenuItem
|
||||
leading={OPEN_SERVICE_ICON}
|
||||
onSelect={handleOpenService}
|
||||
testID={`workspace-script-${scriptName}-open`}
|
||||
>
|
||||
{t("workspace.scripts.actions.openService")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{liveTerminalId ? (
|
||||
<DropdownMenuItem
|
||||
leading={VIEW_TERMINAL_ICON}
|
||||
onSelect={handleViewTerminal}
|
||||
testID={`workspace-script-${scriptName}-view`}
|
||||
>
|
||||
{t("workspace.scripts.actions.view")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<DropdownMenuItem
|
||||
leading={COPY_URL_ICON}
|
||||
description={selected.label}
|
||||
onSelect={handleCopyUrl}
|
||||
testID={`workspace-script-${scriptName}-copy`}
|
||||
>
|
||||
{t("workspace.scripts.actions.copyUrl")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
leading={RESTART_ICON}
|
||||
disabled={actions.isStopPending}
|
||||
onSelect={handleRestart}
|
||||
testID={`workspace-script-${scriptName}-restart`}
|
||||
>
|
||||
{t("workspace.scripts.actions.restart")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
leading={STOP_ICON}
|
||||
disabled={actions.isStopPending}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleStop}
|
||||
testID={`workspace-script-${scriptName}-stop`}
|
||||
>
|
||||
{t("workspace.scripts.actions.stop")}
|
||||
</DropdownMenuItem>
|
||||
{selected && targets.length > 1 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{targets.map((target) => (
|
||||
<ServiceRouteOption
|
||||
key={target.kind}
|
||||
scriptName={scriptName}
|
||||
target={target}
|
||||
selected={target.kind === selected.kind}
|
||||
onSelect={selectRoute}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : 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 (
|
||||
<DropdownMenuItem
|
||||
selected={selected}
|
||||
showSelectedCheck
|
||||
description={target.label}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`workspace-script-${scriptName}-route-${target.kind}`}
|
||||
>
|
||||
{t(routeLabelKey(target.kind))}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
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 : <ExitCodeBadge code={exitCode} />),
|
||||
[exitCode],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
leading={RUN_ICON}
|
||||
trailing={trailing}
|
||||
disabled={actions.isStartPending}
|
||||
onSelect={handleRun}
|
||||
testID={`workspace-script-${script.scriptName}-run`}
|
||||
>
|
||||
{t("workspace.scripts.actions.run")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
}));
|
||||
@@ -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 (
|
||||
<View>
|
||||
<WorkspaceHeaderMenuTriggerIcon />
|
||||
<View style={styles.attentionDot} testID="workspace-header-menu-attention" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -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")
|
||||
}
|
||||
>
|
||||
<WorkspaceHeaderMenuTriggerIcon />
|
||||
{scriptsMenu.needsAttention ? (
|
||||
<WorkspaceHeaderMenuAttentionTriggerIcon />
|
||||
) : (
|
||||
<WorkspaceHeaderMenuTriggerIcon />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
testID="workspace-header-menu"
|
||||
pages={scriptsMenu.pages}
|
||||
sheetTitle={t("workspace.header.actions.workspaceActions")}
|
||||
>
|
||||
{scriptsMenu.trigger}
|
||||
{showCreateBrowserTab ? (
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-browser"
|
||||
@@ -272,9 +302,21 @@ export function WorkspaceHeaderMenuMobile({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const ATTENTION_DOT_SIZE = 6;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
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],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
normalizedServerId={normalizedServerId}
|
||||
scripts={compactHeaderScripts}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
@@ -3426,6 +3456,7 @@ function WorkspaceScreenContent({
|
||||
[
|
||||
activeTabKey,
|
||||
canOpenImportSheet,
|
||||
compactHeaderScripts,
|
||||
createTerminalDisabled,
|
||||
currentBranchName,
|
||||
explorerSidebarToggleAccessibilityState,
|
||||
|
||||
Reference in New Issue
Block a user