Skip to content
12 changes: 8 additions & 4 deletions apps/code/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,15 @@ app.commandLine.appendSwitch("enable-logging", "file");
app.commandLine.appendSwitch("log-file", chromiumLogPath);
app.commandLine.appendSwitch("log-level", "0");

// In dev, expose the renderer over CDP (:9222) for the test-electron-app skill.
// electron-vite launches Electron itself, so this is set in-process rather than
// via a CLI flag.
// In dev, expose the renderer over CDP (:9222 by default) for the
// test-electron-app skill. electron-vite launches Electron itself, so this is
// set in-process rather than via a CLI flag. POSTHOG_CODE_CDP_PORT matches the
// port resolution in scripts/electron-cdp.mjs, for when :9222 is taken.
if (isDev) {
app.commandLine.appendSwitch("remote-debugging-port", "9222");
app.commandLine.appendSwitch(
"remote-debugging-port",
process.env.POSTHOG_CODE_CDP_PORT ?? "9222",
);
}

crashReporter.start({ uploadToServer: false });
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/agent-chat/agentChatStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { AcpMessage } from "@posthog/shared";
import { beforeEach, describe, expect, it } from "vitest";
import { agentChatStore, MAX_CHAT_MESSAGES } from "./agentChatStore";

const message = (i: number): AcpMessage =>
({
message: {
jsonrpc: "2.0",
method: "session/update",
params: { i },
},
}) as unknown as AcpMessage;

describe("agentChatStore", () => {
beforeEach(() => {
agentChatStore.setState({ chats: {} });
});

it("appends messages in order", () => {
const { begin, appendMessages } = agentChatStore.getState();
begin("chat", "agent");
appendMessages("chat", [message(1), message(2)]);
appendMessages("chat", [message(3)]);

expect(agentChatStore.getState().chats.chat?.messages).toHaveLength(3);
});

it("caps retained messages at MAX_CHAT_MESSAGES, dropping oldest", () => {
const { begin, appendMessages } = agentChatStore.getState();
begin("chat", "agent");

const batch = Array.from({ length: MAX_CHAT_MESSAGES }, (_, i) =>
message(i),
);
appendMessages("chat", batch);
appendMessages("chat", [message(MAX_CHAT_MESSAGES)]);

const messages = agentChatStore.getState().chats.chat?.messages ?? [];
expect(messages).toHaveLength(MAX_CHAT_MESSAGES);
const first = messages[0]?.message as { params?: { i?: number } };
const last = messages.at(-1)?.message as { params?: { i?: number } };
expect(first.params?.i).toBe(1);
expect(last.params?.i).toBe(MAX_CHAT_MESSAGES);
});
});
16 changes: 15 additions & 1 deletion packages/core/src/agent-chat/agentChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export interface AgentChatState {
error: string | null;
}

/**
* Retention cap per chat. A live chat streams an AcpMessage per chunk, so an
* unbounded array grows without limit on long sessions; past this the oldest
* messages fall off (the transcript of record lives server-side).
*/
export const MAX_CHAT_MESSAGES = 2000;

