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
@@ -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,320 @@
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 { 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();
});
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);
});
});
+219 -14
View File
@@ -1,21 +1,226 @@
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 { entityRefs } from "./refs";
import {
createPluginAgentSnapshot,
createPluginProjectSnapshot,
createPluginWorkspaceSnapshot,
} from "./snapshots";
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
export interface PluginConnectedHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly string[];
}
export interface PluginTerminalsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly PluginTerminalSnapshot[];
}
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
const noTerminals: PluginTerminalsSource = {
subscribe: () => () => {},
getSnapshot: () => emptyTerminals,
};
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),
},
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 ?? noTerminals;
const hosts = options.hosts ?? connectedPluginHosts;
const subscribers = new Set<() => void>();
let release: (() => void) | null = null;
let previousProjections: readonly HostProjection[] = [];
let previousTerminals = emptyTerminals;
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();
if (sameSequence(previousProjections, projections) && previousTerminals === terminalSnapshots)
return snapshot;
previousProjections = projections;
previousTerminals = terminalSnapshots;
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,
});
return snapshot;
}
return {
subscribe: useSessionStore.subscribe,
getWorkspace(workspaceId) {
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
subscribe(listener) {
subscribers.add(listener);
if (!release) {
const notify = () => {
for (const subscriber of subscribers) subscriber();
};
const releaseStore = subscribeStores(notify);
const releaseHosts = hosts.subscribe(notify);
const releaseTerminals = terminals.subscribe(notify);
release = () => {
releaseStore();
releaseHosts();
releaseTerminals();
};
}
return () => {
subscribers.delete(listener);
if (subscribers.size === 0 && release) {
release();
release = null;
previousProjections = [];
previousTerminals = emptyTerminals;
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(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;
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) => terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null,
};
}
@@ -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",
@@ -131,6 +148,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;
},
@@ -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" }],
}));
@@ -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,
}));
+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"> {}