Skip to content

Commit f2f2609

Browse files
feat: Home Tab (#2474)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent d069600 commit f2f2609

62 files changed

Lines changed: 5687 additions & 133 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ out/
1111
storybook-static
1212
bin/
1313

14+
# tsup bundled config artifacts (temporary files left behind when bundling TS configs)
15+
*.config.bundled_*.mjs
1416

1517
# Environment
1618
.env

apps/code/src/main/di/container.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { FsService } from "../services/fs/service";
4646
import { GitService } from "../services/git/service";
4747
import { GitHubIntegrationService } from "../services/github-integration/service";
4848
import { HandoffService } from "../services/handoff/service";
49+
import { HomeService } from "../services/home/service";
4950
import { InboxLinkService } from "../services/inbox-link/service";
5051
import { LinearIntegrationService } from "../services/linear-integration/service";
5152
import { LlmGatewayService } from "../services/llm-gateway/service";
@@ -69,6 +70,7 @@ import { UIService } from "../services/ui/service";
6970
import { UpdatesService } from "../services/updates/service";
7071
import { UsageMonitorService } from "../services/usage-monitor/service";
7172
import { WatcherRegistryService } from "../services/watcher-registry/service";
73+
import { WorkflowService } from "../services/workflow/service";
7274
import { WorkspaceService } from "../services/workspace/service";
7375
import { MAIN_TOKENS } from "./tokens";
7476

@@ -153,6 +155,8 @@ container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);
153155
container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService);
154156
container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService);
155157
container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService);
158+
container.bind(MAIN_TOKENS.WorkflowService).to(WorkflowService);
159+
container.bind(MAIN_TOKENS.HomeService).to(HomeService);
156160
container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService);
157161

158162
container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore);

apps/code/src/main/di/tokens.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,6 @@ export const MAIN_TOKENS = Object.freeze({
8484
WorkspaceService: Symbol.for("Main.WorkspaceService"),
8585
EnrichmentService: Symbol.for("Main.EnrichmentService"),
8686
UsageMonitorService: Symbol.for("Main.UsageMonitorService"),
87+
WorkflowService: Symbol.for("Main.WorkflowService"),
88+
HomeService: Symbol.for("Main.HomeService"),
8789
});

apps/code/src/main/services/auth/service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,27 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
226226

227227
return response;
228228
}
229+
230+
/**
231+
* Authenticated fetch against a project-scoped PostHog endpoint
232+
* (`/api/projects/:projectId/<path>`). Throws if no project is selected.
233+
*/
234+
async authenticatedProjectFetch(
235+
path: string,
236+
init: RequestInit = {},
237+
): Promise<Response> {
238+
const { currentProjectId, cloudRegion } = this.getState();
239+
if (currentProjectId == null) {
240+
throw new Error("No PostHog project selected");
241+
}
242+
const apiHost = getCloudUrlFromRegion(cloudRegion ?? "us");
243+
return this.authenticatedFetch(
244+
fetch,
245+
`${apiHost}/api/projects/${currentProjectId}/${path}`,
246+
init,
247+
);
248+
}
249+
229250
async redeemInviteCode(code: string): Promise<AuthState> {
230251
const { apiHost } = await this.getValidAccessToken();
231252
const response = await this.authenticatedFetch(
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../../utils/logger", () => ({
4+
logger: {
5+
scope: () => ({
6+
info: vi.fn(),
7+
debug: vi.fn(),
8+
warn: vi.fn(),
9+
error: vi.fn(),
10+
}),
11+
},
12+
}));
13+
14+
import {
15+
EMPTY_HOME_SNAPSHOT,
16+
HomeEvent,
17+
type HomeSnapshot,
18+
} from "@shared/types/home-snapshot";
19+
import type { AuthService } from "../auth/service";
20+
import { HomeService } from "./service";
21+
22+
const POLL_INTERVAL_MS = 120_000;
23+
24+
const SNAP_WITH: HomeSnapshot = {
25+
activeAgents: [
26+
{
27+
taskId: "t1",
28+
title: "Fix bug",
29+
repoName: null,
30+
branch: null,
31+
status: "in_progress",
32+
lastActivityAt: 1,
33+
needsPermission: false,
34+
cloudPrUrl: null,
35+
},
36+
],
37+
needsAttention: [],
38+
inProgress: [],
39+
};
40+
41+
function res(status: number, body: unknown): Response {
42+
return {
43+
ok: status >= 200 && status < 300,
44+
status,
45+
json: async () => body,
46+
} as Response;
47+
}
48+
49+
const fetchMock = vi.fn();
50+
let projectId: string | null;
51+
const authService = {
52+
getState: () => ({ currentProjectId: projectId }),
53+
authenticatedProjectFetch: fetchMock,
54+
} as unknown as AuthService;
55+
56+
let service: HomeService;
57+
let events: HomeSnapshot[];
58+
59+
beforeEach(() => {
60+
fetchMock.mockReset();
61+
projectId = "proj_1";
62+
events = [];
63+
service = new HomeService(authService);
64+
service.on(HomeEvent.SnapshotUpdated, (s) => events.push(s));
65+
});
66+
67+
describe("HomeService.getSnapshot", () => {
68+
it("returns EMPTY_HOME_SNAPSHOT without fetching when no project is selected", async () => {
69+
projectId = null;
70+
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
71+
expect(fetchMock).not.toHaveBeenCalled();
72+
});
73+
74+
it("returns the parsed snapshot on a successful response", async () => {
75+
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
76+
await expect(service.getSnapshot()).resolves.toEqual(SNAP_WITH);
77+
expect(fetchMock).toHaveBeenCalledWith("code_home/", { method: "GET" });
78+
});
79+
80+
it("returns EMPTY_HOME_SNAPSHOT on a non-ok response", async () => {
81+
fetchMock.mockResolvedValue(res(503, {}));
82+
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
83+
});
84+
85+
it("returns EMPTY_HOME_SNAPSHOT when the body fails schema validation", async () => {
86+
fetchMock.mockResolvedValue(res(200, { bogus: true }));
87+
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
88+
});
89+
90+
it("returns EMPTY_HOME_SNAPSHOT when the fetch throws", async () => {
91+
fetchMock.mockRejectedValue(new Error("network down"));
92+
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
93+
});
94+
});
95+
96+
describe("HomeService.refresh", () => {
97+
it("POSTs the refresh endpoint then returns the latest snapshot", async () => {
98+
fetchMock
99+
.mockResolvedValueOnce(res(200, {}))
100+
.mockResolvedValueOnce(res(200, SNAP_WITH));
101+
await expect(service.refresh()).resolves.toEqual(SNAP_WITH);
102+
expect(fetchMock).toHaveBeenNthCalledWith(1, "code_home/refresh/", {
103+
method: "POST",
104+
});
105+
expect(fetchMock).toHaveBeenNthCalledWith(2, "code_home/", {
106+
method: "GET",
107+
});
108+
});
109+
110+
it("still returns a snapshot when the refresh POST fails", async () => {
111+
fetchMock
112+
.mockRejectedValueOnce(new Error("refresh failed"))
113+
.mockResolvedValueOnce(res(200, SNAP_WITH));
114+
await expect(service.refresh()).resolves.toEqual(SNAP_WITH);
115+
});
116+
});
117+
118+
describe("HomeService poll loop", () => {
119+
beforeEach(() => {
120+
vi.useFakeTimers();
121+
});
122+
123+
afterEach(() => {
124+
service.dispose();
125+
vi.useRealTimers();
126+
});
127+
128+
it("emits SnapshotUpdated when the snapshot changes between polls", async () => {
129+
fetchMock
130+
.mockResolvedValueOnce(res(200, SNAP_WITH))
131+
.mockResolvedValueOnce(res(200, EMPTY_HOME_SNAPSHOT));
132+
service.init();
133+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
134+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
135+
expect(events).toEqual([SNAP_WITH, EMPTY_HOME_SNAPSHOT]);
136+
});
137+
138+
it("does not re-emit an unchanged snapshot", async () => {
139+
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
140+
service.init();
141+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
142+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
143+
expect(events).toEqual([SNAP_WITH]);
144+
});
145+
146+
it("does not emit when a poll fails", async () => {
147+
fetchMock.mockResolvedValue(res(500, {}));
148+
service.init();
149+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
150+
expect(events).toEqual([]);
151+
});
152+
153+
it("getSnapshot seeds the dedup state so a matching first poll does not emit", async () => {
154+
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
155+
await service.getSnapshot();
156+
service.init();
157+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
158+
expect(events).toEqual([]);
159+
});
160+
161+
it("dispose stops the timer", async () => {
162+
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
163+
service.init();
164+
service.dispose();
165+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
166+
expect(fetchMock).not.toHaveBeenCalled();
167+
expect(events).toEqual([]);
168+
});
169+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
EMPTY_HOME_SNAPSHOT,
3+
HomeEvent,
4+
type HomeEvents,
5+
type HomeSnapshot,
6+
homeSnapshot,
7+
} from "@shared/types/home-snapshot";
8+
import { inject, injectable, postConstruct } from "inversify";
9+
import { MAIN_TOKENS } from "../../di/tokens";
10+
import { logger } from "../../utils/logger";
11+
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
12+
import type { AuthService } from "../auth/service";
13+
14+
const log = logger.scope("home");
15+
16+
const POLL_INTERVAL_MS = 120_000;
17+
18+
/**
19+
* Reads the per-user Home snapshot from PostHog. All grouping, PR polling, and
20+
* classification happen server-side in the `evaluate-code-workstreams` Temporal
21+
* worker; this service is a thin authenticated client + poll loop that emits
22+
* {@link HomeEvent.SnapshotUpdated} when the snapshot changes.
23+
*/
24+
@injectable()
25+
export class HomeService extends TypedEventEmitter<HomeEvents> {
26+
private timer: ReturnType<typeof setInterval> | null = null;
27+
private lastSerialized: string | null = null;
28+
29+
constructor(
30+
@inject(MAIN_TOKENS.AuthService)
31+
private readonly authService: AuthService,
32+
) {
33+
super();
34+
}
35+
36+
@postConstruct()
37+
init(): void {
38+
this.timer = setInterval(() => {
39+
void this.poll();
40+
}, POLL_INTERVAL_MS);
41+
}
42+
43+
dispose(): void {
44+
if (this.timer) {
45+
clearInterval(this.timer);
46+
this.timer = null;
47+
}
48+
}
49+
50+
async getSnapshot(): Promise<HomeSnapshot> {
51+
const snapshot = (await this.fetchSnapshot()) ?? EMPTY_HOME_SNAPSHOT;
52+
this.lastSerialized = JSON.stringify(snapshot);
53+
return snapshot;
54+
}
55+
56+
async refresh(): Promise<HomeSnapshot> {
57+
await this.requestServerRefresh();
58+
return this.getSnapshot();
59+
}
60+
61+
private async poll(): Promise<void> {
62+
const snapshot = await this.fetchSnapshot();
63+
if (!snapshot) return;
64+
const serialized = JSON.stringify(snapshot);
65+
if (serialized === this.lastSerialized) return;
66+
this.lastSerialized = serialized;
67+
this.emit(HomeEvent.SnapshotUpdated, snapshot);
68+
}
69+
70+
private async fetchSnapshot(): Promise<HomeSnapshot | null> {
71+
if (this.authService.getState().currentProjectId == null) return null;
72+
try {
73+
const res = await this.authService.authenticatedProjectFetch(
74+
"code_home/",
75+
{ method: "GET" },
76+
);
77+
if (!res.ok) {
78+
log.warn("Failed to fetch home snapshot", { status: res.status });
79+
return null;
80+
}
81+
const parsed = homeSnapshot.safeParse(await res.json());
82+
if (!parsed.success) {
83+
log.warn("Home snapshot failed schema validation", {
84+
error: parsed.error.message,
85+
});
86+
return null;
87+
}
88+
return parsed.data;
89+
} catch (err) {
90+
log.warn("Error fetching home snapshot", {
91+
error: err instanceof Error ? err.message : String(err),
92+
});
93+
return null;
94+
}
95+
}
96+
97+
private async requestServerRefresh(): Promise<void> {
98+
if (this.authService.getState().currentProjectId == null) return;
99+
try {
100+
await this.authService.authenticatedProjectFetch("code_home/refresh/", {
101+
method: "POST",
102+
});
103+
} catch (err) {
104+
log.warn("Error requesting home refresh", {
105+
error: err instanceof Error ? err.message : String(err),
106+
});
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)