export const EMPTY_CHAT: AgentChatState = {
agentKey: null,
sessionId: null,
Expand Down Expand Up @@ -87,11 +94,18 @@ export const agentChatStore = createStore<AgentChatStore>((set) => {
set((s) => {
if (messages.length === 0) return s;
const cur = s.chats[chatId] ?? EMPTY_CHAT;
const appended = [...cur.messages, ...messages];
return {
...s,
chats: {
...s.chats,
[chatId]: { ...cur, messages: [...cur.messages, ...messages] },
[chatId]: {
...cur,
messages:
appended.length > MAX_CHAT_MESSAGES
? appended.slice(-MAX_CHAT_MESSAGES)
: appended,
},
},
};
}),
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/features/canvas/stores/freeformChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { hostClient } from "../hostClient";

const log = logger.scope("freeform-edit-store");

// Edit-history retention: each version keeps a full copy of the canvas source
// plus context, so unbounded history grows by whole-document copies. Oldest
// versions fall off past this cap.
const MAX_VERSIONS = 50;

// Working copy of a freeform canvas's source + edit history. Generation no
// longer streams in-process — it runs as a dedicated task that publishes the
// result into the canvas's saved record (see useGenerateFreeformCanvas). This
Expand Down Expand Up @@ -206,9 +211,11 @@ export const useFreeformChatStore = create<FreeformChatStore>()((set, get) => {
// Drop any redo tail before appending (linear history, as with code edits).
const base =
headIdx === -1 ? t.versions : t.versions.slice(0, headIdx + 1);
const capped =
base.length >= MAX_VERSIONS ? base.slice(-(MAX_VERSIONS - 1)) : base;
patch(threadId, (prev) => ({
...prev,
versions: [...base, version],
versions: [...capped, version],
currentVersionId: version.id,
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ export function ReviewShell({

return (
<WorkerPoolContextProvider
poolOptions={{ workerFactory }}
// poolSize: each highlighter worker is a full V8 isolate with shiki
// grammars loaded (~40MB RSS); the library default of 8 is oversized.
poolOptions={{ workerFactory, poolSize: 2 }}
highlighterOptions={{
theme: { dark: "github-dark", light: "github-light" },
langs: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export function ConversationView({
() => ({
workerFactory: () => diffWorkerFactory(),
totalASTLRUCacheSize: 200,
// Each pooled highlighter worker is a full V8 isolate with shiki
// grammars loaded (~40MB RSS); the library default of 8 costs hundreds
// of MB for parallelism conversation diffs don't need.
poolSize: 2,
}),
[diffWorkerFactory],
);
Expand Down
21 changes: 19 additions & 2 deletions packages/ui/src/features/sessions/hooks/useSessionConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import type { Task } from "@posthog/shared/domain-types";
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore";
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
import { useUserPresence } from "@posthog/ui/hooks/useUserPresence";
import { logger } from "@posthog/ui/shell/logger";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { useChatTitleGenerator } from "./useChatTitleGenerator";

const log = logger.scope("session-connection");

interface UseSessionConnectionOptions {
taskId: string;
task: Task;
Expand All @@ -32,6 +36,11 @@ export function useSessionConnection({
const { isOnline } = useConnectivity();
const cloudAuthState = useAuthStateValue((state) => state);
const sessionService = useService<SessionService>(SESSION_SERVICE);
// A mounted view used to heartbeat (and auto-reconnect) forever, pinning a
// ~300-400MB agent process per visible task even when the user walked away.
// Presence-gate both so the workspace-server idle timeout can reclaim the
// agent; the existing idle-kill reconcile path restores it on return.
const userPresent = useUserPresence();

useChatTitleGenerator(task);

Expand Down Expand Up @@ -77,16 +86,23 @@ export function useSessionConnection({

useEffect(() => {
if (!taskRunId) return;
if (!userPresent) {
log.info("User away — pausing activity heartbeat", { taskRunId });
return;
}
return sessionService.startActivityHeartbeat(taskRunId);
}, [taskRunId, sessionService]);
}, [taskRunId, sessionService, userPresent]);

useEffect(() => {
return sessionService.reconcileTaskConnection({
task,
session: connectionSession,
repoPath,
isCloud,
isSuspended,
// While the user is away, freeze local reconciling too — otherwise the
// idle-kill auto-reconnect would respawn the agent process seconds
// after the server reclaimed it. Cloud reconcile ignores this flag.
isSuspended: isSuspended || !userPresent,
isOnline,
cloudAuth: {
status: cloudAuthState.status,
Expand All @@ -104,6 +120,7 @@ export function useSessionConnection({
repoPath,
isCloud,
isSuspended,
userPresent,
isOnline,
cloudAuthState.status,
cloudAuthState.bootstrapComplete,
Expand Down
18 changes: 15 additions & 3 deletions packages/ui/src/features/tasks/useTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ import { useAuthenticatedQuery } from "../../hooks/useAuthenticatedQuery";
import { useMeQuery } from "../auth/useMeQuery";
import { taskKeys } from "./taskKeys";

const TASK_LIST_POLL_INTERVAL_MS = 30_000;
// Full-task polls are heavy (~630KB per response at 100 tasks — descriptions
// and latest_run blobs included), and idle-poll churn was the app's largest
// memory/CPU drain. The sidebar's primary freshness comes from the slim
// summaries poll; full-task consumers are lookups where a minute of staleness
// is invisible.
const TASK_LIST_POLL_INTERVAL_MS = 60_000;
// Summaries are slim and drive the sidebar's live status — keep them fresh.
const TASK_SUMMARY_POLL_INTERVAL_MS = 30_000;
// A task's slack origin and thread URL are set at creation and never change;
// this poll only decorates rows with slack icons/links, so it mainly needs to
// notice new tasks. Full payloads across all users made this the single
// heaviest poll in the app (~2.2MB per response every 30s).
const SLACK_TASK_POLL_INTERVAL_MS = 5 * 60_000;

export function useTasks(
filters?: {
Expand Down Expand Up @@ -43,7 +55,7 @@ export function useTaskSummaries(
(client) => client.getTaskSummaries(ids),
{
enabled: (options?.enabled ?? true) && ids.length > 0,
refetchInterval: TASK_LIST_POLL_INTERVAL_MS,
refetchInterval: TASK_SUMMARY_POLL_INTERVAL_MS,
placeholderData: keepPreviousData,
},
);
Expand All @@ -67,7 +79,7 @@ export function useSlackTasks(options?: {
}) as unknown as Promise<Task[]>,
{
enabled: options?.enabled ?? true,
refetchInterval: TASK_LIST_POLL_INTERVAL_MS,
refetchInterval: SLACK_TASK_POLL_INTERVAL_MS,
},
);
}
11 changes: 11 additions & 0 deletions packages/ui/src/features/terminal/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ class TerminalManagerImpl {
this.loadWebglRenderer(instance);
} else if (instance.terminalElement) {
element.appendChild(instance.terminalElement);
// Detach dropped the WebGL renderer to free its GPU context; restore it
// now that the terminal is visible again.
this.loadWebglRenderer(instance);
instance.term.refresh(0, instance.term.rows - 1);
}

Expand Down Expand Up @@ -537,6 +540,14 @@ class TerminalManagerImpl {
getParkingContainer().appendChild(instance.terminalElement);
}

// A parked terminal renders nothing, but a live WebglAddon still holds a
// GPU context (Chromium also drops the oldest context past 16). Release
// it; attach() reloads it when the terminal becomes visible again.
if (instance.webglAddon) {
instance.webglAddon.dispose();
instance.webglAddon = null;
}

instance.attachedElement = null;
}

Expand Down
117 changes: 117 additions & 0 deletions packages/ui/src/hooks/useUserPresence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { USER_PRESENCE_IDLE_MS, useUserPresence } from "./useUserPresence";

const MINUTE = 60 * 1000;

describe("useUserPresence", () => {
beforeEach(() => {
vi.useFakeTimers();
// Input only counts while the window has focus; jsdom has no real focus.
vi.spyOn(document, "hasFocus").mockReturnValue(true);
});

afterEach(() => {
vi.useRealTimers();
});

it("starts present", () => {
const { result } = renderHook(() => useUserPresence());
expect(result.current).toBe(true);
});

it("flips to away after the idle threshold with no input", () => {
const { result } = renderHook(() => useUserPresence());

act(() => {
vi.advanceTimersByTime(USER_PRESENCE_IDLE_MS + MINUTE);
});

expect(result.current).toBe(false);
});

it("stays present while the user keeps interacting", () => {
const { result } = renderHook(() => useUserPresence());

act(() => {
for (let i = 0; i < 15; i++) {
vi.advanceTimersByTime(MINUTE);
window.dispatchEvent(new Event("pointermove"));
}
});

expect(result.current).toBe(true);
});

it("returns to present on interaction after going away", () => {
const { result } = renderHook(() => useUserPresence());

act(() => {
vi.advanceTimersByTime(USER_PRESENCE_IDLE_MS + MINUTE);
});
expect(result.current).toBe(false);

act(() => {
window.dispatchEvent(new Event("keydown"));
});

expect(result.current).toBe(true);
});

it("ignores input while the window is unfocused", () => {
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const { result } = renderHook(() => useUserPresence());

act(() => {
for (let i = 0; i < 11; i++) {
vi.advanceTimersByTime(MINUTE);
window.dispatchEvent(new Event("pointermove"));
}
});

expect(result.current).toBe(false);
});

it("respects a custom idle threshold", () => {
const { result } = renderHook(() => useUserPresence(2 * MINUTE));

act(() => {
vi.advanceTimersByTime(3 * MINUTE);
});

expect(result.current).toBe(false);
});

it("stays present under a sub-throttle idle threshold while interacting", () => {
// idleMs below the 15s activity throttle: the throttle must scale down or
// active input would be dropped and the user pinned "away".
const { result } = renderHook(() => useUserPresence(10_000));

act(() => {
for (let i = 0; i < 20; i++) {
vi.advanceTimersByTime(4_000);
window.dispatchEvent(new Event("keydown"));
}
});

expect(result.current).toBe(true);
});

it("removes listeners on unmount", () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const { unmount } = renderHook(() => useUserPresence());

unmount();

const removed = removeSpy.mock.calls.map(([event]) => event);
expect(removed).toEqual(
expect.arrayContaining([
"pointerdown",
"pointermove",
"keydown",
"wheel",
"focus",
]),
);
});
});
Loading
Loading