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"> {}