Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Session switch performance trace audit

## Scope

Session-switch User Timing instrumentation across WorkStation tab focus, the
session pipeline, persisted-history hydration, Jotai state commit, and the first
paint after loaded state reaches `ChatView`.

## Lifecycle matrix

| Lifecycle | Expected behavior | Verification | Verdict |
| --- | --- | --- | --- |
| Mount / active switch | Start or join one process-local trace for the target session. | Unit coverage exercises start, join, stages, and completion. | keep |
| Repeated switch | Supersede the previous active trace and retain only the latest 20 completed traces. | Unit coverage asserts stale User Timing entries are cleared after trace 20. | keep |
| Paint | Schedule two animation frames only after the target session reports loaded. | Hook cleanup cancels both scheduled frame IDs. | keep |
| Abort / unmount | Abort cleanup finishes the matching active trace; stale session callbacks are ignored. | Session ID matching is enforced by every mark/finish operation. | keep |
| Idle / hidden | No timer, observer, listener, poller, worker, or subscription is created by the trace module. | Static inspection of the module and hook. | keep |
| Multi-instance | Trace state and browser User Timing entries are local to each WebView process. | No persisted or cross-window state is introduced. | keep |

## Resource findings

| Area | Finding | Verdict | Reason / mitigation |
| --- | --- | --- | --- |
| CPU | Each lifecycle stage adds a bounded number of User Timing marks/measures. | keep | Work only occurs during an explicit session switch; no idle loop exists. |
| Memory | One active trace plus 20 completed traces are retained. | keep | Expired entries are removed from both module state and the browser performance timeline. |
| Rendering | `ChatView` observes session ID and load status to finish the trace after paint. | keep with measurement required | The subscriptions are narrow, but their actual render cost still requires a desktop/WebView profile. |
| Cancellation | The paint hook cancels scheduled animation frames on dependency change or unmount. | keep | Prevents a stale component from completing a newer session trace. |
| Persistence / I/O | Trace data is not persisted and creates no network, filesystem, or database I/O. | keep | Data remains in browser developer tooling only. |

## Verdict

**Pass for bounded instrumentation; runtime measurement pending.** The trace is
lifecycle-safe by inspection and unit coverage and can ship independently because
the PR makes no speedup claim. A packaged desktop/WebView profile is still required
before using the resulting data to claim a runtime performance improvement.
11 changes: 11 additions & 0 deletions src/engines/ChatPanel/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ import Message from "@src/components/Message";
import { useShowInteractArea } from "@src/contexts/workspace/ChatContext";
import { forkExternalHistoryIntoOrgiiSession } from "@src/engines/ChatPanel/externalHistoryFork";
import { derivedSnapshotAtom } from "@src/engines/SessionCore/core/atoms/events";
import {
loadStatusAtom,
sessionIdAtom,
} from "@src/engines/SessionCore/core/atoms/metadata";
import type { SessionEvent } from "@src/engines/SessionCore/core/types";
import { derivePlanApprovalViewState } from "@src/engines/SessionCore/derived/planDisplayEvents";
import { useTodoSync } from "@src/engines/SessionCore/hooks/session/useTodoSync";
import { useSessionSwitchPaintTrace } from "@src/engines/SessionCore/performance/useSessionSwitchPaintTrace";
import { ForkCancelledError } from "@src/features/TeamCollaboration/forkSession";
import { useFileReviewSync } from "@src/hooks/fileReview";
import { createLogger } from "@src/hooks/logger";
Expand Down Expand Up @@ -257,6 +262,12 @@ const ChatView: React.FC<ChatViewProps> = memo(
const streamRetry =
streamRetryStatus?.sessionId === sessionId ? streamRetryStatus : null;
const snapshot = useAtomValue(derivedSnapshotAtom);
const loadedSessionId = useAtomValue(sessionIdAtom);
const loadStatus = useAtomValue(loadStatusAtom);
useSessionSwitchPaintTrace(
sessionId,
loadedSessionId === sessionId && loadStatus === "loaded"
);
const canvasPreviewPill = useChatViewCanvasPreview(sessionId, snapshot);
const currentPlanApproval = usePendingPlanApproval(sessionId);
const chatEvents = snapshot?.chatEvents ?? EMPTY_CHAT_EVENTS;
Expand Down
9 changes: 9 additions & 0 deletions src/engines/SessionCore/core/atoms/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { atom } from "jotai";

import { REPLAY_CONFIG } from "@src/config/workspace/replayConfig";
import { clearLoadedPayloads } from "@src/engines/SessionCore/payloads";
import { markSessionSwitchTrace } from "@src/engines/SessionCore/performance/sessionSwitchPerformance";
import { clearLoadedTurnRegistry } from "@src/engines/SessionCore/turns/loadedTurnRegistry";
import { createLogger } from "@src/hooks/logger";
import { messageQueueAtom } from "@src/store/ui/messageQueueAtom";
Expand Down Expand Up @@ -158,6 +159,11 @@ export const loadSessionAtom = atom(
isFromCache = false,
replace = false,
} = payload;
markSessionSwitchTrace(sessionId, "state-commit-start", {
eventCount: events.length,
fromCache: isFromCache,
replace,
});

// Preserve synthetic user events (injected by session launch) when the
// sync hooks reload from SQLite/API before the backend has persisted the
Expand Down Expand Up @@ -445,6 +451,9 @@ export const loadSessionAtom = atom(
set(replayBarValueAtom, REPLAY_CONFIG.MAX_VALUE);
set(replayModeAtom, "follow");
}
markSessionSwitchTrace(sessionId, "state-commit-complete", {
eventCount: mergedEvents.length,
});
}
);
loadSessionAtom.debugLabel = "session/load";
Expand Down
112 changes: 112 additions & 0 deletions src/engines/SessionCore/performance/sessionSwitchPerformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
SESSION_SWITCH_PERFORMANCE_PREFIX,
finishSessionSwitchTrace,
markSessionSwitchTrace,
resetSessionSwitchPerformanceForTests,
startSessionSwitchTrace,
} from "./sessionSwitchPerformance";

