From 559c0200e098cd257150c2b63c51470bfac7dd32 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:05:05 +0200 Subject: [PATCH] Reuse workspace and subagent lists while replies stream (#7342) * perf(app): skip workspace agent visibility rebuild on stream ticks The workspace screen derived agent visibility on every session-store update: it copied every agent and agent detail into a new Map, walked all agents, and allocated two Sets, then compared them. Stream ticks update the store but leave the agent maps untouched, so this was pure waste at stream rate. createWorkspaceAgentVisibilitySelector returns the previous result until session.agents or session.agentDetails changes, and keeps it when a recompute yields the same sets. Parent lookups read the two maps directly instead of merging them. Fixes #4817 Co-Authored-By: Claude Opus 5 * perf(app): skip subagent row rebuild on stream ticks Every mounted agent panel selected its subagent rows by walking all session agents, allocating rows, sorting, and deep-comparing on every session-store update, including every stream tick. createSubagentsForParentSelector returns the previous rows until session.agents changes, and keeps them when a recompute yields equal rows. The hook rebuilds the selector when the parent or the pending archive ids change. Refs #4817 Co-Authored-By: Claude Opus 5 * perf(app): check pending submissions without allocating selectAgentTurnPresentation runs from several session-store selectors and filtered the submission list only to read its length. Use some(). Refs #4817 Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Ivan Malison Co-authored-by: Claude Opus 5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/src/composer/submission/model.test.ts | 16 ++ .../screens/workspace/workspace-screen.tsx | 17 +- packages/app/src/subagents/select.test.ts | 164 +++++++++++++++++- packages/app/src/subagents/select.ts | 36 +++- .../workspace-tabs/agent-visibility.test.ts | 139 ++++++++++++++- .../src/workspace-tabs/agent-visibility.ts | 53 +++++- 6 files changed, 403 insertions(+), 22 deletions(-) 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)) ); }