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

Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.
This commit is contained in:
Mohamed Boudra
2026-11-06 09:22:19 +01:00
parent 6355b339c8
commit e4106b4e2d
22 changed files with 1353 additions and 120 deletions
+26
View File
@@ -388,3 +388,29 @@ 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, 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();
if (projects[0]) useProject(projects[0], item => item.displayName);
if (terminals[0]) useTerminal(terminals[0]);
const query: Query<PluginWorkspaceSnapshot> = { hosts: [workspace.hostId], orderBy: ["name", "desc"] };
return null;
}
`),
).toEqual([]);
});
});
+98 -36
View File
@@ -1,12 +1,29 @@
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;
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[];
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
@@ -15,44 +32,89 @@ 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 {
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,
);
/** 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);
}
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
type Equality<T> = { equals(left: T, right: T): boolean }["equals"];
function createEntityHook<Ref, Entity>(
read: (source: PluginClientStateSource, ref: Ref) => Entity | null,
) {
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 = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
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));
function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly hostId: string }>(
read: (source: PluginClientStateSource) => readonly Snapshot[],
) {
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 = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
const project = useMemo(() => createCollectionQuery<Snapshot>(), []);
return usePaseoSelector(
source,
() => read(source),
(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((source) => source.getSnapshot().terminals);
export function PluginClientStateProvider({
children,
source,
+8 -1
View File
@@ -1,6 +1,11 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
PluginClientStateProvider,
usePaseoSelector,
type PluginClientStateSource,
type PluginClientSnapshot,
} from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
@@ -31,3 +36,5 @@ export {
isPluginMessage,
} from "./translations.js";
export type { PluginLocaleSource } from "./translations.js";
export { createEntityRefs } from "./refs.js";
+13 -1
View File
@@ -70,7 +70,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 +111,6 @@ 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";
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { createCollectionQuery, type GroupKey } from "./query.js";
const first = Object.freeze({ key: "a" });
const second = Object.freeze({ key: "b" });
const work: GroupKey = "work";
const snapshots = [
{ ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] },
{ ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] },
];
describe("collection query structural sharing", () => {
it("preserves collection identity when a row changes without changing membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const before = project(snapshots);
const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]);
expect(after).toBe(before);
expect(after).toEqual([first, second]);
});
it("shares unaffected groups and deduplicates membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels };
const before = project(snapshots, query);
const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query);
expect(after[1]).toBe(before[1]);
expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] });
expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] });
expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after);
});
it("filters hosts and predicates before sorting", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
expect(project(snapshots, { hosts: ["two"] })).toEqual([second]);
expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]);
expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([
first,
second,
]);
});
it("keeps nulls last in either direction and metadata from the first group", () => {
const project = createCollectionQuery<{
ref: typeof first;
hostId: string;
title: string | null;
}>();
const items = [
{ ref: first, hostId: "one", title: null },
{ ref: second, hostId: "one", title: "Z" },
];
expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(
project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }),
).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]);
});
});
+115
View File
@@ -0,0 +1,115 @@
interface GroupDescriptor {
readonly key: string;
readonly title?: string;
readonly color?: string;
}
export type GroupKey = string | GroupDescriptor;
export interface Query<Snapshot> {
readonly hosts?: readonly string[];
readonly where?: (item: Snapshot) => boolean;
readonly orderBy?:
| keyof Snapshot
| readonly [keyof Snapshot, "asc" | "desc"]
| ((a: Snapshot, b: Snapshot) => number);
readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[];
}
export interface Group<Ref> {
readonly key: string;
readonly title: string | null;
readonly color: string | null;
readonly items: readonly Ref[];
}
function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] {
if (typeof value === "string") return [value];
if ("key" in value) return [value];
return value;
}
interface Referenced {
readonly hostId: string;
readonly ref: unknown;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
function compareValues(left: unknown, right: unknown, direction: number): number {
if (Object.is(left, right)) return 0;
if (left === null || left === undefined) return 1;
if (right === null || right === undefined) return -1;
if (typeof left === "number" && typeof right === "number") return (left - right) * direction;
return String(left).localeCompare(String(right)) * direction;
}
/** A hook owns one projection; sharing survives both source and query changes. */
export function createCollectionQuery<Snapshot extends Referenced>() {
type Ref = Snapshot["ref"];
let previousItems: readonly Ref[] = Object.freeze([]);
let previousGroups: readonly Group<Ref>[] = Object.freeze([]);
return function project(
snapshots: readonly Snapshot[],
query: Query<Snapshot> = {},
): readonly Ref[] | readonly Group<Ref>[] {
const items = snapshots.filter((item) => {
if (query.hosts && !query.hosts.includes(item.hostId)) return false;
return !query.where || query.where(item);
});
const order = query.orderBy;
if (typeof order === "function") items.sort(order);
else if (order !== undefined) {
const field = typeof order === "object" ? order[0] : order;
const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1;
items.sort((a, b) => compareValues(a[field], b[field], direction));
}
if (!query.groupBy) {
const refs = items.map((item) => item.ref);
if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs);
return previousItems;
}
const groups = new Map<
string,
{ key: string; title: string | null; color: string | null; items: Ref[] }
>();
for (const item of items) {
const grouping = query.groupBy(item);
const keys = groupKeys(grouping);
const seen = new Set<string>();
for (const value of keys) {
const descriptor = typeof value === "string" ? { key: value } : value;
if (seen.has(descriptor.key)) continue;
seen.add(descriptor.key);
let group = groups.get(descriptor.key);
if (!group) {
group = {
key: descriptor.key,
title: descriptor.title ?? null,
color: descriptor.color ?? null,
items: [],
};
groups.set(descriptor.key, group);
}
group.items.push(item.ref);
}
}
const oldGroups = new Map(previousGroups.map((group) => [group.key, group]));
const nextGroups = Array.from(groups.values(), (group) => {
const previous = oldGroups.get(group.key);
if (
previous &&
previous.title === group.title &&
previous.color === group.color &&
sameSequence(previous.items, group.items)
)
return previous;
return Object.freeze({ ...group, items: Object.freeze(group.items) });
});
if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups);
return previousGroups;
};
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createEntityRefs } from "./refs.js";
describe("session entity references", () => {
it("interns frozen references separately by kind, host and entity", () => {
const refs = createEntityRefs();
for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) {
const first = reference("host", "entity");
expect(reference("host", "entity")).toBe(first);
expect(Object.isFrozen(first)).toBe(true);
expect(reference("other", "entity")).not.toBe(first);
expect(reference("host", "other")).not.toBe(first);
}
expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity"));
});
it("keeps opaque ids unambiguous and interning scoped to one app session", () => {
const refs = createEntityRefs();
expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key);
expect(createEntityRefs().workspace("host", "entity")).not.toBe(
refs.workspace("host", "entity"),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
/** Owned by the app session, shared by every plugin installation. */
export function createEntityRefs() {
function intern<Kind extends string>(kind: Kind) {
const references = new Map<
string,
Readonly<{ kind: Kind; key: string; hostId: string; id: string }>
>();
return function reference(hostId: string, id: string) {
const key = JSON.stringify([hostId, id]);
let ref = references.get(key);
if (!ref) {
ref = Object.freeze({ kind, key, hostId, id });
references.set(key, ref);
}
return ref;
};
}
return {
workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef,
agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef,
project: intern("project") satisfies (hostId: string, id: string) => ProjectRef,
terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef,
};
}
+39
View File
@@ -1,3 +1,4 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js";
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
@@ -19,7 +20,22 @@ export interface PluginTheme {
};
}
export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done";
export interface PluginWorkspaceSnapshot {
readonly ref: WorkspaceRef;
readonly hostId: string;
readonly project: ProjectRef;
readonly pinnedAt: string | null;
readonly labels: readonly string[];
readonly background: boolean;
readonly branch: string | null;
/** Null when an older host has not supplied the workspace creation time. */
readonly createdAt: string | null;
readonly lastPromptAt: string | null;
readonly parentAgent: AgentRef | null;
readonly indicator: WorkspaceIndicator;
readonly indicatorLabel: string | null;
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
@@ -29,6 +45,7 @@ export interface PluginWorkspaceSnapshot {
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
/** Raw daemon status; prefer indicator for presentation. */
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
@@ -36,6 +53,10 @@ export interface PluginWorkspaceSnapshot {
}
export interface PluginAgentSnapshot {
readonly ref: AgentRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly parentAgent: AgentRef | null;
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
@@ -96,3 +117,21 @@ 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;
readonly createdAt: string;
}
+3
View File
@@ -3,6 +3,9 @@ export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
WorkspaceIndicator,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
+11
View File
@@ -0,0 +1,11 @@
interface EntityRef<Kind extends string> {
readonly kind: Kind;
readonly key: string;
readonly hostId: string;
readonly id: string;
}
export interface WorkspaceRef extends EntityRef<"workspace"> {}
export interface AgentRef extends EntityRef<"agent"> {}
export interface ProjectRef extends EntityRef<"project"> {}
export interface TerminalRef extends EntityRef<"terminal"> {}