Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b16513c
feat: home tab
k11kirky May 29, 2026
50ecc93
home tab changes
k11kirky Jun 1, 2026
3ef4953
home tab redesign
k11kirky Jun 1, 2026
3a44cb2
fix comment clean up
k11kirky Jun 2, 2026
040fdaf
feat: migrate to posthog backend
k11kirky Jun 3, 2026
56a1d57
fix: remove old references
k11kirky Jun 3, 2026
b015ddb
fix: code cleanup
k11kirky Jun 5, 2026
d4c69fa
put behind flag
k11kirky Jun 5, 2026
72dd365
fix: remove committed tsup bundled config artifact
k11kirky Jun 5, 2026
80a0bbd
Merge remote-tracking branch 'origin/main' into posthog-code/home-tab
k11kirky Jun 6, 2026
888d35f
fix: code cleanup v2
k11kirky Jun 7, 2026
e6665f3
fix workflow save result schema
charlesvien Jun 9, 2026
7910842
add tests for home workflow and board logic
charlesvien Jun 9, 2026
daaa1b3
gate workflow save button on validation errors
charlesvien Jun 9, 2026
3b0e728
add home and workflow service tests
charlesvien Jun 9, 2026
3ed4b12
guard cmd+s against concurrent workflow save
charlesvien Jun 9, 2026
88bd950
extract createDefaultAction helper
charlesvien Jun 9, 2026
0ea623d
reuse CiIndicator in workstream detail panel
charlesvien Jun 9, 2026
68b614c
extract SidebarCountBadge component
charlesvien Jun 9, 2026
e45136b
remove dead NewTaskItem variant prop
charlesvien Jun 9, 2026
7bdd9ab
memoize selected workstream, merge key effects
charlesvien Jun 9, 2026
8d0d800
simplify action lookup in ConfigMap
charlesvien Jun 9, 2026
1b07d12
document better-sqlite3 rebuild crash workaround
charlesvien Jun 9, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ out/
storybook-static
bin/

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

# Environment
.env
Expand Down
4 changes: 4 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { FsService } from "../services/fs/service";
import { GitService } from "../services/git/service";
import { GitHubIntegrationService } from "../services/github-integration/service";
import { HandoffService } from "../services/handoff/service";
import { HomeService } from "../services/home/service";
import { InboxLinkService } from "../services/inbox-link/service";
import { LinearIntegrationService } from "../services/linear-integration/service";
import { LlmGatewayService } from "../services/llm-gateway/service";
Expand All @@ -69,6 +70,7 @@ import { UIService } from "../services/ui/service";
import { UpdatesService } from "../services/updates/service";
import { UsageMonitorService } from "../services/usage-monitor/service";
import { WatcherRegistryService } from "../services/watcher-registry/service";
import { WorkflowService } from "../services/workflow/service";
import { WorkspaceService } from "../services/workspace/service";
import { MAIN_TOKENS } from "./tokens";

Expand Down Expand Up @@ -153,6 +155,8 @@ container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);
container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService);
container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService);
container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService);
container.bind(MAIN_TOKENS.WorkflowService).to(WorkflowService);
container.bind(MAIN_TOKENS.HomeService).to(HomeService);
container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService);

container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore);
2 changes: 2 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,6 @@ export const MAIN_TOKENS = Object.freeze({
WorkspaceService: Symbol.for("Main.WorkspaceService"),
EnrichmentService: Symbol.for("Main.EnrichmentService"),
UsageMonitorService: Symbol.for("Main.UsageMonitorService"),
WorkflowService: Symbol.for("Main.WorkflowService"),
HomeService: Symbol.for("Main.HomeService"),
});
21 changes: 21 additions & 0 deletions apps/code/src/main/services/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,27 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {

return response;
}

