Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, beforeEach, afterEach, mock, type Mock } from "bun:test";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { StreamStartEvent, ToolCallStartEvent } from "@/common/types/stream";
import type { WorkspaceChatMessage } from "@/common/orpc/types";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import { WorkspaceStore } from "./WorkspaceStore";
Expand Down Expand Up @@ -370,6 +371,61 @@ describe("WorkspaceStore", () => {
expect(state1).toBe(state2);
});

it("getWorkspaceSidebarState() returns same reference when WorkspaceState hasn't changed", () => {
const originalNow = Date.now;
let now = 1000;
Date.now = () => now;

try {
const workspaceId = "test-workspace";
createAndAddWorkspace(store, workspaceId);

const aggregator = store.getAggregator(workspaceId);
expect(aggregator).toBeDefined();
if (!aggregator) {
throw new Error("Expected aggregator to exist");
}

const streamStart: StreamStartEvent = {
type: "stream-start",
workspaceId,
messageId: "msg1",
model: "claude-opus-4",
historySequence: 1,
startTime: 500,
mode: "exec",
};
aggregator.handleStreamStart(streamStart);

const toolStart: ToolCallStartEvent = {
type: "tool-call-start",
workspaceId,
messageId: "msg1",
toolCallId: "tool1",
toolName: "test_tool",
args: {},
tokens: 0,
timestamp: 600,
};
aggregator.handleToolCallStart(toolStart);

// Simulate store update (MapStore version bump) after handling events.
store.bumpState(workspaceId);

now = 1300;
const sidebar1 = store.getWorkspaceSidebarState(workspaceId);

// Advance time without a store bump. Without snapshot caching, this would
// produce a new object due to Date.now()-derived timing stats.
now = 1350;
const sidebar2 = store.getWorkspaceSidebarState(workspaceId);

expect(sidebar2).toBe(sidebar1);
} finally {
Date.now = originalNow;
}
});

it("syncWorkspaces() does not emit when workspaces unchanged", () => {
const listener = mock(() => undefined);
store.subscribe(listener);
Expand Down
20 changes: 17 additions & 3 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,11 @@ export class WorkspaceStore {

// Cache sidebar state objects to return stable references
private sidebarStateCache = new Map<string, WorkspaceSidebarState>();
// Map from workspaceId -> the WorkspaceState reference used to compute sidebarStateCache.
// React's useSyncExternalStore may call getSnapshot() multiple times per render; this
// ensures getWorkspaceSidebarState() returns a referentially stable snapshot for a given
// MapStore version even when timingStats would otherwise change via Date.now().
private sidebarStateSourceState = new Map<string, WorkspaceState>();

/**
* Get sidebar state for a workspace (subset of full state).
Expand All @@ -687,6 +692,12 @@ export class WorkspaceStore {
*/
getWorkspaceSidebarState(workspaceId: string): WorkspaceSidebarState {
const fullState = this.getWorkspaceState(workspaceId);

const cached = this.sidebarStateCache.get(workspaceId);
if (cached && this.sidebarStateSourceState.get(workspaceId) === fullState) {
return cached;
}

const aggregator = this.aggregators.get(workspaceId);

// Get timing stats: prefer active stream, fall back to last completed
Expand All @@ -711,9 +722,7 @@ export class WorkspaceStore {
// Get session-level aggregate stats
const sessionStats = aggregator?.getSessionTimingStats() ?? null;

const cached = this.sidebarStateCache.get(workspaceId);

// Return cached if values match (timing stats checked by reference since they change frequently)
// Return cached if values match.
if (
cached &&
cached.canInterrupt === fullState.canInterrupt &&
Expand All @@ -728,6 +737,9 @@ export class WorkspaceStore {
(cached.sessionStats?.totalDurationMs === sessionStats?.totalDurationMs &&
cached.sessionStats?.responseCount === sessionStats?.responseCount))
) {
// Even if we re-use the cached object, mark it as derived from the current
// WorkspaceState so repeated getSnapshot() reads during this render are stable.
this.sidebarStateSourceState.set(workspaceId, fullState);
return cached;
}

Expand All @@ -742,6 +754,7 @@ export class WorkspaceStore {
sessionStats,
};
this.sidebarStateCache.set(workspaceId, newState);
this.sidebarStateSourceState.set(workspaceId, fullState);
return newState;
}

Expand Down Expand Up @@ -1143,6 +1156,7 @@ export class WorkspaceStore {
this.recencyCache.delete(workspaceId);
this.previousSidebarValues.delete(workspaceId);
this.sidebarStateCache.delete(workspaceId);
this.sidebarStateSourceState.delete(workspaceId);
this.workspaceCreatedAt.delete(workspaceId);
this.workspaceStats.delete(workspaceId);
this.statsStore.delete(workspaceId);
Expand Down