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` |