/**
* Authenticated fetch against a project-scoped PostHog endpoint
* (`/api/projects/:projectId/<path>`). Throws if no project is selected.
*/
async authenticatedProjectFetch(
path: string,
init: RequestInit = {},
): Promise<Response> {
const { currentProjectId, cloudRegion } = this.getState();
if (currentProjectId == null) {
throw new Error("No PostHog project selected");
}
const apiHost = getCloudUrlFromRegion(cloudRegion ?? "us");
return this.authenticatedFetch(
fetch,
`${apiHost}/api/projects/${currentProjectId}/${path}`,
init,
);
}

async redeemInviteCode(code: string): Promise<AuthState> {
const { apiHost } = await this.getValidAccessToken();
const response = await this.authenticatedFetch(
Expand Down
169 changes: 169 additions & 0 deletions apps/code/src/main/services/home/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../utils/logger", () => ({
logger: {
scope: () => ({
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));

import {
EMPTY_HOME_SNAPSHOT,
HomeEvent,
type HomeSnapshot,
} from "@shared/types/home-snapshot";
import type { AuthService } from "../auth/service";
import { HomeService } from "./service";

const POLL_INTERVAL_MS = 120_000;

const SNAP_WITH: HomeSnapshot = {
activeAgents: [
{
taskId: "t1",
title: "Fix bug",
repoName: null,
branch: null,
status: "in_progress",
lastActivityAt: 1,
needsPermission: false,
cloudPrUrl: null,
},
],
needsAttention: [],
inProgress: [],
};

function res(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}

const fetchMock = vi.fn();
let projectId: string | null;
const authService = {
getState: () => ({ currentProjectId: projectId }),
authenticatedProjectFetch: fetchMock,
} as unknown as AuthService;

let service: HomeService;
let events: HomeSnapshot[];

beforeEach(() => {
fetchMock.mockReset();
projectId = "proj_1";
events = [];
service = new HomeService(authService);
service.on(HomeEvent.SnapshotUpdated, (s) => events.push(s));
});

describe("HomeService.getSnapshot", () => {
it("returns EMPTY_HOME_SNAPSHOT without fetching when no project is selected", async () => {
projectId = null;
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
expect(fetchMock).not.toHaveBeenCalled();
});

it("returns the parsed snapshot on a successful response", async () => {
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
await expect(service.getSnapshot()).resolves.toEqual(SNAP_WITH);
expect(fetchMock).toHaveBeenCalledWith("code_home/", { method: "GET" });
});

it("returns EMPTY_HOME_SNAPSHOT on a non-ok response", async () => {
fetchMock.mockResolvedValue(res(503, {}));
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
});

it("returns EMPTY_HOME_SNAPSHOT when the body fails schema validation", async () => {
fetchMock.mockResolvedValue(res(200, { bogus: true }));
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
});

it("returns EMPTY_HOME_SNAPSHOT when the fetch throws", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
await expect(service.getSnapshot()).resolves.toEqual(EMPTY_HOME_SNAPSHOT);
});
});

describe("HomeService.refresh", () => {
it("POSTs the refresh endpoint then returns the latest snapshot", async () => {
fetchMock
.mockResolvedValueOnce(res(200, {}))
.mockResolvedValueOnce(res(200, SNAP_WITH));
await expect(service.refresh()).resolves.toEqual(SNAP_WITH);
expect(fetchMock).toHaveBeenNthCalledWith(1, "code_home/refresh/", {
method: "POST",
});
expect(fetchMock).toHaveBeenNthCalledWith(2, "code_home/", {
method: "GET",
});
});

it("still returns a snapshot when the refresh POST fails", async () => {
fetchMock
.mockRejectedValueOnce(new Error("refresh failed"))
.mockResolvedValueOnce(res(200, SNAP_WITH));
await expect(service.refresh()).resolves.toEqual(SNAP_WITH);
});
});

describe("HomeService poll loop", () => {
beforeEach(() => {
vi.useFakeTimers();
});

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

it("emits SnapshotUpdated when the snapshot changes between polls", async () => {
fetchMock
.mockResolvedValueOnce(res(200, SNAP_WITH))
.mockResolvedValueOnce(res(200, EMPTY_HOME_SNAPSHOT));
service.init();
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(events).toEqual([SNAP_WITH, EMPTY_HOME_SNAPSHOT]);
});

it("does not re-emit an unchanged snapshot", async () => {
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
service.init();
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(events).toEqual([SNAP_WITH]);
});

it("does not emit when a poll fails", async () => {
fetchMock.mockResolvedValue(res(500, {}));
service.init();
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(events).toEqual([]);
});

it("getSnapshot seeds the dedup state so a matching first poll does not emit", async () => {
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
await service.getSnapshot();
service.init();
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(events).toEqual([]);
});

it("dispose stops the timer", async () => {
fetchMock.mockResolvedValue(res(200, SNAP_WITH));
service.init();
service.dispose();
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
expect(fetchMock).not.toHaveBeenCalled();
expect(events).toEqual([]);
});
});
109 changes: 109 additions & 0 deletions apps/code/src/main/services/home/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
EMPTY_HOME_SNAPSHOT,
HomeEvent,
type HomeEvents,
type HomeSnapshot,
homeSnapshot,
} from "@shared/types/home-snapshot";
import { inject, injectable, postConstruct } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import type { AuthService } from "../auth/service";

const log = logger.scope("home");

const POLL_INTERVAL_MS = 120_000;

/**
* Reads the per-user Home snapshot from PostHog. All grouping, PR polling, and
* classification happen server-side in the `evaluate-code-workstreams` Temporal
* worker; this service is a thin authenticated client + poll loop that emits
* {@link HomeEvent.SnapshotUpdated} when the snapshot changes.
*/
@injectable()
export class HomeService extends TypedEventEmitter<HomeEvents> {
private timer: ReturnType<typeof setInterval> | null = null;
private lastSerialized: string | null = null;

constructor(
@inject(MAIN_TOKENS.AuthService)
private readonly authService: AuthService,
) {
super();
}

@postConstruct()
init(): void {
this.timer = setInterval(() => {
void this.poll();
}, POLL_INTERVAL_MS);
}

dispose(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}

async getSnapshot(): Promise<HomeSnapshot> {
const snapshot = (await this.fetchSnapshot()) ?? EMPTY_HOME_SNAPSHOT;
this.lastSerialized = JSON.stringify(snapshot);
return snapshot;
}

async refresh(): Promise<HomeSnapshot> {
await this.requestServerRefresh();
return this.getSnapshot();
}

private async poll(): Promise<void> {
const snapshot = await this.fetchSnapshot();
Comment thread
k11kirky marked this conversation as resolved.
if (!snapshot) return;
const serialized = JSON.stringify(snapshot);
if (serialized === this.lastSerialized) return;
this.lastSerialized = serialized;
this.emit(HomeEvent.SnapshotUpdated, snapshot);
}

private async fetchSnapshot(): Promise<HomeSnapshot | null> {
if (this.authService.getState().currentProjectId == null) return null;
try {
const res = await this.authService.authenticatedProjectFetch(
"code_home/",
{ method: "GET" },
);
if (!res.ok) {
log.warn("Failed to fetch home snapshot", { status: res.status });
return null;
}
const parsed = homeSnapshot.safeParse(await res.json());
if (!parsed.success) {
log.warn("Home snapshot failed schema validation", {
error: parsed.error.message,
});
return null;
}
return parsed.data;
} catch (err) {
log.warn("Error fetching home snapshot", {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}

private async requestServerRefresh(): Promise<void> {
if (this.authService.getState().currentProjectId == null) return;
try {
await this.authService.authenticatedProjectFetch("code_home/refresh/", {
method: "POST",
});
} catch (err) {
log.warn("Error requesting home refresh", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
Loading
Loading