-
Notifications
You must be signed in to change notification settings - Fork 37
feat: Home Tab #2474
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: Home Tab #2474
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
b16513c
feat: home tab
k11kirky 50ecc93
home tab changes
k11kirky 3ef4953
home tab redesign
k11kirky 3a44cb2
fix comment clean up
k11kirky 040fdaf
feat: migrate to posthog backend
k11kirky 56a1d57
fix: remove old references
k11kirky b015ddb
fix: code cleanup
k11kirky d4c69fa
put behind flag
k11kirky 72dd365
fix: remove committed tsup bundled config artifact
k11kirky 80a0bbd
Merge remote-tracking branch 'origin/main' into posthog-code/home-tab
k11kirky 888d35f
fix: code cleanup v2
k11kirky e6665f3
fix workflow save result schema
charlesvien 7910842
add tests for home workflow and board logic
charlesvien daaa1b3
gate workflow save button on validation errors
charlesvien 3b0e728
add home and workflow service tests
charlesvien 3ed4b12
guard cmd+s against concurrent workflow save
charlesvien 88bd950
extract createDefaultAction helper
charlesvien 0ea623d
reuse CiIndicator in workstream detail panel
charlesvien 68b614c
extract SidebarCountBadge component
charlesvien e45136b
remove dead NewTaskItem variant prop
charlesvien 7bdd9ab
memoize selected workstream, merge key effects
charlesvien 8d0d800
simplify action lookup in ConfigMap
charlesvien 1b07d12
document better-sqlite3 rebuild crash workaround
charlesvien File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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([]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| 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), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.