diff --git a/packages/app/src/composer/submission/model.test.ts b/packages/app/src/composer/submission/model.test.ts index 147ccce954..3aea318f4f 100644 --- a/packages/app/src/composer/submission/model.test.ts +++ b/packages/app/src/composer/submission/model.test.ts @@ -4,6 +4,7 @@ import { beginMessageSubmission, getActiveMessageSubmissions, getSendingClientMessageIds, + hasActiveMessageSubmission, observeMessageSubmissionCanonical, rejectMessageSubmission, } from "./model"; @@ -53,6 +54,21 @@ describe("message submission transactions", () => { expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]); }); + it("reports activity until every submission is provider acknowledged", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1" }), + { clientMessageId: "client-2" }, + ); + const oneAcknowledged = observeMessageSubmissionCanonical(both, ["client-1"]); + + expect(hasActiveMessageSubmission(undefined)).toBe(false); + expect(hasActiveMessageSubmission(both)).toBe(true); + expect(hasActiveMessageSubmission(oneAcknowledged)).toBe(true); + expect( + hasActiveMessageSubmission(observeMessageSubmissionCanonical(oneAcknowledged, ["client-2"])), + ).toBe(false); + }); + it("records provider acknowledgement without settling another transaction", () => { const both = beginMessageSubmission( beginMessageSubmission([], { clientMessageId: "client-1" }), diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 48e123c26a..5c3e1560e1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -162,7 +162,7 @@ import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recover import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model"; import { buildWorkspaceTabSnapshot, - deriveWorkspaceAgentVisibility, + createWorkspaceAgentVisibilitySelector, workspaceAgentVisibilityEqual, } from "@/workspace-tabs/agent-visibility"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; @@ -1148,14 +1148,17 @@ function WorkspaceScreenContent({ ), }); - const workspaceAgentVisibility = useStoreWithEqualityFn( - useSessionStore, - (state) => - deriveWorkspaceAgentVisibility({ - sessionAgents: state.sessions[normalizedServerId]?.agents, - agentDetails: state.sessions[normalizedServerId]?.agentDetails, + const selectWorkspaceAgentVisibility = useMemo( + () => + createWorkspaceAgentVisibilitySelector({ + serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, }), + [normalizedServerId, normalizedWorkspaceId], + ); + const workspaceAgentVisibility = useStoreWithEqualityFn( + useSessionStore, + selectWorkspaceAgentVisibility, workspaceAgentVisibilityEqual, ); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 6835260ca7..38a905183d 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -1,6 +1,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; -import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select"; +import { + createSubagentsForParentSelector, + selectProviderSubagentsForParent, + selectSubagentsForParent, +} from "./select"; +import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility"; import { useProviderSubagentStore } from "./provider-store"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -435,3 +440,160 @@ describe("selectSubagentsForParent", () => { ); }); }); + +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"), + }); + 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); + } + }); +}); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 3bb3bddcb6..164a4d1280 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -97,6 +97,31 @@ export function selectSubagentsForParent( 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; + + 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; + } + return previousRows; + }; +} + export function selectProviderSubagentsForParent( state: ProviderSubagentStoreSnapshot, params: SelectSubagentsParams, @@ -133,12 +158,13 @@ export function selectProviderSubagentsForParent( } export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { - const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId); - const paseoRows = useStoreWithEqualityFn( - useSessionStore, - (state) => selectSubagentsForParent(state, params, pendingArchiveIds), - equal, + const { serverId, parentAgentId } = params; + const pendingArchiveIds = usePendingArchiveAgentIds(serverId); + const selectPaseoRows = useMemo( + () => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds), + [serverId, parentAgentId, pendingArchiveIds], ); + const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal); const supported = useSessionStore( (state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, ); diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 3b16c6b8d5..59f96fccbc 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vitest"; -import type { Agent } from "@/stores/session-store"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { afterEach, describe, expect, it } from "vitest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { buildWorkspaceTabSnapshot, + createWorkspaceAgentVisibilitySelector, deriveWorkspaceAgentVisibility, shouldPruneWorkspaceAgentTab, workspaceAgentVisibilityEqual, @@ -336,6 +338,139 @@ describe("workspace agent visibility", () => { }); }); + describe("createWorkspaceAgentVisibilitySelector", () => { + const SERVER_ID = "srv"; + + 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](); + } + } + + function seedSession(agents: Map): void { + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setAgents(SERVER_ID, agents); + } + + function streamTick(text: string): void { + useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent-agent", { + head: [ + { + kind: "assistant_message", + id: "message-1", + text, + timestamp: new Date("2026-03-04T00:02:00.000Z"), + }, + ], + }); + } + + const parent = makeAgent({ id: "parent-agent", cwd: "/repo", workspaceId: WORKSPACE_ID }); + const child = makeAgent({ + id: "child-agent", + cwd: "/repo", + workspaceId: WORKSPACE_ID, + parentAgentId: parent.id, + }); + const otherWorkspace = makeAgent({ id: "other-agent", cwd: "/other", workspaceId: "ws-2" }); + + afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + }); + + it("returns the cached result without walking agents when a stream tick leaves them unchanged", () => { + const agents = new IterationCountingMap([ + [parent.id, parent], + [otherWorkspace.id, otherWorkspace], + ]); + seedSession(agents); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + 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.activeAgentIds).toEqual(new Set([parent.id])); + }); + + it("recomputes when the agents map changes", () => { + const agents = new Map([[parent.id, parent]]); + seedSession(agents); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const before = select(useSessionStore.getState()); + + useSessionStore.getState().setAgents(SERVER_ID, new Map(agents).set(child.id, child)); + const after = select(useSessionStore.getState()); + + expect(after).not.toBe(before); + expect(after.activeAgentIds).toEqual(new Set([parent.id, child.id])); + expect(after.autoOpenAgentIds).toEqual(new Set([parent.id])); + }); + + it("keeps the previous result when a changed agents map yields the same sets", () => { + const agents = new Map([ + [parent.id, parent], + [otherWorkspace.id, otherWorkspace], + ]); + seedSession(agents); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const before = select(useSessionStore.getState()); + + useSessionStore + .getState() + .setAgents( + SERVER_ID, + new Map(agents).set(otherWorkspace.id, { ...otherWorkspace, title: "Renamed" }), + ); + + expect(select(useSessionStore.getState())).toBe(before); + }); + + it("recomputes when agent details reveal a parent in another workspace", () => { + seedSession(new Map([[child.id, child]])); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + expect(select(useSessionStore.getState()).autoOpenAgentIds).toEqual(new Set()); + + useSessionStore + .getState() + .setAgentDetails( + SERVER_ID, + new Map([[parent.id, { ...parent, workspaceId: "ws-parent" }]]), + ); + const after = select(useSessionStore.getState()); + + expect(after.activeAgentIds).toEqual(new Set([child.id])); + expect(after.autoOpenAgentIds).toEqual(new Set([child.id])); + }); + }); + describe("workspaceAgentVisibilityEqual", () => { it("returns true for identical sets", () => { const a = { diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 399b786ef6..3dd1881643 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -28,17 +28,15 @@ export function deriveWorkspaceAgentVisibility(input: { const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); - const agentsById = new Map([ - ...(agentDetails?.entries() ?? []), - ...(sessionAgents?.entries() ?? []), - ]); for (const agent of sessionAgents?.values() ?? []) { if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } if (!agent.archivedAt) { activeAgentIds.add(agent.id); - const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined; + const parentAgent = agent.parentAgentId + ? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId)) + : undefined; if (isWorkspaceRootAgent(agent, parentAgent)) { autoOpenAgentIds.add(agent.id); } @@ -47,6 +45,46 @@ export function deriveWorkspaceAgentVisibility(input: { return { activeAgentIds, autoOpenAgentIds }; } +interface AgentMapsSnapshot { + sessions: Record< + string, + { agents: Map; agentDetails: Map } | undefined + >; +} + +// Session-store selectors run on every store update, including every stream tick, which +// leaves the agent maps untouched. Reuse the previous result until either map changes, and +// keep it when a recompute yields the same sets so the hook's equality check stays O(1). +export function createWorkspaceAgentVisibilitySelector(input: { + serverId: string; + workspaceId: string | null | undefined; +}): (state: AgentMapsSnapshot) => WorkspaceAgentVisibility { + let previousAgents: Map | undefined; + let previousAgentDetails: Map | undefined; + let previousVisibility: WorkspaceAgentVisibility | null = null; + + return (state) => { + const session = state.sessions[input.serverId]; + const agents = session?.agents; + const agentDetails = session?.agentDetails; + if (previousVisibility && agents === previousAgents && agentDetails === previousAgentDetails) { + return previousVisibility; + } + + const visibility = deriveWorkspaceAgentVisibility({ + sessionAgents: agents, + agentDetails, + workspaceId: input.workspaceId, + }); + previousAgents = agents; + previousAgentDetails = agentDetails; + if (!previousVisibility || !workspaceAgentVisibilityEqual(previousVisibility, visibility)) { + previousVisibility = visibility; + } + return previousVisibility; + }; +} + export function buildWorkspaceTabSnapshot(input: { agentVisibility: WorkspaceAgentVisibility; agentsHydrated: boolean; @@ -73,8 +111,9 @@ export function workspaceAgentVisibilityEqual( b: WorkspaceAgentVisibility, ): boolean { return ( - setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) + a === b || + (setsEqual(a.activeAgentIds, b.activeAgentIds) && + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)) ); }