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
29 changes: 29 additions & 0 deletions packages/core/src/sidebar/buildSidebarData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AgentSession } from "@posthog/shared";
import type { TaskRunStatus } from "@posthog/shared/domain-types";
import { getRepositoryInfo } from "./groupTasks";
import type { TaskData } from "./sidebarData.types";
Expand Down Expand Up @@ -211,3 +212,31 @@ export function sliceChronological(
hasMore: sortedUnpinnedTasks.length > historyVisibleCount,
};
}

/** Only the session fields the sidebar actually reads (see deriveTaskData). */
type SidebarSessionFields = Pick<
AgentSession,
"taskId" | "isPromptPending" | "pendingPermissions" | "cloudStatus"
> & { cloudOutput?: { pr_url?: unknown } | null };

/**
* A compact, primitive signature of just the session fields the sidebar reads.
* The sidebar subscribes to this string instead of the whole sessions record,
* so streaming token appends — which only mutate `session.events` — don't
* re-render the sidebar (and, since it's mounted at the root, the whole tree).
*/
export function computeSidebarSessionSignature(
sessions: Record<string, SidebarSessionFields>,
): string {
const parts: string[] = [];
for (const s of Object.values(sessions)) {
if (!s.taskId) continue;
const prUrl =
typeof s.cloudOutput?.pr_url === "string" ? s.cloudOutput.pr_url : "";
parts.push(
`${s.taskId}\t${s.isPromptPending ? 1 : 0}\t${s.pendingPermissions.size}\t${s.cloudStatus ?? ""}\t${prUrl}`,
);
}
parts.sort();
return parts.join("\n");
}
52 changes: 52 additions & 0 deletions packages/core/src/sidebar/computeSidebarSessionSignature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { computeSidebarSessionSignature } from "./buildSidebarData";

// Minimal session shape the signature reads. `events` is included to prove it
// is ignored (the streaming hot path only mutates `events`).
function session(over: Record<string, unknown> = {}) {
return {
taskId: "t1",
isPromptPending: false,
pendingPermissions: new Map(),
cloudStatus: undefined,
cloudOutput: null,
events: [],
...over,
} as never;
}

