From e4106b4e2d27d7f28b82e40b54debbf8d6a4260c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 03:39:40 +0100 Subject: [PATCH] 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. --- .../plugins/client-state/connected-hosts.ts | 72 ++++ .../app/src/plugins/client-state/fixtures.ts | 53 +++ packages/app/src/plugins/client-state/refs.ts | 3 + .../plugins/client-state/snapshots.test.ts | 151 +++++---- .../app/src/plugins/client-state/snapshots.ts | 95 +++++- .../client-state/source-retention.test.ts | 55 +++ .../client-state/source.browser.test.tsx | 320 ++++++++++++++++++ .../app/src/plugins/client-state/source.ts | 233 ++++++++++++- .../command-center/contributions.test.ts | 26 ++ .../app/src/plugins/timeline/view.test.tsx | 3 +- .../screens/settings/plugins-page.test.tsx | 3 +- packages/plugin/src/boundaries.test.ts | 26 ++ packages/plugin/src/client/client-state.tsx | 134 ++++++-- packages/plugin/src/client/host.ts | 9 +- packages/plugin/src/client/index.ts | 14 +- packages/plugin/src/client/query.test.ts | 58 ++++ packages/plugin/src/client/query.ts | 115 +++++++ packages/plugin/src/client/refs.test.ts | 24 ++ packages/plugin/src/client/refs.ts | 26 ++ packages/plugin/src/contracts.ts | 39 +++ packages/plugin/src/index.ts | 3 + packages/plugin/src/refs.ts | 11 + 22 files changed, 1353 insertions(+), 120 deletions(-) create mode 100644 packages/app/src/plugins/client-state/connected-hosts.ts create mode 100644 packages/app/src/plugins/client-state/fixtures.ts create mode 100644 packages/app/src/plugins/client-state/refs.ts create mode 100644 packages/app/src/plugins/client-state/source-retention.test.ts create mode 100644 packages/app/src/plugins/client-state/source.browser.test.tsx create mode 100644 packages/plugin/src/client/query.test.ts create mode 100644 packages/plugin/src/client/query.ts create mode 100644 packages/plugin/src/client/refs.test.ts create mode 100644 packages/plugin/src/client/refs.ts create mode 100644 packages/plugin/src/refs.ts diff --git a/packages/app/src/plugins/client-state/connected-hosts.ts b/packages/app/src/plugins/client-state/connected-hosts.ts new file mode 100644 index 0000000000..4dfffa6b8d --- /dev/null +++ b/packages/app/src/plugins/client-state/connected-hosts.ts @@ -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 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); diff --git a/packages/app/src/plugins/client-state/fixtures.ts b/packages/app/src/plugins/client-state/fixtures.ts new file mode 100644 index 0000000000..4439e385f4 --- /dev/null +++ b/packages/app/src/plugins/client-state/fixtures.ts @@ -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" }, +}; diff --git a/packages/app/src/plugins/client-state/refs.ts b/packages/app/src/plugins/client-state/refs.ts new file mode 100644 index 0000000000..edfdd9562c --- /dev/null +++ b/packages/app/src/plugins/client-state/refs.ts @@ -0,0 +1,3 @@ +import { createEntityRefs } from "@getpaseo/plugin/client/host"; + +export const entityRefs = createEntityRefs(); diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 0c90185e53..7d63b178ed 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -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", + }); +}); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index 67a08830cd..ac810cf8f0 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -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(); +import { entityRefs } from "./refs"; +import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; + +interface CachedWorkspace { + language: string; + snapshot: PluginWorkspaceSnapshot; +} +const workspaceSnapshots = new WeakMap>(); const agentSnapshots = new WeakMap>(); export function createPluginWorkspaceSnapshot( workspace: WorkspaceDescriptor, + hostId: string, + context: Omit[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(); + 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>(); + +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(); + byHost.set(hostId, snapshot); + projectSnapshots.set(project, byHost); + return snapshot; +} + +function serializeTimestamp(value: Date | null): string | null { + return value ? value.toISOString() : null; +} diff --git a/packages/app/src/plugins/client-state/source-retention.test.ts b/packages/app/src/plugins/client-state/source-retention.test.ts new file mode 100644 index 0000000000..b11fc5d731 --- /dev/null +++ b/packages/app/src/plugins/client-state/source-retention.test.ts @@ -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(); +}); diff --git a/packages/app/src/plugins/client-state/source.browser.test.tsx b/packages/app/src/plugins/client-state/source.browser.test.tsx new file mode 100644 index 0000000000..5a0b3f3811 --- /dev/null +++ b/packages/app/src/plugins/client-state/source.browser.test.tsx @@ -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(); +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 ( +
+ {item?.name}:{item?.indicator} +
+ ); +} +function Workspaces() { + count("workspaces"); + const items = useWorkspaces({ orderBy: "name" }); + return ( +
+ {items.map((reference) => ( + + ))} +
+ ); +} +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 ( +
+ {name?.name}:{custom?.name} +
+ ); +} +function AgentRow({ reference }: { reference: AgentRef }) { + const item = useAgent(reference); + return
{item?.title}
; +} +function AgentSection({ section }: { section: string }) { + const agents = useAgents({ + where: (item) => (item.requiresAttention ? "waiting" : item.status) === section, + }); + count(section); + return ( +
+ {agents.map((reference) => ( + + ))} +
+ ); +} +function Agents() { + return ( +
+ + + +
+ ); +} +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([ + [ + "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({children}), + ); + 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(); + 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(); + 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(); + 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(); + 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(, 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); + }); +}); diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index 142c1f3575..03a2a47296 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -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["pendingByDraftId"]; + snapshot: Omit; + workspaceById: Map; + agentById: Map; + projectById: Map; +} +const hostProjections = new Map(); + +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(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, }; } diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index e8aea62ede..b2fd0891c9 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -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; }, diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index ff3ba9fd6b..7d96747279 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -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()), useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 8a502749db..dfb16ccfa7 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -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()), useHostRuntimeClient: () => runtime.client, useHostRuntimeIsConnected: () => runtime.connected, })); diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index c28bbc8d68..263a791dd6 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -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[] = 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 = { hosts: [workspace.hostId], orderBy: ["name", "desc"] }; + return null; + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/client-state.tsx b/packages/plugin/src/client/client-state.tsx index b1c853ecec..ee8a36e10e 100644 --- a/packages/plugin/src/client/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -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(null); @@ -15,44 +32,89 @@ export function usePluginClientStateSource(): PluginClientStateSource | null { return useContext(PluginClientStateContext); } -function usePluginEntity(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( + 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( - workspaceId: string, - selector: (workspace: PluginWorkspaceSnapshot) => Selection, -): Selection | null { - return usePluginEntity({ - id: workspaceId, - read: (source, id) => source.getWorkspace(id), - selector, - }); +type Equality = { equals(left: T, right: T): boolean }["equals"]; + +function createEntityHook( + read: (source: PluginClientStateSource, ref: Ref) => Entity | null, +) { + function useEntity( + ref: Ref, + selector?: undefined, + equals?: Equality, + ): Entity | null; + function useEntity( + ref: Ref, + selector: (entity: Entity) => Selection, + equals?: Equality, + ): Selection | null; + function useEntity( + ref: Ref, + selector?: (entity: Entity) => Selection, + equals?: Equality, + ): 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( - 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( + read: (source: PluginClientStateSource) => readonly Snapshot[], +) { + function useCollection( + query: Query & { groupBy: NonNullable["groupBy"]> }, + ): readonly Group[]; + function useCollection( + query?: Query & { groupBy?: undefined }, + ): readonly Snapshot["ref"][]; + function useCollection( + query: Query, + ): readonly Snapshot["ref"][] | readonly Group[]; + function useCollection( + query: Query = {}, + ): readonly Snapshot["ref"][] | readonly Group[] { + const source = usePluginClientStateSource(); + if (!source) throw new Error("Plugin state hooks must run inside a plugin host"); + const project = useMemo(() => createCollectionQuery(), []); + 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, diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index 2ec1f57b2e..724cfab437 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -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"; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index ffaf0782d5..9d7c52ddcc 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -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"; diff --git a/packages/plugin/src/client/query.test.ts b/packages/plugin/src/client/query.test.ts new file mode 100644 index 0000000000..ce0dd9bd3c --- /dev/null +++ b/packages/plugin/src/client/query.test.ts @@ -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] }]); + }); +}); diff --git a/packages/plugin/src/client/query.ts b/packages/plugin/src/client/query.ts new file mode 100644 index 0000000000..8e291602ca --- /dev/null +++ b/packages/plugin/src/client/query.ts @@ -0,0 +1,115 @@ +interface GroupDescriptor { + readonly key: string; + readonly title?: string; + readonly color?: string; +} + +export type GroupKey = string | GroupDescriptor; + +export interface Query { + 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 { + 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(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() { + type Ref = Snapshot["ref"]; + let previousItems: readonly Ref[] = Object.freeze([]); + let previousGroups: readonly Group[] = Object.freeze([]); + + return function project( + snapshots: readonly Snapshot[], + query: Query = {}, + ): readonly Ref[] | readonly Group[] { + 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(); + 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; + }; +} diff --git a/packages/plugin/src/client/refs.test.ts b/packages/plugin/src/client/refs.test.ts new file mode 100644 index 0000000000..93407414fc --- /dev/null +++ b/packages/plugin/src/client/refs.test.ts @@ -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"), + ); + }); +}); diff --git a/packages/plugin/src/client/refs.ts b/packages/plugin/src/client/refs.ts new file mode 100644 index 0000000000..0dbdd69428 --- /dev/null +++ b/packages/plugin/src/client/refs.ts @@ -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: 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, + }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 7e71cb22c5..17c14a7edf 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -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; + +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; +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 839b5003df..8680ae949f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -3,6 +3,9 @@ export type { PluginTheme, PluginWorkspaceSnapshot, PluginAgentSnapshot, + PluginProjectSnapshot, + PluginTerminalSnapshot, + WorkspaceIndicator, PluginThemeColors, PluginThemeContribution, PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/refs.ts b/packages/plugin/src/refs.ts new file mode 100644 index 0000000000..e0293bfe09 --- /dev/null +++ b/packages/plugin/src/refs.ts @@ -0,0 +1,11 @@ +interface EntityRef { + 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"> {}