Let plugins add contextual workspace tools (#3465)

* feat(plugins): add workspace panels and command center contributions

Give local plugins contextual UI entry points without making the daemon interpret client placement or callbacks. Panels receive stable IDs and read cached client state through required selectors with shallow equality, avoiding unrelated guest rerenders and context-discovery RPCs.

* fix(plugins): align shared client state ownership

Workspace panels and Command Center callbacks consume the same frozen client snapshots, so their adapter belongs to the plugin boundary rather than either contribution surface. Refresh the Nix dependency hash for the selector-store dependency added by this feature.
This commit is contained in:
Mohamed Boudra
2026-08-17 19:26:40 +02:00
committed by GitHub
parent 1e2089cef7
commit 6d7606160e
64 changed files with 2557 additions and 122 deletions
+3
View File
@@ -27,6 +27,9 @@
"typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.4.0",
"@types/node": "^20.9.0",
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useContext, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
subscribe(listener: () => void): () => void;
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
getAgent(id: string): PluginAgentSnapshot | null;
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
const source = useContext(PluginClientStateContext);
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,
);
}
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
}
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: agentId,
read: (source, id) => source.getAgent(id),
selector,
});
}
export function PluginClientStateProvider({
children,
source,
}: {
children: ReactNode;
source: PluginClientStateSource;
}) {
return (
<PluginClientStateContext.Provider value={source}>{children}</PluginClientStateContext.Provider>
);
}
+114 -1
View File
@@ -3,7 +3,7 @@ import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "./rpc.js";
export interface PluginSurfaceProps {
export interface PluginHostProps {
theme: Record<string, unknown>;
host: {
id: string;
@@ -15,6 +15,70 @@ export interface PluginSurfaceProps {
};
}
export interface PluginSurfaceProps extends PluginHostProps {}
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
readonly projectRootPath: string;
readonly directory: string;
readonly projectKind: "git" | "non_git" | "directory";
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
readonly diffStat: { readonly additions: number; readonly deletions: number } | null;
}
export interface PluginAgentSnapshot {
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
readonly createdAt: string;
readonly updatedAt: string;
readonly lastActivityAt: string;
readonly title: string | null;
readonly cwd: string;
readonly model: string | null;
readonly currentModeId: string | null;
readonly thinkingOptionId: string | null;
readonly requiresAttention: boolean;
readonly attentionReason: "finished" | "error" | "permission" | null;
readonly parentAgentId: string | null;
readonly labels: Readonly<Record<string, string>>;
}
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
}
export interface PluginWorkspacePanelProps extends PluginHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
@@ -36,6 +100,53 @@ export interface PluginAttachmentSourceContribution {
search: PluginRpcContract;
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
context: "global";
}
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
context: "workspace";
workspace: PluginWorkspaceSnapshot;
openPanel(id: string): void;
}
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
context: "agent";
workspace: PluginWorkspaceSnapshot;
agent: PluginAgentSnapshot;
openPanel(id: string): void;
}
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
export interface PluginHandlerContext {
paseo: PaseoApi;
}
@@ -50,6 +161,8 @@ export interface PluginContext {
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
+21 -1
View File
@@ -1,10 +1,13 @@
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginContext,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginWorkspacePanelContribution,
} from "./contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
@@ -14,18 +17,29 @@ import type { ComponentType } from "react";
interface PluginCollector {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export interface PluginRegistrationCollector {
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
export function createPluginContext(
collector: PluginCollector,
): Pick<PluginContext, "addSurface" | "addSidebarItem" | "addAttachmentSource"> {
): Pick<
PluginContext,
| "addSurface"
| "addSidebarItem"
| "addWorkspacePanel"
| "addCommandCenterItem"
| "addAttachmentSource"
> {
return {
addSurface(id, Component) {
collector.addSurface(id, Component);
@@ -33,6 +47,12 @@ export function createPluginContext(
addSidebarItem(contribution) {
collector.addSidebarItem(contribution);
},
addWorkspacePanel(contribution) {
collector.addWorkspacePanel(contribution);
},
addCommandCenterItem(contribution) {
collector.addCommandCenterItem(contribution);
},
addAttachmentSource(contribution) {
collector.addAttachmentSource(contribution);
},
+12
View File
@@ -6,15 +6,27 @@ export {
} from "./attachments.js";
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
PluginAgentPanelProps,
PluginAgentSnapshot,
PluginCleanup,
PluginCommandCapabilities,
PluginCommandCenterItemContribution,
PluginContribution,
PluginContext,
PluginGlobalCommandContext,
PluginHandlerContext,
PluginHostProps,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginWorkspaceCommandContext,
PluginWorkspacePanelContribution,
PluginWorkspacePanelProps,
PluginWorkspaceSnapshot,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export { useRpc } from "./rpc-context.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { shallowEqual } from "./shallow.js";
describe("plugin selector shallow equality", () => {
it("keeps equivalent selector objects and arrays stable", () => {
const shared = { id: "shared" };
expect(shallowEqual({ name: "Paseo", shared }, { name: "Paseo", shared })).toBe(true);
expect(shallowEqual(["Paseo", shared], ["Paseo", shared])).toBe(true);
expect(shallowEqual({ name: "Paseo" }, { name: "Changed" })).toBe(false);
});
it("compares map and set selections by their shallow entries", () => {
expect(shallowEqual(new Map([["status", "running"]]), new Map([["status", "running"]]))).toBe(
true,
);
expect(shallowEqual(new Set(["workspace", "agent"]), new Set(["workspace", "agent"]))).toBe(
true,
);
expect(shallowEqual(new Set(["workspace"]), new Set(["agent"]))).toBe(false);
});
});
+47
View File
@@ -0,0 +1,47 @@
function isIterable(value: object): value is Iterable<unknown> {
return Symbol.iterator in value;
}
function hasEntries(value: object): value is { entries(): Iterable<[unknown, unknown]> } {
return "entries" in value;
}
function compareEntries(
left: { entries(): Iterable<[unknown, unknown]> },
right: { entries(): Iterable<[unknown, unknown]> },
): boolean {
const leftEntries = left instanceof Map ? left : new Map(left.entries());
const rightEntries = right instanceof Map ? right : new Map(right.entries());
if (leftEntries.size !== rightEntries.size) return false;
for (const [key, value] of leftEntries) {
if (!rightEntries.has(key) || !Object.is(value, rightEntries.get(key))) return false;
}
return true;
}
function compareIterables(left: Iterable<unknown>, right: Iterable<unknown>): boolean {
const leftIterator = left[Symbol.iterator]();
const rightIterator = right[Symbol.iterator]();
let leftValue = leftIterator.next();
let rightValue = rightIterator.next();
while (!leftValue.done && !rightValue.done) {
if (!Object.is(leftValue.value, rightValue.value)) return false;
leftValue = leftIterator.next();
rightValue = rightIterator.next();
}
return Boolean(leftValue.done && rightValue.done);
}
export function shallowEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
if (isIterable(left) && isIterable(right)) {
if (hasEntries(left) && hasEntries(right)) return compareEntries(left, right);
return compareIterables(left, right);
}
return compareEntries(
{ entries: () => Object.entries(left) },
{ entries: () => Object.entries(right) },
);
}
+9
View File
@@ -0,0 +1,9 @@
declare module "use-sync-external-store/shim/with-selector" {
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: () => Snapshot,
selector: (snapshot: Snapshot) => Selection,
isEqual?: (left: Selection, right: Selection) => boolean,
): Selection;
}