mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e5a0e739 | ||
|
|
62bd21214b | ||
|
|
dc309586f0 | ||
|
|
e4106b4e2d |
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,8 +41,11 @@ export function useReplicaQuery<
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input));
|
||||
>(
|
||||
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
return useQuery(replicaQueryOptions(input), queryClient);
|
||||
}
|
||||
|
||||
export function useFetchQuery<
|
||||
|
||||
@@ -1161,6 +1161,14 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "تعذر تحميل عرض الشريط الجانبي.",
|
||||
useDefault: "استخدام العرض الافتراضي",
|
||||
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
|
||||
agentUnavailable: "لم يعد الوكيل متاحًا",
|
||||
terminalUnavailable: "لم تعد الطرفية متاحة",
|
||||
projectUnavailable: "لم يعد المشروع متاحًا",
|
||||
},
|
||||
statusGroupAccessibility: "مجموعة {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "تحتاج إدخالاً",
|
||||
@@ -2581,6 +2589,12 @@ export const ar: TranslationResources = {
|
||||
description: "عرض مخطط للتنقل بين المطالبات",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "عرض الشريط الجانبي",
|
||||
label: "العرض",
|
||||
default: "العرض الافتراضي",
|
||||
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
|
||||
},
|
||||
header: {
|
||||
title: "الرأس",
|
||||
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
|
||||
@@ -1169,6 +1169,14 @@ export const en = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Sidebar view failed to load.",
|
||||
useDefault: "Use default view",
|
||||
readFailed: "Could not load your sidebar view preference.",
|
||||
agentUnavailable: "Agent is no longer available",
|
||||
terminalUnavailable: "Terminal is no longer available",
|
||||
projectUnavailable: "Project is no longer available",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} group",
|
||||
statusBucket: {
|
||||
needsInput: "Needs input",
|
||||
@@ -2672,6 +2680,12 @@ export const en = {
|
||||
description: "Show an outline for jumping between prompts",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Sidebar view",
|
||||
label: "View",
|
||||
default: "Default view",
|
||||
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
|
||||
},
|
||||
header: {
|
||||
title: "Header",
|
||||
description: "Choose which items appear at the top of the sidebar and in what order",
|
||||
|
||||
@@ -1197,6 +1197,14 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "No se pudo cargar la vista de la barra lateral.",
|
||||
useDefault: "Usar vista predeterminada",
|
||||
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
|
||||
agentUnavailable: "El agente ya no está disponible",
|
||||
terminalUnavailable: "La terminal ya no está disponible",
|
||||
projectUnavailable: "El proyecto ya no está disponible",
|
||||
},
|
||||
statusGroupAccessibility: "Grupo {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "Necesita datos",
|
||||
@@ -2601,6 +2609,13 @@ export const es: TranslationResources = {
|
||||
description: "Muestra un esquema para saltar entre instrucciones",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Vista de la barra lateral",
|
||||
label: "Vista",
|
||||
default: "Vista predeterminada",
|
||||
unavailable:
|
||||
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
|
||||
},
|
||||
header: {
|
||||
title: "Encabezado",
|
||||
description:
|
||||
|
||||
@@ -1190,6 +1190,14 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Impossible de charger la vue de la barre latérale.",
|
||||
useDefault: "Utiliser la vue par défaut",
|
||||
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
|
||||
agentUnavailable: "Cet agent n’est plus disponible",
|
||||
terminalUnavailable: "Ce terminal n’est plus disponible",
|
||||
projectUnavailable: "Ce projet n’est plus disponible",
|
||||
},
|
||||
statusGroupAccessibility: "Groupe {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "Attend une réponse",
|
||||
@@ -2602,6 +2610,13 @@ export const fr: TranslationResources = {
|
||||
description: "Afficher un plan pour naviguer entre les prompts",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Vue de la barre latérale",
|
||||
label: "Vue",
|
||||
default: "Vue par défaut",
|
||||
unavailable:
|
||||
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
|
||||
},
|
||||
header: {
|
||||
title: "En-tête",
|
||||
description:
|
||||
|
||||
@@ -1174,6 +1174,14 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "サイドバーのビューを読み込めませんでした。",
|
||||
useDefault: "デフォルトのビューを使用",
|
||||
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
|
||||
agentUnavailable: "このエージェントは利用できなくなりました",
|
||||
terminalUnavailable: "このターミナルは利用できなくなりました",
|
||||
projectUnavailable: "このプロジェクトは利用できなくなりました",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} グループ",
|
||||
statusBucket: {
|
||||
needsInput: "入力待ち",
|
||||
@@ -2568,6 +2576,13 @@ export const ja: TranslationResources = {
|
||||
description: "プロンプト間を移動するためのアウトラインを表示します",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "サイドバーのビュー",
|
||||
label: "ビュー",
|
||||
default: "デフォルトのビュー",
|
||||
unavailable:
|
||||
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
|
||||
},
|
||||
header: {
|
||||
title: "ヘッダー",
|
||||
description: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
|
||||
@@ -1168,6 +1168,14 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "사이드바 보기를 불러오지 못했습니다.",
|
||||
useDefault: "기본 보기 사용",
|
||||
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
|
||||
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
|
||||
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
|
||||
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} 그룹",
|
||||
statusBucket: {
|
||||
needsInput: "입력 필요",
|
||||
@@ -2559,6 +2567,13 @@ export const ko: TranslationResources = {
|
||||
description: "프롬프트 사이를 이동하기 위한 개요 표시",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "사이드바 보기",
|
||||
label: "보기",
|
||||
default: "기본 보기",
|
||||
unavailable:
|
||||
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
|
||||
},
|
||||
header: {
|
||||
title: "헤더",
|
||||
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
|
||||
@@ -1187,6 +1187,14 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Não foi possível carregar a visualização da barra lateral.",
|
||||
useDefault: "Usar visualização padrão",
|
||||
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
|
||||
agentUnavailable: "O agente não está mais disponível",
|
||||
terminalUnavailable: "O terminal não está mais disponível",
|
||||
projectUnavailable: "O projeto não está mais disponível",
|
||||
},
|
||||
statusGroupAccessibility: "Grupo {{label}}",
|
||||
statusBucket: {
|
||||
needsInput: "Precisa de resposta",
|
||||
@@ -2583,6 +2591,13 @@ export const ptBR: TranslationResources = {
|
||||
description: "Mostrar uma estrutura para navegar entre prompts",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Visualização da barra lateral",
|
||||
label: "Visualização",
|
||||
default: "Visualização padrão",
|
||||
unavailable:
|
||||
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
|
||||
},
|
||||
header: {
|
||||
title: "Cabeçalho",
|
||||
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
|
||||
|
||||
@@ -1178,6 +1178,14 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "Не удалось загрузить вид боковой панели.",
|
||||
useDefault: "Использовать стандартный вид",
|
||||
readFailed: "Не удалось загрузить настройку вида боковой панели.",
|
||||
agentUnavailable: "Агент больше недоступен",
|
||||
terminalUnavailable: "Терминал больше недоступен",
|
||||
projectUnavailable: "Проект больше недоступен",
|
||||
},
|
||||
statusGroupAccessibility: "Группа «{{label}}»",
|
||||
statusBucket: {
|
||||
needsInput: "Ожидает ввода",
|
||||
@@ -2589,6 +2597,12 @@ export const ru: TranslationResources = {
|
||||
description: "Показывать оглавление для перехода между запросами",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "Вид боковой панели",
|
||||
label: "Вид",
|
||||
default: "Стандартный вид",
|
||||
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
|
||||
},
|
||||
header: {
|
||||
title: "Верх",
|
||||
description:
|
||||
|
||||
@@ -1153,6 +1153,14 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
failed: "无法加载侧边栏视图。",
|
||||
useDefault: "使用默认视图",
|
||||
readFailed: "无法加载侧边栏视图偏好设置。",
|
||||
agentUnavailable: "此智能体已不可用",
|
||||
terminalUnavailable: "此终端已不可用",
|
||||
projectUnavailable: "此项目已不可用",
|
||||
},
|
||||
statusGroupAccessibility: "{{label}} 分组",
|
||||
statusBucket: {
|
||||
needsInput: "需要输入",
|
||||
@@ -2518,6 +2526,12 @@ export const zhCN: TranslationResources = {
|
||||
description: "显示用于在提示词之间跳转的大纲",
|
||||
},
|
||||
sidebar: {
|
||||
view: {
|
||||
title: "侧边栏视图",
|
||||
label: "视图",
|
||||
default: "默认视图",
|
||||
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
|
||||
},
|
||||
header: {
|
||||
title: "顶部",
|
||||
description: "选择侧边栏顶部显示的项目及其顺序",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -55,6 +55,29 @@ describe("evaluatePluginClientBundle", () => {
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
|
||||
it("registers sidebar views without selecting one and removes them on cleanup", async () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"sidebar",
|
||||
bundle(`
|
||||
plugin.addSidebarView({ id: "workspaces", title: "Workspaces", Component() { return null; } });
|
||||
`),
|
||||
);
|
||||
expect(plugin.sidebarViews.map((view) => view.id)).toEqual(["workspaces"]);
|
||||
await plugin.cleanup();
|
||||
expect(plugin.sidebarViews).toEqual([]);
|
||||
});
|
||||
it("rejects duplicate sidebar view ids", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"sidebar",
|
||||
bundle(`
|
||||
const view = { id: "workspaces", title: "Workspaces", Component() { return null; } };
|
||||
plugin.addSidebarView(view); plugin.addSidebarView(view);
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate sidebar view");
|
||||
});
|
||||
|
||||
it("accepts memoized settings screens", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"settings",
|
||||
|
||||
@@ -129,6 +129,7 @@ export function runPluginClientBundle(
|
||||
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
@@ -141,6 +142,7 @@ export function runPluginClientBundle(
|
||||
timelineRenderers: [],
|
||||
};
|
||||
const surfaceIds = new Set<string>();
|
||||
const sidebarViewIds = new Set<string>();
|
||||
const settingsScreenIds = new Set<string>();
|
||||
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
|
||||
header: new Set(),
|
||||
@@ -270,6 +272,18 @@ export function runPluginClientBundle(
|
||||
() => surfaceIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addSidebarView(contribution) {
|
||||
const normalizedId = requireId(contribution.id, "sidebar view id");
|
||||
if (sidebarViewIds.has(normalizedId))
|
||||
throw new Error(`Duplicate sidebar view: ${normalizedId}`);
|
||||
if (!isComponentType(contribution.Component))
|
||||
throw new Error(`Sidebar view ${normalizedId} is not a component`);
|
||||
const title = requirePluginText(contribution.title);
|
||||
sidebarViewIds.add(normalizedId);
|
||||
return register(collector.sidebarViews, { ...contribution, id: normalizedId, title }, () =>
|
||||
sidebarViewIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("header", contribution);
|
||||
},
|
||||
@@ -518,7 +532,8 @@ export function runPluginClientBundle(
|
||||
if (name === "@getpaseo/plugin/client")
|
||||
return {
|
||||
...pluginClientRuntime,
|
||||
useSettings,
|
||||
useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) =>
|
||||
useSettings(definition, id),
|
||||
openExternalUrl,
|
||||
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
||||
useHosts: () =>
|
||||
@@ -583,6 +598,7 @@ export function runPluginClientBundle(
|
||||
cleanup,
|
||||
surfaces: collector.surfaces,
|
||||
settingsScreens: collector.settingsScreens,
|
||||
sidebarViews: collector.sidebarViews,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
filePanels: collector.filePanels,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
}));
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -6506,10 +6506,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",
|
||||
|
||||
@@ -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";
|
||||
@@ -189,6 +190,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;
|
||||
@@ -866,6 +869,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) => {
|
||||
@@ -1212,3 +1223,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 };
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] }]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,6 +3,9 @@ export type {
|
||||
PluginTheme,
|
||||
PluginWorkspaceSnapshot,
|
||||
PluginAgentSnapshot,
|
||||
PluginProjectSnapshot,
|
||||
PluginTerminalSnapshot,
|
||||
WorkspaceIndicator,
|
||||
PluginThemeColors,
|
||||
PluginThemeContribution,
|
||||
PluginAttachmentSourceContribution,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 }] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3059,13 +3059,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(),
|
||||
});
|
||||
|
||||
@@ -3732,6 +3732,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.
|
||||
@@ -6591,6 +6592,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(),
|
||||
@@ -6654,8 +6657,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);
|
||||
|
||||
@@ -1172,6 +1172,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({
|
||||
|
||||
@@ -2033,6 +2033,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 {
|
||||
|
||||
@@ -387,8 +387,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,
|
||||
@@ -500,8 +517,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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -571,6 +602,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
cwd: rootCwd,
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
requestId: "req-root",
|
||||
@@ -587,6 +620,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
|
||||
cwd: worktreeCwd,
|
||||
workspaceId: "ws-test",
|
||||
activity: null,
|
||||
createdAt: null,
|
||||
isScript: false,
|
||||
},
|
||||
],
|
||||
requestId: "req-worktree",
|
||||
@@ -672,7 +707,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1646,19 +1646,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,
|
||||
);
|
||||
|
||||
@@ -268,6 +268,9 @@ export function createWorkerTerminalManager(
|
||||
};
|
||||
|
||||
const session: TerminalSession = {
|
||||
get createdAt() {
|
||||
return record.state.createdAt;
|
||||
},
|
||||
get id() {
|
||||
return record.info.id;
|
||||
},
|
||||
@@ -373,6 +376,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
Reference in New Issue
Block a user