describe("computeSidebarSessionSignature", () => {
it("ignores events, so streaming tokens don't change it", () => {
const few = computeSidebarSessionSignature({
r1: session({ events: [1, 2] }),
});
const many = computeSidebarSessionSignature({
r1: session({ events: [1, 2, 3, 4, 5, 6, 7] }),
});
expect(many).toBe(few);
});

it.each([
{ label: "isPromptPending", over: { isPromptPending: true } },
{ label: "cloudStatus", over: { cloudStatus: "in_progress" } },
{
label: "pendingPermissions size",
over: { pendingPermissions: new Map([["p", {}]]) },
},
{
label: "cloudOutput.pr_url",
over: { cloudOutput: { pr_url: "https://x/pr/1" } },
},
])("changes when $label changes", ({ over }) => {
const before = computeSidebarSessionSignature({ r1: session() });
expect(computeSidebarSessionSignature({ r1: session(over) })).not.toBe(
before,
);
});

it("skips sessions without a taskId", () => {
expect(
computeSidebarSessionSignature({ r1: session({ taskId: "" }) }),
).toBe("");
});
});
1 change: 1 addition & 0 deletions packages/ui/src/features/sessions/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export {
useQueuedMessagesForTask,
useSessionForTask,
useSessions,
useSidebarSessionMap,
useThoughtLevelConfigOptionForTask,
} from "./useSession";

Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/features/sessions/useSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
extractAvailableCommandsFromEvents,
extractUserPromptsFromEvents,
} from "@posthog/core/sessions/sessionEvents";
import { computeSidebarSessionSignature } from "@posthog/core/sidebar/buildSidebarData";
import type { PermissionRequest } from "@posthog/ui/features/sessions/sessionLogTypes";
import { useMemo } from "react";
import { shallow } from "zustand/shallow";
import {
type Adapter,
Expand All @@ -19,6 +21,29 @@ import {

export const useSessions = () => useSessionStore((s) => s.sessions);

/**
* The sidebar's view of sessions, keyed by taskId. Subscribes only to a
* signature of the fields the sidebar reads (see computeSidebarSessionSignature),
* so streaming token appends — which only mutate `events` — don't re-render the
* sidebar (which is mounted at the root). The map is rebuilt from the live
* snapshot only when a sidebar-relevant field actually changes.
*/
export const useSidebarSessionMap = (): Map<string, AgentSession> => {
const signature = useSessionStore((s) =>
computeSidebarSessionSignature(s.sessions),
);
// `signature` is the trigger, not read inside: rebuild the map from the live
// snapshot only when a sidebar-relevant field changes (not on every token).
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by signature on purpose
return useMemo(() => {
const map = new Map<string, AgentSession>();
for (const session of Object.values(useSessionStore.getState().sessions)) {
if (session.taskId) map.set(session.taskId, session);
}
return map;
}, [signature]);
};

/** O(1) lookup using taskIdIndex */
export const useSessionForTask = (
taskId: string | undefined,
Expand Down
62 changes: 62 additions & 0 deletions packages/ui/src/features/sessions/useSidebarSessionMap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AcpMessage, AgentSession } from "@posthog/shared";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { sessionStoreSetters, useSessionStore } from "./sessionStore";
import { useSessions, useSidebarSessionMap } from "./useSession";

function makeSession(taskId: string, taskRunId: string): AgentSession {
return {
taskId,
taskRunId,
events: [],
isPromptPending: false,
pendingPermissions: new Map(),
} as unknown as AgentSession;
}

const TOKEN = {} as AcpMessage;

/** Mount a hook and count how many times it (re)renders. */
function countRenders<T>(hook: () => T): () => number {
let n = 0;
renderHook(() => {
n++;
return hook();
});
return () => n;
}

describe("sidebar session subscription — re-render cost during streaming", () => {
beforeEach(() => {
useSessionStore.setState({ sessions: {}, taskIdIndex: {} });
sessionStoreSetters.setSession(makeSession("t1", "r1"));
});

it("baseline: useSessions() re-renders on every streamed token", () => {
const renders = countRenders(() => useSessions());
const before = renders();
for (let i = 0; i < 20; i++) {
act(() => sessionStoreSetters.appendEvents("r1", [TOKEN]));
}
// Old behaviour: one re-render per token (and the sidebar is at the root).
expect(renders() - before).toBe(20);
});

it("fixed: useSidebarSessionMap() ignores streamed tokens (0 re-renders)", () => {
const renders = countRenders(() => useSidebarSessionMap());
const before = renders();
for (let i = 0; i < 20; i++) {
act(() => sessionStoreSetters.appendEvents("r1", [TOKEN]));
}
expect(renders() - before).toBe(0);
});

it("fixed: still re-renders when a sidebar-relevant field changes", () => {
const renders = countRenders(() => useSidebarSessionMap());
const before = renders();
act(() =>
sessionStoreSetters.updateSession("r1", { isPromptPending: true }),
);
expect(renders() - before).toBe(1);
});
});
13 changes: 2 additions & 11 deletions packages/ui/src/features/sidebar/useSidebarData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { AppView } from "@posthog/ui/router/useAppView";
import { useEffect, useMemo, useRef } from "react";
import { useArchivedTaskIds } from "../archive/useArchivedTaskIds";
import { useProvisioningStore } from "../provisioning/store";
import { useSessions } from "../sessions/sessionStore";
import { useSidebarSessionMap } from "../sessions/sessionStore";
import { useSuspendedTaskIds } from "../suspension/useSuspendedTaskIds";
import { useSlackTasks, useTaskSummaries, useTasks } from "../tasks/useTasks";
import { useWorkspaces } from "../workspace/useWorkspace";
Expand All @@ -41,7 +41,6 @@ export function useSidebarData({
const archivedTaskIds = useArchivedTaskIds();
const suspendedTaskIds = useSuspendedTaskIds();
const provisioningTaskIds = useProvisioningStore((s) => s.activeTasks);
const sessions = useSessions();
const { timestamps } = useTaskViewed();
const historyVisibleCount = useSidebarStore(
(state) => state.historyVisibleCount,
Expand Down Expand Up @@ -140,15 +139,7 @@ export function useSidebarData({
const activeTaskId =
activeView.type === "task-detail" ? (activeView.taskId ?? null) : null;

const sessionByTaskId = useMemo(() => {
const map = new Map<string, (typeof sessions)[string]>();
for (const session of Object.values(sessions)) {
if (session.taskId) {
map.set(session.taskId, session);
}
}
return map;
}, [sessions]);
const sessionByTaskId = useSidebarSessionMap();

const taskData = useMemo(
() =>
Expand Down