diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 98745d4e45..9f591534c7 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -52,4 +52,4 @@ "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..02d9de4092 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,10 @@ 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); +// Home workflow config + snapshot are owned by PostHog now; these services are +// thin authenticated clients over the REST API (docs/workflow-architecture.md). +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 e59051aa16..5e479f92bb 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -191,6 +191,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 { projectId, cloudRegion } = this.getState(); + if (projectId == null) { + throw new Error("No PostHog project selected"); + } + const apiHost = getCloudUrlFromRegion(cloudRegion ?? "us"); + return this.authenticatedFetch( + fetch, + `${apiHost}/api/projects/${projectId}/${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.ts b/apps/code/src/main/services/home/service.ts new file mode 100644 index 0000000000..4d85d09880 --- /dev/null +++ b/apps/code/src/main/services/home/service.ts @@ -0,0 +1,113 @@ +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"); + +// Client poll cadence. The server worker keeps data fresh independently; this +// just pulls the latest snapshot so an open Home view stays current without a +// realtime channel (docs/home-tab.md §10 — REST + client poll for v1). +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 { + return (await this.fetchSnapshot()) ?? EMPTY_HOME_SNAPSHOT; + } + + async refresh(): Promise { + // Fire-and-forget: the server kicks off an async worker eval. The fresh + // result lands via the next poll → onSnapshotUpdated; we just return the + // current snapshot so the mutation has a value. + 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().projectId == 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().projectId == 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/default-workflow.ts b/apps/code/src/main/services/workflow/default-workflow.ts new file mode 100644 index 0000000000..8a34dcbf7a --- /dev/null +++ b/apps/code/src/main/services/workflow/default-workflow.ts @@ -0,0 +1,59 @@ +import type { WorkflowConfig } from "@shared/types/workflow"; + +// Seed config: applied on first run and on "Reset to default". +export function buildDefaultWorkflow(): WorkflowConfig { + return { + id: "default", + version: 1, + updatedAt: new Date(0).toISOString(), + bindings: { + working: [ + { + id: "create_pr", + label: "Create PR", + skillId: "create-pr", + prompt: + "Open a PR for the current branch. Use the task history to write a concise description.", + }, + ], + in_review: [], + ci_failing: [ + { + id: "fix_ci", + label: "Fix CI", + skillId: "fix-ci", + prompt: + "CI is failing on this PR. Investigate the failing checks and push a fix.", + }, + ], + changes_requested: [ + { + id: "address_comments", + label: "Address review", + skillId: "address-comments", + prompt: + "Address the change requests on this PR — read the latest review and respond with code.", + }, + ], + comments_waiting: [ + { + id: "address_threads", + label: "Address comments", + skillId: "address-comments", + prompt: "Address the unresolved review comments on this PR.", + }, + ], + ready_to_merge: [ + { + id: "final_check", + label: "Final check", + skillId: "code-review", + prompt: + "Do a last-pass review of this PR. Call out anything risky before I merge.", + }, + ], + stale: [], + done: [], + }, + }; +} 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..5f9646b524 --- /dev/null +++ b/apps/code/src/main/services/workflow/service.ts @@ -0,0 +1,84 @@ +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"; +import { buildDefaultWorkflow } from "./default-workflow"; + +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. Offline, `get()` falls back to + * the built-in default so the editor still renders. + */ +@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/"); + const parsed = workflowConfig.safeParse(json); + if (parsed.success) return parsed.data; + // Unexpected response shape — render the default rather than breaking the + // Home config surface. (Network/auth failures throw and surface as the + // query's error state.) + return buildDefaultWorkflow(); + } + + 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 03e55f4c79..70dd9173fb 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -14,6 +14,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"; @@ -75,6 +76,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/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index ff1d04eecf..42a9133feb 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -7,6 +7,7 @@ import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksVie import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; +import { HomeView } from "@features/home/components/HomeView"; import { InboxView } from "@features/inbox/components/InboxView"; import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; import { McpServersView } from "@features/mcp-servers/components/McpServersView"; @@ -167,6 +168,8 @@ export function MainLayout() { {view.type === "folder-settings" && } + {view.type === "home" && } + {view.type === "inbox" && } {view.type === "archived" && } 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..cbf8c09e28 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx @@ -0,0 +1,110 @@ +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 { useNavigationStore } from "@stores/navigationStore"; +import { formatRelativeTimeShort } from "@utils/time"; +import { useMemo } from "react"; + +interface HomeActiveAgentsStripProps { + agents: HomeActiveAgent[]; +} + +export function HomeActiveAgentsStrip({ agents }: HomeActiveAgentsStripProps) { + const { data: tasks = [] } = useTasks(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + 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..ae2cb2ab37 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx @@ -0,0 +1,48 @@ +import { CheckCircle, Plus } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex, Text } from "@radix-ui/themes"; +import { useNavigationStore } from "@stores/navigationStore"; + +interface HomeEmptyStateProps { + hasRunningAgents: boolean; +} + +export function HomeEmptyState({ hasRunningAgents }: HomeEmptyStateProps) { + const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); + + 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..51df3b8bb3 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeView.tsx @@ -0,0 +1,294 @@ +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 } 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 !== "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]); + + useEffect(() => { + if (!selectedWorkstreamId) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setSelectedWorkstreamId(null); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectedWorkstreamId, setSelectedWorkstreamId]); + + if (isLoading) { + return ( + + + + ); + } + + const { activeAgents, needsAttention, inProgress } = snapshot; + const totalRows = needsAttention.length + inProgress.length; + const hasContent = activeAgents.length > 0 || totalRows > 0; + + const selectedWorkstream = selectedWorkstreamId + ? (needsAttention.find((ws) => ws.id === selectedWorkstreamId) ?? + inProgress.find((ws) => ws.id === selectedWorkstreamId) ?? + null) + : null; + + 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..15bc69e714 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx @@ -0,0 +1,185 @@ +import { GitBranch, GitPullRequest, Sparkle } 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, + 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} }); + } + 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..86d9aa134d --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx @@ -0,0 +1,371 @@ +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 { useNavigationStore } from "@stores/navigationStore"; +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"; + +interface Props { + workstream: HomeWorkstream; + onClose: () => void; +} + +export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { + const { data: allTasks = [] } = useTasks(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + 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) navigateToTask(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 CiBadge({ status }: { status: PrSnapshot["ciStatus"] }) { + if (status === "passing") { + return ( + + + CI passing + + ); + } + if (status === "failing") { + return ( + + + CI failing + + ); + } + if (status === "pending") { + return ( + + + CI pending + + ); + } + return null; +} + +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..d3aef6babd --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx @@ -0,0 +1,195 @@ +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, + 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 generating = workstream.tasks.some((t) => t.isGenerating); + const awaitingPermission = workstream.tasks.some((t) => t.needsPermission); + + 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 (awaitingPermission) { + 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} + + + +