Compare commits

...
Author SHA1 Message Date
559c0200e0 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 01:05:05 +02:00
6 changed files with 403 additions and 22 deletions
@@ -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" }),
@@ -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,
);
+163 -1
View File
@@ -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<K, V> extends Map<K, V> {
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<string, Agent>(
[
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<string, Agent>(
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<typeof useSessionStore.getState>) => {
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);
}
});
});
+31 -5
View File
@@ -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<string>,
): (state: SessionStoreSnapshot) => SubagentRow[] {
let previousAgents: Map<string, Agent> | 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,
);
@@ -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<K, V> extends Map<K, V> {
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<string, Agent>): 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<string, Agent>([
[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<string>());
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 = {
@@ -28,17 +28,15 @@ export function deriveWorkspaceAgentVisibility(input: {
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const agentsById = new Map<string, Agent>([
...(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<string, Agent>; agentDetails: Map<string, Agent> } | 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<string, Agent> | undefined;
let previousAgentDetails: Map<string, Agent> | 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))
);
}