mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof sidebarViews>) => Promise<void>,
|
||||
) {
|
||||
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<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
) {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
<SidebarSeparator />
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarViewSlot dismiss={handleWorkspacePress}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
@@ -838,31 +841,33 @@ function DesktopSidebar({
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarViewSlot dismiss={closeSidebar}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
|
||||
@@ -41,8 +41,11 @@ export function useReplicaQuery<
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input));
|
||||
>(
|
||||
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input), queryClient);
|
||||
}
|
||||
|
||||
export function useFetchQuery<
|
||||
|
||||
@@ -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: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
|
||||
@@ -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: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "选择侧边栏顶部显示的项目及其顺序",
|
||||
|
||||
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -129,6 +129,7 @@ export function runPluginClientBundle(
|
||||
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
@@ -141,6 +142,7 @@ export function runPluginClientBundle(
|
||||
timelineRenderers: [],
|
||||
};
|
||||
const surfaceIds = new Set<string>();
|
||||
const sidebarViewIds = new Set<string>();
|
||||
const settingsScreenIds = new Set<string>();
|
||||
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
|
||||
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,
|
||||
|
||||
@@ -36,6 +36,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -122,6 +122,7 @@ export class PluginRegistry {
|
||||
cleanup: () => undefined,
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -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<string, string>();
|
||||
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"] } });
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
type ReadResult = RpcOutput<ReturnType<typeof settingsRpc>["read"]>;
|
||||
type WriteResult = RpcOutput<ReturnType<typeof settingsRpc>["write"]>;
|
||||
type WriteInput = RpcInput<ReturnType<typeof settingsRpc>["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<unknown> = Promise.resolve();
|
||||
function serial<T>(work: () => Promise<T>): Promise<T> {
|
||||
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<ReadResult> {
|
||||
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<WriteResult> {
|
||||
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: {} }),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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<Schema extends ZodType>(
|
||||
definition: SettingsDefinition<Schema>,
|
||||
installationId: string,
|
||||
): SettingsState<Schema> {
|
||||
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<Schema> } | { 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<Schema> } | { 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<Schema>, revision: string) {
|
||||
try {
|
||||
await mutation.mutateAsync({ values, revision });
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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<string, SidebarViewEntry>();
|
||||
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()];
|
||||
}
|
||||
@@ -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 <div>Default sidebar</div>;
|
||||
}
|
||||
function PluginView() {
|
||||
return <div>Stored plugin sidebar</div>;
|
||||
}
|
||||
function Sidebar({ storage }: { storage: SidebarViewStorage }) {
|
||||
const selection = useSidebarViewSelection(storage);
|
||||
const fallback = useMemo(() => <DefaultView />, []);
|
||||
const selected =
|
||||
selection.state.status === "ready" && selection.state.selection === "example/workspaces";
|
||||
const view = selected ? <PluginView /> : null;
|
||||
return (
|
||||
<SidebarViewFrame state={selection.state} fallback={fallback} reload={selection.reload}>
|
||||
{view}
|
||||
</SidebarViewFrame>
|
||||
);
|
||||
}
|
||||
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<string>((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(
|
||||
<QueryClientProvider client={client}>
|
||||
<Sidebar storage={storage} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
expect(defaultRenders).toBe(0);
|
||||
await act(async () => {
|
||||
finishRead("example/workspaces");
|
||||
});
|
||||
await expect.poll(() => container?.textContent).toContain("Stored plugin sidebar");
|
||||
expect(defaultRenders).toBe(0);
|
||||
});
|
||||
@@ -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 <SidebarAgentListSkeleton />;
|
||||
if (state.status === "error")
|
||||
return (
|
||||
<View style={styles.failure}>
|
||||
<Text style={styles.text}>{t("sidebar.view.readFailed")}</Text>
|
||||
<Button onPress={reload}>{t("common.actions.retry")}</Button>
|
||||
</View>
|
||||
);
|
||||
return children ?? fallback;
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
failure: { padding: theme.spacing[4], gap: theme.spacing[3] },
|
||||
text: { color: theme.colors.foreground },
|
||||
}));
|
||||
@@ -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(
|
||||
() => (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.text}>{t("sidebar.view.failed")}</Text>
|
||||
<Button onPress={restoreDefault} disabled={pending}>
|
||||
{t("sidebar.view.useDefault")}
|
||||
</Button>
|
||||
{error ? <Text style={styles.text}>{error.message}</Text> : null}
|
||||
</View>
|
||||
),
|
||||
[restoreDefault, pending, error, t],
|
||||
);
|
||||
const entry =
|
||||
state.status === "ready"
|
||||
? sidebarViews(installed).find((view) => view.id === state.selection)
|
||||
: undefined;
|
||||
const view = entry ? (
|
||||
<View style={styles.content} testID="plugin-sidebar-view">
|
||||
<SurfaceErrorBoundary
|
||||
installation={entry.plugin}
|
||||
Surface={entry.view.Component}
|
||||
renderError={renderError}
|
||||
>
|
||||
<ThemedSidebarView entry={entry} dismiss={dismiss} uniProps={themeMapping} />
|
||||
</SurfaceErrorBoundary>
|
||||
</View>
|
||||
) : null;
|
||||
return (
|
||||
<SidebarViewFrame state={state} fallback={children} reload={reload}>
|
||||
{view}
|
||||
</SidebarViewFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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<PluginSidebarViewProps["navigation"]>(
|
||||
() => ({
|
||||
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 (
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<Component
|
||||
theme={theme}
|
||||
layout={layout}
|
||||
active={active}
|
||||
navigation={navigation}
|
||||
dismiss={dismiss}
|
||||
/>
|
||||
</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
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";
|
||||
@@ -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 = (
|
||||
<SettingsRow label={t("settings.appearance.sidebar.view.label")}>
|
||||
<Text style={styles.message}>{t("common.loading")}</Text>
|
||||
</SettingsRow>
|
||||
);
|
||||
} else if (state.status === "error") {
|
||||
content = (
|
||||
<SettingsRow
|
||||
label={t("settings.appearance.sidebar.view.label")}
|
||||
hint={t("sidebar.view.readFailed")}
|
||||
>
|
||||
<Button onPress={reload}>{t("common.actions.retry")}</Button>
|
||||
</SettingsRow>
|
||||
);
|
||||
} 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 = (
|
||||
<SettingsSelect
|
||||
label={t("settings.appearance.sidebar.view.label")}
|
||||
value={value}
|
||||
options={options}
|
||||
onValueChange={setSelection}
|
||||
disabled={pending}
|
||||
hint={hint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsSection title={t("settings.appearance.sidebar.view.title")}>
|
||||
<SettingsCard>{content}</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
|
||||
@@ -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<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -24,6 +24,7 @@ function plugin(input: {
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -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<PluginText>[];
|
||||
sidebarItems: Record<PluginSidebarSection, PluginSidebarItemContribution[]>;
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
|
||||
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SidebarViewPicker />
|
||||
<SidebarItemsCard section="header" />
|
||||
<SidebarItemsCard section="footer" />
|
||||
</>
|
||||
|
||||
@@ -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<PluginWorkspaceSnapshot> = { hosts: [workspace.hostId], orderBy: ["name", "desc"] };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<Text extends PluginText = string>
|
||||
Component: ComponentType<PluginSidebarItemProps>;
|
||||
}
|
||||
|
||||
export interface PluginSidebarViewProps extends Pick<PluginHostProps, "theme" | "layout"> {
|
||||
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<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: Text;
|
||||
description?: Text;
|
||||
Component: ComponentType<PluginSidebarViewProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
addSettingsScreen(contribution: PluginSettingsScreenContribution<PluginText>): PluginCleanup;
|
||||
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
|
||||
addSidebarView(contribution: PluginSidebarViewContribution<PluginText>): PluginCleanup;
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
|
||||
|
||||
@@ -15,6 +15,8 @@ export type {
|
||||
PluginOpenScreenInput,
|
||||
PluginScreenLocation,
|
||||
PluginSidebarItemProps,
|
||||
PluginSidebarViewProps,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
|
||||
@@ -4,10 +4,10 @@ import { defineRpc } from "./rpc.js";
|
||||
|
||||
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
|
||||
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<unknown>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export function defineSettings<Schema extends ZodType>(
|
||||
definition: SettingsDefinition<Schema>,
|
||||
): SettingsDefinition<Schema> {
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export class PluginSettingsStore {
|
||||
|
||||
register<Schema extends ZodType>(definition: SettingsDefinition<Schema>) {
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${workspace.name}`}
|
||||
onPress={open}
|
||||
style={rowStyle}
|
||||
>
|
||||
<Text style={textStyle}>{workspace.title ?? workspace.name}</Text>
|
||||
<Text style={labelStyle}>{workspace.indicatorLabel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
export function Workspaces(view: PluginSidebarViewProps) {
|
||||
const workspaces = useWorkspaces({ orderBy: "name" });
|
||||
return (
|
||||
<ScrollView>
|
||||
{workspaces.map((reference) => (
|
||||
<WorkspaceRow key={reference.key} reference={reference} view={view} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const rowStyle = { minHeight: 28, paddingHorizontal: 12, justifyContent: "center" } as const;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "sidebar-views",
|
||||
"requirements": { "paseo": ">=0.16.0" }
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user