Keep large sidebars responsive (#1966)
* perf(app): keep large sidebars responsive Sidebar rows rescanned every agent for each session update, multiplying work with both workspace and agent count. Build workspace activity once and share the derived sidebar model so updates scale with the underlying collections. * test(app): use valid agent fixture for workspace restore * refactor(app): keep sidebar model surface focused
This commit is contained in:
@@ -27,6 +27,39 @@ const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
function agent(archivedAt: Date | null): Agent {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: createdAt,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
archivedAt,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||
return (
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||
@@ -44,11 +77,7 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
||||
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date(),
|
||||
} as unknown as Agent);
|
||||
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -95,11 +124,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: undefined,
|
||||
} as unknown as Agent);
|
||||
next.set(AGENT_ID, agent(null));
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
@@ -202,11 +203,13 @@ describe("buildStatusSidebarShortcutModel", () => {
|
||||
];
|
||||
|
||||
const model = buildStatusSidebarShortcutModel({
|
||||
workspaces,
|
||||
projectNamesByKey: new Map([
|
||||
["p1", "Project 1"],
|
||||
["p2", "Project 2"],
|
||||
]),
|
||||
groups: buildStatusGroups(
|
||||
workspaces,
|
||||
new Map([
|
||||
["p1", "Project 1"],
|
||||
["p2", "Project 2"],
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toEqual([
|
||||
@@ -242,8 +245,7 @@ describe("buildStatusSidebarShortcutModel", () => {
|
||||
];
|
||||
|
||||
const model = buildStatusSidebarShortcutModel({
|
||||
workspaces,
|
||||
projectNamesByKey: new Map([["p1", "Project 1"]]),
|
||||
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])),
|
||||
collapsedStatusGroupKeys: new Set(["needs_input"]),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarStatusWorkspacePlacement,
|
||||
SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string;
|
||||
@@ -53,17 +52,15 @@ export function buildSidebarShortcutModel(input: {
|
||||
}
|
||||
|
||||
export function buildStatusSidebarShortcutModel(input: {
|
||||
workspaces: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
groups: readonly StatusGroup[];
|
||||
collapsedStatusGroupKeys?: ReadonlySet<string>;
|
||||
shortcutLimit?: number;
|
||||
}): SidebarShortcutModel {
|
||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
|
||||
const groups = buildStatusGroups(input.workspaces, input.projectNamesByKey);
|
||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>();
|
||||
|
||||
for (const group of groups) {
|
||||
for (const group of input.groups) {
|
||||
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { buildWorkspaceAgentActivityIndex } from "./workspace-agent-activity";
|
||||
|
||||
function agent(input: {
|
||||
id: string;
|
||||
workspaceId?: string;
|
||||
status?: Agent["status"];
|
||||
updatedAt: string;
|
||||
attentionTimestamp?: string | null;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: Agent["attentionReason"];
|
||||
pendingPermissionCount?: number;
|
||||
archivedAt?: string | null;
|
||||
parentAgentId?: string | null;
|
||||
}): Agent {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
status: input.status ?? "idle",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date(input.updatedAt),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date(input.updatedAt),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: Array.from({ length: input.pendingPermissionCount ?? 0 }, (_, index) => ({
|
||||
id: `permission-${index}`,
|
||||
provider: "codex",
|
||||
name: "shell",
|
||||
kind: "tool",
|
||||
input: {},
|
||||
})),
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: input.workspaceId,
|
||||
model: null,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
|
||||
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
|
||||
parentAgentId: input.parentAgentId ?? null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace agent activity index", () => {
|
||||
it("keeps the latest active root agent for each workspace", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"older",
|
||||
agent({
|
||||
id: "older",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"permission",
|
||||
agent({
|
||||
id: "permission",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:01:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"attention",
|
||||
agent({
|
||||
id: "attention",
|
||||
workspaceId: "workspace-b",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
attentionTimestamp: "2026-06-01T10:02:00.000Z",
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index).toEqual(
|
||||
new Map([
|
||||
["workspace-a", { status: "needs_input", enteredAt: new Date("2026-06-01T10:01:00.000Z") }],
|
||||
["workspace-b", { status: "attention", enteredAt: new Date("2026-06-01T10:02:00.000Z") }],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let archived or child agents change root workspace activity", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"child",
|
||||
agent({
|
||||
id: "child",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:03:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
parentAgentId: "root",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"archived",
|
||||
agent({
|
||||
id: "archived",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:04:00.000Z",
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
archivedAt: "2026-06-01T10:04:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index.get("workspace-a")).toEqual({
|
||||
status: "running",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
|
||||
|
||||
export interface WorkspaceAgentActivity {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceAgentActivityIndex(
|
||||
agents: ReadonlyMap<string, Agent>,
|
||||
): Map<string, WorkspaceAgentActivity> {
|
||||
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
||||
const current = activityByWorkspaceId.get(agent.workspaceId);
|
||||
if (current && enteredAt <= (current.enteredAt ?? new Date(0))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
activityByWorkspaceId.set(agent.workspaceId, {
|
||||
status: deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
}),
|
||||
enteredAt,
|
||||
});
|
||||
}
|
||||
|
||||
return activityByWorkspaceId;
|
||||
}
|
||||
Reference in New Issue
Block a user