diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 3ef72556e2..65bb1e0eab 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -29,7 +29,9 @@ principal. An ordinary direct B consumer does not become that producer by supply The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal. Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. -A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). +A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). The first child snapshot carries the owner's ledger projection even while creation is pending, because tab placement runs before creation finishes. Explicit clearing survives list and disk-cache round trips; a parent pointer or label cannot recreate membership on a capable daemon. + +Retained app conversations bind their stores, queries, initialization and layout to origin authority as well as destination. Two routes to B therefore keep separate cached evidence and command capabilities. Provider catalog pushes belong to that same destination binding, including facts shown in the parent's track. Route restore remembers an origin ID, then reacquires current authority; persisted namespaces grant no access. Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime. diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 9b80222432..f9a4388879 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -854,7 +854,8 @@ async function revealCompactChanges(page: Page) { await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible(); } async function expectReviewEditorBody(page: Page, body: string) { - await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue( - body, - ); + const editor = page.getByRole("textbox", { name: "Review comment", exact: true }); + // A viewport resize starts the presentation handoff; exactly one editor must remain accessible. + await expect(editor).toHaveCount(1); + await expect(editor).toHaveValue(body); } diff --git a/packages/app/e2e/browser/subagent-origin-pane.spec.ts b/packages/app/e2e/browser/subagent-origin-pane.spec.ts index 47a0120da6..47b80270fe 100644 --- a/packages/app/e2e/browser/subagent-origin-pane.spec.ts +++ b/packages/app/e2e/browser/subagent-origin-pane.spec.ts @@ -1,3 +1,13 @@ +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { submitMessage } from "../support/helpers/composer"; +import { openSessions, clickSessionRow } from "../support/helpers/archive-tab"; +import { + seedFleetSubagents, + connectFleetDirectHost, + openFleetParentTrack, + readFleetAuthorityState, + expectFleetRouteUnavailable, +} from "../support/helpers/subagents"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; @@ -147,3 +157,104 @@ test("a preferred chat file link still opens beside its right-pane agent", async await workspace.cleanup(); } }); + +test("a fleet child retains its origin beside direct access through collision, catalog push, reload and revocation", async ({ + page, +}) => { + test.setTimeout(180_000); + const fleet = await seedFleetSubagents(); + try { + const a = fleet.parent.serverId; + const b = fleet.destination.serverId; + await connectFleetDirectHost(page, fleet); + await openFleetParentTrack(page); + const rows = page.getByTestId(`subagents-track-row-${fleet.remote.id}`); + await expect(rows).toHaveCount(2); + const remote = rows.filter({ hasText: "Fleet child on B" }); + await expect(remote).toBeVisible(); + await expect(remote.getByTestId(`subagents-track-row-facts-${fleet.remote.id}`)).toHaveText( + "Ten second stream · Low · Load test", + ); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status) + .toBe("online"); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion) + .toBeGreaterThan(0); + const beforePush = await readFleetAuthorityState(page, a, b); + await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath }); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion) + .toBeGreaterThan(beforePush.routed.catalogVersion); + await remote.click(); + await expect(page).toHaveURL( + (url) => url.pathname.includes(`/h/${b}/workspace/`) && url.searchParams.get("origin") === a, + ); + await submitMessage(page, "ROUTED_FLEET_PROMPT"); + await expect + .poll(async () => (await fleet.remote.refresh())?.agent.lastUserMessageAt) + .not.toBeNull(); + expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull(); + const routedUrl = page.url(); + await page.reload(); + await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await fleet.remote.waitForFinish(); + // Reopen direct B through ordinary history, then retain it beside the routed conversation. + await openSessions(page); + await clickSessionRow(page, "Fleet child on B"); + await expect(page).toHaveURL( + (url) => url.pathname.includes(`/h/${b}/workspace/`) && !url.searchParams.has("origin"), + ); + await openFleetParentTrack(page); + await page + .getByTestId(`subagents-track-row-${fleet.remote.id}`) + .filter({ hasText: "Fleet child on B" }) + .click(); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status) + .toBe("online"); + await fleet.disable(); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status) + .not.toBe("online"); + const withdrawn = await readFleetAuthorityState(page, a, b); + expect(withdrawn.direct.status).toBe("online"); + await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath }); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).direct.catalogVersion) + .toBeGreaterThan(withdrawn.direct.catalogVersion); + expect((await readFleetAuthorityState(page, a, b)).routed.catalogVersion).toBe( + withdrawn.routed.catalogVersion, + ); + await page.reload(); + await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a); + expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull(); + // Direct B remains a separate usable authority; its newer evidence cannot refill A's withdrawn scope. + await page.goto(buildHostAgentDetailRoute(b, fleet.remote.id, fleet.workspace.workspaceId)); + await submitMessage(page, "DIRECT_ONLY_AFTER_REVOCATION"); + await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible(); + await page.goto(routedUrl); + await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a); + await expectFleetRouteUnavailable(page, a, b); + await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toHaveCount(0); + const beforeRecovery = (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent + .lastUserMessageAt; + await fleet.enable(); + await expect + .poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status) + .toBe("online"); + await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a); + await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible(); + await submitMessage(page, "AFTER_REENABLE_THROUGH_A"); + await expect + .poll( + async () => + (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt, + ) + .not.toBe(beforeRecovery); + expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull(); + } finally { + await fleet.close(); + } +}); diff --git a/packages/app/e2e/support/helpers/agent-message-gate.ts b/packages/app/e2e/support/helpers/agent-message-gate.ts index 9f5735d5ac..eda0e8ce6b 100644 --- a/packages/app/e2e/support/helpers/agent-message-gate.ts +++ b/packages/app/e2e/support/helpers/agent-message-gate.ts @@ -1,3 +1,4 @@ +import { inspectSessionWireRequest } from "./session-wire"; import type { Page, WebSocketRoute } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; @@ -16,11 +17,7 @@ interface SendAgentMessageRequest { function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null { if (typeof message !== "string") return null; try { - const envelope = JSON.parse(message) as { - type?: unknown; - message?: Record; - }; - const request = envelope.type === "session" ? envelope.message : null; + const request = inspectSessionWireRequest(message)?.request; if ( request?.type !== "send_agent_message_request" || typeof request.requestId !== "string" || diff --git a/packages/app/e2e/support/helpers/daemon-client-loader.ts b/packages/app/e2e/support/helpers/daemon-client-loader.ts index 1e4df93958..04e788a0c6 100644 --- a/packages/app/e2e/support/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/support/helpers/daemon-client-loader.ts @@ -74,3 +74,12 @@ export async function loadProtocolSchemas(): Promise { + const moduleUrl = pathToFileURL( + path.resolve(__dirname, "../../../../../packages/client/dist/index.js"), + ).href; + return (await import(moduleUrl)).createPaseoApi; +} diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index bff52fc3f7..2c17052c57 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -1,3 +1,4 @@ +import { inspectSessionWireRequest } from "./session-wire"; import type { Page, WebSocketRoute } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; @@ -71,12 +72,11 @@ function readSessionMessage(message: string | Buffer): ClientRequest | null { function readClientRequest(message: string | Buffer): ClientRequest | null { if (typeof message !== "string") return null; + const session = inspectSessionWireRequest(message); + if (session) return session.request; try { - const envelope = JSON.parse(message) as { - type?: unknown; - message?: ClientRequest; - }; - return envelope.type === "session" ? (envelope.message ?? null) : envelope; + const envelope = JSON.parse(message) as ClientRequest; + return envelope.type === "session" ? null : envelope; } catch { return null; } diff --git a/packages/app/e2e/support/helpers/session-wire.ts b/packages/app/e2e/support/helpers/session-wire.ts new file mode 100644 index 0000000000..72b2899042 --- /dev/null +++ b/packages/app/e2e/support/helpers/session-wire.ts @@ -0,0 +1,44 @@ +/** Inspect an ordinary session request while preserving any owning transport envelopes. */ +export function inspectSessionWireRequest(message: string | Buffer): { + request: Record; + reply(response: unknown): string; +} | null { + const routes: string[] = []; + let raw = typeof message === "string" ? message : message.toString("utf8"); + try { + for (;;) { + const envelope = JSON.parse(raw); + if (envelope?.type !== "session" || !envelope.message || typeof envelope.message !== "object") + return null; + const request: Record = envelope.message; + if (request.type !== "hosts.route.frame.request") { + return { + request, + reply(response) { + let data = JSON.stringify({ type: "session", message: response }); + for (const subscriptionId of routes.toReversed()) { + data = JSON.stringify({ + type: "session", + message: { + type: "hosts.route.frame", + payload: { subscriptionId, data, binary: false }, + }, + }); + } + return data; + }, + }; + } + if ( + typeof request.routeId !== "string" || + typeof request.data !== "string" || + request.binary !== false + ) + return null; + routes.push(request.routeId); + raw = request.data; + } + } catch { + return null; + } +} diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index f3a5ca7c71..94ee2588d1 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -1,3 +1,15 @@ +import type { HostRuntimeStore } from "@/runtime/host-runtime"; +import { addConnectedHostAndReload } from "./hosts"; +import { buildSeededHost } from "./daemon-registry"; +import { openAgentRoute } from "./mock-agent"; +import { openSessions, clickSessionRow } from "./archive-tab"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { randomUUID } from "node:crypto"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient, loadPaseoApiFactory } from "./daemon-client-loader"; +import { startIsolatedHostDaemon } from "./isolated-host-daemon"; +import { seedWorkspace } from "./seed-client"; +import { inspectSessionWireRequest } from "./session-wire"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { expect, type Page } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; @@ -251,22 +263,19 @@ export async function rejectNextManagedSubagentArchiveRequest( await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { - const requestId = archiveRequestId(message, subagentId); - if (requestId && !rejected) { + const intercepted = archiveRequest(message, subagentId); + if (intercepted && !rejected) { rejected = true; // A daemon-rejected archive RPC: the request never reaches the daemon, and its normal // correlated error travels back through the real browser client and mutation rollback. ws.send( - JSON.stringify({ - type: "session", - message: { - type: "rpc_error", - payload: { - requestId, - requestType: "archive_agent_request", - error: "Archive rejected for test", - code: "archive_rejected", - }, + intercepted.reply({ + type: "rpc_error", + payload: { + requestId: intercepted.request.requestId, + requestType: "archive_agent_request", + error: "Archive rejected for test", + code: "archive_rejected", }, }), ); @@ -282,28 +291,18 @@ export async function rejectNextManagedSubagentArchiveRequest( } function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean { - return archiveRequestId(message, subagentId) !== null; + return archiveRequest(message, subagentId) !== null; } -function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - const envelope = JSON.parse(rawMessage) as { - type?: unknown; - message?: { type?: unknown; agentId?: unknown; requestId?: unknown }; - }; - if ( - envelope.type === "session" && - envelope.message?.type === "archive_agent_request" && - envelope.message.agentId === subagentId && - typeof envelope.message.requestId === "string" - ) { - return envelope.message.requestId; - } - return null; - } catch { - return null; - } +function archiveRequest(message: WebSocketMessage, subagentId: string) { + const inspected = inspectSessionWireRequest(message); + if ( + inspected?.request.type === "archive_agent_request" && + inspected.request.agentId === subagentId && + typeof inspected.request.requestId === "string" + ) + return inspected; + return null; } export async function detachSubagentFromTrack(page: Page, childId: string): Promise { @@ -320,3 +319,161 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom await expect(detachButton).toBeVisible({ timeout: 30_000 }); await detachButton.click(); } + +/** Real equal-ID children in equal-ID workspaces, with both route authorities independently live. */ +export async function seedFleetSubagents() { + const workspace = await seedWorkspace({ repoPrefix: "fleet-track-" }); + const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`); + const clients: DaemonClient[] = []; + const apis: ReturnType>>[] = []; + const close = async () => { + await Promise.allSettled(apis.map((api) => api.dispose())); + await Promise.allSettled(clients.map((client) => client.close())); + await destination.close(); + await workspace.cleanup(); + }; + try { + const createApi = await loadPaseoApiFactory(); + const aClient = await connectDaemonClient({ clientIdPrefix: "fleet-origin" }); + clients.push(aClient); + const bClient = await connectDaemonClient({ + clientIdPrefix: "fleet-destination", + port: destination.port, + }); + clients.push(bClient); + const origin = createApi(aClient); + const direct = createApi(bClient); + apis.push(origin, direct); + const registered = await origin.hosts.add({ connection: `127.0.0.1:${destination.port}` }); + await expect + .poll( + async () => + (await origin.hosts.list()).hosts.find( + (host) => host.registrationId === registered.registrationId, + )?.status, + ) + .toBe("online"); + const destinationWorkspace = await direct.workspaces.create({ + workspaceId: workspace.workspaceId, + source: { kind: "directory", path: workspace.repoPath }, + }); + expect(destinationWorkspace.id).toBe(workspace.workspaceId); + const config = { provider: "mock/ten-second-stream", modeId: "load-test" }; + const parent = await origin.workspaces + .ref(workspace.workspaceId) + .agents.create({ title: "Fleet parent", config }); + const agentId = randomUUID(); + const local = await parent.subagents.create({ agentId, title: "Fleet child on A", config }); + const remote = await parent.subagents.create({ + agentId, + serverId: destination.serverId, + workspaceId: destinationWorkspace.id, + title: "Fleet child on B", + config, + }); + return { + workspace, + destination, + origin, + direct, + parent, + local, + remote, + disable: () => origin.hosts.update(registered.registrationId, { enabled: false }), + enable: () => origin.hosts.update(registered.registrationId, { enabled: true }), + close, + }; + } catch (error) { + await close(); + throw error; + } +} + +export async function connectFleetDirectHost( + page: Page, + fleet: Awaited>, +) { + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await addConnectedHostAndReload(page, { + serverId: fleet.destination.serverId, + port: fleet.destination.port, + label: "Fleet B", + }); + await page.evaluate( + (host) => localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([host])), + buildSeededHost({ + serverId: fleet.destination.serverId, + endpoint: `127.0.0.1:${fleet.destination.port}`, + nowIso: new Date().toISOString(), + label: "Fleet B", + }), + ); + await page.goto( + buildHostAgentDetailRoute( + fleet.destination.serverId, + fleet.remote.id, + fleet.workspace.workspaceId, + ), + ); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); +} + +export async function openFleetParentTrack(page: Page) { + await openSessions(page); + await clickSessionRow(page, "Fleet parent"); + await openSubagentsTrack(page); +} + +export async function readFleetAuthorityState( + page: Page, + originServerId: string, + serverId: string, +) { + return page.evaluate( + ({ originServerId: sourceId, serverId: destinationId }) => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore; + const read = (origin: string) => { + const binding = runtime + .getAuthorityBindings() + .find( + (candidate) => + candidate.reference.originServerId === origin && + candidate.reference.serverId === destinationId, + ); + const queries = + binding?.queries + .getQueryCache() + .findAll({ queryKey: ["providersSnapshot", destinationId] }) ?? []; + return { + status: binding?.runtime.getSnapshot(destinationId)?.connectionStatus ?? "absent", + catalogVersion: Math.max(0, ...queries.map((query) => query.state.dataUpdatedAt)), + }; + }; + return { direct: read(destinationId), routed: read(sourceId) }; + }, + { originServerId, serverId }, + ); +} + +export async function expectFleetRouteUnavailable( + page: Page, + originServerId: string, + serverId: string, +) { + await expect + .poll(async () => (await readFleetAuthorityState(page, originServerId, serverId)).routed.status) + .toMatch(/^(offline|error)$/); + const unavailable = page.getByText(/^(Cannot reach .+|.+ is offline)$/).filter({ visible: true }); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(unavailable.or(composer)).toBeVisible(); + if (await composer.isVisible()) { + if (await composer.isEditable()) { + await composer.fill("MUST_NOT_USE_DIRECT_AUTHORITY"); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + } else await expect(composer).toBeDisabled(); + } else + await expect(page.getByRole("button", { name: "Send message", exact: true })).toHaveCount(0); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 559726d7c8..3155a6c1cb 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import { HostAuthoritySessions } from "@/runtime/host-authority-sessions"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; @@ -313,6 +314,7 @@ function HostSessionManager() { return ( <> + {hosts.map((daemon) => ( ))} diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index df16818239..15158b5fef 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -19,13 +19,20 @@ export default function HostRouteLayout() { } function KnownHostRoute() { - const params = useLocalSearchParams<{ serverId?: string | string[] }>(); + const params = useLocalSearchParams<{ + serverId?: string | string[]; + origin?: string | string[]; + }>(); const hosts = useHosts(); const hostRegistryStatus = useHostRegistryStatus(); const bootstrapState = useHostRuntimeBootstrapState(); const routeServerId = typeof params.serverId === "string" ? params.serverId : null; const startupRoute = resolveStartupRoute({ - route: { kind: "host", serverId: routeServerId }, + route: { + kind: "host", + serverId: routeServerId, + ...(typeof params.origin === "string" ? { originServerId: params.origin } : {}), + }, startupBlocker: bootstrapState.startupBlocker, hostRegistryStatus, hosts, diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..7b4e084e49 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -1,3 +1,4 @@ +import { HostAuthorityRoute } from "@/runtime/host-authority-route"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { useLocalSearchParams, useRouter, type Href } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; @@ -5,21 +6,34 @@ import { useFetchQuery } from "@/data/query"; import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; import { useSessionStore } from "@/stores/session-store"; -import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; -import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { useHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; +import { + buildHostRootRoute, + buildSettingsHostRoute, + withHostRouteAuthority, +} from "@/utils/host-routes"; import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; export default function HostAgentReadyRoute() { + const { serverId, origin } = useLocalSearchParams<{ serverId?: string; origin?: string }>(); + const reference = useMemo( + () => ({ originServerId: origin ?? serverId ?? "", serverId: serverId ?? "" }), + [origin, serverId], + ); + if (!serverId) return null; return ( - + + + ); } function HostAgentReadyRouteContent() { const router = useRouter(); + const runtime = useHostRuntimeStore(); const params = useLocalSearchParams<{ serverId?: string; agentId?: string; @@ -40,10 +54,8 @@ function HostAgentReadyRouteContent() { }); useEffect(() => { if (!serverId || !agentId || agentWorkspaceId) return; - void getHostRuntimeStore() - .prepareAgentRoute(serverId, agentId) - .catch(() => undefined); - }, [agentId, agentWorkspaceId, serverId]); + void runtime.prepareAgentRoute(serverId, agentId).catch(() => undefined); + }, [agentId, agentWorkspaceId, serverId, runtime]); const shouldLookupAgent = Boolean( serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, ); @@ -106,11 +118,23 @@ function HostAgentReadyRouteContent() { handledNavigationRef.current = navigationKey; if (resolution.kind === "resolved") { - navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId }); + navigateToAgent({ + serverId, + agentId, + workspaceId: resolution.workspaceId, + authority: runtime.authorityFor(serverId), + }); return; } - router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId)); - }, [agentId, resolution, router, serverId]); + router.replace( + resolution.kind === "invalid" + ? ("/" as Href) + : (withHostRouteAuthority( + buildHostRootRoute(serverId), + runtime.authorityFor(serverId), + ) as Href), + ); + }, [agentId, resolution, router, serverId, runtime]); const handleRetry = useCallback(() => { if (resolution.kind === "lookupError") { @@ -118,21 +142,28 @@ function HostAgentReadyRouteContent() { return; } if (serverId) { - void getHostRuntimeStore().runProbeCycleNow(serverId); + void runtime.runProbeCycleNow(serverId); } - }, [lookupQuery, resolution.kind, serverId]); + }, [lookupQuery, resolution.kind, serverId, runtime]); const handleManageHost = useCallback(() => { if (serverId) { - router.push(buildSettingsHostRoute(serverId)); + router.push(buildSettingsHostRoute(runtime.authorityFor(serverId).originServerId)); } - }, [router, serverId]); + }, [router, serverId, runtime]); const handleBack = useCallback(() => { if (router.canGoBack()) { router.back(); return; } - router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href)); - }, [router, serverId]); + router.replace( + serverId + ? (withHostRouteAuthority( + buildHostRootRoute(serverId), + runtime.authorityFor(serverId), + ) as Href) + : ("/" as Href), + ); + }, [router, serverId, runtime]); if ( resolution.kind === "waitingForHost" || diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 46ba5d8fb2..3a094e72c2 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,4 +1,10 @@ -import { Redirect } from "expo-router"; +import { useMemo } from "react"; +import { HostAuthorityRoute } from "@/runtime/host-authority-route"; +import { + workspaceSelectionAuthority, + type ActiveWorkspaceSelection, +} from "@/stores/last-workspace-selection"; +import { Redirect, useLocalSearchParams } from "expo-router"; import { useHostRouteServerId } from "@/navigation/host-route-context"; import { resolveHostIndexRoute, @@ -13,10 +19,31 @@ import { export default function HostIndexRoute() { const serverId = useHostRouteServerId(); - const workspaceSelection = useLastWorkspaceSelection(); + const remembered = useLastWorkspaceSelection(); + const { origin } = useLocalSearchParams<{ origin?: string }>(); + const selection = useMemo( + () => + remembered?.serverId === serverId + ? { ...remembered, ...(origin ? { authority: { originServerId: origin } } : {}) } + : null, + [remembered, serverId, origin], + ); + if (!selection) return ; + return ( + + + + ); +} + +function HostIndexContent({ + workspaceSelection, +}: { + workspaceSelection: ActiveWorkspaceSelection | null; +}) { + const serverId = useHostRouteServerId(); const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); - const workspaceSelectionWorkspaceId = - workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null; + const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null; const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 80ba6df4d4..26b56b1950 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -1,7 +1,9 @@ +import { HostAuthorityRoute } from "@/runtime/host-authority-route"; +import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigation } from "expo-router/react-navigation"; import { StyleSheet, View } from "react-native"; -import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; +import { useGlobalSearchParams, useRootNavigationState } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { RetainedPanel } from "@/components/retained-panel"; import { @@ -24,16 +26,12 @@ import { resolveWorkspaceDeckEntries, shouldKeepWorkspaceDeckEntryMounted, } from "@/screens/workspace/workspace-deck-retention"; -import { - decodeWorkspaceIdFromPathSegment, - parseWorkspaceOpenIntent, - type WorkspaceOpenIntent, -} from "@/utils/host-routes"; +import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent } from "@/utils/host-routes"; import { replaceBrowserRouteWithCanonicalHostWorkspaceRoute, stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit, } from "@/utils/host-route-browser"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation"; import { isNative, isWeb } from "@/constants/platform"; import { RenderProfile } from "@/utils/render-profiler"; @@ -94,24 +92,34 @@ export default function HostWorkspaceIndexRoute() { } function HostWorkspaceRouteContent() { + const { open } = useGlobalSearchParams<{ open?: string | string[] }>(); + return ( + + ); +} + +function WorkspaceOpenIntentBoundary({ + selection, + active, + children, +}: { + selection: ActiveWorkspaceSelection; + active: boolean; + children: React.ReactNode; +}) { + const { prepareWorkspaceTab } = useWorkspaceTabNavigation(); const navigation = useNavigation(); const rootNavigationState = useRootNavigationState(); const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); const consumedIntentRef = useRef(null); const [intentConsumed, setIntentConsumed] = useState(false); - const params = useLocalSearchParams<{ - serverId?: string | string[]; - workspaceId?: string | string[]; - }>(); const globalParams = useGlobalSearchParams<{ open?: string | string[]; }>(); - const serverId = getParamValue(params.serverId); - const workspaceValue = getParamValue(params.workspaceId); - const workspaceId = workspaceValue - ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") - : ""; - const openValue = getParamValue(globalParams.open); + const { serverId, workspaceId } = selection; + const openValue = active ? getParamValue(globalParams.open) : ""; const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceExists = useWorkspaceExists(serverId, workspaceId); const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); @@ -178,6 +186,7 @@ function HostWorkspaceRouteContent() { openIntent, openValue, rootNavigationState?.key, + prepareWorkspaceTab, serverId, workspaceId, ]); @@ -190,7 +199,7 @@ function HostWorkspaceRouteContent() { return null; } - return ; + return children; } function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { @@ -274,7 +283,20 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { ); } -function WorkspaceDeckEntry({ +function WorkspaceDeckEntry(props: { + selection: ActiveWorkspaceSelection; + active: boolean; + recoveryRequested: boolean; + onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; +}) { + return ( + + + + ); +} + +function WorkspaceDeckEntryContent({ selection, active, recoveryRequested, @@ -308,12 +330,14 @@ function WorkspaceDeckEntry({ active={active} testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`} > - + + + ); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index e0649f65a1..4f3fd33e6b 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -39,10 +39,14 @@ export default function Index() { hosts, anyOnlineHostServerId, workspaceSelection, - workspaceSelectionStatus: resolveWorkspaceSelectionStatus({ - hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost, - workspaceExists: workspaceSelectionExists, - }), + // COMPAT(unboundStartupSelection): added in v0.12, remove after 2027-04-20. + // Qualified restore reacquires its origin at the host boundary before proving absence. + workspaceSelectionStatus: workspaceSelection?.authority + ? "unknown" + : resolveWorkspaceSelectionStatus({ + hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost, + workspaceExists: workspaceSelectionExists, + }), isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index c6e86be582..93f6cbaf45 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -50,7 +50,8 @@ function createAgent(overrides: Partial = {}): Agent { options: [{ id: "high", label: "High" }], }, ], - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, ...overrides, turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index d482bb2d42..eda905cc91 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { RewindMode } from "./use-rewind-capabilities"; import { useRewindComposerRestore } from "./composer-restore"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStoreApi } from "@/stores/session-store"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useHostRuntimeStore } from "@/runtime/host-runtime"; interface UseRewindAgentMutationInput { serverId?: string; @@ -26,6 +26,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { isPending: boolean; } { const toast = useToast(); + const sessionStore = useSessionStoreApi(); + const runtime = useHostRuntimeStore(); const { t } = useTranslation(); const composerRestore = useRewindComposerRestore(); const { isPending, mutateAsync } = useMutation({ @@ -36,12 +38,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { await input.client.rewindAgent(input.agentId, input.messageId, mode); if (mode !== "files") { const cursor = input.serverId - ? useSessionStore - .getState() - .sessions[input.serverId]?.agentTimelineCursor.get(input.agentId) + ? sessionStore.getState().sessions[input.serverId]?.agentTimelineCursor.get(input.agentId) : undefined; if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); - await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { + await runtime.fetchAgentTimeline(input.serverId, input.agentId, { direction: "tail", projection: "projected", ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index bd4d0fe95c..bb69733076 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -5,9 +5,13 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; +import { + isActiveCreateFlowForDraft, + useCreateFlowStore, + useCreateFlowStoreApi, +} from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStoreApi } from "@/stores/session-store"; import { createUserMessage, type StreamItem, @@ -122,6 +126,8 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); + const sessionStore = useSessionStoreApi(); + const flowStore = useCreateFlowStoreApi(); const [localMachine, dispatch] = useReducer( reducer, initialAttempt, @@ -235,6 +241,7 @@ export function useDraftAgentCreateFlow({ images: attempt.images, attachments: attempt.attachments, }), + sessionStore, ); markPendingCreateLifecycle({ draftId, lifecycle: "sent" }); } @@ -255,6 +262,7 @@ export function useDraftAgentCreateFlow({ }, [ createRequest, + sessionStore, draftId, getPendingServerId, markPendingCreateLifecycle, @@ -268,7 +276,7 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - const existing = useCreateFlowStore.getState().pendingByDraftId[draftId]; + const existing = flowStore.getState().pendingByDraftId[draftId]; if ( isSubmitting || isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId }) @@ -285,7 +293,7 @@ export function useDraftAgentCreateFlow({ throw error; } const supportsForgeSearch = - useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch === + sessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch === true; const wirePayload = splitComposerAttachmentsForSubmit(attachments, { format: resolveComposerAttachmentSubmitFormat({ @@ -342,6 +350,8 @@ export function useDraftAgentCreateFlow({ draftId, getPendingServerId, isSubmitting, + flowStore, + sessionStore, onCreateStart, runCreateAttempt, setPendingCreateAttempt, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d00b6f7790..d3fba1e0b1 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -243,7 +243,8 @@ function buildDraftAgentSnapshot(input: { model, features: composerState.agentControls.features, thinkingOptionId, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, }; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 10d670fcae..70f16abefe 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -6,7 +6,7 @@ import { import type { ComposerTextSource } from "./text-source"; import { createStore, type StoreApi } from "zustand/vanilla"; import { useStore } from "zustand"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useHostRuntimeStore } from "@/runtime/host-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -55,7 +55,11 @@ import { } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentTurnPresentation, + useSessionStore, + useSessionStoreApi, +} from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -1264,6 +1268,8 @@ function ComposerContentImpl({ const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const buttonIconSize = resolveComposerButtonIconSize(); + const sessionStore = useSessionStoreApi(); + const runtime = useHostRuntimeStore(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); @@ -1560,19 +1566,27 @@ function ComposerContentImpl({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter(serverId, sessionStore), activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" ? (selectAgentTurnPresentation( - useSessionStore.getState().sessions[serverId], + sessionStore.getState().sessions[serverId], targetAgentId, ).turnId ?? undefined) : undefined, }); onAttentionPromptSend?.(); }; - }, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); + }, [ + appSettings.sendBehavior, + client, + onAttentionPromptSend, + serverId, + sessionStore, + supportsForgeSearch, + t, + ]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; @@ -1603,10 +1617,10 @@ function ComposerContentImpl({ const queueWriter = useMemo( () => ({ - read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [], + read: (id) => sessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [], write: (updater) => setQueuedMessages(serverId, updater), }), - [serverId, setQueuedMessages], + [serverId, sessionStore, setQueuedMessages], ); const queueMessage = useCallback( @@ -1875,26 +1889,23 @@ function ComposerContentImpl({ const handleCancelAgent = useCallback(() => { const targetAgentId = agentIdRef.current; const cancellation = cancelComposerAgent({ - client, + client: client + ? { cancelAgent: (id) => runtime.cancelAgent({ serverId, agentId: id }) } + : null, agentId: targetAgentId, isAgentRunning, isCancellingAgent, isConnected, }); if (!cancellation) return; - const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId); - void cancellation - .catch((error) => { - const message = resolveErrorMessage(error); - if (message && message.trim().length > 0) { - toastErrorRef.current(message); - } - }) - .finally(() => { - getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId); - }); + void cancellation.catch((error) => { + const message = resolveErrorMessage(error); + if (message && message.trim().length > 0) { + toastErrorRef.current(message); + } + }); messageInputRef.current?.focus(); - }, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]); + }, [client, runtime, isAgentRunning, isCancellingAgent, isConnected, serverId]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts index 3ee174d554..b6bad27ff4 100644 --- a/packages/app/src/composer/submission/writer.test.ts +++ b/packages/app/src/composer/submission/writer.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore } from "@/stores/session-store"; +import { createSessionStore, useSessionStore } from "@/stores/session-store"; import type { UserMessageItem } from "@/types/stream"; import { createMessageSubmissionWriter } from "./writer"; @@ -40,10 +40,45 @@ afterEach(() => { test("tracks submissions when canonical prompts are supported", () => { initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true }); - createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin( + createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, useSessionStore).begin( "agent-1", submittedMessage("canonical-only-message"), ); expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]); }); + +test.each([true, false])( + "writes and rejects only in its captured authority store (tracked=%s)", + (tracked) => { + initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: tracked }); + const scoped = createSessionStore(); + scoped.getState().initializeSession(CANONICAL_ONLY_SERVER_ID, null); + scoped.getState().updateSessionServerInfo(CANONICAL_ONLY_SERVER_ID, { + serverId: CANONICAL_ONLY_SERVER_ID, + hostname: null, + version: "0.12.0", + features: { canonicalSubmittedPrompts: tracked }, + }); + const writer = createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, scoped); + writer.begin("agent-1", submittedMessage("scoped-message")); + const session = scoped.getState().sessions[CANONICAL_ONLY_SERVER_ID]; + if (tracked) + expect( + session.messageSubmissions.get("agent-1")?.map((submission) => submission.clientMessageId), + ).toEqual(["scoped-message"]); + else + expect( + session.agentStreamTail + .get("agent-1") + ?.map((item) => (item.kind === "user_message" ? item.clientMessageId : null)), + ).toEqual(["scoped-message"]); + expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual([]); + expect( + useSessionStore + .getState() + .sessions[CANONICAL_ONLY_SERVER_ID]?.agentStreamTail.get("agent-1") ?? [], + ).toEqual([]); + expect(writer.reject("agent-1", "scoped-message")).toBe("rejected"); + }, +); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index f8b40034b6..6f08a07dab 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -1,31 +1,37 @@ import type { MessageSubmissionWriter } from "@/composer/actions"; -import { useSessionStore } from "@/stores/session-store"; +import type { SessionStoreApi } from "@/stores/session-store"; import { appendSubmittedUserMessage, removeSubmittedUserMessage, type UserMessageItem, } from "@/types/stream"; -function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) { - const session = useSessionStore.getState().sessions[serverId]; +function appendUntrackedSubmission( + store: SessionStoreApi, + serverId: string, + agentId: string, + message: UserMessageItem, +) { + const session = store.getState().sessions[serverId]; if (!session) return; const tail = session.agentStreamTail.get(agentId) ?? []; const head = session.agentStreamHead.get(agentId) ?? []; - useSessionStore + store .getState() .setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); } function removeUntrackedSubmission( + store: SessionStoreApi, serverId: string, agentId: string, clientMessageId: string, ): void { - const session = useSessionStore.getState().sessions[serverId]; + const session = store.getState().sessions[serverId]; if (!session) return; const tail = session.agentStreamTail.get(agentId) ?? []; const head = session.agentStreamHead.get(agentId) ?? []; - useSessionStore + store .getState() .setAgentStreamState( serverId, @@ -34,32 +40,37 @@ function removeUntrackedSubmission( ); } -function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { +function createUntrackedMessageSubmissionWriter( + serverId: string, + store: SessionStoreApi, +): MessageSubmissionWriter { return { - begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message), + begin: (agentId, message) => appendUntrackedSubmission(store, serverId, agentId, message), accept: () => undefined, reject: (agentId, clientMessageId) => { - removeUntrackedSubmission(serverId, agentId, clientMessageId); + removeUntrackedSubmission(store, serverId, agentId, clientMessageId); return "rejected"; }, }; } -export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { +export function createMessageSubmissionWriter( + serverId: string, + store: SessionStoreApi, +): MessageSubmissionWriter { const supportsTrackedMessageSubmissions = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features - ?.canonicalSubmittedPrompts === true; + store.getState().sessions[serverId]?.serverInfo?.features?.canonicalSubmittedPrompts === true; if (!supportsTrackedMessageSubmissions) { // COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6. - return createUntrackedMessageSubmissionWriter(serverId); + return createUntrackedMessageSubmissionWriter(serverId, store); } return { begin: (agentId, message) => - useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), + store.getState().beginAgentMessageSubmission(serverId, agentId, message), accept: (agentId, clientMessageId) => - useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), + store.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), reject: (agentId, clientMessageId) => - useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + store.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), }; } @@ -67,6 +78,7 @@ export function handoffCreatedAgentMessageSubmission( serverId: string, agentId: string, message: UserMessageItem, + store: SessionStoreApi, ): boolean { - return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); + return store.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 26d3bd461a..16ce66d5b3 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,7 +1,10 @@ import { useRef, ReactNode, useCallback, useEffect } from "react"; import { Buffer } from "buffer"; import { AppState } from "react-native"; -import { observeOpenWorkspaceAgentIds } from "@/stores/workspace-layout-store"; +import { + observeOpenWorkspaceAgentIds, + useWorkspaceLayoutStoreApi, +} from "@/stores/workspace-layout-store"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; @@ -45,7 +48,7 @@ import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store"; +import { resyncProviderSubagents, useProviderSubagentStoreApi } from "@/subagents/provider-store"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -198,6 +201,8 @@ export function SessionProvider(props: SessionProviderProps) { function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) { const sessionStore = useSessionStoreApi(); + const providerStore = useProviderSubagentStoreApi(); + const layoutStore = useWorkspaceLayoutStoreApi(); const runtime = useHostRuntimeStore(); const { t } = useTranslation(); const voiceRuntime = useVoiceRuntimeOptional(); @@ -476,8 +481,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); - const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) => - sync.replaceOpenTabAgentIds(agentIds), + const stopObservingOpenChats = observeOpenWorkspaceAgentIds( + serverId, + (agentIds) => sync.replaceOpenTabAgentIds(agentIds), + layoutStore, ); return () => { @@ -494,6 +501,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider sessionStore, runtime, sessionStore.initializations, + layoutStore, setInitializingAgents, setViewedTimelineSync, voiceRuntime, @@ -538,11 +546,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubProviderSubagentUpdate = feeds.subscribe({ snapshot: () => { - void resyncProviderSubagents(client, serverId); + void resyncProviderSubagents(client, serverId, providerStore); }, update: (message) => { if (message.type !== "agent.provider_subagents.update") return; - useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + providerStore.getState().applyUpdate(serverId, message.payload); }, }); @@ -728,6 +736,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider clearAgentStreamHead, setInitializingAgents, setAgents, + providerStore, setPendingPermissions, notifyAgentAttention, updateSessionServerInfo, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index dcd150d764..b1de2eb3d5 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -28,7 +28,8 @@ function createAgent(status: Agent["status"]): Agent { title: "Agent", cwd: "/tmp", model: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, projectPlacement: null, }; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 1bc4cf2135..e32a3a43f2 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import { describe, expect, it } from "vitest"; import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; @@ -769,7 +770,9 @@ function agent(input: { cwd: "/repo", workspaceId: input.workspaceId, model: null, - parentAgentId: input.parentAgentId ?? null, + ...managedAgentFields( + input.parentAgentId ? { serverId: "srv", agentId: input.parentAgentId } : null, + ), archivedAt: input.archivedAt ?? null, labels: {}, }; diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 96b0395e76..d1d8f5aa15 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -7,7 +7,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { useHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -221,7 +221,7 @@ export function useAgentHistory(options: { }): AgentHistoryResult { const { t } = useTranslation(); const daemons = useHosts(); - const runtime = getHostRuntimeStore(); + const runtime = useHostRuntimeStore(); const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 19caef4f8b..7ca46aed7d 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -43,7 +43,8 @@ function createAgent(id: string): Agent { title: "Agent", cwd: "/repo", model: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, }; } diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index 8ce37cdb7e..fa63204577 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -39,7 +39,8 @@ function makeAgent(overrides: Partial = {}): Agent { title: "Agent 1", cwd: "/repo", model: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, archivedAt: null, ...overrides, diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 2bc3f5c9df..52c7de792a 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -5,10 +5,11 @@ import { i18n } from "@/i18n/i18next"; import { selectAgentTimelineState, useSessionStore, + useSessionStoreApi, type AgentTimelineCursorState, } from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useHostRuntimeStore } from "@/runtime/host-runtime"; export interface LoadOlderAgentHistoryClient { fetchAgentTimeline: ( @@ -78,6 +79,8 @@ export function useLoadOlderAgentHistory({ toast?: ToastApi | null; }) { const { t } = useTranslation(); + const sessionStore = useSessionStoreApi(); + const runtime = useHostRuntimeStore(); const hasOlder = useSessionStore((state) => { const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); return timeline.status === "synced" && timeline.older === "available"; @@ -110,13 +113,13 @@ export function useLoadOlderAgentHistory({ ); const loadOlder = useCallback(async (): Promise => { - const session = useSessionStore.getState().sessions[serverId]; + const session = sessionStore.getState().sessions[serverId]; const timeline = selectAgentTimelineState(session, agentId); return await loadOlderAgentHistory(agentId, { client: session?.client ? { fetchAgentTimeline: (timelineAgentId, request) => - getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), + runtime.fetchAgentTimeline(serverId, timelineAgentId, request), } : null, cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, @@ -126,7 +129,7 @@ export function useLoadOlderAgentHistory({ toast, failedMessage: t("loadOlderHistory.failed"), }); - }, [agentId, serverId, setInFlight, toast, t]); + }, [agentId, serverId, setInFlight, toast, t, runtime, sessionStore]); return { isLoadingOlder, diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index 6e1e130090..c2da689a65 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -428,3 +428,41 @@ describe("host runtime app lifecycle", () => { }, ); }); + +it("restores a qualified destination through its saved origin host boundary", () => { + const selection = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } }; + expect( + resolveStartupRoute({ + route: { kind: "index", pathname: "/" }, + startupBlocker: { kind: "none" }, + hostRegistryStatus: "ready", + hosts: [{ serverId: "A" }], + anyOnlineHostServerId: "A", + workspaceSelection: selection, + workspaceSelectionStatus: "unknown", + isWorkspaceSelectionLoaded: true, + hasGivenUpWaitingForHost: false, + }), + ).toEqual({ kind: "redirect", href: "/h/B?origin=A" }); + expect( + resolveHostIndexRoute({ + serverId: "B", + workspaceSelection: selection, + workspaceSelectionStatus: "exists", + }), + ).toBe("/h/B/workspace/workspace?origin=A"); +}); + +it("authorizes a named host route by its intended origin and never substitutes a directly connected destination", () => { + const route = { kind: "host" as const, serverId: "B", originServerId: "A" }; + const base = { + route, + startupBlocker: { kind: "none" as const }, + hostRegistryStatus: "ready" as const, + }; + expect(resolveStartupRoute({ ...base, hosts: [{ serverId: "A" }] })).toEqual({ kind: "render" }); + expect(resolveStartupRoute({ ...base, hosts: [{ serverId: "B" }] })).toEqual({ + kind: "redirect", + href: "/open-project", + }); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 8ee1f7bfb2..818d2cd351 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -1,4 +1,5 @@ import type { AppState } from "react-native"; +import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartCondition, @@ -10,6 +11,7 @@ import { buildHostRootRoute, buildHostWorkspaceRoute, buildOpenProjectRoute, + withHostRouteAuthority, } from "@/utils/host-routes"; export interface HostRuntimeBootstrapStore { @@ -100,6 +102,7 @@ export interface IndexStartupRouteTarget { export interface HostStartupRouteTarget { kind: "host"; serverId: string | null; + originServerId?: string; } export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget; @@ -161,7 +164,10 @@ export function resolveHostIndexRoute(input: { input.workspaceSelection?.serverId === input.serverId && shouldRestoreWorkspaceSelection(input) ) { - return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId); + const route = buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId); + return input.workspaceSelection.authority + ? (withHostRouteAuthority(route, input.workspaceSelection.authority) as Href) + : route; } return buildOpenProjectRoute(); } @@ -188,13 +194,18 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St if ( shouldRestoreWorkspaceSelection(input) && - hostExists(input.hosts, input.workspaceSelection.serverId) + hostExists(input.hosts, workspaceSelectionAuthority(input.workspaceSelection).originServerId) ) { // Native cold launch must enter the host boundary first. The host index // owns workspace restore after its local dynamic params exist. return { kind: "redirect", - href: buildHostRootRoute(input.workspaceSelection.serverId), + href: (input.workspaceSelection.authority + ? withHostRouteAuthority( + buildHostRootRoute(input.workspaceSelection.serverId), + input.workspaceSelection.authority, + ) + : buildHostRootRoute(input.workspaceSelection.serverId)) as Href, }; } @@ -215,7 +226,7 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St } function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision { - if (hostExists(input.hosts, input.route.serverId)) { + if (hostExists(input.hosts, input.route.originServerId ?? input.route.serverId)) { return { kind: "render" }; } diff --git a/packages/app/src/navigation/workspace-route-navigation.ts b/packages/app/src/navigation/workspace-route-navigation.ts index d8b33ea305..032e741663 100644 --- a/packages/app/src/navigation/workspace-route-navigation.ts +++ b/packages/app/src/navigation/workspace-route-navigation.ts @@ -131,6 +131,7 @@ function dispatchHostWorkspacePopTo( serverId: selection.serverId, workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId), ...(open ? { open } : {}), + ...(selection.authority ? { origin: selection.authority.originServerId } : {}), }, // React Navigation consumes this nested hint when resolving the host child screen. // The browser-route canonicalizer strips the resulting ?pop=true URL artifact. diff --git a/packages/app/src/panels/agent-facts-line.ts b/packages/app/src/panels/agent-facts-line.ts index 7c01e5ce42..f519b073ea 100644 --- a/packages/app/src/panels/agent-facts-line.ts +++ b/packages/app/src/panels/agent-facts-line.ts @@ -4,7 +4,7 @@ import { formatAgentModeLabel } from "@/agent-controls/labels"; import { formatTokenCount } from "@/components/context-window-meter.utils"; import { resolveAgentModelSelection } from "@/composer/agent-controls/utils"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; export interface AgentFactsInput { models: AgentModelDefinition[] | null; @@ -36,30 +36,45 @@ export function buildAgentFactsLine(input: AgentFactsInput): string | null { return parts.join(" · "); } +export interface AgentFactsSnapshot extends Omit { + provider: string; + cwd: string; +} + +export function agentFactsSnapshot(agent: Agent): AgentFactsSnapshot { + return { + provider: agent.provider, + cwd: agent.cwd, + runtimeModelId: agent.runtimeInfo?.model ?? null, + configuredModelId: agent.model, + runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: agent.thinkingOptionId ?? null, + availableModes: agent.availableModes, + currentModeId: agent.currentModeId, + contextWindowUsedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null, + contextWindowMaxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null, + }; +} + +/** Uses the destination binding's catalog query and push owner for the supplied snapshot. */ +export function useAgentSnapshotFactsLine( + serverId: string, + agent: AgentFactsSnapshot | null, +): string | null { + const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null; + return agent ? buildAgentFactsLine({ ...agent, models }) : null; +} + /** The facts line for a Paseo agent in the session store, kept current as the agent reports. */ export function useAgentFactsLine(serverId: string, agentId: string): string | null { const agent = useSessionStore( useShallow((state) => { const current = state.sessions[serverId]?.agents.get(agentId); - return current - ? { - provider: current.provider, - cwd: current.cwd, - runtimeModelId: current.runtimeInfo?.model ?? null, - configuredModelId: current.model ?? null, - runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null, - explicitThinkingOptionId: current.thinkingOptionId ?? null, - availableModes: current.availableModes, - currentModeId: current.currentModeId, - contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null, - contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null, - } - : null; + return current ? agentFactsSnapshot(current) : null; }), ); - const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); - const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null; - return agent ? buildAgentFactsLine({ ...agent, models }) : null; + return useAgentSnapshotFactsLine(serverId, agent); } /** The mode control shows the current mode, or the provider's first one until the agent reports. */ diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index e67a004b45..1412ae59d3 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -7,6 +7,8 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { usePaneContext } from "@/panels/pane-context"; import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; +import { subagentRowBelongsToWorkspace } from "@/subagents/opens-in-sheet"; +import type { PaseoSubagentRow } from "@/subagents/select"; import { useSessionStore } from "@/stores/session-store"; import { type ArchiveFinishedStatus, @@ -19,8 +21,8 @@ import { SubagentsTrack } from "@/subagents/track"; import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; -import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside"; -import { openComposerChanges } from "@/workspace-tabs/open-supporting-view"; +import { useWorkspaceTargetActions } from "@/workspace-tabs/open-beside"; +import { useSupportingViewActions } from "@/workspace-tabs/open-supporting-view"; /** * The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above @@ -49,6 +51,8 @@ export const AgentTracks = memo(function AgentTracks({ hasPluginComposerPills: boolean; }): ReactElement | null { const { tabId } = usePaneContext(); + const { openPreferredWorkspaceTarget } = useWorkspaceTargetActions(); + const { openComposerChanges } = useSupportingViewActions(); const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); const isCompact = useIsCompactFormFactor(); const openInSidePane = useSettings((settings) => settings.openInSidePane); @@ -56,8 +60,8 @@ export const AgentTracks = memo(function AgentTracks({ const canDetachSubagents = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, ); - const archiveSubagent = useArchiveSubagent({ serverId }); - const detachSubagent = useDetachSubagent({ serverId }); + const archiveSubagent = useArchiveSubagent({ parent: { serverId, agentId } }); + const detachSubagent = useDetachSubagent({ parent: { serverId, agentId } }); const openSubagentTarget = useCallback( (target: WorkspaceTabTarget) => { openPreferredWorkspaceTarget({ @@ -68,18 +72,19 @@ export const AgentTracks = memo(function AgentTracks({ parentTabId: tabId, }); }, - [openInSidePane, tabId, workspaceKey], + [openInSidePane, openPreferredWorkspaceTarget, tabId, workspaceKey], ); const handleOpenSubagent = useCallback( - (subagentId: string) => { - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId); - // A subagent in another workspace lives in that workspace's tabs. - if (agent?.workspaceId && agent.workspaceId !== workspaceId) { - navigateToAgent({ serverId, agentId: subagentId }); + (row: PaseoSubagentRow) => { + if (!subagentRowBelongsToWorkspace(row, { serverId, workspaceId })) { + navigateToAgent({ + ...row.address, + workspaceId: row.workspace?.workspaceId, + authority: row.authority, + }); return; } - openSubagentTarget({ kind: "agent", agentId: subagentId }); + openSubagentTarget({ kind: "agent", agentId: row.address.agentId }); }, [openSubagentTarget, serverId, workspaceId], ); @@ -89,7 +94,7 @@ export const AgentTracks = memo(function AgentTracks({ }, [openSubagentTarget], ); - const opensInSheet = useSubagentOpensInSheet(workspaceKey); + const opensInSheet = useSubagentOpensInSheet(workspaceKey, { serverId, workspaceId }); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -99,7 +104,7 @@ export const AgentTracks = memo(function AgentTracks({ workspaceKey, preferences: openInSidePane, }); - }, [isCompact, openInSidePane, workspaceKey]); + }, [isCompact, openComposerChanges, openInSidePane, workspaceKey]); if ( !hasWorkspaceDiffStat && diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index ecd4a082bf..9460a732d5 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -25,6 +25,7 @@ import { refreshProviderSubagents, observeProviderSubagentTimeline, useProviderSubagentStore, + useProviderSubagentStoreApi, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; import type { PendingPermission } from "@/types/shared"; @@ -114,6 +115,7 @@ function useProviderSubagentDescriptor( } function ProviderSubagentPanel() { + const providerStore = useProviderSubagentStoreApi(); const { t } = useTranslation(); const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); @@ -145,6 +147,7 @@ function ProviderSubagentPanel() { const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); const opensInSheet = useSubagentOpensInSheet( buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + { serverId, workspaceId }, ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { @@ -155,13 +158,16 @@ function ProviderSubagentPanel() { useEffect(() => { if (!client || !supported) return; - void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined); - }, [client, serverId, supported, target.parentAgentId]); + void refreshProviderSubagents(client, serverId, target.parentAgentId, providerStore).catch( + () => undefined, + ); + }, [client, providerStore, serverId, supported, target.parentAgentId]); useEffect(() => { if (!client || !supported) return; return observeProviderSubagentTimeline({ client, + store: providerStore, serverId, parentAgentId: target.parentAgentId, subagentId: target.subagentId, @@ -175,7 +181,7 @@ function ProviderSubagentPanel() { }); }, }); - }, [client, supported, serverId, target.parentAgentId, target.subagentId]); + }, [client, providerStore, supported, serverId, target.parentAgentId, target.subagentId]); const loadOlder = useCallback((): boolean => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { @@ -191,7 +197,7 @@ function ProviderSubagentPanel() { limit: TIMELINE_FETCH_PAGE_SIZE, }) .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + providerStore.getState().replaceTimeline(serverId, payload); return undefined; }) .catch(() => undefined) @@ -199,6 +205,7 @@ function ProviderSubagentPanel() { return true; }, [ client, + providerStore, isLoadingOlder, serverId, supported, diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index cbbbaaed4b..f0253930e3 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -49,7 +49,8 @@ const agent: Agent = { thinkingOptionId: "high", requiresAttention: true, attentionReason: "permission", - parentAgentId: null, + parent: null, + managedRelationship: null, labels: { phase: "implementation" }, }; @@ -103,6 +104,7 @@ describe("plugin context snapshots", () => { thinkingOptionId: "high", requiresAttention: true, attentionReason: "permission", + parent: null, parentAgentId: null, labels: { phase: "implementation" }, }); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index 67a08830cd..f12dc5aa82 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -47,7 +47,9 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl thinkingOptionId: agent.thinkingOptionId ?? null, requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, - parentAgentId: agent.parentAgentId, + parent: agent.parent ? Object.freeze({ ...agent.parent }) : null, + // COMPAT(pluginParentId): added in v0.12.0, remove after 2027-04-20. + parentAgentId: agent.parent?.agentId ?? null, labels: Object.freeze({ ...agent.labels }), }); const byWorkspace = agentSnapshots.get(agent) ?? new Map(); diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index a2544bd034..4a691dffc6 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -43,6 +43,7 @@ const agent: PluginAgentSnapshot = { thinkingOptionId: null, requiresAttention: false, attentionReason: null, + parent: null, parentAgentId: null, labels: {}, }; diff --git a/packages/app/src/review/feedback/use-feedback.ts b/packages/app/src/review/feedback/use-feedback.ts index 0596f3c55f..b76112f390 100644 --- a/packages/app/src/review/feedback/use-feedback.ts +++ b/packages/app/src/review/feedback/use-feedback.ts @@ -4,14 +4,18 @@ import { getReviewDraftComments, clearSentReviewDraftComments, } from "../store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { + useWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, + type WorkspaceLayoutStoreApi, +} from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useAppSettings } from "@/hooks/use-settings"; import { dispatchComposerAgentMessage } from "@/composer/actions"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; -import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store"; +import { useSessionStoreApi, selectAgentTurnPresentation } from "@/stores/session-store"; import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button"; import { useIsCompactFormFactor } from "@/constants/layout"; import { buttonControlHeight } from "@/components/ui/control-geometry"; @@ -35,6 +39,8 @@ export function useReviewFeedback(input: { }) { const { serverId, reviewDraftKey, workspaceId } = input; const toast = useToast(); + const layoutStore = useWorkspaceLayoutStoreApi(); + const sessionStore = useSessionStoreApi(); const isCompact = useIsCompactFormFactor(); const workspaceKey = workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) @@ -68,7 +74,7 @@ export function useReviewFeedback(input: { comments: getReviewDraftComments(reviewDraftKey) ?? [], attachment: live.current.attachment, connected: live.current.connected, - recipients: readRecipients(workspaceKey), + recipients: readRecipients(workspaceKey, layoutStore), }), send: async (recipient, attachment) => { const { client: targetClient, sendBehavior } = live.current; @@ -80,12 +86,12 @@ export function useReviewFeedback(input: { text: i18n.t("review.feedback.prompt"), attachments: [attachment], encodeImages: async () => [], - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter(serverId, sessionStore), activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" ? (selectAgentTurnPresentation( - useSessionStore.getState().sessions[serverId], + sessionStore.getState().sessions[serverId], recipient.agentId, ).turnId ?? undefined) : undefined, @@ -103,7 +109,7 @@ export function useReviewFeedback(input: { clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }), failedMessage: () => i18n.t("review.feedback.failed"), }), - [reviewDraftKey, serverId, workspaceKey, workspaceId], + [layoutStore, sessionStore, reviewDraftKey, serverId, workspaceKey, workspaceId], ); useEffect(() => { model.refresh(); @@ -119,10 +125,11 @@ export function useReviewFeedback(input: { }; } -function readRecipients(workspaceKey: string | null): FeedbackRecipient[] { - const tabs = workspaceKey - ? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey) - : []; +function readRecipients( + workspaceKey: string | null, + store: WorkspaceLayoutStoreApi, +): FeedbackRecipient[] { + const tabs = workspaceKey ? store.getState().getWorkspaceTabs(workspaceKey) : []; const descriptors = deriveWorkspaceTabModel({ tabs }).tabs; const recipients: FeedbackRecipient[] = []; for (const { descriptor } of descriptors) { diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 2087c5f49f..3fbf023839 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -38,7 +38,7 @@ export class AgentDirectoryReplica { private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, - store: SessionStoreApi = useSessionStore, + private readonly store: SessionStoreApi = useSessionStore, ) { this.storeProjection = new AgentStoreProjection(serverId, store); } @@ -81,7 +81,11 @@ export class AgentDirectoryReplica { const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, - agent: normalizeAgentSnapshot(payload, this.serverId), + agent: normalizeAgentSnapshot( + payload, + this.serverId, + this.store.getState().sessions[this.serverId]?.serverInfo, + ), }); const normalized: Agent = { ...timelineAgent, diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 659eaa367c..7203d9b67d 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -80,7 +80,11 @@ export class AgentStoreProjection { this.removeFromDirectory(delta.agentId); return { agentId: delta.agentId, stoppedRunning: false }; } - const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); + const normalized = normalizeAgentSnapshot( + delta.agent, + this.serverId, + this.store.getState().sessions[this.serverId]?.serverInfo, + ); const session = this.store.getState().sessions[this.serverId]; const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); const legacyWorkspaceId = @@ -113,6 +117,7 @@ export class AgentStoreProjection { const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({ serverId: this.serverId, entries, + serverInfo: this.store.getState().sessions[this.serverId]?.serverInfo, }); const store = this.store.getState(); const current = store.sessions[this.serverId]?.agents ?? new Map(); diff --git a/packages/app/src/runtime/host-authority-provider.test.tsx b/packages/app/src/runtime/host-authority-provider.test.tsx index 4d22934787..6e77e7f5ad 100644 --- a/packages/app/src/runtime/host-authority-provider.test.tsx +++ b/packages/app/src/runtime/host-authority-provider.test.tsx @@ -1,4 +1,12 @@ +import { + createWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, +} from "@/stores/workspace-layout-store"; import React from "react"; +import { + createProviderSubagentStore, + useProviderSubagentStoreApi, +} from "@/subagents/provider-store"; import { renderToStaticMarkup } from "react-dom/server"; import { expect, it } from "vitest"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +23,8 @@ it("ordinary consumer hooks retain the runtime, store and creation owner of each namespace: JSON.stringify(["host-authority-v1", originServerId, "B"]), sessionStore, queries: new QueryClient(), + providerSubagents: createProviderSubagentStore(), + layout: createWorkspaceLayoutStore(), runtime: new HostRuntimeStore({ sessionStore }), ready: Promise.resolve(), }; @@ -26,6 +36,8 @@ it("ordinary consumer hooks retain the runtime, store and creation owner of each const store = useSessionStoreApi(); const creates = useCreateFlowStoreApi(); expect(useQueryClient()).toBe(expected.queries); + expect(useProviderSubagentStoreApi()).toBe(expected.providerSubagents); + expect(useWorkspaceLayoutStoreApi()).toBe(expected.layout); expect(runtime).toBe(expected.runtime); expect(store).toBe(expected.sessionStore); expect(creates).toBe(expected.sessionStore.createFlows); diff --git a/packages/app/src/runtime/host-authority-provider.tsx b/packages/app/src/runtime/host-authority-provider.tsx index 36f1e9994f..dffe05699a 100644 --- a/packages/app/src/runtime/host-authority-provider.tsx +++ b/packages/app/src/runtime/host-authority-provider.tsx @@ -1,3 +1,5 @@ +import { WorkspaceLayoutStoreContext } from "@/stores/workspace-layout-store"; +import { ProviderSubagentStoreContext } from "@/subagents/provider-store"; import type { ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { SessionStoreContext } from "@/stores/session-store"; @@ -14,13 +16,17 @@ export function HostAuthorityProvider({ }) { return ( - - - - {children} - - - + + + + + + {children} + + + + + ); } diff --git a/packages/app/src/runtime/host-authority-route.tsx b/packages/app/src/runtime/host-authority-route.tsx new file mode 100644 index 0000000000..97660ef589 --- /dev/null +++ b/packages/app/src/runtime/host-authority-route.tsx @@ -0,0 +1,55 @@ +import { withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; +import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { HostAuthorityProvider } from "./host-authority-provider"; +import { + getHostRuntimeStore, + type HostAuthorityReference, + type OwnedHostAuthorityBinding, +} from "./host-runtime"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const spinnerColors = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The retained route owns its lease; destination identity never selects another origin. */ +export function HostAuthorityRoute({ + reference, + children, + pending = , +}: { + reference: HostAuthorityReference; + children: ReactNode; + pending?: ReactNode; +}) { + const registry = getHostRuntimeStore(); + const { originServerId, serverId } = reference; + useSyncExternalStore( + (changed) => registry.subscribe(originServerId, changed), + () => registry.getSnapshot(originServerId), + () => registry.getSnapshot(originServerId), + ); + const serverInfo = registry.getServerInfo(originServerId); + const features = serverInfo?.features; + // COMPAT(preFleetDirectRoutes): added in v0.12, remove after 2027-04-20. + // Old direct hosts retain their existing shell/session owner. Owned SDK scopes still reject unsupported capability. + const legacy = + originServerId === serverId && serverInfo !== null && features?.hostSessionScopes !== true; + const [lease, setLease] = useState(null); + useEffect(() => { + if (legacy) return; + const owned = registry.acquireBinding({ originServerId, serverId }); + setLease(owned); + return () => { + void owned.release(); + }; + }, [legacy, originServerId, registry, serverId]); + if (legacy) return children; + if ( + !lease || + lease.binding.reference.originServerId !== originServerId || + lease.binding.reference.serverId !== serverId + ) + return pending; + return {children}; +} diff --git a/packages/app/src/runtime/host-authority-sessions.tsx b/packages/app/src/runtime/host-authority-sessions.tsx new file mode 100644 index 0000000000..e372acfe6f --- /dev/null +++ b/packages/app/src/runtime/host-authority-sessions.tsx @@ -0,0 +1,35 @@ +import { useSyncExternalStore } from "react"; +import { SessionProvider } from "@/contexts/session-context"; +import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher"; +import { HostAuthorityProvider } from "./host-authority-provider"; +import { + getHostRuntimeStore, + useHostRuntimeClient, + type HostAuthorityBinding, +} from "./host-runtime"; + +/** One ordinary session adapter per runtime-owned binding, shared by all retained screens. */ +export function HostAuthoritySessions() { + const registry = getHostRuntimeStore(); + const bindings = useSyncExternalStore( + (changed) => registry.subscribeAll(changed), + registry.getAuthorityBindings, + registry.getAuthorityBindings, + ); + return bindings.map((binding) => ( + + + + + )); +} + +function AuthoritySession({ binding }: { binding: HostAuthorityBinding }) { + const client = useHostRuntimeClient(binding.reference.serverId); + if (!client) return null; + return ( + + {null} + + ); +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index baaf3f12f1..4365c7d2e5 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { indexedDB as fakeIndexedDb, IDBKeyRange as FakeIDBKeyRange } from "fake-indexeddb"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryObserver } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { readInitialDaemonConnectionHint, type HostRuntimeControllerDeps, type HostRuntimeStorage, + type HostAuthorityBinding, } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; @@ -61,6 +63,12 @@ class FakeDaemonClient { publishEvent(message: SessionOutboundMessage): void { for (const receive of this.eventListeners) receive(message); } + readonly cancelCalls: string[] = []; + cancelResponse: Promise | null = null; + async cancelAgent(agentId: string): Promise { + this.cancelCalls.push(agentId); + await this.cancelResponse; + } readonly archiveCalls: string[] = []; archiveResponse: Promise<{ archivedAt: string }> | null = null; async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { @@ -3434,7 +3442,8 @@ describe("HostRuntimeStore", () => { lastActivityAt: new Date(stale.updatedAt), archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null, attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null, - parentAgentId: null, + parent: null, + managedRelationship: null, }; return new Map([[stale.id, staleAgent]]); }); @@ -4484,6 +4493,14 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => { }); }); +function agentInBinding(binding: HostAuthorityBinding, address: AgentAddress): Agent { + const agent = binding.sessionStore + .getState() + .sessions[address.serverId]?.agents.get(address.agentId); + if (!agent) throw new Error(`Expected agent in binding ${binding.namespace}`); + return agent; +} + it("retains one authority binding for direct and routed consumers without changing a retained direct destination", async () => { globalThis.indexedDB = fakeIndexedDb; globalThis.IDBKeyRange = FakeIDBKeyRange; @@ -4628,6 +4645,50 @@ it("retains one authority binding for direct and routed consumers without changi queryClient: routed.binding.queries, unavailableMessage: "Authority unavailable", }); + const running = { + ...agent, + status: "running" as const, + turn: { + phase: "open" as const, + turnId: "owned-turn", + startedAt: new Date(), + cancellationRequestId: null, + }, + }; + direct.binding.runtime.acceptAgentSnapshot(bId, running); + routed.binding.runtime.acceptAgentSnapshot(bId, { ...running, title: "Routed" }); + const cancelGate = createDeferred(); + routeClient.cancelResponse = cancelGate.promise; + const canceling = routed.binding.runtime.cancelAgent({ serverId: bId, agentId: agent.id }); + void canceling.catch(() => {}); + await expect.poll(() => routeClient.cancelCalls).toEqual([agent.id]); + expect(clients.get(bId)!.cancelCalls).toEqual([]); + expect( + agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn + .cancellationRequestId, + ).not.toBeNull(); + expect( + agentInBinding(direct.binding, { serverId: bId, agentId: agent.id }).turn + .cancellationRequestId, + ).toBeNull(); + cancelGate.reject(new Error("Selected cancellation rejected")); + await expect(canceling).rejects.toThrow("Selected cancellation rejected"); + expect( + agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn + .cancellationRequestId, + ).toBeNull(); + expect(agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn.phase).toBe( + "open", + ); + expect(agentInBinding(direct.binding, { serverId: bId, agentId: agent.id }).turn.phase).toBe( + "open", + ); + routeClient.cancelResponse = Promise.resolve(); + await routed.binding.runtime.cancelAgent({ serverId: bId, agentId: agent.id }); + expect( + agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn + .cancellationRequestId, + ).toBeNull(); const gate = createDeferred<{ archivedAt: string }>(); routeClient.archiveResponse = gate.promise; const archiving = archive({ serverId: bId, agentId: agent.id }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index ffb8d0419d..8514015cc2 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,9 +1,20 @@ +import { + createWorkspaceLayoutStore, + type WorkspaceLayoutStoreApi, +} from "@/stores/workspace-layout-store"; +import { createPaseoApi, type PaseoAgentHandle } from "@getpaseo/client"; +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { acquireHostConnection, type HostConnectionLease, } from "@getpaseo/client/internal/host-connection"; import { createContext, useContext, useSyncExternalStore, useMemo } from "react"; import { QueryClient } from "@tanstack/react-query"; +import { + createProviderSubagentStore, + type ProviderSubagentStoreApi, +} from "@/subagents/provider-store"; +import { ManagedSubagents } from "@/subagents/managed"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; import { @@ -1490,6 +1501,8 @@ export interface HostAuthorityBinding { readonly namespace: string; readonly sessionStore: SessionStoreApi; readonly queries: QueryClient; + readonly providerSubagents: ProviderSubagentStoreApi; + readonly layout: WorkspaceLayoutStoreApi; readonly runtime: HostRuntimeStore; /** Initial acquisition only. Current availability comes from runtime snapshots. */ readonly ready: Promise; @@ -1523,11 +1536,18 @@ function initialAuthoritySnapshot(serverId: string): HostRuntimeSnapshot { } export class HostRuntimeStore { + readonly subagents: ManagedSubagents; + private readonly authority: HostAuthorityReference | null; + private readonly bindingOwner: HostRuntimeStore; + private disposed = false; + private readonly lifetime = new AbortController(); private controllers = new Map(); + private readonly controllerObservations = new WeakMap void>(); private readonly authorityBindings = new Map< string, { binding: HostAuthorityBinding; refs: number; close(): Promise } >(); + private bindingSnapshot: readonly HostAuthorityBinding[] = []; private serverListeners = new Map void>>(); private globalListeners = new Set<() => void>(); private hostListListeners = new Set<() => void>(); @@ -1563,12 +1583,17 @@ export class HostRuntimeStore { replicaRowStore?: ReplicaRowStore; sessionStore?: SessionStoreApi; queries?: QueryClient; + authority?: HostAuthorityReference; + bindingOwner?: HostRuntimeStore; resolveHostConnection?: typeof acquireHostConnection; revokePushNotifications?: typeof revokePushNotifications; }) { this.deps = input?.deps ?? createDefaultDeps(); this.sessionStore = input?.sessionStore ?? useSessionStore; this.queries = input?.queries ?? queryClient; + this.authority = input?.authority ?? null; + this.bindingOwner = input?.bindingOwner ?? this; + this.subagents = new ManagedSubagents(this); this.resolveHostConnection = input?.resolveHostConnection ?? acquireHostConnection; this.storage = input?.storage ?? AsyncStorage; this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); @@ -1576,6 +1601,59 @@ export class HostRuntimeStore { } /** A retained screen owns a lease; route identity never grants access by itself. */ + authorityFor(serverId: string): HostAuthorityReference { + return { originServerId: this.authority?.originServerId ?? serverId, serverId }; + } + + /** Resolve an ordinary agent action through the retained origin, owning its entire scope. */ + async withAgent( + address: AgentAddress, + operation: (agent: PaseoAgentHandle) => Promise, + ): Promise { + if (this.disposed) throw new Error("Host authority binding is disposed"); + const authority = this.authorityFor(address.serverId); + if (this.bindingOwner.getSnapshot(authority.originServerId)?.connectionStatus !== "online") + throw new Error("Selected host authority is unavailable"); + const lease = this.bindingOwner.acquireBinding(authority); + let api: ReturnType | null = null; + try { + const client = await lease.binding.runtime.awaitActiveClient( + address.serverId, + this.lifetime.signal, + ); + if (this.disposed) throw new Error("Host authority binding is disposed"); + api = createPaseoApi(client); + return await operation(api.agents.ref(address.agentId)); + } finally { + // Cleanup cannot erase the acknowledged outcome of a submitted action. + await Promise.allSettled([api?.dispose(), lease.release()]); + } + } + + private awaitActiveClient(serverId: string, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let stop = () => {}; + const finish = (failure: Error | null, client?: DaemonClient) => { + stop(); + signal.removeEventListener("abort", aborted); + if (failure) reject(failure); + else resolve(client!); + }; + const aborted = () => finish(new Error("Host authority binding is disposed")); + const changed = () => { + if (signal.aborted || this.disposed) return aborted(); + const snapshot = this.getSnapshot(serverId); + if (snapshot?.connectionStatus === "online" && snapshot.client) + finish(null, snapshot.client); + else if (snapshot?.connectionStatus !== "connecting") + finish(new Error(snapshot?.lastError ?? "Selected host authority is unavailable")); + }; + stop = this.subscribe(serverId, changed); + signal.addEventListener("abort", aborted, { once: true }); + changed(); + }); + } + acquireBinding(reference: HostAuthorityReference): OwnedHostAuthorityBinding { const normalized = { originServerId: reference.originServerId, serverId: reference.serverId }; const namespace = authorityNamespace(normalized); @@ -1589,6 +1667,15 @@ export class HostRuntimeStore { namespace, sessionStore: scope.runtime.sessionStore, queries, + providerSubagents: createProviderSubagentStore(), + layout: createWorkspaceLayoutStore(undefined, this.storage, { + namespace, + sessionStore: scope.runtime.sessionStore, + // COMPAT(preFleetDirectLayout): added in v0.12, remove after 2027-04-20. + ...(normalized.originServerId === normalized.serverId + ? { legacyServerId: normalized.serverId } + : {}), + }), runtime: scope.runtime, ready: scope.ready, }, @@ -1602,6 +1689,7 @@ export class HostRuntimeStore { }, }; this.authorityBindings.set(namespace, entry); + this.publishAuthorityBindings(); } entry.refs++; const retained = entry; @@ -1612,8 +1700,10 @@ export class HostRuntimeStore { if (!released) { retained.refs--; if (retained.refs === 0) { - if (this.authorityBindings.get(namespace) === retained) + if (this.authorityBindings.get(namespace) === retained) { this.authorityBindings.delete(namespace); + this.publishAuthorityBindings(); + } released = retained.close(); } else released = Promise.resolve(); } @@ -1622,6 +1712,14 @@ export class HostRuntimeStore { }; } + getAuthorityBindings = (): readonly HostAuthorityBinding[] => this.bindingSnapshot; + + private publishAuthorityBindings(): void { + this.bindingSnapshot = [...this.authorityBindings.values()].map((entry) => entry.binding); + this.version++; + for (const listener of this.globalListeners) listener(); + } + private createAuthorityScope( reference: HostAuthorityReference, namespace: string, @@ -1634,6 +1732,8 @@ export class HostRuntimeStore { const scoped = new HostRuntimeStore({ sessionStore: createSessionStore(), queries, + authority: reference, + bindingOwner: this.bindingOwner, replicaRowStore: createReplicaRowStore(namespace), storage: this.storage, deps: this.deps, @@ -1800,6 +1900,8 @@ export class HostRuntimeStore { runtime: scoped, ready, close: async () => { + scoped.disposed = true; + scoped.lifetime.abort(); stopped = true; generation++; stopOrigin(); @@ -1817,7 +1919,13 @@ export class HostRuntimeStore { new Error("Host authority binding is disposed"), ); } - await Promise.allSettled([lease?.release(), directory?.release(), acquiring, ...retired]); + await Promise.allSettled([ + lease?.release(), + directory?.release(), + acquiring, + ...retired, + scoped.subagents.releaseServer(serverId), + ]); await scoped.replicaCache.flush(); scoped.sessionStore.getState().clearSession(serverId); }, @@ -2546,6 +2654,8 @@ export class HostRuntimeStore { if (nextIds.has(serverId)) { continue; } + this.controllerObservations.get(controller)?.(); + this.controllerObservations.delete(controller); this.controllers.delete(serverId); this.snapshots.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); @@ -2587,12 +2697,13 @@ export class HostRuntimeStore { this.syncSessionReplica(host.serverId, initialSnapshot); this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus); this.connectionStatusStartedAtByServer.set(host.serverId, Date.now()); - controller.subscribe(() => { + const stopObservation = controller.subscribe(() => { const snapshot = controller.getSnapshot(); this.syncSessionReplica(snapshot.serverId, snapshot); this.syncDirectoryConnection(snapshot.serverId); this.emit(snapshot.serverId); }); + this.controllerObservations.set(controller, stopObservation); void controller .start( initialConnection @@ -2657,6 +2768,7 @@ export class HostRuntimeStore { private clearHostReplica(serverId: string): void { this.syncServerDataClient(serverId, null); + void this.subagents.releaseServer(serverId); this.sessionStore.getState().clearSession(serverId); useWorkspaceSetupStore.getState().clearServer(serverId); } @@ -2733,7 +2845,7 @@ export class HostRuntimeStore { supportsForgeAttachments, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter(serverId, this.sessionStore), // The queue drains when the agent goes idle, but the agent can start a turn of its own // in that same moment (a wake-up after background work). Steering folds the message // into that turn instead of interrupting it; with no turn running, the daemon starts @@ -2765,13 +2877,29 @@ export class HostRuntimeStore { this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition); } - beginAgentCancellation(serverId: string, agentId: string): number { + async cancelAgent(address: AgentAddress): Promise { + const client = this.getClient(address.serverId); + if ( + this.disposed || + !client || + this.getSnapshot(address.serverId)?.connectionStatus !== "online" + ) + throw new Error("Selected host authority is unavailable"); + const requestId = this.beginAgentCancellation(address.serverId, address.agentId); + try { + await client.cancelAgent(address.agentId); + } finally { + this.settleAgentCancellation(address.serverId, address.agentId, requestId); + } + } + + private beginAgentCancellation(serverId: string, agentId: string): number { const requestId = ++this.nextCancellationRequestId; this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId }); return requestId; } - settleAgentCancellation(serverId: string, agentId: string, requestId: number): void { + private settleAgentCancellation(serverId: string, agentId: string, requestId: number): void { this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId }); } @@ -2819,6 +2947,10 @@ export class HostRuntimeStore { return this.version; } + getServerInfo(serverId: string) { + return this.sessionStore.getState().sessions[serverId]?.serverInfo ?? null; + } + getClient(serverId: string): DaemonClient | null { return this.snapshots.get(serverId)?.client ?? null; } diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index c48777a3e2..090cfd569a 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -1124,3 +1124,28 @@ it("retains semantic agent messages across disk-cache reconstruction", async () await writer.flush(); expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value); }); + +it("retains owner membership and explicit clearing across disk-cache reconstruction", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const value = directory(); + const original = agent(); + const parent = { serverId: SERVER_ID, agentId: "parent" }; + const managedRelationship = { + id: "relationship", + parent, + parentWorkspace: { serverId: SERVER_ID, workspaceId: "workspace-1" }, + }; + value.agents.set(original.id, { ...original, parent, managedRelationship }); + commitDirectory(writer, SERVER_ID, value); + await writer.flush(); + expect( + (await createCache(storage).readDirectory(SERVER_ID)).agents.get(original.id), + ).toMatchObject({ parent, managedRelationship }); + value.agents.set(original.id, { ...original, parent, managedRelationship: null }); + commitDirectory(writer, SERVER_ID, value); + await writer.flush(); + expect( + (await createCache(storage).readDirectory(SERVER_ID)).agents.get(original.id), + ).toMatchObject({ parent, managedRelationship: null }); +}); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 681d11a2c4..1e2c787ff6 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,6 +1,8 @@ +import { AgentAddressSchema } from "@getpaseo/protocol/addresses"; import { z } from "zod"; import { AgentStatusSchema, + ManagedAgentRelationshipSchema, AgentTimelineItemPayloadSchema, WorkspaceGitHubRuntimePayloadSchema, } from "@getpaseo/protocol/messages"; @@ -221,6 +223,8 @@ const StoredProjectPlacementSchema = z.strictObject({ }); const StoredAgentSnapshotSchema = z.strictObject({ + parent: AgentAddressSchema.nullable().optional(), + managedRelationship: ManagedAgentRelationshipSchema.nullable().optional(), id: z.string(), provider: AgentProviderSchema, cwd: z.string(), @@ -611,6 +615,8 @@ function serializeAgentTurn(agent: Agent): NonNullable { function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, + parent: agent.parent, + managedRelationship: agent.managedRelationship, provider: agent.provider, cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx index de13097503..2a10fdb12a 100644 --- a/packages/app/src/screens/workspace/sheet-tabs.tsx +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -36,7 +36,11 @@ import { WorkspacePaneContent, type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; -import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; +import { + useWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, + type SheetTab, +} from "@/stores/workspace-layout-store"; const SNAP_POINTS = ["92%"]; const NO_SHEETS: readonly SheetTab[] = []; @@ -108,6 +112,7 @@ interface SheetTabSheetProps extends Omit workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, ); @@ -122,12 +127,10 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) async (tabId: string) => { await onCloseTab(tabId); // A declined unsaved-changes prompt leaves the tab in its sheet. - const open = workspaceKey - ? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey] - : undefined; + const open = workspaceKey ? layout.getState().sheetsByWorkspace[workspaceKey] : undefined; return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; }, - [onCloseTab, workspaceKey], + [layout, onCloseTab, workspaceKey], ); return ( <> diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts index 44fa3f8171..772088bbf8 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts @@ -219,3 +219,15 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => { ).toBe(true); }); }); + +it("retains separate screens for the same destination workspace under distinct origins", () => { + const direct = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "B" } }; + const throughA = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } }; + const entries = reconcileRetainedWorkspaceSelections({ + currentEntries: [{ selection: direct, inactiveSince: null }], + activeSelection: throughA, + now: 10, + }); + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.selection)).toEqual([throughA, direct]); +}); diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.ts b/packages/app/src/screens/workspace/workspace-deck-retention.ts index 07b1be0675..caa2cd8c34 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.ts @@ -1,3 +1,4 @@ +import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10; @@ -39,14 +40,19 @@ interface ResolveWorkspaceDeckEntriesInput { } export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string { - return `${selection.serverId}:${selection.workspaceId}`; + return JSON.stringify([ + workspaceSelectionAuthority(selection).originServerId, + selection.serverId, + selection.workspaceId, + ]); } export function areWorkspaceSelectionsEqual( left: ActiveWorkspaceSelection | null, right: ActiveWorkspaceSelection | null, ): boolean { - return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId; + if (!left || !right) return left === right; + return getWorkspaceSelectionKey(left) === getWorkspaceSelectionKey(right); } export function areRetainedWorkspaceSelectionListsEqual( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 530c1a07d0..cdb7d68a62 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -5,7 +5,7 @@ import { type InlinePathTarget, } from "@/assistant-file-links"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; -import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; +import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation"; import type { LayoutChangeEvent } from "react-native"; import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; import { ExplorerFrame } from "./explorer-sidebar/frame"; @@ -67,19 +67,17 @@ import { getOrCreateClientId } from "@/utils/client-id"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { - isExplorerSidebarOpen, - openExplorerSidebarView, - toggleExplorerSidebar, + useExplorerSidebarActions, useIsExplorerSidebarOpen, } from "@/workspace-tabs/explorer-sidebar"; -import { - openPreferredWorkspacePreview, - openPreferredWorkspaceTarget, - openWorkspaceTargetBeside, -} from "@/workspace-tabs/open-beside"; -import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; +import { useWorkspaceTargetActions } from "@/workspace-tabs/open-beside"; +import { useSupportingViewActions } from "@/workspace-tabs/open-supporting-view"; import { traceInstant } from "@/performance/native-trace"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { + useSessionStore, + useSessionStoreApi, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { canDismissPaneInLayout, collectAllTabs, @@ -111,7 +109,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store"; import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { useVisibleAgentIds } from "./visible-agent-ids"; import { - getHostRuntimeStore, + useHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, @@ -680,12 +678,13 @@ function useWorkspaceRouteActions(normalizedServerId: string): { handleDismissMissingWorkspace: () => void; } { const router = useRouter(); + const runtime = useHostRuntimeStore(); const handleRetryHost = useCallback(() => { if (!normalizedServerId) { return; } - void getHostRuntimeStore().runProbeCycleNow(normalizedServerId); - }, [normalizedServerId]); + void runtime.runProbeCycleNow(normalizedServerId); + }, [normalizedServerId, runtime]); const handleManageHost = useCallback(() => { if (!normalizedServerId) { return; @@ -1018,6 +1017,14 @@ function WorkspaceScreenContent({ recoveryRequested, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); + const runtime = useHostRuntimeStore(); + const sessionStore = useSessionStoreApi(); + const { revealWorkspaceTab } = useWorkspaceTabNavigation(); + const { openPreferredWorkspacePreview, openPreferredWorkspaceTarget, openWorkspaceTargetBeside } = + useWorkspaceTargetActions(); + const { openWorkspacePullRequest } = useSupportingViewActions(); + const { isExplorerSidebarOpen, openExplorerSidebarView, toggleExplorerSidebar } = + useExplorerSidebarActions(); const _insets = useSafeAreaInsets(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); @@ -1040,19 +1047,16 @@ function WorkspaceScreenContent({ const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); useEffect(() => { if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; - void getHostRuntimeStore() + void runtime .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) .catch(() => undefined); - }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); + }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor, runtime]); // The focused workspace keeps its script status live even when no sidebar or viewed agent // timeline demands the directory. Retained, hidden workspaces release it. useEffect(() => { if (!isRouteFocused || !normalizedServerId || !normalizedWorkspaceId) return; - return getHostRuntimeStore().acquireScriptStatusDemand( - normalizedServerId, - normalizedWorkspaceId, - ); - }, [isRouteFocused, normalizedServerId, normalizedWorkspaceId]); + return runtime.acquireScriptStatusDemand(normalizedServerId, normalizedWorkspaceId); + }, [isRouteFocused, normalizedServerId, normalizedWorkspaceId, runtime]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); @@ -1362,7 +1366,7 @@ function WorkspaceScreenContent({ isCompact: isMobile, workspaceKey: persistenceKey, }); - }, [isMobile, persistenceKey]); + }, [isMobile, persistenceKey, toggleExplorerSidebar]); const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); @@ -1428,11 +1432,9 @@ function WorkspaceScreenContent({ }); useEffect(() => { for (const agentId of visibleAgentIds) { - void getHostRuntimeStore() - .prepareAgentTimeline(normalizedServerId, agentId) - .catch(() => undefined); + void runtime.prepareAgentTimeline(normalizedServerId, agentId).catch(() => undefined); } - }, [normalizedServerId, visibleAgentIds]); + }, [normalizedServerId, visibleAgentIds, runtime]); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; @@ -1586,7 +1588,7 @@ function WorkspaceScreenContent({ } revealWorkspaceTab(persistenceKey, tabId); }, - [persistenceKey], + [persistenceKey, revealWorkspaceTab], ); const selectTabInPane = useCallback( (paneId: string, tabId: string) => { @@ -1715,7 +1717,7 @@ function WorkspaceScreenContent({ requestFileNavigation(tabId); } }, - [openInSidePane, persistenceKey, requestFileNavigation], + [openInSidePane, persistenceKey, requestFileNavigation, openPreferredWorkspaceTarget], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -1980,7 +1982,7 @@ function WorkspaceScreenContent({ } const agent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; let closePolicy = resolveCloseAgentTabPolicy(agent); const isRunning = agent?.status === "running"; @@ -1998,7 +2000,7 @@ function WorkspaceScreenContent({ } if (closePolicy.kind === "layout-only") { - const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client; + const sessionClient = sessionStore.getState().sessions[normalizedServerId]?.client; if (!sessionClient) { toast.error(t("common.errors.daemonClientUnavailable")); return; @@ -2009,7 +2011,7 @@ function WorkspaceScreenContent({ labels: { [getOpenAgentTabLabel(clientId)]: "false" }, }); const latestAgent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; closePolicy = resolveCloseAgentTabPolicy(latestAgent); } catch (error) { console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId }); @@ -2042,6 +2044,7 @@ function WorkspaceScreenContent({ persistenceKey, t, toast, + sessionStore, ], ); @@ -2157,7 +2160,7 @@ function WorkspaceScreenContent({ async (agentId: string) => { if (!agentId) return; const agent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; const providerSessionId = agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; if (!agent || !providerSessionId) { @@ -2182,7 +2185,7 @@ function WorkspaceScreenContent({ toast.error(t("workspace.tabs.toasts.copyFailed")); } }, - [normalizedServerId, toast, t], + [normalizedServerId, toast, t, sessionStore], ); const handleReloadAgent = useCallback( @@ -2199,9 +2202,9 @@ function WorkspaceScreenContent({ // returns reset:true. Without a cursor, the server returns reset:false // and the client takes the incremental path, where new-epoch rows are // dropped against the stale cursor. - const sessionState = useSessionStore.getState().sessions[normalizedServerId]; + const sessionState = sessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); - await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { + await runtime.fetchAgentTimeline(normalizedServerId, agentId, { direction: "tail", projection: "projected", ...(currentCursor @@ -2215,7 +2218,7 @@ function WorkspaceScreenContent({ ); } }, - [client, isConnected, normalizedServerId, toast, t], + [client, isConnected, normalizedServerId, toast, t, runtime, sessionStore], ); const handleCopyWorkspacePath = useCallback(async () => { @@ -2272,7 +2275,7 @@ function WorkspaceScreenContent({ } const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => { - const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId); + const agent = sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId); return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive"; }); const modifiedCount = tabsToClose.filter( @@ -2311,7 +2314,7 @@ function WorkspaceScreenContent({ labels: { [getOpenAgentTabLabel(clientId)]: "false" }, }); const latestAgent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") { await archiveAgent({ serverId: normalizedServerId, agentId }); } @@ -2342,6 +2345,7 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, persistenceKey, t, + sessionStore, ], ); @@ -2492,6 +2496,9 @@ function WorkspaceScreenContent({ openWorkspaceTabFocused, persistenceKey, pullRequestOpenLocation, + openWorkspaceTargetBeside, + openWorkspacePullRequest, + openExplorerSidebarView, ], ); @@ -2651,6 +2658,7 @@ function WorkspaceScreenContent({ isGitCheckout, isMobile, persistenceKey, + openExplorerSidebarView, ], ); @@ -2678,7 +2686,7 @@ function WorkspaceScreenContent({ }); return true; }, - [handleToggleExplorerSidebar, isMobile, persistenceKey], + [handleToggleExplorerSidebar, isMobile, persistenceKey, isExplorerSidebarOpen], ); const handleWorkspacePaneAction = useCallback( @@ -3030,6 +3038,8 @@ function WorkspaceScreenContent({ setWorkspaceTabState, explorerSidebarPaneId, lastMainPaneId, + openPreferredWorkspacePreview, + openWorkspaceTargetBeside, ], ); const focusedPaneId = useMemo( diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts index e5fc979be3..3e0a8d402a 100644 --- a/packages/app/src/stores/last-workspace-selection.test.ts +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -71,3 +71,20 @@ describe("last workspace selection", () => { }); }); }); + +it("persists and restores selected origin independently of a destination and distinguishes authority changes", async () => { + const storage = new DelayedWorkspaceSelectionStorage(); + const store = createLastWorkspaceSelectionStore(storage); + store.remember({ serverId: "B", workspaceId: "workspace", authority: { originServerId: "B" } }); + store.remember({ serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } }); + expect(store.getSelection()).toEqual({ + serverId: "B", + workspaceId: "workspace", + authority: { originServerId: "A" }, + }); + const restored = createLastWorkspaceSelectionStore(storage); + const hydration = restored.hydrate(); + storage.finishHydrationWith(storage.getSavedSelection()); + await hydration; + expect(restored.getSelection()).toEqual(store.getSelection()); +}); diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts index 6e689e0a48..dcd94c03da 100644 --- a/packages/app/src/stores/last-workspace-selection.ts +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -3,6 +3,20 @@ import { z } from "zod"; export interface ActiveWorkspaceSelection { serverId: string; workspaceId: string; + authority?: { originServerId: string }; +} + +/** COMPAT(workspaceRouteOrigin): added in v0.12, remove after 2027-04-20. + * Pre-fleet routes and stored selections name the destination's own authority. + */ +export function workspaceSelectionAuthority(selection: ActiveWorkspaceSelection): { + originServerId: string; + serverId: string; +} { + return { + originServerId: selection.authority?.originServerId ?? selection.serverId, + serverId: selection.serverId, + }; } export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection"; @@ -16,6 +30,7 @@ export interface LastWorkspaceSelectionStorage { const ActiveWorkspaceSelectionSchema: z.ZodType = z.strictObject({ serverId: z.string().trim().min(1), workspaceId: z.string().trim().min(1), + authority: z.strictObject({ originServerId: z.string().trim().min(1) }).optional(), }); function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { @@ -54,7 +69,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio } if ( selection?.serverId === normalized.serverId && - selection.workspaceId === normalized.workspaceId + selection.workspaceId === normalized.workspaceId && + workspaceSelectionAuthority(selection).originServerId === + workspaceSelectionAuthority(normalized).originServerId ) { return; } diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index be001c5f2d..c0d8ff3f54 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -1,5 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useLocalSearchParams, usePathname } from "expo-router"; +import { useGlobalSearchParams, useLocalSearchParams, usePathname } from "expo-router"; import { useEffect, useSyncExternalStore } from "react"; import { createLastWorkspaceSelectionStore, @@ -75,15 +75,24 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { serverId?: string | string[]; workspaceId?: string | string[]; }>(); - const selection = parseActiveWorkspaceSelection({ pathname: usePathname(), params }); + const { origin } = useGlobalSearchParams<{ origin?: string | string[] }>(); + const selection = parseActiveWorkspaceSelection({ + pathname: usePathname(), + params: { ...params, origin }, + }); const serverId = selection?.serverId ?? null; const workspaceId = selection?.workspaceId ?? null; + const originServerId = selection?.authority?.originServerId; useEffect(() => { if (!serverId || !workspaceId) { return; } - lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); - }, [serverId, workspaceId]); + lastWorkspaceSelectionStore.remember({ + serverId, + workspaceId, + ...(originServerId ? { authority: { originServerId } } : {}), + }); + }, [serverId, workspaceId, originServerId]); return selection; } diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index f8bb8ccf9f..1512b1bb6e 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -5,6 +5,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseHostWorkspaceRouteFromPathname, + withHostRouteAuthority, } from "@/utils/host-routes"; import { normalizeWorkspaceOpaqueId, @@ -20,6 +21,7 @@ export interface RouteSelectionInput { params: { serverId?: string | string[]; workspaceId?: string | string[]; + origin?: string | string[]; }; } @@ -29,6 +31,7 @@ export interface NavigateToWorkspaceInput { target?: WorkspaceTabTarget; pin?: boolean; placement?: WorkspaceTabPlacement; + authority?: { originServerId: string }; } export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { @@ -57,6 +60,7 @@ function getParamValue(value: string | string[] | undefined): string { function parseWorkspaceSelectionFromRouteParams(params: { serverId?: string | string[]; workspaceId?: string | string[]; + origin?: string | string[]; }): ActiveWorkspaceSelection | null { const serverId = getParamValue(params.serverId); const workspaceValue = getParamValue(params.workspaceId); @@ -64,7 +68,8 @@ function parseWorkspaceSelectionFromRouteParams(params: { if (!serverId || !workspaceId) { return null; } - return { serverId, workspaceId }; + const originServerId = getParamValue(params.origin); + return { serverId, workspaceId, ...(originServerId ? { authority: { originServerId } } : {}) }; } export function parseActiveWorkspaceSelection( @@ -72,7 +77,10 @@ export function parseActiveWorkspaceSelection( ): ActiveWorkspaceSelection | null { const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname); if (routeSelection) { - return routeSelection; + const originServerId = getParamValue(input.params.origin); + return routeSelection.authority || !originServerId + ? routeSelection + : { ...routeSelection, authority: { originServerId } }; } if (input.pathname !== "/" && input.pathname !== "") { @@ -86,13 +94,16 @@ export function navigateToWorkspace( input: NavigateToWorkspaceInput, deps: NavigateToWorkspaceDeps, ): string { - const workspaces = deps.getSessionWorkspaces(input.serverId); + // COMPAT(unboundWorkspaceNavigation): added in v0.12, remove after 2027-04-20. + // Bound routes prepare their intent only after reacquiring the selected authority. + const workspaces = input.authority ? null : deps.getSessionWorkspaces(input.serverId); const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId, }); const shouldDeferAgentOpen = Boolean( - input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()), + input.target?.kind === "agent" && + (input.authority || !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()), ); if (input.target) { if (!shouldDeferAgentOpen) { @@ -114,7 +125,7 @@ export function navigateToWorkspace( } } - const route = + const baseRoute = input.target?.kind === "agent" && shouldDeferAgentOpen ? buildHostWorkspaceOpenRoute( input.serverId, @@ -122,7 +133,12 @@ export function navigateToWorkspace( `agent:${input.target.agentId}`, ) : buildHostWorkspaceRoute(input.serverId, input.workspaceId); - deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId }); + const route = input.authority ? withHostRouteAuthority(baseRoute, input.authority) : baseRoute; + deps.rememberLastWorkspace({ + serverId: input.serverId, + workspaceId: input.workspaceId, + ...(input.authority ? { authority: input.authority } : {}), + }); deps.navigateToRoute(route); return route; } diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index c53eae4c49..f08db9244a 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -40,7 +40,8 @@ function createTestAgent(agentId: string): Agent { title: null, cwd: "/repo", model: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, }; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index b7f8747db6..6e50b1ab37 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -1,3 +1,5 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import type { ManagedAgentRelationship } from "@getpaseo/protocol/messages"; import { createCreateFlowStore, defaultCreateFlowStore } from "@/stores/create-flow-store"; import { createAgentInitializations, @@ -103,7 +105,8 @@ export interface Agent { attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; archivedAt?: Date | null; - parentAgentId: string | null; + parent: AgentAddress | null; + managedRelationship: ManagedAgentRelationship | null; labels: Record; projectPlacement?: ProjectPlacementPayload | null; } diff --git a/packages/app/src/stores/workspace-layout-storage.ts b/packages/app/src/stores/workspace-layout-storage.ts index c105d40a06..b285e3f52a 100644 --- a/packages/app/src/stores/workspace-layout-storage.ts +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -1,3 +1,4 @@ +import type { StateStorage } from "zustand/middleware"; import { z } from "zod"; import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions"; @@ -113,3 +114,42 @@ export const WorkspaceLayoutPersistedStateSchema = z.strictObject({ // and ignored so upgrading does not discard the layout. Remove after 2027-08-20. acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(), }); + +// COMPAT(preFleetDirectLayout): added in v0.12, remove after 2027-04-20. +// Only an existing self-origin layout may seed its new namespace. Once written, the namespace wins. +export function workspaceLayoutStorageWithLegacySeed( + storage: StateStorage, + serverId: string, +): StateStorage { + return { + setItem: (key, value) => storage.setItem(key, value), + removeItem: (key) => storage.removeItem(key), + async getItem(key) { + const current = await storage.getItem(key); + if (current !== null) return current; + const legacy = await storage.getItem("workspace-layout-state"); + if (!legacy) return null; + try { + const parsed = z + .object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number() }) + .safeParse(JSON.parse(legacy)); + if (!parsed.success) return null; + const prefix = `${serverId}:`; + const state = Object.fromEntries( + Object.entries(parsed.data.state).map(([field, records]) => [ + field, + records && + Object.fromEntries( + Object.entries(records).filter(([workspaceKey]) => workspaceKey.startsWith(prefix)), + ), + ]), + ); + const seeded = JSON.stringify({ state, version: parsed.data.version }); + await storage.setItem(key, seeded); + return seeded; + } catch { + return null; + } + }, + }; +} diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index a989dc0dd3..2c1ba6f14f 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-native-async-storage/async-storage", () => { @@ -17,7 +18,7 @@ vi.mock("@react-native-async-storage/async-storage", () => { import AsyncStorage from "@react-native-async-storage/async-storage"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createSessionStore, useSessionStore, type Agent } from "@/stores/session-store"; import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; @@ -4477,7 +4478,15 @@ describe("compact workspace sheet", () => { ); } - function seedAgents(agents: Array>): void { + function seedAgents( + agents: Array< + Pick & { + parentAgentId?: string; + parent?: null; + managedRelationship?: null; + } + >, + ): void { const timestamp = new Date("2026-04-21T10:00:00.000Z"); useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); useSessionStore.getState().setAgents( @@ -4521,6 +4530,9 @@ describe("compact workspace sheet", () => { labels: {}, projectPlacement: null, ...agent, + ...managedAgentFields( + agent.parentAgentId ? { serverId: "server-1", agentId: agent.parentAgentId } : null, + ), } satisfies Agent, ]), ), @@ -4719,7 +4731,7 @@ describe("compact workspace sheet", () => { it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, { id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" }, ]); @@ -4756,7 +4768,7 @@ describe("compact workspace sheet", () => { it("gives a sheet target opened from inside the sheet the sheet's own parent", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, ]); const store = createCompactStore(); @@ -4785,9 +4797,9 @@ describe("compact workspace sheet", () => { it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, - { id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "second-root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, ]); const store = createCompactStore(); const rootTabId = store @@ -4963,7 +4975,7 @@ describe("compact workspace sheet", () => { it("closes the sheet when its agent leaves the workspace", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, ]); const store = createCompactStore(); @@ -4990,7 +5002,7 @@ describe("compact workspace sheet", () => { it("drops a departed agent's sheet from the middle of the stack", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, ]); const store = createCompactStore(); @@ -5018,7 +5030,7 @@ describe("compact workspace sheet", () => { it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, ]); const store = createCompactStore(); @@ -5041,8 +5053,8 @@ describe("compact workspace sheet", () => { expect(topSheet(store)?.tab.tabId).toBe(sheetTabId); seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, - { id: "child", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, + { id: "child", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, ]); store.getState().reconcileTabs(workspaceKey, snapshot); @@ -5057,7 +5069,7 @@ describe("compact workspace sheet", () => { it("re-resolves the sheet policy when the sheet tab gets another target", () => { seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, ]); const store = createCompactStore(); @@ -5111,10 +5123,35 @@ describe("compact workspace sheet", () => { ).toBe(tabId); }); + it("uses its authority's membership for compact placement beside an equal direct address", () => { + seedAgents([ + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const boundStore = createSessionStore(); + boundStore.getState().initializeSession("server-1", null as unknown as DaemonClient); + boundStore + .getState() + .setAgents("server-1", new Map(useSessionStore.getState().sessions["server-1"].agents)); + const directChild = useSessionStore.getState().sessions["server-1"].agents.get("child")!; + useSessionStore + .getState() + .setAgents("server-1", new Map([["child", { ...directChild, managedRelationship: null }]])); + const layout = createWorkspaceLayoutStore(undefined, undefined, { sessionStore: boundStore }); + layout.getState().setLayoutMode("compact"); + const input = { + workspaceKey, + target: { kind: "agent" as const, agentId: "child" }, + intent: "reveal" as const, + }; + expect(layout.getState().opensInSheet(input)).toBe(true); + expect(createCompactStore().getState().opensInSheet(input)).toBe(false); + }); + it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => { const store = createCompactStore(); seedAgents([ - { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" }, { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, ]); const received: string[][] = []; @@ -5131,3 +5168,105 @@ describe("compact workspace sheet", () => { expect(received).toEqual([[], ["child"], []]); }); }); + +it("persists independent layouts for equal destination workspaces under separate authority namespaces", async () => { + const values = new Map(); + const storage = { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { + values.set(key, value); + }, + removeItem: async (key: string) => { + values.delete(key); + }, + }; + const direct = createWorkspaceLayoutStore(undefined, storage, { namespace: "B-B" }); + const routed = createWorkspaceLayoutStore(undefined, storage, { namespace: "A-B" }); + await Promise.all([direct.persist.rehydrate(), routed.persist.rehydrate()]); + direct.getState().openTab({ + workspaceKey: "B:workspace", + target: { kind: "agent", agentId: "direct" }, + intent: "reveal", + }); + routed.getState().openTab({ + workspaceKey: "B:workspace", + target: { kind: "agent", agentId: "routed" }, + intent: "reveal", + }); + const reopenedDirect = createWorkspaceLayoutStore(undefined, storage, { namespace: "B-B" }); + await reopenedDirect.persist.rehydrate(); + const reopened = createWorkspaceLayoutStore(undefined, storage, { namespace: "A-B" }); + await reopened.persist.rehydrate(); + expect( + reopenedDirect + .getState() + .getWorkspaceTabs("B:workspace") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: "direct" }]); + expect( + reopened + .getState() + .getWorkspaceTabs("B:workspace") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: "routed" }]); +}); + +it("upgrades pre-feature direct layout once without importing another server's tabs", async () => { + const values = new Map(); + const storage = { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { + values.set(key, value); + }, + removeItem: async (key: string) => { + values.delete(key); + }, + }; + const legacy = createWorkspaceLayoutStore(undefined, storage); + await legacy.persist.rehydrate(); + legacy.getState().openTab({ + workspaceKey: "B:workspace", + target: { kind: "agent", agentId: "saved" }, + intent: "reveal", + }); + legacy.getState().openTab({ + workspaceKey: "C:workspace", + target: { kind: "agent", agentId: "unrelated" }, + intent: "reveal", + }); + const upgraded = createWorkspaceLayoutStore(undefined, storage, { + namespace: "direct-B", + legacyServerId: "B", + }); + await upgraded.persist.rehydrate(); + expect( + upgraded + .getState() + .getWorkspaceTabs("B:workspace") + .some((tab) => tab.target.kind === "agent" && tab.target.agentId === "saved"), + ).toBe(true); + expect(upgraded.getState().layoutByWorkspace["C:workspace"]).toBeUndefined(); + upgraded.getState().openTab({ + workspaceKey: "B:workspace", + target: { kind: "agent", agentId: "new" }, + intent: "reveal", + }); + legacy.getState().openTab({ + workspaceKey: "B:workspace", + target: { kind: "agent", agentId: "later-global" }, + intent: "reveal", + }); + const restored = createWorkspaceLayoutStore(undefined, storage, { + namespace: "direct-B", + legacyServerId: "B", + }); + await restored.persist.rehydrate(); + expect( + restored + .getState() + .getWorkspaceTabs("B:workspace") + .flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])), + ).toEqual(["saved", "new"]); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 01c932605e..011d7ffa57 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,9 +1,13 @@ +import { useSessionStore, type SessionStoreApi } from "@/stores/session-store"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useEffect, useState } from "react"; -import { create } from "zustand"; +import { createContext, useContext, useEffect, useState } from "react"; +import { create, useStore } from "zustand"; import { persist, type StateStorage } from "zustand/middleware"; import type { z } from "zod"; -import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage"; +import { + WorkspaceLayoutPersistedStateSchema, + workspaceLayoutStorageWithLegacySeed, +} from "./workspace-layout-storage"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { @@ -763,12 +767,13 @@ function resolveSheetPolicy( workspaceKey: string, target: WorkspaceTabTarget, intent: WorkspaceTabOpenIntent, + policy: typeof resolveCompactSheetPolicy, ): CompactSheetPolicy | null { if (state.layoutMode !== "compact" || intent === "background") return null; if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) { return null; } - return resolveCompactSheetPolicy(workspaceKey, target); + return policy(workspaceKey, target); } /** @@ -880,8 +885,9 @@ function resolveSheetPlacement( workspaceKey: string, target: WorkspaceTabTarget, intent: WorkspaceTabOpenIntent, + resolvePolicy: typeof resolveCompactSheetPolicy, ): { policy: CompactSheetPolicy; keptIndex: number | null } | null { - const policy = resolveSheetPolicy(state, workspaceKey, target, intent); + const policy = resolveSheetPolicy(state, workspaceKey, target, intent, resolvePolicy); if (!policy) return null; if (intent !== "reveal") return { policy, keptIndex: null }; const sheets = getSheets(state, workspaceKey); @@ -926,10 +932,11 @@ function sheetAgentNoLongerFits( workspaceKey: string, sheet: SheetTab, snapshot: WorkspaceTabSnapshot, + policy: typeof resolveCompactSheetPolicy, ): boolean { if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true; - return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null; + return policy(workspaceKey, sheet.tab.target) === null; } /** @@ -997,7 +1004,10 @@ function createPersistedStateProjection() { export function createWorkspaceLayoutStore( ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, storage: StateStorage = AsyncStorage, + persistence: { namespace?: string; legacyServerId?: string; sessionStore?: SessionStoreApi } = {}, ) { + const compactSheetPolicy: typeof resolveCompactSheetPolicy = (workspaceKey, target) => + resolveCompactSheetPolicy(workspaceKey, target, persistence.sessionStore ?? useSessionStore); return create()( persist( (set, get) => ({ @@ -1050,6 +1060,7 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey, normalizedTarget, input.intent, + compactSheetPolicy, ); if (sheetPlacement) { const sheets = getSheets(get(), normalizedWorkspaceKey); @@ -1163,8 +1174,13 @@ export function createWorkspaceLayoutStore( const normalizedTarget = normalizeWorkspaceTabTarget(input.target); if (!normalizedWorkspaceKey || !normalizedTarget) return false; return ( - resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !== - null + resolveSheetPlacement( + get(), + normalizedWorkspaceKey, + normalizedTarget, + input.intent, + compactSheetPolicy, + ) !== null ); }, setLayoutMode: (mode) => { @@ -1493,7 +1509,7 @@ export function createWorkspaceLayoutStore( const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) { - const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); + const policy = compactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); const next: WorkspaceLayoutStore = { ...get(), ...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({ @@ -1650,7 +1666,13 @@ export function createWorkspaceLayoutStore( } const sheets = getSheets(state, normalizedWorkspaceKey); const fittingSheets = sheets.filter( - (sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot), + (sheet) => + !sheetAgentNoLongerFits( + normalizedWorkspaceKey, + sheet, + snapshot, + compactSheetPolicy, + ), ); const sheetAgentGone = fittingSheets.length !== sheets.length; if ( @@ -2213,11 +2235,19 @@ export function createWorkspaceLayoutStore( }, }), { - name: "workspace-layout-state", + name: persistence.namespace + ? `workspace-layout-state:${persistence.namespace}` + : "workspace-layout-state", version: WORKSPACE_LAYOUT_PERSIST_VERSION, - storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, { - skipUnchanged: true, - }), + storage: createValidatedPersistStorage( + persistence.legacyServerId + ? workspaceLayoutStorageWithLegacySeed(storage, persistence.legacyServerId) + : storage, + WorkspaceLayoutPersistedStateSchema, + { + skipUnchanged: true, + }, + ), migrate: (persistedState, version) => migrateWorkspaceLayoutPersistedState(persistedState, version, ids), partialize: createPersistedStateProjection(), @@ -2280,7 +2310,19 @@ export function createWorkspaceLayoutStore( ); } -export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); +export type WorkspaceLayoutStoreApi = ReturnType; +export const defaultWorkspaceLayoutStore = createWorkspaceLayoutStore(); +export const WorkspaceLayoutStoreContext = createContext(null); +export function useWorkspaceLayoutStoreApi(): WorkspaceLayoutStoreApi { + return useContext(WorkspaceLayoutStoreContext) ?? defaultWorkspaceLayoutStore; +} +// COMPAT(unboundWorkspaceLayout): added in v0.12, remove after 2027-04-20. +// Existing app-shell callers retain the default store; bound consumers capture their owner. +export const useWorkspaceLayoutStore = Object.assign(function useWorkspaceLayoutStore( + selector: (state: WorkspaceLayoutStore) => T, +): T { + return useStore(useWorkspaceLayoutStoreApi(), selector); +}, defaultWorkspaceLayoutStore); /** * The agent tabs that exist right now, including sheet tabs, across every workspace of this host, @@ -2331,20 +2373,19 @@ export function observeOpenWorkspaceAgentIds( } export function useWorkspaceLayoutStoreHydrated(): boolean { - const [hasHydrated, setHasHydrated] = useState(() => - useWorkspaceLayoutStore.persist.hasHydrated(), - ); + const store = useWorkspaceLayoutStoreApi(); + const [hasHydrated, setHasHydrated] = useState(() => store.persist.hasHydrated()); useEffect(() => { - if (useWorkspaceLayoutStore.persist.hasHydrated()) { + if (store.persist.hasHydrated()) { setHasHydrated(true); return; } - return useWorkspaceLayoutStore.persist.onFinishHydration(() => { + return store.persist.onFinishHydration(() => { setHasHydrated(true); }); - }, []); + }, [store]); return hasHydrated; } diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 5f04a39bd9..346848a290 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -7,6 +8,7 @@ import { } from "@/workspace-tabs/agent-visibility"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { selectSubagentsForParent } from "@/subagents/select"; +import { membershipFixture, relationshipFixture } from "@/subagents/test-fixtures"; import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions"; import { useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; @@ -67,7 +69,8 @@ const AGENT_DEFAULTS: Agent = { attentionReason: null, attentionTimestamp: null, archivedAt: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, projectPlacement: null, }; @@ -142,7 +145,7 @@ describe("workspace subagents integration", () => { const child = makeAgent({ id: "child-agent", - parentAgentId: "parent-agent", + ...managedAgentFields({ serverId: SERVER_ID, agentId: "parent-agent" }), title: "Child agent", }); const parent = makeAgent({ @@ -163,13 +166,12 @@ describe("workspace subagents integration", () => { expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); expect( selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-agent", - }, + membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [ + relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child), + ]), + { originServerId: SERVER_ID, serverId: SERVER_ID }, new Set(), - ).map((row) => row.id), + ).map((row) => row.address.agentId), ).toEqual(["child-agent"]); }); @@ -186,7 +188,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: "parent-agent", + ...managedAgentFields({ serverId: SERVER_ID, agentId: "parent-agent" }), title: "Child agent", }); @@ -196,16 +198,15 @@ describe("workspace subagents integration", () => { expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); expect( selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-agent", - }, + membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [ + relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child), + ]), + { originServerId: SERVER_ID, serverId: SERVER_ID }, new Set(), - ).map((row) => row.id), + ).map((row) => row.address.agentId), ).toEqual(["child-agent"]); - appendAgent({ ...child, parentAgentId: null, labels: {} }); + appendAgent({ ...child, parent: null, managedRelationship: null, labels: {} }); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([ @@ -214,11 +215,12 @@ describe("workspace subagents integration", () => { ]); expect( selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-agent", - }, + membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [ + relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child, { + phase: "detached", + }), + ]), + { originServerId: SERVER_ID, serverId: SERVER_ID }, new Set(), ), ).toEqual([]); @@ -238,7 +240,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: parent.id, + ...managedAgentFields({ serverId: SERVER_ID, agentId: parent.id }), title: "Cross-workspace child", }); @@ -248,13 +250,12 @@ describe("workspace subagents integration", () => { expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_child-agent"]); expect( selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: parent.id, - }, + membershipFixture({ serverId: SERVER_ID, agentId: parent.id }, [ + relationshipFixture({ serverId: SERVER_ID, agentId: parent.id }, child), + ]), + { originServerId: SERVER_ID, serverId: SERVER_ID }, new Set(), - ).map((row) => row.id), + ).map((row) => row.address.agentId), ).toEqual([child.id]); }); @@ -271,7 +272,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: parent.id, + ...managedAgentFields({ serverId: SERVER_ID, agentId: parent.id }), title: "Child agent", }); diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 5c1615d96c..cffe3d7d90 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -2,10 +2,20 @@ import { describe, expect, it } from "vitest"; import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished"; import type { PaseoSubagentRow, ProviderSubagentRow } from "./select"; +const address = (agentId: string) => ({ serverId: "server-1", agentId }); +const parent = address("parent"); + function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow { return { kind: "paseo", - id, + facts: null, + address: address(id), + authority: { originServerId: "server-1", serverId: "server-1" }, + relationshipId: id, + workspace: null, + freshness: "current", + phase: "attached", + error: null, provider: "codex", title: id, description: null, @@ -44,9 +54,11 @@ function managed( overrides: Partial = {}, ): ManagedSubagentSnapshot { return { - id, + address: address(id), status, - parentAgentId: "parent", + freshness: "current", + phase: "attached", + parent, archivedAt: null, ...overrides, }; @@ -75,9 +87,9 @@ describe("createArchiveFinishedSubagents", () => { const archive = createArchiveFinishedSubagents( [provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: (id) => { + parent, + getManagedSubagent: (child) => current.get(child.agentId), + archiveManagedSubagent: ({ agentId: id }) => { events.push(`archive:${id}`); return id === "first" ? first.promise : second.promise; }, @@ -109,9 +121,9 @@ describe("createArchiveFinishedSubagents", () => { second.resolve(); await expect(operation).resolves.toEqual({ - archivedPaseoIds: ["first", "second"], + archivedPaseoAgents: [address("first"), address("second")], dismissedProviderIds: ["native"], - skippedPaseoIds: ["resumed"], + skippedPaseoAgents: [address("resumed")], failures: [], }); expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]); @@ -129,9 +141,9 @@ describe("createArchiveFinishedSubagents", () => { const responses = [first.promise, second.promise, retry.promise]; const calls: string[] = []; const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: (id) => { + parent, + getManagedSubagent: (child) => current.get(child.agentId), + archiveManagedSubagent: ({ agentId: id }) => { calls.push(id); const response = responses.shift(); if (!response) throw new Error("Unexpected archive"); @@ -148,10 +160,10 @@ describe("createArchiveFinishedSubagents", () => { second.resolve(); archive.setRows([]); await expect(firstAttempt).resolves.toEqual({ - archivedPaseoIds: ["second"], + archivedPaseoAgents: [address("second")], dismissedProviderIds: [], - skippedPaseoIds: [], - failures: [{ id: "first", error: firstError }], + skippedPaseoAgents: [], + failures: [{ address: address("first"), error: firstError }], }); expect(archive.getState()).toEqual({ @@ -181,10 +193,10 @@ describe("createArchiveFinishedSubagents", () => { const retryError = new Error("retry failed"); retry.reject(retryError); await expect(retryAttempt).resolves.toEqual({ - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: [], - skippedPaseoIds: [], - failures: [{ id: "first", error: retryError }], + skippedPaseoAgents: [], + failures: [{ address: address("first"), error: retryError }], }); expect(archive.getState()).toEqual({ @@ -203,9 +215,9 @@ describe("createArchiveFinishedSubagents", () => { ["second", managed("second")], ]); const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + parent, + getManagedSubagent: (child) => current.get(child.agentId), + archiveManagedSubagent: async ({ agentId: id }) => { throw id === "first" ? firstError : secondError; }, dismissProviderSubagents: () => undefined, @@ -229,8 +241,8 @@ describe("createArchiveFinishedSubagents", () => { it("clears retry state when the authoritative failed child is running", async () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + parent, + getManagedSubagent: (child) => current.get(child.agentId), archiveManagedSubagent: async () => { current.set("failed", managed("failed", "running")); throw new Error("archive failed"); @@ -245,9 +257,9 @@ describe("createArchiveFinishedSubagents", () => { expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } }); await expect(archive.archiveFinished()).resolves.toEqual({ - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: [], - skippedPaseoIds: [], + skippedPaseoAgents: [], failures: [], }); }); @@ -255,8 +267,8 @@ describe("createArchiveFinishedSubagents", () => { it("clears retry state when the authoritative failed child is initializing", async () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + parent, + getManagedSubagent: (child) => current.get(child.agentId), archiveManagedSubagent: async () => { current.set("failed", managed("failed", "initializing")); throw new Error("archive failed"); @@ -271,9 +283,9 @@ describe("createArchiveFinishedSubagents", () => { expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } }); await expect(archive.archiveFinished()).resolves.toEqual({ - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: [], - skippedPaseoIds: [], + skippedPaseoAgents: [], failures: [], }); }); @@ -288,7 +300,7 @@ describe("createArchiveFinishedSubagents", () => { [ "belongs to a different parent", (current: Map) => - current.set("failed", managed("failed", "idle", { parentAgentId: "other-parent" })), + current.set("failed", managed("failed", "idle", { parent: address("other-parent") })), ], ])( "clears retry state when the authoritative failed child %s", @@ -296,8 +308,8 @@ describe("createArchiveFinishedSubagents", () => { const error = new Error("archive failed"); const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + parent, + getManagedSubagent: (child) => current.get(child.agentId), archiveManagedSubagent: async () => { updateSnapshot(current); throw error; @@ -306,10 +318,10 @@ describe("createArchiveFinishedSubagents", () => { }); await expect(archive.archiveFinished()).resolves.toEqual({ - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: [], - skippedPaseoIds: [], - failures: [{ id: "failed", error }], + skippedPaseoAgents: [], + failures: [{ address: address("failed"), error }], }); expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } }); }, @@ -324,9 +336,9 @@ describe("createArchiveFinishedSubagents", () => { ]); const calls: string[] = []; const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + parent, + getManagedSubagent: (child) => current.get(child.agentId), + archiveManagedSubagent: async ({ agentId: id }) => { calls.push(id); if (calls.length === 1) throw firstError; if (calls.length === 2) { @@ -340,12 +352,12 @@ describe("createArchiveFinishedSubagents", () => { const operation = archive.archiveFinished(); archive.setRows([]); await expect(operation).resolves.toEqual({ - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: [], - skippedPaseoIds: [], + skippedPaseoAgents: [], failures: [ - { id: "first", error: firstError }, - { id: "second", error: secondError }, + { address: address("first"), error: firstError }, + { address: address("second"), error: secondError }, ], }); archive.setRows([paseo("first"), paseo("second", "running")]); @@ -355,9 +367,9 @@ describe("createArchiveFinishedSubagents", () => { status: { kind: "failed", failedCount: 1, totalCount: 1 }, }); await expect(archive.archiveFinished()).resolves.toEqual({ - archivedPaseoIds: ["first"], + archivedPaseoAgents: [address("first")], dismissedProviderIds: [], - skippedPaseoIds: [], + skippedPaseoAgents: [], failures: [], }); expect(calls).toEqual(["first", "second", "first"]); @@ -377,15 +389,15 @@ describe("createArchiveFinishedSubagents", () => { ["first", managed("first")], ["resumed", managed("resumed")], ["archived", managed("archived", "idle", { archivedAt: new Date() })], - ["reparented", managed("reparented", "idle", { parentAgentId: "other-parent" })], + ["reparented", managed("reparented", "idle", { parent: address("other-parent") })], ["running", managed("running", "running")], ["initializing", managed("initializing", "initializing")], ]); const archived: string[] = []; const archive = createArchiveFinishedSubagents(rows, { - parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + parent, + getManagedSubagent: (child) => current.get(child.agentId), + archiveManagedSubagent: async ({ agentId: id }) => { archived.push(id); current.set("resumed", managed("resumed", "running")); }, @@ -393,9 +405,16 @@ describe("createArchiveFinishedSubagents", () => { }); await expect(archive.archiveFinished()).resolves.toEqual({ - archivedPaseoIds: ["first"], + archivedPaseoAgents: [address("first")], dismissedProviderIds: [], - skippedPaseoIds: ["resumed", "missing", "archived", "reparented", "running", "initializing"], + skippedPaseoAgents: [ + address("resumed"), + address("missing"), + address("archived"), + address("reparented"), + address("running"), + address("initializing"), + ], failures: [], }); @@ -412,16 +431,16 @@ describe("createArchiveFinishedSubagents", () => { ]); const dismissed: string[][] = []; const archive = createArchiveFinishedSubagents([...descriptors.values()], { - parentAgentId: "parent", + parent, getManagedSubagent: () => undefined, archiveManagedSubagent: async () => undefined, dismissProviderSubagents: (ids) => dismissed.push(ids), }); await expect(archive.archiveFinished()).resolves.toEqual({ - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: ["finished"], - skippedPaseoIds: [], + skippedPaseoAgents: [], failures: [], }); @@ -430,3 +449,17 @@ describe("createArchiveFinishedSubagents", () => { expect(descriptors.get("running")).toBe(running); }); }); + +it("does not treat unavailable cached idle or unresolved lifecycle as current completion", () => { + const stale = { ...paseo("cached-idle"), freshness: "unavailable" as const }; + const pending = { ...paseo("pending"), phase: "detach_pending" as const }; + const archive = createArchiveFinishedSubagents([stale, pending], { + parent, + getManagedSubagent: () => undefined, + archiveManagedSubagent: async () => { + throw new Error("Must not dispatch"); + }, + dismissProviderSubagents: () => undefined, + }); + expect(archive.getState().eligibleCount).toBe(0); +}); diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts index 0f3c685591..a181eacf76 100644 --- a/packages/app/src/subagents/archive-finished.ts +++ b/packages/app/src/subagents/archive-finished.ts @@ -1,4 +1,6 @@ import type { Agent } from "@/stores/session-store"; +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import type { SubagentRelationship } from "@getpaseo/protocol/messages"; import type { SubagentRow } from "./select"; export type ArchiveFinishedStatus = @@ -12,18 +14,25 @@ export interface ArchiveFinishedState { } export interface ArchiveFinishedOutcome { - archivedPaseoIds: string[]; + archivedPaseoAgents: AgentAddress[]; dismissedProviderIds: string[]; - skippedPaseoIds: string[]; - failures: Array<{ id: string; error: unknown }>; + skippedPaseoAgents: AgentAddress[]; + failures: Array<{ address: AgentAddress; error: unknown }>; } -export type ManagedSubagentSnapshot = Pick; +export interface ManagedSubagentSnapshot { + address: AgentAddress; + parent: AgentAddress; + status: Agent["status"] | null; + freshness: SubagentRelationship["freshness"]; + phase: SubagentRelationship["phase"]; + archivedAt: Agent["archivedAt"]; +} export interface ArchiveFinishedSubagentsDeps { - parentAgentId: string; - getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined; - archiveManagedSubagent: (id: string) => Promise; + parent: AgentAddress; + getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined; + archiveManagedSubagent: (address: AgentAddress) => Promise; dismissProviderSubagents: (ids: string[]) => void; } @@ -35,18 +44,26 @@ export interface ArchiveFinishedSubagents { } export function isFinishedSubagent(row: SubagentRow): boolean { - if (row.kind === "paseo") return row.status === "idle" || row.status === "error"; + if (row.kind === "paseo") + return ( + row.freshness === "current" && + row.phase === "attached" && + (row.status === "idle" || row.status === "error") + ); return row.status === "completed" || row.status === "failed" || row.status === "canceled"; } function canArchiveManagedSubagent( agent: ManagedSubagentSnapshot | undefined, - parentAgentId: string, + parent: AgentAddress, ): boolean { return Boolean( agent && + agent.freshness === "current" && + agent.phase === "attached" && !agent.archivedAt && - agent.parentAgentId === parentAgentId && + agent.parent.serverId === parent.serverId && + agent.parent.agentId === parent.agentId && (agent.status === "idle" || agent.status === "error"), ); } @@ -55,12 +72,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string { return identitySignature(rowIdentities(rows)); } -function managedRowIdentity(id: string): string { - return `paseo:${id}`; +function managedRowIdentity(address: AgentAddress): string { + return JSON.stringify(["paseo", address.serverId, address.agentId]); } function rowIdentity(row: SubagentRow): string { - return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`; + return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`; } function rowIdentities(rows: readonly SubagentRow[]): Set { @@ -73,9 +90,9 @@ function identitySignature(ids: ReadonlySet): string { function emptyOutcome(): ArchiveFinishedOutcome { return { - archivedPaseoIds: [], + archivedPaseoAgents: [], dismissedProviderIds: [], - skippedPaseoIds: [], + skippedPaseoAgents: [], failures: [], }; } @@ -169,7 +186,7 @@ async function runArchiveFinished( reportProgress: (completedCount: number) => void, ): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set }> { const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id); - const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id); + const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address); let completedCount = 0; const outcome = emptyOutcome(); const retryableFailureIds = new Set(); @@ -181,19 +198,19 @@ async function runArchiveFinished( reportProgress(completedCount); } - for (const id of paseoIds) { - if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { + for (const address of paseoAddresses) { + if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parent)) { try { - await deps.archiveManagedSubagent(id); - outcome.archivedPaseoIds.push(id); + await deps.archiveManagedSubagent(address); + outcome.archivedPaseoAgents.push(address); } catch (error) { - outcome.failures.push({ id, error }); - if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { - retryableFailureIds.add(managedRowIdentity(id)); + outcome.failures.push({ address, error }); + if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parent)) { + retryableFailureIds.add(managedRowIdentity(address)); } } } else { - outcome.skippedPaseoIds.push(id); + outcome.skippedPaseoAgents.push(address); } completedCount += 1; reportProgress(completedCount); diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts index 9314961b16..d5fc0cd136 100644 --- a/packages/app/src/subagents/archive-subagent.test.ts +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -42,7 +42,7 @@ function createFakeEnv( subagents.set(id, snapshot); }, deps: { - getSubagent: (id) => subagents.get(id), + getSubagent: (child) => subagents.get(child.agentId), confirm: async (dialog) => { recordedConfirmInputs.push(dialog); return options.confirmResult ?? false; @@ -132,7 +132,10 @@ describe("requestArchiveSubagent", () => { ], }); - await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestArchiveSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedArchives).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); }); @@ -148,7 +151,10 @@ describe("requestArchiveSubagent", () => { ], }); - await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestArchiveSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedArchives).toEqual([]); }); @@ -164,7 +170,10 @@ describe("requestArchiveSubagent", () => { ], }); - await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestArchiveSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedConfirmInputs).toEqual([ resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }), @@ -174,7 +183,7 @@ describe("requestArchiveSubagent", () => { it("asks with the missing-subagent dialog when the snapshot is unknown", async () => { const env = createFakeEnv({ confirmResult: false }); - await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps); + await requestArchiveSubagent({ child: { serverId: "server-1", agentId: "missing" } }, env.deps); expect(env.recordedConfirmInputs).toEqual([ resolveArchiveSubagentDialog({ title: undefined, status: undefined }), @@ -198,7 +207,7 @@ describe("requestArchiveSubagent", () => { }; await expect( - requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), + requestArchiveSubagent({ child: { serverId: "server-1", agentId: "child-agent" } }, env.deps), ).resolves.toBeUndefined(); expect(env.recordedErrors).toEqual([error]); }); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts index 653dcb1cb2..108690dee2 100644 --- a/packages/app/src/subagents/archive-subagent.ts +++ b/packages/app/src/subagents/archive-subagent.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import type { Agent } from "@/stores/session-store"; import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; @@ -38,22 +39,21 @@ export function resolveArchiveSubagentDialog( } export interface ArchiveSubagentDeps { - getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined; + getSubagent: (child: AgentAddress) => ResolveArchiveSubagentDialogInput | undefined; confirm: (input: ConfirmDialogInput) => Promise; - archiveAgent: (input: { serverId: string; agentId: string }) => Promise; + archiveAgent: (input: AgentAddress) => Promise; reportError: (error: unknown) => void; } export interface RequestArchiveSubagentInput { - serverId: string; - subagentId: string; + child: AgentAddress; } export async function requestArchiveSubagent( input: RequestArchiveSubagentInput, deps: ArchiveSubagentDeps, ): Promise { - const subagent = deps.getSubagent(input.subagentId); + const subagent = deps.getSubagent(input.child); const confirmed = await deps.confirm( resolveArchiveSubagentDialog({ title: subagent?.title, @@ -64,7 +64,7 @@ export async function requestArchiveSubagent( return; } try { - await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }); + await deps.archiveAgent(input.child); } catch (error) { deps.reportError(error); } diff --git a/packages/app/src/subagents/close-tab-policy.test.ts b/packages/app/src/subagents/close-tab-policy.test.ts index cdce429b00..4afebdbc6a 100644 --- a/packages/app/src/subagents/close-tab-policy.test.ts +++ b/packages/app/src/subagents/close-tab-policy.test.ts @@ -1,15 +1,20 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import { describe, expect, it } from "vitest"; import { resolveCloseAgentTabPolicy } from "./close-tab-policy"; describe("resolveCloseAgentTabPolicy", () => { it("archives root agents when their tab closes", () => { - expect(resolveCloseAgentTabPolicy({ parentAgentId: null })).toEqual({ + expect(resolveCloseAgentTabPolicy({ managedRelationship: null })).toEqual({ kind: "archive-on-close", }); }); it("keeps subagent tab close layout-only", () => { - expect(resolveCloseAgentTabPolicy({ parentAgentId: "parent-agent" })).toEqual({ + expect( + resolveCloseAgentTabPolicy( + managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }), + ), + ).toEqual({ kind: "layout-only", }); }); diff --git a/packages/app/src/subagents/close-tab-policy.ts b/packages/app/src/subagents/close-tab-policy.ts index fa82e738b9..497cc3bdb4 100644 --- a/packages/app/src/subagents/close-tab-policy.ts +++ b/packages/app/src/subagents/close-tab-policy.ts @@ -3,9 +3,9 @@ import type { Agent } from "@/stores/session-store"; export type CloseAgentTabPolicy = { kind: "archive-on-close" } | { kind: "layout-only" }; export function resolveCloseAgentTabPolicy( - agent: Pick | null | undefined, + agent: Pick | null | undefined, ): CloseAgentTabPolicy { - if (agent?.parentAgentId) { + if (agent?.managedRelationship) { return { kind: "layout-only" }; } diff --git a/packages/app/src/subagents/detach-subagent.test.ts b/packages/app/src/subagents/detach-subagent.test.ts index a9875dc18c..882fde1c9d 100644 --- a/packages/app/src/subagents/detach-subagent.test.ts +++ b/packages/app/src/subagents/detach-subagent.test.ts @@ -46,14 +46,16 @@ function createFakeEnv( recordedConfirmInputs, recordedErrors, deps: { - getSubagent: (id) => subagents.get(id), + getSubagent: (child) => subagents.get(child.agentId), confirm: async (dialog) => { recordedConfirmInputs.push(dialog); return options.confirmResult ?? false; }, detachAgent: async (input) => { recordedDetaches.push(input); + return { phase: "detached" }; }, + reportPending: () => undefined, openDetachedAgent: (input) => { recordedOpens.push(input); }, @@ -93,7 +95,10 @@ describe("requestDetachSubagent", () => { initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], }); - await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestDetachSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); }); @@ -104,7 +109,10 @@ describe("requestDetachSubagent", () => { initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], }); - await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestDetachSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); }); @@ -115,7 +123,10 @@ describe("requestDetachSubagent", () => { initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], }); - await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestDetachSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedDetaches).toEqual([]); expect(env.recordedOpens).toEqual([]); @@ -127,7 +138,10 @@ describe("requestDetachSubagent", () => { initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], }); - await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + await requestDetachSubagent( + { child: { serverId: "server-1", agentId: "child-agent" } }, + env.deps, + ); expect(env.recordedConfirmInputs).toEqual([ resolveDetachSubagentDialog({ title: "Review branch" }), @@ -145,9 +159,25 @@ describe("requestDetachSubagent", () => { }; await expect( - requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), + requestDetachSubagent({ child: { serverId: "server-1", agentId: "child-agent" } }, env.deps), ).resolves.toBeUndefined(); expect(env.recordedErrors).toEqual([error]); expect(env.recordedOpens).toEqual([]); }); }); + +it("retains pending detach without opening a standalone conversation", async () => { + const env = createFakeEnv({ confirmResult: true }); + let pending = 0; + env.deps.detachAgent = async () => ({ phase: "detach-pending" }); + env.deps.reportPending = () => { + pending += 1; + }; + await requestDetachSubagent( + { child: { serverId: "server-2", agentId: "child-agent" } }, + env.deps, + ); + expect(pending).toBe(1); + expect(env.recordedOpens).toEqual([]); + expect(env.recordedErrors).toEqual([]); +}); diff --git a/packages/app/src/subagents/detach-subagent.ts b/packages/app/src/subagents/detach-subagent.ts index f325cb032a..e878656c95 100644 --- a/packages/app/src/subagents/detach-subagent.ts +++ b/packages/app/src/subagents/detach-subagent.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import type { Agent } from "@/stores/session-store"; import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; @@ -34,23 +35,23 @@ export function resolveDetachSubagentDialog( } export interface DetachSubagentDeps { - getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined; + getSubagent: (child: AgentAddress) => ResolveDetachSubagentDialogInput | undefined; confirm: (input: ConfirmDialogInput) => Promise; - detachAgent: (input: { serverId: string; agentId: string }) => Promise; - openDetachedAgent: (input: { serverId: string; agentId: string }) => void; + detachAgent: (input: AgentAddress) => Promise<{ phase: string }>; + reportPending: () => void; + openDetachedAgent: (input: AgentAddress) => void; reportError: (error: unknown) => void; } export interface RequestDetachSubagentInput { - serverId: string; - subagentId: string; + child: AgentAddress; } export async function requestDetachSubagent( input: RequestDetachSubagentInput, deps: DetachSubagentDeps, ): Promise { - const subagent = deps.getSubagent(input.subagentId); + const subagent = deps.getSubagent(input.child); const confirmed = await deps.confirm( resolveDetachSubagentDialog({ title: subagent?.title, @@ -60,8 +61,9 @@ export async function requestDetachSubagent( return; } try { - await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId }); - deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId }); + const result = await deps.detachAgent(input.child); + if (result.phase === "detached") deps.openDetachedAgent(input.child); + else deps.reportPending(); } catch (error) { deps.reportError(error); } diff --git a/packages/app/src/subagents/managed.ts b/packages/app/src/subagents/managed.ts new file mode 100644 index 0000000000..010772c162 --- /dev/null +++ b/packages/app/src/subagents/managed.ts @@ -0,0 +1,203 @@ +import { createPaseoApi, type PaseoApi, type PaseoAgentHandle } from "@getpaseo/client"; +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import type { SubagentDirectory, SubagentRelationship } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +export interface ManagedSubagentState { + readonly parent: AgentAddress; + readonly rows: readonly SubagentRelationship[]; + readonly status: "loading" | "current" | "unavailable"; + readonly error: string | null; + readonly archiving: ReadonlySet; +} + +export interface ManagedSubagentDirectory { + getSnapshot(): ManagedSubagentState; + subscribe(changed: () => void): () => void; + archive(child: AgentAddress): Promise; + detach(child: AgentAddress): Promise; + forget(child: AgentAddress): Promise; +} + +interface MembershipRuntime { + getClient(serverId: string): DaemonClient | null; + getSnapshot(serverId: string): { connectionStatus: string } | null; + subscribe(serverId: string, changed: () => void): () => void; + withAgent( + address: AgentAddress, + operation: (agent: PaseoAgentHandle) => Promise, + ): Promise; +} + +/** Retains authoritative membership for the runtime, independently of view mounting. */ +export class ManagedSubagents { + private readonly parents = new Map(); + constructor(private readonly runtime: MembershipRuntime) {} + + directory(parent: AgentAddress): ManagedSubagentDirectory { + const key = JSON.stringify([parent.serverId, parent.agentId]); + let directory = this.parents.get(key); + if (!directory) { + directory = new ParentMembership(parent, this.runtime); + this.parents.set(key, directory); + } + return directory; + } + + async releaseServer(serverId: string): Promise { + const releasing: Promise[] = []; + for (const [key, parent] of this.parents) { + if (parent.getSnapshot().parent.serverId !== serverId) continue; + this.parents.delete(key); + releasing.push(parent.close()); + } + await Promise.allSettled(releasing); + } +} + +class ParentMembership implements ManagedSubagentDirectory { + private state: ManagedSubagentState; + private readonly listeners = new Set<() => void>(); + private stopConnection: (() => void) | null = null; + private client: DaemonClient | null = null; + private api: PaseoApi | null = null; + private generation = 0; + private closed = false; + private readonly releasing = new Set>(); + + constructor( + private readonly parent: AgentAddress, + private readonly runtime: MembershipRuntime, + ) { + this.state = { parent, rows: [], status: "loading", error: null, archiving: new Set() }; + } + getSnapshot = (): ManagedSubagentState => this.state; + subscribe = (changed: () => void): (() => void) => { + this.listeners.add(changed); + if (!this.stopConnection && !this.closed) { + this.stopConnection = this.runtime.subscribe(this.parent.serverId, this.syncConnection); + this.syncConnection(); + } + // The runtime owns observation/recovery; unmounting a view only removes its listener. + return () => { + this.listeners.delete(changed); + }; + }; + private publish(state: ManagedSubagentState): void { + this.state = state; + for (const changed of this.listeners) changed(); + } + private unavailable(error: string): void { + this.publish({ + ...this.state, + status: "unavailable", + error, + rows: this.state.rows.map((row) => ({ ...row, freshness: "unavailable" })), + }); + } + private retireApi(): void { + if (!this.api) return; + const releasing = this.api.dispose(); + this.api = null; + this.releasing.add(releasing); + void releasing.finally(() => this.releasing.delete(releasing)).catch(() => {}); + } + private syncConnection = (): void => { + if (this.closed) return; + const client = this.runtime.getClient(this.parent.serverId); + if (client !== this.client) { + this.generation++; + this.retireApi(); + this.client = client; + } + if (!client || this.runtime.getSnapshot(this.parent.serverId)?.connectionStatus !== "online") { + this.unavailable("Parent authority is unavailable"); + return; + } + if (this.api) return; + const generation = this.generation; + const accept = (directory: SubagentDirectory) => { + if (this.closed || generation !== this.generation) return; + if ( + directory.parent.serverId !== this.parent.serverId || + directory.parent.agentId !== this.parent.agentId + ) + throw new Error("Subagent directory does not match its owning parent"); + this.publish({ + ...this.state, + parent: this.parent, + rows: directory.rows, + status: "current", + error: null, + }); + }; + try { + this.api = createPaseoApi(client); + const observation = this.api.agents.ref(this.parent.agentId).subagents.observe(); + observation.subscribe({ + snapshot: accept, + update: (message) => { + if (message.type === "agent.subagents.changed") accept(message.payload.directory); + }, + error: (error) => { + if (!this.closed && generation === this.generation) + this.unavailable( + error instanceof Error ? error.message : "Subagent membership is unavailable", + ); + }, + }); + void observation.ready.catch((error) => { + if (!this.closed && generation === this.generation) + this.unavailable( + error instanceof Error ? error.message : "Subagent membership is unavailable", + ); + }); + } catch (error) { + this.retireApi(); + this.unavailable( + error instanceof Error ? error.message : "Subagent membership is unavailable", + ); + } + }; + private requireMembership(child: AgentAddress): SubagentRelationship { + if (this.closed || !this.api || this.state.status !== "current") + throw new Error("Parent authority is unavailable"); + const row = this.state.rows.find( + (candidate) => + candidate.child.serverId === child.serverId && candidate.child.agentId === child.agentId, + ); + if (!row || ["forgotten", "archived", "detached", "canceled"].includes(row.phase)) + throw new Error("Subagent membership is no longer attached"); + return row; + } + archive = async (child: AgentAddress): Promise => { + this.requireMembership(child); + const key = JSON.stringify([child.serverId, child.agentId]); + if (this.state.archiving.has(key)) return; + this.publish({ ...this.state, archiving: new Set([...this.state.archiving, key]) }); + try { + await this.runtime.withAgent(child, (agent) => agent.archive()); + } finally { + const archiving = new Set(this.state.archiving); + archiving.delete(key); + this.publish({ ...this.state, archiving }); + } + }; + detach = async (child: AgentAddress): Promise => { + this.requireMembership(child); + return this.api!.agents.ref(this.parent.agentId).subagents.detach(child); + }; + forget = async (child: AgentAddress): Promise => { + this.requireMembership(child); + await this.api!.agents.ref(this.parent.agentId).subagents.forget(child); + }; + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.generation++; + this.stopConnection?.(); + this.retireApi(); + this.listeners.clear(); + await Promise.allSettled(this.releasing); + } +} diff --git a/packages/app/src/subagents/open-tab-labels.test.ts b/packages/app/src/subagents/open-tab-labels.test.ts index 61874cd058..36411312ec 100644 --- a/packages/app/src/subagents/open-tab-labels.test.ts +++ b/packages/app/src/subagents/open-tab-labels.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import { describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; @@ -12,7 +13,9 @@ function tab(agentId: string, createdAt: number): WorkspaceTab { function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent { return { id: input.id, - parentAgentId: input.parentAgentId ?? null, + ...managedAgentFields( + input.parentAgentId ? { serverId: "server-1", agentId: input.parentAgentId } : null, + ), labels: input.open ? { [label]: "true" } : {}, } as Agent; } diff --git a/packages/app/src/subagents/open-tab-labels.ts b/packages/app/src/subagents/open-tab-labels.ts index ca7d8e63dc..c63d41af0d 100644 --- a/packages/app/src/subagents/open-tab-labels.ts +++ b/packages/app/src/subagents/open-tab-labels.ts @@ -14,7 +14,7 @@ export function getAgentTabsNeedingOpenLabel(input: { } const agent = input.getAgent(tab.target.agentId); if ( - agent?.parentAgentId && + agent?.managedRelationship && agent.labels[input.label] !== "true" && !input.pendingAgentIds.has(agent.id) ) { diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx index 0792091135..3e27cf53be 100644 --- a/packages/app/src/subagents/opens-in-sheet.test.tsx +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -9,6 +9,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { useSubagentOpensInSheet } from "./opens-in-sheet"; import { selectSubagentsForParent, type SubagentRow } from "./select"; +import { managedAgentFields, membershipFixture, relationshipFixture } from "./test-fixtures"; vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); @@ -64,7 +65,8 @@ const AGENT_DEFAULTS: Agent = { attentionReason: null, attentionTimestamp: null, archivedAt: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, projectPlacement: null, }; @@ -77,7 +79,12 @@ if (!workspaceKey) throw new Error("workspace key"); function seedParentWithChild(): SubagentRow { const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; - const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + const child: Agent = { + ...AGENT_DEFAULTS, + id: "child", + title: "Child", + ...managedAgentFields({ serverId: SERVER_ID, agentId: "parent" }), + }; useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); useSessionStore.getState().setAgents( SERVER_ID, @@ -87,8 +94,10 @@ function seedParentWithChild(): SubagentRow { ]), ); const [row] = selectSubagentsForParent( - useSessionStore.getState(), - { serverId: SERVER_ID, parentAgentId: "parent" }, + membershipFixture({ serverId: SERVER_ID, agentId: "parent" }, [ + relationshipFixture({ serverId: SERVER_ID, agentId: "parent" }, child), + ]), + { originServerId: SERVER_ID, serverId: SERVER_ID }, new Set(), ); if (!row) throw new Error("child row"); @@ -114,7 +123,11 @@ describe("useSubagentOpensInSheet", () => { const target = { kind: "agent", agentId: "child" } as const; // The row renders its answer, as the track does, so a stale render is a stale answer. - const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row)); + const { result } = renderHook(() => + useSubagentOpensInSheet(workspaceKey, { serverId: SERVER_ID, workspaceId: WORKSPACE_ID })( + row, + ), + ); expect(result.current).toBe(true); let tabId: string | null = null; diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts index 26432ea039..9ae75240e7 100644 --- a/packages/app/src/subagents/opens-in-sheet.ts +++ b/packages/app/src/subagents/opens-in-sheet.ts @@ -1,18 +1,36 @@ -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { + useWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, +} from "@/stores/workspace-layout-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { SubagentRow } from "./select"; +import type { WorkspaceAddress } from "@getpaseo/protocol/addresses"; + +export function subagentRowBelongsToWorkspace( + row: SubagentRow, + workspace: WorkspaceAddress, +): boolean { + return ( + row.kind === "provider" || + (row.workspace !== null && + row.workspace.serverId === workspace.serverId && + row.workspace.workspaceId === workspace.workspaceId) + ); +} /** The workspace target a subagent row opens. */ export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { return row.kind === "provider" ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } - : { kind: "agent", agentId: row.id }; + : { kind: "agent", agentId: row.address.agentId }; } /** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */ export function useSubagentOpensInSheet( workspaceKey: string | null, + workspace: WorkspaceAddress, ): (row: SubagentRow) => boolean { + const layout = useWorkspaceLayoutStoreApi(); // The rows render the answer, so this subscribes to what the answer reads: the layout mode and // the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet, // and a row rendered while that tab existed must re-render once it closes, or choosing the row @@ -22,8 +40,9 @@ export function useSubagentOpensInSheet( workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, ); return (row) => + subagentRowBelongsToWorkspace(row, workspace) && workspaceKey !== null && - useWorkspaceLayoutStore + layout .getState() .opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" }); } diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 01d6880d5b..7086c279c4 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -2,6 +2,7 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { + createProviderSubagentStore, observeProviderSubagentTimeline, providerSubagentKey, refreshProviderSubagents, @@ -824,3 +825,59 @@ describe("resyncing after the update feed resubscribes", () => { expect(daemon.requested).toEqual([PARENT_ID]); }); }); + +test("isolates equal provider identities and in-flight list ownership between authority bindings", async () => { + const direct = createProviderSubagentStore(); + const routed = createProviderSubagentStore(); + const pending: Array< + (value: { + requestId: string; + parentAgentId: string; + error: null; + subagents: ProviderSubagentDescriptorPayload[]; + }) => void + > = []; + const client = { + listProviderSubagents: () => + new Promise<{ + requestId: string; + parentAgentId: string; + error: null; + subagents: ProviderSubagentDescriptorPayload[]; + }>((resolve) => pending.push(resolve)), + }; + const descriptor: ProviderSubagentDescriptorPayload = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Scoped", + description: null, + status: "completed", + createdAt: "2026-10-20T10:00:00Z", + updatedAt: "2026-10-20T10:00:00Z", + toolCallId: null, + }; + const directRequest = refreshProviderSubagents(client, SERVER_ID, PARENT_ID, direct); + const routedRequest = refreshProviderSubagents(client, SERVER_ID, PARENT_ID, routed); + expect(pending).toHaveLength(2); + pending[0]!({ + requestId: "direct", + parentAgentId: PARENT_ID, + error: null, + subagents: [{ ...descriptor, title: "Direct private title" }], + }); + await directRequest; + expect(routed.getState().descriptors.size).toBe(0); + pending[1]!({ + requestId: "routed", + parentAgentId: PARENT_ID, + error: null, + subagents: [descriptor], + }); + await routedRequest; + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(routed.getState().descriptors.get(key)?.title).toBe("Scoped"); + routed.getState().hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]); + expect(direct.getState().hiddenFromTrack.size).toBe(0); + expect(direct.getState().descriptors.get(key)?.title).toBe("Direct private title"); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index fd44dae8d2..867952e2ca 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -4,7 +4,8 @@ import type { SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { create } from "zustand"; +import { create, useStore } from "zustand"; +import { createContext, useContext } from "react"; import { applyStreamEvent } from "@/types/stream"; import { processTimelineResponse, @@ -71,27 +72,36 @@ export function providerSubagentLifecycleStatus( type ProviderSubagentListClient = Pick; -const pendingListRequests = new WeakMap>>(); +const pendingListRequests = new WeakMap< + ProviderSubagentStoreApi, + WeakMap>> +>(); export function refreshProviderSubagents( client: ProviderSubagentListClient, serverId: string, parentAgentId: string, + store: ProviderSubagentStoreApi = defaultProviderSubagentStore, ): Promise { const requestKey = `${serverId}\0${parentAgentId}`; - let clientRequests = pendingListRequests.get(client); + let requests = pendingListRequests.get(store); + if (!requests) { + requests = new WeakMap(); + pendingListRequests.set(store, requests); + } + let clientRequests = requests.get(client); if (!clientRequests) { clientRequests = new Map(); - pendingListRequests.set(client, clientRequests); + requests.set(client, clientRequests); } const pending = clientRequests.get(requestKey); if (pending) return pending; - useProviderSubagentStore.getState().trackParent(serverId, parentAgentId); + store.getState().trackParent(serverId, parentAgentId); const request = client .listProviderSubagents(parentAgentId) .then((payload) => { - useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents); + store.getState().replaceList(serverId, parentAgentId, payload.subagents); return undefined; }) .finally(() => { @@ -113,15 +123,16 @@ function parentPrefix(serverId: string, parentAgentId: string): string { export async function resyncProviderSubagents( client: ProviderSubagentListClient, serverId: string, + store: ProviderSubagentStoreApi = defaultProviderSubagentStore, ): Promise { const serverPrefix = `${serverId}\0`; - const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents] + const parentAgentIds = [...store.getState().trackedParents] .filter((key) => key.startsWith(serverPrefix)) .map((key) => key.slice(serverPrefix.length, -1)); await Promise.all( parentAgentIds.map((parentAgentId) => // A parent that was archived meanwhile has nothing left to catch up. - refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined), + refreshProviderSubagents(client, serverId, parentAgentId, store).catch(() => undefined), ), ); } @@ -168,195 +179,215 @@ function settleTimeline( }; } -export const useProviderSubagentStore = create((set) => ({ - descriptors: new Map(), - timelines: new Map(), - hiddenFromTrack: new Set(), - trackedParents: new Set(), - trackParent(serverId, parentAgentId) { - set((state) => { - const prefix = parentPrefix(serverId, parentAgentId); - if (state.trackedParents.has(prefix)) return state; - return { trackedParents: new Set(state.trackedParents).add(prefix) }; - }); - }, - hideFromTrack(serverId, parentAgentId, subagentIds) { - set((state) => { - const hiddenFromTrack = new Set(state.hiddenFromTrack); - for (const subagentId of subagentIds) { - const key = providerSubagentKey(serverId, parentAgentId, subagentId); - if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key); - } - return { hiddenFromTrack }; - }); - }, - replaceList(serverId, parentAgentId, subagents) { - set((state) => { - const prefix = parentPrefix(serverId, parentAgentId); - const descriptors = new Map( - [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), - ); - const hiddenFromTrack = new Set(state.hiddenFromTrack); - for (const subagent of subagents) { - const key = providerSubagentKey(serverId, parentAgentId, subagent.id); - descriptors.set(key, subagent); - if (subagent.status === "running") { - hiddenFromTrack.delete(key); - } - } - const retainedKeys = new Set(descriptors.keys()); - const timelines = new Map( - [...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)), - ); - for (const subagent of subagents) { - const key = providerSubagentKey(serverId, parentAgentId, subagent.id); - const current = timelines.get(key); - const previous = state.descriptors.get(key); - if (current && previous?.status !== subagent.status) { - timelines.set(key, settleTimeline(current, subagent)); - } - } - return { descriptors, timelines, hiddenFromTrack }; - }); - }, - applyUpdate(serverId, payload) { - set((state) => { - if (payload.kind === "upsert") { - const key = providerSubagentKey( - serverId, - payload.subagent.parentAgentId, - payload.subagent.id, - ); - const descriptors = new Map(state.descriptors); +export const createProviderSubagentStore = () => + create((set) => ({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + trackedParents: new Set(), + trackParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + if (state.trackedParents.has(prefix)) return state; + return { trackedParents: new Set(state.trackedParents).add(prefix) }; + }); + }, + hideFromTrack(serverId, parentAgentId, subagentIds) { + set((state) => { const hiddenFromTrack = new Set(state.hiddenFromTrack); - const previous = descriptors.get(key); - descriptors.set(key, payload.subagent); - if (payload.subagent.status === "running") { - hiddenFromTrack.delete(key); + for (const subagentId of subagentIds) { + const key = providerSubagentKey(serverId, parentAgentId, subagentId); + if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key); } - let timelines = state.timelines; - const current = state.timelines.get(key); - if (current && previous?.status !== payload.subagent.status) { - timelines = new Map(state.timelines); - timelines.set(key, settleTimeline(current, payload.subagent)); + return { hiddenFromTrack }; + }); + }, + replaceList(serverId, parentAgentId, subagents) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + const descriptors = new Map( + [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), + ); + const hiddenFromTrack = new Set(state.hiddenFromTrack); + for (const subagent of subagents) { + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + descriptors.set(key, subagent); + if (subagent.status === "running") { + hiddenFromTrack.delete(key); + } + } + const retainedKeys = new Set(descriptors.keys()); + const timelines = new Map( + [...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)), + ); + for (const subagent of subagents) { + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + const current = timelines.get(key); + const previous = state.descriptors.get(key); + if (current && previous?.status !== subagent.status) { + timelines.set(key, settleTimeline(current, subagent)); + } } return { descriptors, timelines, hiddenFromTrack }; - } - if (payload.kind === "remove") { + }); + }, + applyUpdate(serverId, payload) { + set((state) => { + if (payload.kind === "upsert") { + const key = providerSubagentKey( + serverId, + payload.subagent.parentAgentId, + payload.subagent.id, + ); + const descriptors = new Map(state.descriptors); + const hiddenFromTrack = new Set(state.hiddenFromTrack); + const previous = descriptors.get(key); + descriptors.set(key, payload.subagent); + if (payload.subagent.status === "running") { + hiddenFromTrack.delete(key); + } + let timelines = state.timelines; + const current = state.timelines.get(key); + if (current && previous?.status !== payload.subagent.status) { + timelines = new Map(state.timelines); + timelines.set(key, settleTimeline(current, payload.subagent)); + } + return { descriptors, timelines, hiddenFromTrack }; + } + if (payload.kind === "remove") { + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const descriptors = new Map(state.descriptors); + descriptors.delete(key); + const timelines = new Map(state.timelines); + timelines.delete(key); + return { descriptors, timelines }; + } const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); - const descriptors = new Map(state.descriptors); - descriptors.delete(key); + const existing = state.timelines.get(key); + if (existing?.epoch && existing.epoch !== payload.epoch) { + return state; + } + const current = existing ?? EMPTY_TIMELINE; + if (payload.seq <= current.lastSeq) { + return state; + } + const next = processAgentStreamEvent({ + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + hasAuthoritativeBaseline: current.cursor !== undefined, + event: { type: "timeline", provider: payload.provider, item: payload.item }, + timestamp: new Date(payload.timestamp), + seq: payload.seq, + epoch: payload.epoch, + }); const timelines = new Map(state.timelines); - timelines.delete(key); - return { descriptors, timelines }; - } - const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); - const existing = state.timelines.get(key); - if (existing?.epoch && existing.epoch !== payload.epoch) { - return state; - } - const current = existing ?? EMPTY_TIMELINE; - if (payload.seq <= current.lastSeq) { - return state; - } - const next = processAgentStreamEvent({ - currentTail: current.tail, - currentHead: current.head, - currentCursor: current.cursor, - hasAuthoritativeBaseline: current.cursor !== undefined, - event: { type: "timeline", provider: payload.provider, item: payload.item }, - timestamp: new Date(payload.timestamp), - seq: payload.seq, - epoch: payload.epoch, + timelines.set( + key, + settleTimeline( + { + tail: next.tail, + head: next.head, + epoch: payload.epoch, + lastSeq: payload.seq, + cursor: next.cursor ?? current.cursor, + hasOlder: current.hasOlder, + needsRefresh: + current.needsRefresh || + next.sideEffects.some((effect) => effect.type === "catch_up"), + }, + state.descriptors.get(key), + ), + ); + return { timelines }; }); - const timelines = new Map(state.timelines); - timelines.set( - key, - settleTimeline( - { - tail: next.tail, - head: next.head, - epoch: payload.epoch, - lastSeq: payload.seq, - cursor: next.cursor ?? current.cursor, - hasOlder: current.hasOlder, - needsRefresh: - current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"), + }, + replaceTimeline(serverId, payload) { + const provider = payload.provider; + if (!provider) { + return; + } + set((state) => { + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const existing = state.timelines.get(key); + const current = existing ?? EMPTY_TIMELINE; + // Normalize optional wire metadata at the child timeline boundary. Legacy + // notices are complete singleton items; history always comes from a projected host. + const entries = payload.rows.map((row) => ({ + ...row, + provider, + seqStart: row.seqStart ?? row.seq, + seqEnd: row.seqEnd ?? row.seq, + })); + const result = processTimelineResponse({ + payload: { + ...payload, + agentId: payload.subagentId, + projection: "projected", + startCursor: + payload.startCursor ?? + (entries.length + ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } + : null), + endCursor: + payload.endCursor ?? + (entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null), + entries, }, - state.descriptors.get(key), - ), - ); - return { timelines }; - }); - }, - replaceTimeline(serverId, payload) { - const provider = payload.provider; - if (!provider) { - return; - } - set((state) => { - const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); - const existing = state.timelines.get(key); - const current = existing ?? EMPTY_TIMELINE; - // Normalize optional wire metadata at the child timeline boundary. Legacy - // notices are complete singleton items; history always comes from a projected host. - const entries = payload.rows.map((row) => ({ - ...row, - provider, - seqStart: row.seqStart ?? row.seq, - seqEnd: row.seqEnd ?? row.seq, - })); - const result = processTimelineResponse({ - payload: { - ...payload, - agentId: payload.subagentId, - projection: "projected", - startCursor: - payload.startCursor ?? - (entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null), - endCursor: - payload.endCursor ?? - (entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null), - entries, - }, - currentTail: current.tail, - currentHead: current.head, - currentCursor: current.cursor, - isInitializing: current.cursor === undefined, - hasActiveInitDeferred: current.cursor === undefined, - initRequestDirection: "tail", - sendingClientMessageIds: [], + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + isInitializing: current.cursor === undefined, + hasActiveInitDeferred: current.cursor === undefined, + initRequestDirection: "tail", + sendingClientMessageIds: [], + }); + const timelines = new Map(state.timelines); + timelines.set( + key, + settleTimeline( + { + tail: result.tail, + head: result.head, + epoch: payload.epoch, + lastSeq: + payload.reset || current.epoch !== payload.epoch + ? (result.cursor?.endSeq ?? 0) + : Math.max(current.lastSeq, result.cursor?.endSeq ?? 0), + cursor: result.cursor ?? undefined, + hasOlder: + result.older === "unchanged" ? current.hasOlder : result.older === "available", + needsRefresh: + result.sideEffects.some((effect) => effect.type === "catch_up") && + (payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)), + }, + state.descriptors.get(key), + ), + ); + return { timelines }; }); - const timelines = new Map(state.timelines); - timelines.set( - key, - settleTimeline( - { - tail: result.tail, - head: result.head, - epoch: payload.epoch, - lastSeq: - payload.reset || current.epoch !== payload.epoch - ? (result.cursor?.endSeq ?? 0) - : Math.max(current.lastSeq, result.cursor?.endSeq ?? 0), - cursor: result.cursor ?? undefined, - hasOlder: - result.older === "unchanged" ? current.hasOlder : result.older === "available", - needsRefresh: - result.sideEffects.some((effect) => effect.type === "catch_up") && - (payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)), - }, - state.descriptors.get(key), - ), - ); - return { timelines }; - }); + }, + })); + +export type ProviderSubagentStoreApi = ReturnType; +// COMPAT(unboundProviderSubagents): added in v0.12, remove after 2027-04-20. +// Existing shell callers retain their store; bound consumers receive one captured owner. +export const defaultProviderSubagentStore = createProviderSubagentStore(); +export const ProviderSubagentStoreContext = createContext(null); +export function useProviderSubagentStoreApi(): ProviderSubagentStoreApi { + return useContext(ProviderSubagentStoreContext) ?? defaultProviderSubagentStore; +} +export const useProviderSubagentStore: ProviderSubagentStoreApi = Object.assign( + function useProviderSubagentStore(selector: (state: ProviderSubagentState) => T): T { + return useStore(useProviderSubagentStoreApi(), selector); }, -})); + defaultProviderSubagentStore, +); /** Owns child history bootstrap and recovery while a pane observes the child. */ export function observeProviderSubagentTimeline({ client, + store = defaultProviderSubagentStore, serverId, parentAgentId, subagentId, @@ -364,6 +395,7 @@ export function observeProviderSubagentTimeline({ reportError, }: { client: Pick; + store?: ProviderSubagentStoreApi; serverId: string; parentAgentId: string; subagentId: string; @@ -382,25 +414,21 @@ export function observeProviderSubagentTimeline({ direction: "tail", limit, }); - if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + if (active) store.getState().replaceTimeline(serverId, payload); succeeded = true; } catch (error) { // A later stream update or reopening the pane retries a disconnected read. if (!(error instanceof DaemonConnectionError)) throw error; } finally { fetching = false; - if ( - succeeded && - active && - useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh - ) + if (succeeded && active && store.getState().timelines.get(key)?.needsRefresh) requestRefresh(); } }; const requestRefresh = () => { void refresh().catch(reportError); }; - const unsubscribe = useProviderSubagentStore.subscribe((state) => { + const unsubscribe = store.subscribe((state) => { if (state.timelines.get(key)?.needsRefresh) requestRefresh(); }); requestRefresh(); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 38a905183d..c80c4e60d1 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -5,14 +6,125 @@ import { selectProviderSubagentsForParent, selectSubagentsForParent, } from "./select"; -import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility"; +import { membershipFixture, relationshipFixture } from "./test-fixtures"; import { useProviderSubagentStore } from "./provider-store"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { ManagedSubagents } from "./managed"; const SERVER_ID = "server-1"; const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z"); const EMPTY_PENDING_ARCHIVE_IDS = new Set(); +it("observes authoritative qualified membership with colliding IDs and retains it through authority withdrawal", async () => { + const harnessModule = new URL( + "../../../server/src/server/test-utils/paseo-daemon.ts", + import.meta.url, + ).pathname; + const { createTestPaseoDaemon } = await import(harnessModule); + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + const a = await createTestPaseoDaemon(); + const b = await createTestPaseoDaemon(); + const origin = createHostClient(`127.0.0.1:${a.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + const direct = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + await Promise.all([origin.connect(), direct.connect()]); + const paseo = createPaseoApi(origin); + const destination = createPaseoApi(direct); + const changed = new Set<() => void>(); + let available = true; + const originRuntime = paseo.hosts.runtime(); + const model = new ManagedSubagents({ + getClient: (serverId) => (available && serverId === a.daemon.getServerId() ? origin : null), + getSnapshot: () => ({ connectionStatus: available ? "online" : "offline" }), + withAgent: async (address, operation) => operation(await originRuntime.agent(address)), + subscribe: (_serverId, listener) => { + changed.add(listener); + return () => { + changed.delete(listener); + }; + }, + }); + try { + const registration = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await expect.poll(async () => (await paseo.hosts.list()).hosts[0]?.status).toBe("online"); + const parent = await paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const collision = crypto.randomUUID(); + const local = await parent.subagents.create({ + agentId: collision, + config: { provider: "codex/test" }, + idempotencyKey: "local", + }); + const routed = await parent.subagents.create({ + agentId: collision, + serverId: b.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "routed", + }); + const directory = model.directory(parent.address); + const stopView = directory.subscribe(() => {}); + await expect.poll(() => directory.getSnapshot().status).toBe("current"); + expect(directory.getSnapshot().rows.map((row) => row.child)).toEqual( + expect.arrayContaining([local.address, routed.address]), + ); + expect(directory.getSnapshot().rows).toHaveLength(2); + stopView(); + await parent.subagents.create({ + config: { provider: "codex/test" }, + idempotencyKey: "without-view", + }); + await expect.poll(() => directory.getSnapshot().rows.length).toBe(3); + available = false; + for (const listener of changed) listener(); + expect(directory.getSnapshot()).toMatchObject({ status: "unavailable" }); + expect(directory.getSnapshot().rows).toHaveLength(3); + expect(directory.getSnapshot().rows.every((row) => row.freshness === "unavailable")).toBe(true); + expect(direct.getConnectionState().status).toBe("connected"); + await parent.subagents.create({ + config: { provider: "codex/test" }, + idempotencyKey: "while-withdrawn", + }); + expect(directory.getSnapshot().rows).toHaveLength(3); + available = true; + for (const listener of changed) listener(); + await expect.poll(() => directory.getSnapshot().rows.length).toBe(4); + expect(directory.getSnapshot().status).toBe("current"); + // A managed action resolves its complete address through the parent capability. + await directory.archive(local.address); + await expect.poll(async () => (await local.refresh())?.agent.archivedAt).not.toBeNull(); + expect((await routed.refresh())?.agent.archivedAt).toBeNull(); + await paseo.hosts.update(registration.registrationId, { enabled: false }); + await expect(directory.archive(routed.address)).rejects.toBeDefined(); + // The separately connected B remains usable but must never satisfy the A-owned action. + expect((await destination.agents.ref(collision).refresh())?.agent.archivedAt).toBeNull(); + expect(directory.getSnapshot().archiving.size).toBe(0); + await model.releaseServer(parent.serverId); + const released = directory.getSnapshot(); + await parent.subagents.create({ + config: { provider: "codex/test" }, + idempotencyKey: "after-release", + }); + expect(directory.getSnapshot()).toBe(released); + } finally { + await model.releaseServer(a.daemon.getServerId()); + await Promise.allSettled([originRuntime.dispose(), paseo.dispose(), destination.dispose()]); + await Promise.allSettled([origin.close(), direct.close()]); + await Promise.allSettled([a.close(), b.close()]); + } +}); + const AGENT_DEFAULTS: Agent = { serverId: SERVER_ID, id: "agent", @@ -47,7 +159,8 @@ const AGENT_DEFAULTS: Agent = { attentionReason: null, attentionTimestamp: null, archivedAt: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, projectPlacement: null, }; @@ -180,420 +293,295 @@ describe("selectSubagentsForParent", () => { ).map((row) => row.id), ).toEqual(["nested"]); }); +}); - it("returns only non-archived children for the requested parent", () => { +const fixtureParent = { serverId: SERVER_ID, agentId: "parent" }; +const authority = { originServerId: SERVER_ID, serverId: SERVER_ID }; +function membership(agents: Agent[]) { + return membershipFixture( + fixtureParent, + agents.map((agent) => relationshipFixture(fixtureParent, agent)), + ); +} + +describe("authoritative managed row projection", () => { + it("projects only supplied membership, independently of labels, siblings and grandchildren", () => { setAgents([ - makeAgent({ id: "parent-a" }), - makeAgent({ id: "child-a", parentAgentId: "parent-a" }), makeAgent({ - id: "archived-child", - parentAgentId: "parent-a", - archivedAt: new Date("2026-03-08T12:00:00.000Z"), - }), - ]); - - const rows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-a", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ); - - expect(rows.map((row) => row.id)).toEqual(["child-a"]); - }); - - it("excludes siblings, unrelated agents, and grandchildren", () => { - setAgents([ - makeAgent({ id: "parent-a" }), - makeAgent({ id: "parent-b" }), - makeAgent({ id: "child-a", parentAgentId: "parent-a" }), - makeAgent({ id: "sibling-b", parentAgentId: "parent-b" }), - makeAgent({ id: "grandchild-a", parentAgentId: "child-a" }), - makeAgent({ id: "unrelated" }), - ]); - - const rows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-a", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ); - - expect(rows.map((row) => row.id)).toEqual(["child-a"]); - }); - - it("shows only direct children for each parent", () => { - setAgents([ - makeAgent({ id: "parent" }), - makeAgent({ id: "child", parentAgentId: "parent" }), - makeAgent({ id: "grandchild", parentAgentId: "child" }), - ]); - - const parentRows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ); - const childRows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "child", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ); - - expect(parentRows.map((row) => row.id)).toEqual(["child"]); - expect(childRows.map((row) => row.id)).toEqual(["grandchild"]); - }); - - it("sorts by createdAt ascending", () => { - setAgents([ - makeAgent({ id: "parent" }), - makeAgent({ - id: "third", - parentAgentId: "parent", - createdAt: new Date("2026-03-08T10:03:00.000Z"), + id: "unrelated", + ...managedAgentFields({ serverId: SERVER_ID, agentId: "parent" }), }), makeAgent({ - id: "first", - parentAgentId: "parent", - createdAt: new Date("2026-03-08T10:01:00.000Z"), - }), - makeAgent({ - id: "second", - parentAgentId: "parent", - createdAt: new Date("2026-03-08T10:02:00.000Z"), + id: "grandchild", + ...managedAgentFields({ serverId: SERVER_ID, agentId: "child" }), }), ]); - - const rows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ); - - expect(rows.map((row) => row.id)).toEqual(["first", "second", "third"]); - }); - - it("maps only row-rendered fields and does not expose onOpen", () => { - const createdAt = new Date("2026-03-08T10:01:00.000Z"); - setAgents([ - makeAgent({ id: "parent" }), + const state = membership([ makeAgent({ id: "child", - parentAgentId: "parent", - provider: "claude", - title: "Review child", - status: "running", - requiresAttention: true, - createdAt, - model: "should-not-leak", - cwd: "/private/project", + ...managedAgentFields({ serverId: SERVER_ID, agentId: "untrusted-other-fixtureParent" }), }), ]); - - const rows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent", - }, - EMPTY_PENDING_ARCHIVE_IDS, + const rows = selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS); + expect(rows.map((row) => row.address)).toEqual([{ serverId: SERVER_ID, agentId: "child" }]); + expect(selectSubagentsForParent(membership([]), authority, EMPTY_PENDING_ARCHIVE_IDS)).toEqual( + [], ); - - expect(rows).toEqual([ - { - kind: "paseo", - id: "child", - provider: "claude", - title: "Review child", - description: null, - subtitle: null, - status: "running", - turn: { phase: "idle", cancellationRequestId: null }, - requiresAttention: true, - createdAt, - }, - ]); - expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ - "createdAt", - "description", - "id", - "kind", - "provider", - "requiresAttention", - "status", - "subtitle", - "title", - "turn", - ]); - expect(rows[0]).not.toHaveProperty("onOpen"); - expect(rows[0]).not.toHaveProperty("model"); - expect(rows[0]).not.toHaveProperty("cwd"); }); - it("moves a child when parentAgentId changes", () => { - const child = makeAgent({ id: "child", parentAgentId: "parent-a" }); - setAgents([makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), child]); - - expect( - selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-a", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ).map((row) => row.id), - ).toEqual(["child"]); - expect( - selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-b", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ).map((row) => row.id), - ).toEqual([]); - - setAgents([ - makeAgent({ id: "parent-a" }), - makeAgent({ id: "parent-b" }), - { ...child, parentAgentId: "parent-b" }, - ]); - - expect( - selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-a", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ).map((row) => row.id), - ).toEqual([]); - expect( - selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent-b", - }, - EMPTY_PENDING_ARCHIVE_IDS, - ).map((row) => row.id), - ).toEqual(["child"]); - }); - - it("excludes children whose archive is pending", () => { - setAgents([ - makeAgent({ id: "parent" }), - makeAgent({ id: "child-a", parentAgentId: "parent" }), - makeAgent({ id: "child-b", parentAgentId: "parent" }), - ]); - - const rows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent", - }, - new Set(["child-b"]), - ); - - expect(rows.map((row) => row.id)).toEqual(["child-a"]); - }); - - it("returns the shared empty array when pending archive hides the last child", () => { - setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child", parentAgentId: "parent" })]); - - const rows = selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "parent", - }, - new Set(["child"]), - ); - - expect(rows).toEqual([]); - expect(rows).toBe( - selectSubagentsForParent( - useSessionStore.getState(), - { - serverId: SERVER_ID, - parentAgentId: "missing-parent", - }, - EMPTY_PENDING_ARCHIVE_IDS, + it("removes confirmed terminal membership but retains creating and pending rows", () => { + const phases = [ + "attached", + "creating", + "detach_pending", + "lifecycle_pending", + "detached", + "archived", + "forgotten", + "canceled", + ] as const; + const state = membershipFixture( + fixtureParent, + phases.map((phase) => + relationshipFixture(fixtureParent, makeAgent({ id: phase }), { phase }), ), ); + expect( + selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS).map((row) => row.phase), + ).toEqual(["attached", "creating", "detach_pending", "lifecycle_pending"]); + }); + + it("sorts by creation time and preserves ordinary presentation fields without exposing raw agent data", () => { + const earlier = makeAgent({ + id: "first", + title: "Review child", + provider: "claude", + status: "running", + requiresAttention: true, + createdAt: new Date("2026-03-08T09:00:00Z"), + }); + const rows = selectSubagentsForParent( + membership([makeAgent({ id: "second" }), earlier]), + authority, + EMPTY_PENDING_ARCHIVE_IDS, + ); + expect(rows.map((row) => row.address.agentId)).toEqual(["first", "second"]); + expect(rows[0]).toMatchObject({ + title: "Review child", + provider: "claude", + status: "running", + requiresAttention: true, + createdAt: earlier.createdAt, + authority, + }); + expect(rows[0]).not.toHaveProperty("onOpen"); + expect(rows[0]).not.toHaveProperty("cwd"); + expect(rows[0]).not.toHaveProperty("model"); + }); + + it("keeps colliding IDs distinct and hides only the qualified pending archive", () => { + const rows = selectSubagentsForParent( + membership([makeAgent({ id: "same" }), makeAgent({ serverId: "server-2", id: "same" })]), + authority, + new Set([JSON.stringify([SERVER_ID, "same"])]), + ); + expect(rows.map((row) => row.address)).toEqual([{ serverId: "server-2", agentId: "same" }]); + expect(rows[0]?.authority).toEqual({ originServerId: SERVER_ID, serverId: "server-2" }); + }); + + it("retains unavailable membership with its last snapshot and unresolved membership without a fabricated status", () => { + const state = membershipFixture(fixtureParent, [ + relationshipFixture(fixtureParent, makeAgent({ id: "stale", status: "running" }), { + freshness: "unavailable", + error: { code: "host_offline", message: "Host unavailable" }, + }), + relationshipFixture(fixtureParent, makeAgent({ id: "creating" }), { + phase: "creating", + snapshot: undefined, + freshness: "unavailable", + }), + ]); + const rows = selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS); + expect(rows.find((row) => row.address.agentId === "stale")).toMatchObject({ + status: "running", + freshness: "unavailable", + error: { code: "host_offline" }, + }); + expect(rows.find((row) => row.address.agentId === "creating")).toMatchObject({ + status: null, + turn: null, + createdAt: null, + phase: "creating", + }); }); }); describe("createSubagentsForParentSelector", () => { - class IterationCountingMap extends Map { - iterations = 0; - override values() { - this.iterations += 1; - return super.values(); - } - override entries() { - this.iterations += 1; - return super.entries(); - } - override [Symbol.iterator]() { - this.iterations += 1; - return super[Symbol.iterator](); - } - } - - const params = { serverId: SERVER_ID, parentAgentId: "parent" }; - - function streamTick(text: string): void { - useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", { - head: [ - { - kind: "assistant_message", - id: "message-1", - text, - timestamp: new Date("2026-03-08T10:05:00.000Z"), - }, - ], - }); - } - - it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => { - const agents = new IterationCountingMap( - [ - makeAgent({ id: "parent" }), - makeAgent({ id: "child", parentAgentId: "parent" }), - makeAgent({ id: "unrelated" }), - ].map((agent) => [agent.id, agent]), - ); - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore.getState().setAgents(SERVER_ID, agents); - const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS); - const before = select(useSessionStore.getState()); - const iterationsAfterFirstSelect = agents.iterations; - - for (let tick = 0; tick < 100; tick += 1) { - streamTick(`token ${tick}`); - expect(select(useSessionStore.getState())).toBe(before); - } - - expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents); - expect(agents.iterations).toBe(iterationsAfterFirstSelect); - expect(before.map((row) => row.id)).toEqual(["child"]); - }); - - it("recomputes when the agents map changes", () => { - setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]); - const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS); - const before = select(useSessionStore.getState()); - - useSessionStore.getState().setAgents(SERVER_ID, (current) => { - const child = makeAgent({ - id: "child-b", - parentAgentId: "parent", - createdAt: new Date("2026-03-08T10:01:00.000Z"), + it("retains row identity across 100 unrelated transcript commits without reading the session agent map", () => { + setAgents([makeAgent({ id: "parent" })]); + const state = membership([makeAgent({ id: "child" })]); + const select = createSubagentsForParentSelector(authority, EMPTY_PENDING_ARCHIVE_IDS); + const before = select(state); + for (let tick = 0; tick < 100; tick++) { + useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", { + head: [ + { + kind: "assistant_message", + id: "message", + text: `token ${tick}`, + timestamp: AGENT_TIMESTAMP, + }, + ], }); - return new Map(current).set(child.id, child); - }); - const after = select(useSessionStore.getState()); - - expect(after).not.toBe(before); - expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]); - }); - - it("keeps the previous rows when a changed agents map yields the same rows", () => { - setAgents([ - makeAgent({ id: "parent" }), - makeAgent({ id: "child", parentAgentId: "parent" }), - makeAgent({ id: "unrelated" }), - ]); - const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS); - const before = select(useSessionStore.getState()); - - useSessionStore.getState().setAgents(SERVER_ID, (current) => { - return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" })); - }); - - expect(select(useSessionStore.getState())).toBe(before); - }); - - it("applies the pending archive ids it was created with", () => { - setAgents([ - makeAgent({ id: "parent" }), - makeAgent({ id: "child-a", parentAgentId: "parent" }), - makeAgent({ id: "child-b", parentAgentId: "parent" }), - ]); - - const rows = createSubagentsForParentSelector( - params, - new Set(["child-b"]), - )(useSessionStore.getState()); - - expect(rows.map((row) => row.id)).toEqual(["child-a"]); - }); - it("does no agent map walks for 100 stream commits with unchanged source maps", () => { - const agents = new IterationCountingMap( - Array.from({ length: 200 }, (_, i) => { - const agent = makeAgent({ - id: `agent-${i}`, - workspaceId: "workspace", - parentAgentId: i < 3 ? null : `agent-${i % 3}`, - }); - return [agent.id, agent] as const; - }), - ); - useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore.getState().setAgents(SERVER_ID, agents); - const selectVisibility = createWorkspaceAgentVisibilitySelector({ - serverId: SERVER_ID, - workspaceId: "workspace", - }); - const selectChildren = Array.from({ length: 3 }, (_, i) => - createSubagentsForParentSelector( - { serverId: SERVER_ID, parentAgentId: `agent-${i}` }, - EMPTY_PENDING_ARCHIVE_IDS, - ), - ); - const select = (state: ReturnType) => { - selectVisibility(state); - selectChildren.forEach((selector) => selector(state)); - }; - select(useSessionStore.getState()); - const before = agents.iterations; - const unsubscribe = useSessionStore.subscribe(select); - try { - for (let tick = 0; tick < 100; tick++) { - useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", { - head: [ - { - kind: "assistant_message", - id: "message", - text: `token ${tick}`, - timestamp: AGENT_TIMESTAMP, - }, - ], - }); - } - expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents); - expect(agents.iterations - before).toBe(0); - } finally { - unsubscribe(); - useSessionStore.getState().clearSession(SERVER_ID); + expect(select(state)).toBe(before); } + expect(before.map((row) => row.address.agentId)).toEqual(["child"]); + }); + it("recomputes changed membership and preserves equal projections after a refreshed snapshot", () => { + const select = createSubagentsForParentSelector(authority, EMPTY_PENDING_ARCHIVE_IDS); + const state = membership([makeAgent({ id: "first" })]); + const before = select(state); + expect(select({ ...state })).toBe(before); + const after = select(membership([makeAgent({ id: "first" }), makeAgent({ id: "second" })])); + expect(after).not.toBe(before); + expect(after.map((row) => row.address.agentId)).toEqual(["first", "second"]); + }); + it("applies captured qualified archive keys, including hiding the last child", () => { + const select = createSubagentsForParentSelector( + authority, + new Set([JSON.stringify([SERVER_ID, "child"])]), + ); + expect(select(membership([makeAgent({ id: "child" })]))).toEqual([]); }); }); + +it("resolves child actions through the owning app runtime after initial failure and blocks disposed callers", async () => { + const harnessModule = new URL( + "../../../server/src/server/test-utils/paseo-daemon.ts", + import.meta.url, + ).pathname; + const { createTestPaseoDaemon } = await import(harnessModule); + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection"); + const { HostRuntimeStore } = await import("@/runtime/host-runtime"); + const { createSessionStore } = await import("@/stores/session-store"); + const { indexedDB, IDBKeyRange } = await import("fake-indexeddb"); + globalThis.indexedDB = indexedDB; + globalThis.IDBKeyRange = IDBKeyRange; + const daemons = await Promise.all([ + createTestPaseoDaemon(), + createTestPaseoDaemon(), + createTestPaseoDaemon(), + ]); + const [a, b, c] = daemons; + const clients = daemons.map((daemon) => + createHostClient(`127.0.0.1:${daemon.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }), + ); + await Promise.all(clients.map((client) => client.connect())); + const [origin, parentApi, destination] = clients.map((client) => createPaseoApi(client)); + const storage = new Map(); + let failC = true; + let acquisitions = 0; + const registry = new HostRuntimeStore({ + sessionStore: createSessionStore(), + storage: { + getItem: async (key) => storage.get(key) ?? null, + setItem: async (key, value) => { + storage.set(key, value); + }, + removeItem: async (key) => { + storage.delete(key); + }, + }, + resolveHostConnection: async (source, serverId, signal) => { + acquisitions++; + expect(source).toBe(clients[0]); + if (serverId === c.daemon.getServerId() && failC) + throw new Error("Initial C acquisition unavailable"); + return acquireHostConnection(source, serverId, signal); + }, + }); + const leases: Array = []; + try { + const [, cRegistration] = await Promise.all([ + origin!.hosts.add({ connection: `127.0.0.1:${b.port}` }), + origin!.hosts.add({ connection: `127.0.0.1:${c.port}` }), + parentApi!.hosts.add({ connection: `127.0.0.1:${c.port}` }), + ]); + await expect + .poll(async () => + (await origin!.hosts.list()).hosts.every((host) => host.status === "online"), + ) + .toBe(true); + await expect.poll(async () => (await parentApi!.hosts.list()).hosts[0]?.status).toBe("online"); + const parent = await parentApi!.agents.create({ + cwd: b.staticDir, + config: { provider: "codex/test" }, + }); + const workspace = await destination!.workspaces.create({ + source: { kind: "directory", path: c.staticDir }, + }); + const child = await parent.subagents.create({ + serverId: c.daemon.getServerId(), + workspaceId: workspace.id, + config: { provider: "codex/test" }, + idempotencyKey: "bound-parent-child", + }); + await registry.upsertDirectConnection({ + serverId: a.daemon.getServerId(), + endpoint: `127.0.0.1:${a.port}`, + existingClient: clients[0], + }); + await expect + .poll(() => registry.getSnapshot(a.daemon.getServerId())?.connectionStatus) + .toBe("online"); + const retainedC = registry.acquireBinding({ + originServerId: a.daemon.getServerId(), + serverId: c.daemon.getServerId(), + }); + leases.push(retainedC); + await expect(retainedC.binding.ready).rejects.toThrow("Initial C acquisition unavailable"); + failC = false; + await origin!.hosts.update(cRegistration!.registrationId, { + metadata: { revision: "recovered" }, + }); + await expect + .poll(() => retainedC.binding.runtime.getSnapshot(c.daemon.getServerId())?.connectionStatus) + .toBe("online"); + const boundParent = registry.acquireBinding({ + originServerId: a.daemon.getServerId(), + serverId: b.daemon.getServerId(), + }); + leases.push(boundParent); + await boundParent.binding.ready; + const directory = boundParent.binding.runtime.subagents.directory(parent.address); + directory.subscribe(() => {}); + await expect.poll(() => directory.getSnapshot().rows.length).toBe(1); + await directory.archive(child.address); + expect((await child.refresh())?.agent.archivedAt).not.toBeNull(); + const captured = boundParent.binding.runtime.withAgent.bind(boundParent.binding.runtime); + await boundParent.release(); + const beforeDisposedCall = acquisitions; + await expect(captured(child.address, (agent) => agent.refresh())).rejects.toThrow("disposed"); + expect(acquisitions).toBe(beforeDisposedCall); + const direct = registry.acquireBinding({ + originServerId: a.daemon.getServerId(), + serverId: a.daemon.getServerId(), + }); + leases.push(direct); + await direct.binding.ready; + expect( + await direct.binding.runtime.withAgent(child.address, async (agent) => agent.address), + ).toEqual(child.address); + } finally { + await Promise.allSettled(leases.map((lease) => lease.release())); + registry.syncHosts([]); + await Promise.allSettled([origin, parentApi, destination].map((api) => api!.dispose())); + await Promise.allSettled(clients.map((client) => client.close())); + await Promise.allSettled(daemons.map((daemon) => daemon.close())); + } +}, 60000); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 164a4d1280..0d001297d2 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -1,23 +1,43 @@ -import { useEffect, useMemo } from "react"; -import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore, type Agent } from "@/stores/session-store"; -import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store"; +import { + refreshProviderSubagents, + useProviderSubagentStore, + useProviderSubagentStoreApi, +} from "./provider-store"; +import { useHostRuntimeStore, type HostAuthorityReference } from "@/runtime/host-runtime"; +import type { AgentAddress, WorkspaceAddress } from "@getpaseo/protocol/addresses"; +import type { ManagedSubagentState } from "./managed"; +import type { SubagentRelationship } from "@getpaseo/protocol/messages"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { + buildAgentFactsLine, + agentFactsSnapshot, + type AgentFactsSnapshot, +} from "@/panels/agent-facts-line"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; export interface PaseoSubagentRow { kind: "paseo"; - id: Agent["id"]; + address: AgentAddress; + authority: HostAuthorityReference; + relationshipId: string; + workspace: WorkspaceAddress | null; + freshness: SubagentRelationship["freshness"]; + phase: SubagentRelationship["phase"]; + error: SubagentRelationship["error"]; provider: Agent["provider"]; title: Agent["title"]; /** Managed agents have a real title, so the union's task line is always absent for them. */ description: null; - subtitle: null; - status: Agent["status"]; - turn: Agent["turn"]; + subtitle: string | null; + facts: AgentFactsSnapshot | null; + status: Agent["status"] | null; + turn: Agent["turn"] | null; requiresAttention: Agent["requiresAttention"]; - createdAt: Agent["createdAt"]; + createdAt: Agent["createdAt"] | null; } export interface ProviderSubagentRow { @@ -39,7 +59,6 @@ export interface ProviderSubagentRow { export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow; -type SessionStoreSnapshot = ReturnType; type ProviderSubagentStoreSnapshot = ReturnType; interface SelectSubagentsParams { @@ -49,79 +68,75 @@ interface SelectSubagentsParams { providerParentSubagentId?: string; } -const EMPTY_SUBAGENT_ROWS: SubagentRow[] = []; const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = []; -function toSubagentRow(agent: Agent): SubagentRow { - return { - kind: "paseo", - id: agent.id, - provider: agent.provider, - title: agent.title, - description: null, - subtitle: null, - status: agent.status, - turn: agent.turn, - requiresAttention: agent.requiresAttention, - createdAt: agent.createdAt, - }; +export function subagentRowIdentity(row: SubagentRow): string { + return row.kind === "paseo" + ? JSON.stringify(["paseo", row.address.serverId, row.address.agentId]) + : JSON.stringify(["provider", row.parentAgentId, row.id]); } export function selectSubagentsForParent( - state: SessionStoreSnapshot, - params: SelectSubagentsParams, - pendingArchiveIds: ReadonlySet, -): SubagentRow[] { - const agents = state.sessions[params.serverId]?.agents; - if (!agents || agents.size === 0) { - return EMPTY_SUBAGENT_ROWS; + state: ManagedSubagentState, + authority: HostAuthorityReference, + pendingArchiveKeys: ReadonlySet, +): PaseoSubagentRow[] { + const rows: PaseoSubagentRow[] = []; + for (const relationship of state.rows) { + if (["detached", "archived", "forgotten", "canceled"].includes(relationship.phase)) continue; + const address = relationship.child; + if (pendingArchiveKeys.has(JSON.stringify([address.serverId, address.agentId]))) continue; + const snapshot = relationship.snapshot + ? normalizeAgentSnapshot(relationship.snapshot, address.serverId) + : null; + rows.push({ + kind: "paseo", + address, + authority: { originServerId: authority.originServerId, serverId: address.serverId }, + relationshipId: relationship.id, + workspace: relationship.workspace ?? null, + phase: relationship.phase, + freshness: relationship.freshness, + error: relationship.error, + provider: snapshot?.provider ?? "", + title: snapshot?.title ?? null, + description: null, + subtitle: snapshot + ? buildAgentFactsLine({ ...agentFactsSnapshot(snapshot), models: null }) + : null, + facts: snapshot ? agentFactsSnapshot(snapshot) : null, + status: snapshot?.status ?? null, + turn: snapshot?.turn ?? null, + requiresAttention: snapshot?.requiresAttention ?? false, + createdAt: snapshot?.createdAt ?? null, + }); } - - const rows: SubagentRow[] = []; - for (const agent of agents.values()) { - if ( - agent.archivedAt || - pendingArchiveIds.has(agent.id) || - agent.parentAgentId !== params.parentAgentId - ) { - continue; - } - rows.push(toSubagentRow(agent)); - } - - if (rows.length === 0) { - return EMPTY_SUBAGENT_ROWS; - } - - rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + rows.sort(compareSubagentRows); return rows; } -// Session-store selectors run on every store update, including every stream tick, which -// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep -// them when a recompute yields equal rows so the hook's equality check stays O(1). export function createSubagentsForParentSelector( - params: SelectSubagentsParams, - pendingArchiveIds: ReadonlySet, -): (state: SessionStoreSnapshot) => SubagentRow[] { - let previousAgents: Map | undefined; - let previousRows: SubagentRow[] | null = null; - + authority: HostAuthorityReference, + pendingArchiveKeys: ReadonlySet, +): (state: ManagedSubagentState) => PaseoSubagentRow[] { + let previous: ManagedSubagentState | null = null; + let previousRows: PaseoSubagentRow[] = []; return (state) => { - const agents = state.sessions[params.serverId]?.agents; - if (previousRows && agents === previousAgents) { - return previousRows; - } - - const rows = selectSubagentsForParent(state, params, pendingArchiveIds); - previousAgents = agents; - if (!previousRows || !equal(previousRows, rows)) { - previousRows = rows; - } + if (previous === state) return previousRows; + const rows = selectSubagentsForParent(state, authority, pendingArchiveKeys); + previous = state; + if (!equal(previousRows, rows)) previousRows = rows; return previousRows; }; } +function compareSubagentRows(left: SubagentRow, right: SubagentRow): number { + return ( + (left.createdAt?.getTime() ?? 0) - (right.createdAt?.getTime() ?? 0) || + subagentRowIdentity(left).localeCompare(subagentRowIdentity(right)) + ); +} + export function selectProviderSubagentsForParent( state: ProviderSubagentStoreSnapshot, params: SelectSubagentsParams, @@ -153,18 +168,25 @@ export function selectProviderSubagentsForParent( createdAt: new Date(subagent.createdAt), }); } - rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + rows.sort(compareSubagentRows); return rows; } export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const { serverId, parentAgentId } = params; - const pendingArchiveIds = usePendingArchiveAgentIds(serverId); - const selectPaseoRows = useMemo( - () => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds), - [serverId, parentAgentId, pendingArchiveIds], + const providerStore = useProviderSubagentStoreApi(); + const runtime = useHostRuntimeStore(); + const directory = runtime.subagents.directory({ serverId, agentId: parentAgentId }); + const membership = useSyncExternalStore( + directory.subscribe, + directory.getSnapshot, + directory.getSnapshot, + ); + const paseoRows = useMemo( + () => + selectSubagentsForParent(membership, runtime.authorityFor(serverId), membership.archiving), + [membership, runtime, serverId], ); - const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal); const supported = useSessionStore( (state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, ); @@ -173,7 +195,7 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true, ); const providerRows = useStoreWithEqualityFn( - useProviderSubagentStore, + providerStore, (state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported), equal, ); @@ -181,16 +203,19 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo useEffect(() => { if (!client || !supported) return; - void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch( - () => undefined, - ); - }, [client, params.parentAgentId, params.serverId, supported]); + void refreshProviderSubagents( + client, + params.serverId, + params.parentAgentId, + providerStore, + ).catch(() => undefined); + }, [client, params.parentAgentId, params.serverId, providerStore, supported]); return useMemo(() => { if (params.providerParentSubagentId) return providerRows; if (providerRows.length === 0) return paseoRows; const rows = [...paseoRows, ...providerRows]; - rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + rows.sort(compareSubagentRows); return rows; }, [params.providerParentSubagentId, paseoRows, providerRows]); } diff --git a/packages/app/src/subagents/test-fixtures.ts b/packages/app/src/subagents/test-fixtures.ts new file mode 100644 index 0000000000..b4c7a73055 --- /dev/null +++ b/packages/app/src/subagents/test-fixtures.ts @@ -0,0 +1,43 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import type { SubagentRelationship } from "@getpaseo/protocol/messages"; +import type { Agent } from "@/stores/session-store"; +import { projectAgentSnapshot } from "@/utils/agent-snapshots"; +import type { ManagedSubagentState } from "./managed"; + +export function relationshipFixture( + parent: AgentAddress, + agent: Agent, + overrides: Partial = {}, +): SubagentRelationship { + return { + id: JSON.stringify([parent, agent.serverId, agent.id]), + parent, + child: { serverId: agent.serverId, agentId: agent.id }, + ...(agent.workspaceId + ? { workspace: { serverId: agent.serverId, workspaceId: agent.workspaceId } } + : {}), + snapshot: projectAgentSnapshot(agent), + phase: "attached", + observedAt: agent.updatedAt.toISOString(), + freshness: "current", + error: null, + ...overrides, + }; +} + +export function membershipFixture( + parent: AgentAddress, + rows: SubagentRelationship[], +): ManagedSubagentState { + return { parent, rows, status: "current", error: null, archiving: new Set() }; +} + +export function managedAgentFields( + parent: AgentAddress | null, + parentWorkspace: import("@getpaseo/protocol/addresses").WorkspaceAddress | null = null, +): Pick { + return { + parent, + managedRelationship: parent ? { id: JSON.stringify(parent), parent, parentWorkspace } : null, + }; +} diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index df009583be..ae2140023d 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -8,12 +8,17 @@ import { resolveRowLabel, } from "./track-presentation"; -function row( - overrides: Partial & Pick, -): PaseoSubagentRow { +function row(overrides: Partial & { id: string }): PaseoSubagentRow { return { kind: "paseo", - id: overrides.id, + facts: null, + address: { serverId: "server-1", agentId: overrides.id }, + authority: { originServerId: "server-1", serverId: "server-1" }, + relationshipId: overrides.id, + workspace: null, + freshness: "current", + phase: "attached", + error: null, provider: overrides.provider ?? "codex", title: overrides.title ?? `Agent ${overrides.id}`, description: null, @@ -170,7 +175,7 @@ describe("resolveRowLabel", () => { describe("buildSubagentRowPresentationData", () => { it("namespaces the key with a subagent prefix", () => { expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe( - "paseo_subagent_child-a", + JSON.stringify(["paseo", "server-1", "child-a"]), ); }); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d95082ca39..78b122b5c2 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -2,13 +2,13 @@ import type { TFunction } from "i18next"; import type { ComposerTrackPillSegment } from "@/composer/tracks"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; -import type { SubagentRow } from "./select"; +import { subagentRowIdentity, type SubagentRow } from "./select"; import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { if (row.kind === "paseo") { - if (row.turn.phase === "open") return "running"; + if (row.turn?.phase === "open") return "running"; return row.status === "running" ? "idle" : row.status; } return providerSubagentLifecycleStatus(row.status); @@ -33,15 +33,18 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP const subtitle = providerSubtitle ?? (description ? title : null); const status = presentationStatus(row); return { - key: `${row.kind}_subagent_${row.id}`, + key: subagentRowIdentity(row), kind: "agent", label: label ?? "", subtitle: subtitle ?? "", titleState: label ? "ready" : "loading", - statusBucket: deriveSidebarStateBucket({ - status, - requiresAttention: false, - }), + statusBucket: + status === null + ? null + : deriveSidebarStateBucket({ + status, + requiresAttention: false, + }), }; } diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 43f659c534..67c22ed048 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,3 +1,5 @@ +import { HostAuthorityRoute } from "@/runtime/host-authority-route"; +import { useAgentSnapshotFactsLine } from "@/panels/agent-facts-line"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -14,9 +16,8 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; -import { useAgentFactsLine } from "@/panels/agent-facts-line"; import { getPanelManifest } from "@/panels/panel-manifest"; -import type { SubagentRow } from "./select"; +import { subagentRowIdentity, type SubagentRow, type PaseoSubagentRow } from "./select"; import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { buildSubagentPillPresentation, @@ -35,17 +36,17 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ export interface SubagentsTrackProps { serverId: string; rows: SubagentRow[]; - onOpenSubagent: (id: string) => void; + onOpenSubagent: (row: PaseoSubagentRow) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; /** * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. */ opensInSheet: (row: SubagentRow) => boolean; - onArchiveSubagent: (id: string) => void; + onArchiveSubagent: (row: PaseoSubagentRow) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; - onDetachSubagent?: (id: string) => void; + onDetachSubagent?: (row: PaseoSubagentRow) => void; } const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; @@ -53,7 +54,10 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; const ROW_ICON_SIZE = 14; function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { - const icon = useProviderIcon(row.provider, serverId); + const icon = useProviderIcon( + row.provider, + row.kind === "paseo" ? row.address.serverId : serverId, + ); const data = buildSubagentRowPresentationData(row); return { ...data, @@ -105,7 +109,7 @@ export function SubagentsTrack({ ) : null} {rows.map((row) => ( void; + onOpenSubagent: (row: PaseoSubagentRow) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; opensInSheet: boolean; - onArchiveSubagent: (id: string) => void; - onDetachSubagent?: (id: string) => void; + onArchiveSubagent: (row: PaseoSubagentRow) => void; + onDetachSubagent?: (row: PaseoSubagentRow) => void; } function SubagentsTrackRow({ @@ -205,17 +209,22 @@ function SubagentsTrackRow({ if (row.kind === "provider") { onOpenProviderSubagent(row.parentAgentId, row.id); } else { - onOpenSubagent(row.id); + onOpenSubagent(row); } }, [onOpenProviderSubagent, onOpenSubagent, row]); const handleArchivePress = useCallback(() => { - onArchiveSubagent(row.id); - }, [onArchiveSubagent, row.id]); + if (row.kind === "paseo") onArchiveSubagent(row); + }, [onArchiveSubagent, row]); const handleDetachPress = useCallback(() => { - onDetachSubagent?.(row.id); - }, [onDetachSubagent, row.id]); + if (row.kind === "paseo") onDetachSubagent?.(row); + }, [onDetachSubagent, row]); const actionsAlwaysVisible = isNative || isCompact; + const rowId = row.kind === "paseo" ? row.address.agentId : row.id; + const pendingFacts = useMemo( + () => , + [rowId, row.subtitle], + ); const renderRow = useCallback( ({ active }: { active: boolean }) => ( <> @@ -225,14 +234,31 @@ function SubagentsTrackRow({ {displayLabel} {row.kind === "paseo" ? ( - + <> + + + + {row.freshness !== "current" || + row.phase === "detach_pending" || + row.phase === "lifecycle_pending" ? ( + + ) : null} + ) : ( - + )} {row.kind === "paseo" ? ( @@ -268,10 +295,9 @@ function SubagentsTrackRow({ ); } -/** The same facts line the agent's sheet shows when it opens without its composer. */ -function PaseoSubagentFacts({ serverId, agentId }: { serverId: string; agentId: string }) { - const text = useAgentFactsLine(serverId, agentId); - return ; +function ManagedSubagentFacts({ row }: { row: PaseoSubagentRow }) { + const text = useAgentSnapshotFactsLine(row.address.serverId, row.facts); + return ; } function SubagentFacts({ rowId, text }: { rowId: string; text: string | null }) { diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts index e7653f054e..de015b64af 100644 --- a/packages/app/src/subagents/use-archive-finished.ts +++ b/packages/app/src/subagents/use-archive-finished.ts @@ -1,13 +1,12 @@ import { useEffect, useMemo, useSyncExternalStore } from "react"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeStore } from "@/runtime/host-runtime"; import { createArchiveFinishedSubagents, type ArchiveFinishedOutcome, type ArchiveFinishedState, type ArchiveFinishedSubagents, } from "./archive-finished"; -import { useProviderSubagentStore } from "./provider-store"; +import { useProviderSubagentStoreApi } from "./provider-store"; import type { SubagentRow } from "./select"; export type { ArchiveFinishedStatus } from "./archive-finished"; @@ -27,18 +26,38 @@ export function useArchiveFinishedSubagents({ parentAgentId, rows, }: UseArchiveFinishedSubagentsInput): ArchiveFinishedSubagentsCapability { - const { archiveAgent } = useArchiveAgent(); + const runtime = useHostRuntimeStore(); + const providerStore = useProviderSubagentStoreApi(); + const directory = runtime.subagents.directory({ serverId, agentId: parentAgentId }); const archiveFinished = useMemo( () => createArchiveFinishedSubagents([], { - parentAgentId, - getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id), - archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }), + parent: { serverId, agentId: parentAgentId }, + getManagedSubagent: (address) => { + const row = directory + .getSnapshot() + .rows.find( + (candidate) => + candidate.child.serverId === address.serverId && + candidate.child.agentId === address.agentId, + ); + return row && row.phase === "attached" + ? { + address: row.child, + parent: row.parent, + status: row.snapshot?.status ?? null, + freshness: row.freshness, + phase: row.phase, + archivedAt: row.snapshot?.archivedAt ? new Date(row.snapshot.archivedAt) : null, + } + : undefined; + }, + archiveManagedSubagent: directory.archive, dismissProviderSubagents: (ids) => { - useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids); + providerStore.getState().hideFromTrack(serverId, parentAgentId, ids); }, }), - [archiveAgent, parentAgentId, serverId], + [directory, parentAgentId, providerStore, serverId], ); const state = useSyncExternalStore( archiveFinished.subscribe, diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index b00f9bb871..00c2df3147 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,7 +1,8 @@ import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeStore } from "@/runtime/host-runtime"; +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import type { PaseoSubagentRow } from "./select"; import { confirmDialog } from "@/utils/confirm-dialog"; import { toErrorMessage } from "@/utils/error-messages"; import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent"; @@ -14,29 +15,37 @@ export type { } from "./archive-subagent"; export interface UseArchiveSubagentInput { - serverId: string; + parent: AgentAddress; } -export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { - const { archiveAgent } = useArchiveAgent(); - const { serverId } = input; +export function useArchiveSubagent( + input: UseArchiveSubagentInput, +): (row: PaseoSubagentRow) => void { + const runtime = useHostRuntimeStore(); + const directory = runtime.subagents.directory(input.parent); const toast = useToast(); return useCallback( - (subagentId: string) => { + (row: PaseoSubagentRow) => { void requestArchiveSubagent( - { serverId, subagentId }, + { child: row.address }, { - getSubagent: (id): ResolveArchiveSubagentDialogInput | undefined => - useSessionStore.getState().sessions[serverId]?.agents?.get(id), + getSubagent: (child): ResolveArchiveSubagentDialogInput | undefined => + directory + .getSnapshot() + .rows.find( + (current) => + current.child.serverId === child.serverId && + current.child.agentId === child.agentId, + )?.snapshot, confirm: confirmDialog, - archiveAgent, + archiveAgent: directory.archive, reportError: (error) => { toast.error(toErrorMessage(error)); }, }, ); }, - [archiveAgent, serverId, toast], + [directory, toast], ); } diff --git a/packages/app/src/subagents/use-detach-subagent.ts b/packages/app/src/subagents/use-detach-subagent.ts index 36b2f2a560..ac5a84613d 100644 --- a/packages/app/src/subagents/use-detach-subagent.ts +++ b/packages/app/src/subagents/use-detach-subagent.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { i18n } from "@/i18n/i18next"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeStore } from "@/runtime/host-runtime"; +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import type { PaseoSubagentRow } from "./select"; import { confirmDialog } from "@/utils/confirm-dialog"; import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -15,30 +17,32 @@ export type { } from "./detach-subagent"; export interface UseDetachSubagentInput { - serverId: string; + parent: AgentAddress; } -export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void { - const { serverId } = input; +export function useDetachSubagent(input: UseDetachSubagentInput): (row: PaseoSubagentRow) => void { + const runtime = useHostRuntimeStore(); + const directory = runtime.subagents.directory(input.parent); const toast = useToast(); return useCallback( - (subagentId: string) => { + (row: PaseoSubagentRow) => { void requestDetachSubagent( - { serverId, subagentId }, + { child: row.address }, { - getSubagent: (id): ResolveDetachSubagentDialogInput | undefined => - useSessionStore.getState().sessions[serverId]?.agents?.get(id), + getSubagent: (child): ResolveDetachSubagentDialogInput | undefined => + directory + .getSnapshot() + .rows.find( + (current) => + current.child.serverId === child.serverId && + current.child.agentId === child.agentId, + )?.snapshot, confirm: confirmDialog, - detachAgent: async ({ serverId: targetServerId, agentId }) => { - const client = useSessionStore.getState().sessions[targetServerId]?.client; - if (!client) { - throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected")); - } - await client.detachAgent(agentId); - }, + detachAgent: directory.detach, + reportPending: () => toast.show(i18n.t("agentList.badges.pending", { count: 1 })), openDetachedAgent: ({ serverId: targetServerId, agentId }) => { - navigateToAgent({ serverId: targetServerId, agentId }); + navigateToAgent({ serverId: targetServerId, agentId, authority: row.authority }); }, reportError: (error) => { toast.error(toErrorMessage(error)); @@ -46,6 +50,6 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s }, ); }, - [serverId, toast], + [directory, toast], ); } diff --git a/packages/app/src/subagents/workspace-root-policy.ts b/packages/app/src/subagents/workspace-root-policy.ts index 8594c0c7aa..915b01cc1d 100644 --- a/packages/app/src/subagents/workspace-root-policy.ts +++ b/packages/app/src/subagents/workspace-root-policy.ts @@ -1,17 +1,25 @@ import type { Agent } from "@/stores/session-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -type WorkspaceAgent = Pick; +type WorkspaceAgent = Pick; export function isWorkspaceRootAgent( agent: WorkspaceAgent, - parentAgent: Pick | undefined, + parentAgent?: Pick, ): boolean { - if (!agent.parentAgentId) { - return true; - } - + const membership = agent.managedRelationship; + if (!membership) return true; const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId); - const parentWorkspaceId = normalizeWorkspaceOpaqueId(parentAgent?.workspaceId); - return Boolean(workspaceId && parentWorkspaceId && workspaceId !== parentWorkspaceId); + const parentWorkspace = + membership.parentWorkspace ?? + (parentAgent?.serverId === membership.parent.serverId && + parentAgent.id === membership.parent.agentId && + parentAgent.workspaceId + ? { serverId: parentAgent.serverId, workspaceId: parentAgent.workspaceId } + : null); + return Boolean( + workspaceId && + parentWorkspace && + (agent.serverId !== parentWorkspace.serverId || workspaceId !== parentWorkspace.workspaceId), + ); } diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index ac5a6f84f3..2925e330b4 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import { describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { pickAttentionAgent, shouldClearAgentAttention } from "@/utils/agent-attention"; @@ -28,7 +29,8 @@ function createAgent(input: Partial & Pick): Agent { title: null, cwd: "/repo/worktree", model: null, - parentAgentId: null, + parent: null, + managedRelationship: null, labels: {}, requiresAttention: false, attentionReason: null, @@ -219,7 +221,7 @@ describe("pickAttentionAgent", () => { pickAttentionAgent([ createAgent({ id: "subagent", - parentAgentId: "parent-agent", + ...managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }), requiresAttention: true, attentionReason: "permission", attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), @@ -239,7 +241,7 @@ describe("pickAttentionAgent", () => { pickAttentionAgent([ createAgent({ id: "subagent", - parentAgentId: "parent-agent", + ...managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }), requiresAttention: true, attentionReason: "permission", attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts index a3e5df6279..5dc850f4fd 100644 --- a/packages/app/src/utils/agent-attention.ts +++ b/packages/app/src/utils/agent-attention.ts @@ -38,7 +38,7 @@ export function pickAttentionAgent(agents: Agent[]): string | null { continue; } - if (agent.parentAgentId) { + if (agent.managedRelationship) { continue; } diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 0362aa56de..465f214b53 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; @@ -18,6 +17,8 @@ function createAgentPayload( ): AgentSnapshotPayload { return { id: input.id, + parent: input.parent, + managedRelationship: input.managedRelationship, provider: input.provider ?? "codex", cwd: input.cwd ?? "/repo", model: input.model ?? null, @@ -156,7 +157,7 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); - it("re-derives parentAgentId every time an agent snapshot is ingested", () => { + it("replaces owner membership every time an agent snapshot is ingested", () => { const serverId = "server-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); @@ -166,7 +167,11 @@ describe("replaceFetchedAgentDirectory", () => { createEntry( createAgentPayload({ id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, + managedRelationship: { + id: "relationship", + parent: { serverId, agentId: "parent-a" }, + parentWorkspace: null, + }, }), ), ]); @@ -175,14 +180,19 @@ describe("replaceFetchedAgentDirectory", () => { createEntry( createAgentPayload({ id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, + managedRelationship: { + id: "relationship", + parent: { serverId, agentId: "parent-b" }, + parentWorkspace: null, + }, }), ), ]); expect( - useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId, - ).toBe("parent-b"); + useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.managedRelationship + ?.parent, + ).toEqual({ serverId, agentId: "parent-b" }); store.clearSession(serverId); }); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index d700a0f510..fc2bf00850 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -2,7 +2,7 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { Agent } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; -import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { ServerInfoStatusPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; type AgentDirectoryFetchEntry = FetchAgentsEntry; export type AgentDirectoryDelta = Extract< @@ -19,6 +19,7 @@ interface PendingPermissionEntry { export function buildAgentDirectoryState(input: { serverId: string; entries: AgentDirectoryFetchEntry[]; + serverInfo?: Pick | null; }): { agents: Map; pendingPermissions: Map; @@ -27,7 +28,7 @@ export function buildAgentDirectoryState(input: { const pendingPermissions = new Map(); for (const entry of input.entries) { - const normalized = normalizeAgentSnapshot(entry.agent, input.serverId); + const normalized = normalizeAgentSnapshot(entry.agent, input.serverId, input.serverInfo); const projectPlacement = resolveProjectPlacement({ projectPlacement: entry.project, cwd: normalized.cwd, diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index e1be8c5a0c..3c728bacd8 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -10,6 +10,8 @@ function createSnapshot( ): AgentSnapshotPayload { return { id: input.id ?? "agent-1", + parent: input.parent, + managedRelationship: input.managedRelationship, provider: input.provider ?? "codex", cwd: input.cwd ?? "/repo", model: input.model ?? null, @@ -78,15 +80,15 @@ describe("normalizeAgentSnapshot", () => { }); }); - it("derives parentAgentId from the parent label while preserving labels", () => { + it("normalizes legacy parent identity from the parent label while preserving labels", () => { const labels = { [PARENT_AGENT_ID_LABEL]: "parent-1", "custom.label": "still-here", }; - const agent = normalizeAgentSnapshot(createSnapshot({ labels }), "server-1"); + const agent = normalizeAgentSnapshot(createSnapshot({ labels }), "server-1", { features: {} }); - expect(agent.parentAgentId).toBe("parent-1"); + expect(agent.parent).toEqual({ serverId: "server-1", agentId: "parent-1" }); expect(agent.labels).toEqual(labels); }); @@ -94,24 +96,57 @@ describe("normalizeAgentSnapshot", () => { const agent = normalizeAgentSnapshot( createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: " parent-1 \n" } }), "server-1", + { features: {} }, ); - expect(agent.parentAgentId).toBe("parent-1"); + expect(agent.parent).toEqual({ serverId: "server-1", agentId: "parent-1" }); }); it("maps missing, empty, and non-string parent labels to null", () => { - const missing = normalizeAgentSnapshot(createSnapshot(), "server-1"); + const missing = normalizeAgentSnapshot(createSnapshot(), "server-1", { features: {} }); const empty = normalizeAgentSnapshot( createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: " " } }), "server-1", + { features: {} }, ); const nonString = normalizeAgentSnapshot( createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } }), "server-1", + { features: {} }, ); - expect(missing.parentAgentId).toBeNull(); - expect(empty.parentAgentId).toBeNull(); - expect(nonString.parentAgentId).toBeNull(); + expect(missing.parent).toBeNull(); + expect(empty.parent).toBeNull(); + expect(nonString.parent).toBeNull(); }); }); + +it("capable snapshots never acquire membership from a parent pointer or injected label", () => { + for (const parent of [ + { serverId: "server-1", agentId: "parent" }, + { serverId: "other", agentId: "parent" }, + ]) { + const normalized = normalizeAgentSnapshot( + createSnapshot({ parent, labels: { [PARENT_AGENT_ID_LABEL]: "parent" } }), + "server-1", + { features: { fleetSubagents: true } }, + ); + expect(normalized.parent).toEqual(parent); + expect(normalized.managedRelationship).toBeNull(); + expect(projectAgentSnapshot(normalized)).toMatchObject({ parent, managedRelationship: null }); + } +}); +it("round trips owner membership and explicit clearing without consulting labels", () => { + const managedRelationship = { + id: "relationship", + parent: { serverId: "A", agentId: "parent" }, + parentWorkspace: { serverId: "A", workspaceId: "same-workspace" }, + }; + const input = createSnapshot({ parent: managedRelationship.parent, managedRelationship }); + expect(projectAgentSnapshot(normalizeAgentSnapshot(input, "A"))).toMatchObject({ + managedRelationship, + }); + expect( + projectAgentSnapshot(normalizeAgentSnapshot({ ...input, managedRelationship: null }, "A")), + ).toMatchObject({ managedRelationship: null }); +}); diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index c1c70c640e..04499ecc0c 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,4 +1,4 @@ -import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { AgentSnapshotPayload, ServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import { @@ -60,6 +60,8 @@ export function derivePendingPermissionKey( export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { return { id: agent.id, + parent: agent.parent, + managedRelationship: agent.managedRelationship, provider: agent.provider, cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), @@ -88,7 +90,39 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { }; } -export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) { +function normalizeManagedMembership( + snapshot: AgentSnapshotPayload, + serverId: string, + serverInfo?: Pick | null, +) { + // COMPAT(legacyManagedMembership): added in v0.12.0, remove after 2027-04-20. + // A capable daemon's absent projection never grants membership from labels or a pointer. + const legacyParentId = + serverInfo && + serverInfo.features?.fleetSubagents !== true && + snapshot.parent === undefined && + snapshot.managedRelationship === undefined + ? getParentAgentIdFromLabels(snapshot.labels) + : null; + const legacyParent = legacyParentId ? { serverId, agentId: legacyParentId } : null; + const parent = snapshot.parent === undefined ? legacyParent : snapshot.parent; + const managedRelationship = + snapshot.managedRelationship ?? + (legacyParentId + ? { + id: `legacy:${serverId}:${snapshot.id}`, + parent: { serverId, agentId: legacyParentId }, + parentWorkspace: null, + } + : null); + return { parent, managedRelationship }; +} + +export function normalizeAgentSnapshot( + snapshot: AgentSnapshotPayload, + serverId: string, + serverInfo?: Pick | null, +) { const createdAt = new Date(snapshot.createdAt); const updatedAt = new Date(snapshot.updatedAt); const lastUserMessageAt = snapshot.lastUserMessageAt @@ -98,7 +132,11 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: ? new Date(snapshot.attentionTimestamp) : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; - const parentAgentId = getParentAgentIdFromLabels(snapshot.labels); + const { parent, managedRelationship } = normalizeManagedMembership( + snapshot, + serverId, + serverInfo, + ); // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. @@ -132,7 +170,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: attentionReason: snapshot.attentionReason ?? null, attentionTimestamp, archivedAt, - parentAgentId, + parent, + managedRelationship, labels: snapshot.labels, }; } diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index c13d6f25f9..d080dfdac5 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -289,7 +289,7 @@ export function parseHostAgentRouteFromPathname( export function parseHostWorkspaceRouteFromPathname( pathname: string, -): { serverId: string; workspaceId: string } | null { +): { serverId: string; workspaceId: string; authority?: { originServerId: string } } | null { const pathOnly = stripSearchAndHash(pathname); const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/); if (!match) { @@ -306,7 +306,8 @@ export function parseHostWorkspaceRouteFromPathname( if (!workspaceId) { return null; } - return { serverId, workspaceId }; + const authority = getHostRouteAuthority(pathname); + return { serverId, workspaceId, ...(authority ? { authority } : {}) }; } export function stripHostWorkspaceRouteEchoSearch(route: string): string { @@ -359,6 +360,16 @@ export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) { return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}` as const; } +/** Route metadata identifies the intended authority owner; restoration reacquires its grants. */ +export function withHostRouteAuthority( + route: string, + authority: { originServerId: string }, +): string { + const params = new URLSearchParams(extractSearch(route)); + params.set("origin", authority.originServerId); + return `${stripSearchAndHash(route)}?${params.toString()}${extractHash(route)}`; +} + export function buildHostWorkspaceOpenRoute( serverId: string, workspaceId: string, @@ -590,3 +601,8 @@ export function normalizeProjectSettingsRouteId(value: string | string[] | undef const id = Array.isArray(value) ? value[0] : value; return typeof id === "string" ? id : ""; } + +export function getHostRouteAuthority(route: string): { originServerId: string } | null { + const originServerId = new URLSearchParams(extractSearch(route)).get("origin")?.trim(); + return originServerId ? { originServerId } : null; +} diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 8ccb75d68d..0fdaa4dffb 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -7,7 +7,11 @@ export type { NavigateToAgentInput } from "./resolve"; export function navigateToAgent(input: NavigateToAgentInput): string { return resolveNavigateToAgent(input, { - readAgentNavTarget: ({ serverId, agentId }) => { + readAgentNavTarget: ({ serverId, agentId, authority }) => { + // COMPAT(unboundAgentNavigation): added in v0.12, remove after 2027-04-20. + // Older callers use the app registry. Bound navigation resolves unknown workspace + // metadata through its named route instead of consulting another authority's cache. + if (authority) return { agentWorkspaceId: null }; const session = useSessionStore.getState().sessions[serverId]; const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); return { diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index a6ba8c155d..976b7be753 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -1,4 +1,4 @@ -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { buildHostAgentDetailRoute, withHostRouteAuthority } from "@/utils/host-routes"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store"; @@ -9,6 +9,7 @@ export interface NavigateToAgentInput { // (cold deep-links). Otherwise the workspace is read from the store. workspaceId?: string | null; pin?: boolean; + authority?: { originServerId: string }; } export interface AgentNavTarget { @@ -16,7 +17,11 @@ export interface AgentNavTarget { } export interface NavigateToAgentDeps { - readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget; + readAgentNavTarget: (input: { + serverId: string; + agentId: string; + authority?: { originServerId: string }; + }) => AgentNavTarget; navigateToHostAgent: (route: string) => void; navigateToWorkspace: (input: NavigateToWorkspaceInput) => string; } @@ -27,11 +32,16 @@ export function resolveNavigateToAgent( ): string { const agentWorkspaceId = input.workspaceId ?? - deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId; + deps.readAgentNavTarget({ + serverId: input.serverId, + agentId: input.agentId, + ...(input.authority ? { authority: input.authority } : {}), + }).agentWorkspaceId; const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); if (!workspaceId) { - const route = buildHostAgentDetailRoute(input.serverId, input.agentId); + const base = buildHostAgentDetailRoute(input.serverId, input.agentId); + const route = input.authority ? withHostRouteAuthority(base, input.authority) : base; deps.navigateToHostAgent(route); return route; } @@ -41,5 +51,6 @@ export function resolveNavigateToAgent( workspaceId, target: { kind: "agent", agentId: input.agentId }, pin: input.pin, + ...(input.authority ? { authority: input.authority } : {}), }); } diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 7ccca095f1..ceb0ed16c1 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import { describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { buildWorkspaceAgentActivityIndex } from "./workspace-agent-activity"; @@ -60,7 +61,9 @@ function agent(input: { attentionReason: input.attentionReason, attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null, archivedAt: input.archivedAt ? new Date(input.archivedAt) : null, - parentAgentId: input.parentAgentId ?? null, + ...managedAgentFields( + input.parentAgentId ? { serverId: "host-a", agentId: input.parentAgentId } : null, + ), labels: {}, }; } diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 69f3c23da4..c145f0101b 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -21,7 +21,9 @@ export function buildWorkspaceAgentActivityIndex( const latestActivityAtByWorkspaceId = new Map(); for (const agent of agents.values()) { - const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined; + const parentAgent = agent.managedRelationship?.parent.agentId + ? agents.get(agent.managedRelationship?.parent.agentId) + : undefined; if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) { continue; } diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 59f96fccbc..985af8aba3 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -1,3 +1,4 @@ +import { managedAgentFields } from "@/subagents/test-fixtures"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -51,7 +52,9 @@ function makeAgent(input: { workspaceId: input.workspaceId, model: null, thinkingOptionId: null, - parentAgentId: input.parentAgentId ?? null, + ...managedAgentFields( + input.parentAgentId ? { serverId: "srv", agentId: input.parentAgentId } : null, + ), labels: {}, requiresAttention: false, attentionReason: null, @@ -521,3 +524,28 @@ describe("workspace agent visibility", () => { }); }); }); + +it("compares qualified workspace identity and ignores an unattested parent pointer", () => { + const child = makeAgent({ id: "child", cwd: "/repo", workspaceId: "same" }); + const parent = { serverId: "A", agentId: "parent" }; + const attested = { + ...child, + ...managedAgentFields(parent, { serverId: "A", workspaceId: "same" }), + }; + const visibility = (value: Agent) => + deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([[value.id, value]]), + workspaceId: "same", + }).autoOpenAgentIds; + expect(visibility(attested)).toEqual(new Set(["child"])); + expect( + visibility({ + ...attested, + managedRelationship: { + ...attested.managedRelationship!, + parentWorkspace: { serverId: "srv", workspaceId: "same" }, + }, + }), + ).toEqual(new Set()); + expect(visibility({ ...child, parent })).toEqual(new Set(["child"])); +}); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 3dd1881643..c56bb536a1 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -34,8 +34,9 @@ export function deriveWorkspaceAgentVisibility(input: { } if (!agent.archivedAt) { activeAgentIds.add(agent.id); - const parentAgent = agent.parentAgentId - ? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId)) + const parentAgent = agent.managedRelationship?.parent.agentId + ? (sessionAgents?.get(agent.managedRelationship?.parent.agentId) ?? + agentDetails?.get(agent.managedRelationship?.parent.agentId)) : undefined; if (isWorkspaceRootAgent(agent, parentAgent)) { autoOpenAgentIds.add(agent.id); diff --git a/packages/app/src/workspace-tabs/compact-sheet.ts b/packages/app/src/workspace-tabs/compact-sheet.ts index 947d4904c5..312de6010a 100644 --- a/packages/app/src/workspace-tabs/compact-sheet.ts +++ b/packages/app/src/workspace-tabs/compact-sheet.ts @@ -1,11 +1,14 @@ import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes"; import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store"; import { isWorkspaceRootAgent } from "@/subagents/policies"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; -function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null { - const sessions = useSessionStore.getState().sessions; +function workspaceAgentLookup( + workspaceKey: string, + store: SessionStoreApi, +): (agentId: string) => Agent | null { + const sessions = store.getState().sessions; const serverId = Object.keys(sessions) .filter((candidate) => workspaceKey.startsWith(`${candidate}:`)) .sort((left, right) => right.length - left.length)[0]; @@ -20,13 +23,16 @@ function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent export function resolveCompactSheetPolicy( workspaceKey: string, target: WorkspaceTabTarget, + store: SessionStoreApi = useSessionStore, ): CompactSheetPolicy | null { return panelCompactSheetPolicy(target, { isWorkspaceRootAgent: (agentId) => { - const getAgent = workspaceAgentLookup(workspaceKey); + const getAgent = workspaceAgentLookup(workspaceKey, store); const agent = getAgent(agentId); if (!agent) return true; - const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined; + const parent = agent.managedRelationship?.parent.agentId + ? (getAgent(agent.managedRelationship?.parent.agentId) ?? undefined) + : undefined; return isWorkspaceRootAgent(agent, parent); }, }); diff --git a/packages/app/src/workspace-tabs/explorer-sidebar.ts b/packages/app/src/workspace-tabs/explorer-sidebar.ts index 26c64e016b..490b4d064c 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.ts @@ -1,7 +1,11 @@ +import { useMemo } from "react"; import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsExplorerSidebarVisible, useWorkspaceLayoutStore, + defaultWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, + type WorkspaceLayoutStoreApi, } from "@/stores/workspace-layout-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; @@ -28,58 +32,88 @@ export function resolveExplorerSidebarPresentation( return "dock"; } -/** Reveals the Explorer sidebar and selects one of its navigation trees. */ -export function openExplorerSidebarView( - input: ExplorerSidebarQuery & { view: ExplorerSidebarView }, -): void { - if (!input.workspaceKey) return; - const store = useWorkspaceLayoutStore.getState(); - const paneId = showExplorerSidebar(input); - store.openTab({ - workspaceKey: input.workspaceKey, - target: VIEW_TARGETS[input.view], - intent: "reveal", - placement: paneId ? { mode: "pane", paneId } : undefined, - }); -} +export function createExplorerSidebarActions(layout: WorkspaceLayoutStoreApi) { + /** Reveals the Explorer sidebar and selects one of its navigation trees. */ + function openExplorerSidebarView( + input: ExplorerSidebarQuery & { view: ExplorerSidebarView }, + ): void { + if (!input.workspaceKey) return; + const store = layout.getState(); + const paneId = showExplorerSidebar(input); + store.openTab({ + workspaceKey: input.workspaceKey, + target: VIEW_TARGETS[input.view], + intent: "reveal", + placement: paneId ? { mode: "pane", paneId } : undefined, + }); + } -export function showExplorerSidebar(input: ExplorerSidebarQuery): string | null { - if (input.isCompact) { - usePanelStore.getState().showMobileFileExplorer(); + function showExplorerSidebar(input: ExplorerSidebarQuery): string | null { + if (input.isCompact) { + usePanelStore.getState().showMobileFileExplorer(); + } + if (input.workspaceKey) { + const store = layout.getState(); + return input.isCompact + ? store.ensureExplorerSidebar(input.workspaceKey) + : store.showExplorerSidebar(input.workspaceKey); + } + return null; } - if (input.workspaceKey) { - const store = useWorkspaceLayoutStore.getState(); - return input.isCompact - ? store.ensureExplorerSidebar(input.workspaceKey) - : store.showExplorerSidebar(input.workspaceKey); - } - return null; -} -export function hideExplorerSidebar(input: ExplorerSidebarQuery): void { - if (input.isCompact) { - usePanelStore.getState().showMobileAgent(); - return; + function hideExplorerSidebar(input: ExplorerSidebarQuery): void { + if (input.isCompact) { + usePanelStore.getState().showMobileAgent(); + return; + } + if (input.workspaceKey) { + layout.getState().hideExplorerSidebar(input.workspaceKey); + } } - if (input.workspaceKey) { - useWorkspaceLayoutStore.getState().hideExplorerSidebar(input.workspaceKey); - } -} -export function toggleExplorerSidebar(input: ExplorerSidebarQuery): void { - if (input.isCompact) { - if (isExplorerSidebarOpen(input)) hideExplorerSidebar(input); - else showExplorerSidebar(input); - return; + function toggleExplorerSidebar(input: ExplorerSidebarQuery): void { + if (input.isCompact) { + if (isExplorerSidebarOpen(input)) hideExplorerSidebar(input); + else showExplorerSidebar(input); + return; + } + if (!input.workspaceKey) return; + if (isExplorerSidebarOpen(input)) { + hideExplorerSidebar(input); + } else { + showExplorerSidebar(input); + } } - if (!input.workspaceKey) return; - if (isExplorerSidebarOpen(input)) { - hideExplorerSidebar(input); - } else { - showExplorerSidebar(input); - } -} + function isExplorerSidebarOpen(input: ExplorerSidebarQuery): boolean { + if (input.isCompact) { + return selectIsCompactFileExplorerOpen(usePanelStore.getState()); + } + return Boolean( + input.workspaceKey && selectIsExplorerSidebarVisible(layout.getState(), input.workspaceKey), + ); + } + + return { + openExplorerSidebarView, + showExplorerSidebar, + hideExplorerSidebar, + toggleExplorerSidebar, + isExplorerSidebarOpen, + }; +} +export function useExplorerSidebarActions() { + const layout = useWorkspaceLayoutStoreApi(); + return useMemo(() => createExplorerSidebarActions(layout), [layout]); +} +// COMPAT(unboundExplorerNavigation): added in v0.12, remove after 2027-04-20. +export const { + openExplorerSidebarView, + showExplorerSidebar, + hideExplorerSidebar, + toggleExplorerSidebar, + isExplorerSidebarOpen, +} = createExplorerSidebarActions(defaultWorkspaceLayoutStore); export function useIsExplorerSidebarOpen(input: ExplorerSidebarQuery): boolean { const compactOpen = usePanelStore(selectIsCompactFileExplorerOpen); const paneOpen = useWorkspaceLayoutStore((state) => @@ -87,13 +121,3 @@ export function useIsExplorerSidebarOpen(input: ExplorerSidebarQuery): boolean { ); return input.isCompact ? compactOpen : paneOpen; } - -export function isExplorerSidebarOpen(input: ExplorerSidebarQuery): boolean { - if (input.isCompact) { - return selectIsCompactFileExplorerOpen(usePanelStore.getState()); - } - return Boolean( - input.workspaceKey && - selectIsExplorerSidebarVisible(useWorkspaceLayoutStore.getState(), input.workspaceKey), - ); -} diff --git a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx index 03df9b695a..0a66e72717 100644 --- a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx +++ b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx @@ -1,6 +1,9 @@ import { useLayoutEffect } from "react"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; -import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store"; +import { + useWorkspaceLayoutStoreApi, + type WorkspaceLayoutMode, +} from "@/stores/workspace-layout-store"; function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode { if (isCompact) return "compact"; @@ -13,9 +16,10 @@ function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode { * sibling's subtree, so a route sees the mode even from its first layout effect. */ export function WorkspaceLayoutModePublisher(): null { + const layout = useWorkspaceLayoutStoreApi(); const isCompact = useIsCompactFormFactor(); useLayoutEffect(() => { - useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact)); - }, [isCompact]); + layout.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact)); + }, [isCompact, layout]); return null; } diff --git a/packages/app/src/workspace-tabs/navigation.ts b/packages/app/src/workspace-tabs/navigation.ts index 5e36f65102..e86db385b3 100644 --- a/packages/app/src/workspace-tabs/navigation.ts +++ b/packages/app/src/workspace-tabs/navigation.ts @@ -1,19 +1,43 @@ -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { + prepareWorkspaceTab as prepareWorkspaceTabPure, + type PrepareWorkspaceTabInput, +} from "@/utils/prepare-workspace-tab"; +import { useMemo } from "react"; +import { useWorkspaceLayoutStoreApi } from "@/stores/workspace-layout-store"; +import { + defaultWorkspaceLayoutStore, + type WorkspaceLayoutStoreApi, +} from "@/stores/workspace-layout-store"; import { usePanelStore } from "@/stores/panel-store"; -/** Reveals the actual destination after placement and deduplication, including existing tabs. */ -export function revealWorkspaceTab(workspaceKey: string, tabId: string | null): string | null { - if (!tabId) return null; - const store = useWorkspaceLayoutStore.getState(); - const destination = store.getTabDestination(workspaceKey, tabId); - // Opening a sheet already reveals it, preserving the surface underneath. - if (!destination) return null; - if (destination.kind === "sheet") return tabId; - store.focusTab(workspaceKey, tabId); - if (store.layoutMode === "compact") { - const panels = usePanelStore.getState(); - if (destination.host === "explorer") panels.showMobileFileExplorer(); - else panels.showMobileAgent(); +export function createWorkspaceTabNavigation(layout: WorkspaceLayoutStoreApi) { + /** Reveals the actual destination after placement and deduplication, including existing tabs. */ + function revealWorkspaceTab(workspaceKey: string, tabId: string | null): string | null { + if (!tabId) return null; + const store = layout.getState(); + const destination = store.getTabDestination(workspaceKey, tabId); + // Opening a sheet already reveals it, preserving the surface underneath. + if (!destination) return null; + if (destination.kind === "sheet") return tabId; + store.focusTab(workspaceKey, tabId); + if (store.layoutMode === "compact") { + const panels = usePanelStore.getState(); + if (destination.host === "explorer") panels.showMobileFileExplorer(); + else panels.showMobileAgent(); + } + return tabId; } - return tabId; + + return { + revealWorkspaceTab, + prepareWorkspaceTab: (input: PrepareWorkspaceTabInput) => + prepareWorkspaceTabPure(input, { openTab: (request) => layout.getState().openTab(request) }), + }; +} +// COMPAT(unboundWorkspaceNavigation): added in v0.12, remove after 2027-04-20. +export const { revealWorkspaceTab } = createWorkspaceTabNavigation(defaultWorkspaceLayoutStore); + +export function useWorkspaceTabNavigation() { + const layout = useWorkspaceLayoutStoreApi(); + return useMemo(() => createWorkspaceTabNavigation(layout), [layout]); } diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts index 9e80745db8..a9d5e4eea8 100644 --- a/packages/app/src/workspace-tabs/open-beside.test.ts +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -174,3 +174,30 @@ describe("opening beside", () => { expect(paneIdHolding(besideTabId)).toBe("main"); }); }); + +it("opens equal-address child tabs only in the captured layout owner", async () => { + const { createWorkspaceLayoutStore } = await import("@/stores/workspace-layout-store"); + const { createWorkspaceTargetActions } = await import("./open-beside"); + const direct = createWorkspaceLayoutStore(); + const routed = createWorkspaceLayoutStore(); + direct.getState().setLayoutMode("split-panes"); + routed.getState().setLayoutMode("compact"); + const actions = createWorkspaceTargetActions(routed); + actions.openWorkspaceTargetAtLocation({ + workspaceKey: "B:workspace", + target: { kind: "agent", agentId: "child" }, + location: "main", + }); + expect( + routed + .getState() + .getWorkspaceTabs("B:workspace") + .some((tab) => tab.target.kind === "agent" && tab.target.agentId === "child"), + ).toBe(true); + expect( + direct + .getState() + .getWorkspaceTabs("B:workspace") + .some((tab) => tab.target.kind === "agent" && tab.target.agentId === "child"), + ).toBe(false); +}); diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index abf6eba2ec..b5024e12e5 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -1,4 +1,5 @@ -import { revealWorkspaceTab } from "./navigation"; +import { createWorkspaceTabNavigation } from "./navigation"; +import { useMemo } from "react"; import type { OpenInSidePanePreferences } from "@/hooks/use-settings"; import { collectAllTabs, @@ -6,7 +7,9 @@ import { DEFAULT_PANE_ID, findPaneById, findPaneContainingTab, - useWorkspaceLayoutStore, + defaultWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, + type WorkspaceLayoutStoreApi, type WorkspaceTabPlacement, } from "@/stores/workspace-layout-store"; import { getPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; @@ -38,178 +41,198 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge lastMainPaneId: string | null; } -/** A side pane exists only where the workspace renders split panes. */ -function canOpenBeside(): boolean { - return useWorkspaceLayoutStore.getState().layoutMode === "split-panes"; -} - -function resolveMainPane(input: { - workspaceKey: string; - explorerSidebarPaneId: string | null; - lastMainPaneId: string | null; -}) { - const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[input.workspaceKey]; - if (!layout) return null; - const panes = collectAllPanes(layout.root); - return ( - panes.find( - (pane) => - pane.id === input.lastMainPaneId && - pane.id !== input.explorerSidebarPaneId && - pane.hidden !== true, - ) ?? - panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.hidden !== true) ?? - panes.find((pane) => pane.id !== input.explorerSidebarPaneId && pane.hidden !== true) ?? - null - ); -} - -function canReplacePreview(input: { - serverId: string; - workspaceId: string; - tabId: string; - currentTarget: WorkspaceTabTarget; - nextTarget: WorkspaceTabTarget; -}): boolean { - if (input.nextTarget.kind === "working_diff") { - return input.currentTarget.kind === "working_diff"; - } - return ( - input.nextTarget.kind === "file" && - input.currentTarget.kind === "file" && - !getPanelInstanceAttributes({ - serverId: input.serverId, - workspaceId: input.workspaceId, - tabId: input.tabId, - }).modified - ); -} - -/** Opens an implicit target according to its source-specific desktop preference. */ -export function openPreferredWorkspaceTarget( - input: OpenPreferredWorkspaceTargetInput, -): string | null { - return openWorkspaceTargetAtLocation({ - workspaceKey: input.workspaceKey, - target: input.target, - location: input.preferences[input.source] ? "side" : "main", - parentTabId: input.parentTabId, - }); -} - -/** Opens an implicit target at the user's preferred main or side location. */ -export function openWorkspaceTargetAtLocation( - input: OpenWorkspaceTargetAtLocationInput, -): string | null { - if (!input.workspaceKey) return null; - const store = useWorkspaceLayoutStore.getState(); - const layout = store.layoutByWorkspace[input.workspaceKey]; - const targetAlreadyExists = Boolean( - layout && - collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)), - ); - const shouldOpenBeside = canOpenBeside() && input.location === "side"; - let placement: WorkspaceTabPlacement | undefined; - if (shouldOpenBeside && !targetAlreadyExists) { - const paneId = store.ensureSidePane(input.workspaceKey); - placement = paneId ? { mode: "prefer", paneId } : undefined; - } else if (!shouldOpenBeside && layout && input.parentTabId) { - // A target opened from a tab belongs in that tab's pane, whichever pane has focus. - const parentPane = findPaneContainingTab(layout.root, input.parentTabId); - placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined; - } - return revealWorkspaceTab( - input.workspaceKey, - store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement, - parentTabId: input.parentTabId ?? undefined, - }), - ); -} - -/** Opens a tree selection while reusing an unmodified preview in its destination pane. */ -export function openPreferredWorkspacePreview( - input: OpenPreferredWorkspacePreviewInput, -): string | null { - if (!input.workspaceKey) return null; - const store = useWorkspaceLayoutStore.getState(); - const layout = store.layoutByWorkspace[input.workspaceKey]; - if (!layout) return null; - const existing = collectAllTabs(layout.root).some((tab) => - workspaceTabTargetsEqual(tab.target, input.target), - ); - // The sheet is already a single replaceable slot, so it needs no preview tab. - if ( - existing || - store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" }) - ) { - return openPreferredWorkspaceTarget(input); +export function createWorkspaceTargetActions(layoutStore: WorkspaceLayoutStoreApi) { + const { revealWorkspaceTab } = createWorkspaceTabNavigation(layoutStore); + /** A side pane exists only where the workspace renders split panes. */ + function canOpenBeside(): boolean { + return layoutStore.getState().layoutMode === "split-panes"; } - const mainPane = resolveMainPane({ - workspaceKey: input.workspaceKey, - explorerSidebarPaneId: input.explorerSidebarPaneId, - lastMainPaneId: input.lastMainPaneId, - }); - const destinationPaneId = - canOpenBeside() && input.preferences[input.source] - ? store.ensureSidePane(input.workspaceKey) - : mainPane?.id; - if (!destinationPaneId) return null; - const nextLayout = useWorkspaceLayoutStore.getState().layoutByWorkspace[input.workspaceKey]; - const destinationPane = nextLayout ? findPaneById(nextLayout.root, destinationPaneId) : null; - const activeTab = nextLayout - ? collectAllTabs(nextLayout.root).find((tab) => tab.tabId === destinationPane?.focusedTabId) - : null; - if ( - activeTab && - canReplacePreview({ - serverId: input.serverId, - workspaceId: input.workspaceId, - tabId: activeTab.tabId, - currentTarget: activeTab.target, - nextTarget: input.target, - }) - ) { - return revealWorkspaceTab( - input.workspaceKey, - store.replaceTab(input.workspaceKey, activeTab.tabId, input.target), + function resolveMainPane(input: { + workspaceKey: string; + explorerSidebarPaneId: string | null; + lastMainPaneId: string | null; + }) { + const layout = layoutStore.getState().layoutByWorkspace[input.workspaceKey]; + if (!layout) return null; + const panes = collectAllPanes(layout.root); + return ( + panes.find( + (pane) => + pane.id === input.lastMainPaneId && + pane.id !== input.explorerSidebarPaneId && + pane.hidden !== true, + ) ?? + panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.hidden !== true) ?? + panes.find((pane) => pane.id !== input.explorerSidebarPaneId && pane.hidden !== true) ?? + null ); } - return revealWorkspaceTab( - input.workspaceKey, - store.openTab({ + + function canReplacePreview(input: { + serverId: string; + workspaceId: string; + tabId: string; + currentTarget: WorkspaceTabTarget; + nextTarget: WorkspaceTabTarget; + }): boolean { + if (input.nextTarget.kind === "working_diff") { + return input.currentTarget.kind === "working_diff"; + } + return ( + input.nextTarget.kind === "file" && + input.currentTarget.kind === "file" && + !getPanelInstanceAttributes({ + serverId: input.serverId, + workspaceId: input.workspaceId, + tabId: input.tabId, + }).modified + ); + } + + /** Opens an implicit target according to its source-specific desktop preference. */ + function openPreferredWorkspaceTarget(input: OpenPreferredWorkspaceTargetInput): string | null { + return openWorkspaceTargetAtLocation({ workspaceKey: input.workspaceKey, target: input.target, - intent: "reveal", - placement: { mode: "pane", paneId: destinationPaneId }, - parentTabId: input.parentTabId ?? activeTab?.tabId ?? undefined, - }), - ); + location: input.preferences[input.source] ? "side" : "main", + parentTabId: input.parentTabId, + }); + } + + /** Opens an implicit target at the user's preferred main or side location. */ + function openWorkspaceTargetAtLocation(input: OpenWorkspaceTargetAtLocationInput): string | null { + if (!input.workspaceKey) return null; + const store = layoutStore.getState(); + const layout = store.layoutByWorkspace[input.workspaceKey]; + const targetAlreadyExists = Boolean( + layout && + collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)), + ); + const shouldOpenBeside = canOpenBeside() && input.location === "side"; + let placement: WorkspaceTabPlacement | undefined; + if (shouldOpenBeside && !targetAlreadyExists) { + const paneId = store.ensureSidePane(input.workspaceKey); + placement = paneId ? { mode: "prefer", paneId } : undefined; + } else if (!shouldOpenBeside && layout && input.parentTabId) { + // A target opened from a tab belongs in that tab's pane, whichever pane has focus. + const parentPane = findPaneContainingTab(layout.root, input.parentTabId); + placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined; + } + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement, + parentTabId: input.parentTabId ?? undefined, + }), + ); + } + + /** Opens a tree selection while reusing an unmodified preview in its destination pane. */ + function openPreferredWorkspacePreview(input: OpenPreferredWorkspacePreviewInput): string | null { + if (!input.workspaceKey) return null; + const store = layoutStore.getState(); + const layout = store.layoutByWorkspace[input.workspaceKey]; + if (!layout) return null; + const existing = collectAllTabs(layout.root).some((tab) => + workspaceTabTargetsEqual(tab.target, input.target), + ); + // The sheet is already a single replaceable slot, so it needs no preview tab. + if ( + existing || + store.opensInSheet({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + }) + ) { + return openPreferredWorkspaceTarget(input); + } + + const mainPane = resolveMainPane({ + workspaceKey: input.workspaceKey, + explorerSidebarPaneId: input.explorerSidebarPaneId, + lastMainPaneId: input.lastMainPaneId, + }); + const destinationPaneId = + canOpenBeside() && input.preferences[input.source] + ? store.ensureSidePane(input.workspaceKey) + : mainPane?.id; + if (!destinationPaneId) return null; + const nextLayout = layoutStore.getState().layoutByWorkspace[input.workspaceKey]; + const destinationPane = nextLayout ? findPaneById(nextLayout.root, destinationPaneId) : null; + const activeTab = nextLayout + ? collectAllTabs(nextLayout.root).find((tab) => tab.tabId === destinationPane?.focusedTabId) + : null; + if ( + activeTab && + canReplacePreview({ + serverId: input.serverId, + workspaceId: input.workspaceId, + tabId: activeTab.tabId, + currentTarget: activeTab.target, + nextTarget: input.target, + }) + ) { + return revealWorkspaceTab( + input.workspaceKey, + store.replaceTab(input.workspaceKey, activeTab.tabId, input.target), + ); + } + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: { mode: "pane", paneId: destinationPaneId }, + parentTabId: input.parentTabId ?? activeTab?.tabId ?? undefined, + }), + ); + } + + /** + * Explicit Open to Side: creates the side pane and places the target there. Without split panes + * the target opens like any other. + */ + function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null { + if (!input.workspaceKey) return null; + if (!canOpenBeside()) { + return openWorkspaceTargetAtLocation({ ...input, location: "main" }); + } + const store = layoutStore.getState(); + const paneId = store.ensureSidePane(input.workspaceKey); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: paneId ? { mode: "pane", paneId } : undefined, + parentTabId: input.parentTabId ?? undefined, + }), + ); + } + + return { + openPreferredWorkspaceTarget, + openPreferredWorkspacePreview, + openWorkspaceTargetBeside, + openWorkspaceTargetAtLocation, + }; } -/** - * Explicit Open to Side: creates the side pane and places the target there. Without split panes - * the target opens like any other. - */ -export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null { - if (!input.workspaceKey) return null; - if (!canOpenBeside()) { - return openWorkspaceTargetAtLocation({ ...input, location: "main" }); - } - const store = useWorkspaceLayoutStore.getState(); - const paneId = store.ensureSidePane(input.workspaceKey); - return revealWorkspaceTab( - input.workspaceKey, - store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement: paneId ? { mode: "pane", paneId } : undefined, - parentTabId: input.parentTabId ?? undefined, - }), - ); +export function useWorkspaceTargetActions() { + const store = useWorkspaceLayoutStoreApi(); + return useMemo(() => createWorkspaceTargetActions(store), [store]); } +// COMPAT(unboundWorkspaceTargets): added in v0.12, remove after 2027-04-20. +export const { + openPreferredWorkspaceTarget, + openPreferredWorkspacePreview, + openWorkspaceTargetBeside, + openWorkspaceTargetAtLocation, +} = createWorkspaceTargetActions(defaultWorkspaceLayoutStore); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index 73ae55c31f..60e834cdea 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -1,5 +1,6 @@ import { supportsDesktopPaneSplits } from "@/constants/layout"; -import { revealWorkspaceTab } from "./navigation"; +import { createWorkspaceTabNavigation } from "./navigation"; +import { useMemo } from "react"; import { collectAllPanes, collectAllTabs, @@ -7,18 +8,16 @@ import { DEFAULT_PANE_ID, findPaneContainingTab, resolveExplorerSidebarPaneId, - useWorkspaceLayoutStore, + defaultWorkspaceLayoutStore, + useWorkspaceLayoutStoreApi, + type WorkspaceLayoutStoreApi, } from "@/stores/workspace-layout-store"; import type { OpenInSidePanePreferences, PullRequestOpenLocation } from "@/hooks/use-settings"; import { - isExplorerSidebarOpen, - openExplorerSidebarView, + createExplorerSidebarActions, type ExplorerSidebarView, } from "@/workspace-tabs/explorer-sidebar"; -import { - openPreferredWorkspaceTarget, - openWorkspaceTargetAtLocation, -} from "@/workspace-tabs/open-beside"; +import { createWorkspaceTargetActions } from "@/workspace-tabs/open-beside"; interface WorkspaceViewInput { isCompact: boolean; @@ -34,101 +33,126 @@ interface OpenWorkspacePullRequestInput extends WorkspaceViewInput { destination: PullRequestOpenLocation; } -function supportingViewsBelongInExplorer(input: WorkspaceViewInput): boolean { - return input.isCompact || !(input.supportsPaneSplits ?? supportsDesktopPaneSplits()); -} +export function createSupportingViewActions(layoutStore: WorkspaceLayoutStoreApi) { + const { revealWorkspaceTab } = createWorkspaceTabNavigation(layoutStore); + const { openExplorerSidebarView, isExplorerSidebarOpen } = + createExplorerSidebarActions(layoutStore); + const { openPreferredWorkspaceTarget, openWorkspaceTargetAtLocation } = + createWorkspaceTargetActions(layoutStore); + function supportingViewsBelongInExplorer(input: WorkspaceViewInput): boolean { + return input.isCompact || !(input.supportsPaneSplits ?? supportsDesktopPaneSplits()); + } -function openExplorerView(input: WorkspaceViewInput, view: ExplorerSidebarView): void { - openExplorerSidebarView({ ...input, view }); -} + function openExplorerView(input: WorkspaceViewInput, view: ExplorerSidebarView): void { + openExplorerSidebarView({ ...input, view }); + } -/** Opens the workspace Changes view according to the current layout and diff preference. */ -export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | null { - if (supportingViewsBelongInExplorer(input)) { + /** Opens the workspace Changes view according to the current layout and diff preference. */ + function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | null { + if (supportingViewsBelongInExplorer(input)) { + openExplorerView(input, "changes"); + return null; + } + return openPreferredWorkspaceTarget({ + workspaceKey: input.workspaceKey, + target: { kind: "working_diff" }, + source: "diffs", + preferences: input.preferences, + }); + } + + /** Reveals Changes from the composer, then opens its diff on a subsequent desktop action. */ + function openComposerChanges(input: OpenWorkspaceChangesInput): string | null { + if (supportingViewsBelongInExplorer(input) || isExplorerSidebarOpen(input)) { + return openWorkspaceChanges(input); + } openExplorerView(input, "changes"); return null; } - return openPreferredWorkspaceTarget({ - workspaceKey: input.workspaceKey, - target: { kind: "working_diff" }, - source: "diffs", - preferences: input.preferences, - }); -} -/** Reveals Changes from the composer, then opens its diff on a subsequent desktop action. */ -export function openComposerChanges(input: OpenWorkspaceChangesInput): string | null { - if (supportingViewsBelongInExplorer(input) || isExplorerSidebarOpen(input)) { - return openWorkspaceChanges(input); + /** Opens the workspace pull request at its semantic destination. */ + function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): string | null { + if (!input.workspaceKey) return null; + let tabId: string | null; + if (supportingViewsBelongInExplorer(input) || input.destination === "explorer") { + const store = layoutStore.getState(); + const paneId = store.ensureExplorerSidebar(input.workspaceKey); + tabId = store.openTab({ + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + intent: "reveal", + placement: paneId ? { mode: "prefer", paneId } : undefined, + }); + } else { + return openWorkspaceTargetAtLocation({ + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + location: input.destination, + }); + } + revealWorkspaceTab(input.workspaceKey, tabId); + return tabId; } - openExplorerView(input, "changes"); - return null; -} -/** Opens the workspace pull request at its semantic destination. */ -export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): string | null { - if (!input.workspaceKey) return null; - let tabId: string | null; - if (supportingViewsBelongInExplorer(input) || input.destination === "explorer") { - const store = useWorkspaceLayoutStore.getState(); - const paneId = store.ensureExplorerSidebar(input.workspaceKey); - tabId = store.openTab({ - workspaceKey: input.workspaceKey, - target: { kind: "pull_request" }, - intent: "reveal", - placement: paneId ? { mode: "prefer", paneId } : undefined, - }); - } else { - return openWorkspaceTargetAtLocation({ - workspaceKey: input.workspaceKey, - target: { kind: "pull_request" }, - location: input.destination, + /** Adds a detected desktop PR once without revealing or selecting its destination. */ + function autoOpenWorkspacePullRequest(input: { + workspaceKey: string | null; + destination: PullRequestOpenLocation; + }): string | null { + if (!input.workspaceKey) return null; + const workspaceKey = input.workspaceKey; + const store = layoutStore.getState(); + return store.autoOpenPullRequestTab(workspaceKey, () => { + const layout = + store.layoutByWorkspace[workspaceKey] ?? createWorkspaceLayoutWithExplorerSidebar(); + // A background open finds an existing PR tab wherever the user left it. + if (collectAllTabs(layout.root).some((tab) => tab.target.kind === "pull_request")) { + return {}; + } + const explorerPaneId = resolveExplorerSidebarPaneId( + layout, + store.explorerSidebarPaneIdByWorkspace[workspaceKey], + ); + let paneId = explorerPaneId; + if (input.destination === "side") { + paneId = store.ensureSidePane(workspaceKey, { focus: false }); + } else if (input.destination === "main") { + const panes = collectAllPanes(layout.root); + const mainPane = + panes.find((pane) => pane.id === DEFAULT_PANE_ID) ?? + panes.find((pane) => pane.id !== explorerPaneId && !pane.hidden); + paneId = mainPane?.id ?? null; + } + const changes = + input.destination === "explorer" + ? collectAllTabs(layout.root).find( + (tab) => + tab.target.kind === "changes_tree" && + findPaneContainingTab(layout.root, tab.tabId)?.id === paneId, + ) + : undefined; + return { + placement: paneId ? { mode: "prefer", paneId } : undefined, + insertionPosition: changes ? { afterTabId: changes.tabId } : undefined, + }; }); } - revealWorkspaceTab(input.workspaceKey, tabId); - return tabId; -} -/** Adds a detected desktop PR once without revealing or selecting its destination. */ -export function autoOpenWorkspacePullRequest(input: { - workspaceKey: string | null; - destination: PullRequestOpenLocation; -}): string | null { - if (!input.workspaceKey) return null; - const workspaceKey = input.workspaceKey; - const store = useWorkspaceLayoutStore.getState(); - return store.autoOpenPullRequestTab(workspaceKey, () => { - const layout = - store.layoutByWorkspace[workspaceKey] ?? createWorkspaceLayoutWithExplorerSidebar(); - // A background open finds an existing PR tab wherever the user left it. - if (collectAllTabs(layout.root).some((tab) => tab.target.kind === "pull_request")) { - return {}; - } - const explorerPaneId = resolveExplorerSidebarPaneId( - layout, - store.explorerSidebarPaneIdByWorkspace[workspaceKey], - ); - let paneId = explorerPaneId; - if (input.destination === "side") { - paneId = store.ensureSidePane(workspaceKey, { focus: false }); - } else if (input.destination === "main") { - const panes = collectAllPanes(layout.root); - const mainPane = - panes.find((pane) => pane.id === DEFAULT_PANE_ID) ?? - panes.find((pane) => pane.id !== explorerPaneId && !pane.hidden); - paneId = mainPane?.id ?? null; - } - const changes = - input.destination === "explorer" - ? collectAllTabs(layout.root).find( - (tab) => - tab.target.kind === "changes_tree" && - findPaneContainingTab(layout.root, tab.tabId)?.id === paneId, - ) - : undefined; - return { - placement: paneId ? { mode: "prefer", paneId } : undefined, - insertionPosition: changes ? { afterTabId: changes.tabId } : undefined, - }; - }); + return { + openWorkspaceChanges, + openComposerChanges, + openWorkspacePullRequest, + autoOpenWorkspacePullRequest, + }; } +export function useSupportingViewActions() { + const layout = useWorkspaceLayoutStoreApi(); + return useMemo(() => createSupportingViewActions(layout), [layout]); +} +// COMPAT(unboundSupportingViews): added in v0.12, remove after 2027-04-20. +export const { + openWorkspaceChanges, + openComposerChanges, + openWorkspacePullRequest, + autoOpenWorkspacePullRequest, +} = createSupportingViewActions(defaultWorkspaceLayoutStore); diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 6694f05aea..b827761655 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -49,6 +49,8 @@ export interface PluginAgentSnapshot { readonly thinkingOptionId: string | null; readonly requiresAttention: boolean; readonly attentionReason: "finished" | "error" | "permission" | null; + readonly parent: { readonly serverId: string; readonly agentId: string } | null; + /** @deprecated Use the qualified parent address. */ readonly parentAgentId: string | null; readonly labels: Readonly>; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 439192d2df..2fd17816d3 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -836,8 +836,16 @@ const AgentActiveTurnPayloadSchema = z.object({ startedAt: z.string().nullable(), }); +export const ManagedAgentRelationshipSchema = z.object({ + id: z.string(), + parent: AgentAddressSchema, + parentWorkspace: WorkspaceAddressSchema.nullable(), +}); +export type ManagedAgentRelationship = z.infer; + export const AgentSnapshotPayloadSchema = z.object({ parent: AgentAddressSchema.nullable().optional(), + managedRelationship: ManagedAgentRelationshipSchema.nullable().optional(), id: z.string(), provider: AgentProviderSchema, cwd: z.string(), @@ -874,6 +882,7 @@ export type AgentSnapshotPayload = z.infer; export const AgentListItemPayloadSchema = z.object({ parent: AgentAddressSchema.nullable().optional(), + managedRelationship: ManagedAgentRelationshipSchema.nullable().optional(), id: z.string(), shortId: z.string(), title: z.string().nullable(), diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index ec4186b022..b807e139d7 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -4,6 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js"; import { buildStoredAgentPayload, toAgentPayload, + toAgentListItemPayload, toRecentProviderSessionDescriptorPayload, toStoredAgentRecord, type ManagedAgent, @@ -539,3 +540,21 @@ describe("toRecentProviderSessionDescriptorPayload", () => { }); }); }); + +it("retains owner membership and explicit clearing in compact list projections", () => { + const snapshot = toAgentPayload(createManagedAgent()); + const parent = { serverId: "A", agentId: "parent" }; + const managedRelationship = { + id: "relationship", + parent, + parentWorkspace: { serverId: "A", workspaceId: "workspace" }, + }; + expect(toAgentListItemPayload({ ...snapshot, parent, managedRelationship })).toMatchObject({ + parent, + managedRelationship, + }); + expect(toAgentListItemPayload({ ...snapshot, parent, managedRelationship: null })).toMatchObject({ + parent, + managedRelationship: null, + }); +}); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 5ee5c290b6..83697b66ef 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -277,6 +277,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp ?? null, parent: agent.parent, + managedRelationship: agent.managedRelationship, labels: agent.labels, ...(agent.providerUnavailable ? { providerUnavailable: true } : {}), }; diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index 2f51d4a4c1..ac733e46c0 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -11,7 +11,9 @@ export class AgentDirectory { storage: AgentStorage; workspaces: Pick; projectLive(agent: ManagedAgent): Promise; - projectStored(record: StoredAgentRecord): AgentSnapshotPayload; + projectStored( + record: StoredAgentRecord, + ): AgentSnapshotPayload | Promise; isProviderVisible(provider: string): boolean; isStoredProviderAvailable(record: StoredAgentRecord): boolean; }, @@ -60,6 +62,8 @@ export class AgentDirectory { const active = await Promise.all( live.filter(matches).map((agent) => this.deps.projectLive(agent)), ); - return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider)); + return [...active, ...(await Promise.all(stored))].filter((agent) => + this.deps.isProviderVisible(agent.provider), + ); } } diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 200498d438..0eca6d0428 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1,3 +1,4 @@ +import type { AgentPromptInput } from "../agent/agent-sdk-types.js"; import { afterEach, expect, test } from "vitest"; import { createPaseoApi, HostRuntime } from "@getpaseo/client"; import { createHostClient, createHostChannelTransport } from "@getpaseo/client/node"; @@ -427,7 +428,7 @@ class RoutePeer { } } -import { mkdir, writeFile, readFile, chmod, stat, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, writeFile, readFile, chmod, stat, mkdtemp, rm, realpath } from "node:fs/promises"; import { join, dirname, basename } from "node:path"; import { tmpdir, userInfo } from "node:os"; import { resolveDaemonVersion } from "../daemon-version.js"; @@ -1639,6 +1640,45 @@ test("SDK creation persists a qualified parent without resolving a colliding age expect((await restarted.daemon.agentStorage.get(child.id))?.parent).toEqual(parent.address); }); +test("managed membership is present on the first child snapshot and clears after forget", async () => { + const a = await daemon(); + const { paseo, client } = await api(a); + const parent = await paseo.agents.create({ + cwd: a.staticDir, + config: { provider: "codex/test" }, + }); + const published: import("@getpaseo/protocol/messages").AgentSnapshotPayload[] = []; + const directory = client.observeAgents(); + const stop = directory.subscribe({ + snapshot(value) { + for (const entry of value.entries) published.push(entry.agent); + }, + update(message) { + if (message.type === "agent_update" && message.payload.kind === "upsert") + published.push(message.payload.agent); + }, + }); + await directory.ready; + const child = await parent.subagents.create({ config: { provider: "codex/test" } }); + await expect.poll(() => published.find((snapshot) => snapshot.id === child.id)).toBeDefined(); + const first = published.find((snapshot) => snapshot.id === child.id)!; + expect(first).toMatchObject({ + managedRelationship: { + parent: parent.address, + parentWorkspace: { serverId: parent.serverId, workspaceId: parent.current()!.workspaceId }, + }, + }); + expect((await child.refresh())?.agent).toMatchObject({ + managedRelationship: { parent: parent.address }, + }); + await parent.subagents.forget(child.address); + await expect + .poll(async () => (await child.refresh())?.agent) + .toMatchObject({ managedRelationship: null }); + stop(); + await directory.release(); +}); + test("parent capability owns local and fleet membership, idempotent creation and restart recovery", async () => { const root = await mkdtemp(join(tmpdir(), "paseo-subagent-owner-")); cleanup.push(() => rm(root, { recursive: true, force: true })); @@ -1712,7 +1752,7 @@ test("startup upgrade adopts existing ordinary local children once without treat const { AgentStorage } = await import("../agent/agent-storage.js"); const { default: pino } = await import("pino"); const root = await mkdtemp(join(tmpdir(), "paseo-legacy-membership-")); - cleanup.push(() => rm(root, { recursive: true, force: true })); + cleanup.push(() => rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })); const storage = new AgentStorage(join(root, ".paseo", "agents"), pino({ level: "silent" })); const parentId = crypto.randomUUID(); const childId = crypto.randomUUID(); @@ -3092,11 +3132,11 @@ test("parented modern creation retains one reserved identity and exact child dir }); test("parented workspace creation retains its original receipt and both resources across retry", async () => { - let prompts = 0; + const prompts: AgentPromptInput[] = []; const instance = await createTestPaseoDaemon({ agentClients: createTestAgentClients({ - onStartTurn: () => { - prompts++; + onStartTurn: (prompt, config) => { + if (!config.internal) prompts.push(prompt); }, }), }); @@ -3134,7 +3174,8 @@ test("parented workspace creation retains its original receipt and both resource expect((await parent.subagents.list()).rows).toHaveLength(1); const repeated = await origin.client.createWorkspace(request); expect(repeated.workspace?.id).toBe(workspaceId); - expect(prompts).toBe(1); + expect(prompts).toHaveLength(1); + expect(JSON.stringify(prompts[0])).toContain(request.agent.initialPrompt); const receipt = origin.paseo.creations.observe({ kind: "workspace", idempotencyKey: request.idempotencyKey, @@ -3307,11 +3348,11 @@ test("admitted workspace cancellation returns its existing receipt and leaves it test.each([false, true])( "legacy parented creation preserves exact directory and prompt with worktree: %s", async (worktree) => { - let prompts = 0; + const prompts: AgentPromptInput[] = []; const instance = await createTestPaseoDaemon({ agentClients: createTestAgentClients({ - onStartTurn: () => { - prompts++; + onStartTurn: (prompt, config) => { + if (!config.internal) prompts.push(prompt); }, }), }); @@ -3408,10 +3449,13 @@ test.each([false, true])( const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], { cwd: snapshot.cwd, }); - expect(snapshot.cwd).toBe(join(stdout.trim(), "legacy-nested")); + expect(await realpath(snapshot.cwd)).toBe( + await realpath(join(stdout.trim(), "legacy-nested")), + ); expect(snapshot.workspaceId).not.toBe((await parent.refresh())!.agent.workspaceId); } else expect(snapshot.cwd).toBe(directory); - expect(prompts).toBe(1); + expect(prompts).toHaveLength(1); + expect(JSON.stringify(prompts[0])).toContain(request.initialPrompt); }, ); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e5d9e566a3..17b435e453 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -773,6 +773,7 @@ export class Session { private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private readonly orchestrationSkills: SessionOptions["orchestrationSkills"]; private unsubscribeAgentEvents: (() => void) | null = null; + private unsubscribeMembership: (() => void) | null = null; private unsubscribeProjectMutations: (() => void) | null = null; private unsubscribePluginChanges: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; @@ -1713,7 +1714,20 @@ export class Session { this.wantsEvent("agent_permission_resolved") || this.wantsEvent("agent.provider_subagents.update"); if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents(); + if (agents && !this.unsubscribeMembership) { + this.unsubscribeMembership = + this.subagentOptions.subagents?.observeMembership((agentId) => { + void this.refreshMembershipProjection(agentId).catch((error) => + this.sessionLogger.error( + { err: error, agentId }, + "Failed to refresh managed membership", + ), + ); + }) ?? null; + } if (!agents) { + this.unsubscribeMembership?.(); + this.unsubscribeMembership = null; this.unsubscribeAgentEvents?.(); this.unsubscribeAgentEvents = null; } @@ -2116,10 +2130,23 @@ export class Session { }; } + private async refreshMembershipProjection(agentId: string): Promise { + const live = this.agentManager.getAgent(agentId); + if (live) await this.agentUpdates.forwardLiveAgent(live); + else { + const stored = await this.agentStorage.get(agentId); + if (stored) await this.agentUpdates.emitStoredRecord(stored); + } + } + private async enrichAgentPayload(payload: AgentSnapshotPayload): Promise { const storedRecord = await this.agentStorage.get(payload.id); payload.title = storedRecord?.title ?? null; payload.archivedAt = storedRecord?.archivedAt ?? null; + const { subagents, serverId } = this.subagentOptions; + if (subagents && serverId) { + payload.managedRelationship = await subagents.membership({ serverId, agentId: payload.id }); + } return payload; } @@ -2127,11 +2154,11 @@ export class Session { return this.enrichAgentPayload(toAgentPayload(agent)); } - private buildStoredAgentPayload( + private async buildStoredAgentPayload( record: StoredAgentRecord, registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()), - ): AgentSnapshotPayload { - return buildStoredAgentPayload(record, registeredProviderIds); + ): Promise { + return this.enrichAgentPayload(buildStoredAgentPayload(record, registeredProviderIds)); } private isProviderVisibleToClient(provider: string): boolean { @@ -4628,7 +4655,7 @@ export class Session { ); const record = await this.agentStorage.get(creation.agent.id); if (!record) throw new Error("Previously created agent no longer exists"); - agent = this.buildStoredAgentPayload(record); + agent = await this.buildStoredAgentPayload(record); } else { ({ agent } = await this.createSessionAgent(msg)); } @@ -4713,7 +4740,7 @@ export class Session { ); const record = await this.agentStorage.get(child.agentId); if (!record) throw new Error("Created subagent is not available"); - const agent = this.buildStoredAgentPayload(record); + const agent = await this.buildStoredAgentPayload(record); await onAgentReady?.(agent); return { agent, initialPromptStarted: hasInitialCreationPrompt(msg) }; } @@ -5809,7 +5836,7 @@ export class Session { if (!settled) { return null; } - const payload = this.buildStoredAgentPayload(settled.record); + const payload = await this.buildStoredAgentPayload(settled.record); return this.isProviderVisibleToClient(payload.provider) ? payload : null; } @@ -8048,7 +8075,7 @@ export class Session { attentionTimestamp: null, }; await this.agentStorage.upsert(nextRecord); - const agent = this.buildStoredAgentPayload(nextRecord); + const agent = await this.buildStoredAgentPayload(nextRecord); const project = await this.buildProjectPlacementForWorkspace(workspace); this.emit({ type: "agent_update", @@ -8803,7 +8830,7 @@ export class Session { return; } const { record, lastMessage } = settled; - const final = this.buildStoredAgentPayload(record); + const final = await this.buildStoredAgentPayload(record); let status: "permission" | "error" | "idle"; if (record.attentionReason === "permission") { status = "permission"; @@ -9105,6 +9132,8 @@ export class Session { this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; + this.unsubscribeMembership?.(); + this.unsubscribeMembership = null; this.agentUpdates.dispose(); await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 87af6a9a8a..4e47cb7e42 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -50,7 +50,9 @@ export interface AgentUpdatesServiceDeps { includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise; emit(message: SessionOutboundMessage): void; enrichAgentPayload(payload: AgentSnapshotPayload): Promise; - buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload; + buildStoredAgentPayload( + record: StoredAgentRecord, + ): AgentSnapshotPayload | Promise; isProviderVisibleToClient(provider: string): boolean; buildProjectPlacementForWorkspaceId(workspaceId: string): Promise; emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise; @@ -292,7 +294,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } async function emitStoredRecord(record: StoredAgentRecord): Promise { - const payload = deps.buildStoredAgentPayload(record); + const payload = await deps.buildStoredAgentPayload(record); await publishPayload(payload); return payload; } diff --git a/packages/server/src/server/subagents/index.ts b/packages/server/src/server/subagents/index.ts index 9b9c4b226d..43ce412b37 100644 --- a/packages/server/src/server/subagents/index.ts +++ b/packages/server/src/server/subagents/index.ts @@ -2,7 +2,11 @@ import type { HostDirectory } from "@getpaseo/protocol/hosts"; import { shouldDetachFromArchivedParent } from "@getpaseo/protocol/agent-lifecycle"; import { hasOpenAgentTab, getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import { WorkspaceAddressSchema, type WorkspaceAddress } from "@getpaseo/protocol/addresses"; -import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + ManagedAgentRelationship, +} from "@getpaseo/protocol/messages"; import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -67,6 +71,8 @@ const LedgerSchema = z.object({ export class Subagents { private readonly records = new Map(); private readonly listeners = new Set<() => void>(); + private readonly membershipListeners = new Set<(agentId: string) => void>(); + private readonly publishedMembership = new Map(); private readonly settling = new Map>(); private readonly startingObservers = new Map>(); private readonly running = new Map>(); @@ -213,6 +219,7 @@ export class Subagents { records: [...this.records.values()], legacyUpgrade: this.legacyUpgrade, }); + this.publishMembershipChanges(); for (const changed of this.listeners) changed(); } async assertHostRemovable(serverId: string): Promise { @@ -243,6 +250,53 @@ export class Subagents { .map(({ row }) => structuredClone(row)), }; } + /** Only the relationship owner attests membership; a destination's parent pointer is metadata. */ + async membership(child: AgentAddress): Promise { + await this.ready; + if (child.serverId !== this.options.serverId) return null; + const record = [...this.records.values()].find( + (candidate) => sameAddress(candidate.row.child, child) && this.attestsMembership(candidate), + ); + if (!record) return null; + const intent = record.intent; + const parent = await this.options.agents.get(record.row.parent.agentId); + if (record.intent !== intent || !this.attestsMembership(record)) return null; + return { + id: record.row.id, + parent: { ...record.row.parent }, + parentWorkspace: parent?.workspaceId + ? { serverId: record.row.parent.serverId, workspaceId: parent.workspaceId } + : null, + }; + } + + observeMembership(changed: (agentId: string) => void): () => void { + this.membershipListeners.add(changed); + return () => { + this.membershipListeners.delete(changed); + }; + } + + private attestsMembership(record: RelationshipRecord): boolean { + return ( + record.row.parent.serverId === this.options.serverId && + record.row.child.serverId === this.options.serverId && + record.intent.kind !== "forgotten" && + (record.confirmed === null || record.confirmed === "attached") + ); + } + + private publishMembershipChanges(): void { + for (const record of this.records.values()) { + if (record.row.child.serverId !== this.options.serverId) continue; + const key = this.attestsMembership(record) ? JSON.stringify(record.row.parent) : ""; + const id = record.row.child.agentId; + if (this.publishedMembership.get(id) === key) continue; + this.publishedMembership.set(id, key); + for (const changed of this.membershipListeners) changed(id); + } + } + private authorityKey(authority: SubagentAuthority): string { return JSON.stringify([ authority.principalId, @@ -911,6 +965,7 @@ export class Subagents { ); this.observers.clear(); this.listeners.clear(); + this.membershipListeners.clear(); await Promise.allSettled(this.running.values()); await this.mutation; } diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 676e724e48..b5b791b6cd 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1754,24 +1754,25 @@ Workspace snapshot fields: Agent snapshot fields: -| Field | Type | -| ------------------- | -------------------------------------------------------------- | -| `id` | `string` | -| `workspaceId` | `string` | -| `provider` | `string` | -| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` | -| `createdAt` | ISO timestamp | -| `updatedAt` | ISO timestamp | -| `lastActivityAt` | ISO timestamp | -| `title` | `string \| null` | -| `cwd` | `string` | -| `model` | `string \| null` | -| `currentModeId` | `string \| null` | -| `thinkingOptionId` | `string \| null` | -| `requiresAttention` | `boolean` | -| `attentionReason` | `"finished" \| "error" \| "permission" \| null` | -| `parentAgentId` | `string \| null` | -| `labels` | `Record` | +| Field | Type | +| ------------------- | ---------------------------------------------------------------- | +| `id` | `string` | +| `workspaceId` | `string` | +| `provider` | `string` | +| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` | +| `createdAt` | ISO timestamp | +| `updatedAt` | ISO timestamp | +| `lastActivityAt` | ISO timestamp | +| `title` | `string \| null` | +| `cwd` | `string` | +| `model` | `string \| null` | +| `currentModeId` | `string \| null` | +| `thinkingOptionId` | `string \| null` | +| `requiresAttention` | `boolean` | +| `attentionReason` | `"finished" \| "error" \| "permission" \| null` | +| `parent` | `{ serverId: string; agentId: string } \| null` | +| `parentAgentId` | Deprecated compatibility ID; use `parent` for complete identity. | +| `labels` | `Record` | Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace.