From dc309586f0698687b167de30dd180dc9cf092d5b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 04:02:35 +0100 Subject: [PATCH] feat(plugin): let users select a sidebar view An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery. --- .../app/e2e/browser/sidebar-views.spec.ts | 16 ++ .../app/e2e/support/helpers/sidebar-views.ts | 99 ++++++++++ packages/app/src/components/left-sidebar.tsx | 105 ++++++----- packages/app/src/data/query.ts | 7 +- packages/app/src/i18n/resources/ar.ts | 14 ++ packages/app/src/i18n/resources/en.ts | 14 ++ packages/app/src/i18n/resources/es.ts | 15 ++ packages/app/src/i18n/resources/fr.ts | 15 ++ packages/app/src/i18n/resources/ja.ts | 15 ++ packages/app/src/i18n/resources/ko.ts | 15 ++ packages/app/src/i18n/resources/pt-BR.ts | 15 ++ packages/app/src/i18n/resources/ru.ts | 14 ++ packages/app/src/i18n/resources/zh-CN.ts | 14 ++ .../app/src/plugins/buttons/model.test.ts | 1 + .../command-center/contributions.test.ts | 1 + packages/app/src/plugins/evaluate.test.ts | 23 +++ packages/app/src/plugins/evaluate.ts | 18 +- .../plugins/installation-provider.test.tsx | 1 + packages/app/src/plugins/registry.ts | 1 + .../app/src/plugins/settings/device.test.ts | 65 +++++++ packages/app/src/plugins/settings/device.ts | 73 ++++++++ .../app/src/plugins/settings/use-settings.ts | 64 ++++--- .../app/src/plugins/sidebar-groups.test.ts | 1 + .../app/src/plugins/sidebar-view/catalog.ts | 25 +++ .../sidebar-view/frame.browser.test.tsx | 69 +++++++ .../app/src/plugins/sidebar-view/frame.tsx | 34 ++++ .../app/src/plugins/sidebar-view/index.tsx | 173 ++++++++++++++++++ .../app/src/plugins/sidebar-view/picker.tsx | 58 ++++++ .../app/src/plugins/sidebar-view/selection.ts | 47 +++++ .../src/plugins/surface-contribution.test.ts | 1 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + packages/app/src/plugins/types.ts | 2 + .../workspace-panels/resolution.test.ts | 1 + .../settings/sidebar/sidebar-nav-section.tsx | 2 + packages/plugin/src/boundaries.test.ts | 11 +- packages/plugin/src/client/contracts.ts | 20 ++ packages/plugin/src/client/index.ts | 2 + packages/plugin/src/settings.ts | 7 +- .../src/server/plugins/settings/index.test.ts | 8 + .../src/server/plugins/settings/index.ts | 1 + .../sidebar-views/client/workspaces.tsx | 54 ++++++ .../sidebar-views/index.client.tsx | 10 + .../sidebar-views/paseo-plugin.json | 4 + public-docs/plugins/migration.md | 1 + 45 files changed, 1059 insertions(+), 79 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-views.spec.ts create mode 100644 packages/app/e2e/support/helpers/sidebar-views.ts create mode 100644 packages/app/src/plugins/settings/device.test.ts create mode 100644 packages/app/src/plugins/settings/device.ts create mode 100644 packages/app/src/plugins/sidebar-view/catalog.ts create mode 100644 packages/app/src/plugins/sidebar-view/frame.browser.test.tsx create mode 100644 packages/app/src/plugins/sidebar-view/frame.tsx create mode 100644 packages/app/src/plugins/sidebar-view/index.tsx create mode 100644 packages/app/src/plugins/sidebar-view/picker.tsx create mode 100644 packages/app/src/plugins/sidebar-view/selection.ts create mode 100644 plugin-examples/sidebar-views/client/workspaces.tsx create mode 100644 plugin-examples/sidebar-views/index.client.tsx create mode 100644 plugin-examples/sidebar-views/paseo-plugin.json 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..85d0365cff --- /dev/null +++ b/packages/app/e2e/browser/sidebar-views.spec.ts @@ -0,0 +1,16 @@ +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.restoreDefault(); + await views.recoverFromFailure(); + await views.chooseWorkspaces(); + await views.disableChosenView(); + }); +}); 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..08c946a9c5 --- /dev/null +++ b/packages/app/e2e/support/helpers/sidebar-views.ts @@ -0,0 +1,99 @@ +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( + "return client.addSidebarView", + 'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n return client.addSidebarView', + ), + ); + 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 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/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 f86d81b6e9..dd2a63f7a4 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1161,6 +1161,14 @@ export const ar: TranslationResources = { }, }, sidebar: { + view: { + failed: "تعذر تحميل عرض الشريط الجانبي.", + useDefault: "استخدام العرض الافتراضي", + readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.", + agentUnavailable: "لم يعد الوكيل متاحًا", + terminalUnavailable: "لم تعد الطرفية متاحة", + projectUnavailable: "لم يعد المشروع متاحًا", + }, statusGroupAccessibility: "مجموعة {{label}}", statusBucket: { needsInput: "تحتاج إدخالاً", @@ -2581,6 +2589,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 103a981abc..3a3cf88fce 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1169,6 +1169,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", @@ -2672,6 +2680,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 48737ee20e..d5eb04fec0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1197,6 +1197,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", @@ -2601,6 +2609,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 0d2e4aabce..5e92f870ec 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1190,6 +1190,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", @@ -2602,6 +2610,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 ca93aa3106..fe88b244d4 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1174,6 +1174,14 @@ export const ja: TranslationResources = { }, }, sidebar: { + view: { + failed: "サイドバーのビューを読み込めませんでした。", + useDefault: "デフォルトのビューを使用", + readFailed: "サイドバーのビュー設定を読み込めませんでした。", + agentUnavailable: "このエージェントは利用できなくなりました", + terminalUnavailable: "このターミナルは利用できなくなりました", + projectUnavailable: "このプロジェクトは利用できなくなりました", + }, statusGroupAccessibility: "{{label}} グループ", statusBucket: { needsInput: "入力待ち", @@ -2568,6 +2576,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 51ea225983..0508428dbf 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1168,6 +1168,14 @@ export const ko: TranslationResources = { }, }, sidebar: { + view: { + failed: "사이드바 보기를 불러오지 못했습니다.", + useDefault: "기본 보기 사용", + readFailed: "사이드바 보기 설정을 불러오지 못했습니다.", + agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다", + terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다", + projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다", + }, statusGroupAccessibility: "{{label}} 그룹", statusBucket: { needsInput: "입력 필요", @@ -2559,6 +2567,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 5c2695f3bf..8739a59c1a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1187,6 +1187,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", @@ -2583,6 +2591,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 ea8fb1eb25..7cd203bea0 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1178,6 +1178,14 @@ export const ru: TranslationResources = { }, }, sidebar: { + view: { + failed: "Не удалось загрузить вид боковой панели.", + useDefault: "Использовать стандартный вид", + readFailed: "Не удалось загрузить настройку вида боковой панели.", + agentUnavailable: "Агент больше недоступен", + terminalUnavailable: "Терминал больше недоступен", + projectUnavailable: "Проект больше недоступен", + }, statusGroupAccessibility: "Группа «{{label}}»", statusBucket: { needsInput: "Ожидает ввода", @@ -2589,6 +2597,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 54454d9bff..498a710e46 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1153,6 +1153,14 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + view: { + failed: "无法加载侧边栏视图。", + useDefault: "使用默认视图", + readFailed: "无法加载侧边栏视图偏好设置。", + agentUnavailable: "此智能体已不可用", + terminalUnavailable: "此终端已不可用", + projectUnavailable: "此项目已不可用", + }, statusGroupAccessibility: "{{label}} 分组", statusBucket: { needsInput: "需要输入", @@ -2518,6 +2526,12 @@ export const zhCN: TranslationResources = { description: "显示用于在提示词之间跳转的大纲", }, sidebar: { + view: { + title: "侧边栏视图", + label: "视图", + default: "默认视图", + unavailable: "所选视图不可用或已禁用。正在显示默认视图。", + }, header: { title: "顶部", description: "选择侧边栏顶部显示的项目及其顺序", 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/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index b2fd0891c9..37a71ff255 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -99,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { cleanup: () => {}, settingsScreens: [], surfaces: [{ id: "main", title: "Main", Component: () => null }], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], 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..36fa2a5c0c 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -36,6 +36,7 @@ function installation(invoked: string[]): InstalledPlugin { cleanup: () => undefined, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], 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..b3d9f14b99 --- /dev/null +++ b/packages/app/src/plugins/settings/device.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { createDeviceSettings } from "./device"; + +function device() { + const 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"] } }); + }); +}); diff --git a/packages/app/src/plugins/settings/device.ts b/packages/app/src/plugins/settings/device.ts new file mode 100644 index 0000000000..7c348c302f --- /dev/null +++ b/packages/app/src/plugins/settings/device.ts @@ -0,0 +1,73 @@ +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); + const revision = raw ?? "missing"; + try { + let values: unknown = {}; + 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); + } + } + const parsed = await definition.schema.parseAsync(values); + return { status: "ready", revision, values: z.json().parse(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/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/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/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 263a791dd6..7496511bc9 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -393,7 +393,7 @@ describe("sidebar data SDK boundary", () => { it("accepts references and preserves string-id panel consumers", () => { expect( typeErrors(` - import { useWorkspace, useAgent, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client"; + import { useWorkspace, useAgent, 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); @@ -407,6 +407,15 @@ describe("sidebar data SDK boundary", () => { const terminals: readonly TerminalRef[] = useTerminals(); 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; } diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 76c163af53..d6fd17ebcb 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,3 +1,4 @@ +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"; @@ -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; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 9d7c52ddcc..8d6ea59084 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, 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/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/plugin-examples/sidebar-views/client/workspaces.tsx b/plugin-examples/sidebar-views/client/workspaces.tsx new file mode 100644 index 0000000000..a6f2d6d4d0 --- /dev/null +++ b/plugin-examples/sidebar-views/client/workspaces.tsx @@ -0,0 +1,54 @@ +import { useCallback, useMemo } from "react"; +import { + useWorkspace, + useWorkspaces, + type WorkspaceRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + view, +}: { + reference: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const workspace = useWorkspace(reference); + const open = useCallback( + () => view.navigation.openWorkspace(reference), + [view.navigation, reference], + ); + const textStyle = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, lineHeight: 16 }), + [view.theme], + ); + const labelStyle = useMemo( + () => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }), + [view.theme], + ); + if (!workspace) return null; + return ( + + {workspace.title ?? workspace.name} + {workspace.indicatorLabel} + + ); +} +export function Workspaces(view: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} + +const rowStyle = { minHeight: 28, paddingHorizontal: 12, justifyContent: "center" } as const; diff --git a/plugin-examples/sidebar-views/index.client.tsx b/plugin-examples/sidebar-views/index.client.tsx new file mode 100644 index 0000000000..2a820ca0c1 --- /dev/null +++ b/plugin-examples/sidebar-views/index.client.tsx @@ -0,0 +1,10 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { Workspaces } from "./client/workspaces"; + +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ + id: "workspaces", + title: "Example workspaces", + Component: Workspaces, + }); +} 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/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` |