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` | + +为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。