Let plugins provide custom sidebar views across hosts (#8906)

* feat(plugin): expose stable sidebar entity data across hosts

Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.

* feat(plugin): let users select a sidebar view

An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.

* feat(plugin): add sidebar view examples and ref actions

Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.

* feat(plugin): observe terminals across connected hosts

Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
This commit is contained in:
Mohamed Boudra
2026-11-07 18:05:31 +07:00
committed by GitHub
parent aac228e0a8
commit a449271d57
106 changed files with 4385 additions and 269 deletions
+7
View File
@@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Replace sidebar content
The sidebar view slot is global across hosts, while header and footer contributions retain their
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
its layout. This keeps plugin failures from removing navigation or recovery controls. The
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
@@ -0,0 +1,18 @@
import { test } from "../support/fixtures";
import { withSidebarViews } from "../support/helpers/sidebar-views";
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
page,
}) => {
test.setTimeout(150_000);
await withSidebarViews(page, async (views) => {
await views.expectDefault();
await views.chooseWorkspaces();
await views.exerciseExamples();
await views.exerciseTerminals();
await views.restoreDefault();
await views.recoverFromFailure();
await views.chooseWorkspaces();
await views.disableChosenView();
});
});
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -0,0 +1,191 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { gotoWorkspace } from "./launcher";
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
import { visibleTestId } from "./plugin-sidebar-items";
import { getServerId } from "./server-id";
export async function withSidebarViews(
page: Page,
run: (views: ReturnType<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(
"const cleanups = [",
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
),
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-views-",
title: "Sidebar view workspace",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(sidebarViews(page, workspace.workspaceId, client));
} finally {
await client.removePlugin("sidebar-views");
await client.close();
await workspace.cleanup();
await example.cleanup();
}
}
function sidebarViews(
page: Page,
workspaceId: string,
client: Awaited<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 exerciseExamples() {
await choose("Example agents");
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
).toBeVisible();
await choose("Example nested agents");
await expectPlugin();
await choose("Example label folders");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toContain("Inbox");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Later"]);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
assigned: false,
});
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Unlabelled"]);
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Inbox"]);
},
async exerciseTerminals() {
await choose("Example terminals");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
.click();
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
const workspace = (await client.fetchWorkspaces()).entries.find(
(item) => item.id === workspaceId,
)!;
const created = await client.createTerminal(
workspace.workspaceDirectory,
"Sidebar terminal",
undefined,
{ workspaceId },
);
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
try {
await expect(
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
).toBeVisible();
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
await expect
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
.not.toContain(created.terminal!.id);
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
} finally {
await client.killTerminal(created.terminal.id);
}
},
async restoreDefault() {
await choose("Default view");
await expectDefault();
},
async recoverFromFailure() {
await choose("Broken example");
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use default view", exact: true }).click();
await expectDefault();
},
async disableChosenView() {
await client.disablePlugin("sidebar-views");
await expectDefault();
await openSidebarNavSettings(page);
await expect(
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
exact: true,
}),
).toBeVisible();
await leaveSettings(page);
},
};
}
+55 -50
View File
@@ -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 />
+4 -4
View File
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
+5 -2
View File
@@ -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<
+14
View File
@@ -1179,6 +1179,14 @@ export const ar: TranslationResources = {
},
},
sidebar: {
view: {
failed: "تعذر تحميل عرض الشريط الجانبي.",
useDefault: "استخدام العرض الافتراضي",
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
agentUnavailable: "لم يعد الوكيل متاحًا",
terminalUnavailable: "لم تعد الطرفية متاحة",
projectUnavailable: "لم يعد المشروع متاحًا",
},
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
@@ -2607,6 +2615,12 @@ export const ar: TranslationResources = {
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
view: {
title: "عرض الشريط الجانبي",
label: "العرض",
default: "العرض الافتراضي",
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
},
header: {
title: "الرأس",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
+14
View File
@@ -1187,6 +1187,14 @@ export const en = {
},
},
sidebar: {
view: {
failed: "Sidebar view failed to load.",
useDefault: "Use default view",
readFailed: "Could not load your sidebar view preference.",
agentUnavailable: "Agent is no longer available",
terminalUnavailable: "Terminal is no longer available",
projectUnavailable: "Project is no longer available",
},
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
@@ -2698,6 +2706,12 @@ export const en = {
description: "Show an outline for jumping between prompts",
},
sidebar: {
view: {
title: "Sidebar view",
label: "View",
default: "Default view",
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
},
header: {
title: "Header",
description: "Choose which items appear at the top of the sidebar and in what order",
+15
View File
@@ -1215,6 +1215,14 @@ export const es: TranslationResources = {
},
},
sidebar: {
view: {
failed: "No se pudo cargar la vista de la barra lateral.",
useDefault: "Usar vista predeterminada",
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
agentUnavailable: "El agente ya no está disponible",
terminalUnavailable: "La terminal ya no está disponible",
projectUnavailable: "El proyecto ya no está disponible",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
@@ -2627,6 +2635,13 @@ export const es: TranslationResources = {
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
view: {
title: "Vista de la barra lateral",
label: "Vista",
default: "Vista predeterminada",
unavailable:
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
},
header: {
title: "Encabezado",
description:
+15
View File
@@ -1208,6 +1208,14 @@ export const fr: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Impossible de charger la vue de la barre latérale.",
useDefault: "Utiliser la vue par défaut",
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
agentUnavailable: "Cet agent n’est plus disponible",
terminalUnavailable: "Ce terminal n’est plus disponible",
projectUnavailable: "Ce projet n’est plus disponible",
},
statusGroupAccessibility: "Groupe {{label}}",
statusBucket: {
needsInput: "Attend une réponse",
@@ -2628,6 +2636,13 @@ export const fr: TranslationResources = {
description: "Afficher un plan pour naviguer entre les prompts",
},
sidebar: {
view: {
title: "Vue de la barre latérale",
label: "Vue",
default: "Vue par défaut",
unavailable:
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
},
header: {
title: "En-tête",
description:
+15
View File
@@ -1192,6 +1192,14 @@ export const ja: TranslationResources = {
},
},
sidebar: {
view: {
failed: "サイドバーのビューを読み込めませんでした。",
useDefault: "デフォルトのビューを使用",
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
agentUnavailable: "このエージェントは利用できなくなりました",
terminalUnavailable: "このターミナルは利用できなくなりました",
projectUnavailable: "このプロジェクトは利用できなくなりました",
},
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
@@ -2594,6 +2602,13 @@ export const ja: TranslationResources = {
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
view: {
title: "サイドバーのビュー",
label: "ビュー",
default: "デフォルトのビュー",
unavailable:
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
},
header: {
title: "ヘッダー",
description: "サイドバー上部に表示する項目とその順序を選択します",
+15
View File
@@ -1186,6 +1186,14 @@ export const ko: TranslationResources = {
},
},
sidebar: {
view: {
failed: "사이드바 보기를 불러오지 못했습니다.",
useDefault: "기본 보기 사용",
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
},
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
@@ -2585,6 +2593,13 @@ export const ko: TranslationResources = {
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
view: {
title: "사이드바 보기",
label: "보기",
default: "기본 보기",
unavailable:
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
},
header: {
title: "헤더",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
+15
View File
@@ -1205,6 +1205,14 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Não foi possível carregar a visualização da barra lateral.",
useDefault: "Usar visualização padrão",
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
agentUnavailable: "O agente não está mais disponível",
terminalUnavailable: "O terminal não está mais disponível",
projectUnavailable: "O projeto não está mais disponível",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
@@ -2609,6 +2617,13 @@ export const ptBR: TranslationResources = {
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
view: {
title: "Visualização da barra lateral",
label: "Visualização",
default: "Visualização padrão",
unavailable:
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
},
header: {
title: "Cabeçalho",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
+14
View File
@@ -1196,6 +1196,14 @@ export const ru: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Не удалось загрузить вид боковой панели.",
useDefault: "Использовать стандартный вид",
readFailed: "Не удалось загрузить настройку вида боковой панели.",
agentUnavailable: "Агент больше недоступен",
terminalUnavailable: "Терминал больше недоступен",
projectUnavailable: "Проект больше недоступен",
},
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
@@ -2615,6 +2623,12 @@ export const ru: TranslationResources = {
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
view: {
title: "Вид боковой панели",
label: "Вид",
default: "Стандартный вид",
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
},
header: {
title: "Верх",
description:
+14
View File
@@ -1171,6 +1171,14 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
view: {
failed: "无法加载侧边栏视图。",
useDefault: "使用默认视图",
readFailed: "无法加载侧边栏视图偏好设置。",
agentUnavailable: "此智能体已不可用",
terminalUnavailable: "此终端已不可用",
projectUnavailable: "此项目已不可用",
},
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
@@ -2544,6 +2552,12 @@ export const zhCN: TranslationResources = {
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
view: {
title: "侧边栏视图",
label: "视图",
default: "默认视图",
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
},
header: {
title: "顶部",
description: "选择侧边栏顶部显示的项目及其顺序",
+2 -1
View File
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -32,7 +33,7 @@ export function createPluginCapabilities(
navigation.openSurface(plugin.id, screenId, params);
}
return {
paseo: plugin.paseo,
paseo: getPluginClientApi(plugin).paseo,
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -139,7 +140,7 @@ function harness() {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
+2 -3
View File
@@ -1,6 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import { getPluginClientApi } from "./client-state/api";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
hosts: getPluginClientApi(installation).hosts,
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -0,0 +1,68 @@
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
import { router } from "expo-router";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { createPluginHosts } from "../hosts";
import type { InstalledPlugin } from "../types";
import { createPluginClientStateSource } from "./source";
interface InstallationApi {
paseo: ReturnType<typeof createPluginReferenceApi>;
hosts: ReturnType<typeof createPluginHosts>;
}
// Registry presentation clones share the installation lifetime and its client pool.
const apis = new WeakMap<AbortSignal, InstallationApi>();
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
const cached = apis.get(plugin.lifetime.signal);
if (cached) return cached;
const state = createPluginClientStateSource();
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
const paseo = createPluginReferenceApi({
paseo: plugin.paseo,
resolveHost: hosts.getPaseoClient,
readProject: state.getProject,
navigation: {
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
},
openAgent(ref) {
const agent = state.getAgent(ref);
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
},
openTerminal(ref) {
const terminal = state.getTerminal(ref);
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
},
openProject(ref) {
const project = state.getProject(ref);
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
},
},
});
const api = { paseo, hosts };
apis.set(plugin.lifetime.signal, api);
return api;
}
@@ -0,0 +1,72 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { PluginConnectedHostsSource } from "./source";
interface PluginHostsRuntime {
getHosts(): readonly { serverId: string }[];
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
acquireDirectoryDemand(serverId: string): () => void;
}
export function createConnectedPluginHosts(
getRuntime: () => PluginHostsRuntime,
): PluginConnectedHostsSource {
let snapshot: readonly string[] = [];
const listeners = new Set<() => void>();
let release: (() => void) | null = null;
const source: PluginConnectedHostsSource = {
getSnapshot() {
const runtime = getRuntime();
const next = runtime
.getHosts()
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
.map((host) => host.serverId);
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
snapshot = Object.freeze(next);
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
if (!release) {
const runtime = getRuntime();
const demands = new Map<string, () => void>();
const reconcile = () => {
const ids = new Set(source.getSnapshot());
for (const [id, releaseDemand] of demands) {
if (!ids.has(id)) {
demands.delete(id);
releaseDemand();
}
}
for (const id of ids) {
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
}
};
const notify = () => {
reconcile();
for (const subscriber of listeners) subscriber();
};
const releaseRuntime = runtime.subscribeAll(notify);
const releaseHosts = runtime.subscribeHostList(notify);
reconcile();
release = () => {
releaseRuntime();
releaseHosts();
for (const releaseDemand of demands.values()) releaseDemand();
demands.clear();
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && release) {
release();
release = null;
}
};
},
};
return source;
}
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
@@ -0,0 +1,53 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
export const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
export const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
@@ -0,0 +1,3 @@
import { createEntityRefs } from "@getpaseo/plugin/client/host";
export const entityRefs = createEntityRefs();
@@ -1,66 +1,20 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
createPluginProjectSnapshot,
} from "./snapshots";
import { workspace, agent } from "./fixtures";
describe("plugin context snapshots", () => {
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
const snapshot = createPluginWorkspaceSnapshot(workspace);
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -90,7 +44,7 @@ describe("plugin context snapshots", () => {
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "agent-1",
workspaceId: "workspace-1",
provider: "codex",
@@ -119,3 +73,82 @@ describe("plugin context snapshots", () => {
expect(agent.labels).toEqual({ phase: "implementation" });
});
});
describe("sidebar entity snapshots", () => {
it("interns cross-host references and preserves identity across unrelated reads", () => {
const first = createPluginWorkspaceSnapshot(workspace, "host-1");
const second = createPluginWorkspaceSnapshot(workspace, "host-2");
expect(first.ref).not.toBe(second.ref);
expect(first.ref.hostId).toBe("host-1");
expect(first.project.id).toBe(workspace.projectId);
expect(Object.isFrozen(first.labels)).toBe(true);
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first);
expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref);
});
it("invalidates a done workspace when its effective root-agent status changes", () => {
const idle: WorkspaceDescriptor = { ...workspace, status: "done" };
const before = createPluginWorkspaceSnapshot(idle, "host-1");
const activity = new Map([
[idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }],
]);
const after = createPluginWorkspaceSnapshot(idle, "host-1", {
workspaceAgentActivity: activity,
});
expect(before.indicator).toBe("done");
expect(after.status).toBe("done");
expect(after.indicator).toBe("needs_input");
expect(after.indicatorLabel).toBe("Needs input");
expect(after.ref).toBe(before.ref);
expect(
createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }),
).toBe(after);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git" as const,
};
const snapshot = createPluginProjectSnapshot(project, "host-1");
expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot);
expect(snapshot.displayName).toBe("Empty");
expect(snapshot.ref.id).toBe("empty");
expect(Object.isFrozen(snapshot)).toBe(true);
});
});
it("uses the app status labels and invalidates a snapshot on language change", async () => {
i18n.addResourceBundle("fr", "translation", fr);
const previousLanguage = i18n.language;
try {
await i18n.changeLanguage("en");
const english = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(english.indicatorLabel).toBe("Working");
await i18n.changeLanguage("fr");
const french = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(french).not.toBe(english);
expect(french.ref).toBe(english.ref);
expect(french.indicatorLabel).toBe("En cours");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french);
} finally {
await i18n.changeLanguage(previousLanguage);
}
});
it("preserves the host-qualified parent of a remotely delegated agent", () => {
const child = {
...agent,
serverId: "host-2",
parentAgentId: null,
labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" },
};
expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({
hostId: "host-1",
id: "parent",
kind: "agent",
});
});
@@ -1,16 +1,57 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { i18n } from "@/i18n/i18next";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
} from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
import { entityRefs } from "./refs";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
interface CachedWorkspace {
language: string;
snapshot: PluginWorkspaceSnapshot;
}
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
export function createPluginWorkspaceSnapshot(
workspace: WorkspaceDescriptor,
hostId: string,
context: Omit<Parameters<typeof deriveEffectiveWorkspaceStatus>[0], "workspace" | "serverId"> & {
parentAgent?: PluginWorkspaceSnapshot["parentAgent"];
} = {},
): PluginWorkspaceSnapshot {
const cached = workspaceSnapshots.get(workspace);
if (cached) return cached;
const indicator = deriveEffectiveWorkspaceStatus({
...context,
workspace,
serverId: hostId,
}).status;
const parentAgent = context.parentAgent ?? null;
const cached = workspaceSnapshots.get(workspace)?.get(hostId);
if (
cached &&
cached.language === i18n.language &&
cached.snapshot.indicator === indicator &&
cached.snapshot.parentAgent === parentAgent
)
return cached.snapshot;
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
const snapshot = Object.freeze({
ref: entityRefs.workspace(hostId, workspace.id),
hostId,
project: entityRefs.project(hostId, workspace.projectId),
pinnedAt: workspace.pinnedAt ?? null,
labels: Object.freeze([...(workspace.labels ?? [])]),
background: workspace.background ?? false,
branch: workspace.gitRuntime?.currentBranch ?? null,
createdAt: serializeTimestamp(workspace.createdAt),
lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt),
parentAgent,
indicator,
indicatorLabel: i18n.t(indicatorLabelKeys[indicator]),
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
@@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot(
name: workspace.name,
title: workspace.title ?? null,
status: workspace.status,
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
archivingAt: workspace.archivingAt,
diffStat,
});
workspaceSnapshots.set(workspace, snapshot);
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
byHost.set(hostId, { language: i18n.language, snapshot });
workspaceSnapshots.set(workspace, byHost);
return snapshot;
}
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
const cached = agentSnapshots.get(agent)?.get(workspaceId);
if (cached) return cached;
const parent = getParentAddressFromLabels(agent.labels, agent.serverId);
const snapshot = Object.freeze({
ref: entityRefs.agent(agent.serverId, agent.id),
hostId: agent.serverId,
workspace: entityRefs.workspace(agent.serverId, workspaceId),
parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null,
id: agent.id,
workspaceId,
provider: agent.provider,
@@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
agentSnapshots.set(agent, byWorkspace);
return snapshot;
}
const indicatorLabelKeys = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
running: "sidebar.statusBucket.working",
attention: "sidebar.statusBucket.readyToReview",
done: "sidebar.statusBucket.done",
} as const;
const projectSnapshots = new WeakMap<ProjectDescriptor, Map<string, PluginProjectSnapshot>>();
export function createPluginProjectSnapshot(
project: ProjectDescriptor,
hostId: string,
): PluginProjectSnapshot {
const cached = projectSnapshots.get(project)?.get(hostId);
if (cached) return cached;
const snapshot = Object.freeze({
ref: entityRefs.project(hostId, project.projectId),
hostId,
displayName: project.projectCustomName ?? project.projectDisplayName,
rootPath: project.projectRootPath,
kind: project.projectKind,
iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null,
});
const byHost = projectSnapshots.get(project) ?? new Map<string, PluginProjectSnapshot>();
byHost.set(hostId, snapshot);
projectSnapshots.set(project, byHost);
return snapshot;
}
function serializeTimestamp(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
@@ -0,0 +1,55 @@
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createPluginClientStateSource } from "./source";
import { workspace } from "./fixtures";
setFlagsFromString("--expose_gc");
const collectGarbage: () => void = runInNewContext("gc");
setFlagsFromString("--no-expose_gc");
function observeHost() {
const hostId = "removed-plugin-host";
let hosts = [hostId];
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]]));
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => hosts, subscribe: () => () => {} },
});
const release = source.subscribe(() => source.getSnapshot());
source.getSnapshot();
const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces);
const remove = () => {
hosts = [];
store.clearSession(hostId);
};
return { reference, release, remove };
}
it("releases removed hosts' projected data while other plugin readers remain", async () => {
const { reference, release, remove } = observeHost();
remove();
try {
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
} finally {
release();
}
});
it("releases previous projections when the last plugin reader leaves", async () => {
const { reference, release, remove } = observeHost();
release();
remove();
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
});
@@ -0,0 +1,420 @@
import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "../theme";
import { createConnectedPluginHosts } from "./connected-hosts";
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import {
useAgents,
useWorkspaces,
useWorkspace,
useAgent,
type WorkspaceRef,
type AgentRef,
} from "@getpaseo/plugin/client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source";
import { agent, workspace } from "./fixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
const renders = new Map<string, number>();
function count(key: string) {
renders.set(key, (renders.get(key) ?? 0) + 1);
}
function WorkspaceRow({ reference }: { reference: WorkspaceRef }) {
const item = useWorkspace(reference);
count(reference.key);
return (
<div>
{item?.name}:{item?.indicator}
</div>
);
}
function Workspaces() {
count("workspaces");
const items = useWorkspaces({ orderBy: "name" });
return (
<div>
{items.map((reference) => (
<WorkspaceRow key={reference.key} reference={reference} />
))}
</div>
);
}
function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) {
const name = useWorkspace(reference, (item) => ({ name: item.name }));
const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name);
count("selected");
return (
<div>
{name?.name}:{custom?.name}
</div>
);
}
function AgentRow({ reference }: { reference: AgentRef }) {
const item = useAgent(reference);
return <div>{item?.title}</div>;
}
function AgentSection({ section }: { section: string }) {
const agents = useAgents({
where: (item) => (item.requiresAttention ? "waiting" : item.status) === section,
});
count(section);
return (
<div>
{agents.map((reference) => (
<AgentRow key={reference.key} reference={reference} />
))}
</div>
);
}
function Agents() {
return (
<div>
<AgentSection section="running" />
<AgentSection section="waiting" />
<AgentSection section="idle" />
</div>
);
}
function seedHost(hostId: string) {
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(
hostId,
new Map([
["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }],
["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }],
]),
);
store.setProjects(hostId, [
{
projectId: "empty",
projectDisplayName: "Empty project",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git",
},
]);
store.setAgents(
hostId,
new Map<string, Agent>([
[
"agent-1",
{
...agent,
serverId: hostId,
id: "agent-1",
workspaceId: "workspace-1",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
[
"agent-2",
{
...agent,
serverId: hostId,
id: "agent-2",
workspaceId: "workspace-2",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
]),
);
}
it("keeps archived agents readable individually while excluding them from collections", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
const reference = source.getSnapshot().agents[0]!.ref;
useSessionStore.getState().setAgents(
"host-1",
new Map([
[
agent.id,
{
...agent,
archivedAt: new Date("2026-11-01T00:00:00.000Z"),
},
],
]),
);
expect(source.getSnapshot().agents).toEqual([]);
expect(source.getAgent(reference)?.title).toBe(agent.title);
expect(source.getAgent(agent.id)).toBe(source.getAgent(reference));
});
it("demands directories for connected hosts and releases them with its last reader", () => {
const hostId = "plugin-demand-host";
let online = true;
let demands = 0;
const listeners = new Set<() => void>();
const hosts = createConnectedPluginHosts(() => ({
getHosts: () => [{ serverId: hostId }],
getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }),
subscribeAll: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
subscribeHostList: () => () => {},
acquireDirectoryDemand: () => {
demands++;
seedHost(hostId);
return () => {
demands--;
};
},
}));
const source = createPluginClientStateSource(undefined, { hosts });
const first = source.subscribe(() => {});
const second = source.subscribe(() => {});
expect(demands).toBe(1);
expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]);
useSessionStore
.getState()
.setWorkspaces(
hostId,
(previous) =>
new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])),
);
expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([
"Live update",
"Live update",
]);
online = false;
for (const listener of listeners) listener();
expect(demands).toBe(0);
expect(source.getSnapshot().workspaces).toEqual([]);
online = true;
for (const listener of listeners) listener();
expect(demands).toBe(1);
first();
expect(demands).toBe(1);
second();
expect(demands).toBe(0);
expect(listeners.size).toBe(0);
});
function waitForInput() {
useSessionStore.getState().setAgents("host-1", (previous) => {
const current = previous.get("agent-1");
if (!current) throw new Error("Missing test agent");
const next = new Map(previous);
next.set(current.id, {
...current,
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
attentionReason: "permission",
});
return next;
});
}
class ConnectedHosts implements PluginConnectedHostsSource {
private ids: readonly string[] = ["host-1"];
private listeners = new Set<() => void>();
getSnapshot = () => this.ids;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
connect(id: string) {
this.ids = [...this.ids, id];
for (const listener of this.listeners) listener();
}
}
function mount(children: React.ReactNode, hosts = new ConnectedHosts()) {
const source = createPluginClientStateSource("host-1", { hosts });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(<PluginClientStateProvider source={source}>{children}</PluginClientStateProvider>),
);
return source;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
useSessionStore.getState().clearSession("host-1");
useSessionStore.getState().clearSession("host-2");
renders.clear();
});
it("shows a restored child in the nested example while its cached parent remains archived", () => {
seedHost("host-1");
const parent = {
...agent,
id: "archived-parent",
title: "Archived parent",
archivedAt: new Date("2026-11-01T00:00:00Z"),
};
const child = {
...agent,
id: "restored-child",
title: "Restored child",
parentAgentId: parent.id,
labels: { "paseo.parent-agent-id": parent.id },
archivedAt: parent.archivedAt,
};
const store = useSessionStore.getState();
store.setProjects("host-1", [
{
projectId: workspace.projectId,
projectDisplayName: "Project",
projectCustomName: null,
projectRootPath: workspace.projectRootPath,
projectKind: "git",
},
]);
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, child],
]),
);
const view = {
theme: toPluginTheme(lightTheme),
layout: { compact: false, platform: "web" as const },
active: { workspace: null, agent: null },
navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} },
dismiss() {},
};
const source = mount(<Nested {...view} />);
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
expect(container?.textContent).not.toContain(child.title);
act(() =>
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, { ...child, archivedAt: null }],
]),
),
);
expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull();
expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull();
});
describe("sidebar data hooks through the app store and plugin provider", () => {
it("renders only one workspace row when its root agent starts waiting", () => {
seedHost("host-1");
const source = mount(<Workspaces />);
const first = source.getSnapshot().workspaces[0]!;
const second = source.getSnapshot().workspaces[1]!;
const before = source.getSnapshot();
act(waitForInput);
expect(renders.get("workspaces")).toBe(1);
expect(renders.get(first.ref.key)).toBe(2);
expect(renders.get(second.ref.key)).toBe(1);
expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input");
expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]);
});
it("moves the agent and renders only its two affected sections without memo", () => {
seedHost("host-1");
mount(<Agents />);
act(waitForInput);
expect(renders.get("running")).toBe(2);
expect(renders.get("waiting")).toBe(2);
expect(renders.get("idle")).toBe(1);
});
it("uses shallow selected equality and custom equality without a selector", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
mount(<SelectedWorkspace reference={source.getSnapshot().workspaces[0]!.ref} />);
act(waitForInput);
expect(renders.get("selected")).toBe(1);
});
it("refreshes translated labels through the subscribed source when language changes", async () => {
seedHost("host-1");
i18n.addResourceBundle("fr", "translation", fr);
const source = mount(<Workspaces />);
const before = source.getSnapshot().workspaces[0]!;
const previousLanguage = i18n.language;
try {
await act(async () => {
await i18n.changeLanguage("fr");
});
const after = source.getSnapshot().workspaces[0]!;
expect(after).not.toBe(before);
expect(after.indicatorLabel).toBe("En cours");
expect(after.ref).toBe(before.ref);
expect(renders.get(before.ref.key)).toBe(2);
expect(renders.get("workspaces")).toBe(1);
} finally {
await act(async () => {
await i18n.changeLanguage(previousLanguage);
});
}
});
it("adds a newly connected host and exposes projects with no workspaces", () => {
seedHost("host-1");
seedHost("host-2");
const hosts = new ConnectedHosts();
const source = mount(<Workspaces />, hosts);
expect(source.getSnapshot().workspaces).toHaveLength(2);
expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project");
act(() => hosts.connect("host-2"));
expect(source.getSnapshot().workspaces).toHaveLength(4);
expect(source.getSnapshot().projects).toHaveLength(2);
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
});
});
it("observes terminals only while a terminal reader is subscribed", () => {
let active = 0;
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => {
active++;
return () => {
active--;
};
},
},
});
const releaseWorkspace = source.subscribe(() => {});
expect(active).toBe(0);
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
const releaseSecond = source.subscribe(() => {}, { terminals: true });
expect(active).toBe(1);
releaseTerminal();
expect(active).toBe(1);
releaseSecond();
expect(active).toBe(0);
releaseWorkspace();
});
it("terminal row reads propagate only the referenced host's observation error", () => {
const error = new Error("Update the host");
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => () => {},
getErrors: () => new Map([["unsupported", error]]),
},
});
const refs = createEntityRefs();
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
});
+242 -15
View File
@@ -1,21 +1,248 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import { useSessionStore } from "@/stores/session-store";
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
import { i18n } from "@/i18n/i18next";
import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
import { connectedPluginHosts } from "./connected-hosts";
import { pluginTerminals } from "./terminals";
import { entityRefs } from "./refs";
import {
createPluginAgentSnapshot,
createPluginProjectSnapshot,
createPluginWorkspaceSnapshot,
} from "./snapshots";
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
return {
subscribe: useSessionStore.subscribe,
getWorkspace(workspaceId) {
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
export interface PluginConnectedHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly string[];
}
export interface PluginTerminalsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly PluginTerminalSnapshot[];
getErrors?(): ReadonlyMap<string, Error>;
}
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
const listeners = new Set<() => void>();
let releaseStores: (() => void) | null = null;
// Every plugin host shares one subscription to each app store.
function subscribeStores(listener: () => void): () => void {
if (!releaseStores) {
const notify = () => {
for (const subscriber of listeners) subscriber();
};
i18n.on("languageChanged", notify);
const releaseSession = useSessionStore.subscribe((state) => {
for (const hostId of hostProjections.keys()) {
if (!state.sessions[hostId]) hostProjections.delete(hostId);
}
notify();
});
const releaseCreate = useCreateFlowStore.subscribe(notify);
releaseStores = () => {
i18n.off("languageChanged", notify);
releaseSession();
releaseCreate();
};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && releaseStores) {
releaseStores();
releaseStores = null;
hostProjections.clear();
}
};
}
interface HostProjection {
language: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
projects: SessionState["projects"];
activity: SessionState["workspaceAgentActivity"];
pending: ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
snapshot: Omit<PluginClientSnapshot, "terminals">;
workspaceById: Map<string, PluginClientSnapshot["workspaces"][number]>;
agentById: Map<string, PluginClientSnapshot["agents"][number]>;
projectById: Map<string, PluginClientSnapshot["projects"][number]>;
}
const hostProjections = new Map<string, HostProjection>();
function projectHost(hostId: string): HostProjection | null {
const session = useSessionStore.getState().sessions[hostId];
if (!session) {
hostProjections.delete(hostId);
return null;
}
const pending = useCreateFlowStore.getState().pendingByDraftId;
const previous = hostProjections.get(hostId);
const unchanged =
previous &&
previous.language === i18n.language &&
previous.workspaces === session.workspaces &&
previous.agents === session.agents &&
previous.projects === session.projects &&
previous.activity === session.workspaceAgentActivity &&
previous.pending === pending;
if (unchanged) return previous;
const workspaces = Array.from(session.workspaces.values(), (workspace) => {
const activity = session.workspaceAgentActivity.get(workspace.id);
const root = activity ? session.agents.get(activity.agentId) : undefined;
return createPluginWorkspaceSnapshot(workspace, hostId, {
pendingCreateAttempts: pending,
workspaceAgentActivity: session.workspaceAgentActivity,
parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null,
});
});
const agents: PluginClientSnapshot["agents"][number][] = [];
for (const agent of session.agents.values()) {
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (!workspaceId || agent.archivedAt) continue;
agents.push(createPluginAgentSnapshot(agent, workspaceId));
}
const projects = Array.from(session.projects.values(), (project) =>
createPluginProjectSnapshot(project, hostId),
);
const projection: HostProjection = {
language: i18n.language,
workspaces: session.workspaces,
agents: session.agents,
projects: session.projects,
activity: session.workspaceAgentActivity,
pending,
snapshot: {
workspaces: Object.freeze(workspaces),
agents: Object.freeze(agents),
projects: Object.freeze(projects),
},
getAgent(agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId);
return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null;
workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
agentById: new Map(agents.map((agent) => [agent.id, agent])),
projectById: new Map(projects.map((project) => [project.ref.id, project])),
};
hostProjections.set(hostId, projection);
return projection;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */
export function createPluginClientStateSource(
serverId?: string,
options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {},
): PluginClientStateSource {
const terminals = options.terminals ?? pluginTerminals;
const hosts = options.hosts ?? connectedPluginHosts;
const subscribers = new Set<() => void>();
const terminalSubscribers = new Set<() => void>();
let releaseTerminals: (() => void) | null = null;
let release: (() => void) | null = null;
let previousProjections: readonly HostProjection[] = [];
let previousTerminals = emptyTerminals;
let previousTerminalErrors: ReadonlyMap<string, Error> | undefined;
let snapshot: PluginClientSnapshot = {
workspaces: [],
agents: [],
projects: [],
terminals: emptyTerminals,
};
function getSnapshot(): PluginClientSnapshot {
const ids = hosts.getSnapshot();
const projections: HostProjection[] = [];
for (const id of ids) {
const projection = projectHost(id);
if (projection) projections.push(projection);
}
const terminalSnapshots = terminals.getSnapshot();
const terminalErrors = terminals.getErrors?.();
if (
sameSequence(previousProjections, projections) &&
previousTerminals === terminalSnapshots &&
previousTerminalErrors === terminalErrors
)
return snapshot;
previousProjections = projections;
previousTerminals = terminalSnapshots;
previousTerminalErrors = terminalErrors;
snapshot = Object.freeze({
workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)),
agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)),
projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)),
terminals: terminalSnapshots,
terminalErrors,
});
return snapshot;
}
return {
subscribe(listener, subscriptionOptions) {
subscribers.add(listener);
if (subscriptionOptions?.terminals) {
terminalSubscribers.add(listener);
releaseTerminals ??= terminals.subscribe(() => {
for (const subscriber of terminalSubscribers) subscriber();
});
}
if (!release) {
const notify = () => {
for (const subscriber of subscribers) subscriber();
};
const releaseStore = subscribeStores(notify);
const releaseHosts = hosts.subscribe(notify);
release = () => {
releaseStore();
releaseHosts();
};
}
return () => {
subscribers.delete(listener);
terminalSubscribers.delete(listener);
if (terminalSubscribers.size === 0 && releaseTerminals) {
releaseTerminals();
releaseTerminals = null;
}
if (subscribers.size === 0 && release) {
release();
release = null;
previousProjections = [];
previousTerminals = emptyTerminals;
previousTerminalErrors = undefined;
snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals };
}
};
},
getSnapshot,
getWorkspace(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.workspace(serverId, reference)
: reference;
if (typeof ref === "string") return null;
return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null;
},
getAgent(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.agent(serverId, reference)
: reference;
if (typeof ref === "string") return null;
const projected = projectHost(ref.hostId)?.agentById.get(ref.id);
if (projected) return projected;
const session = useSessionStore.getState().sessions[ref.hostId];
const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id);
const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId);
return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null;
},
getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null,
getTerminal(ref) {
const error = terminals.getErrors?.().get(ref.hostId);
if (error) throw error;
return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null;
},
};
}
@@ -0,0 +1,154 @@
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TerminalsChanged } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { entityRefs } from "./refs";
import type { PluginTerminalsSource } from "./source";
interface TerminalHost {
serverId: string;
client: DaemonClient;
epoch: number;
}
export interface TerminalHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly TerminalHost[];
}
interface Observation {
client: DaemonClient;
epoch: number;
items: readonly PluginTerminalSnapshot[];
release(): void;
}
/** One owned observation per connected host, shared by every plugin surface. */
export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource {
const observations = new Map<string, Observation>();
const listeners = new Set<() => void>();
let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]);
let errors: ReadonlyMap<string, Error> = new Map();
let releaseHosts: (() => void) | null = null;
function publish() {
const next = [...observations.values()].flatMap((item) => item.items);
if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index]))
snapshot = Object.freeze(next);
for (const listener of listeners) listener();
}
function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) {
if (observations.get(hostId) !== observation) return;
const previous = new Map(observation.items.map((item) => [item.ref.id, item]));
observation.items = Object.freeze(
payload.terminals.flatMap((terminal) => {
// Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace.
if (!terminal.workspaceId) return [];
const ref = entityRefs.terminal(hostId, terminal.id);
const workspace = entityRefs.workspace(hostId, terminal.workspaceId);
const title = terminal.title ?? terminal.name;
const createdAt = terminal.createdAt ?? null;
const isScript = terminal.isScript === true;
const cached = previous.get(terminal.id);
if (
cached &&
cached.workspace === workspace &&
cached.title === title &&
cached.createdAt === createdAt &&
cached.isScript === isScript
)
return [cached];
return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })];
}),
);
errors = new Map([...errors].filter(([id]) => id !== hostId));
publish();
}
function reconcile() {
const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host]));
for (const [id, observation] of observations) {
if (
active.get(id)?.client !== observation.client ||
active.get(id)?.epoch !== observation.epoch
) {
observations.delete(id);
observation.release();
}
}
errors = new Map([...errors].filter(([id]) => active.has(id)));
for (const [hostId, { client, epoch }] of active) {
if (observations.has(hostId)) continue;
const observation: Observation = { client, epoch, items: [], release() {} };
observations.set(hostId, observation);
try {
// The SDK owns the capability gate; a failed host is reported only to terminal readers.
const subscription = client.observeTerminals({});
const unsubscribe = subscription.subscribe({
snapshot: (payload) => receive(hostId, observation, payload),
update: (message) => {
if (message.type === "terminals_changed") receive(hostId, observation, message.payload);
},
error: (error) => {
if (observations.get(hostId) !== observation) return;
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
publish();
},
});
void subscription.ready.catch(() => {}); // Observer owns error delivery.
observation.release = () => {
unsubscribe();
void subscription.release().catch(() => {});
};
} catch (error) {
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
}
}
publish();
}
return {
getSnapshot: () => snapshot,
getErrors: () => errors,
subscribe(listener) {
listeners.add(listener);
if (!releaseHosts) {
releaseHosts = hosts.subscribe(reconcile);
reconcile();
}
return () => {
listeners.delete(listener);
if (!listeners.size && releaseHosts) {
releaseHosts();
releaseHosts = null;
for (const observation of observations.values()) observation.release();
observations.clear();
snapshot = Object.freeze([]);
errors = new Map();
}
};
},
};
}
export const pluginTerminals = createPluginTerminalsSource({
getSnapshot() {
const runtime = getHostRuntimeStore();
return runtime.getHosts().flatMap((host) => {
const state = runtime.getSnapshot(host.serverId);
return state?.connectionStatus === "online" && state.client
? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }]
: [];
});
},
subscribe(listener) {
const runtime = getHostRuntimeStore();
const releaseRuntime = runtime.subscribeAll(listener);
const releaseHosts = runtime.subscribeHostList(listener);
return () => {
releaseRuntime();
releaseHosts();
};
},
});
@@ -13,7 +13,20 @@ import { z } from "zod";
import type { InstalledPlugin } from "../types";
import { buildPluginCommandCenterContributions } from "./contributions";
import { entityRefs } from "../client-state/refs";
const workspace: PluginWorkspaceSnapshot = {
ref: entityRefs.workspace("host-1", "workspace-1"),
hostId: "host-1",
project: entityRefs.project("host-1", "project-1"),
pinnedAt: null,
labels: [],
background: false,
branch: null,
createdAt: null,
lastPromptAt: null,
parentAgent: null,
indicator: "running",
indicatorLabel: "Running",
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -30,6 +43,10 @@ const workspace: PluginWorkspaceSnapshot = {
};
const agent: PluginAgentSnapshot = {
ref: entityRefs.agent("host-1", "agent-1"),
hostId: "host-1",
workspace: workspace.ref,
parentAgent: null,
id: "agent-1",
workspaceId: workspace.id,
provider: "codex",
@@ -82,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [{ id: "main", title: "Main", Component: () => null }],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -131,6 +149,15 @@ function stateSource() {
subscribe() {
return () => {};
},
getSnapshot() {
return { workspaces: [workspace], agents: [agent], projects: [], terminals: [] };
},
getProject() {
return null;
},
getTerminal() {
return null;
},
getWorkspace(id: string) {
return id === workspace.id ? workspace : null;
},
+23
View File
@@ -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",
+17 -1
View File
@@ -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,
@@ -1,7 +1,8 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
import { defineRpc } from "@getpaseo/plugin";
import type { PaseoApi } from "@getpaseo/client";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { QueryClient } from "@tanstack/react-query";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin {
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
paseo: { dispose: async () => undefined } as PaseoApi,
paseo: createPaseoApi(
new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "installation-provider",
reconnect: { enabled: false },
}),
),
invoke: async (method) => {
invoked.push(method);
return { state: "green" };
@@ -36,6 +43,7 @@ function installation(invoked: string[]): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -75,4 +83,6 @@ it("gives every surface the installation's client and RPCs on its first frame",
expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true);
await expect(rpc!({})).resolves.toEqual({ state: "green" });
expect(invoked).toEqual(["deploys.status"]);
plugin.lifetime.abort();
await plugin.paseo.dispose();
});
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { QueryClientProvider } from "@tanstack/react-query";
import {
PaseoApiProvider,
@@ -17,7 +18,7 @@ export function PluginInstallationProvider({
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PaseoApiProvider paseo={getPluginClientApi(plugin).paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { filePanelTitle } from "@/file-panels/choice/presentation";
import { i18n } from "@/i18n/i18next";
import { createPluginHosts } from "./hosts";
@@ -50,7 +51,7 @@ function registry(
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected plugin RPC");
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { createPluginHosts } from "./hosts";
import { afterEach, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -25,7 +26,7 @@ function registry(version: string) {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("No RPC in this plugin");
+1
View File
@@ -122,6 +122,7 @@ export class PluginRegistry {
cleanup: () => undefined,
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import { createDeviceSettings } from "./device";
function device(records = new Map<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"] } });
});
});
it("persists a validated migration once and returns the persisted revision across restarts", async () => {
const records = new Map<string, string>();
const settings = device(records);
await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } });
let migrations = 0;
const upgraded = defineSettings({
...definition,
version: 2,
migrate: () => {
migrations++;
return { collapsed: ["migrated"] };
},
});
const first = await settings("plugin", upgraded).read();
const restarted = device(records)("plugin", upgraded);
expect(await restarted.read()).toEqual(first);
expect(migrations).toBe(1);
expect(first).toEqual({
status: "ready",
revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }),
values: { collapsed: ["migrated"] },
});
expect(
await restarted.write({
revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }),
values: { collapsed: [] },
}),
).toMatchObject({ status: "conflict" });
expect(
await restarted.write({ revision: first.revision, values: { collapsed: [] } }),
).toMatchObject({ status: "saved" });
});
@@ -0,0 +1,80 @@
import {
settingsRpc,
type SettingsDefinition,
type RpcOutput,
type RpcInput,
} from "@getpaseo/plugin";
import { z } from "zod";
interface DeviceStorage {
getItem(key: string): Promise<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);
let revision = raw ?? "missing";
try {
let values: unknown = {};
let migrated = false;
if (raw !== null) {
const stored = envelope.parse(JSON.parse(raw));
if (stored.version > definition.version)
throw new Error("Update this plugin to read its newer settings");
values = stored.values;
if (stored.version < definition.version) {
if (!definition.migrate)
throw new Error("This plugin has no migration for its saved settings");
values = await definition.migrate(values, stored.version);
migrated = true;
}
}
const parsed = z.json().parse(await definition.schema.parseAsync(values));
if (migrated) {
const serialized = JSON.stringify({ version: definition.version, values: parsed });
await storage.setItem(key, serialized);
revision = serialized;
}
return { status: "ready", revision, values: parsed };
} catch (error) {
return {
status: "invalid",
revision,
error: error instanceof Error ? error.message : String(error),
};
}
}
function write(input: WriteInput): Promise<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,
+1
View File
@@ -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: [],
@@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null }));
const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null }));
vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient),
useHosts: () => [{ serverId: "host-1", label: "Local" }],
}));
+2
View File
@@ -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: [],
@@ -20,7 +20,8 @@ const runtime = vi.hoisted(() => ({
client: null as DaemonClient | null,
}));
vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
useHostRuntimeClient: () => runtime.client,
useHostRuntimeIsConnected: () => runtime.connected,
}));
@@ -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" />
</>
+7 -1
View File
@@ -6578,10 +6578,16 @@ export class DaemonClient {
// ============================================================================
observeTerminals(input: {
cwd: string;
cwd?: string;
workspaceId?: string;
signal?: AbortSignal;
}): OwnedSubscription<CorrelatedResponsePayload<"terminals_changed">> {
// COMPAT(terminalCollection): added in v0.16, remove after 2027-06.
if (
input.cwd === undefined &&
this.getLastServerInfoMessage()?.features?.terminalCollection !== true
)
throw new Error("Update the host to list terminals across workspaces.");
const { signal, ...query } = input;
return this.observe(
"terminals_changed",
+36
View File
@@ -1,3 +1,4 @@
import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels";
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
@@ -190,6 +191,8 @@ export interface PaseoWorkspaceHandle {
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>;
setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>;
setBaseBranch(options: {
baseRef: string;
requestId?: string;
@@ -899,6 +902,14 @@ function createWorkspaceHandleFactory(
current: () => current,
refresh,
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned),
setLabel: (name, options) =>
assignWorkspaceLabel({
client: daemonClient,
workspaceId: id,
name,
assigned: options.assigned,
}),
setBaseBranch: (options) =>
daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }),
archive: async (requestId) => {
@@ -1288,3 +1299,28 @@ function createGeneratedClientId(): string {
: Math.random().toString(36).slice(2);
return `paseo-sdk-${randomId}`;
}
interface WorkspaceLabelAssignment {
client: DaemonClient;
workspaceId: string;
name: string;
assigned: boolean;
}
async function assignWorkspaceLabel(
input: WorkspaceLabelAssignment,
): Promise<{ labels: string[] }> {
input.client.ensureConnected();
// COMPAT(workspaceLabels): added in v0.5.0, remove gate after 2027-08-14.
if (input.client.getLastServerInfoMessage()?.features?.workspaceLabels !== true)
throw new Error("Update the host to assign workspace labels.");
const catalog = await input.client.listWorkspaceLabels();
const key = workspaceLabelKey(input.name);
const existing = catalog.labels.find((label) => workspaceLabelKey(label.name) === key);
const label = existing ?? { name: input.name, color: "violet" as const };
const result = await input.client.setWorkspaceLabel({
workspaceId: input.workspaceId,
label,
assigned: input.assigned,
});
return { labels: result.workspaceLabels };
}
+42
View File
@@ -388,3 +388,45 @@ describe("plugin subscription SDK boundary", () => {
).toEqual([]);
});
});
describe("sidebar data SDK boundary", () => {
it("accepts references and preserves string-id panel consumers", () => {
expect(
typeErrors(`
import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client";
import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin";
function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) {
const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace);
const title: string | null = useWorkspace("workspace", item => item.name);
const status = useAgent(agent, item => ({ status: item.status }), (a, b) => a?.status === b?.status);
const agentSnapshot = useAgent(agent);
const custom = useWorkspace(workspace, undefined, (a, b) => a?.name === b?.name);
const workspaces: readonly WorkspaceRef[] = useWorkspaces({ orderBy: "name" });
const groups: readonly Group<AgentRef>[] = useAgents({ groupBy: item => item.status });
const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] });
const terminals: readonly TerminalRef[] = useTerminals();
const paseo = usePaseo();
void paseo.workspaces.ref(workspace).setPinned(true);
void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true });
void paseo.workspaces.ref("old-id").setTitle("Title");
if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); }
if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); }
void paseo.agents.ref(agent);
if (projects[0]) useProject(projects[0], item => item.displayName);
if (terminals[0]) useTerminal(terminals[0]);
function View(props: PluginSidebarViewProps) {
props.navigation.openWorkspace(workspace);
props.navigation.openAgent(agent);
props.dismiss();
return null;
}
function contribute(client: PluginClientContext) {
client.addSidebarView({ id: "workspaces", title: "Workspaces", Component: View });
}
const query: Query<PluginWorkspaceSnapshot> = { hosts: [workspace.hostId], orderBy: ["name", "desc"] };
return null;
}
`),
).toEqual([]);
});
});
+122 -35
View File
@@ -1,12 +1,30 @@
import { createContext, useContext, type ReactNode } from "react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
import type { AgentRef, WorkspaceRef, ProjectRef, TerminalRef } from "../refs.js";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
} from "../contracts.js";
import { createCollectionQuery, type Query, type Group } from "./query.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
subscribe(listener: () => void): () => void;
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
getAgent(id: string): PluginAgentSnapshot | null;
subscribe(listener: () => void, options?: { terminals?: boolean }): () => void;
getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null;
getAgent(id: string | AgentRef): PluginAgentSnapshot | null;
getProject(ref: ProjectRef): PluginProjectSnapshot | null;
getTerminal(ref: TerminalRef): PluginTerminalSnapshot | null;
getSnapshot(): PluginClientSnapshot;
}
export interface PluginClientSnapshot {
readonly workspaces: readonly PluginWorkspaceSnapshot[];
readonly agents: readonly PluginAgentSnapshot[];
readonly projects: readonly PluginProjectSnapshot[];
readonly terminals: readonly PluginTerminalSnapshot[];
readonly terminalErrors?: ReadonlyMap<string, Error>;
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
@@ -15,44 +33,113 @@ export function usePluginClientStateSource(): PluginClientStateSource | null {
return useContext(PluginClientStateContext);
}
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
/** Shared selection semantics for host surfaces and plugin entities. */
export function usePaseoSelector<Entity, Selection>(
source: { subscribe(listener: () => void): () => void },
read: () => Entity,
selector: (entity: Entity) => Selection,
equals: (left: Selection, right: Selection) => boolean = shallowEqual,
): Selection {
return useSyncExternalStoreWithSelector(source.subscribe, read, read, selector, equals);
}
type Equality<T> = { equals(left: T, right: T): boolean }["equals"];
function useStateSource(terminals: boolean) {
const source = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
return useSyncExternalStoreWithSelector(
source.subscribe,
() => input.read(source, input.id),
() => input.read(source, input.id),
(entity) => (entity ? input.selector(entity) : null),
shallowEqual,
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
return useMemo(
() =>
terminals
? {
...source,
subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }),
}
: source,
[source, terminals],
);
}
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
function createEntityHook<Ref, Entity>(
read: (source: PluginClientStateSource, ref: Ref) => Entity | null,
terminals = false,
) {
function useEntity(
ref: Ref,
selector?: undefined,
equals?: Equality<Entity | null>,
): Entity | null;
function useEntity<Selection>(
ref: Ref,
selector: (entity: Entity) => Selection,
equals?: Equality<Selection | null>,
): Selection | null;
function useEntity<Selection>(
ref: Ref,
selector?: (entity: Entity) => Selection,
equals?: Equality<Entity | Selection | null>,
): Entity | Selection | null {
const source = useStateSource(terminals);
function select(entity: Entity | null): Entity | Selection | null {
return entity !== null && selector ? selector(entity) : entity;
}
const equality = equals ?? (selector ? shallowEqual : Object.is);
return usePaseoSelector(source, () => read(source, ref), select, equality);
}
return useEntity;
}
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: agentId,
read: (source, id) => source.getAgent(id),
selector,
});
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string) =>
source.getWorkspace(ref),
);
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref));
export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref));
export const useTerminal = createEntityHook(
(source, ref: TerminalRef) => source.getTerminal(ref),
true,
);
function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly hostId: string }>(
read: (source: PluginClientStateSource, query: Query<Snapshot>) => readonly Snapshot[],
terminals = false,
) {
function useCollection(
query: Query<Snapshot> & { groupBy: NonNullable<Query<Snapshot>["groupBy"]> },
): readonly Group<Snapshot["ref"]>[];
function useCollection(
query?: Query<Snapshot> & { groupBy?: undefined },
): readonly Snapshot["ref"][];
function useCollection(
query: Query<Snapshot>,
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[];
function useCollection(
query: Query<Snapshot> = {},
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[] {
const source = useStateSource(terminals);
const project = useMemo(() => createCollectionQuery<Snapshot>(), []);
return usePaseoSelector(
source,
() => read(source, query),
(snapshots) => project(snapshots, query),
Object.is,
);
}
return useCollection;
}
export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces);
export const useAgents = createCollectionHook((source) => source.getSnapshot().agents);
export const useProjects = createCollectionHook((source) => source.getSnapshot().projects);
export const useTerminals = createCollectionHook<PluginTerminalSnapshot>((source, query) => {
const snapshot = source.getSnapshot();
for (const [hostId, error] of snapshot.terminalErrors ?? []) {
if (!query.hosts || query.hosts.includes(hostId)) throw error;
}
return snapshot.terminals;
}, true);
export function PluginClientStateProvider({
children,
source,
+22 -2
View File
@@ -1,6 +1,7 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
import type { PluginText } from "../text.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { PluginPaseoApi } from "./reference-api.js";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
@@ -96,6 +97,24 @@ export interface PluginSidebarItemContribution<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;
@@ -308,7 +328,7 @@ export interface PluginTimelineRendererContribution<Schema extends ZodType = Zod
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
paseo: PluginPaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
+10 -1
View File
@@ -1,6 +1,11 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
PluginClientStateProvider,
usePaseoSelector,
type PluginClientStateSource,
type PluginClientSnapshot,
} from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
@@ -31,3 +36,7 @@ export {
isPluginMessage,
} from "./translations.js";
export type { PluginLocaleSource } from "./translations.js";
export { createEntityRefs } from "./refs.js";
export { createPluginReferenceApi } from "./reference-api.js";
+17 -1
View File
@@ -15,6 +15,8 @@ export type {
PluginOpenScreenInput,
PluginScreenLocation,
PluginSidebarItemProps,
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginIcon,
PluginPanelLocation,
@@ -70,7 +72,16 @@ export {
useFileUrl,
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export {
useAgent,
useWorkspace,
useProject,
useTerminal,
useWorkspaces,
useAgents,
useProjects,
useTerminals,
} from "./client-state.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
@@ -102,3 +113,8 @@ export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
export { useTabState } from "./tab.js";
export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
export type { Query, Group, GroupKey } from "./query.js";
export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js";
+11 -5
View File
@@ -1,17 +1,23 @@
import type { PaseoApi } from "@getpaseo/client";
import type { PluginPaseoApi } from "./reference-api.js";
import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext<PaseoApi | null>(null);
const PaseoApiContext = createContext<PluginPaseoApi | null>(null);
export function usePaseoContextValue(): PaseoApi | null {
export function usePaseoContextValue(): PluginPaseoApi | null {
return useContext(PaseoApiContext);
}
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
export function PaseoApiProvider({
children,
paseo,
}: {
children: ReactNode;
paseo: PluginPaseoApi;
}) {
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
}
export function usePaseo(): PaseoApi {
export function usePaseo(): PluginPaseoApi {
const paseo = usePaseoContextValue();
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
return paseo;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { createCollectionQuery, type GroupKey } from "./query.js";
const first = Object.freeze({ key: "a" });
const second = Object.freeze({ key: "b" });
const work: GroupKey = "work";
const snapshots = [
{ ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] },
{ ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] },
];
describe("collection query structural sharing", () => {
it("preserves collection identity when a row changes without changing membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const before = project(snapshots);
const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]);
expect(after).toBe(before);
expect(after).toEqual([first, second]);
});
it("shares unaffected groups and deduplicates membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels };
const before = project(snapshots, query);
const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query);
expect(after[1]).toBe(before[1]);
expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] });
expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] });
expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after);
});
it("filters hosts and predicates before sorting", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
expect(project(snapshots, { hosts: ["two"] })).toEqual([second]);
expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]);
expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([
first,
second,
]);
});
it("keeps nulls last in either direction and metadata from the first group", () => {
const project = createCollectionQuery<{
ref: typeof first;
hostId: string;
title: string | null;
}>();
const items = [
{ ref: first, hostId: "one", title: null },
{ ref: second, hostId: "one", title: "Z" },
];
expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(
project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }),
).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]);
});
});
+115
View File
@@ -0,0 +1,115 @@
interface GroupDescriptor {
readonly key: string;
readonly title?: string;
readonly color?: string;
}
export type GroupKey = string | GroupDescriptor;
export interface Query<Snapshot> {
readonly hosts?: readonly string[];
readonly where?: (item: Snapshot) => boolean;
readonly orderBy?:
| keyof Snapshot
| readonly [keyof Snapshot, "asc" | "desc"]
| ((a: Snapshot, b: Snapshot) => number);
readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[];
}
export interface Group<Ref> {
readonly key: string;
readonly title: string | null;
readonly color: string | null;
readonly items: readonly Ref[];
}
function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] {
if (typeof value === "string") return [value];
if ("key" in value) return [value];
return value;
}
interface Referenced {
readonly hostId: string;
readonly ref: unknown;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
function compareValues(left: unknown, right: unknown, direction: number): number {
if (Object.is(left, right)) return 0;
if (left === null || left === undefined) return 1;
if (right === null || right === undefined) return -1;
if (typeof left === "number" && typeof right === "number") return (left - right) * direction;
return String(left).localeCompare(String(right)) * direction;
}
/** A hook owns one projection; sharing survives both source and query changes. */
export function createCollectionQuery<Snapshot extends Referenced>() {
type Ref = Snapshot["ref"];
let previousItems: readonly Ref[] = Object.freeze([]);
let previousGroups: readonly Group<Ref>[] = Object.freeze([]);
return function project(
snapshots: readonly Snapshot[],
query: Query<Snapshot> = {},
): readonly Ref[] | readonly Group<Ref>[] {
const items = snapshots.filter((item) => {
if (query.hosts && !query.hosts.includes(item.hostId)) return false;
return !query.where || query.where(item);
});
const order = query.orderBy;
if (typeof order === "function") items.sort(order);
else if (order !== undefined) {
const field = typeof order === "object" ? order[0] : order;
const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1;
items.sort((a, b) => compareValues(a[field], b[field], direction));
}
if (!query.groupBy) {
const refs = items.map((item) => item.ref);
if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs);
return previousItems;
}
const groups = new Map<
string,
{ key: string; title: string | null; color: string | null; items: Ref[] }
>();
for (const item of items) {
const grouping = query.groupBy(item);
const keys = groupKeys(grouping);
const seen = new Set<string>();
for (const value of keys) {
const descriptor = typeof value === "string" ? { key: value } : value;
if (seen.has(descriptor.key)) continue;
seen.add(descriptor.key);
let group = groups.get(descriptor.key);
if (!group) {
group = {
key: descriptor.key,
title: descriptor.title ?? null,
color: descriptor.color ?? null,
items: [],
};
groups.set(descriptor.key, group);
}
group.items.push(item.ref);
}
}
const oldGroups = new Map(previousGroups.map((group) => [group.key, group]));
const nextGroups = Array.from(groups.values(), (group) => {
const previous = oldGroups.get(group.key);
if (
previous &&
previous.title === group.title &&
previous.color === group.color &&
sameSequence(previous.items, group.items)
)
return previous;
return Object.freeze({ ...group, items: Object.freeze(group.items) });
});
if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups);
return previousGroups;
};
}
@@ -0,0 +1,88 @@
import type {
PaseoApi,
PaseoAgent,
PaseoAgentHandle,
PaseoWorkspace,
PaseoWorkspaceHandle,
PaseoWorkspaceCreateOptions,
} from "@getpaseo/client";
import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client";
import type { PluginProjectSnapshot } from "../contracts.js";
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
import type { PluginSidebarViewProps } from "./contracts.js";
export interface PluginProjectHandle {
readonly ref: ProjectRef;
open(): void;
createWorkspace(
input?: Omit<PaseoWorkspaceCreateOptions, "source"> & {
source?: PaseoWorkspaceCreateOptions["source"];
},
): Promise<PaseoWorkspaceHandle>;
}
export interface PluginTerminalHandle extends PaseoTerminalHandle {
focus(): void;
}
export interface PluginPaseoApi extends PaseoApi {
workspaces: Omit<PaseoApi["workspaces"], "ref"> & {
ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle;
};
agents: Omit<PaseoApi["agents"], "ref"> & {
ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle;
};
projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle };
terminals: Omit<PaseoApi["terminals"], "ref"> & {
ref(terminal: TerminalRef): PluginTerminalHandle;
ref(terminal: string | PaseoTerminal): PaseoTerminalHandle;
};
}
/** Host adapter; the installation retains its single API identity across every surface. */
export function createPluginReferenceApi(input: {
paseo: PaseoApi;
resolveHost(hostId: string): PaseoApi;
readProject(ref: ProjectRef): PluginProjectSnapshot | null;
navigation: PluginSidebarViewProps["navigation"];
}): PluginPaseoApi {
const { paseo } = input;
const workspaceRef = paseo.workspaces.ref;
const agentRef = paseo.agents.ref;
const terminalRef = paseo.terminals.ref;
function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) {
if (typeof workspace !== "string" && "hostId" in workspace)
return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id);
return workspaceRef(workspace);
}
function refAgent(agent: string | PaseoAgent | AgentRef) {
if (typeof agent !== "string" && "hostId" in agent)
return input.resolveHost(agent.hostId).agents.ref(agent.id);
return agentRef(agent);
}
function terminal(ref: TerminalRef): PluginTerminalHandle;
function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle;
function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle {
if (typeof ref !== "string" && "hostId" in ref) {
const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id);
return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) });
}
return terminalRef(ref);
}
function project(ref: ProjectRef): PluginProjectHandle {
return {
ref,
open: () => input.navigation.openProject(ref),
async createWorkspace(options = {}) {
const snapshot = input.readProject(ref);
if (!snapshot) throw new Error("Project is no longer available");
const source = options.source ?? { kind: "directory", path: snapshot.rootPath };
return input.resolveHost(ref.hostId).workspaces.create({ ...options, source });
},
};
}
return Object.assign(paseo, {
workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }),
agents: Object.assign(paseo.agents, { ref: refAgent }),
projects: Object.assign(paseo.projects, { ref: project }),
terminals: Object.assign(paseo.terminals, { ref: terminal }),
});
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createEntityRefs } from "./refs.js";
describe("session entity references", () => {
it("interns frozen references separately by kind, host and entity", () => {
const refs = createEntityRefs();
for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) {
const first = reference("host", "entity");
expect(reference("host", "entity")).toBe(first);
expect(Object.isFrozen(first)).toBe(true);
expect(reference("other", "entity")).not.toBe(first);
expect(reference("host", "other")).not.toBe(first);
}
expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity"));
});
it("keeps opaque ids unambiguous and interning scoped to one app session", () => {
const refs = createEntityRefs();
expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key);
expect(createEntityRefs().workspace("host", "entity")).not.toBe(
refs.workspace("host", "entity"),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
/** Owned by the app session, shared by every plugin installation. */
export function createEntityRefs() {
function intern<Kind extends string>(kind: Kind) {
const references = new Map<
string,
Readonly<{ kind: Kind; key: string; hostId: string; id: string }>
>();
return function reference(hostId: string, id: string) {
const key = JSON.stringify([hostId, id]);
let ref = references.get(key);
if (!ref) {
ref = Object.freeze({ kind, key, hostId, id });
references.set(key, ref);
}
return ref;
};
}
return {
workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef,
agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef,
project: intern("project") satisfies (hostId: string, id: string) => ProjectRef,
terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef,
};
}
+40
View File
@@ -1,3 +1,4 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js";
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
@@ -19,7 +20,22 @@ export interface PluginTheme {
};
}
export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done";
export interface PluginWorkspaceSnapshot {
readonly ref: WorkspaceRef;
readonly hostId: string;
readonly project: ProjectRef;
readonly pinnedAt: string | null;
readonly labels: readonly string[];
readonly background: boolean;
readonly branch: string | null;
/** Null when an older host has not supplied the workspace creation time. */
readonly createdAt: string | null;
readonly lastPromptAt: string | null;
readonly parentAgent: AgentRef | null;
readonly indicator: WorkspaceIndicator;
readonly indicatorLabel: string | null;
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
@@ -29,6 +45,7 @@ export interface PluginWorkspaceSnapshot {
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
/** Raw daemon status; prefer indicator for presentation. */
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
@@ -36,6 +53,10 @@ export interface PluginWorkspaceSnapshot {
}
export interface PluginAgentSnapshot {
readonly ref: AgentRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly parentAgent: AgentRef | null;
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
@@ -96,3 +117,22 @@ export interface PluginTimelineTransformResult {
}
export type PluginCleanup = () => void | Promise<void>;
export interface PluginProjectSnapshot {
readonly ref: ProjectRef;
readonly hostId: string;
readonly displayName: string;
readonly rootPath: string;
readonly kind: PluginWorkspaceSnapshot["projectKind"];
readonly iconRevision: string | null;
}
export interface PluginTerminalSnapshot {
readonly ref: TerminalRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly title: string;
readonly isScript: boolean;
/** Null for terminals owned by workers started before timestamps were tracked. */
readonly createdAt: string | null;
}
+3
View File
@@ -3,6 +3,9 @@ export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
WorkspaceIndicator,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
+11
View File
@@ -0,0 +1,11 @@
interface EntityRef<Kind extends string> {
readonly kind: Kind;
readonly key: string;
readonly hostId: string;
readonly id: string;
}
export interface WorkspaceRef extends EntityRef<"workspace"> {}
export interface AgentRef extends EntityRef<"agent"> {}
export interface ProjectRef extends EntityRef<"project"> {}
export interface TerminalRef extends EntityRef<"terminal"> {}
+4 -3
View File
@@ -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;
+40
View File
@@ -571,3 +571,43 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
describe("terminal collection protocol compatibility", () => {
test("accepts released directory observations and their snapshots", () => {
expect(
SessionInboundMessageSchema.parse({
type: "subscribe_terminals_request",
cwd: "/repo",
requestId: "old",
}),
).toMatchObject({ cwd: "/repo" });
expect(
SessionOutboundMessageSchema.parse({
type: "terminals_changed",
payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] },
}),
).toMatchObject({ payload: { cwd: "/repo" } });
});
test("accepts capability-gated host observations and additive metadata", () => {
expect(
SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }),
).toMatchObject({ requestId: "host" });
expect(
SessionOutboundMessageSchema.parse({
type: "terminals_changed",
payload: {
terminals: [
{
id: "terminal",
name: "Shell",
cwd: "/repo",
workspaceId: "workspace",
createdAt: null,
isScript: true,
},
],
},
}),
).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } });
});
});
+7 -4
View File
@@ -3093,13 +3093,13 @@ export const ListTerminalsRequestSchema = z.object({
export const SubscribeTerminalsRequestSchema = z.object({
type: z.literal("subscribe_terminals_request"),
requestId: z.string().optional(),
cwd: z.string(),
cwd: z.string().optional(),
workspaceId: z.string().optional(),
});
export const UnsubscribeTerminalsRequestSchema = z.object({
type: z.literal("unsubscribe_terminals_request"),
cwd: z.string(),
cwd: z.string().optional(),
workspaceId: z.string().optional(),
});
@@ -3770,6 +3770,7 @@ export const ServerInfoStatusPayloadSchema = z
directorySync: z.boolean().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
workspaceLabels: z.boolean().optional(),
terminalCollection: z.boolean().optional(),
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
workspaceBaseBranch: z.boolean().optional(),
// COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02.
@@ -6684,6 +6685,8 @@ export const ListCommandsResponseSchema = z.object({
// ============================================================================
const TerminalInfoSchema = z.object({
createdAt: z.string().nullable().optional(),
isScript: z.boolean().optional(),
id: z.string(),
name: z.string(),
cwd: z.string(),
@@ -6747,8 +6750,8 @@ export const TerminalsChangedSchema = z.object({
subscriptionId: z.string().optional(),
requestId: z.string().optional(),
workspaceId: z.string().optional(),
cwd: z.string(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
cwd: z.string().optional(),
terminals: z.array(TerminalInfoSchema.partial({ cwd: true })),
}),
});
@@ -3,6 +3,11 @@
// and tear down its own live subscription without disturbing the other. An
// absent workspaceId (old clients) keys to the cwd on its own. The `::`
// separator cannot collide with a workspace id, which is always `wks_<hex>`.
export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string {
export function terminalSubscriptionKey(
cwd: string | undefined,
workspaceId: string | undefined,
): string {
if (cwd === undefined)
return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`;
return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`;
}
@@ -1,5 +1,5 @@
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
@@ -269,3 +269,132 @@ test.runIf(process.platform !== "win32")(
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
},
);
test("host-wide terminal observation tracks every workspace, metadata and release", async () => {
const first = await createWorkspace("Observe first");
const second = await createWorkspace("Observe second");
expect(client.getLastServerInfoMessage()?.features?.terminalCollection).toBe(true);
const observation = client.observeTerminals({});
let terminals = (await observation.ready).terminals;
const update = observation.subscribe({
snapshot: (value) => {
terminals = value.terminals;
},
update: (message) => {
if (message.type === "terminals_changed") terminals = message.payload.terminals;
},
});
try {
expect(terminals).toEqual([]);
const scoped = client.observeTerminals({ workspaceId: first });
const scopedSnapshot = await scoped.ready;
let owned = scopedSnapshot.terminals;
const releaseScoped = scoped.subscribe({
snapshot: (value) => {
owned = value.terminals;
},
update: (message) => {
if (message.type === "terminals_changed") owned = message.payload.terminals;
},
});
// Use a controlled process: interactive shells publish their own OSC title on startup.
const command = {
command: process.execPath,
args: ["-e", "console.log('READY'); process.stdin.resume()"],
};
const a = await sdk.terminals.create({
workspaceId: first,
name: "First terminal",
...command,
});
const b = await sdk.terminals.create({
workspaceId: second,
name: "Second terminal",
...command,
});
await expect
.poll(async () => (await a.capture({ stripAnsi: true })).lines.join("\n"))
.toContain("READY");
await expect
.poll(async () => (await b.capture({ stripAnsi: true })).lines.join("\n"))
.toContain("READY");
await expect.poll(() => terminals.map((item) => item.id).sort()).toEqual([a.id, b.id].sort());
expect(terminals.every((item) => item.cwd === cwd && item.createdAt && !item.isScript)).toBe(
true,
);
expect(terminals.map((item) => item.workspaceId).sort()).toEqual([first, second].sort());
await expect.poll(() => owned.map((item) => item.id)).toEqual([a.id]);
releaseScoped();
await scoped.release();
await client.renameTerminal({ terminalId: a.id, title: "Renamed terminal" });
await expect
.poll(() => terminals.find((item) => item.id === a.id)?.title)
.toBe("Renamed terminal");
await b.kill();
await expect.poll(() => terminals.map((item) => item.id)).toEqual([a.id]);
await a.kill();
await expect.poll(() => terminals).toEqual([]);
} finally {
update();
await observation.release();
}
}, 60_000);
test("terminal collection reports workspace script ownership after runtime registration", async () => {
await writeFile(
path.join(cwd, "paseo.json"),
JSON.stringify({
scripts: {
sleeper: {
type: "script",
command: `node -e "const fs = require('node:fs'); fs.writeFileSync('sleeper.pid', String(process.pid)); setInterval(() => { if (fs.existsSync('finish-sleeper')) process.exit(0); }, 50)"`,
},
},
}),
);
const workspaceId = await createWorkspace("Script observation");
const observation = client.observeTerminals({});
let terminals = (await observation.ready).terminals;
const release = observation.subscribe({
snapshot: (value) => {
terminals = value.terminals;
},
update: (message) => {
if (message.type === "terminals_changed") terminals = message.payload.terminals;
},
});
try {
expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull();
await expect
.poll(() =>
terminals.some((terminal) => terminal.workspaceId === workspaceId && terminal.isScript),
)
.toBe(true);
const terminalId = terminals.find((terminal) => terminal.workspaceId === workspaceId)!.id;
const pidFile = path.join(cwd, "sleeper.pid");
await expect
.poll(async () => Number(await readFile(pidFile, "utf8")), { timeout: 10_000 })
.toBeGreaterThan(0);
const pid = Number(await readFile(pidFile, "utf8"));
await writeFile(path.join(cwd, "finish-sleeper"), "done");
await expect.poll(() => isFixtureProcessRunning(pid), { timeout: 10_000 }).toBe(false);
// The program has exited; now close its interactive shell as well.
await sdk.terminals.ref(terminalId).kill();
await expect
.poll(() => terminals.filter((terminal) => terminal.workspaceId === workspaceId))
.toEqual([]);
} finally {
release();
await observation.release();
}
}, 60_000);
function isFixtureProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
throw error;
}
}
@@ -2122,7 +2122,7 @@ it.each(examples)("builds and loads the %s plugin example", async (example) => {
const runtime = createTestRuntime(
{ settingsDirectory },
undefined,
resolveDaemonVersion(import.meta.url),
example === "sidebar-views" ? "0.16.0" : resolveDaemonVersion(import.meta.url),
);
try {
await runtime.startPlugin(example, path.join(examplesDirectory, example));
@@ -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);
+6
View File
@@ -1187,6 +1187,12 @@ export class Session {
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger,
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
isScriptTerminal: (terminal) =>
this.scriptRuntimeStore
?.listForWorkspace(terminal.workspaceId)
.some((script) => script.terminalId === terminal.id) ?? false,
subscribeTerminalMetadata: (listener) =>
this.scriptRuntimeStore?.subscribe(listener) ?? (() => {}),
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
});
this.agentDirectory = new AgentDirectory({
@@ -2038,6 +2038,7 @@ export class VoiceAssistantWebSocketServer {
directorySync: true,
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
...(this.workspaceLabelService ? { workspaceLabels: true } : {}),
terminalCollection: true,
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
workspaceSetupRun: true,
workspaceSetupObservation: true,
@@ -97,3 +97,16 @@ describe("WorkspaceScriptRuntimeStore", () => {
]);
});
});
it("notifies terminal metadata consumers when script ownership changes", () => {
const store = new WorkspaceScriptRuntimeStore();
const observed: string[][] = [];
const release = store.subscribe(() =>
observed.push(store.listForWorkspace("workspace-101").map((entry) => entry.terminalId)),
);
store.set(createEntry());
store.remove({ workspaceId: "workspace-101", scriptName: "web" });
release();
store.set(createEntry());
expect(observed).toEqual([["terminal-1"], []]);
});
@@ -13,6 +13,17 @@ interface RuntimeEntryKey {
}
export class WorkspaceScriptRuntimeStore {
private readonly listeners = new Set<() => void>();
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private changed(): void {
for (const listener of this.listeners) listener();
}
private readonly entries = new Map<string, ScriptRuntimeEntry>();
private readonly scriptsByWorkspace = new Map<string, Set<string>>();
@@ -31,6 +42,7 @@ export class WorkspaceScriptRuntimeStore {
this.entries.set(entryKey, { ...entry });
this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName);
this.changed();
}
remove(key: RuntimeEntryKey): void {
@@ -42,6 +54,7 @@ export class WorkspaceScriptRuntimeStore {
this.entries.delete(entryKey);
this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName);
this.changed();
}
listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] {
@@ -70,6 +83,7 @@ export class WorkspaceScriptRuntimeStore {
this.entries.delete(this.toEntryKey(entry));
}
this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId));
this.changed();
}
isRunning(key: RuntimeEntryKey): boolean {
@@ -392,8 +392,25 @@ describe("terminal-session-controller with terminals held by a newer Paseo", ()
};
}
test("reports incompatible workers to a legacy subscription without a request id", async () => {
const outboundMessages: SessionOutboundMessage[] = [];
const controller = createController({
terminalManager: incompatibleManager(),
emit: (message) => outboundMessages.push(message),
emitBinary: () => {},
hasBinaryChannel: () => true,
isPathWithinRoot: isSameOrDescendantPath,
sessionLogger: createLogger(),
});
await controller.dispatch({ type: "subscribe_terminals_request" });
expect(outboundMessages).toEqual([
{ type: "status", payload: { status: "error", message: incompatible.message } },
]);
});
test.each([
{ type: "list_terminals_request" as const, requestId: "req-list" },
{ type: "subscribe_terminals_request" as const, requestId: "req-observe" },
{ type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" },
{
type: "create_terminal_request" as const,
@@ -505,8 +522,22 @@ describe("terminal-session-controller subdirectory aggregation", () => {
payload: {
cwd: rootCwd,
terminals: [
{ id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null },
{ id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null },
{
id: "root-term",
name: "Terminal 1",
workspaceId: "ws-test",
activity: null,
createdAt: null,
isScript: false,
},
{
id: "subdir-term",
name: "Mobile",
workspaceId: "ws-test",
activity: null,
createdAt: null,
isScript: false,
},
],
},
},
@@ -576,6 +607,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
cwd: rootCwd,
workspaceId: "ws-test",
activity: null,
createdAt: null,
isScript: false,
},
],
requestId: "req-root",
@@ -592,6 +625,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
cwd: worktreeCwd,
workspaceId: "ws-test",
activity: null,
createdAt: null,
isScript: false,
},
],
requestId: "req-worktree",
@@ -677,7 +712,16 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
type: "terminals_changed",
payload: {
cwd,
terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }],
terminals: [
{
id: "a",
name: "A",
workspaceId: "ws-a",
activity: null,
createdAt: null,
isScript: false,
},
],
},
},
]);
@@ -38,13 +38,12 @@ import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { applyTerminalSize } from "./terminal-size-ownership.js";
import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
const MAX_TERMINAL_STREAM_SLOTS = 256;
interface TerminalDirectorySubscription {
cwd: string;
cwd: string | undefined;
workspaceId: string | undefined;
owner: OwnedSubscription;
refresh: Promise<void> | null;
@@ -87,6 +86,8 @@ export interface TerminalSessionControllerOptions {
supportsChunkedReplay?: (source: object) => boolean;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
sessionLogger: pino.Logger;
isScriptTerminal?: (terminal: TerminalSession) => boolean;
subscribeTerminalMetadata?: (listener: () => void) => () => void;
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
// Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and
@@ -163,9 +164,17 @@ export class TerminalSessionController {
start(): void {
if (!this.terminalManager || this.unsubscribeTerminalsChanged) return;
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
const releaseManager = this.terminalManager.subscribeTerminalsChanged((event) => {
void this.handleTerminalsChanged(event);
});
const releaseMetadata = this.options.subscribeTerminalMetadata?.(() => {
for (const subscription of this.subscribedDirectories.values())
void this.emitTerminalsSnapshotForSubscription(subscription);
});
this.unsubscribeTerminalsChanged = () => {
releaseManager();
releaseMetadata?.();
};
}
getMetrics(): TerminalSessionControllerMetrics {
@@ -191,6 +200,7 @@ export class TerminalSessionController {
) {
return false;
}
if (subscription.cwd === undefined) return true;
return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots);
});
}
@@ -302,15 +312,7 @@ export class TerminalSessionController {
}
}
private toTerminalInfo(
terminal: Pick<TerminalSession, "id" | "name" | "workspaceId" | "getTitle" | "getActivity">,
): {
id: string;
name: string;
workspaceId: string;
title?: string;
activity: TerminalActivity | null;
} {
private toTerminalInfo(terminal: TerminalSession) {
const title = terminal.getTitle();
const activity = terminal.getActivity();
return {
@@ -319,6 +321,8 @@ export class TerminalSessionController {
workspaceId: terminal.workspaceId,
...(title ? { title } : {}),
activity,
createdAt: terminal.createdAt ?? null,
isScript: this.options.isScriptTerminal?.(terminal) ?? false,
};
}
@@ -329,7 +333,8 @@ export class TerminalSessionController {
// aggregated list — so the client's cache replacement doesn't drop the
// terminals that live directly at the root.
const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter(
(subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd),
(subscription) =>
subscription.cwd === undefined || this.isPathWithinRoot(subscription.cwd, event.cwd),
);
for (const subscription of matchingSubscriptions) {
await this.emitTerminalsSnapshotForSubscription(subscription);
@@ -368,6 +373,10 @@ export class TerminalSessionController {
await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId);
} catch (error) {
await owner.release();
if (error instanceof TerminalWorkerIncompatibleError) {
this.emitIncompatible(msg, error);
return;
}
throw error;
}
}
@@ -383,17 +392,26 @@ export class TerminalSessionController {
const refresh = (async () => {
do {
subscription.pending = false;
const terminals = await this.getTerminalsForWorkspaceRoot(
subscription.cwd,
subscription.workspaceId,
);
const terminals =
subscription.cwd === undefined
? (await this.getAllTerminalSessions()).filter(
(terminal) =>
subscription.workspaceId === undefined ||
terminal.workspaceId === subscription.workspaceId,
)
: await this.getTerminalsForWorkspaceRoot(subscription.cwd, subscription.workspaceId);
if (subscription.owner.signal.aborted) return;
subscription.owner.emit({
type: "terminals_changed",
payload: {
cwd: subscription.cwd,
workspaceId: subscription.workspaceId,
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
terminals: terminals.map((terminal) =>
Object.assign(
this.toTerminalInfo(terminal),
subscription.cwd === undefined ? { cwd: terminal.cwd } : {},
),
),
...(requestId ? { requestId } : {}),
},
});
@@ -464,9 +482,13 @@ export class TerminalSessionController {
// An empty list or a generic failure would read as "no terminals"; the client needs to know
// they exist and why it cannot reach them.
private emitIncompatible(
msg: { type: string; requestId: string },
msg: { type: string; requestId?: string },
error: TerminalWorkerIncompatibleError,
): void {
if (msg.requestId === undefined) {
this.emit({ type: "status", payload: { status: "error", message: error.message } });
return;
}
this.emit({
type: "rpc_error",
payload: {
@@ -482,6 +504,8 @@ export class TerminalSessionController {
if (!this.terminalManager) {
return [];
}
const unavailable = this.terminalManager.getUnavailableError?.();
if (unavailable) throw unavailable;
const directories = this.terminalManager.listDirectories();
const manager = this.terminalManager;
const terminalsByDirectory = await Promise.all(
@@ -263,7 +263,12 @@ function runWorker(argv: string[]): void {
}
function terminalMetadata(session: LocalTerminalSession) {
return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() };
return {
...session.getSize(),
createdAt: session.createdAt,
hasOutput: session.hasOutput(),
title: session.getTitle(),
};
}
function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo {
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js";
import {
classifyTerminalWorkerProtocol,
TERMINAL_WORKER_PROTOCOL_VERSION,
} from "./terminal-worker-protocol.js";
describe("classifyTerminalWorkerProtocol", () => {
it("rejects the unreleased cell protocol without a draining adapter", () => {
@@ -23,3 +26,8 @@ describe("classifyTerminalWorkerProtocol", () => {
expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit);
});
});
it("drains v5 terminals while v6 supplies creation timestamps", () => {
expect(TERMINAL_WORKER_PROTOCOL_VERSION).toBe(6);
expect(classifyTerminalWorkerProtocol(5)).toBe("previous");
});
@@ -1,6 +1,8 @@
import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js";
export interface WorkerTerminalState {
/** COMPAT(terminal-created-at): v6; v5 draining workers omit this, remove after 2027-06. */
createdAt?: string;
rows: number;
cols: number;
hasOutput: boolean;
@@ -43,7 +45,7 @@ export interface WorkerCreateTerminalOptions {
// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new
// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
// worker running untouched. See docs/terminal-worker.md.
export const TERMINAL_WORKER_PROTOCOL_VERSION = 5;
export const TERMINAL_WORKER_PROTOCOL_VERSION = 6;
// Test-only: the protocol version a worker reports, so tests can stand up older and newer
// workers from one build.
+3
View File
@@ -88,6 +88,8 @@ export interface TerminalActivityTransition {
}
export interface TerminalSession {
/** Absent on terminal workers started before creation timestamps were tracked. */
createdAt?: string;
id: string;
name: string;
cwd: string;
@@ -1378,6 +1380,7 @@ export async function createTerminal(
return {
id,
createdAt: new Date().toISOString(),
pid: ptyProcess.pid,
name,
cwd,
@@ -1666,19 +1666,19 @@ it.each([1, 4])(
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
const workers = await workerRecords(paseoHome);
expect(workers).toHaveLength(1);
expect(workers[0]!.protocolVersion).toBe(5);
expect(workers[0]!.protocolVersion).toBe(6);
},
);
it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => {
const paseoHome = createTemporaryHome();
const newer = await leaveWorkerWithTerminal(paseoHome, 6);
const newer = await leaveWorkerWithTerminal(paseoHome, 7);
manager = await startRealWorkerManager({ paseoHome });
const unavailable = manager.getUnavailableError?.();
expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError);
expect(unavailable?.message).toContain("worker protocol 6");
expect(unavailable?.message).toContain("worker protocol 7");
await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf(
TerminalWorkerIncompatibleError,
);
@@ -265,6 +265,9 @@ export function createWorkerTerminalManager(
};
const session: TerminalSession = {
get createdAt() {
return record.state.createdAt;
},
get id() {
return record.info.id;
},
@@ -370,6 +373,10 @@ export function createWorkerTerminalManager(
for (const listener of Array.from(record.titleChangeListeners)) {
listener(manualTitle);
}
emitTerminalsChanged({
cwd: record.info.cwd,
terminals: listTerminalItemsForCwd(record.info.cwd),
});
},
getExitInfo(): TerminalExitInfo | null {
return record.exitInfo;
@@ -0,0 +1,34 @@
import type { PluginAgentSnapshot } from "@getpaseo/plugin";
import { ScrollView, View } from "react-native";
import { useAgents, type PluginSidebarViewProps } from "@getpaseo/plugin/client";
import { AgentRow, Heading } from "./rows";
export function agentTask(agent: PluginAgentSnapshot) {
if (agent.attentionReason === "permission") return "Respond";
if (agent.requiresAttention || agent.status === "error") return "Review";
if (agent.status === "running" || agent.status === "initializing") return "Wait";
return "Start";
}
function Task({ title, view }: { title: string; view: PluginSidebarViewProps }) {
const agents = useAgents({
where: (item) => agentTask(item) === title,
orderBy: ["lastActivityAt", "desc"],
});
return (
<View>
<Heading title={title} view={view} />
{agents.map((reference) => (
<AgentRow key={reference.key} reference={reference} view={view} />
))}
</View>
);
}
export function Agents(view: PluginSidebarViewProps) {
return (
<ScrollView>
<Task title="Respond" view={view} />
<Task title="Review" view={view} />
<Task title="Wait" view={view} />
<Task title="Start" view={view} />
</ScrollView>
);
}
@@ -0,0 +1,191 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import {
usePaseo,
useSettings,
useWorkspaces,
type WorkspaceRef,
type PluginSidebarViewProps,
} from "@getpaseo/plugin/client";
import { folders } from "../shared/preferences";
import { Heading, WorkspaceRow } from "./rows";
interface Move {
reference: WorkspaceRef;
from: string | null;
}
function folderLabel(key: string): string | null {
return key === "unlabelled" ? null : key.slice("label:".length);
}
function Movable({
reference,
folder,
view,
select,
}: {
reference: WorkspaceRef;
folder: string | null;
view: PluginSidebarViewProps;
select: (move: Move) => void;
}) {
const pick = useCallback(() => select({ reference, from: folder }), [select, reference, folder]);
return (
<View>
<WorkspaceRow reference={reference} view={view} onLongPress={pick} />
<Pressable
accessibilityRole="button"
accessibilityLabel={`Move workspace ${reference.id}`}
onPress={pick}
>
<Heading title="Move…" view={view} />
</Pressable>
</View>
);
}
export function Labels(view: PluginSidebarViewProps) {
const paseo = usePaseo();
const settings = useSettings(folders);
const [selected, select] = useState<Move | null>(null);
const groups = useWorkspaces({
orderBy: "name",
groupBy: (item) =>
item.labels.length ? item.labels.map((label) => `label:${label}`) : ["unlabelled"],
});
const style = useMemo(
() => ({ color: view.theme.colors.foreground, paddingHorizontal: 12 }),
[view.theme],
);
const move = useMutation({
mutationFn: async (destination: string | null) => {
if (!selected) return;
const workspace = paseo.workspaces.ref(selected.reference);
// Add first: if adding fails the workspace keeps its original folder.
if (destination && destination !== selected.from)
await workspace.setLabel(destination, { assigned: true });
if (selected.from && selected.from !== destination)
await workspace.setLabel(selected.from, { assigned: false });
select(null);
},
});
const toggle = useCallback(
(folder: string) => {
if (settings.status !== "ready") return;
void settings.save(
{
collapsed: settings.values.collapsed.includes(folder)
? settings.values.collapsed.filter((item) => item !== folder)
: [...settings.values.collapsed, folder],
},
settings.revision,
);
},
[settings],
);
const cancel = useCallback(() => select(null), []);
if (settings.status === "loading") return <Text style={style}>Loading folders…</Text>;
if (settings.status !== "ready")
return (
<View>
<Text style={style}>{settings.error}</Text>
<Pressable accessibilityRole="button" onPress={settings.reload}>
<Heading title="Reload folders" view={view} />
</Pressable>
{settings.status === "invalid" ? (
<Pressable accessibilityRole="button" onPress={settings.reset}>
<Heading title="Reset folders" view={view} />
</Pressable>
) : null}
</View>
);
return (
<ScrollView>
{selected ? (
<View>
<Text style={style}>Choose a destination folder</Text>
<Pressable accessibilityRole="button" onPress={cancel}>
<Heading title="Cancel move" view={view} />
</Pressable>
</View>
) : (
<Text style={style}>Long press a workspace or choose Move…</Text>
)}
{move.error ? <Text style={style}>{move.error.message}</Text> : null}
{Array.from(
new Set(["label:Inbox", "label:Later", "unlabelled", ...groups.map((group) => group.key)]),
).map((folder) => (
<Folder
key={folder}
folder={folder}
view={view}
selected={!!selected}
pending={move.isPending}
move={move.mutate}
collapsed={settings.values.collapsed.includes(folder)}
toggle={toggle}
>
{groups
.find((group) => group.key === folder)
?.items.map((reference) => (
<Movable
key={reference.key}
reference={reference}
folder={folderLabel(folder)}
view={view}
select={select}
/>
))}
</Folder>
))}
{settings.saveError ? <Text style={style}>{settings.saveError}</Text> : null}
</ScrollView>
);
}
function Folder({
folder,
view,
selected,
pending,
move,
collapsed,
toggle,
children,
}: {
folder: string;
view: PluginSidebarViewProps;
selected: boolean;
pending: boolean;
move: (folder: string | null) => void;
collapsed: boolean;
toggle: (folder: string) => void;
children: import("react").ReactNode;
}) {
const toggleFolder = useCallback(() => toggle(folder), [toggle, folder]);
const label = folderLabel(folder);
const title = label ?? "Unlabelled";
const destination = useCallback(() => move(label), [move, label]);
return (
<View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Toggle ${title}`}
onPress={toggleFolder}
>
<Heading title={`${collapsed ? "▸" : "▾"} ${title}`} view={view} />
</Pressable>
{selected ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={label === null ? "Remove current label" : `Move to ${title}`}
disabled={pending}
onPress={destination}
>
<Heading
title={label === null ? "Remove current label" : `Move to ${title}`}
view={view}
/>
</Pressable>
) : null}
{collapsed ? null : children}
</View>
);
}
@@ -0,0 +1,109 @@
import { ScrollView, View } from "react-native";
import {
useProjects,
useWorkspaces,
useAgents,
useAgent,
type AgentRef,
type WorkspaceRef,
type ProjectRef,
type PluginSidebarViewProps,
} from "@getpaseo/plugin/client";
import { AgentRow, ProjectHeading, WorkspaceRow } from "./rows";
const indent = { paddingLeft: 12 };
function AgentTree({ reference, view }: { reference: AgentRef; view: PluginSidebarViewProps }) {
const agent = useAgent(reference);
const children = useAgents({
where: (item) =>
item.parentAgent?.key === reference.key && item.workspace.key === agent?.workspace.key,
orderBy: "createdAt",
});
return (
<View>
<AgentRow reference={reference} view={view} />
<View style={indent}>
{children.map((child) => (
<AgentTree key={child.key} reference={child} view={view} />
))}
</View>
</View>
);
}
function RootWithParent({
reference,
parent,
workspace,
view,
}: {
reference: AgentRef;
parent: AgentRef;
workspace: WorkspaceRef;
view: PluginSidebarViewProps;
}) {
const parents = useAgents({
where: (item) => item.ref.key === parent.key && item.workspace.key === workspace.key,
});
return parents.length > 0 ? null : <AgentTree reference={reference} view={view} />;
}
function Root({
reference,
workspace,
view,
}: {
reference: AgentRef;
workspace: WorkspaceRef;
view: PluginSidebarViewProps;
}) {
const agent = useAgent(reference);
if (!agent) return null;
return agent.parentAgent ? (
<RootWithParent
reference={reference}
parent={agent.parentAgent}
workspace={workspace}
view={view}
/>
) : (
<AgentTree reference={reference} view={view} />
);
}
function Workspace({ reference, view }: { reference: WorkspaceRef; view: PluginSidebarViewProps }) {
const agents = useAgents({
where: (item) => item.workspace.key === reference.key,
orderBy: "createdAt",
});
return (
<View>
<WorkspaceRow reference={reference} view={view} />
<View style={indent}>
{agents.map((agent) => (
<Root key={agent.key} reference={agent} workspace={reference} view={view} />
))}
</View>
</View>
);
}
function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) {
const workspaces = useWorkspaces({
where: (item) => item.project.key === reference.key,
orderBy: "name",
});
return (
<View>
<ProjectHeading reference={reference} view={view} />
{workspaces.map((workspace) => (
<Workspace key={workspace.key} reference={workspace} view={view} />
))}
</View>
);
}
export function Nested(view: PluginSidebarViewProps) {
const projects = useProjects({ orderBy: "displayName" });
return (
<ScrollView>
{projects.map((reference) => (
<Project key={reference.key} reference={reference} view={view} />
))}
</ScrollView>
);
}
@@ -0,0 +1,139 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import {
useWorkspace,
useAgent,
useProject,
type WorkspaceRef,
type AgentRef,
type ProjectRef,
type PluginSidebarViewProps,
} from "@getpaseo/plugin/client";
export function Heading({ title, view }: { title: string; view: PluginSidebarViewProps }) {
const style = useMemo(
() => ({
color: view.theme.colors.foregroundMuted,
fontSize: 11,
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 4,
}),
[view.theme],
);
return (
<Text accessibilityRole="header" style={style}>
{title}
</Text>
);
}
export function WorkspaceRow({
reference,
view,
onLongPress,
}: {
reference: WorkspaceRef;
view: PluginSidebarViewProps;
onLongPress?: () => void;
}) {
const workspace = useWorkspace(reference);
const open = useCallback(
() => view.navigation.openWorkspace(reference),
[view.navigation, reference],
);
const style = useMemo(
() => ({ color: view.theme.colors.foreground, fontSize: 12, lineHeight: 16 }),
[view.theme],
);
const detail = useMemo(
() => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }),
[view.theme],
);
const background = useMemo(
() => ({
...rowStyle,
backgroundColor:
view.active.workspace?.key === reference.key ? view.theme.colors.surface2 : undefined,
}),
[view.active.workspace, view.theme, reference],
);
if (!workspace) return null;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Open ${workspace.title ?? workspace.name}`}
onPress={open}
onLongPress={onLongPress}
style={background}
>
<Text numberOfLines={1} style={style}>
{workspace.title ?? workspace.name}
</Text>
<Text numberOfLines={1} style={detail}>
{workspace.indicatorLabel}
</Text>
</Pressable>
);
}
export function AgentRow({
reference,
view,
}: {
reference: AgentRef;
view: PluginSidebarViewProps;
}) {
const agent = useAgent(reference);
const open = useCallback(
() => view.navigation.openAgent(reference),
[view.navigation, reference],
);
const style = useMemo(
() => ({ color: view.theme.colors.foreground, fontSize: 12 }),
[view.theme],
);
if (!agent) return null;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Open agent ${agent.title ?? agent.provider}`}
onPress={open}
style={rowStyle}
>
<Text numberOfLines={1} style={style}>
{agent.title ?? agent.provider}
</Text>
</Pressable>
);
}
export function ProjectHeading({
reference,
view,
}: {
reference: ProjectRef;
view: PluginSidebarViewProps;
}) {
const project = useProject(reference);
const open = useCallback(
() => view.navigation.openProject(reference),
[view.navigation, reference],
);
if (!project) return null;
return (
<View>
<Heading title={project.displayName} view={view} />
<Pressable
accessibilityRole="button"
accessibilityLabel={`New workspace in ${project.displayName}`}
onPress={open}
>
<Heading title="+ New workspace" view={view} />
</Pressable>
</View>
);
}
const rowStyle = {
minHeight: 28,
paddingHorizontal: 12,
paddingVertical: 4,
justifyContent: "center",
} as const;
@@ -0,0 +1,131 @@
import { Component, useCallback, useMemo, useState, type ReactNode } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import {
useHosts,
useTerminals,
useTerminal,
usePaseo,
type TerminalRef,
type PluginSidebarViewProps,
} from "@getpaseo/plugin/client";
import { Heading } from "./rows";
class TerminalError extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
return this.state.error ? <Text>{this.state.error.message}</Text> : this.props.children;
}
}
function TerminalRow({
reference,
view,
}: {
reference: TerminalRef;
view: PluginSidebarViewProps;
}) {
const terminal = useTerminal(reference);
const paseo = usePaseo();
const focus = useCallback(() => paseo.terminals.ref(reference).focus(), [paseo, reference]);
const close = useMutation({ mutationFn: () => paseo.terminals.ref(reference).kill() });
const kill = useCallback(() => close.mutate(), [close]);
const style = useMemo(
() => ({ color: view.theme.colors.foreground, fontSize: 12, padding: 12 }),
[view.theme],
);
if (!terminal) return null;
return (
<View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Focus terminal ${terminal.title}`}
testID={`sidebar-terminal-open-${reference.id}`}
onPress={focus}
>
<Text style={style}>
{terminal.title}
{terminal.isScript ? " (script)" : ""}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Kill terminal ${terminal.title}`}
testID={`sidebar-terminal-kill-${reference.id}`}
disabled={close.isPending}
onPress={kill}
>
<Text style={style}>Close terminal</Text>
</Pressable>
{close.error ? <Text style={style}>{close.error.message}</Text> : null}
</View>
);
}
function HostTerminals({ hostId, view }: { hostId: string; view: PluginSidebarViewProps }) {
const terminals = useTerminals({ hosts: [hostId], orderBy: "createdAt" });
return (
<View>
{terminals.length ? (
terminals.map((reference) => (
<TerminalRow key={reference.key} reference={reference} view={view} />
))
) : (
<Heading title="No terminals" view={view} />
)}
</View>
);
}
function HostButton({
hostId,
label,
select,
view,
}: {
hostId: string;
label: string;
select: (id: string) => void;
view: PluginSidebarViewProps;
}) {
const choose = useCallback(() => select(hostId), [select, hostId]);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Terminals on ${hostId}`}
onPress={choose}
>
<Heading title={label} view={view} />
</Pressable>
);
}
export function Terminals(view: PluginSidebarViewProps) {
const hosts = useHosts();
const [chosen, select] = useState<string | null>(null);
const selected = chosen;
return (
<ScrollView>
{hosts.map((host) => (
<HostButton
key={host.serverId}
hostId={host.serverId}
label={host.label}
select={select}
view={view}
/>
))}
{selected ? (
<TerminalError key={selected}>
<HostTerminals hostId={selected} view={view} />
</TerminalError>
) : (
<Heading
title={
hosts.length ? "Choose a host to list terminals" : "Connect a host to list terminals"
}
view={view}
/>
)}
</ScrollView>
);
}
@@ -0,0 +1,37 @@
import { ScrollView, View } from "react-native";
import {
useProjects,
useWorkspaces,
type ProjectRef,
type PluginSidebarViewProps,
} from "@getpaseo/plugin/client";
import { Heading, ProjectHeading, WorkspaceRow } from "./rows";
function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) {
const workspaces = useWorkspaces({
where: (item) => item.project.key === reference.key && !item.pinnedAt,
orderBy: "name",
});
return (
<View>
<ProjectHeading reference={reference} view={view} />
{workspaces.map((workspace) => (
<WorkspaceRow key={workspace.key} reference={workspace} view={view} />
))}
</View>
);
}
export function Workspaces(view: PluginSidebarViewProps) {
const projects = useProjects({ orderBy: "displayName" });
const pinned = useWorkspaces({ where: (item) => !!item.pinnedAt, orderBy: "name" });
return (
<ScrollView>
{pinned.length ? <Heading title="Pinned" view={view} /> : null}
{pinned.map((reference) => (
<WorkspaceRow key={reference.key} reference={reference} view={view} />
))}
{projects.map((reference) => (
<Project key={reference.key} reference={reference} view={view} />
))}
</ScrollView>
);
}
@@ -0,0 +1,18 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Workspaces } from "./client/workspaces";
import { Agents } from "./client/agents";
import { Nested } from "./client/nested";
import { Terminals } from "./client/terminals";
import { Labels } from "./client/labels";
export default function contribute(client: PluginClientContext) {
const cleanups = [
client.addSidebarView({ id: "terminals", title: "Example terminals", Component: Terminals }),
client.addSidebarView({ id: "workspaces", title: "Example workspaces", Component: Workspaces }),
client.addSidebarView({ id: "agents", title: "Example agents", Component: Agents }),
client.addSidebarView({ id: "nested", title: "Example nested agents", Component: Nested }),
client.addSidebarView({ id: "labels", title: "Example label folders", Component: Labels }),
];
return () => {
for (const cleanup of cleanups) void cleanup();
};
}
@@ -0,0 +1,4 @@
{
"id": "sidebar-views",
"requirements": { "paseo": ">=0.16.0" }
}

Some files were not shown because too many files have changed in this diff Show More