interface UserTimingCall {
name: string;
options?: unknown;
}

describe("session switch performance traces", () => {
const marks: UserTimingCall[] = [];
const measures: UserTimingCall[] = [];
const clearMarks = vi.fn();
const clearMeasures = vi.fn();

beforeEach(() => {
marks.length = 0;
measures.length = 0;
clearMarks.mockReset();
clearMeasures.mockReset();
vi.stubGlobal("performance", {
clearMarks,
clearMeasures,
mark: (name: string, options?: unknown) => {
marks.push({ name, options });
},
measure: (name: string, options?: unknown) => {
measures.push({ name, options });
},
});
resetSessionSwitchPerformanceForTests();
});

afterEach(() => {
resetSessionSwitchPerformanceForTests();
vi.unstubAllGlobals();
});

it("records stage segments and a final painted measure", () => {
startSessionSwitchTrace("session-a", "session-jump");
markSessionSwitchTrace("session-a", "state-cleared");
markSessionSwitchTrace("session-a", "rust-switch-complete", {
cacheHit: true,
});
finishSessionSwitchTrace("session-a", "painted");

expect(marks.map(({ name }) => name)).toEqual([
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:start`,
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:state-cleared`,
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:rust-switch-complete`,
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:painted`,
]);
expect(measures.map(({ name }) => name)).toContain(
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:total-to:painted`
);
expect(marks[2].options).toMatchObject({
detail: {
cacheHit: true,
sessionId: "session-a",
stage: "rust-switch-complete",
},
});
});

it("joins the click trace when the pipeline effect sees the same session", () => {
const clickTrace = startSessionSwitchTrace("session-a", "workstation-tab");
const pipelineTrace = startSessionSwitchTrace(
"session-a",
"pipeline-effect",
{ joinExisting: true }
);

expect(pipelineTrace).toBe(clickTrace);
expect(marks).toHaveLength(1);
});

it("drops late stages from a superseded session", () => {
startSessionSwitchTrace("session-a", "session-jump");
startSessionSwitchTrace("session-b", "session-jump");

markSessionSwitchTrace("session-a", "rust-switch-complete");
markSessionSwitchTrace("session-b", "rust-switch-complete");

expect(marks.map(({ name }) => name)).toEqual([
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:start`,
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:superseded`,
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000002:mark:start`,
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000002:mark:rust-switch-complete`,
]);
});

it("bounds retained performance entries to the latest twenty traces", () => {
for (let index = 0; index < 21; index += 1) {
const sessionId = `session-${index}`;
startSessionSwitchTrace(sessionId, "session-jump");
finishSessionSwitchTrace(sessionId, "painted");
}

expect(clearMarks).toHaveBeenCalledWith(
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:start`
);
expect(clearMeasures).toHaveBeenCalledWith(
`${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:total-to:painted`
);
});
});
Loading
Loading