diff --git a/.gitignore b/.gitignore index 0d718003e2..adebb9f1f3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..cb633ee78c 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -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"; @@ -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"; @@ -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); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 69ea894b37..94851e469f 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -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"), }); diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index 4749898e09..18ba3d1f21 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -226,6 +226,27 @@ export class AuthService extends TypedEventEmitter { return response; } + + /** + * Authenticated fetch against a project-scoped PostHog endpoint + * (`/api/projects/:projectId/`). Throws if no project is selected. + */ + async authenticatedProjectFetch( + path: string, + init: RequestInit = {}, + ): Promise { + 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 { const { apiHost } = await this.getValidAccessToken(); const response = await this.authenticatedFetch( diff --git a/apps/code/src/main/services/home/service.test.ts b/apps/code/src/main/services/home/service.test.ts new file mode 100644 index 0000000000..328b548613 --- /dev/null +++ b/apps/code/src/main/services/home/service.test.ts @@ -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([]); + }); +}); diff --git a/apps/code/src/main/services/home/service.ts b/apps/code/src/main/services/home/service.ts new file mode 100644 index 0000000000..cb14a54847 --- /dev/null +++ b/apps/code/src/main/services/home/service.ts @@ -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 { + private timer: ReturnType | 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 { + const snapshot = (await this.fetchSnapshot()) ?? EMPTY_HOME_SNAPSHOT; + this.lastSerialized = JSON.stringify(snapshot); + return snapshot; + } + + async refresh(): Promise { + await this.requestServerRefresh(); + return this.getSnapshot(); + } + + private async poll(): Promise { + 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 { + 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 { + 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), + }); + } + } +} diff --git a/apps/code/src/main/services/workflow/service.test.ts b/apps/code/src/main/services/workflow/service.test.ts new file mode 100644 index 0000000000..e2c3e7ce59 --- /dev/null +++ b/apps/code/src/main/services/workflow/service.test.ts @@ -0,0 +1,141 @@ +import { 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 { + type SaveInput, + type WorkflowConfig, + WorkflowEvent, +} from "@shared/types/workflow"; +import type { AuthService } from "../auth/service"; +import { WorkflowService } from "./service"; + +const BINDINGS = { + working: [], + in_review: [], + ci_failing: [], + changes_requested: [], + comments_waiting: [], + ready_to_merge: [], + stale: [], + done: [], +}; + +const CONFIG: WorkflowConfig = { + id: "wf_1", + version: 2, + updatedAt: "2026-01-01T00:00:00Z", + bindings: BINDINGS, +}; + +const SAVE_INPUT: SaveInput = { + config: { id: "wf_1", version: 2, bindings: BINDINGS }, + expectedVersion: 2, +}; + +function res(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +const fetchMock = vi.fn(); +const authService = { + authenticatedProjectFetch: fetchMock, +} as unknown as AuthService; + +let service: WorkflowService; +let changed: WorkflowConfig[]; + +beforeEach(() => { + fetchMock.mockReset(); + changed = []; + service = new WorkflowService(authService); + service.on(WorkflowEvent.Changed, (c) => changed.push(c)); +}); + +describe("WorkflowService.get", () => { + it("returns the parsed config", async () => { + fetchMock.mockResolvedValue(res(200, CONFIG)); + await expect(service.get()).resolves.toEqual(CONFIG); + expect(fetchMock).toHaveBeenCalledWith("code_workflow/", { method: "GET" }); + }); + + it("throws on a 500 response", async () => { + fetchMock.mockResolvedValue(res(500, {})); + await expect(service.get()).rejects.toThrow("Workflow request failed: 500"); + }); +}); + +describe("WorkflowService.save", () => { + it("emits Changed and returns the config on a saved result", async () => { + fetchMock.mockResolvedValue(res(200, { status: "saved", config: CONFIG })); + await expect(service.save(SAVE_INPUT)).resolves.toEqual({ + status: "saved", + config: CONFIG, + }); + expect(changed).toEqual([CONFIG]); + }); + + it("sends the config and expectedVersion as a JSON POST body", async () => { + fetchMock.mockResolvedValue(res(200, { status: "saved", config: CONFIG })); + await service.save(SAVE_INPUT); + expect(fetchMock).toHaveBeenCalledWith("code_workflow/save/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: SAVE_INPUT.config, + expectedVersion: SAVE_INPUT.expectedVersion, + }), + }); + }); + + it("does not emit on a 409 conflict that omits config", async () => { + fetchMock.mockResolvedValue(res(409, { status: "conflict" })); + await expect(service.save(SAVE_INPUT)).resolves.toEqual({ + status: "conflict", + }); + expect(changed).toEqual([]); + }); + + it("does not emit on a 422 invalid result and surfaces diagnostics", async () => { + const diagnostics = [ + { severity: "error", code: "action_empty_prompt", message: "empty" }, + ]; + fetchMock.mockResolvedValue(res(422, { status: "invalid", diagnostics })); + await expect(service.save(SAVE_INPUT)).resolves.toEqual({ + status: "invalid", + diagnostics, + }); + expect(changed).toEqual([]); + }); +}); + +describe("WorkflowService.resetToDefault", () => { + it("emits Changed and returns the config", async () => { + fetchMock.mockResolvedValue(res(200, CONFIG)); + await expect(service.resetToDefault()).resolves.toEqual(CONFIG); + expect(changed).toEqual([CONFIG]); + expect(fetchMock).toHaveBeenCalledWith("code_workflow/reset/", { + method: "POST", + }); + }); + + it("throws on a non-ok response", async () => { + fetchMock.mockResolvedValue(res(500, {})); + await expect(service.resetToDefault()).rejects.toThrow( + "Workflow request failed: 500", + ); + }); +}); diff --git a/apps/code/src/main/services/workflow/service.ts b/apps/code/src/main/services/workflow/service.ts new file mode 100644 index 0000000000..b9ba520de4 --- /dev/null +++ b/apps/code/src/main/services/workflow/service.ts @@ -0,0 +1,77 @@ +import { + type SaveInput, + type SaveResult, + saveResult, + type WorkflowConfig, + WorkflowEvent, + type WorkflowEvents, + workflowConfig, +} from "@shared/types/workflow"; +import { inject, injectable } 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("workflow"); + +/** + * Reads and writes the user's Home workflow config from PostHog + * (`/api/projects/:id/code_workflow/`). The server owns persistence, the + * monotonic `version`, optimistic concurrency, validation, and the default + * seed; this service is a thin authenticated client that emits + * {@link WorkflowEvent.Changed} on save/reset. + */ +@injectable() +export class WorkflowService extends TypedEventEmitter { + constructor( + @inject(MAIN_TOKENS.AuthService) + private readonly authService: AuthService, + ) { + super(); + } + + async get(): Promise { + const json = await this.request("GET", "code_workflow/"); + return workflowConfig.parse(json); + } + + async save(input: SaveInput): Promise { + const json = await this.request("POST", "code_workflow/save/", { + config: input.config, + expectedVersion: input.expectedVersion, + }); + const parsed = saveResult.parse(json); + if (parsed.status === "saved") { + this.emit(WorkflowEvent.Changed, parsed.config); + log.info("Workflow saved", { version: parsed.config.version }); + } + return parsed; + } + + async resetToDefault(): Promise { + const json = await this.request("POST", "code_workflow/reset/"); + const config = workflowConfig.parse(json); + this.emit(WorkflowEvent.Changed, config); + log.info("Workflow reset to default", { version: config.version }); + return config; + } + + private async request( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const init: RequestInit = { method }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + const res = await this.authService.authenticatedProjectFetch(path, init); + // 409/422 carry a structured SaveResult body the caller validates. + if (!res.ok && res.status !== 409 && res.status !== 422) { + throw new Error(`Workflow request failed: ${res.status}`); + } + return res.json(); + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f0f8dd9eb5..75c3acdd0a 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -18,6 +18,7 @@ import { fsRouter } from "./routers/fs"; import { gitRouter } from "./routers/git"; import { githubIntegrationRouter } from "./routers/github-integration"; import { handoffRouter } from "./routers/handoff"; +import { homeRouter } from "./routers/home"; import { linearIntegrationRouter } from "./routers/linear-integration.js"; import { llmGatewayRouter } from "./routers/llm-gateway"; import { logsRouter } from "./routers/logs"; @@ -37,6 +38,7 @@ import { suspensionRouter } from "./routers/suspension.js"; import { uiRouter } from "./routers/ui"; import { updatesRouter } from "./routers/updates"; import { usageMonitorRouter } from "./routers/usage-monitor"; +import { workflowRouter } from "./routers/workflow"; import { workspaceRouter } from "./routers/workspace"; import { router } from "./trpc"; @@ -61,6 +63,7 @@ export const trpcRouter = router({ git: gitRouter, githubIntegration: githubIntegrationRouter, handoff: handoffRouter, + home: homeRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, mcpApps: mcpAppsRouter, @@ -81,6 +84,7 @@ export const trpcRouter = router({ updates: updatesRouter, usageMonitor: usageMonitorRouter, deepLink: deepLinkRouter, + workflow: workflowRouter, workspace: workspaceRouter, }); diff --git a/apps/code/src/main/trpc/routers/home.ts b/apps/code/src/main/trpc/routers/home.ts new file mode 100644 index 0000000000..a6e9990c64 --- /dev/null +++ b/apps/code/src/main/trpc/routers/home.ts @@ -0,0 +1,31 @@ +import { + HomeEvent, + type HomeEvents, + homeSnapshot, +} from "@shared/types/home-snapshot"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { HomeService } from "../../services/home/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => container.get(MAIN_TOKENS.HomeService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const homeRouter = router({ + getSnapshot: publicProcedure + .output(homeSnapshot) + .query(() => getService().getSnapshot()), + refresh: publicProcedure + .output(homeSnapshot) + .mutation(() => getService().refresh()), + onSnapshotUpdated: subscribe(HomeEvent.SnapshotUpdated), +}); diff --git a/apps/code/src/main/trpc/routers/workflow.ts b/apps/code/src/main/trpc/routers/workflow.ts new file mode 100644 index 0000000000..fdb4420e9c --- /dev/null +++ b/apps/code/src/main/trpc/routers/workflow.ts @@ -0,0 +1,36 @@ +import { + saveInput, + saveResult, + WorkflowEvent, + type WorkflowEvents, + workflowConfig, +} from "@shared/types/workflow"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { WorkflowService } from "../../services/workflow/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.WorkflowService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const workflowRouter = router({ + get: publicProcedure.output(workflowConfig).query(() => getService().get()), + save: publicProcedure + .input(saveInput) + .output(saveResult) + .mutation(({ input }) => getService().save(input)), + resetToDefault: publicProcedure + .output(workflowConfig) + .mutation(() => getService().resetToDefault()), + onChanged: subscribe(WorkflowEvent.Changed), +}); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index c6277ab875..3335551a17 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -13,6 +13,7 @@ import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; import { registerBillingSubscriptions } from "@features/billing/subscriptions"; import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; +import { registerHomeSubscriptions } from "@features/home/subscriptions"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { Flex, Spinner, Text } from "@radix-ui/themes"; @@ -76,6 +77,11 @@ function App() { return registerBillingSubscriptions(); }, [isAuthenticated]); + useEffect(() => { + if (!isAuthenticated) return; + return registerHomeSubscriptions(); + }, [isAuthenticated]); + // Initialize update store useEffect(() => { return initializeUpdateStore(); diff --git a/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx b/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx new file mode 100644 index 0000000000..8668aff499 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx @@ -0,0 +1,109 @@ +import { openTask } from "@hooks/useOpenTask"; +import { CircleNotch, GitBranch, Warning } from "@phosphor-icons/react"; +import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useTasks } from "@renderer/features/tasks/hooks/useTasks"; +import type { HomeActiveAgent } from "@shared/types/home-snapshot"; +import { formatRelativeTimeShort } from "@utils/time"; +import { useMemo } from "react"; + +interface HomeActiveAgentsStripProps { + agents: HomeActiveAgent[]; +} + +export function HomeActiveAgentsStrip({ agents }: HomeActiveAgentsStripProps) { + const { data: tasks = [] } = useTasks(); + const taskById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); + + if (agents.length === 0) return null; + + return ( + + + + Running + + {agents.length} + + + + + {agents.map((agent) => { + const task = taskById.get(agent.taskId); + const dotColor = agent.needsPermission + ? "var(--amber-9)" + : agent.status === "queued" + ? "var(--gray-8)" + : "var(--green-9)"; + return ( + + ); + })} + + + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeBoardView.tsx b/apps/code/src/renderer/features/home/components/HomeBoardView.tsx new file mode 100644 index 0000000000..3abf84f16a --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeBoardView.tsx @@ -0,0 +1,82 @@ +import { ScrollArea } from "@radix-ui/themes"; +import type { HomeSnapshot } from "@shared/types/home-snapshot"; +import type { SituationId } from "@shared/types/workflow"; +import { useMemo } from "react"; +import { buildBoardColumns, type HomeBoardColumn } from "../utils/boardColumns"; +import { SITUATION_VISUAL, situationCss } from "../utils/situationDisplay"; +import { HomeWorkstreamCard } from "./HomeWorkstreamCard"; + +interface HomeBoardViewProps { + snapshot: HomeSnapshot; +} + +export function HomeBoardView({ snapshot }: HomeBoardViewProps) { + const columns = useMemo( + () => buildBoardColumns(snapshot.needsAttention, snapshot.inProgress), + [snapshot.needsAttention, snapshot.inProgress], + ); + + return ( + +
+ {columns.map((column) => ( + + ))} +
+
+ ); +} + +function BoardColumn({ column }: { column: HomeBoardColumn }) { + const v = SITUATION_VISUAL[column.id]; + const c = situationCss(v.color); + const Icon = v.Icon; + const count = column.workstreams.length; + + return ( +
+
+ + + + + {v.label} + + + {count} + +
+ +
+ +
+ {count === 0 ? ( + + ) : ( + column.workstreams.map((ws) => ( + + )) + )} +
+
+
+
+ ); +} + +function EmptyColumn({ sid }: { sid: SituationId }) { + const v = SITUATION_VISUAL[sid]; + const Icon = v.Icon; + return ( +
+ + Nothing here +
+ ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx b/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx new file mode 100644 index 0000000000..46e64140cb --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx @@ -0,0 +1,42 @@ +import { openTaskInput } from "@hooks/useOpenTask"; +import { CheckCircle, Plus } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex, Text } from "@radix-ui/themes"; + +interface HomeEmptyStateProps { + hasRunningAgents: boolean; +} + +export function HomeEmptyState({ hasRunningAgents }: HomeEmptyStateProps) { + return ( + + + + + + You're caught up + + + {hasRunningAgents + ? "Nothing else needs your attention. Your active agents are working." + : "Nothing needs your attention right now. Start something new when you're ready."} + + {!hasRunningAgents ? ( + + ) : null} + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeView.tsx b/apps/code/src/renderer/features/home/components/HomeView.tsx new file mode 100644 index 0000000000..a6e38020e0 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeView.tsx @@ -0,0 +1,293 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { + CircleHalf, + Graph, + House, + Kanban, + ListBullets, + Warning, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useEffect, useMemo } from "react"; +import { ConfigMap } from "../config/ConfigMap"; +import { useHomeSnapshot } from "../hooks/useHomeSnapshot"; +import { type HomeViewMode, useHomeUiStore } from "../stores/homeUiStore"; +import { HomeActiveAgentsStrip } from "./HomeActiveAgentsStrip"; +import { HomeBoardView } from "./HomeBoardView"; +import { HomeEmptyState } from "./HomeEmptyState"; +import { HomeWorkstreamDetailPanel } from "./HomeWorkstreamDetailPanel"; +import { HomeWorkstreamRow } from "./HomeWorkstreamRow"; + +const VIEW_CYCLE: HomeViewMode[] = ["list", "board", "config"]; + +const HEADER_CONTENT = ( + + + + Home + + +); + +export function HomeView() { + const { snapshot, isLoading } = useHomeSnapshot(); + const viewMode = useHomeUiStore((s) => s.viewMode); + const setViewMode = useHomeUiStore((s) => s.setViewMode); + const selectedWorkstreamId = useHomeUiStore((s) => s.selectedWorkstreamId); + const setSelectedWorkstreamId = useHomeUiStore( + (s) => s.setSelectedWorkstreamId, + ); + + useSetHeaderContent(HEADER_CONTENT); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + if (selectedWorkstreamId) setSelectedWorkstreamId(null); + return; + } + if (e.key !== "v" || e.metaKey || e.ctrlKey || e.altKey) return; + // Don't capture `v` while the user is typing. + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + const idx = VIEW_CYCLE.indexOf(viewMode); + setViewMode(VIEW_CYCLE[(idx + 1) % VIEW_CYCLE.length] ?? "list"); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [viewMode, setViewMode, selectedWorkstreamId, setSelectedWorkstreamId]); + + const { activeAgents, needsAttention, inProgress } = snapshot; + const selectedWorkstream = useMemo( + () => + selectedWorkstreamId + ? (needsAttention.find((ws) => ws.id === selectedWorkstreamId) ?? + inProgress.find((ws) => ws.id === selectedWorkstreamId) ?? + null) + : null, + [selectedWorkstreamId, needsAttention, inProgress], + ); + + if (isLoading) { + return ( + + + + ); + } + + const totalRows = needsAttention.length + inProgress.length; + const hasContent = activeAgents.length > 0 || totalRows > 0; + + return ( + + + + + + + Home + + + {hasContent ? ( + + {needsAttention.length > 0 ? ( + + ) : null} + {activeAgents.length > 0 ? ( + + ) : null} + {inProgress.length > 0 ? ( + + ) : null} + + ) : ( + + You're caught up + + )} + + + + + + + + {viewMode === "config" ? ( + + + + ) : ( + <> + + + + {!hasContent ? ( + 0} /> + ) : viewMode === "board" ? ( + + + + ) : ( + + {needsAttention.length > 0 ? ( +
+ } + count={needsAttention.length} + > + {needsAttention.map((ws) => ( + + ))} +
+ ) : null} + + {inProgress.length > 0 ? ( +
+ } + count={inProgress.length} + > + {inProgress.map((ws) => ( + + ))} +
+ ) : null} + + {totalRows === 0 && activeAgents.length > 0 ? ( + + ) : null} +
+ )} +
+ {selectedWorkstream ? ( + + setSelectedWorkstreamId(null)} + /> + + ) : null} +
+ + )} +
+ ); +} + +interface SectionProps { + title: string; + count: number; + icon?: React.ReactNode; + children: React.ReactNode; +} + +interface ViewModeToggleProps { + value: HomeViewMode; + onChange: (next: HomeViewMode) => void; +} + +function ViewModeToggle({ value, onChange }: ViewModeToggleProps) { + return ( + + + + + + ); +} + +function Stat({ + color, + label, + pulse = false, +}: { + color: string; + label: string; + pulse?: boolean; +}) { + return ( + + + {label} + + ); +} + +function Section({ title, count, icon, children }: SectionProps) { + return ( + + + {icon} + {title} + + {count} + + + {children} + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx b/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx new file mode 100644 index 0000000000..6cedf4a81e --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx @@ -0,0 +1,202 @@ +import { + GitBranch, + GitPullRequest, + Sparkle, + Warning, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { HomeWorkstream } from "@shared/types/home-snapshot"; +import { formatRelativeTimeShort } from "@utils/time"; +import { useWorkstreamPresentation } from "../hooks/useWorkstreamPresentation"; +import { useHomeUiStore } from "../stores/homeUiStore"; +import { SITUATION_VISUAL } from "../utils/situationDisplay"; +import { SituationChip } from "./SituationChip"; +import { + AuthorAvatar, + CiIndicator, + type MetaItem, + MetaList, + WorkstreamOverflowMenu, +} from "./WorkstreamBits"; + +interface HomeWorkstreamCardProps { + workstream: HomeWorkstream; +} + +export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { + const { + pr, + title, + primarySid, + accent, + author, + extraSituations, + needsPermission, + primaryBound, + restBound, + primaryIsPr, + primaryIsTask, + showPrInMenu, + showTaskInMenu, + hasMenu, + runAction, + openTask, + openPr, + } = useWorkstreamPresentation(workstream); + const setSelectedWorkstreamId = useHomeUiStore( + (s) => s.setSelectedWorkstreamId, + ); + const isSelected = useHomeUiStore( + (s) => s.selectedWorkstreamId === workstream.id, + ); + + const taskCount = workstream.tasks.length; + const primary = SITUATION_VISUAL[primarySid]; + const PrimaryIcon = primary.Icon; + + const meta: MetaItem[] = []; + if (workstream.branch) { + meta.push({ + key: "branch", + node: ( + + + + {workstream.branch} + + + ), + }); + } + if (pr) { + meta.push({ key: "pr", node: #{pr.number} }); + } + if (needsPermission) { + meta.push({ + key: "perm", + node: ( + + + Awaiting permission + + ), + }); + } + meta.push({ + key: "time", + node: {formatRelativeTimeShort(workstream.lastActivityAt)}, + }); + + return ( + setSelectedWorkstreamId(workstream.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedWorkstreamId(workstream.id); + } + }} + role="button" + tabIndex={0} + aria-label={`Open ${title}`} + className="group hover:-translate-y-px relative flex cursor-pointer flex-col gap-2 overflow-hidden rounded-lg border border-(--gray-4) bg-(--color-panel-solid) px-3 pt-3 pb-2.5 transition-all hover:border-(--gray-7) hover:shadow-md" + style={ + isSelected + ? { + borderColor: "var(--accent-8)", + boxShadow: "0 0 0 1px var(--accent-8)", + } + : undefined + } + > + + +
+ + + {primary.label} + +
+ {author ? : null} + {pr ? : null} +
+
+ + + {title} + + + {extraSituations.length > 0 ? ( +
+ {extraSituations.map((sid) => ( + + ))} +
+ ) : null} + + + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {primaryBound ? ( + + ) : primaryIsPr ? ( + + ) : primaryIsTask ? ( + + ) : ( + + )} + +
+ {taskCount > 1 ? ( + + {taskCount} tasks + + ) : null} + {hasMenu ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx b/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx new file mode 100644 index 0000000000..252ea2adc3 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx @@ -0,0 +1,343 @@ +import { openTask } from "@hooks/useOpenTask"; +import { + ArrowSquareOut, + CaretDown, + CaretRight, + ChatCircle, + CheckCircle, + CircleDashed, + GitBranch, + GitPullRequest, + Sparkle, + Spinner, + Warning, + X, + XCircle, +} from "@phosphor-icons/react"; +import { Badge, Button } from "@posthog/quill"; +import { Box, DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import { useTasks } from "@renderer/features/tasks/hooks/useTasks"; +import type { TaskRunStatus } from "@shared/types"; +import type { + HomeWorkstream, + HomeWorkstreamTask, +} from "@shared/types/home-snapshot"; +import type { PrSnapshot } from "@shared/types/pr-snapshot"; +import { openUrlInBrowser } from "@utils/browser"; +import { formatRelativeTimeShort } from "@utils/time"; +import { type BoundAction, useBoundActions } from "../hooks/useBoundActions"; +import { useRunWorkstreamAction } from "../hooks/useRunWorkstreamAction"; +import { SituationChip } from "./SituationChip"; +import { CiIndicator } from "./WorkstreamBits"; + +interface Props { + workstream: HomeWorkstream; + onClose: () => void; +} + +export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { + const { data: allTasks = [] } = useTasks(); + const boundActions = useBoundActions(workstream); + const runAction = useRunWorkstreamAction(); + + const pr = workstream.pr; + const headTask = workstream.tasks[0]; + const title = + pr?.title ?? headTask?.title ?? workstream.branch ?? "Workstream"; + + function handleOpenTask(task: HomeWorkstreamTask) { + const found = allTasks.find((t) => t.id === task.id); + if (found) void openTask(found); + } + + function handleOpenPr() { + if (workstream.prUrl) void openUrlInBrowser(workstream.prUrl); + } + + const primaryAction = boundActions[0] ?? null; + const overflowActions = boundActions.slice(1); + + return ( + + {/* Header */} + + + + {title} + + + {workstream.repoName ? {workstream.repoName} : null} + {workstream.branch ? ( + + + + {workstream.branch} + + + ) : null} + {pr ? #{pr.number} : null} + {formatRelativeTimeShort(workstream.lastActivityAt)} + + + + + +
+ + {workstream.situations.length > 0 ? ( +
+ + {workstream.situations.map((sid) => ( + + ))} + +
+ ) : null} + + {pr ? : null} + + {boundActions.length > 0 ? ( +
+ + {primaryAction ? ( + + ) : null} + {overflowActions.length > 0 ? ( + + + + + + {overflowActions.map((action: BoundAction) => ( + runAction(action, workstream)} + > + + {action.label} + + {action.situationLabel} + + + ))} + + + ) : null} + +
+ ) : null} + +
+ + {workstream.tasks.map((task) => ( + handleOpenTask(task)} + /> + ))} + +
+
+
+ + {/* Footer links */} + {workstream.prUrl ? ( + + + + ) : null} +
+ ); +} + +interface SectionProps { + title: string; + subtitle?: string; + children: React.ReactNode; +} + +function Section({ title, subtitle, children }: SectionProps) { + return ( + + + + {title} + + {subtitle ? ( + {subtitle} + ) : null} + + {children} + + ); +} + +function PrBlock({ pr, onOpen }: { pr: PrSnapshot; onOpen: () => void }) { + return ( +
+ + + + + + {pr.reviewDecision === "approved" ? ( + + + Approved + + ) : null} + {pr.reviewDecision === "changes_requested" ? ( + + + Changes requested + + ) : null} + {pr.unresolvedThreads > 0 ? ( + + + + {pr.unresolvedThreads} unresolved review thread + {pr.unresolvedThreads === 1 ? "" : "s"} + + + ) : null} + {pr.author && !pr.isCurrentUserAuthor ? ( + by @{pr.author} + ) : null} + + +
+ ); +} + +function PrStatePill({ pr }: { pr: PrSnapshot }) { + if (pr.state === "merged") return Merged; + if (pr.state === "draft") return Draft; + if (pr.state === "closed") return Closed; + return Open; +} + +function TaskRow({ + task, + onClick, +}: { + task: HomeWorkstreamTask; + onClick: () => void; +}) { + return ( + + ); +} + +function TaskStatusIcon({ + status, + isGenerating, +}: { + status: TaskRunStatus | undefined; + isGenerating: boolean; +}) { + if (isGenerating || status === "in_progress" || status === "queued") { + return ( + + ); + } + if (status === "completed") { + return ( + + ); + } + if (status === "failed") { + return ( + + ); + } + return ; +} diff --git a/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx b/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx new file mode 100644 index 0000000000..433fbb8a7f --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx @@ -0,0 +1,194 @@ +import { + ChatCircle, + GitBranch, + GitPullRequest, + Sparkle, + Warning, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { HomeWorkstream } from "@shared/types/home-snapshot"; +import { formatRelativeTimeShort } from "@utils/time"; +import { useWorkstreamPresentation } from "../hooks/useWorkstreamPresentation"; +import { useHomeUiStore } from "../stores/homeUiStore"; +import { SituationChip } from "./SituationChip"; +import { + AuthorAvatar, + CiIndicator, + type MetaItem, + MetaList, + StatusGlyph, + WorkstreamOverflowMenu, +} from "./WorkstreamBits"; + +interface HomeWorkstreamRowProps { + workstream: HomeWorkstream; +} + +export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { + const { + pr, + title, + primarySid, + accent, + author, + extraSituations, + generating, + needsPermission, + primaryBound, + restBound, + primaryIsPr, + primaryIsTask, + showPrInMenu, + showTaskInMenu, + hasMenu, + runAction, + openTask, + openPr, + } = useWorkstreamPresentation(workstream); + const setSelectedWorkstreamId = useHomeUiStore( + (s) => s.setSelectedWorkstreamId, + ); + const isSelected = useHomeUiStore( + (s) => s.selectedWorkstreamId === workstream.id, + ); + + const meta: MetaItem[] = []; + if (workstream.repoName) { + meta.push({ + key: "repo", + node: {workstream.repoName}, + }); + } + if (workstream.branch) { + meta.push({ + key: "branch", + node: ( + + + + {workstream.branch} + + + ), + }); + } + if (pr) { + meta.push({ key: "pr", node: #{pr.number} }); + } + if (pr && pr.ciStatus !== "passing" && pr.ciStatus !== "none") { + meta.push({ + key: "ci", + node: , + }); + } + if (needsPermission) { + meta.push({ + key: "perm", + node: ( + + + Awaiting permission + + ), + }); + } + if (generating) { + meta.push({ + key: "gen", + node: ( + + + Generating + + ), + }); + } + + return ( + setSelectedWorkstreamId(workstream.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedWorkstreamId(workstream.id); + } + }} + role="button" + tabIndex={0} + aria-label={`Open ${title}`} + className="group relative flex cursor-pointer items-center gap-3 border-(--gray-3) border-b py-2.5 pr-3 pl-4 transition-colors hover:bg-(--gray-2)" + style={isSelected ? { backgroundColor: "var(--accent-a3)" } : undefined} + > + + + + +
+
+ + {title} + + {extraSituations.map((sid) => ( + + ))} +
+ +
+ + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {author ? : null} + + {formatRelativeTimeShort(workstream.lastActivityAt)} + + +
+ {primaryBound ? ( + + ) : primaryIsPr ? ( + + ) : primaryIsTask ? ( + + ) : null} + + {hasMenu ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/code/src/renderer/features/home/components/SituationChip.tsx b/apps/code/src/renderer/features/home/components/SituationChip.tsx new file mode 100644 index 0000000000..2f1034c9dc --- /dev/null +++ b/apps/code/src/renderer/features/home/components/SituationChip.tsx @@ -0,0 +1,24 @@ +import type { SituationId } from "@shared/types/workflow"; +import { SITUATION_VISUAL, situationCss } from "../utils/situationDisplay"; + +interface Props { + sid: SituationId; + /** Hide the leading glyph (e.g. when the chip sits next to a status icon). */ + showIcon?: boolean; +} + +export function SituationChip({ sid, showIcon = true }: Props) { + const v = SITUATION_VISUAL[sid]; + const c = situationCss(v.color); + const Icon = v.Icon; + return ( + + {showIcon ? : null} + {v.label} + + ); +} diff --git a/apps/code/src/renderer/features/home/components/WorkstreamBits.tsx b/apps/code/src/renderer/features/home/components/WorkstreamBits.tsx new file mode 100644 index 0000000000..0b7fbb667f --- /dev/null +++ b/apps/code/src/renderer/features/home/components/WorkstreamBits.tsx @@ -0,0 +1,217 @@ +import { + ArrowSquareOut, + CheckCircle, + CircleNotch, + DotsThree, + GitPullRequest, + Sparkle, + XCircle, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { DropdownMenu, Text } from "@radix-ui/themes"; +import type { PrCiStatus } from "@shared/types/pr-snapshot"; +import type { SituationId } from "@shared/types/workflow"; +import { Fragment } from "react"; +import type { BoundAction } from "../hooks/useBoundActions"; +import { SITUATION_VISUAL, situationCss } from "../utils/situationDisplay"; + +/** The tinted square status tile that leads every row / card, glyphed by primary situation. */ +export function StatusGlyph({ + sid, + size = 30, +}: { + sid: SituationId; + size?: number; +}) { + const v = SITUATION_VISUAL[sid]; + const c = situationCss(v.color); + const Icon = v.Icon; + return ( + + + + ); +} + +export function StatusDot({ sid }: { sid: SituationId }) { + const c = situationCss(SITUATION_VISUAL[sid].color); + return ( + + ); +} + +/** Compact CI signal — icon-only by default, optional inline label. */ +export function CiIndicator({ + status, + showLabel = false, +}: { + status: PrCiStatus; + showLabel?: boolean; +}) { + if (status === "none") return null; + if (status === "passing") { + return ( + + + {showLabel ? CI passing : null} + + ); + } + if (status === "failing") { + return ( + + + {showLabel ? CI failing : null} + + ); + } + return ( + + + {showLabel ? CI running : null} + + ); +} + +/** + * GitHub avatar for a PR author. Same `github.com/.png` source + + * `.github-avatar` placeholder as the inbox; hides itself if it can't load. + */ +export function AuthorAvatar({ + login, + size = 18, +}: { + login: string | null; + size?: number; +}) { + if (!login) return null; + return ( + {`@${login}`} e.currentTarget.classList.add("loaded")} + onError={(e) => { + e.currentTarget.style.display = "none"; + }} + /> + ); +} + +/** + * The "more actions" overflow shared by the row and card: non-primary bound + * actions, then the open-PR / open-task fallbacks. + */ +export function WorkstreamOverflowMenu({ + restBound, + showPrInMenu, + showTaskInMenu, + onRun, + onOpenPr, + onOpenTask, + size = "sm", +}: { + restBound: BoundAction[]; + showPrInMenu: boolean; + showTaskInMenu: boolean; + onRun: (action: BoundAction) => void; + onOpenPr: () => void; + onOpenTask: () => void; + size?: "sm" | "xs"; +}) { + const sparkleSize = size === "xs" ? 11 : 12; + const dotsSize = size === "xs" ? 15 : 16; + return ( + + + + + + {restBound.map((action) => ( + onRun(action)} + > + + {action.label} + + {action.situationLabel} + + + ))} + {restBound.length > 0 && (showPrInMenu || showTaskInMenu) ? ( + + ) : null} + {showPrInMenu ? ( + + + Open PR in browser + + + ) : null} + {showTaskInMenu ? ( + Open task + ) : null} + + + ); +} + +export interface MetaItem { + key: string; + node: React.ReactNode; +} + +/** + * A muted, dot-separated metadata line (repo · branch · #PR · …). Callers pass + * only the items that exist, so separators never dangle. + */ +export function MetaList({ + items, + className, +}: { + items: MetaItem[]; + className?: string; +}) { + return ( +
+ {items.map((item, i) => ( + + {i > 0 ? ( + + · + + ) : null} + {item.node} + + ))} +
+ ); +} diff --git a/apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx b/apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx new file mode 100644 index 0000000000..90f4dcbe45 --- /dev/null +++ b/apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx @@ -0,0 +1,206 @@ +import { useSkillsForPicker } from "@features/home/hooks/useSkillsForPicker"; +import { useWorkflowEditorStore } from "@features/home/stores/workflowEditorStore"; +import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { usePreviewConfig } from "@features/task-detail/hooks/usePreviewConfig"; +import { ArrowDown, ArrowUp, Trash, X } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Card, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; +import { + SITUATIONS, + type SituationId, + type WorkflowAction, +} from "@shared/types/workflow"; +import { useMemo } from "react"; +import { SITUATION_TONE } from "./workflowMapLayout"; + +interface Props { + situationId: SituationId; + action: WorkflowAction; + totalInSituation: number; + indexInSituation: number; +} + +export function ActionEditorPanel({ + situationId, + action, + totalInSituation, + indexInSituation, +}: Props) { + const updateAction = useWorkflowEditorStore((s) => s.updateAction); + const removeAction = useWorkflowEditorStore((s) => s.removeAction); + const moveAction = useWorkflowEditorStore((s) => s.moveAction); + const selectSituation = useWorkflowEditorStore((s) => s.selectSituation); + + const { skills, isLoading } = useSkillsForPicker(); + const selectedSkill = skills.find((s) => s.name === action.skillId) ?? null; + + const lastUsedAdapter = useSettingsStore((s) => s.lastUsedAdapter); + const adapterForModel = action.adapter ?? lastUsedAdapter; + const { modelOption, isLoading: modelLoading } = + usePreviewConfig(adapterForModel); + // Show the action's pinned model in the picker, else the adapter's default. + const effectiveModelOption = useMemo(() => { + if (!modelOption || modelOption.type !== "select" || !action.model) { + return modelOption; + } + return { ...modelOption, currentValue: action.model }; + }, [modelOption, action.model]); + + const meta = SITUATIONS.find((s) => s.id === situationId); + const tone = SITUATION_TONE[situationId]; + + function patch(p: Partial) { + updateAction(situationId, action.id, p); + } + + function handleRemove() { + removeAction(situationId, action.id); + // Fall back to the situation overview so the user keeps context. + selectSituation(situationId); + } + + return ( + + + + + {meta?.label} + + Edit action + + + + +
+ + + patch({ label: e.target.value })} + /> + + + + + {selectedSkill ? ( + + {selectedSkill.description} + + ) : null} + + + +