From a449271d57cd57671ad377417d9ec64d6b3d6e76 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Nov 2026 18:05:31 +0700 Subject: [PATCH] Let plugins provide custom sidebar views across hosts (#8906) * feat(plugin): expose stable sidebar entity data across hosts Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase. * feat(plugin): let users select a sidebar view An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery. * feat(plugin): add sidebar view examples and ref actions Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders. * feat(plugin): observe terminals across connected hosts Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp. --- docs/plugins.md | 7 + .../app/e2e/browser/sidebar-views.spec.ts | 18 + .../app/e2e/support/helpers/new-workspace.ts | 3 + .../app/e2e/support/helpers/sidebar-views.ts | 191 ++++++++ packages/app/src/components/left-sidebar.tsx | 105 ++--- packages/app/src/data/push-router.test.ts | 8 +- packages/app/src/data/query.ts | 7 +- packages/app/src/i18n/resources/ar.ts | 14 + packages/app/src/i18n/resources/en.ts | 14 + packages/app/src/i18n/resources/es.ts | 15 + packages/app/src/i18n/resources/fr.ts | 15 + packages/app/src/i18n/resources/ja.ts | 15 + packages/app/src/i18n/resources/ko.ts | 15 + packages/app/src/i18n/resources/pt-BR.ts | 15 + packages/app/src/i18n/resources/ru.ts | 14 + packages/app/src/i18n/resources/zh-CN.ts | 14 + packages/app/src/plugins/actions.ts | 3 +- .../app/src/plugins/buttons/model.test.ts | 1 + .../src/plugins/catalog-sync-model.test.ts | 3 +- packages/app/src/plugins/client-runtime.ts | 5 +- packages/app/src/plugins/client-state/api.ts | 68 +++ .../plugins/client-state/connected-hosts.ts | 72 +++ .../app/src/plugins/client-state/fixtures.ts | 53 +++ packages/app/src/plugins/client-state/refs.ts | 3 + .../plugins/client-state/snapshots.test.ts | 151 ++++--- .../app/src/plugins/client-state/snapshots.ts | 95 +++- .../client-state/source-retention.test.ts | 55 +++ .../client-state/source.browser.test.tsx | 420 ++++++++++++++++++ .../app/src/plugins/client-state/source.ts | 257 ++++++++++- .../app/src/plugins/client-state/terminals.ts | 154 +++++++ .../command-center/contributions.test.ts | 27 ++ packages/app/src/plugins/evaluate.test.ts | 23 + packages/app/src/plugins/evaluate.ts | 18 +- .../plugins/installation-provider.test.tsx | 14 +- .../app/src/plugins/installation-provider.tsx | 3 +- .../src/plugins/registry-lifecycle.test.ts | 3 +- .../src/plugins/registry-requirements.test.ts | 3 +- packages/app/src/plugins/registry.ts | 1 + .../app/src/plugins/settings/device.test.ts | 97 ++++ packages/app/src/plugins/settings/device.ts | 80 ++++ .../app/src/plugins/settings/use-settings.ts | 64 ++- .../app/src/plugins/sidebar-groups.test.ts | 1 + .../app/src/plugins/sidebar-view/catalog.ts | 25 ++ .../sidebar-view/frame.browser.test.tsx | 69 +++ .../app/src/plugins/sidebar-view/frame.tsx | 34 ++ .../app/src/plugins/sidebar-view/index.tsx | 173 ++++++++ .../app/src/plugins/sidebar-view/picker.tsx | 58 +++ .../app/src/plugins/sidebar-view/selection.ts | 47 ++ .../src/plugins/surface-contribution.test.ts | 1 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + .../app/src/plugins/timeline/view.test.tsx | 3 +- packages/app/src/plugins/types.ts | 2 + .../workspace-panels/resolution.test.ts | 1 + .../screens/settings/plugins-page.test.tsx | 3 +- .../settings/sidebar/sidebar-nav-section.tsx | 2 + packages/client/src/daemon-client.ts | 8 +- packages/client/src/index.ts | 36 ++ packages/plugin/src/boundaries.test.ts | 42 ++ packages/plugin/src/client/client-state.tsx | 157 +++++-- packages/plugin/src/client/contracts.ts | 24 +- packages/plugin/src/client/host.ts | 11 +- packages/plugin/src/client/index.ts | 18 +- packages/plugin/src/client/paseo-context.tsx | 16 +- packages/plugin/src/client/query.test.ts | 58 +++ packages/plugin/src/client/query.ts | 115 +++++ packages/plugin/src/client/reference-api.ts | 88 ++++ packages/plugin/src/client/refs.test.ts | 24 + packages/plugin/src/client/refs.ts | 26 ++ packages/plugin/src/contracts.ts | 40 ++ packages/plugin/src/index.ts | 3 + packages/plugin/src/refs.ts | 11 + packages/plugin/src/settings.ts | 7 +- packages/protocol/src/messages.test.ts | 40 ++ packages/protocol/src/messages.ts | 11 +- .../protocol/src/terminal-subscription-key.ts | 7 +- .../terminal-workspace-sdk.e2e.test.ts | 131 +++++- .../src/server/plugins/runtime.posix.test.ts | 2 +- .../src/server/plugins/settings/index.test.ts | 8 + .../src/server/plugins/settings/index.ts | 1 + packages/server/src/server/session.ts | 6 + .../server/src/server/websocket-server.ts | 1 + .../workspace-script-runtime-store.test.ts | 13 + .../server/workspace-script-runtime-store.ts | 14 + .../terminal-session-controller.test.ts | 50 ++- .../terminal/terminal-session-controller.ts | 62 ++- .../src/terminal/terminal-worker-process.ts | 7 +- .../terminal/terminal-worker-protocol.test.ts | 10 +- .../src/terminal/terminal-worker-protocol.ts | 4 +- packages/server/src/terminal/terminal.ts | 3 + .../terminal/worker-terminal-manager.test.ts | 6 +- .../src/terminal/worker-terminal-manager.ts | 7 + .../sidebar-views/client/agents.tsx | 34 ++ .../sidebar-views/client/labels.tsx | 191 ++++++++ .../sidebar-views/client/nested.tsx | 109 +++++ plugin-examples/sidebar-views/client/rows.tsx | 139 ++++++ .../sidebar-views/client/terminals.tsx | 131 ++++++ .../sidebar-views/client/workspaces.tsx | 37 ++ .../sidebar-views/index.client.tsx | 18 + .../sidebar-views/paseo-plugin.json | 4 + .../sidebar-views/shared/preferences.ts | 8 + public-docs/plugins/migration.md | 1 + public-docs/plugins/reference.md | 21 +- public-docs/plugins/reference.zh-CN.md | 18 +- public-docs/plugins/sidebar-views.md | 177 ++++++++ public-docs/plugins/sidebar-views.zh-CN.md | 135 ++++++ 106 files changed, 4385 insertions(+), 269 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-views.spec.ts create mode 100644 packages/app/e2e/support/helpers/sidebar-views.ts create mode 100644 packages/app/src/plugins/client-state/api.ts create mode 100644 packages/app/src/plugins/client-state/connected-hosts.ts create mode 100644 packages/app/src/plugins/client-state/fixtures.ts create mode 100644 packages/app/src/plugins/client-state/refs.ts create mode 100644 packages/app/src/plugins/client-state/source-retention.test.ts create mode 100644 packages/app/src/plugins/client-state/source.browser.test.tsx create mode 100644 packages/app/src/plugins/client-state/terminals.ts create mode 100644 packages/app/src/plugins/settings/device.test.ts create mode 100644 packages/app/src/plugins/settings/device.ts create mode 100644 packages/app/src/plugins/sidebar-view/catalog.ts create mode 100644 packages/app/src/plugins/sidebar-view/frame.browser.test.tsx create mode 100644 packages/app/src/plugins/sidebar-view/frame.tsx create mode 100644 packages/app/src/plugins/sidebar-view/index.tsx create mode 100644 packages/app/src/plugins/sidebar-view/picker.tsx create mode 100644 packages/app/src/plugins/sidebar-view/selection.ts create mode 100644 packages/plugin/src/client/query.test.ts create mode 100644 packages/plugin/src/client/query.ts create mode 100644 packages/plugin/src/client/reference-api.ts create mode 100644 packages/plugin/src/client/refs.test.ts create mode 100644 packages/plugin/src/client/refs.ts create mode 100644 packages/plugin/src/refs.ts create mode 100644 plugin-examples/sidebar-views/client/agents.tsx create mode 100644 plugin-examples/sidebar-views/client/labels.tsx create mode 100644 plugin-examples/sidebar-views/client/nested.tsx create mode 100644 plugin-examples/sidebar-views/client/rows.tsx create mode 100644 plugin-examples/sidebar-views/client/terminals.tsx create mode 100644 plugin-examples/sidebar-views/client/workspaces.tsx create mode 100644 plugin-examples/sidebar-views/index.client.tsx create mode 100644 plugin-examples/sidebar-views/paseo-plugin.json create mode 100644 plugin-examples/sidebar-views/shared/preferences.ts create mode 100644 public-docs/plugins/sidebar-views.md create mode 100644 public-docs/plugins/sidebar-views.zh-CN.md diff --git a/docs/plugins.md b/docs/plugins.md index db6151f9de..2da369f4a5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource( When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. +## Replace sidebar content + +The sidebar view slot is global across hosts, while header and footer contributions retain their +host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns +its layout. This keeps plugin failures from removing navigation or recovery controls. The +[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API. + ## Contribute sidebar items Sidebar header and footer items are plugin components, not descriptors. The diff --git a/packages/app/e2e/browser/sidebar-views.spec.ts b/packages/app/e2e/browser/sidebar-views.spec.ts new file mode 100644 index 0000000000..665b682b53 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-views.spec.ts @@ -0,0 +1,18 @@ +import { test } from "../support/fixtures"; +import { withSidebarViews } from "../support/helpers/sidebar-views"; + +test("sidebar views replace the content, preserve chrome, and recover to the default", async ({ + page, +}) => { + test.setTimeout(150_000); + await withSidebarViews(page, async (views) => { + await views.expectDefault(); + await views.chooseWorkspaces(); + await views.exerciseExamples(); + await views.exerciseTerminals(); + await views.restoreDefault(); + await views.recoverFromFailure(); + await views.chooseWorkspaces(); + await views.disableChosenView(); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 63034d52a3..23696cf4ce 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "createWorkspace" + | "createTerminal" + | "killTerminal" | "fetchAgents" | "fetchWorkspaces" | "getPaseoWorktreeList" @@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick< | "removePlugin" | "reloadPlugin" | "setWorkspaceTitle" + | "setWorkspaceLabel" >; type CreateWorkspacePayload = Awaited>; diff --git a/packages/app/e2e/support/helpers/sidebar-views.ts b/packages/app/e2e/support/helpers/sidebar-views.ts new file mode 100644 index 0000000000..74952d3ff6 --- /dev/null +++ b/packages/app/e2e/support/helpers/sidebar-views.ts @@ -0,0 +1,191 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { copyPluginExample } from "./plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { seedWorkspace } from "./seed-client"; +import { gotoWorkspace } from "./launcher"; +import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings"; +import { visibleTestId } from "./plugin-sidebar-items"; +import { getServerId } from "./server-id"; + +export async function withSidebarViews( + page: Page, + run: (views: ReturnType) => Promise, +) { + const example = await copyPluginExample("sidebar-views"); + const entry = path.join(example.directory, "index.client.tsx"); + const original = await readFile(entry, "utf8"); + await writeFile( + entry, + original.replace( + "const cleanups = [", + 'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [', + ), + ); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const workspace = await seedWorkspace({ + repoPrefix: "sidebar-views-", + title: "Sidebar view workspace", + }); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await run(sidebarViews(page, workspace.workspaceId, client)); + } finally { + await client.removePlugin("sidebar-views"); + await client.close(); + await workspace.cleanup(); + await example.cleanup(); + } +} +function sidebarViews( + page: Page, + workspaceId: string, + client: Awaited>, +) { + const builtinRow = () => + visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`); + async function choose(title: string) { + await openSidebarNavSettings(page); + await page.getByRole("button", { name: /^View:/ }).click(); + await page.getByRole("menuitem", { name: title, exact: true }).click(); + await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible(); + await leaveSettings(page); + } + async function expectDefault() { + await expect(builtinRow()).toBeVisible(); + } + async function expectPlugin() { + await expect( + visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", { + exact: true, + }), + ).toBeVisible(); + await expect(builtinRow()).toHaveCount(0); + await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible(); + await expect(visibleTestId(page, "sidebar-settings")).toBeVisible(); + } + return { + expectDefault, + async chooseWorkspaces() { + await choose("Example workspaces"); + await expectPlugin(); + }, + async exerciseExamples() { + await choose("Example agents"); + await expect( + visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }), + ).toBeVisible(); + await choose("Example nested agents"); + await expectPlugin(); + await choose("Example label folders"); + const slot = visibleTestId(page, "plugin-sidebar-view"); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toContain("Inbox"); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Later", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Later"]); + await client.setWorkspaceLabel({ + workspaceId, + label: { name: "Unlabelled", color: "red" }, + assigned: true, + }); + await client.setWorkspaceLabel({ + workspaceId, + label: { name: "Later", color: "red" }, + assigned: false, + }); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Unlabelled"]); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Inbox"]); + }, + async exerciseTerminals() { + await choose("Example terminals"); + const slot = visibleTestId(page, "plugin-sidebar-view"); + await slot + .getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true }) + .click(); + await expect(slot.getByText("No terminals", { exact: true })).toBeVisible(); + const workspace = (await client.fetchWorkspaces()).entries.find( + (item) => item.id === workspaceId, + )!; + const created = await client.createTerminal( + workspace.workspaceDirectory, + "Sidebar terminal", + undefined, + { workspaceId }, + ); + if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed"); + try { + await expect( + slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`), + ).toBeVisible(); + await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click(); + await expect(page.getByTestId("terminal-surface").first()).toBeVisible(); + await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click(); + await expect + .poll(async () => (await client.listTerminals()).terminals.map((item) => item.id)) + .not.toContain(created.terminal!.id); + await expect(slot.getByText("No terminals", { exact: true })).toBeVisible(); + } finally { + await client.killTerminal(created.terminal.id); + } + }, + async restoreDefault() { + await choose("Default view"); + await expectDefault(); + }, + async recoverFromFailure() { + await choose("Broken example"); + await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Use default view", exact: true }).click(); + await expectDefault(); + }, + async disableChosenView() { + await client.disablePlugin("sidebar-views"); + await expectDefault(); + await openSidebarNavSettings(page); + await expect( + page.getByText("The selected view is unavailable or disabled. Showing Default view.", { + exact: true, + }), + ).toBeVisible(); + await leaveSettings(page); + }, + }; +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 1b2df08302..7504b175aa 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,3 +1,4 @@ +import { SidebarViewSlot } from "@/plugins/sidebar-view"; import { useAddHost } from "@/hosts/add-host"; import { router } from "expo-router"; import { @@ -642,31 +643,33 @@ function MobileSidebar({ )} - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 6599c28467..d6d9011259 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} subscriptionId: string; }>; unsubscribeCheckoutDiffCalls: string[]; - subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; - unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; + subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>; + unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>; } { const handlers: Record = { providers_snapshot_update: [], @@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} subscriptionId: string; }> = []; const unsubscribeCheckoutDiffCalls: string[] = []; - const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = []; - const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = []; + const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = []; + const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = []; function on( type: K, diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index e2b25d7b29..e80ee5f32a 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -41,8 +41,11 @@ export function useReplicaQuery< TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(input: ReplicaQueryInput): UseQueryResult { - return useQuery(replicaQueryOptions(input)); +>( + input: ReplicaQueryInput, + queryClient?: QueryClient, +): UseQueryResult { + return useQuery(replicaQueryOptions(input), queryClient); } export function useFetchQuery< diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9b3c41f663..4b93a54632 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1179,6 +1179,14 @@ export const ar: TranslationResources = { }, }, sidebar: { + view: { + failed: "تعذر تحميل عرض الشريط الجانبي.", + useDefault: "استخدام العرض الافتراضي", + readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.", + agentUnavailable: "لم يعد الوكيل متاحًا", + terminalUnavailable: "لم تعد الطرفية متاحة", + projectUnavailable: "لم يعد المشروع متاحًا", + }, statusGroupAccessibility: "مجموعة {{label}}", statusBucket: { needsInput: "تحتاج إدخالاً", @@ -2607,6 +2615,12 @@ export const ar: TranslationResources = { description: "عرض مخطط للتنقل بين المطالبات", }, sidebar: { + view: { + title: "عرض الشريط الجانبي", + label: "العرض", + default: "العرض الافتراضي", + unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.", + }, header: { title: "الرأس", description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ecb2890c93..9ad175d5b9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1187,6 +1187,14 @@ export const en = { }, }, sidebar: { + view: { + failed: "Sidebar view failed to load.", + useDefault: "Use default view", + readFailed: "Could not load your sidebar view preference.", + agentUnavailable: "Agent is no longer available", + terminalUnavailable: "Terminal is no longer available", + projectUnavailable: "Project is no longer available", + }, statusGroupAccessibility: "{{label}} group", statusBucket: { needsInput: "Needs input", @@ -2698,6 +2706,12 @@ export const en = { description: "Show an outline for jumping between prompts", }, sidebar: { + view: { + title: "Sidebar view", + label: "View", + default: "Default view", + unavailable: "The selected view is unavailable or disabled. Showing Default view.", + }, header: { title: "Header", description: "Choose which items appear at the top of the sidebar and in what order", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 11968541d4..38ba72c77f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1215,6 +1215,14 @@ export const es: TranslationResources = { }, }, sidebar: { + view: { + failed: "No se pudo cargar la vista de la barra lateral.", + useDefault: "Usar vista predeterminada", + readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.", + agentUnavailable: "El agente ya no está disponible", + terminalUnavailable: "La terminal ya no está disponible", + projectUnavailable: "El proyecto ya no está disponible", + }, statusGroupAccessibility: "Grupo {{label}}", statusBucket: { needsInput: "Necesita datos", @@ -2627,6 +2635,13 @@ export const es: TranslationResources = { description: "Muestra un esquema para saltar entre instrucciones", }, sidebar: { + view: { + title: "Vista de la barra lateral", + label: "Vista", + default: "Vista predeterminada", + unavailable: + "La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.", + }, header: { title: "Encabezado", description: diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4f31acf15a..01b438b7e3 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1208,6 +1208,14 @@ export const fr: TranslationResources = { }, }, sidebar: { + view: { + failed: "Impossible de charger la vue de la barre latérale.", + useDefault: "Utiliser la vue par défaut", + readFailed: "Impossible de charger votre préférence de vue de la barre latérale.", + agentUnavailable: "Cet agent n’est plus disponible", + terminalUnavailable: "Ce terminal n’est plus disponible", + projectUnavailable: "Ce projet n’est plus disponible", + }, statusGroupAccessibility: "Groupe {{label}}", statusBucket: { needsInput: "Attend une réponse", @@ -2628,6 +2636,13 @@ export const fr: TranslationResources = { description: "Afficher un plan pour naviguer entre les prompts", }, sidebar: { + view: { + title: "Vue de la barre latérale", + label: "Vue", + default: "Vue par défaut", + unavailable: + "La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.", + }, header: { title: "En-tête", description: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4913934cfd..52419d9851 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1192,6 +1192,14 @@ export const ja: TranslationResources = { }, }, sidebar: { + view: { + failed: "サイドバーのビューを読み込めませんでした。", + useDefault: "デフォルトのビューを使用", + readFailed: "サイドバーのビュー設定を読み込めませんでした。", + agentUnavailable: "このエージェントは利用できなくなりました", + terminalUnavailable: "このターミナルは利用できなくなりました", + projectUnavailable: "このプロジェクトは利用できなくなりました", + }, statusGroupAccessibility: "{{label}} グループ", statusBucket: { needsInput: "入力待ち", @@ -2594,6 +2602,13 @@ export const ja: TranslationResources = { description: "プロンプト間を移動するためのアウトラインを表示します", }, sidebar: { + view: { + title: "サイドバーのビュー", + label: "ビュー", + default: "デフォルトのビュー", + unavailable: + "選択したビューは利用できないか無効です。デフォルトのビューを表示しています。", + }, header: { title: "ヘッダー", description: "サイドバー上部に表示する項目とその順序を選択します", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 76c101c1d1..173dc82268 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1186,6 +1186,14 @@ export const ko: TranslationResources = { }, }, sidebar: { + view: { + failed: "사이드바 보기를 불러오지 못했습니다.", + useDefault: "기본 보기 사용", + readFailed: "사이드바 보기 설정을 불러오지 못했습니다.", + agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다", + terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다", + projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다", + }, statusGroupAccessibility: "{{label}} 그룹", statusBucket: { needsInput: "입력 필요", @@ -2585,6 +2593,13 @@ export const ko: TranslationResources = { description: "프롬프트 사이를 이동하기 위한 개요 표시", }, sidebar: { + view: { + title: "사이드바 보기", + label: "보기", + default: "기본 보기", + unavailable: + "선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.", + }, header: { title: "헤더", description: "사이드바 상단에 표시할 항목과 순서를 선택하세요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index cbd13dfd60..4f38145a6d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1205,6 +1205,14 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + view: { + failed: "Não foi possível carregar a visualização da barra lateral.", + useDefault: "Usar visualização padrão", + readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.", + agentUnavailable: "O agente não está mais disponível", + terminalUnavailable: "O terminal não está mais disponível", + projectUnavailable: "O projeto não está mais disponível", + }, statusGroupAccessibility: "Grupo {{label}}", statusBucket: { needsInput: "Precisa de resposta", @@ -2609,6 +2617,13 @@ export const ptBR: TranslationResources = { description: "Mostrar uma estrutura para navegar entre prompts", }, sidebar: { + view: { + title: "Visualização da barra lateral", + label: "Visualização", + default: "Visualização padrão", + unavailable: + "A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.", + }, header: { title: "Cabeçalho", description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ee788167f5..d06d2f3154 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1196,6 +1196,14 @@ export const ru: TranslationResources = { }, }, sidebar: { + view: { + failed: "Не удалось загрузить вид боковой панели.", + useDefault: "Использовать стандартный вид", + readFailed: "Не удалось загрузить настройку вида боковой панели.", + agentUnavailable: "Агент больше недоступен", + terminalUnavailable: "Терминал больше недоступен", + projectUnavailable: "Проект больше недоступен", + }, statusGroupAccessibility: "Группа «{{label}}»", statusBucket: { needsInput: "Ожидает ввода", @@ -2615,6 +2623,12 @@ export const ru: TranslationResources = { description: "Показывать оглавление для перехода между запросами", }, sidebar: { + view: { + title: "Вид боковой панели", + label: "Вид", + default: "Стандартный вид", + unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.", + }, header: { title: "Верх", description: diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ea9d34fd4f..cacc0ea57c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1171,6 +1171,14 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + view: { + failed: "无法加载侧边栏视图。", + useDefault: "使用默认视图", + readFailed: "无法加载侧边栏视图偏好设置。", + agentUnavailable: "此智能体已不可用", + terminalUnavailable: "此终端已不可用", + projectUnavailable: "此项目已不可用", + }, statusGroupAccessibility: "{{label}} 分组", statusBucket: { needsInput: "需要输入", @@ -2544,6 +2552,12 @@ export const zhCN: TranslationResources = { description: "显示用于在提示词之间跳转的大纲", }, sidebar: { + view: { + title: "侧边栏视图", + label: "视图", + default: "默认视图", + unavailable: "所选视图不可用或已禁用。正在显示默认视图。", + }, header: { title: "顶部", description: "选择侧边栏顶部显示的项目及其顺序", diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index b61eae7c1d..257d198d24 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { PluginAgentCommandContext, @@ -32,7 +33,7 @@ export function createPluginCapabilities( navigation.openSurface(plugin.id, screenId, params); } return { - paseo: plugin.paseo, + paseo: getPluginClientApi(plugin).paseo, rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input), openSettings(screenId) { if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index e8f604bb50..6c2bbe91af 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -23,6 +23,7 @@ function installation(): InstalledPlugin { cleanup: () => undefined, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 55cae64e5d..7d9ad09e41 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import appPackage from "../../package.json"; import { afterEach, expect, it } from "vitest"; import { QueryObserver } from "@tanstack/react-query"; @@ -139,7 +140,7 @@ function harness() { }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected runtime RPC"); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index d9439ff8ad..184d24a8f6 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,6 +1,5 @@ import { createPlayAudio, type AudioEngine } from "@/audio"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { createPluginHosts } from "./hosts"; +import { getPluginClientApi } from "./client-state/api"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { subscribePluginRpc } from "@getpaseo/plugin/client/host"; import { @@ -27,7 +26,7 @@ export function createPluginClientRuntime( ...capabilities, i18n: installation.i18n, playAudio: createPlayAudio(audio, installation.lifetime.signal), - hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), + hosts: getPluginClientApi(installation).hosts, addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); }, diff --git a/packages/app/src/plugins/client-state/api.ts b/packages/app/src/plugins/client-state/api.ts new file mode 100644 index 0000000000..4ce3425f9a --- /dev/null +++ b/packages/app/src/plugins/client-state/api.ts @@ -0,0 +1,68 @@ +import { createPluginReferenceApi } from "@getpaseo/plugin/client/host"; +import { router } from "expo-router"; +import { i18n } from "@/i18n/i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import { createPluginHosts } from "../hosts"; +import type { InstalledPlugin } from "../types"; +import { createPluginClientStateSource } from "./source"; + +interface InstallationApi { + paseo: ReturnType; + hosts: ReturnType; +} +// Registry presentation clones share the installation lifetime and its client pool. +const apis = new WeakMap(); + +export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi { + const cached = apis.get(plugin.lifetime.signal); + if (cached) return cached; + const state = createPluginClientStateSource(); + const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal); + const paseo = createPluginReferenceApi({ + paseo: plugin.paseo, + resolveHost: hosts.getPaseoClient, + readProject: state.getProject, + navigation: { + openWorkspace(ref) { + navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id }); + }, + openAgent(ref) { + const agent = state.getAgent(ref); + if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable")); + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: agent.workspace.id, + target: { kind: "agent", agentId: ref.id }, + }); + }, + openTerminal(ref) { + const terminal = state.getTerminal(ref); + if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable")); + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: terminal.workspace.id, + target: { kind: "terminal", terminalId: ref.id }, + }); + }, + openProject(ref) { + const project = state.getProject(ref); + if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable")); + usePanelStore.getState().showMobileAgent(); + router.push( + buildNewWorkspaceRoute({ + serverId: ref.hostId, + projectId: ref.id, + sourceDirectory: project.rootPath, + displayName: project.displayName, + }), + ); + }, + }, + }); + const api = { paseo, hosts }; + apis.set(plugin.lifetime.signal, api); + return api; +} diff --git a/packages/app/src/plugins/client-state/connected-hosts.ts b/packages/app/src/plugins/client-state/connected-hosts.ts new file mode 100644 index 0000000000..4dfffa6b8d --- /dev/null +++ b/packages/app/src/plugins/client-state/connected-hosts.ts @@ -0,0 +1,72 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import type { PluginConnectedHostsSource } from "./source"; + +interface PluginHostsRuntime { + getHosts(): readonly { serverId: string }[]; + getSnapshot(serverId: string): { connectionStatus: string } | null | undefined; + subscribeAll(listener: () => void): () => void; + subscribeHostList(listener: () => void): () => void; + acquireDirectoryDemand(serverId: string): () => void; +} + +export function createConnectedPluginHosts( + getRuntime: () => PluginHostsRuntime, +): PluginConnectedHostsSource { + let snapshot: readonly string[] = []; + const listeners = new Set<() => void>(); + let release: (() => void) | null = null; + + const source: PluginConnectedHostsSource = { + getSnapshot() { + const runtime = getRuntime(); + const next = runtime + .getHosts() + .filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online") + .map((host) => host.serverId); + if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index])) + snapshot = Object.freeze(next); + return snapshot; + }, + subscribe(listener) { + listeners.add(listener); + if (!release) { + const runtime = getRuntime(); + const demands = new Map void>(); + const reconcile = () => { + const ids = new Set(source.getSnapshot()); + for (const [id, releaseDemand] of demands) { + if (!ids.has(id)) { + demands.delete(id); + releaseDemand(); + } + } + for (const id of ids) { + if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id)); + } + }; + const notify = () => { + reconcile(); + for (const subscriber of listeners) subscriber(); + }; + const releaseRuntime = runtime.subscribeAll(notify); + const releaseHosts = runtime.subscribeHostList(notify); + reconcile(); + release = () => { + releaseRuntime(); + releaseHosts(); + for (const releaseDemand of demands.values()) releaseDemand(); + demands.clear(); + }; + } + return () => { + listeners.delete(listener); + if (listeners.size === 0 && release) { + release(); + release = null; + } + }; + }, + }; + return source; +} +export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore); diff --git a/packages/app/src/plugins/client-state/fixtures.ts b/packages/app/src/plugins/client-state/fixtures.ts new file mode 100644 index 0000000000..4439e385f4 --- /dev/null +++ b/packages/app/src/plugins/client-state/fixtures.ts @@ -0,0 +1,53 @@ +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +export const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + workspaceDirectory: "/repo/paseo/review", + projectKind: "git", + workspaceKind: "worktree", + name: "Review", + title: "Plugin review", + pinnedAt: null, + status: "running", + statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"), + archivingAt: null, + diffStat: { additions: 12, deletions: 3 }, + scripts: [], +}; + +export const agent: Agent = { + serverId: "host-1", + id: "agent-1", + provider: "codex", + status: "running", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date("2026-08-16T10:01:00.000Z"), + updatedAt: new Date("2026-08-16T10:02:00.000Z"), + lastUserMessageAt: null, + lastActivityAt: new Date("2026-08-16T10:03:00.000Z"), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "code", + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Implement plugin panels", + cwd: "/repo/paseo/review", + workspaceId: "workspace-1", + model: "gpt-5.6", + thinkingOptionId: "high", + requiresAttention: true, + attentionReason: "permission", + parentAgentId: null, + labels: { phase: "implementation" }, +}; diff --git a/packages/app/src/plugins/client-state/refs.ts b/packages/app/src/plugins/client-state/refs.ts new file mode 100644 index 0000000000..edfdd9562c --- /dev/null +++ b/packages/app/src/plugins/client-state/refs.ts @@ -0,0 +1,3 @@ +import { createEntityRefs } from "@getpaseo/plugin/client/host"; + +export const entityRefs = createEntityRefs(); diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 0c90185e53..7d63b178ed 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -1,66 +1,20 @@ +import { i18n } from "@/i18n/i18next"; +import { fr } from "@/i18n/resources/fr"; import { describe, expect, it } from "vitest"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; -import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; - -const workspace: WorkspaceDescriptor = { - createdAt: null, - lastUserMessageAt: null, - id: "workspace-1", - projectId: "project-1", - projectDisplayName: "Paseo", - projectRootPath: "/repo/paseo", - workspaceDirectory: "/repo/paseo/review", - projectKind: "git", - workspaceKind: "worktree", - name: "Review", - title: "Plugin review", - pinnedAt: null, - status: "running", - statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"), - archivingAt: null, - diffStat: { additions: 12, deletions: 3 }, - scripts: [], -}; - -const agent: Agent = { - serverId: "host-1", - id: "agent-1", - provider: "codex", - status: "running", - turn: { phase: "idle", cancellationRequestId: null }, - createdAt: new Date("2026-08-16T10:01:00.000Z"), - updatedAt: new Date("2026-08-16T10:02:00.000Z"), - lastUserMessageAt: null, - lastActivityAt: new Date("2026-08-16T10:03:00.000Z"), - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: "code", - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Implement plugin panels", - cwd: "/repo/paseo/review", - workspaceId: "workspace-1", - model: "gpt-5.6", - thinkingOptionId: "high", - requiresAttention: true, - attentionReason: "permission", - parentAgentId: null, - labels: { phase: "implementation" }, -}; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + createPluginAgentSnapshot, + createPluginWorkspaceSnapshot, + createPluginProjectSnapshot, +} from "./snapshots"; +import { workspace, agent } from "./fixtures"; describe("plugin context snapshots", () => { it("commits a deeply immutable normalized workspace snapshot synchronously", () => { - const snapshot = createPluginWorkspaceSnapshot(workspace); - expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot); + const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1"); + expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot); - expect(snapshot).toEqual({ + expect(snapshot).toMatchObject({ id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", @@ -90,7 +44,7 @@ describe("plugin context snapshots", () => { const snapshot = createPluginAgentSnapshot(agent, workspace.id); expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot); - expect(snapshot).toEqual({ + expect(snapshot).toMatchObject({ id: "agent-1", workspaceId: "workspace-1", provider: "codex", @@ -119,3 +73,82 @@ describe("plugin context snapshots", () => { expect(agent.labels).toEqual({ phase: "implementation" }); }); }); + +describe("sidebar entity snapshots", () => { + it("interns cross-host references and preserves identity across unrelated reads", () => { + const first = createPluginWorkspaceSnapshot(workspace, "host-1"); + const second = createPluginWorkspaceSnapshot(workspace, "host-2"); + expect(first.ref).not.toBe(second.ref); + expect(first.ref.hostId).toBe("host-1"); + expect(first.project.id).toBe(workspace.projectId); + expect(Object.isFrozen(first.labels)).toBe(true); + expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first); + expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref); + }); + + it("invalidates a done workspace when its effective root-agent status changes", () => { + const idle: WorkspaceDescriptor = { ...workspace, status: "done" }; + const before = createPluginWorkspaceSnapshot(idle, "host-1"); + const activity = new Map([ + [idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }], + ]); + const after = createPluginWorkspaceSnapshot(idle, "host-1", { + workspaceAgentActivity: activity, + }); + expect(before.indicator).toBe("done"); + expect(after.status).toBe("done"); + expect(after.indicator).toBe("needs_input"); + expect(after.indicatorLabel).toBe("Needs input"); + expect(after.ref).toBe(before.ref); + expect( + createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }), + ).toBe(after); + }); + + it("reads projects without requiring a workspace", () => { + const project = { + projectId: "empty", + projectDisplayName: "Empty", + projectCustomName: null, + projectRootPath: "/empty", + projectKind: "git" as const, + }; + const snapshot = createPluginProjectSnapshot(project, "host-1"); + expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot); + expect(snapshot.displayName).toBe("Empty"); + expect(snapshot.ref.id).toBe("empty"); + expect(Object.isFrozen(snapshot)).toBe(true); + }); +}); + +it("uses the app status labels and invalidates a snapshot on language change", async () => { + i18n.addResourceBundle("fr", "translation", fr); + const previousLanguage = i18n.language; + try { + await i18n.changeLanguage("en"); + const english = createPluginWorkspaceSnapshot(workspace, "host-1"); + expect(english.indicatorLabel).toBe("Working"); + await i18n.changeLanguage("fr"); + const french = createPluginWorkspaceSnapshot(workspace, "host-1"); + expect(french).not.toBe(english); + expect(french.ref).toBe(english.ref); + expect(french.indicatorLabel).toBe("En cours"); + expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french); + } finally { + await i18n.changeLanguage(previousLanguage); + } +}); + +it("preserves the host-qualified parent of a remotely delegated agent", () => { + const child = { + ...agent, + serverId: "host-2", + parentAgentId: null, + labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" }, + }; + expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({ + hostId: "host-1", + id: "parent", + kind: "agent", + }); +}); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index 67a08830cd..ac810cf8f0 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -1,16 +1,57 @@ -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; +import { i18n } from "@/i18n/i18next"; +import type { + PluginAgentSnapshot, + PluginWorkspaceSnapshot, + PluginProjectSnapshot, +} from "@getpaseo/plugin"; +import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store"; -const workspaceSnapshots = new WeakMap(); +import { entityRefs } from "./refs"; +import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; + +interface CachedWorkspace { + language: string; + snapshot: PluginWorkspaceSnapshot; +} +const workspaceSnapshots = new WeakMap>(); const agentSnapshots = new WeakMap>(); export function createPluginWorkspaceSnapshot( workspace: WorkspaceDescriptor, + hostId: string, + context: Omit[0], "workspace" | "serverId"> & { + parentAgent?: PluginWorkspaceSnapshot["parentAgent"]; + } = {}, ): PluginWorkspaceSnapshot { - const cached = workspaceSnapshots.get(workspace); - if (cached) return cached; + const indicator = deriveEffectiveWorkspaceStatus({ + ...context, + workspace, + serverId: hostId, + }).status; + const parentAgent = context.parentAgent ?? null; + const cached = workspaceSnapshots.get(workspace)?.get(hostId); + if ( + cached && + cached.language === i18n.language && + cached.snapshot.indicator === indicator && + cached.snapshot.parentAgent === parentAgent + ) + return cached.snapshot; const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null; const snapshot = Object.freeze({ + ref: entityRefs.workspace(hostId, workspace.id), + hostId, + project: entityRefs.project(hostId, workspace.projectId), + pinnedAt: workspace.pinnedAt ?? null, + labels: Object.freeze([...(workspace.labels ?? [])]), + background: workspace.background ?? false, + branch: workspace.gitRuntime?.currentBranch ?? null, + createdAt: serializeTimestamp(workspace.createdAt), + lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt), + parentAgent, + indicator, + indicatorLabel: i18n.t(indicatorLabelKeys[indicator]), id: workspace.id, projectId: workspace.projectId, projectDisplayName: workspace.projectDisplayName, @@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot( name: workspace.name, title: workspace.title ?? null, status: workspace.status, - statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null, + statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt), archivingAt: workspace.archivingAt, diffStat, }); - workspaceSnapshots.set(workspace, snapshot); + const byHost = workspaceSnapshots.get(workspace) ?? new Map(); + byHost.set(hostId, { language: i18n.language, snapshot }); + workspaceSnapshots.set(workspace, byHost); return snapshot; } export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot { const cached = agentSnapshots.get(agent)?.get(workspaceId); if (cached) return cached; + const parent = getParentAddressFromLabels(agent.labels, agent.serverId); const snapshot = Object.freeze({ + ref: entityRefs.agent(agent.serverId, agent.id), + hostId: agent.serverId, + workspace: entityRefs.workspace(agent.serverId, workspaceId), + parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null, id: agent.id, workspaceId, provider: agent.provider, @@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl agentSnapshots.set(agent, byWorkspace); return snapshot; } + +const indicatorLabelKeys = { + needs_input: "sidebar.statusBucket.needsInput", + failed: "sidebar.statusBucket.failed", + running: "sidebar.statusBucket.working", + attention: "sidebar.statusBucket.readyToReview", + done: "sidebar.statusBucket.done", +} as const; +const projectSnapshots = new WeakMap>(); + +export function createPluginProjectSnapshot( + project: ProjectDescriptor, + hostId: string, +): PluginProjectSnapshot { + const cached = projectSnapshots.get(project)?.get(hostId); + if (cached) return cached; + const snapshot = Object.freeze({ + ref: entityRefs.project(hostId, project.projectId), + hostId, + displayName: project.projectCustomName ?? project.projectDisplayName, + rootPath: project.projectRootPath, + kind: project.projectKind, + iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null, + }); + const byHost = projectSnapshots.get(project) ?? new Map(); + byHost.set(hostId, snapshot); + projectSnapshots.set(project, byHost); + return snapshot; +} + +function serializeTimestamp(value: Date | null): string | null { + return value ? value.toISOString() : null; +} diff --git a/packages/app/src/plugins/client-state/source-retention.test.ts b/packages/app/src/plugins/client-state/source-retention.test.ts new file mode 100644 index 0000000000..b11fc5d731 --- /dev/null +++ b/packages/app/src/plugins/client-state/source-retention.test.ts @@ -0,0 +1,55 @@ +import { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { setImmediate } from "node:timers/promises"; +import { expect, it } from "vitest"; +import { useSessionStore } from "@/stores/session-store"; +import { createPluginClientStateSource } from "./source"; +import { workspace } from "./fixtures"; + +setFlagsFromString("--expose_gc"); +const collectGarbage: () => void = runInNewContext("gc"); +setFlagsFromString("--no-expose_gc"); + +function observeHost() { + const hostId = "removed-plugin-host"; + let hosts = [hostId]; + const store = useSessionStore.getState(); + store.initializeSession(hostId, null); + store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]])); + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => hosts, subscribe: () => () => {} }, + }); + const release = source.subscribe(() => source.getSnapshot()); + source.getSnapshot(); + const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces); + const remove = () => { + hosts = []; + store.clearSession(hostId); + }; + return { reference, release, remove }; +} + +it("releases removed hosts' projected data while other plugin readers remain", async () => { + const { reference, release, remove } = observeHost(); + remove(); + try { + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + collectGarbage(); + } + expect(reference.deref()).toBeUndefined(); + } finally { + release(); + } +}); + +it("releases previous projections when the last plugin reader leaves", async () => { + const { reference, release, remove } = observeHost(); + release(); + remove(); + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + collectGarbage(); + } + expect(reference.deref()).toBeUndefined(); +}); diff --git a/packages/app/src/plugins/client-state/source.browser.test.tsx b/packages/app/src/plugins/client-state/source.browser.test.tsx new file mode 100644 index 0000000000..46e31c7cab --- /dev/null +++ b/packages/app/src/plugins/client-state/source.browser.test.tsx @@ -0,0 +1,420 @@ +import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested"; +import { lightTheme } from "@/styles/theme"; +import { toPluginTheme } from "../theme"; +import { createConnectedPluginHosts } from "./connected-hosts"; +import { i18n } from "@/i18n/i18next"; +import { fr } from "@/i18n/resources/fr"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import { + useAgents, + useWorkspaces, + useWorkspace, + useAgent, + type WorkspaceRef, + type AgentRef, +} from "@getpaseo/plugin/client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source"; +import { agent, workspace } from "./fixtures"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; +const renders = new Map(); +function count(key: string) { + renders.set(key, (renders.get(key) ?? 0) + 1); +} +function WorkspaceRow({ reference }: { reference: WorkspaceRef }) { + const item = useWorkspace(reference); + count(reference.key); + return ( +
+ {item?.name}:{item?.indicator} +
+ ); +} +function Workspaces() { + count("workspaces"); + const items = useWorkspaces({ orderBy: "name" }); + return ( +
+ {items.map((reference) => ( + + ))} +
+ ); +} +function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) { + const name = useWorkspace(reference, (item) => ({ name: item.name })); + const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name); + count("selected"); + return ( +
+ {name?.name}:{custom?.name} +
+ ); +} +function AgentRow({ reference }: { reference: AgentRef }) { + const item = useAgent(reference); + return
{item?.title}
; +} +function AgentSection({ section }: { section: string }) { + const agents = useAgents({ + where: (item) => (item.requiresAttention ? "waiting" : item.status) === section, + }); + count(section); + return ( +
+ {agents.map((reference) => ( + + ))} +
+ ); +} +function Agents() { + return ( +
+ + + +
+ ); +} +function seedHost(hostId: string) { + const store = useSessionStore.getState(); + store.initializeSession(hostId, null); + store.setWorkspaces( + hostId, + new Map([ + ["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }], + ["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }], + ]), + ); + store.setProjects(hostId, [ + { + projectId: "empty", + projectDisplayName: "Empty project", + projectCustomName: null, + projectRootPath: "/empty", + projectKind: "git", + }, + ]); + store.setAgents( + hostId, + new Map([ + [ + "agent-1", + { + ...agent, + serverId: hostId, + id: "agent-1", + workspaceId: "workspace-1", + status: "running", + requiresAttention: false, + attentionReason: null, + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, + ], + [ + "agent-2", + { + ...agent, + serverId: hostId, + id: "agent-2", + workspaceId: "workspace-2", + status: "running", + requiresAttention: false, + attentionReason: null, + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, + ], + ]), + ); +} +it("keeps archived agents readable individually while excluding them from collections", () => { + seedHost("host-1"); + const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() }); + const reference = source.getSnapshot().agents[0]!.ref; + useSessionStore.getState().setAgents( + "host-1", + new Map([ + [ + agent.id, + { + ...agent, + archivedAt: new Date("2026-11-01T00:00:00.000Z"), + }, + ], + ]), + ); + expect(source.getSnapshot().agents).toEqual([]); + expect(source.getAgent(reference)?.title).toBe(agent.title); + expect(source.getAgent(agent.id)).toBe(source.getAgent(reference)); +}); + +it("demands directories for connected hosts and releases them with its last reader", () => { + const hostId = "plugin-demand-host"; + let online = true; + let demands = 0; + const listeners = new Set<() => void>(); + const hosts = createConnectedPluginHosts(() => ({ + getHosts: () => [{ serverId: hostId }], + getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }), + subscribeAll: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + subscribeHostList: () => () => {}, + acquireDirectoryDemand: () => { + demands++; + seedHost(hostId); + return () => { + demands--; + }; + }, + })); + const source = createPluginClientStateSource(undefined, { hosts }); + const first = source.subscribe(() => {}); + const second = source.subscribe(() => {}); + expect(demands).toBe(1); + expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]); + useSessionStore + .getState() + .setWorkspaces( + hostId, + (previous) => + new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])), + ); + expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([ + "Live update", + "Live update", + ]); + online = false; + for (const listener of listeners) listener(); + expect(demands).toBe(0); + expect(source.getSnapshot().workspaces).toEqual([]); + online = true; + for (const listener of listeners) listener(); + expect(demands).toBe(1); + first(); + expect(demands).toBe(1); + second(); + expect(demands).toBe(0); + expect(listeners.size).toBe(0); +}); + +function waitForInput() { + useSessionStore.getState().setAgents("host-1", (previous) => { + const current = previous.get("agent-1"); + if (!current) throw new Error("Missing test agent"); + const next = new Map(previous); + next.set(current.id, { + ...current, + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + requiresAttention: true, + attentionReason: "permission", + }); + return next; + }); +} +class ConnectedHosts implements PluginConnectedHostsSource { + private ids: readonly string[] = ["host-1"]; + private listeners = new Set<() => void>(); + getSnapshot = () => this.ids; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + connect(id: string) { + this.ids = [...this.ids, id]; + for (const listener of this.listeners) listener(); + } +} +function mount(children: React.ReactNode, hosts = new ConnectedHosts()) { + const source = createPluginClientStateSource("host-1", { hosts }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render({children}), + ); + return source; +} +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + useSessionStore.getState().clearSession("host-1"); + useSessionStore.getState().clearSession("host-2"); + renders.clear(); +}); + +it("shows a restored child in the nested example while its cached parent remains archived", () => { + seedHost("host-1"); + const parent = { + ...agent, + id: "archived-parent", + title: "Archived parent", + archivedAt: new Date("2026-11-01T00:00:00Z"), + }; + const child = { + ...agent, + id: "restored-child", + title: "Restored child", + parentAgentId: parent.id, + labels: { "paseo.parent-agent-id": parent.id }, + archivedAt: parent.archivedAt, + }; + const store = useSessionStore.getState(); + store.setProjects("host-1", [ + { + projectId: workspace.projectId, + projectDisplayName: "Project", + projectCustomName: null, + projectRootPath: workspace.projectRootPath, + projectKind: "git", + }, + ]); + store.setAgents( + "host-1", + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const view = { + theme: toPluginTheme(lightTheme), + layout: { compact: false, platform: "web" as const }, + active: { workspace: null, agent: null }, + navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} }, + dismiss() {}, + }; + const source = mount(); + expect(source.getAgent(parent.id)?.title).toBe(parent.title); + expect(container?.textContent).not.toContain(child.title); + act(() => + store.setAgents( + "host-1", + new Map([ + [parent.id, parent], + [child.id, { ...child, archivedAt: null }], + ]), + ), + ); + expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull(); + expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull(); +}); + +describe("sidebar data hooks through the app store and plugin provider", () => { + it("renders only one workspace row when its root agent starts waiting", () => { + seedHost("host-1"); + const source = mount(); + const first = source.getSnapshot().workspaces[0]!; + const second = source.getSnapshot().workspaces[1]!; + const before = source.getSnapshot(); + act(waitForInput); + expect(renders.get("workspaces")).toBe(1); + expect(renders.get(first.ref.key)).toBe(2); + expect(renders.get(second.ref.key)).toBe(1); + expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input"); + expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]); + }); + it("moves the agent and renders only its two affected sections without memo", () => { + seedHost("host-1"); + mount(); + act(waitForInput); + expect(renders.get("running")).toBe(2); + expect(renders.get("waiting")).toBe(2); + expect(renders.get("idle")).toBe(1); + }); + it("uses shallow selected equality and custom equality without a selector", () => { + seedHost("host-1"); + const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() }); + mount(); + act(waitForInput); + expect(renders.get("selected")).toBe(1); + }); + it("refreshes translated labels through the subscribed source when language changes", async () => { + seedHost("host-1"); + i18n.addResourceBundle("fr", "translation", fr); + const source = mount(); + const before = source.getSnapshot().workspaces[0]!; + const previousLanguage = i18n.language; + try { + await act(async () => { + await i18n.changeLanguage("fr"); + }); + const after = source.getSnapshot().workspaces[0]!; + expect(after).not.toBe(before); + expect(after.indicatorLabel).toBe("En cours"); + expect(after.ref).toBe(before.ref); + expect(renders.get(before.ref.key)).toBe(2); + expect(renders.get("workspaces")).toBe(1); + } finally { + await act(async () => { + await i18n.changeLanguage(previousLanguage); + }); + } + }); + it("adds a newly connected host and exposes projects with no workspaces", () => { + seedHost("host-1"); + seedHost("host-2"); + const hosts = new ConnectedHosts(); + const source = mount(, hosts); + expect(source.getSnapshot().workspaces).toHaveLength(2); + expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project"); + act(() => hosts.connect("host-2")); + expect(source.getSnapshot().workspaces).toHaveLength(4); + expect(source.getSnapshot().projects).toHaveLength(2); + expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4); + }); +}); + +it("observes terminals only while a terminal reader is subscribed", () => { + let active = 0; + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => [], subscribe: () => () => {} }, + terminals: { + getSnapshot: () => [], + subscribe: () => { + active++; + return () => { + active--; + }; + }, + }, + }); + const releaseWorkspace = source.subscribe(() => {}); + expect(active).toBe(0); + const releaseTerminal = source.subscribe(() => {}, { terminals: true }); + const releaseSecond = source.subscribe(() => {}, { terminals: true }); + expect(active).toBe(1); + releaseTerminal(); + expect(active).toBe(1); + releaseSecond(); + expect(active).toBe(0); + releaseWorkspace(); +}); + +it("terminal row reads propagate only the referenced host's observation error", () => { + const error = new Error("Update the host"); + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => [], subscribe: () => () => {} }, + terminals: { + getSnapshot: () => [], + subscribe: () => () => {}, + getErrors: () => new Map([["unsupported", error]]), + }, + }); + const refs = createEntityRefs(); + expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error); + expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull(); +}); diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index 142c1f3575..137c6624cb 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -1,21 +1,248 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; -import { useSessionStore } from "@/stores/session-store"; -import { selectWorkspace } from "@/stores/session-store-hooks/selectors"; +import { i18n } from "@/i18n/i18next"; +import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host"; +import type { PluginTerminalSnapshot } from "@getpaseo/plugin"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; +import { connectedPluginHosts } from "./connected-hosts"; +import { pluginTerminals } from "./terminals"; +import { entityRefs } from "./refs"; +import { + createPluginAgentSnapshot, + createPluginProjectSnapshot, + createPluginWorkspaceSnapshot, +} from "./snapshots"; -export function createPluginClientStateSource(serverId: string): PluginClientStateSource { - return { - subscribe: useSessionStore.subscribe, - getWorkspace(workspaceId) { - const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId); - return workspace ? createPluginWorkspaceSnapshot(workspace) : null; +export interface PluginConnectedHostsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly string[]; +} + +export interface PluginTerminalsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly PluginTerminalSnapshot[]; + getErrors?(): ReadonlyMap; +} + +const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]); +const listeners = new Set<() => void>(); +let releaseStores: (() => void) | null = null; + +// Every plugin host shares one subscription to each app store. +function subscribeStores(listener: () => void): () => void { + if (!releaseStores) { + const notify = () => { + for (const subscriber of listeners) subscriber(); + }; + i18n.on("languageChanged", notify); + const releaseSession = useSessionStore.subscribe((state) => { + for (const hostId of hostProjections.keys()) { + if (!state.sessions[hostId]) hostProjections.delete(hostId); + } + notify(); + }); + const releaseCreate = useCreateFlowStore.subscribe(notify); + releaseStores = () => { + i18n.off("languageChanged", notify); + releaseSession(); + releaseCreate(); + }; + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && releaseStores) { + releaseStores(); + releaseStores = null; + hostProjections.clear(); + } + }; +} + +interface HostProjection { + language: string; + workspaces: SessionState["workspaces"]; + agents: SessionState["agents"]; + projects: SessionState["projects"]; + activity: SessionState["workspaceAgentActivity"]; + pending: ReturnType["pendingByDraftId"]; + snapshot: Omit; + workspaceById: Map; + agentById: Map; + projectById: Map; +} +const hostProjections = new Map(); + +function projectHost(hostId: string): HostProjection | null { + const session = useSessionStore.getState().sessions[hostId]; + if (!session) { + hostProjections.delete(hostId); + return null; + } + const pending = useCreateFlowStore.getState().pendingByDraftId; + const previous = hostProjections.get(hostId); + const unchanged = + previous && + previous.language === i18n.language && + previous.workspaces === session.workspaces && + previous.agents === session.agents && + previous.projects === session.projects && + previous.activity === session.workspaceAgentActivity && + previous.pending === pending; + if (unchanged) return previous; + const workspaces = Array.from(session.workspaces.values(), (workspace) => { + const activity = session.workspaceAgentActivity.get(workspace.id); + const root = activity ? session.agents.get(activity.agentId) : undefined; + return createPluginWorkspaceSnapshot(workspace, hostId, { + pendingCreateAttempts: pending, + workspaceAgentActivity: session.workspaceAgentActivity, + parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null, + }); + }); + const agents: PluginClientSnapshot["agents"][number][] = []; + for (const agent of session.agents.values()) { + const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (!workspaceId || agent.archivedAt) continue; + agents.push(createPluginAgentSnapshot(agent, workspaceId)); + } + const projects = Array.from(session.projects.values(), (project) => + createPluginProjectSnapshot(project, hostId), + ); + const projection: HostProjection = { + language: i18n.language, + workspaces: session.workspaces, + agents: session.agents, + projects: session.projects, + activity: session.workspaceAgentActivity, + pending, + snapshot: { + workspaces: Object.freeze(workspaces), + agents: Object.freeze(agents), + projects: Object.freeze(projects), }, - getAgent(agentId) { - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; - const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId); - return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null; + workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])), + agentById: new Map(agents.map((agent) => [agent.id, agent])), + projectById: new Map(projects.map((project) => [project.ref.id, project])), + }; + hostProjections.set(hostId, projection); + return projection; +} + +function sameSequence(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */ +export function createPluginClientStateSource( + serverId?: string, + options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {}, +): PluginClientStateSource { + const terminals = options.terminals ?? pluginTerminals; + const hosts = options.hosts ?? connectedPluginHosts; + const subscribers = new Set<() => void>(); + const terminalSubscribers = new Set<() => void>(); + let releaseTerminals: (() => void) | null = null; + let release: (() => void) | null = null; + let previousProjections: readonly HostProjection[] = []; + let previousTerminals = emptyTerminals; + let previousTerminalErrors: ReadonlyMap | undefined; + let snapshot: PluginClientSnapshot = { + workspaces: [], + agents: [], + projects: [], + terminals: emptyTerminals, + }; + function getSnapshot(): PluginClientSnapshot { + const ids = hosts.getSnapshot(); + const projections: HostProjection[] = []; + for (const id of ids) { + const projection = projectHost(id); + if (projection) projections.push(projection); + } + const terminalSnapshots = terminals.getSnapshot(); + const terminalErrors = terminals.getErrors?.(); + if ( + sameSequence(previousProjections, projections) && + previousTerminals === terminalSnapshots && + previousTerminalErrors === terminalErrors + ) + return snapshot; + previousProjections = projections; + previousTerminals = terminalSnapshots; + previousTerminalErrors = terminalErrors; + snapshot = Object.freeze({ + workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)), + agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)), + projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)), + terminals: terminalSnapshots, + terminalErrors, + }); + return snapshot; + } + return { + subscribe(listener, subscriptionOptions) { + subscribers.add(listener); + if (subscriptionOptions?.terminals) { + terminalSubscribers.add(listener); + releaseTerminals ??= terminals.subscribe(() => { + for (const subscriber of terminalSubscribers) subscriber(); + }); + } + if (!release) { + const notify = () => { + for (const subscriber of subscribers) subscriber(); + }; + const releaseStore = subscribeStores(notify); + const releaseHosts = hosts.subscribe(notify); + release = () => { + releaseStore(); + releaseHosts(); + }; + } + return () => { + subscribers.delete(listener); + terminalSubscribers.delete(listener); + if (terminalSubscribers.size === 0 && releaseTerminals) { + releaseTerminals(); + releaseTerminals = null; + } + if (subscribers.size === 0 && release) { + release(); + release = null; + previousProjections = []; + previousTerminals = emptyTerminals; + previousTerminalErrors = undefined; + snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals }; + } + }; + }, + getSnapshot, + getWorkspace(reference) { + const ref = + typeof reference === "string" && serverId + ? entityRefs.workspace(serverId, reference) + : reference; + if (typeof ref === "string") return null; + return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null; + }, + getAgent(reference) { + const ref = + typeof reference === "string" && serverId + ? entityRefs.agent(serverId, reference) + : reference; + if (typeof ref === "string") return null; + const projected = projectHost(ref.hostId)?.agentById.get(ref.id); + if (projected) return projected; + const session = useSessionStore.getState().sessions[ref.hostId]; + const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id); + const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId); + return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null; + }, + getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null, + getTerminal(ref) { + const error = terminals.getErrors?.().get(ref.hostId); + if (error) throw error; + return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null; }, }; } diff --git a/packages/app/src/plugins/client-state/terminals.ts b/packages/app/src/plugins/client-state/terminals.ts new file mode 100644 index 0000000000..6b2630d7e7 --- /dev/null +++ b/packages/app/src/plugins/client-state/terminals.ts @@ -0,0 +1,154 @@ +import type { PluginTerminalSnapshot } from "@getpaseo/plugin"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { TerminalsChanged } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { entityRefs } from "./refs"; +import type { PluginTerminalsSource } from "./source"; + +interface TerminalHost { + serverId: string; + client: DaemonClient; + epoch: number; +} +export interface TerminalHostsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly TerminalHost[]; +} +interface Observation { + client: DaemonClient; + epoch: number; + items: readonly PluginTerminalSnapshot[]; + release(): void; +} + +/** One owned observation per connected host, shared by every plugin surface. */ +export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource { + const observations = new Map(); + const listeners = new Set<() => void>(); + let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]); + let errors: ReadonlyMap = new Map(); + let releaseHosts: (() => void) | null = null; + function publish() { + const next = [...observations.values()].flatMap((item) => item.items); + if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index])) + snapshot = Object.freeze(next); + for (const listener of listeners) listener(); + } + function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) { + if (observations.get(hostId) !== observation) return; + const previous = new Map(observation.items.map((item) => [item.ref.id, item])); + observation.items = Object.freeze( + payload.terminals.flatMap((terminal) => { + // Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace. + if (!terminal.workspaceId) return []; + const ref = entityRefs.terminal(hostId, terminal.id); + const workspace = entityRefs.workspace(hostId, terminal.workspaceId); + const title = terminal.title ?? terminal.name; + const createdAt = terminal.createdAt ?? null; + const isScript = terminal.isScript === true; + const cached = previous.get(terminal.id); + if ( + cached && + cached.workspace === workspace && + cached.title === title && + cached.createdAt === createdAt && + cached.isScript === isScript + ) + return [cached]; + return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })]; + }), + ); + errors = new Map([...errors].filter(([id]) => id !== hostId)); + publish(); + } + function reconcile() { + const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host])); + for (const [id, observation] of observations) { + if ( + active.get(id)?.client !== observation.client || + active.get(id)?.epoch !== observation.epoch + ) { + observations.delete(id); + observation.release(); + } + } + errors = new Map([...errors].filter(([id]) => active.has(id))); + for (const [hostId, { client, epoch }] of active) { + if (observations.has(hostId)) continue; + const observation: Observation = { client, epoch, items: [], release() {} }; + observations.set(hostId, observation); + try { + // The SDK owns the capability gate; a failed host is reported only to terminal readers. + const subscription = client.observeTerminals({}); + const unsubscribe = subscription.subscribe({ + snapshot: (payload) => receive(hostId, observation, payload), + update: (message) => { + if (message.type === "terminals_changed") receive(hostId, observation, message.payload); + }, + error: (error) => { + if (observations.get(hostId) !== observation) return; + errors = new Map(errors).set( + hostId, + error instanceof Error ? error : new Error(String(error)), + ); + publish(); + }, + }); + void subscription.ready.catch(() => {}); // Observer owns error delivery. + observation.release = () => { + unsubscribe(); + void subscription.release().catch(() => {}); + }; + } catch (error) { + errors = new Map(errors).set( + hostId, + error instanceof Error ? error : new Error(String(error)), + ); + } + } + publish(); + } + return { + getSnapshot: () => snapshot, + getErrors: () => errors, + subscribe(listener) { + listeners.add(listener); + if (!releaseHosts) { + releaseHosts = hosts.subscribe(reconcile); + reconcile(); + } + return () => { + listeners.delete(listener); + if (!listeners.size && releaseHosts) { + releaseHosts(); + releaseHosts = null; + for (const observation of observations.values()) observation.release(); + observations.clear(); + snapshot = Object.freeze([]); + errors = new Map(); + } + }; + }, + }; +} + +export const pluginTerminals = createPluginTerminalsSource({ + getSnapshot() { + const runtime = getHostRuntimeStore(); + return runtime.getHosts().flatMap((host) => { + const state = runtime.getSnapshot(host.serverId); + return state?.connectionStatus === "online" && state.client + ? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }] + : []; + }); + }, + subscribe(listener) { + const runtime = getHostRuntimeStore(); + const releaseRuntime = runtime.subscribeAll(listener); + const releaseHosts = runtime.subscribeHostList(listener); + return () => { + releaseRuntime(); + releaseHosts(); + }; + }, +}); diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index e8aea62ede..37a71ff255 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -13,7 +13,20 @@ import { z } from "zod"; import type { InstalledPlugin } from "../types"; import { buildPluginCommandCenterContributions } from "./contributions"; +import { entityRefs } from "../client-state/refs"; const workspace: PluginWorkspaceSnapshot = { + ref: entityRefs.workspace("host-1", "workspace-1"), + hostId: "host-1", + project: entityRefs.project("host-1", "project-1"), + pinnedAt: null, + labels: [], + background: false, + branch: null, + createdAt: null, + lastPromptAt: null, + parentAgent: null, + indicator: "running", + indicatorLabel: "Running", id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", @@ -30,6 +43,10 @@ const workspace: PluginWorkspaceSnapshot = { }; const agent: PluginAgentSnapshot = { + ref: entityRefs.agent("host-1", "agent-1"), + hostId: "host-1", + workspace: workspace.ref, + parentAgent: null, id: "agent-1", workspaceId: workspace.id, provider: "codex", @@ -82,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { cleanup: () => {}, settingsScreens: [], surfaces: [{ id: "main", title: "Main", Component: () => null }], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], @@ -131,6 +149,15 @@ function stateSource() { subscribe() { return () => {}; }, + getSnapshot() { + return { workspaces: [workspace], agents: [agent], projects: [], terminals: [] }; + }, + getProject() { + return null; + }, + getTerminal() { + return null; + }, getWorkspace(id: string) { return id === workspace.id ? workspace : null; }, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 1aae11f63d..0621d0ce22 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -55,6 +55,29 @@ describe("evaluatePluginClientBundle", () => { expect(active).toBe(0); }); + it("registers sidebar views without selecting one and removes them on cleanup", async () => { + const plugin = evaluatePluginClientBundle( + "sidebar", + bundle(` + plugin.addSidebarView({ id: "workspaces", title: "Workspaces", Component() { return null; } }); + `), + ); + expect(plugin.sidebarViews.map((view) => view.id)).toEqual(["workspaces"]); + await plugin.cleanup(); + expect(plugin.sidebarViews).toEqual([]); + }); + it("rejects duplicate sidebar view ids", () => { + expect(() => + evaluatePluginClientBundle( + "sidebar", + bundle(` + const view = { id: "workspaces", title: "Workspaces", Component() { return null; } }; + plugin.addSidebarView(view); plugin.addSidebarView(view); + `), + ), + ).toThrow("Duplicate sidebar view"); + }); + it("accepts memoized settings screens", () => { const plugin = evaluatePluginClientBundle( "settings", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 56e296efd6..b5b0b71d13 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -129,6 +129,7 @@ export function runPluginClientBundle( const collector: Omit = { surfaces: [], settingsScreens: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], @@ -141,6 +142,7 @@ export function runPluginClientBundle( timelineRenderers: [], }; const surfaceIds = new Set(); + const sidebarViewIds = new Set(); const settingsScreenIds = new Set(); const sidebarItemIds: Record> = { header: new Set(), @@ -270,6 +272,18 @@ export function runPluginClientBundle( () => surfaceIds.delete(normalizedId), ); }, + addSidebarView(contribution) { + const normalizedId = requireId(contribution.id, "sidebar view id"); + if (sidebarViewIds.has(normalizedId)) + throw new Error(`Duplicate sidebar view: ${normalizedId}`); + if (!isComponentType(contribution.Component)) + throw new Error(`Sidebar view ${normalizedId} is not a component`); + const title = requirePluginText(contribution.title); + sidebarViewIds.add(normalizedId); + return register(collector.sidebarViews, { ...contribution, id: normalizedId, title }, () => + sidebarViewIds.delete(normalizedId), + ); + }, addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { return addSidebarItem("header", contribution); }, @@ -518,7 +532,8 @@ export function runPluginClientBundle( if (name === "@getpaseo/plugin/client") return { ...pluginClientRuntime, - useSettings, + useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) => + useSettings(definition, id), openExternalUrl, getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), useHosts: () => @@ -583,6 +598,7 @@ export function runPluginClientBundle( cleanup, surfaces: collector.surfaces, settingsScreens: collector.settingsScreens, + sidebarViews: collector.sidebarViews, sidebarItems: collector.sidebarItems, legacySidebarItems: collector.legacySidebarItems, filePanels: collector.filePanels, diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index b15a68d5e0..737c614c8c 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -1,7 +1,8 @@ import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { usePaseo, useRpc } from "@getpaseo/plugin/client"; import { defineRpc } from "@getpaseo/plugin"; -import type { PaseoApi } from "@getpaseo/client"; +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin { lifetime: new AbortController(), locale: "en", i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), - paseo: { dispose: async () => undefined } as PaseoApi, + paseo: createPaseoApi( + new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "installation-provider", + reconnect: { enabled: false }, + }), + ), invoke: async (method) => { invoked.push(method); return { state: "green" }; @@ -36,6 +43,7 @@ function installation(invoked: string[]): InstalledPlugin { cleanup: () => undefined, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], @@ -75,4 +83,6 @@ it("gives every surface the installation's client and RPCs on its first frame", expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true); await expect(rpc!({})).resolves.toEqual({ state: "green" }); expect(invoked).toEqual(["deploys.status"]); + plugin.lifetime.abort(); + await plugin.paseo.dispose(); }); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx index ba3b159f99..a51875fa88 100644 --- a/packages/app/src/plugins/installation-provider.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, @@ -17,7 +18,7 @@ export function PluginInstallationProvider({ }) { return ( - + {children} diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index a1a766e5c8..ad85e7aec2 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { filePanelTitle } from "@/file-panels/choice/presentation"; import { i18n } from "@/i18n/i18next"; import { createPluginHosts } from "./hosts"; @@ -50,7 +51,7 @@ function registry( }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected plugin RPC"); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 8628c0ebfb..6add4ee6d6 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { createPluginHosts } from "./hosts"; import { afterEach, expect, it } from "vitest"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -25,7 +26,7 @@ function registry(version: string) { }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("No RPC in this plugin"); diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index fe21dc785f..6c1a3730f6 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -122,6 +122,7 @@ export class PluginRegistry { cleanup: () => undefined, surfaces: [], settingsScreens: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/settings/device.test.ts b/packages/app/src/plugins/settings/device.test.ts new file mode 100644 index 0000000000..607e66976b --- /dev/null +++ b/packages/app/src/plugins/settings/device.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { createDeviceSettings } from "./device"; + +function device(records = new Map()) { + return createDeviceSettings({ + getItem: async (key) => records.get(key) ?? null, + setItem: async (key, value) => { + records.set(key, value); + }, + }); +} +const definition = defineSettings({ + id: "layout", + scope: "device", + version: 1, + schema: z.object({ collapsed: z.array(z.string()).default([]) }), +}); + +describe("device plugin settings", () => { + it("shares one installation across hosts and keeps other installations separate", async () => { + const settings = device(); + const first = settings("plugin", definition); + const second = settings("plugin", definition); + const before = await first.read(); + expect(before).toEqual({ status: "ready", revision: "missing", values: { collapsed: [] } }); + const saved = await first.write({ revision: before.revision, values: { collapsed: ["work"] } }); + expect(saved.status).toBe("saved"); + expect(await second.read()).toMatchObject({ status: "ready", values: { collapsed: ["work"] } }); + expect(await settings("other", definition).read()).toEqual(before); + }); + it("serializes competing writes and resets through the same revision contract", async () => { + const settings = device()("plugin", definition); + const initial = await settings.read(); + const results = await Promise.all([ + settings.write({ revision: initial.revision, values: { collapsed: ["a"] } }), + settings.write({ revision: initial.revision, values: { collapsed: ["b"] } }), + ]); + expect(results.map((result) => result.status)).toEqual(["saved", "conflict"]); + const current = await settings.read(); + expect(await settings.reset({ revision: current.revision })).toMatchObject({ + status: "saved", + values: { collapsed: [] }, + }); + }); + it("validates writes and migrates saved layout values", async () => { + const settings = device(); + const first = settings("plugin", definition); + expect(await first.write({ revision: "missing", values: { collapsed: 3 } })).toMatchObject({ + status: "invalid", + }); + await first.write({ revision: "missing", values: { collapsed: ["a"] } }); + const upgraded = settings( + "plugin", + defineSettings({ + ...definition, + version: 2, + migrate: (_values, from) => ({ collapsed: [`v${from}`] }), + }), + ); + expect(await upgraded.read()).toMatchObject({ status: "ready", values: { collapsed: ["v1"] } }); + }); +}); + +it("persists a validated migration once and returns the persisted revision across restarts", async () => { + const records = new Map(); + const settings = device(records); + await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } }); + let migrations = 0; + const upgraded = defineSettings({ + ...definition, + version: 2, + migrate: () => { + migrations++; + return { collapsed: ["migrated"] }; + }, + }); + const first = await settings("plugin", upgraded).read(); + const restarted = device(records)("plugin", upgraded); + expect(await restarted.read()).toEqual(first); + expect(migrations).toBe(1); + expect(first).toEqual({ + status: "ready", + revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }), + values: { collapsed: ["migrated"] }, + }); + expect( + await restarted.write({ + revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }), + values: { collapsed: [] }, + }), + ).toMatchObject({ status: "conflict" }); + expect( + await restarted.write({ revision: first.revision, values: { collapsed: [] } }), + ).toMatchObject({ status: "saved" }); +}); diff --git a/packages/app/src/plugins/settings/device.ts b/packages/app/src/plugins/settings/device.ts new file mode 100644 index 0000000000..523eed599f --- /dev/null +++ b/packages/app/src/plugins/settings/device.ts @@ -0,0 +1,80 @@ +import { + settingsRpc, + type SettingsDefinition, + type RpcOutput, + type RpcInput, +} from "@getpaseo/plugin"; +import { z } from "zod"; + +interface DeviceStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} +type ReadResult = RpcOutput["read"]>; +type WriteResult = RpcOutput["write"]>; +type WriteInput = RpcInput["write"]>; +const envelope = z.object({ version: z.number().int().positive(), values: z.json() }); + +/** One writer per device; installations with the same id share their layout settings across hosts. */ +export function createDeviceSettings(storage: DeviceStorage) { + let queue: Promise = Promise.resolve(); + function serial(work: () => Promise): Promise { + const result = queue.then(work); + queue = result.catch(() => undefined); + return result; + } + return function settings(installationId: string, definition: SettingsDefinition) { + const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`; + async function read(): Promise { + const raw = await storage.getItem(key); + let revision = raw ?? "missing"; + try { + let values: unknown = {}; + let migrated = false; + if (raw !== null) { + const stored = envelope.parse(JSON.parse(raw)); + if (stored.version > definition.version) + throw new Error("Update this plugin to read its newer settings"); + values = stored.values; + if (stored.version < definition.version) { + if (!definition.migrate) + throw new Error("This plugin has no migration for its saved settings"); + values = await definition.migrate(values, stored.version); + migrated = true; + } + } + const parsed = z.json().parse(await definition.schema.parseAsync(values)); + if (migrated) { + const serialized = JSON.stringify({ version: definition.version, values: parsed }); + await storage.setItem(key, serialized); + revision = serialized; + } + return { status: "ready", revision, values: parsed }; + } catch (error) { + return { + status: "invalid", + revision, + error: error instanceof Error ? error.message : String(error), + }; + } + } + function write(input: WriteInput): Promise { + return serial(async () => { + const raw = await storage.getItem(key); + if ((raw ?? "missing") !== input.revision) + return { status: "conflict", error: "Settings changed. Reload before saving." }; + const parsed = await definition.schema.safeParseAsync(input.values); + if (!parsed.success) return { status: "invalid", error: parsed.error.message }; + const values = z.json().parse(parsed.data); + const serialized = JSON.stringify({ version: definition.version, values }); + await storage.setItem(key, serialized); + return { status: "saved", revision: serialized, values }; + }); + } + return { + read: () => serial(read), + write, + reset: (input: { revision: string }) => write({ revision: input.revision, values: {} }), + }; + }; +} diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts index decf4bfe05..b65dc4dc03 100644 --- a/packages/app/src/plugins/settings/use-settings.ts +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -1,3 +1,6 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { queryClient as appQueryClient } from "@/data/query-client"; +import { createDeviceSettings } from "./device"; import type { SettingsState } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -8,6 +11,8 @@ import { useReplicaQuery } from "@/data/query"; import { z, type ZodType } from "zod"; import { pluginSettingsKey } from "./query"; +const deviceSettings = createDeviceSettings(AsyncStorage); + function message(error: unknown): string { if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); return error instanceof Error ? error.message : String(error); @@ -15,34 +20,49 @@ function message(error: unknown): string { export function useSettings( definition: SettingsDefinition, + installationId: string, ): SettingsState { const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]); const read = useRpc(rpc.read); const write = useRpc(rpc.write); const reset = useRpc(rpc.reset); - const client = useQueryClient(); - const key = pluginSettingsKey(definition.id); - const query = useReplicaQuery({ - queryKey: key, - pushEvent: "plugin_settings_changed", - queryFn: () => read({}), - retry: false, - }); - const mutation = useMutation({ - mutationFn: async ( - input: { revision: string; values: z.output } | { revision: string }, - ) => { - const result = - "values" in input - ? await write({ - revision: input.revision, - values: z.json().parse(await definition.schema.parseAsync(input.values)), - }) - : await reset(input); - if (result.status !== "saved") throw new Error(result.error); - client.setQueryData(key, { ...result, status: "ready" }); + const installationClient = useQueryClient(); + const device = definition.scope === "device"; + const client = device ? appQueryClient : installationClient; + const key = device + ? ["plugin-device-settings", installationId, definition.id, definition.version] + : pluginSettingsKey(definition.id); + const local = useMemo( + () => deviceSettings(installationId, definition), + [installationId, definition], + ); + const query = useReplicaQuery( + { + queryKey: key, + pushEvent: device ? "plugin_device_settings_saved" : "plugin_settings_changed", + queryFn: () => (device ? local.read() : read({})), + retry: false, }, - }); + client, + ); + const mutation = useMutation( + { + mutationFn: async ( + input: { revision: string; values: z.output } | { revision: string }, + ) => { + const result = + "values" in input + ? await (device ? local.write : write)({ + revision: input.revision, + values: z.json().parse(await definition.schema.parseAsync(input.values)), + }) + : await (device ? local.reset : reset)(input); + if (result.status !== "saved") throw new Error(result.error); + client.setQueryData(key, { ...result, status: "ready" }); + }, + }, + client, + ); async function save(values: z.output, revision: string) { try { await mutation.mutateAsync({ values, revision }); diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index bc9517d4b4..a64af934d8 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -31,6 +31,7 @@ function installed( }, settingsScreens: [], surfaces: [{ id: "surface", title: "Surface", Component: () => null }], + sidebarViews: [], sidebarItems: { header: [{ id: contributionId, title: "Example", Component: () => null }], footer: [{ id: "status", title: "Status", Component: () => null }], diff --git a/packages/app/src/plugins/sidebar-view/catalog.ts b/packages/app/src/plugins/sidebar-view/catalog.ts new file mode 100644 index 0000000000..3711cb8414 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/catalog.ts @@ -0,0 +1,25 @@ +import type { InstalledPlugin } from "../types"; + +export interface SidebarViewEntry { + id: string; + title: string; + plugin: InstalledPlugin; + view: InstalledPlugin["sidebarViews"][number]; +} + +export function sidebarViews(plugins: readonly InstalledPlugin[]): SidebarViewEntry[] { + const entries = new Map(); + for (const plugin of plugins) { + for (const view of plugin.sidebarViews) { + const id = `${plugin.id}/${view.id}`; + if (!entries.has(id)) + entries.set(id, { + id, + title: plugin.i18n.resolve(view.title, plugin.locale), + plugin, + view, + }); + } + } + return [...entries.values()]; +} diff --git a/packages/app/src/plugins/sidebar-view/frame.browser.test.tsx b/packages/app/src/plugins/sidebar-view/frame.browser.test.tsx new file mode 100644 index 0000000000..730b80e292 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/frame.browser.test.tsx @@ -0,0 +1,69 @@ +import React, { act, useMemo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it } from "vitest"; +import { SidebarViewFrame } from "./frame"; +import { useSidebarViewSelection, type SidebarViewStorage } from "./selection"; + +const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +let root: Root | null = null; +let container: HTMLDivElement | null = null; +let defaultRenders = 0; +function DefaultView() { + defaultRenders++; + return
Default sidebar
; +} +function PluginView() { + return
Stored plugin sidebar
; +} +function Sidebar({ storage }: { storage: SidebarViewStorage }) { + const selection = useSidebarViewSelection(storage); + const fallback = useMemo(() => , []); + const selected = + selection.state.status === "ready" && selection.state.selection === "example/workspaces"; + const view = selected ? : null; + return ( + + {view} + + ); +} +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + defaultRenders = 0; + client.clear(); +}); + +function deferredStorage() { + let finishRead: (value: string) => void = () => { + throw new Error("Read not started"); + }; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + const storage: SidebarViewStorage = { getItem: () => read, setItem: async () => {} }; + return { storage, finishRead }; +} + +it("never renders the default before a stored plugin selection is known", async () => { + const { storage, finishRead } = deferredStorage(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => + root?.render( + + + , + ), + ); + expect(defaultRenders).toBe(0); + await act(async () => { + finishRead("example/workspaces"); + }); + await expect.poll(() => container?.textContent).toContain("Stored plugin sidebar"); + expect(defaultRenders).toBe(0); +}); diff --git a/packages/app/src/plugins/sidebar-view/frame.tsx b/packages/app/src/plugins/sidebar-view/frame.tsx new file mode 100644 index 0000000000..c059530be8 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/frame.tsx @@ -0,0 +1,34 @@ +import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton"; +import type { ReactNode } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import type { SidebarViewSelectionState } from "./selection"; + +export function SidebarViewFrame({ + state, + fallback, + children, + reload, +}: { + state: SidebarViewSelectionState; + fallback: ReactNode; + children: ReactNode; + reload(): void; +}) { + const { t } = useTranslation(); + if (state.status === "loading") return ; + if (state.status === "error") + return ( + + {t("sidebar.view.readFailed")} + + + ); + return children ?? fallback; +} +const styles = StyleSheet.create((theme) => ({ + failure: { padding: theme.spacing[4], gap: theme.spacing[3] }, + text: { color: theme.colors.foreground }, +})); diff --git a/packages/app/src/plugins/sidebar-view/index.tsx b/packages/app/src/plugins/sidebar-view/index.tsx new file mode 100644 index 0000000000..266ae9b1c6 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/index.tsx @@ -0,0 +1,173 @@ +import { useTranslation } from "react-i18next"; +import { SidebarViewFrame } from "./frame"; +import type { PluginSidebarViewProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { router } from "expo-router"; +import { useMemo, useCallback, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { + navigateToWorkspace, + useActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { useSessionStore } from "@/stores/session-store"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "../client-state/source"; +import { entityRefs } from "../client-state/refs"; +import { PluginEnvironmentProvider } from "../popover"; +import { resolvePluginPlatform } from "../platform"; +import { useInstalledPlugins } from "../registry"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import { sidebarViews, type SidebarViewEntry } from "./catalog"; +import { useSidebarViewSelection } from "./selection"; + +const source = createPluginClientStateSource(); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +export function SidebarViewSlot({ children, dismiss }: { children: ReactNode; dismiss(): void }) { + const { t } = useTranslation(); + const installed = useInstalledPlugins(); + const { state, reload, setSelection, pending, error } = useSidebarViewSelection(); + const restoreDefault = useCallback(() => setSelection("default"), [setSelection]); + const renderError = useCallback( + () => ( + + {t("sidebar.view.failed")} + + {error ? {error.message} : null} + + ), + [restoreDefault, pending, error, t], + ); + const entry = + state.status === "ready" + ? sidebarViews(installed).find((view) => view.id === state.selection) + : undefined; + const view = entry ? ( + + + + + + ) : null; + return ( + + {view} + + ); +} + +function SidebarView({ + entry, + dismiss, + theme, +}: { + entry: SidebarViewEntry; + dismiss(): void; + theme: PluginTheme; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const compact = useIsCompactFormFactor(); + const selection = useActiveWorkspaceSelection(); + const focusedAgentId = useSessionStore((state) => + selection ? (state.sessions[selection.serverId]?.focusedAgentId ?? null) : null, + ); + const activeWorkspace = selection + ? entityRefs.workspace(selection.serverId, selection.workspaceId) + : null; + const activeAgent = + selection && focusedAgentId ? entityRefs.agent(selection.serverId, focusedAgentId) : null; + const environment = useMemo( + () => ({ installation: entry.plugin, state: source, toast }), + [entry.plugin, toast], + ); + const navigation = useMemo( + () => ({ + openWorkspace(ref) { + navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id }); + dismiss(); + }, + openAgent(ref) { + const agent = source.getAgent(ref); + if (!agent) { + toast.error(t("sidebar.view.agentUnavailable")); + return; + } + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: agent.workspace.id, + target: { kind: "agent", agentId: ref.id }, + }); + dismiss(); + }, + openTerminal(ref) { + const terminal = source.getTerminal(ref); + if (!terminal) { + toast.error(t("sidebar.view.terminalUnavailable")); + return; + } + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: terminal.workspace.id, + target: { kind: "terminal", terminalId: ref.id }, + }); + dismiss(); + }, + openProject(ref) { + const project = source.getProject(ref); + if (!project) { + toast.error(t("sidebar.view.projectUnavailable")); + return; + } + router.push( + buildNewWorkspaceRoute({ + serverId: ref.hostId, + projectId: ref.id, + sourceDirectory: project.rootPath, + displayName: project.displayName, + }), + ); + dismiss(); + }, + }), + [dismiss, toast, t], + ); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const active = useMemo( + () => ({ workspace: activeWorkspace, agent: activeAgent }), + [activeWorkspace, activeAgent], + ); + const Component = entry.view.Component; + return ( + + + + ); +} +const ThemedSidebarView = withUnistyles(SidebarView); + +const styles = StyleSheet.create((theme) => ({ + content: { flex: 1 }, + placeholder: { padding: theme.spacing[4], gap: theme.spacing[3] }, + text: { color: theme.colors.foreground }, +})); + +export { SidebarViewPicker } from "./picker"; diff --git a/packages/app/src/plugins/sidebar-view/picker.tsx b/packages/app/src/plugins/sidebar-view/picker.tsx new file mode 100644 index 0000000000..920df1c797 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/picker.tsx @@ -0,0 +1,58 @@ +import { Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { SettingsCard, SettingsRow, SettingsSection, SettingsSelect } from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { useInstalledPlugins } from "../registry"; +import { sidebarViews } from "./catalog"; +import { useSidebarViewSelection } from "./selection"; + +export function SidebarViewPicker() { + const { t } = useTranslation(); + const views = sidebarViews(useInstalledPlugins()); + const { state, reload, setSelection, pending, error } = useSidebarViewSelection(); + let content; + if (state.status === "loading") { + content = ( + + {t("common.loading")} + + ); + } else if (state.status === "error") { + content = ( + + + + ); + } else { + const missing = + state.selection !== "default" && !views.some((view) => view.id === state.selection); + const value = missing ? "default" : state.selection; + let hint: string | undefined; + if (missing) hint = t("settings.appearance.sidebar.view.unavailable"); + if (error) hint = error.message; + const options = [ + { value: "default", label: t("settings.appearance.sidebar.view.default") }, + ...views.map((view) => ({ value: view.id, label: view.title })), + ]; + content = ( + + ); + } + return ( + + {content} + + ); +} +const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } })); diff --git a/packages/app/src/plugins/sidebar-view/selection.ts b/packages/app/src/plugins/sidebar-view/selection.ts new file mode 100644 index 0000000000..f1c8eb08a2 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/selection.ts @@ -0,0 +1,47 @@ +import { useCallback } from "react"; +import { useFetchQuery } from "@/data/query"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +const key = "sidebar.view"; +const queryKey = [key]; + +export interface SidebarViewStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} +export type SidebarViewSelectionState = + | { status: "loading" } + | { status: "error"; error: Error } + | { status: "ready"; selection: string }; + +export function useSidebarViewSelection(storage: SidebarViewStorage = AsyncStorage) { + const client = useQueryClient(); + const query = useFetchQuery({ + queryKey, + queryFn: async () => (await storage.getItem(key)) ?? "default", + dataShape: "value", + immutableWhen: () => true, + }); + const save = useMutation({ + mutationFn: (selection: string) => storage.setItem(key, selection), + onSuccess: (_result, selection) => { + client.setQueryData(queryKey, selection); + }, + }); + let state: SidebarViewSelectionState; + if (query.isPending) state = { status: "loading" }; + else if (query.isError) state = { status: "error", error: query.error }; + else state = { status: "ready", selection: query.data }; + const { refetch } = query; + const reload = useCallback(() => { + void refetch(); + }, [refetch]); + return { + state, + reload, + setSelection: save.mutate, + pending: save.isPending, + error: save.error, + }; +} diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 4973b34f5b..fff830f7e7 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -49,6 +49,7 @@ function installation( cleanup: () => undefined, settingsScreens: [], surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })), + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: sidebarItems.map((item) => ({ ...item, diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index c75d6b5b01..8a208b37c0 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install }, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 2a07c1cf47..ef8d68d50c 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -24,6 +24,7 @@ function plugin(input: { cleanup: () => {}, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index ff3ba9fd6b..7d96747279 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null })); const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null })); -vi.mock("@/runtime/host-runtime", () => ({ +vi.mock("@/runtime/host-runtime", async (importOriginal) => ({ + ...(await importOriginal()), useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 29c0470e5d..81d29e0279 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -13,6 +13,7 @@ import type { PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, PluginComposerPillContribution as SDKPluginComposerPillContribution, PluginSidebarContribution as SDKPluginSidebarContribution, + PluginSidebarViewContribution, PluginSidebarItemContribution as SDKPluginSidebarItemContribution, PluginScreenContribution as SDKPluginScreenContribution, PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution, @@ -48,6 +49,7 @@ export interface EvaluatedPlugin { cleanup: PluginCleanup; surfaces: PluginScreenContribution[]; settingsScreens: PluginSettingsScreenContribution[]; + sidebarViews: PluginSidebarViewContribution[]; sidebarItems: Record; // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 /** `addSidebarItem` registrations, so `/plugin//sidebar/` routes keep resolving. */ diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 15282266b7..546a8916ac 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -21,6 +21,7 @@ function installed(): InstalledPlugin { cleanup: () => {}, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 8a502749db..dfb16ccfa7 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -20,7 +20,8 @@ const runtime = vi.hoisted(() => ({ client: null as DaemonClient | null, })); -vi.mock("@/runtime/host-runtime", () => ({ +vi.mock("@/runtime/host-runtime", async (importOriginal) => ({ + ...(await importOriginal()), useHostRuntimeClient: () => runtime.client, useHostRuntimeIsConnected: () => runtime.connected, })); diff --git a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx index c7fbcd7d06..94f336fdea 100644 --- a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx @@ -1,3 +1,4 @@ +import { SidebarViewPicker } from "@/plugins/sidebar-view"; import { useCallback, useMemo, type ComponentType, type ReactElement } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -183,6 +184,7 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen export function SidebarNavSection(): ReactElement { return ( <> + diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 017c302f7d..c8fbff81e1 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6578,10 +6578,16 @@ export class DaemonClient { // ============================================================================ observeTerminals(input: { - cwd: string; + cwd?: string; workspaceId?: string; signal?: AbortSignal; }): OwnedSubscription> { + // COMPAT(terminalCollection): added in v0.16, remove after 2027-06. + if ( + input.cwd === undefined && + this.getLastServerInfoMessage()?.features?.terminalCollection !== true + ) + throw new Error("Update the host to list terminals across workspaces."); const { signal, ...query } = input; return this.observe( "terminals_changed", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 79a2de50a1..390c9310f3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js"; import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; @@ -190,6 +191,8 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; + setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>; + setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>; setBaseBranch(options: { baseRef: string; requestId?: string; @@ -899,6 +902,14 @@ function createWorkspaceHandleFactory( current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), + setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned), + setLabel: (name, options) => + assignWorkspaceLabel({ + client: daemonClient, + workspaceId: id, + name, + assigned: options.assigned, + }), setBaseBranch: (options) => daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }), archive: async (requestId) => { @@ -1288,3 +1299,28 @@ function createGeneratedClientId(): string { : Math.random().toString(36).slice(2); return `paseo-sdk-${randomId}`; } + +interface WorkspaceLabelAssignment { + client: DaemonClient; + workspaceId: string; + name: string; + assigned: boolean; +} +async function assignWorkspaceLabel( + input: WorkspaceLabelAssignment, +): Promise<{ labels: string[] }> { + input.client.ensureConnected(); + // COMPAT(workspaceLabels): added in v0.5.0, remove gate after 2027-08-14. + if (input.client.getLastServerInfoMessage()?.features?.workspaceLabels !== true) + throw new Error("Update the host to assign workspace labels."); + const catalog = await input.client.listWorkspaceLabels(); + const key = workspaceLabelKey(input.name); + const existing = catalog.labels.find((label) => workspaceLabelKey(label.name) === key); + const label = existing ?? { name: input.name, color: "violet" as const }; + const result = await input.client.setWorkspaceLabel({ + workspaceId: input.workspaceId, + label, + assigned: input.assigned, + }); + return { labels: result.workspaceLabels }; +} diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index c28bbc8d68..24385a3a16 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -388,3 +388,45 @@ describe("plugin subscription SDK boundary", () => { ).toEqual([]); }); }); + +describe("sidebar data SDK boundary", () => { + it("accepts references and preserves string-id panel consumers", () => { + expect( + typeErrors(` + import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client"; + import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin"; + function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) { + const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace); + const title: string | null = useWorkspace("workspace", item => item.name); + const status = useAgent(agent, item => ({ status: item.status }), (a, b) => a?.status === b?.status); + const agentSnapshot = useAgent(agent); + const custom = useWorkspace(workspace, undefined, (a, b) => a?.name === b?.name); + const workspaces: readonly WorkspaceRef[] = useWorkspaces({ orderBy: "name" }); + const groups: readonly Group[] = useAgents({ groupBy: item => item.status }); + const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] }); + const terminals: readonly TerminalRef[] = useTerminals(); + const paseo = usePaseo(); + void paseo.workspaces.ref(workspace).setPinned(true); + void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true }); + void paseo.workspaces.ref("old-id").setTitle("Title"); + if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); } + if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); } + void paseo.agents.ref(agent); + if (projects[0]) useProject(projects[0], item => item.displayName); + if (terminals[0]) useTerminal(terminals[0]); + function View(props: PluginSidebarViewProps) { + props.navigation.openWorkspace(workspace); + props.navigation.openAgent(agent); + props.dismiss(); + return null; + } + function contribute(client: PluginClientContext) { + client.addSidebarView({ id: "workspaces", title: "Workspaces", Component: View }); + } + const query: Query = { hosts: [workspace.hostId], orderBy: ["name", "desc"] }; + return null; + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/client-state.tsx b/packages/plugin/src/client/client-state.tsx index b1c853ecec..db2a6b5f5a 100644 --- a/packages/plugin/src/client/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -1,12 +1,30 @@ -import { createContext, useContext, type ReactNode } from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js"; +import type { AgentRef, WorkspaceRef, ProjectRef, TerminalRef } from "../refs.js"; +import type { + PluginAgentSnapshot, + PluginWorkspaceSnapshot, + PluginProjectSnapshot, + PluginTerminalSnapshot, +} from "../contracts.js"; +import { createCollectionQuery, type Query, type Group } from "./query.js"; import { shallowEqual } from "./shallow.js"; export interface PluginClientStateSource { - subscribe(listener: () => void): () => void; - getWorkspace(id: string): PluginWorkspaceSnapshot | null; - getAgent(id: string): PluginAgentSnapshot | null; + subscribe(listener: () => void, options?: { terminals?: boolean }): () => void; + getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null; + getAgent(id: string | AgentRef): PluginAgentSnapshot | null; + getProject(ref: ProjectRef): PluginProjectSnapshot | null; + getTerminal(ref: TerminalRef): PluginTerminalSnapshot | null; + getSnapshot(): PluginClientSnapshot; +} + +export interface PluginClientSnapshot { + readonly workspaces: readonly PluginWorkspaceSnapshot[]; + readonly agents: readonly PluginAgentSnapshot[]; + readonly projects: readonly PluginProjectSnapshot[]; + readonly terminals: readonly PluginTerminalSnapshot[]; + readonly terminalErrors?: ReadonlyMap; } const PluginClientStateContext = createContext(null); @@ -15,44 +33,113 @@ export function usePluginClientStateSource(): PluginClientStateSource | null { return useContext(PluginClientStateContext); } -function usePluginEntity(input: { - id: string; - read(source: PluginClientStateSource, id: string): Entity | null; - selector(entity: Entity): Selection; -}): Selection | null { +/** Shared selection semantics for host surfaces and plugin entities. */ +export function usePaseoSelector( + source: { subscribe(listener: () => void): () => void }, + read: () => Entity, + selector: (entity: Entity) => Selection, + equals: (left: Selection, right: Selection) => boolean = shallowEqual, +): Selection { + return useSyncExternalStoreWithSelector(source.subscribe, read, read, selector, equals); +} + +type Equality = { equals(left: T, right: T): boolean }["equals"]; + +function useStateSource(terminals: boolean) { const source = usePluginClientStateSource(); - if (!source) throw new Error("Plugin state hooks must run inside a workspace panel"); - return useSyncExternalStoreWithSelector( - source.subscribe, - () => input.read(source, input.id), - () => input.read(source, input.id), - (entity) => (entity ? input.selector(entity) : null), - shallowEqual, + if (!source) throw new Error("Plugin state hooks must run inside a plugin host"); + return useMemo( + () => + terminals + ? { + ...source, + subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }), + } + : source, + [source, terminals], ); } -export function useWorkspace( - workspaceId: string, - selector: (workspace: PluginWorkspaceSnapshot) => Selection, -): Selection | null { - return usePluginEntity({ - id: workspaceId, - read: (source, id) => source.getWorkspace(id), - selector, - }); +function createEntityHook( + read: (source: PluginClientStateSource, ref: Ref) => Entity | null, + terminals = false, +) { + function useEntity( + ref: Ref, + selector?: undefined, + equals?: Equality, + ): Entity | null; + function useEntity( + ref: Ref, + selector: (entity: Entity) => Selection, + equals?: Equality, + ): Selection | null; + function useEntity( + ref: Ref, + selector?: (entity: Entity) => Selection, + equals?: Equality, + ): Entity | Selection | null { + const source = useStateSource(terminals); + function select(entity: Entity | null): Entity | Selection | null { + return entity !== null && selector ? selector(entity) : entity; + } + const equality = equals ?? (selector ? shallowEqual : Object.is); + return usePaseoSelector(source, () => read(source, ref), select, equality); + } + return useEntity; } -export function useAgent( - agentId: string, - selector: (agent: PluginAgentSnapshot) => Selection, -): Selection | null { - return usePluginEntity({ - id: agentId, - read: (source, id) => source.getAgent(id), - selector, - }); +// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03. +export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string) => + source.getWorkspace(ref), +); +// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03. +export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref)); +export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref)); +export const useTerminal = createEntityHook( + (source, ref: TerminalRef) => source.getTerminal(ref), + true, +); + +function createCollectionHook( + read: (source: PluginClientStateSource, query: Query) => readonly Snapshot[], + terminals = false, +) { + function useCollection( + query: Query & { groupBy: NonNullable["groupBy"]> }, + ): readonly Group[]; + function useCollection( + query?: Query & { groupBy?: undefined }, + ): readonly Snapshot["ref"][]; + function useCollection( + query: Query, + ): readonly Snapshot["ref"][] | readonly Group[]; + function useCollection( + query: Query = {}, + ): readonly Snapshot["ref"][] | readonly Group[] { + const source = useStateSource(terminals); + const project = useMemo(() => createCollectionQuery(), []); + return usePaseoSelector( + source, + () => read(source, query), + (snapshots) => project(snapshots, query), + Object.is, + ); + } + return useCollection; } +export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces); +export const useAgents = createCollectionHook((source) => source.getSnapshot().agents); +export const useProjects = createCollectionHook((source) => source.getSnapshot().projects); +export const useTerminals = createCollectionHook((source, query) => { + const snapshot = source.getSnapshot(); + for (const [hostId, error] of snapshot.terminalErrors ?? []) { + if (!query.hosts || query.hosts.includes(hostId)) throw error; + } + return snapshot.terminals; +}, true); + export function PluginClientStateProvider({ children, source, diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 76c163af53..229e8ec478 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,6 +1,7 @@ +import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js"; import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; -import type { PaseoApi } from "@getpaseo/client"; +import type { PluginPaseoApi } from "./reference-api.js"; import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginFile } from "./file.js"; @@ -96,6 +97,24 @@ export interface PluginSidebarItemContribution Component: ComponentType; } +export interface PluginSidebarViewProps extends Pick { + active: { workspace: WorkspaceRef | null; agent: AgentRef | null }; + navigation: { + openWorkspace(ref: WorkspaceRef): void; + openAgent(ref: AgentRef): void; + openTerminal(ref: TerminalRef): void; + openProject(ref: ProjectRef): void; + }; + dismiss(): void; +} + +export interface PluginSidebarViewContribution { + id: string; + title: Text; + description?: Text; + Component: ComponentType; +} + /** * A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size * and color; buttons widen the props with their host and target context. @@ -185,6 +204,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl playAudio(source: { base64: string; mimeType: string }): Promise; addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; addScreen(contribution: PluginScreenContribution): PluginCleanup; + addSidebarView(contribution: PluginSidebarViewContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; addAgentPanel(contribution: PluginAgentPanelContribution): PluginCleanup; @@ -308,7 +328,7 @@ export interface PluginTimelineRendererContribution( contract: PluginRpcContract, input: PluginWireInput>, diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index 2ec1f57b2e..19cf91f46d 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,6 +1,11 @@ import type { PluginText } from "../text.js"; import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; -export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; +export { + PluginClientStateProvider, + usePaseoSelector, + type PluginClientStateSource, + type PluginClientSnapshot, +} from "./client-state.js"; export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, @@ -31,3 +36,7 @@ export { isPluginMessage, } from "./translations.js"; export type { PluginLocaleSource } from "./translations.js"; + +export { createEntityRefs } from "./refs.js"; + +export { createPluginReferenceApi } from "./reference-api.js"; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index ffaf0782d5..1be686d922 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -15,6 +15,8 @@ export type { PluginOpenScreenInput, PluginScreenLocation, PluginSidebarItemProps, + PluginSidebarViewProps, + PluginSidebarViewContribution, PluginSidebarItemContribution, PluginIcon, PluginPanelLocation, @@ -70,7 +72,16 @@ export { useFileUrl, } from "./file.js"; export { usePaseo } from "./paseo-context.js"; -export { useAgent, useWorkspace } from "./client-state.js"; +export { + useAgent, + useWorkspace, + useProject, + useTerminal, + useWorkspaces, + useAgents, + useProjects, + useTerminals, +} from "./client-state.js"; export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js"; export type { PluginSubscribeOptions, @@ -102,3 +113,8 @@ export type { PluginI18n } from "./translations.js"; export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; export { useTabState } from "./tab.js"; + +export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js"; +export type { Query, Group, GroupKey } from "./query.js"; + +export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js"; diff --git a/packages/plugin/src/client/paseo-context.tsx b/packages/plugin/src/client/paseo-context.tsx index fe1a0b9c5c..8ec6910bb2 100644 --- a/packages/plugin/src/client/paseo-context.tsx +++ b/packages/plugin/src/client/paseo-context.tsx @@ -1,17 +1,23 @@ -import type { PaseoApi } from "@getpaseo/client"; +import type { PluginPaseoApi } from "./reference-api.js"; import { createContext, useContext, type ReactNode } from "react"; -const PaseoApiContext = createContext(null); +const PaseoApiContext = createContext(null); -export function usePaseoContextValue(): PaseoApi | null { +export function usePaseoContextValue(): PluginPaseoApi | null { return useContext(PaseoApiContext); } -export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) { +export function PaseoApiProvider({ + children, + paseo, +}: { + children: ReactNode; + paseo: PluginPaseoApi; +}) { return {children}; } -export function usePaseo(): PaseoApi { +export function usePaseo(): PluginPaseoApi { const paseo = usePaseoContextValue(); if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface"); return paseo; diff --git a/packages/plugin/src/client/query.test.ts b/packages/plugin/src/client/query.test.ts new file mode 100644 index 0000000000..ce0dd9bd3c --- /dev/null +++ b/packages/plugin/src/client/query.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createCollectionQuery, type GroupKey } from "./query.js"; + +const first = Object.freeze({ key: "a" }); +const second = Object.freeze({ key: "b" }); +const work: GroupKey = "work"; +const snapshots = [ + { ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] }, + { ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] }, +]; + +describe("collection query structural sharing", () => { + it("preserves collection identity when a row changes without changing membership", () => { + const project = createCollectionQuery<(typeof snapshots)[number]>(); + const before = project(snapshots); + const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]); + expect(after).toBe(before); + expect(after).toEqual([first, second]); + }); + + it("shares unaffected groups and deduplicates membership", () => { + const project = createCollectionQuery<(typeof snapshots)[number]>(); + const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels }; + const before = project(snapshots, query); + const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query); + expect(after[1]).toBe(before[1]); + expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] }); + expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] }); + expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after); + }); + + it("filters hosts and predicates before sorting", () => { + const project = createCollectionQuery<(typeof snapshots)[number]>(); + expect(project(snapshots, { hosts: ["two"] })).toEqual([second]); + expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]); + expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]); + expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([ + first, + second, + ]); + }); + + it("keeps nulls last in either direction and metadata from the first group", () => { + const project = createCollectionQuery<{ + ref: typeof first; + hostId: string; + title: string | null; + }>(); + const items = [ + { ref: first, hostId: "one", title: null }, + { ref: second, hostId: "one", title: "Z" }, + ]; + expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]); + expect( + project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }), + ).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]); + }); +}); diff --git a/packages/plugin/src/client/query.ts b/packages/plugin/src/client/query.ts new file mode 100644 index 0000000000..8e291602ca --- /dev/null +++ b/packages/plugin/src/client/query.ts @@ -0,0 +1,115 @@ +interface GroupDescriptor { + readonly key: string; + readonly title?: string; + readonly color?: string; +} + +export type GroupKey = string | GroupDescriptor; + +export interface Query { + readonly hosts?: readonly string[]; + readonly where?: (item: Snapshot) => boolean; + readonly orderBy?: + | keyof Snapshot + | readonly [keyof Snapshot, "asc" | "desc"] + | ((a: Snapshot, b: Snapshot) => number); + readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[]; +} + +export interface Group { + readonly key: string; + readonly title: string | null; + readonly color: string | null; + readonly items: readonly Ref[]; +} + +function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] { + if (typeof value === "string") return [value]; + if ("key" in value) return [value]; + return value; +} + +interface Referenced { + readonly hostId: string; + readonly ref: unknown; +} + +function sameSequence(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]); +} + +function compareValues(left: unknown, right: unknown, direction: number): number { + if (Object.is(left, right)) return 0; + if (left === null || left === undefined) return 1; + if (right === null || right === undefined) return -1; + if (typeof left === "number" && typeof right === "number") return (left - right) * direction; + return String(left).localeCompare(String(right)) * direction; +} + +/** A hook owns one projection; sharing survives both source and query changes. */ +export function createCollectionQuery() { + type Ref = Snapshot["ref"]; + let previousItems: readonly Ref[] = Object.freeze([]); + let previousGroups: readonly Group[] = Object.freeze([]); + + return function project( + snapshots: readonly Snapshot[], + query: Query = {}, + ): readonly Ref[] | readonly Group[] { + const items = snapshots.filter((item) => { + if (query.hosts && !query.hosts.includes(item.hostId)) return false; + return !query.where || query.where(item); + }); + const order = query.orderBy; + if (typeof order === "function") items.sort(order); + else if (order !== undefined) { + const field = typeof order === "object" ? order[0] : order; + const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1; + items.sort((a, b) => compareValues(a[field], b[field], direction)); + } + if (!query.groupBy) { + const refs = items.map((item) => item.ref); + if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs); + return previousItems; + } + const groups = new Map< + string, + { key: string; title: string | null; color: string | null; items: Ref[] } + >(); + for (const item of items) { + const grouping = query.groupBy(item); + const keys = groupKeys(grouping); + const seen = new Set(); + for (const value of keys) { + const descriptor = typeof value === "string" ? { key: value } : value; + if (seen.has(descriptor.key)) continue; + seen.add(descriptor.key); + let group = groups.get(descriptor.key); + if (!group) { + group = { + key: descriptor.key, + title: descriptor.title ?? null, + color: descriptor.color ?? null, + items: [], + }; + groups.set(descriptor.key, group); + } + group.items.push(item.ref); + } + } + const oldGroups = new Map(previousGroups.map((group) => [group.key, group])); + const nextGroups = Array.from(groups.values(), (group) => { + const previous = oldGroups.get(group.key); + if ( + previous && + previous.title === group.title && + previous.color === group.color && + sameSequence(previous.items, group.items) + ) + return previous; + return Object.freeze({ ...group, items: Object.freeze(group.items) }); + }); + if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups); + return previousGroups; + }; +} diff --git a/packages/plugin/src/client/reference-api.ts b/packages/plugin/src/client/reference-api.ts new file mode 100644 index 0000000000..b782e26438 --- /dev/null +++ b/packages/plugin/src/client/reference-api.ts @@ -0,0 +1,88 @@ +import type { + PaseoApi, + PaseoAgent, + PaseoAgentHandle, + PaseoWorkspace, + PaseoWorkspaceHandle, + PaseoWorkspaceCreateOptions, +} from "@getpaseo/client"; +import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client"; +import type { PluginProjectSnapshot } from "../contracts.js"; +import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js"; +import type { PluginSidebarViewProps } from "./contracts.js"; + +export interface PluginProjectHandle { + readonly ref: ProjectRef; + open(): void; + createWorkspace( + input?: Omit & { + source?: PaseoWorkspaceCreateOptions["source"]; + }, + ): Promise; +} +export interface PluginTerminalHandle extends PaseoTerminalHandle { + focus(): void; +} +export interface PluginPaseoApi extends PaseoApi { + workspaces: Omit & { + ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle; + }; + agents: Omit & { + ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle; + }; + projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle }; + terminals: Omit & { + ref(terminal: TerminalRef): PluginTerminalHandle; + ref(terminal: string | PaseoTerminal): PaseoTerminalHandle; + }; +} + +/** Host adapter; the installation retains its single API identity across every surface. */ +export function createPluginReferenceApi(input: { + paseo: PaseoApi; + resolveHost(hostId: string): PaseoApi; + readProject(ref: ProjectRef): PluginProjectSnapshot | null; + navigation: PluginSidebarViewProps["navigation"]; +}): PluginPaseoApi { + const { paseo } = input; + const workspaceRef = paseo.workspaces.ref; + const agentRef = paseo.agents.ref; + const terminalRef = paseo.terminals.ref; + function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) { + if (typeof workspace !== "string" && "hostId" in workspace) + return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id); + return workspaceRef(workspace); + } + function refAgent(agent: string | PaseoAgent | AgentRef) { + if (typeof agent !== "string" && "hostId" in agent) + return input.resolveHost(agent.hostId).agents.ref(agent.id); + return agentRef(agent); + } + function terminal(ref: TerminalRef): PluginTerminalHandle; + function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle; + function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle { + if (typeof ref !== "string" && "hostId" in ref) { + const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id); + return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) }); + } + return terminalRef(ref); + } + function project(ref: ProjectRef): PluginProjectHandle { + return { + ref, + open: () => input.navigation.openProject(ref), + async createWorkspace(options = {}) { + const snapshot = input.readProject(ref); + if (!snapshot) throw new Error("Project is no longer available"); + const source = options.source ?? { kind: "directory", path: snapshot.rootPath }; + return input.resolveHost(ref.hostId).workspaces.create({ ...options, source }); + }, + }; + } + return Object.assign(paseo, { + workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }), + agents: Object.assign(paseo.agents, { ref: refAgent }), + projects: Object.assign(paseo.projects, { ref: project }), + terminals: Object.assign(paseo.terminals, { ref: terminal }), + }); +} diff --git a/packages/plugin/src/client/refs.test.ts b/packages/plugin/src/client/refs.test.ts new file mode 100644 index 0000000000..93407414fc --- /dev/null +++ b/packages/plugin/src/client/refs.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createEntityRefs } from "./refs.js"; + +describe("session entity references", () => { + it("interns frozen references separately by kind, host and entity", () => { + const refs = createEntityRefs(); + for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) { + const first = reference("host", "entity"); + expect(reference("host", "entity")).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(reference("other", "entity")).not.toBe(first); + expect(reference("host", "other")).not.toBe(first); + } + expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity")); + }); + + it("keeps opaque ids unambiguous and interning scoped to one app session", () => { + const refs = createEntityRefs(); + expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key); + expect(createEntityRefs().workspace("host", "entity")).not.toBe( + refs.workspace("host", "entity"), + ); + }); +}); diff --git a/packages/plugin/src/client/refs.ts b/packages/plugin/src/client/refs.ts new file mode 100644 index 0000000000..0dbdd69428 --- /dev/null +++ b/packages/plugin/src/client/refs.ts @@ -0,0 +1,26 @@ +import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js"; + +/** Owned by the app session, shared by every plugin installation. */ +export function createEntityRefs() { + function intern(kind: Kind) { + const references = new Map< + string, + Readonly<{ kind: Kind; key: string; hostId: string; id: string }> + >(); + return function reference(hostId: string, id: string) { + const key = JSON.stringify([hostId, id]); + let ref = references.get(key); + if (!ref) { + ref = Object.freeze({ kind, key, hostId, id }); + references.set(key, ref); + } + return ref; + }; + } + return { + workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef, + agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef, + project: intern("project") satisfies (hostId: string, id: string) => ProjectRef, + terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef, + }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 7e71cb22c5..d99d61e8aa 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,3 +1,4 @@ +import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js"; import type { PluginText } from "./text.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginSerializableIcon } from "./icons.js"; @@ -19,7 +20,22 @@ export interface PluginTheme { }; } +export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done"; + export interface PluginWorkspaceSnapshot { + readonly ref: WorkspaceRef; + readonly hostId: string; + readonly project: ProjectRef; + readonly pinnedAt: string | null; + readonly labels: readonly string[]; + readonly background: boolean; + readonly branch: string | null; + /** Null when an older host has not supplied the workspace creation time. */ + readonly createdAt: string | null; + readonly lastPromptAt: string | null; + readonly parentAgent: AgentRef | null; + readonly indicator: WorkspaceIndicator; + readonly indicatorLabel: string | null; readonly id: string; readonly projectId: string; readonly projectDisplayName: string; @@ -29,6 +45,7 @@ export interface PluginWorkspaceSnapshot { readonly kind: "directory" | "local_checkout" | "checkout" | "worktree"; readonly name: string; readonly title: string | null; + /** Raw daemon status; prefer indicator for presentation. */ readonly status: "needs_input" | "failed" | "running" | "attention" | "done"; readonly statusEnteredAt: string | null; readonly archivingAt: string | null; @@ -36,6 +53,10 @@ export interface PluginWorkspaceSnapshot { } export interface PluginAgentSnapshot { + readonly ref: AgentRef; + readonly hostId: string; + readonly workspace: WorkspaceRef; + readonly parentAgent: AgentRef | null; readonly id: string; readonly workspaceId: string; readonly provider: string; @@ -96,3 +117,22 @@ export interface PluginTimelineTransformResult { } export type PluginCleanup = () => void | Promise; + +export interface PluginProjectSnapshot { + readonly ref: ProjectRef; + readonly hostId: string; + readonly displayName: string; + readonly rootPath: string; + readonly kind: PluginWorkspaceSnapshot["projectKind"]; + readonly iconRevision: string | null; +} + +export interface PluginTerminalSnapshot { + readonly ref: TerminalRef; + readonly hostId: string; + readonly workspace: WorkspaceRef; + readonly title: string; + readonly isScript: boolean; + /** Null for terminals owned by workers started before timestamps were tracked. */ + readonly createdAt: string | null; +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 839b5003df..8680ae949f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -3,6 +3,9 @@ export type { PluginTheme, PluginWorkspaceSnapshot, PluginAgentSnapshot, + PluginProjectSnapshot, + PluginTerminalSnapshot, + WorkspaceIndicator, PluginThemeColors, PluginThemeContribution, PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/refs.ts b/packages/plugin/src/refs.ts new file mode 100644 index 0000000000..e0293bfe09 --- /dev/null +++ b/packages/plugin/src/refs.ts @@ -0,0 +1,11 @@ +interface EntityRef { + readonly kind: Kind; + readonly key: string; + readonly hostId: string; + readonly id: string; +} + +export interface WorkspaceRef extends EntityRef<"workspace"> {} +export interface AgentRef extends EntityRef<"agent"> {} +export interface ProjectRef extends EntityRef<"project"> {} +export interface TerminalRef extends EntityRef<"terminal"> {} diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts index 30e890b3ef..e670885678 100644 --- a/packages/plugin/src/settings.ts +++ b/packages/plugin/src/settings.ts @@ -4,10 +4,10 @@ import { defineRpc } from "./rpc.js"; export interface SettingsDefinition { id: string; - scope: "host"; + scope: "host" | "device"; version: number; schema: Schema; - /** Convert a previous stored version to the current schema. Runs on the host. */ + /** Convert a previous stored version to the current schema. Runs in the selected scope. */ migrate?: (values: unknown, fromVersion: number) => unknown | Promise; } @@ -15,7 +15,8 @@ export function defineSettings( definition: SettingsDefinition, ): SettingsDefinition { if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID"); - if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported"); + if (definition.scope !== "host" && definition.scope !== "device") + throw new Error("Invalid settings scope"); if (!Number.isSafeInteger(definition.version) || definition.version < 1) throw new Error("Invalid settings version"); return definition; diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 373cef409b..ebe7d9b7d8 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -571,3 +571,43 @@ test.each(["all", "base", "uncommitted"] as const)( expect(parsed.attachments).toEqual([attachment]); }, ); + +describe("terminal collection protocol compatibility", () => { + test("accepts released directory observations and their snapshots", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "subscribe_terminals_request", + cwd: "/repo", + requestId: "old", + }), + ).toMatchObject({ cwd: "/repo" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "terminals_changed", + payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] }, + }), + ).toMatchObject({ payload: { cwd: "/repo" } }); + }); + test("accepts capability-gated host observations and additive metadata", () => { + expect( + SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }), + ).toMatchObject({ requestId: "host" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "terminals_changed", + payload: { + terminals: [ + { + id: "terminal", + name: "Shell", + cwd: "/repo", + workspaceId: "workspace", + createdAt: null, + isScript: true, + }, + ], + }, + }), + ).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2899bf56cb..a6d05ca509 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3093,13 +3093,13 @@ export const ListTerminalsRequestSchema = z.object({ export const SubscribeTerminalsRequestSchema = z.object({ type: z.literal("subscribe_terminals_request"), requestId: z.string().optional(), - cwd: z.string(), + cwd: z.string().optional(), workspaceId: z.string().optional(), }); export const UnsubscribeTerminalsRequestSchema = z.object({ type: z.literal("unsubscribe_terminals_request"), - cwd: z.string(), + cwd: z.string().optional(), workspaceId: z.string().optional(), }); @@ -3770,6 +3770,7 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), + terminalCollection: z.boolean().optional(), // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. workspaceBaseBranch: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. @@ -6684,6 +6685,8 @@ export const ListCommandsResponseSchema = z.object({ // ============================================================================ const TerminalInfoSchema = z.object({ + createdAt: z.string().nullable().optional(), + isScript: z.boolean().optional(), id: z.string(), name: z.string(), cwd: z.string(), @@ -6747,8 +6750,8 @@ export const TerminalsChangedSchema = z.object({ subscriptionId: z.string().optional(), requestId: z.string().optional(), workspaceId: z.string().optional(), - cwd: z.string(), - terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + cwd: z.string().optional(), + terminals: z.array(TerminalInfoSchema.partial({ cwd: true })), }), }); diff --git a/packages/protocol/src/terminal-subscription-key.ts b/packages/protocol/src/terminal-subscription-key.ts index 648ff96f71..4d64dfff83 100644 --- a/packages/protocol/src/terminal-subscription-key.ts +++ b/packages/protocol/src/terminal-subscription-key.ts @@ -3,6 +3,11 @@ // and tear down its own live subscription without disturbing the other. An // absent workspaceId (old clients) keys to the cwd on its own. The `::` // separator cannot collide with a workspace id, which is always `wks_`. -export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string { +export function terminalSubscriptionKey( + cwd: string | undefined, + workspaceId: string | undefined, +): string { + if (cwd === undefined) + return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`; return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`; } diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 80c9409cef..b0c38bc985 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -1,5 +1,5 @@ import { resolveDaemonVersion } from "../daemon-version.js"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -269,3 +269,132 @@ test.runIf(process.platform !== "win32")( expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); }, ); + +test("host-wide terminal observation tracks every workspace, metadata and release", async () => { + const first = await createWorkspace("Observe first"); + const second = await createWorkspace("Observe second"); + expect(client.getLastServerInfoMessage()?.features?.terminalCollection).toBe(true); + const observation = client.observeTerminals({}); + let terminals = (await observation.ready).terminals; + const update = observation.subscribe({ + snapshot: (value) => { + terminals = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") terminals = message.payload.terminals; + }, + }); + try { + expect(terminals).toEqual([]); + const scoped = client.observeTerminals({ workspaceId: first }); + const scopedSnapshot = await scoped.ready; + let owned = scopedSnapshot.terminals; + const releaseScoped = scoped.subscribe({ + snapshot: (value) => { + owned = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") owned = message.payload.terminals; + }, + }); + // Use a controlled process: interactive shells publish their own OSC title on startup. + const command = { + command: process.execPath, + args: ["-e", "console.log('READY'); process.stdin.resume()"], + }; + const a = await sdk.terminals.create({ + workspaceId: first, + name: "First terminal", + ...command, + }); + const b = await sdk.terminals.create({ + workspaceId: second, + name: "Second terminal", + ...command, + }); + await expect + .poll(async () => (await a.capture({ stripAnsi: true })).lines.join("\n")) + .toContain("READY"); + await expect + .poll(async () => (await b.capture({ stripAnsi: true })).lines.join("\n")) + .toContain("READY"); + await expect.poll(() => terminals.map((item) => item.id).sort()).toEqual([a.id, b.id].sort()); + expect(terminals.every((item) => item.cwd === cwd && item.createdAt && !item.isScript)).toBe( + true, + ); + expect(terminals.map((item) => item.workspaceId).sort()).toEqual([first, second].sort()); + await expect.poll(() => owned.map((item) => item.id)).toEqual([a.id]); + releaseScoped(); + await scoped.release(); + await client.renameTerminal({ terminalId: a.id, title: "Renamed terminal" }); + await expect + .poll(() => terminals.find((item) => item.id === a.id)?.title) + .toBe("Renamed terminal"); + await b.kill(); + await expect.poll(() => terminals.map((item) => item.id)).toEqual([a.id]); + await a.kill(); + await expect.poll(() => terminals).toEqual([]); + } finally { + update(); + await observation.release(); + } +}, 60_000); + +test("terminal collection reports workspace script ownership after runtime registration", async () => { + await writeFile( + path.join(cwd, "paseo.json"), + JSON.stringify({ + scripts: { + sleeper: { + type: "script", + command: `node -e "const fs = require('node:fs'); fs.writeFileSync('sleeper.pid', String(process.pid)); setInterval(() => { if (fs.existsSync('finish-sleeper')) process.exit(0); }, 50)"`, + }, + }, + }), + ); + const workspaceId = await createWorkspace("Script observation"); + const observation = client.observeTerminals({}); + let terminals = (await observation.ready).terminals; + const release = observation.subscribe({ + snapshot: (value) => { + terminals = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") terminals = message.payload.terminals; + }, + }); + try { + expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect + .poll(() => + terminals.some((terminal) => terminal.workspaceId === workspaceId && terminal.isScript), + ) + .toBe(true); + const terminalId = terminals.find((terminal) => terminal.workspaceId === workspaceId)!.id; + const pidFile = path.join(cwd, "sleeper.pid"); + await expect + .poll(async () => Number(await readFile(pidFile, "utf8")), { timeout: 10_000 }) + .toBeGreaterThan(0); + const pid = Number(await readFile(pidFile, "utf8")); + await writeFile(path.join(cwd, "finish-sleeper"), "done"); + await expect.poll(() => isFixtureProcessRunning(pid), { timeout: 10_000 }).toBe(false); + // The program has exited; now close its interactive shell as well. + await sdk.terminals.ref(terminalId).kill(); + await expect + .poll(() => terminals.filter((terminal) => terminal.workspaceId === workspaceId)) + .toEqual([]); + } finally { + release(); + await observation.release(); + } +}, 60_000); + +function isFixtureProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 1b2e7192c7..1792a9d7ff 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -2122,7 +2122,7 @@ it.each(examples)("builds and loads the %s plugin example", async (example) => { const runtime = createTestRuntime( { settingsDirectory }, undefined, - resolveDaemonVersion(import.meta.url), + example === "sidebar-views" ? "0.16.0" : resolveDaemonVersion(import.meta.url), ); try { await runtime.startPlugin(example, path.join(examplesDirectory, example)); diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts index de81919eae..f47a8fb6ba 100644 --- a/packages/server/src/server/plugins/settings/index.test.ts +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -175,3 +175,11 @@ test("installation namespaces and definition IDs remain separate", async () => { expect(() => store.register(definition)).toThrow("Duplicate settings"); expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID"); }); + +test("device-scoped settings are owned by the client", async () => { + const { directory } = await setup(); + const store = new PluginSettingsStore(directory, () => {}); + expect(() => store.register(defineSettings({ ...definition, scope: "device" }))).toThrow( + "Device settings belong to the client", + ); +}); diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index 775a47f6a4..683eb3ab82 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -45,6 +45,7 @@ export class PluginSettingsStore { register(definition: SettingsDefinition) { defineSettings(definition); + if (definition.scope !== "host") throw new Error("Device settings belong to the client"); if (this.definitions.has(definition.id)) throw new Error(`Duplicate settings: ${definition.id}`); this.definitions.set(definition.id, definition); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 55d6c58401..43710fa35d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1187,6 +1187,12 @@ export class Session { isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), + isScriptTerminal: (terminal) => + this.scriptRuntimeStore + ?.listForWorkspace(terminal.workspaceId) + .some((script) => script.terminalId === terminal.id) ?? false, + subscribeTerminalMetadata: (listener) => + this.scriptRuntimeStore?.subscribe(listener) ?? (() => {}), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentDirectory = new AgentDirectory({ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 1bbc171f4a..c5f1303f77 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2038,6 +2038,7 @@ export class VoiceAssistantWebSocketServer { directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. ...(this.workspaceLabelService ? { workspaceLabels: true } : {}), + terminalCollection: true, // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, workspaceSetupObservation: true, diff --git a/packages/server/src/server/workspace-script-runtime-store.test.ts b/packages/server/src/server/workspace-script-runtime-store.test.ts index 625d1f2fe9..8b33d4ea53 100644 --- a/packages/server/src/server/workspace-script-runtime-store.test.ts +++ b/packages/server/src/server/workspace-script-runtime-store.test.ts @@ -97,3 +97,16 @@ describe("WorkspaceScriptRuntimeStore", () => { ]); }); }); + +it("notifies terminal metadata consumers when script ownership changes", () => { + const store = new WorkspaceScriptRuntimeStore(); + const observed: string[][] = []; + const release = store.subscribe(() => + observed.push(store.listForWorkspace("workspace-101").map((entry) => entry.terminalId)), + ); + store.set(createEntry()); + store.remove({ workspaceId: "workspace-101", scriptName: "web" }); + release(); + store.set(createEntry()); + expect(observed).toEqual([["terminal-1"], []]); +}); diff --git a/packages/server/src/server/workspace-script-runtime-store.ts b/packages/server/src/server/workspace-script-runtime-store.ts index a2f48199b6..f8f0ca0698 100644 --- a/packages/server/src/server/workspace-script-runtime-store.ts +++ b/packages/server/src/server/workspace-script-runtime-store.ts @@ -13,6 +13,17 @@ interface RuntimeEntryKey { } export class WorkspaceScriptRuntimeStore { + private readonly listeners = new Set<() => void>(); + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + private changed(): void { + for (const listener of this.listeners) listener(); + } + private readonly entries = new Map(); private readonly scriptsByWorkspace = new Map>(); @@ -31,6 +42,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.set(entryKey, { ...entry }); this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName); + this.changed(); } remove(key: RuntimeEntryKey): void { @@ -42,6 +54,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.delete(entryKey); this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName); + this.changed(); } listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] { @@ -70,6 +83,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.delete(this.toEntryKey(entry)); } this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId)); + this.changed(); } isRunning(key: RuntimeEntryKey): boolean { diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index d91eae6f06..c81cd2fbb5 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -392,8 +392,25 @@ describe("terminal-session-controller with terminals held by a newer Paseo", () }; } + test("reports incompatible workers to a legacy subscription without a request id", async () => { + const outboundMessages: SessionOutboundMessage[] = []; + const controller = createController({ + terminalManager: incompatibleManager(), + emit: (message) => outboundMessages.push(message), + emitBinary: () => {}, + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + }); + await controller.dispatch({ type: "subscribe_terminals_request" }); + expect(outboundMessages).toEqual([ + { type: "status", payload: { status: "error", message: incompatible.message } }, + ]); + }); + test.each([ { type: "list_terminals_request" as const, requestId: "req-list" }, + { type: "subscribe_terminals_request" as const, requestId: "req-observe" }, { type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" }, { type: "create_terminal_request" as const, @@ -505,8 +522,22 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, - { id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null }, + { + id: "root-term", + name: "Terminal 1", + workspaceId: "ws-test", + activity: null, + createdAt: null, + isScript: false, + }, + { + id: "subdir-term", + name: "Mobile", + workspaceId: "ws-test", + activity: null, + createdAt: null, + isScript: false, + }, ], }, }, @@ -576,6 +607,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { cwd: rootCwd, workspaceId: "ws-test", activity: null, + createdAt: null, + isScript: false, }, ], requestId: "req-root", @@ -592,6 +625,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { cwd: worktreeCwd, workspaceId: "ws-test", activity: null, + createdAt: null, + isScript: false, }, ], requestId: "req-worktree", @@ -677,7 +712,16 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { type: "terminals_changed", payload: { cwd, - terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }], + terminals: [ + { + id: "a", + name: "A", + workspaceId: "ws-a", + activity: null, + createdAt: null, + isScript: false, + }, + ], }, }, ]); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 653524e54a..d047ee0c20 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -38,13 +38,12 @@ import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { applyTerminalSize } from "./terminal-size-ownership.js"; import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; -import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; const MAX_TERMINAL_STREAM_SLOTS = 256; interface TerminalDirectorySubscription { - cwd: string; + cwd: string | undefined; workspaceId: string | undefined; owner: OwnedSubscription; refresh: Promise | null; @@ -87,6 +86,8 @@ export interface TerminalSessionControllerOptions { supportsChunkedReplay?: (source: object) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; + isScriptTerminal?: (terminal: TerminalSession) => boolean; + subscribeTerminalMetadata?: (listener: () => void) => () => void; listTerminalWorkspaceRefs?: () => Promise; listTerminalWorkspaceRoots?: () => Promise; // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and @@ -163,9 +164,17 @@ export class TerminalSessionController { start(): void { if (!this.terminalManager || this.unsubscribeTerminalsChanged) return; - this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => { + const releaseManager = this.terminalManager.subscribeTerminalsChanged((event) => { void this.handleTerminalsChanged(event); }); + const releaseMetadata = this.options.subscribeTerminalMetadata?.(() => { + for (const subscription of this.subscribedDirectories.values()) + void this.emitTerminalsSnapshotForSubscription(subscription); + }); + this.unsubscribeTerminalsChanged = () => { + releaseManager(); + releaseMetadata?.(); + }; } getMetrics(): TerminalSessionControllerMetrics { @@ -191,6 +200,7 @@ export class TerminalSessionController { ) { return false; } + if (subscription.cwd === undefined) return true; return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots); }); } @@ -302,15 +312,7 @@ export class TerminalSessionController { } } - private toTerminalInfo( - terminal: Pick, - ): { - id: string; - name: string; - workspaceId: string; - title?: string; - activity: TerminalActivity | null; - } { + private toTerminalInfo(terminal: TerminalSession) { const title = terminal.getTitle(); const activity = terminal.getActivity(); return { @@ -319,6 +321,8 @@ export class TerminalSessionController { workspaceId: terminal.workspaceId, ...(title ? { title } : {}), activity, + createdAt: terminal.createdAt ?? null, + isScript: this.options.isScriptTerminal?.(terminal) ?? false, }; } @@ -329,7 +333,8 @@ export class TerminalSessionController { // aggregated list — so the client's cache replacement doesn't drop the // terminals that live directly at the root. const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter( - (subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd), + (subscription) => + subscription.cwd === undefined || this.isPathWithinRoot(subscription.cwd, event.cwd), ); for (const subscription of matchingSubscriptions) { await this.emitTerminalsSnapshotForSubscription(subscription); @@ -368,6 +373,10 @@ export class TerminalSessionController { await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId); } catch (error) { await owner.release(); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } throw error; } } @@ -383,17 +392,26 @@ export class TerminalSessionController { const refresh = (async () => { do { subscription.pending = false; - const terminals = await this.getTerminalsForWorkspaceRoot( - subscription.cwd, - subscription.workspaceId, - ); + const terminals = + subscription.cwd === undefined + ? (await this.getAllTerminalSessions()).filter( + (terminal) => + subscription.workspaceId === undefined || + terminal.workspaceId === subscription.workspaceId, + ) + : await this.getTerminalsForWorkspaceRoot(subscription.cwd, subscription.workspaceId); if (subscription.owner.signal.aborted) return; subscription.owner.emit({ type: "terminals_changed", payload: { cwd: subscription.cwd, workspaceId: subscription.workspaceId, - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + terminals: terminals.map((terminal) => + Object.assign( + this.toTerminalInfo(terminal), + subscription.cwd === undefined ? { cwd: terminal.cwd } : {}, + ), + ), ...(requestId ? { requestId } : {}), }, }); @@ -464,9 +482,13 @@ export class TerminalSessionController { // An empty list or a generic failure would read as "no terminals"; the client needs to know // they exist and why it cannot reach them. private emitIncompatible( - msg: { type: string; requestId: string }, + msg: { type: string; requestId?: string }, error: TerminalWorkerIncompatibleError, ): void { + if (msg.requestId === undefined) { + this.emit({ type: "status", payload: { status: "error", message: error.message } }); + return; + } this.emit({ type: "rpc_error", payload: { @@ -482,6 +504,8 @@ export class TerminalSessionController { if (!this.terminalManager) { return []; } + const unavailable = this.terminalManager.getUnavailableError?.(); + if (unavailable) throw unavailable; const directories = this.terminalManager.listDirectories(); const manager = this.terminalManager; const terminalsByDirectory = await Promise.all( diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index b9eb4698c0..c61ead0628 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -263,7 +263,12 @@ function runWorker(argv: string[]): void { } function terminalMetadata(session: LocalTerminalSession) { - return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() }; + return { + ...session.getSize(), + createdAt: session.createdAt, + hasOutput: session.hasOutput(), + title: session.getTitle(), + }; } function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo { diff --git a/packages/server/src/terminal/terminal-worker-protocol.test.ts b/packages/server/src/terminal/terminal-worker-protocol.test.ts index 6ea9a823db..9193a5dabd 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.test.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; +import { + classifyTerminalWorkerProtocol, + TERMINAL_WORKER_PROTOCOL_VERSION, +} from "./terminal-worker-protocol.js"; describe("classifyTerminalWorkerProtocol", () => { it("rejects the unreleased cell protocol without a draining adapter", () => { @@ -23,3 +26,8 @@ describe("classifyTerminalWorkerProtocol", () => { expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit); }); }); + +it("drains v5 terminals while v6 supplies creation timestamps", () => { + expect(TERMINAL_WORKER_PROTOCOL_VERSION).toBe(6); + expect(classifyTerminalWorkerProtocol(5)).toBe("previous"); +}); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index e781543697..62e731a4dc 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -1,6 +1,8 @@ import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js"; import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js"; export interface WorkerTerminalState { + /** COMPAT(terminal-created-at): v6; v5 draining workers omit this, remove after 2027-06. */ + createdAt?: string; rows: number; cols: number; hasOutput: boolean; @@ -43,7 +45,7 @@ export interface WorkerCreateTerminalOptions { // `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new // terminals; it exits after its last terminal), shuts down anything older, and leaves a newer // worker running untouched. See docs/terminal-worker.md. -export const TERMINAL_WORKER_PROTOCOL_VERSION = 5; +export const TERMINAL_WORKER_PROTOCOL_VERSION = 6; // Test-only: the protocol version a worker reports, so tests can stand up older and newer // workers from one build. diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b09bc8bbf6..183be5e8bf 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -88,6 +88,8 @@ export interface TerminalActivityTransition { } export interface TerminalSession { + /** Absent on terminal workers started before creation timestamps were tracked. */ + createdAt?: string; id: string; name: string; cwd: string; @@ -1378,6 +1380,7 @@ export async function createTerminal( return { id, + createdAt: new Date().toISOString(), pid: ptyProcess.pid, name, cwd, diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 26cbb031d0..b13e63b074 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1666,19 +1666,19 @@ it.each([1, 4])( expect(manager.getTerminal(old.terminalId)).toBeUndefined(); const workers = await workerRecords(paseoHome); expect(workers).toHaveLength(1); - expect(workers[0]!.protocolVersion).toBe(5); + expect(workers[0]!.protocolVersion).toBe(6); }, ); it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => { const paseoHome = createTemporaryHome(); - const newer = await leaveWorkerWithTerminal(paseoHome, 6); + const newer = await leaveWorkerWithTerminal(paseoHome, 7); manager = await startRealWorkerManager({ paseoHome }); const unavailable = manager.getUnavailableError?.(); expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError); - expect(unavailable?.message).toContain("worker protocol 6"); + expect(unavailable?.message).toContain("worker protocol 7"); await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf( TerminalWorkerIncompatibleError, ); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 2592b0c89c..097def78d3 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -265,6 +265,9 @@ export function createWorkerTerminalManager( }; const session: TerminalSession = { + get createdAt() { + return record.state.createdAt; + }, get id() { return record.info.id; }, @@ -370,6 +373,10 @@ export function createWorkerTerminalManager( for (const listener of Array.from(record.titleChangeListeners)) { listener(manualTitle); } + emitTerminalsChanged({ + cwd: record.info.cwd, + terminals: listTerminalItemsForCwd(record.info.cwd), + }); }, getExitInfo(): TerminalExitInfo | null { return record.exitInfo; diff --git a/plugin-examples/sidebar-views/client/agents.tsx b/plugin-examples/sidebar-views/client/agents.tsx new file mode 100644 index 0000000000..8d0d469121 --- /dev/null +++ b/plugin-examples/sidebar-views/client/agents.tsx @@ -0,0 +1,34 @@ +import type { PluginAgentSnapshot } from "@getpaseo/plugin"; +import { ScrollView, View } from "react-native"; +import { useAgents, type PluginSidebarViewProps } from "@getpaseo/plugin/client"; +import { AgentRow, Heading } from "./rows"; +export function agentTask(agent: PluginAgentSnapshot) { + if (agent.attentionReason === "permission") return "Respond"; + if (agent.requiresAttention || agent.status === "error") return "Review"; + if (agent.status === "running" || agent.status === "initializing") return "Wait"; + return "Start"; +} +function Task({ title, view }: { title: string; view: PluginSidebarViewProps }) { + const agents = useAgents({ + where: (item) => agentTask(item) === title, + orderBy: ["lastActivityAt", "desc"], + }); + return ( + + + {agents.map((reference) => ( + + ))} + + ); +} +export function Agents(view: PluginSidebarViewProps) { + return ( + + + + + + + ); +} diff --git a/plugin-examples/sidebar-views/client/labels.tsx b/plugin-examples/sidebar-views/client/labels.tsx new file mode 100644 index 0000000000..da2e1ae7ab --- /dev/null +++ b/plugin-examples/sidebar-views/client/labels.tsx @@ -0,0 +1,191 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { + usePaseo, + useSettings, + useWorkspaces, + type WorkspaceRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { folders } from "../shared/preferences"; +import { Heading, WorkspaceRow } from "./rows"; +interface Move { + reference: WorkspaceRef; + from: string | null; +} +function folderLabel(key: string): string | null { + return key === "unlabelled" ? null : key.slice("label:".length); +} +function Movable({ + reference, + folder, + view, + select, +}: { + reference: WorkspaceRef; + folder: string | null; + view: PluginSidebarViewProps; + select: (move: Move) => void; +}) { + const pick = useCallback(() => select({ reference, from: folder }), [select, reference, folder]); + return ( + + + + + + + ); +} +export function Labels(view: PluginSidebarViewProps) { + const paseo = usePaseo(); + const settings = useSettings(folders); + const [selected, select] = useState(null); + const groups = useWorkspaces({ + orderBy: "name", + groupBy: (item) => + item.labels.length ? item.labels.map((label) => `label:${label}`) : ["unlabelled"], + }); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, paddingHorizontal: 12 }), + [view.theme], + ); + const move = useMutation({ + mutationFn: async (destination: string | null) => { + if (!selected) return; + const workspace = paseo.workspaces.ref(selected.reference); + // Add first: if adding fails the workspace keeps its original folder. + if (destination && destination !== selected.from) + await workspace.setLabel(destination, { assigned: true }); + if (selected.from && selected.from !== destination) + await workspace.setLabel(selected.from, { assigned: false }); + select(null); + }, + }); + const toggle = useCallback( + (folder: string) => { + if (settings.status !== "ready") return; + void settings.save( + { + collapsed: settings.values.collapsed.includes(folder) + ? settings.values.collapsed.filter((item) => item !== folder) + : [...settings.values.collapsed, folder], + }, + settings.revision, + ); + }, + [settings], + ); + const cancel = useCallback(() => select(null), []); + if (settings.status === "loading") return Loading folders…; + if (settings.status !== "ready") + return ( + + {settings.error} + + + + {settings.status === "invalid" ? ( + + + + ) : null} + + ); + return ( + + {selected ? ( + + Choose a destination folder + + + + + ) : ( + Long press a workspace or choose Move… + )} + {move.error ? {move.error.message} : null} + {Array.from( + new Set(["label:Inbox", "label:Later", "unlabelled", ...groups.map((group) => group.key)]), + ).map((folder) => ( + + {groups + .find((group) => group.key === folder) + ?.items.map((reference) => ( + + ))} + + ))} + {settings.saveError ? {settings.saveError} : null} + + ); +} +function Folder({ + folder, + view, + selected, + pending, + move, + collapsed, + toggle, + children, +}: { + folder: string; + view: PluginSidebarViewProps; + selected: boolean; + pending: boolean; + move: (folder: string | null) => void; + collapsed: boolean; + toggle: (folder: string) => void; + children: import("react").ReactNode; +}) { + const toggleFolder = useCallback(() => toggle(folder), [toggle, folder]); + const label = folderLabel(folder); + const title = label ?? "Unlabelled"; + const destination = useCallback(() => move(label), [move, label]); + return ( + + + + + {selected ? ( + + + + ) : null} + {collapsed ? null : children} + + ); +} diff --git a/plugin-examples/sidebar-views/client/nested.tsx b/plugin-examples/sidebar-views/client/nested.tsx new file mode 100644 index 0000000000..b6f4b84eb3 --- /dev/null +++ b/plugin-examples/sidebar-views/client/nested.tsx @@ -0,0 +1,109 @@ +import { ScrollView, View } from "react-native"; +import { + useProjects, + useWorkspaces, + useAgents, + useAgent, + type AgentRef, + type WorkspaceRef, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { AgentRow, ProjectHeading, WorkspaceRow } from "./rows"; +const indent = { paddingLeft: 12 }; +function AgentTree({ reference, view }: { reference: AgentRef; view: PluginSidebarViewProps }) { + const agent = useAgent(reference); + const children = useAgents({ + where: (item) => + item.parentAgent?.key === reference.key && item.workspace.key === agent?.workspace.key, + orderBy: "createdAt", + }); + return ( + + + + {children.map((child) => ( + + ))} + + + ); +} +function RootWithParent({ + reference, + parent, + workspace, + view, +}: { + reference: AgentRef; + parent: AgentRef; + workspace: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const parents = useAgents({ + where: (item) => item.ref.key === parent.key && item.workspace.key === workspace.key, + }); + return parents.length > 0 ? null : ; +} +function Root({ + reference, + workspace, + view, +}: { + reference: AgentRef; + workspace: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const agent = useAgent(reference); + if (!agent) return null; + return agent.parentAgent ? ( + + ) : ( + + ); +} +function Workspace({ reference, view }: { reference: WorkspaceRef; view: PluginSidebarViewProps }) { + const agents = useAgents({ + where: (item) => item.workspace.key === reference.key, + orderBy: "createdAt", + }); + return ( + + + + {agents.map((agent) => ( + + ))} + + + ); +} +function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) { + const workspaces = useWorkspaces({ + where: (item) => item.project.key === reference.key, + orderBy: "name", + }); + return ( + + + {workspaces.map((workspace) => ( + + ))} + + ); +} +export function Nested(view: PluginSidebarViewProps) { + const projects = useProjects({ orderBy: "displayName" }); + return ( + + {projects.map((reference) => ( + + ))} + + ); +} diff --git a/plugin-examples/sidebar-views/client/rows.tsx b/plugin-examples/sidebar-views/client/rows.tsx new file mode 100644 index 0000000000..ef55333f15 --- /dev/null +++ b/plugin-examples/sidebar-views/client/rows.tsx @@ -0,0 +1,139 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { + useWorkspace, + useAgent, + useProject, + type WorkspaceRef, + type AgentRef, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; + +export function Heading({ title, view }: { title: string; view: PluginSidebarViewProps }) { + const style = useMemo( + () => ({ + color: view.theme.colors.foregroundMuted, + fontSize: 11, + paddingHorizontal: 12, + paddingTop: 12, + paddingBottom: 4, + }), + [view.theme], + ); + return ( + + {title} + + ); +} +export function WorkspaceRow({ + reference, + view, + onLongPress, +}: { + reference: WorkspaceRef; + view: PluginSidebarViewProps; + onLongPress?: () => void; +}) { + const workspace = useWorkspace(reference); + const open = useCallback( + () => view.navigation.openWorkspace(reference), + [view.navigation, reference], + ); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, lineHeight: 16 }), + [view.theme], + ); + const detail = useMemo( + () => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }), + [view.theme], + ); + const background = useMemo( + () => ({ + ...rowStyle, + backgroundColor: + view.active.workspace?.key === reference.key ? view.theme.colors.surface2 : undefined, + }), + [view.active.workspace, view.theme, reference], + ); + if (!workspace) return null; + return ( + + + {workspace.title ?? workspace.name} + + + {workspace.indicatorLabel} + + + ); +} +export function AgentRow({ + reference, + view, +}: { + reference: AgentRef; + view: PluginSidebarViewProps; +}) { + const agent = useAgent(reference); + const open = useCallback( + () => view.navigation.openAgent(reference), + [view.navigation, reference], + ); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12 }), + [view.theme], + ); + if (!agent) return null; + return ( + + + {agent.title ?? agent.provider} + + + ); +} +export function ProjectHeading({ + reference, + view, +}: { + reference: ProjectRef; + view: PluginSidebarViewProps; +}) { + const project = useProject(reference); + const open = useCallback( + () => view.navigation.openProject(reference), + [view.navigation, reference], + ); + if (!project) return null; + return ( + + + + + + + ); +} +const rowStyle = { + minHeight: 28, + paddingHorizontal: 12, + paddingVertical: 4, + justifyContent: "center", +} as const; diff --git a/plugin-examples/sidebar-views/client/terminals.tsx b/plugin-examples/sidebar-views/client/terminals.tsx new file mode 100644 index 0000000000..2106e12130 --- /dev/null +++ b/plugin-examples/sidebar-views/client/terminals.tsx @@ -0,0 +1,131 @@ +import { Component, useCallback, useMemo, useState, type ReactNode } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { + useHosts, + useTerminals, + useTerminal, + usePaseo, + type TerminalRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { Heading } from "./rows"; + +class TerminalError extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ? {this.state.error.message} : this.props.children; + } +} +function TerminalRow({ + reference, + view, +}: { + reference: TerminalRef; + view: PluginSidebarViewProps; +}) { + const terminal = useTerminal(reference); + const paseo = usePaseo(); + const focus = useCallback(() => paseo.terminals.ref(reference).focus(), [paseo, reference]); + const close = useMutation({ mutationFn: () => paseo.terminals.ref(reference).kill() }); + const kill = useCallback(() => close.mutate(), [close]); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, padding: 12 }), + [view.theme], + ); + if (!terminal) return null; + return ( + + + + {terminal.title} + {terminal.isScript ? " (script)" : ""} + + + + Close terminal + + {close.error ? {close.error.message} : null} + + ); +} +function HostTerminals({ hostId, view }: { hostId: string; view: PluginSidebarViewProps }) { + const terminals = useTerminals({ hosts: [hostId], orderBy: "createdAt" }); + return ( + + {terminals.length ? ( + terminals.map((reference) => ( + + )) + ) : ( + + )} + + ); +} +function HostButton({ + hostId, + label, + select, + view, +}: { + hostId: string; + label: string; + select: (id: string) => void; + view: PluginSidebarViewProps; +}) { + const choose = useCallback(() => select(hostId), [select, hostId]); + return ( + + + + ); +} +export function Terminals(view: PluginSidebarViewProps) { + const hosts = useHosts(); + const [chosen, select] = useState(null); + const selected = chosen; + return ( + + {hosts.map((host) => ( + + ))} + {selected ? ( + + + + ) : ( + + )} + + ); +} diff --git a/plugin-examples/sidebar-views/client/workspaces.tsx b/plugin-examples/sidebar-views/client/workspaces.tsx new file mode 100644 index 0000000000..5c41eb4242 --- /dev/null +++ b/plugin-examples/sidebar-views/client/workspaces.tsx @@ -0,0 +1,37 @@ +import { ScrollView, View } from "react-native"; +import { + useProjects, + useWorkspaces, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { Heading, ProjectHeading, WorkspaceRow } from "./rows"; +function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) { + const workspaces = useWorkspaces({ + where: (item) => item.project.key === reference.key && !item.pinnedAt, + orderBy: "name", + }); + return ( + + + {workspaces.map((workspace) => ( + + ))} + + ); +} +export function Workspaces(view: PluginSidebarViewProps) { + const projects = useProjects({ orderBy: "displayName" }); + const pinned = useWorkspaces({ where: (item) => !!item.pinnedAt, orderBy: "name" }); + return ( + + {pinned.length ? : null} + {pinned.map((reference) => ( + + ))} + {projects.map((reference) => ( + + ))} + + ); +} diff --git a/plugin-examples/sidebar-views/index.client.tsx b/plugin-examples/sidebar-views/index.client.tsx new file mode 100644 index 0000000000..3cf194c8a2 --- /dev/null +++ b/plugin-examples/sidebar-views/index.client.tsx @@ -0,0 +1,18 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { Workspaces } from "./client/workspaces"; +import { Agents } from "./client/agents"; +import { Nested } from "./client/nested"; +import { Terminals } from "./client/terminals"; +import { Labels } from "./client/labels"; +export default function contribute(client: PluginClientContext) { + const cleanups = [ + client.addSidebarView({ id: "terminals", title: "Example terminals", Component: Terminals }), + client.addSidebarView({ id: "workspaces", title: "Example workspaces", Component: Workspaces }), + client.addSidebarView({ id: "agents", title: "Example agents", Component: Agents }), + client.addSidebarView({ id: "nested", title: "Example nested agents", Component: Nested }), + client.addSidebarView({ id: "labels", title: "Example label folders", Component: Labels }), + ]; + return () => { + for (const cleanup of cleanups) void cleanup(); + }; +} diff --git a/plugin-examples/sidebar-views/paseo-plugin.json b/plugin-examples/sidebar-views/paseo-plugin.json new file mode 100644 index 0000000000..1e66495a19 --- /dev/null +++ b/plugin-examples/sidebar-views/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "sidebar-views", + "requirements": { "paseo": ">=0.16.0" } +} diff --git a/plugin-examples/sidebar-views/shared/preferences.ts b/plugin-examples/sidebar-views/shared/preferences.ts new file mode 100644 index 0000000000..69b65eb397 --- /dev/null +++ b/plugin-examples/sidebar-views/shared/preferences.ts @@ -0,0 +1,8 @@ +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +export const folders = defineSettings({ + id: "folders", + scope: "device", + version: 1, + schema: z.object({ collapsed: z.array(z.string()).default([]) }), +}); diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index d4b247a16c..0293e3fb0d 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -73,6 +73,7 @@ Use this table as the complete registration checklist. | `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` | | `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) | | New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` | +| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) | | New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` | | `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | | Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` | diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 5e88cf9bf5..1489372e8b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -634,6 +634,13 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples. +## Sidebar views + +`addSidebarView({ id, title, description?, Component })` registers an optional replacement for the +sidebar content region. It returns cleanup. The user selects one view in Settings > Sidebar; +registration does not activate it. See [Build a sidebar view](sidebar-views.md) for the props, +ref-based queries and actions, device state and theme tokens. + ## Screens and sidebar items A screen is a full page of plugin UI. A sidebar item is a component Paseo renders in the sidebar @@ -1387,13 +1394,13 @@ export default function contribute(server: PluginServerContext) { } ``` -| Definition field | Contract | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | -| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. | -| `version` | Required positive integer describing the schema, independent of the write revision. | -| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | -| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | +| Definition field | Contract | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | +| `scope` | Required `"host"` or `"device"`. Host documents are shared by authorized clients. Device documents stay on this device, keyed by installation and settings ID; use them for layout preferences. | +| `version` | Required positive integer describing the schema, independent of the write revision. | +| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | +| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | Call `useSettings(preferences)` in any contributed component. It returns a discriminated state: diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index bb979f52e2..de3e486908 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -510,6 +510,10 @@ export default function contribute(client: PluginClientContext) { 使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。 +## 侧边栏视图 {#sidebar-views} + +`addSidebarView({ id, title, description?, Component })` 注册可替换侧边栏内容区域的视图,并返回清理函数。用户在设置的侧边栏页面中选择视图;注册不会激活它。视图属性、基于引用的查询与操作、设备状态和主题令牌见[构建侧边栏视图](sidebar-views.md)。 + ## 界面和侧边栏项 {#screens-and-sidebar-items} 界面是一整页的插件 UI。侧边栏项是 Paseo 在侧边栏顶部或底部渲染的组件;按下它时做什么由它决定。先注册界面,再注册一个打开它的项: @@ -1157,13 +1161,13 @@ export default function contribute(server: PluginServerContext) { } ``` -| 定义字段 | 契约 | -| ------------------------------ | --------------------------------------------------------------------------------------------- | -| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 | -| `scope` | 必须为 `"host"`。该主机所有获得授权的客户端共享这份文档。没有按用户、设备本地或跨主机的同步。 | -| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 | -| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 | -| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 | +| 定义字段 | 契约 | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 | +| `scope` | 必填 `"host"` 或 `"device"`。主机文档由授权客户端共享。设备文档以插件安装和设置 ID 为键保存在当前设备上,用于布局偏好。 | +| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 | +| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 | +| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 | 在任何贡献的组件中调用 `useSettings(preferences)`。它返回一个可区分的状态: diff --git a/public-docs/plugins/sidebar-views.md b/public-docs/plugins/sidebar-views.md new file mode 100644 index 0000000000..542c0f6166 --- /dev/null +++ b/public-docs/plugins/sidebar-views.md @@ -0,0 +1,177 @@ +# Build a sidebar view + +A sidebar view replaces the content between Paseo's header and footer. You receive theme tokens, +active entity refs and navigation methods, then render your own React Native UI. Install the +[sidebar-views example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views) +to try projects, task-based agents, nested agents and label folders. + +## Register a view + +```tsx +import type { + PluginClientContext, + PluginSidebarViewProps, + WorkspaceRef, +} from "@getpaseo/plugin/client"; +import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + navigation, + theme, +}: { + reference: WorkspaceRef; + navigation: PluginSidebarViewProps["navigation"]; + theme: PluginSidebarViewProps["theme"]; +}) { + const workspace = useWorkspace(reference); + if (!workspace) return null; + return ( + navigation.openWorkspace(reference)}> + {workspace.title ?? workspace.name} + + ); +} +function Workspaces({ navigation, theme }: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces }); +} +``` + +Registration adds an option under **Settings > Sidebar > Sidebar view**. The user selects it; +installing the plugin leaves the current view selected. Header navigation, plugin header items, +the callout and footer stay visible. A missing or disabled view falls back to Default view. +A view that throws shows a placeholder with **Use default view**. + +The view renders once across connected hosts. `active.workspace` and `active.agent` are refs or +`null`. Use `navigation.openWorkspace`, `openAgent`, `openTerminal` or `openProject` with a ref; +`openProject` starts the new workspace flow. Navigation closes the compact sidebar. Call +`dismiss()` after navigation you implement yourself. + +## Query refs, read each row + +`useWorkspaces`, `useAgents`, `useProjects` and `useTerminals` return immutable arrays of refs. +A ref has `kind`, `hostId`, `id` and a cross-host `key`. The same entity keeps the same frozen +ref for the app session. Pass it to `useWorkspace`, `useAgent`, `useProject` or `useTerminal` +inside the row. A deleted entity returns `null`. + +```tsx +const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects. +const agents = useAgents({ + hosts: [hostId], + where: (agent) => agent.workspace.key === workspaceRef.key, + orderBy: ["lastActivityAt", "desc"], +}); +const folders = useWorkspaces({ + groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders. +}); +``` + +Every query accepts `hosts` (server IDs), `where`, `orderBy` and `groupBy`. Omit `hosts` to query +every connected host. `orderBy` accepts a snapshot field, `[field, "asc" | "desc"]`, or a +comparator. Null fields sort last. Keep comparators cheap; they run when data changes. +`groupBy` returns a string, `{ key, title?, color? }`, or an array of these. Grouped results have +`key`, `title`, `color` and `items`. Groups follow first appearance after sorting; duplicate keys +use the first title. An empty array puts an item in no group. + +An unchanged ref sequence keeps its array identity. Groups keep their identity when their +metadata and item sequence stay the same. A workspace status change updates that row without +rendering the list again. A grouping change updates the affected groups. Inline query objects +and functions work without managing subscriptions yourself. + +Entity hooks accept a selector and optional equality function: + +```tsx +const workspace = useWorkspace(ref); // Interned snapshot, compared by identity. +const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality. +const selected = useWorkspace(ref, selector, equals); +``` + +Without a selector, custom equality receives snapshots. With a selector, it receives selections. +Existing workspace panels can continue passing a host-local string ID to `useWorkspace` and +`useAgent`. Prefer refs for a view spanning hosts. `useHosts()` retains its existing +`serverId`, `label` and connection `status` fields. + +Workspace snapshots include `project`, `pinnedAt`, `labels`, `background`, `branch`, +`lastPromptAt`, `parentAgent` and `createdAt` (null on older hosts). Use `indicator` and the +localized `indicatorLabel` to display the status Paseo shows. `status` remains the raw daemon +status. Agent snapshots include their workspace and parent refs. Project snapshots provide +`displayName`, `rootPath`, `kind` and `iconRevision`. + +## List terminals + +The terminals example selects a host, lists its terminals, focuses a terminal tab and closes a +terminal through its handle. `useTerminals({ hosts: [hostId] })` requires a host advertising +`terminalCollection`. A terminal reader throws an update-host error for an unsupported queried +host; other entity hooks continue working. Keep host selection outside an error boundary so the +user can select another host. Omit `hosts` to require the capability on every connected host. + +Terminal snapshots contain `workspace`, `title`, `isScript` and `createdAt`. A terminal kept alive +by a worker from before timestamp tracking has `createdAt: null`. Script ownership follows +workspace script runtime entries. The host observation follows terminal creation, closure, title +changes and reconnects, and releases when its last plugin subscriber leaves. + +## Act on a ref + +`usePaseo()` routes ref-based actions to the ref's host: + +```tsx +const paseo = usePaseo(); +await paseo.workspaces.ref(workspaceRef).setPinned(true); +await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true }); +await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false }); +paseo.projects.ref(projectRef).open(); +await paseo.projects.ref(projectRef).createWorkspace(); +paseo.terminals.ref(terminalRef).focus(); +await paseo.terminals.ref(terminalRef).kill(); +``` + +Workspace and agent handles retain their existing methods. Assigning a new label creates it; +assigning an existing label keeps its color. The label-folder example adds the destination +before removing the source, so a failed assignment preserves the original folder. It owns the +move gesture and error display. + +## Keep layout choices on the device + +Define a [settings document](reference.md#settings-screens) with `scope: "device"` for collapsed +folders or manual order. `useSettings` reads and writes it through the same revision-based API +as host settings. Device documents belong to the installation and settings ID, persist across +app launches, and are shared across that installation's client surfaces. They are not synced +to other devices. Use `scope: "host"` for data clients should share through the daemon. + +## Match the app's density + +Render with React Native primitives. The SDK exports no sidebar-view rows, sections, lists, +badges or drag helpers. The existing header/footer item kit is a separate surface. + +For a dense row use a minimum height of 28, horizontal padding of 12, a 12-point primary label +with 16-point line height and an 11-point secondary label. Use these supplied tokens: + +| Row element | Theme token | +| -------------------------------- | ------------------------ | +| Primary label | `colors.foreground` | +| Secondary label, section heading | `colors.foregroundMuted` | +| Selected row fill | `colors.surface2` | +| Separator | `colors.border` | +| Action or focus accent | `colors.accent` | +| Success indicator | `colors.statusSuccess` | +| Needs input indicator | `colors.statusWarning` | +| Failure indicator | `colors.statusDanger` | + +Give pressable rows an accessible label and show controls on touch layouts. The examples use +only plugin-owned components and the tokens passed to the view. diff --git a/public-docs/plugins/sidebar-views.zh-CN.md b/public-docs/plugins/sidebar-views.zh-CN.md new file mode 100644 index 0000000000..0c6ab89d33 --- /dev/null +++ b/public-docs/plugins/sidebar-views.zh-CN.md @@ -0,0 +1,135 @@ +# 构建侧边栏视图 {#build-a-sidebar-view} + +侧边栏视图替换 Paseo 页眉与页脚之间的内容。插件接收主题令牌、当前实体引用和导航方法,使用 React Native 绘制自己的界面。安装[侧边栏视图示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views),体验项目列表、按任务分组的 Agent、嵌套 Agent 和标签文件夹。 + +## 注册视图 {#register-a-view} + +```tsx +import type { + PluginClientContext, + PluginSidebarViewProps, + WorkspaceRef, +} from "@getpaseo/plugin/client"; +import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + navigation, + theme, +}: { + reference: WorkspaceRef; + navigation: PluginSidebarViewProps["navigation"]; + theme: PluginSidebarViewProps["theme"]; +}) { + const workspace = useWorkspace(reference); + if (!workspace) return null; + return ( + navigation.openWorkspace(reference)}> + {workspace.title ?? workspace.name} + + ); +} +function Workspaces({ navigation, theme }: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces }); +} +``` + +注册后,用户可以在**设置 → 侧边栏 → 侧边栏视图**中选择该视图。安装插件不会切换当前视图。页眉导航、插件页眉项、提示区域和页脚保持可见。所选视图缺失或被禁用时,显示默认视图。视图抛出异常时,显示带有**使用默认视图**按钮的占位界面。 + +视图同时展示所有已连接主机的数据。`active.workspace` 和 `active.agent` 是引用或 `null`。将引用传给 `navigation.openWorkspace`、`openAgent`、`openTerminal` 或 `openProject`;`openProject` 打开新建工作区流程。导航会关闭紧凑布局的侧边栏。如果插件自行执行导航,完成后调用 `dismiss()`。 + +## 查询引用,逐行读取 {#query-refs-read-each-row} + +`useWorkspaces`、`useAgents`、`useProjects` 和 `useTerminals` 返回不可变的引用数组。引用包含 `kind`、`hostId`、`id` 和跨主机唯一的 `key`。在一次应用会话中,同一实体使用同一个冻结引用。在行组件内将它传给 `useWorkspace`、`useAgent`、`useProject` 或 `useTerminal`。实体删除后返回 `null`。 + +```tsx +const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects. +const agents = useAgents({ + hosts: [hostId], + where: (agent) => agent.workspace.key === workspaceRef.key, + orderBy: ["lastActivityAt", "desc"], +}); +const folders = useWorkspaces({ + groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders. +}); +``` + +每个查询都支持 `hosts`(服务器 ID)、`where`、`orderBy` 和 `groupBy`。省略 `hosts` 时查询所有已连接主机。`orderBy` 接受快照字段、`[field, "asc" | "desc"]` 或比较函数。空值排在最后。比较函数会在数据变化时执行,应保持开销较小。 + +`groupBy` 返回字符串、`{ key, title?, color? }` 或这些值组成的数组。分组结果包含 `key`、`title`、`color` 和 `items`。分组按排序后首次出现的顺序排列;重复的键使用第一个标题。返回空数组表示该项不属于任何分组。 + +引用序列不变时,数组对象保持不变。分组的元数据和项序列不变时,分组对象保持不变。工作区状态变化只更新对应行,不会重新渲染整个列表。分组变化会更新受影响的分组。查询对象和函数可以内联编写,无需自行管理订阅。 + +实体钩子支持选择器和可选的相等比较函数: + +```tsx +const workspace = useWorkspace(ref); // Interned snapshot, compared by identity. +const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality. +const selected = useWorkspace(ref, selector, equals); +``` + +没有选择器时,自定义相等比较函数接收快照;有选择器时接收选择结果。现有工作区面板仍可向 `useWorkspace` 和 `useAgent` 传入主机本地的字符串 ID。跨主机视图应使用引用。`useHosts()` 保留现有的 `serverId`、`label` 和连接 `status` 字段。 + +工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。 + +## 列出终端 {#list-terminals} + +终端示例支持选择主机、列出终端、聚焦终端标签页,以及通过句柄关闭终端。`useTerminals({ hosts: [hostId] })` 要求主机声明 `terminalCollection` 能力。查询不支持该能力的主机时,终端读取会抛出要求更新主机的错误;其他实体钩子继续工作。将主机选择器放在错误边界之外,方便用户切换主机。省略 `hosts` 时,所有已连接主机都必须支持该能力。 + +终端快照包含 `workspace`、`title`、`isScript` 和 `createdAt`。在时间戳跟踪引入前启动的 worker 所保留的终端,其 `createdAt` 为 null。脚本归属来自工作区脚本运行时条目。主机观察跟踪终端创建、关闭、标题变化和重新连接,并在最后一个终端读取订阅者离开时释放。 + +## 通过引用执行操作 {#act-on-a-ref} + +`usePaseo()` 将基于引用的操作发送到引用所属的主机: + +```tsx +const paseo = usePaseo(); +await paseo.workspaces.ref(workspaceRef).setPinned(true); +await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true }); +await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false }); +paseo.projects.ref(projectRef).open(); +await paseo.projects.ref(projectRef).createWorkspace(); +paseo.terminals.ref(terminalRef).focus(); +await paseo.terminals.ref(terminalRef).kill(); +``` + +工作区和 Agent 句柄保留现有方法。分配新标签会创建该标签;分配已有标签会保留其颜色。标签文件夹示例先添加目标标签,再移除源标签,因此分配失败时仍保留原文件夹。插件负责移动手势和错误显示。 + +## 在设备上保存布局选择 {#keep-layout-choices-on-the-device} + +定义 `scope: "device"` 的[设置文档](reference.md#settings-screens),保存折叠文件夹或手动排序。`useSettings` 通过与主机设置相同的修订版本 API 读取和写入。设备文档属于插件安装和设置 ID,在应用重启后保留,并由该安装的客户端界面共享。它们不会同步到其他设备。需要由客户端通过 daemon 共享的数据使用 `scope: "host"`。 + +## 匹配应用的信息密度 {#match-the-apps-density} + +使用 React Native 原语绘制界面。SDK 不导出侧边栏视图的行、分区、列表、徽标或拖拽辅助组件。现有页眉和页脚组件包属于独立界面。 + +紧凑行的最小高度为 28,水平内边距为 12;主标签字号为 12、行高为 16,次标签字号为 11。使用以下主题令牌: + +| 行元素 | 主题令牌 | +| ---------------- | ------------------------ | +| 主标签 | `colors.foreground` | +| 次标签、分区标题 | `colors.foregroundMuted` | +| 选中行背景 | `colors.surface2` | +| 分隔线 | `colors.border` | +| 操作或焦点强调色 | `colors.accent` | +| 成功指示 | `colors.statusSuccess` | +| 需要输入指示 | `colors.statusWarning` | +| 失败指示 | `colors.statusDanger` | + +为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。