diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index b3fc672e90..0f969745c2 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -4,7 +4,6 @@ export const EXPERIMENT_SUGGESTIONS_FLAG = export const SELF_DRIVING_SETUP_TASK_FLAG = "posthog-code-self-driving-setup-task"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; -export const HOME_TAB_FLAG = "posthog-code-home-tab"; export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; export const BRANCH_PREFIX = "posthog-code/"; export const DATA_DIR = ".posthog-code"; diff --git a/docs/home-tab.md b/docs/home-tab.md deleted file mode 100644 index d729445965..0000000000 --- a/docs/home-tab.md +++ /dev/null @@ -1,547 +0,0 @@ -# Home tab - -## 1. Summary - -A new **Home** sidebar tab that helps users reach inbox zero on code work. Home aggregates PRs, branches, and worktrees into *workstreams* and surfaces only the items that need the user's attention right now — things that are not already obvious from the existing task list. - -The existing task panel already shows what your agents are doing (in-flight, failed, cancelled, needing permission). Home does **not** duplicate that. Home is about everything that happens *around* an agent task — the PR it produced, the comments people left on it, the CI that's failing, the branch that never got a PR, the review someone is waiting on you for. - -Home should answer: - -- What's running right now? (glanceable strip — no detail) -- What needs me to do something so this work can ship? -- What's stale and should be picked up, closed, or cleaned up? -- Which PRs have feedback, failing checks, or are waiting on my review? - -## 2. Goals - -1. **One surface for "what needs me"** — PR feedback, failing CI, review requests, stale branches, ready-to-merge PRs. -2. **Inbox-zero shape** — only render rows the user can act on now; snooze and mute reduce the list; no endless feed. -3. **Agent-native actions** — primary actions kick off new tasks with the right skill (review, fix CI, address comments). -4. **Don't duplicate the task list** — running and failed agent runs surface as a compact strip, not as attention rows. -5. **Three views of the same data** — a triage **list** (severity-first), a workflow **board** (stage-first kanban), and a **config** canvas where the user models the workflow that the other two views derive from. Same workstream model, three renderings. -6. **User-configurable workflow** — list sections and board columns are *not* hardcoded for the long run. The user defines the pipeline (steps, conditions, agents attached at each step) on the Config canvas; List and Board are projections of that configuration over the live `HomeSnapshot`. A built-in default workflow matches the v1 hardcoded spec so the feature ships useful on day one. -7. **Host-agnostic architecture** — all aggregation, workflow persistence, and classification in main services; renderer is dumb (yes, even the canvas). - -## 3. Non-goals (v1) - -- Full Git client or repo browser. -- Re-surfacing what the task panel already shows (running runs, failed runs, agents needing permission). -- Cross-org reporting or an "all users" view. -- Destructive cleanup automation. -- Mirroring GitHub's notifications inbox. -- Slack-origin signals or signal reports with no branch/PR yet — Inbox already handles those. - -## 4. Sidebar & navigation - -**Sidebar order:** New task, **Home**, Search, Inbox, Skills, MCP Servers, Command Center, task list. - -**Default landing view:** Home if the user has ≥1 workspace, otherwise New task. - -**Implementation:** - -- Add `"home"` to `ViewType` in `apps/code/src/renderer/stores/navigationStore.ts:24`. -- Add `navigateToHome` action. -- **Rename** existing `apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx` (which currently exports `NewTaskItem` and `InboxItem`) before this feature lands. New `HomeItem.tsx` owns the Home sidebar entry. -- Add a `Home` arm to the view switch in `apps/code/src/renderer/components/MainLayout.tsx`. -- Keep using the existing `navigationStore`; no second router introduced. - -## 5. Core model - -```ts -type HomeSnapshot = { - generatedAt: string - summary: { needsAttention: number; inProgress: number; watching: number } - activeAgents: ActiveAgentSummary[] // compact strip; see §6 - workstreams: HomeWorkstream[] -} - -type HomeWorkstream = { - id: string // PR URL → repo#branch → worktreePath → taskId - repository: { path?: string; owner: string; name: string } | null - branch: string | null - baseBranch: string | null - pr: HomePullRequest | null - tasks: HomeTaskSummary[] // sorted newest first; count shown, latest expanded - attention: HomeAttentionItem[] - state: "attention" | "in_progress" | "watching" - lastActivityAt: string - lastUserViewedAt: string | null -} - -type HomeAttentionItem = { - id: string - kind: - | "pr_review_requested" - | "pr_comments" - | "pr_ci_failed" - | "pr_ready_to_merge" - | "stale_no_pr" - | "branch_cleanup" - severity: "critical" | "attention" | "quiet" - source: "git" | "github" - title: string // "CI failed on test:e2e" - detail: string // one-line context - primaryActionId: string - secondaryActionIds: string[] - snoozedUntil: string | null - mutedAt: string | null // cleared on next state change - mutedAtSha: string | null // anchor for "next change" detection -} - -type HomePullRequest = { - url: string; number: number; title: string - state: "open" | "draft" | "merged" | "closed" - mergeable: boolean | null - ciStatus: "passing" | "failing" | "pending" | "none" - unresolvedThreads: number - newCommentsSinceViewed: number - reviewDecision: "approved" | "changes_requested" | "review_required" | null - isCurrentUserRequestedReviewer: boolean - isCurrentUserAuthor: boolean - lastUpdatedAt: string -} - -type ActiveAgentSummary = { - taskId: string - taskRunId: string - title: string - branch: string | null - prUrl: string | null - startedAt: string - // Status comes directly from TaskRun; no Home-side classification. - status: "queued" | "in_progress" -} -``` - -### Workflow configuration model - -The Config canvas persists a `WorkflowConfig` that the List and Board views project over the snapshot. The model lives in main; the renderer reads it via tRPC. A built-in default `WorkflowConfig` (matching the §6.3 hardcoded board columns) ships with the app and is used until the user customises it. - -```ts -type WorkflowConfig = { - id: string - version: number // bumps on every save; enables optimistic concurrency - updatedAt: string - nodes: WorkflowNode[] - edges: WorkflowEdge[] - layout: Record // canvas positions, opaque to main -} - -type WorkflowNode = - | { id: NodeId; kind: "input"; label: string; inputType: "pr_url" | "branch" | "task_prompt" } - | { id: NodeId; kind: "task"; label: string; skillId: string | null } - | { id: NodeId; kind: "step"; label: string; match: WorkflowMatch; agents: AgentBinding[] } - | { id: NodeId; kind: "queue"; label: string; queueKind: "stale" | "merge" | "cleanup"; match: WorkflowMatch; agents: AgentBinding[] } - | { id: NodeId; kind: "branch"; label: string; condition: WorkflowCondition } - | { id: NodeId; kind: "terminal"; label: string; terminalKind: "merged" | "closed" | "archived" } - -type WorkflowEdge = { - id: EdgeId - from: NodeId - to: NodeId - // Edge fires when the source node's condition resolves to this case (e.g. "ci_failed", "approved"). - whenCase: string | null -} - -// Declarative predicate; main evaluates it against a HomeWorkstream. -type WorkflowMatch = { - hasPr?: boolean - prState?: ("open" | "draft" | "merged" | "closed")[] - ciStatus?: ("passing" | "failing" | "pending" | "none")[] - reviewDecision?: ("approved" | "changes_requested" | "review_required")[] - hasUnresolvedComments?: boolean - isStale?: { olderThanHours: number } - // Extend as needed; the renderer never interprets this directly. -} - -type WorkflowCondition = - | { kind: "ci_status" } // cases: "passing" | "failing" | "pending" - | { kind: "review_decision" } // cases: "approved" | "changes_requested" | "review_required" - | { kind: "pr_state" } // cases: "open" | "draft" | "merged" | "closed" - -type AgentBinding = { - id: string - label: string // "Fix CI", "Address comments" - skillId: string // resolved against the skills registry in main - promptTemplate: string // mustache-style; main fills `{{prUrl}}`, `{{branch}}`, etc. - trigger: "primary" | "secondary" | "auto" // "auto" runs as soon as a workstream lands in this node -} -``` - -**Classification (main only).** `HomeService` walks each workstream against the workflow's nodes in declaration order and assigns it to the first matching `step` / `queue` node. The result is a `nodeId` per workstream, stored on the snapshot — the renderer never re-evaluates predicates. This keeps `WorkflowMatch` as a private main-side language. - -**Workstream grouping key precedence:** PR URL → `repo + branch` → worktree path → task id. - -**Note: no `run_failed` or `agent_needs_input` kinds.** Failed runs, cancelled runs, and runs awaiting permission are already obvious in the task panel. Home does not surface them as attention rows. If they materially affect downstream state (e.g. a failed run left a branch with no PR for >24h), the *branch* surfaces as `stale_no_pr` — the user-facing reason, not the agent-side cause. - -## 6. UI layout - -Home renders three views over the same `HomeSnapshot` + active `WorkflowConfig`. The user picks one with a persistent toggle in the header; data, actions, and empty states are consistent across views — only the layout and density differ. - -- **List view (default)** — triage-first. Two sections (Needs attention / In progress) sorted by severity and recency. Best for "what should I do next?". -- **Board view** — workflow-first kanban. One column per `step`/`queue` node in the active workflow, left → right. Best for "where is everything in the pipeline?". -- **Config view** — drag-and-drop canvas where the user authors the workflow that List and Board project over. Best for "how does *my* work actually flow?". - -A piece of work belongs to a single bucket in list/board: the list assigns it to a *section* by severity, the board assigns it to a *column* by the workflow node it matched. Same `HomeWorkstream`, two derivations of the same workflow. - -### 6.1 Shared chrome (both views) - -**Header:** title `Home`, summary line (`3 need attention · 2 running · 4 watching`), repo chip (default: current repo), **view toggle** (`List` / `Board`), refresh button. - -**Strip — Active agents.** Horizontal, compact, glanceable. One small card per `in_progress` or `queued` TaskRun. - -- Task title, branch chip, elapsed timer, skill icon. -- Click → opens the task detail view (same as clicking it in the task panel). -- This strip is **information, not attention**. It never gets a count badge, never blocks empty state. Hidden entirely when no agents are running. -- The strip lives at the top in **both** views. Agents are a transient activity feed; workstreams are persistent. They run on different timescales, so they don't share visual real estate with the buckets. - -**Detail pane** (two-pane, like Inbox): workstream header → PR header (state, CI, reviewers) → attached task runs (clickable to their detail view) → unresolved comments preview → action bar (`SkillButtonsMenu` shape). The detail pane is shared between views — selecting a row in list or a card on the board opens the same panel. - -**Keyboard:** `j`/`k` move, `Enter` primary action, `s` snooze, `m` mute, `o` open in browser, `r` refresh, `v` toggle list/board. - -**Empty states:** - -- Zero attention, no agents running: "You're caught up." + summary chips. -- Zero attention, agents running: strip is visible, sections/columns empty + "Everything else is healthy." -- No GitHub auth: degraded mode (local Git signals only) + CTA to connect. -- No workspaces: redirect to New task. - -### 6.2 List view - -**Section 1 — Needs attention.** Only rows where `state === "attention"`. Sorted by severity, then last activity. Multiple attention items on one workstream stack as chips on the row; top-severity drives the primary action. - -**Section 2 — In progress.** Workstreams with recent activity but no outstanding attention item (e.g. PR open, draft, CI green, no comments waiting on you). Collapsed by default when Section 1 is non-empty. - -**Section 3 — Watching.** *Not a section — a counter pill in the header.* Click expands an overlay: snoozed items, PRs awaiting external reviewers, items in pending CI. Never a default list of rows. - -**Row anatomy.** - -- Left: workstream title (PR title or branch), repo+branch metadata, attention chips. -- Middle: PR state pill (open/draft/merged), CI dot, task-run count if >1. -- Right: primary action button (split-button with overflow), timestamp. -- Severity-tinted left border: red (critical) / amber (attention) / none (quiet). - -### 6.3 Board view - -**Columns are workflow-derived.** One column per `step` / `queue` node in the active `WorkflowConfig`, ordered by topological-sort over the edges (left = inputs, right = terminals). The mapping `workstream → column` is the `nodeId` already assigned by `HomeService` (see §5 Classification); the renderer does *not* re-evaluate predicates. - -**Default workflow (shipped pre-customisation):** - -| Column | Backing node | Contains | Empty placeholder | -|---|---|---|---| -| **No PR yet** | step `no_pr` | `stale_no_pr`; or any workstream with a branch but no PR and no other attention | "Nothing here" | -| **In review** | step `in_review` | PR open or draft with no attention items — awaiting external reviewer or CI | "Nothing here" | -| **Needs me** | step `needs_me` | `pr_ci_failed`, `pr_comments`, `pr_review_requested` | "Nothing here" | -| **Ready to merge** | queue `merge_queue` | `pr_ready_to_merge` | "Nothing here" | -| **Cleanup** | queue `cleanup` | `branch_cleanup` | "Nothing here" | - -A workstream belongs to exactly one column — the first node whose `match` predicate it satisfies, walked in declaration order. The default config encodes today's resolution order (`cleanup` → `merge_queue` → `needs_me` → `no_pr` → `in_review`). Once the user edits the workflow in the Config view, this default is replaced; the renderer doesn't know or care. - -**Card anatomy** (denser than a row): - -- Title (line-clamp-2), CI status icon top-right. -- Repo, branch chip, PR `#number`, relative time. -- Attention chips (abbreviated: `Review` / `CI` / `Feedback` / `Mergeable` / `Stale` / `Cleanup`). -- Author chip when it's someone else's PR (`by @user`). -- Review decision line ("Changes requested" / "Approved") when relevant. -- One-line summary from the top attention. -- Primary action button + task count if >1. -- Severity-tinted left border, same colour scheme as the list rows. - -**Empty columns render a dashed "Nothing here" placeholder.** Important for kanban scanning — the user should be able to glance and see *which stage is empty*, not just *which stage is full*. - -**Why kanban as well as list:** PRs move through a fixed workflow. The list is great when you're doing inbox-zero triage ("what should I act on first?"). The board is great when you're doing a status review ("how many things are stuck in review? what's about to ship?"). Same data, different question. - -### 6.4 Config view - -A drag-and-drop canvas where the user models their workflow. The canvas is the *only* heavy frontend surface in Home; everything that touches data still goes through main per R1/R2. - -**What you can build.** - -- **Input nodes** — entry points for new work. Examples: "Paste a PR URL", "Start from a task prompt", "Pick a branch". When the user triggers an input, it creates or attaches a workstream. -- **Task nodes** — kick off a new agent task with a chosen skill. Used as starting nodes (user-initiated) or as transition handlers (the next-step agent in a chain). -- **Step nodes** — workflow stages a workstream sits in (e.g. `In review`, `Needs me`). Each step has a `match` predicate and zero-or-more `AgentBinding`s that surface as quick actions on the List/Board row when a workstream lands there. -- **Queue nodes** — like steps but visually distinct on the board (e.g. `Stale queue`, `Merge queue`, `Cleanup`). Same predicate model. -- **Branch nodes** — split the flow by a condition (e.g. `CI status` → `passing` / `failing` / `pending`). Outgoing edges declare which case they handle via `whenCase`. -- **Terminal nodes** — `merged`, `closed`, `archived`. Workstreams that match a terminal disappear from List/Board. - -**Worked example (matches the user's brief):** - -``` -[Input: PR URL] ┐ - ├──► [Step: PR open]──► [Branch: CI status] -[Task: "Create a PR" (skill: create-pr)] ────────── │ - ├─ failing ──► [Step: Needs me] ─► [Agent binding: "Fix CI"] -[Step: No PR yet] ──► [Branch: PR exists?] │ - └─ no ──► [Queue: Stale queue] ├─ pending ──► [Step: In review] - │ - └─ passing ──► [Branch: review_decision] - ├─ changes_requested ──► [Step: Needs me] ─► [Agent: "Address comments"] - ├─ review_required ──► [Step: In review] - └─ approved ──► [Queue: Merge queue] ─► [Terminal: merged] -``` - -The board renders one column per step/queue node in the order above; the list renders the same nodes as severity-banded sections (severity inferred from each node's attention kinds). - -**Canvas UI behaviour.** - -- Pan / zoom / fit-to-content; minimap toggle. -- Sidebar palette of node types — drag onto canvas to add. -- Click a node → inspector panel on the right: label, match predicate builder, attached `AgentBinding`s (skill picker + prompt template editor with `{{prUrl}}` / `{{branch}}` / `{{failingChecks}}` placeholders). -- Click an edge → set `whenCase` from the source branch node's case list. -- Validation surfaces inline: orphan nodes, unreachable terminals, duplicate `whenCase` on a branch's outgoing edges, predicates that overlap a higher-priority node. Validation runs in main (the canvas only renders the diagnostics). -- **Save model: explicit, not auto.** A dirty banner appears once the user changes anything; "Save" submits the whole workflow; "Discard" reverts to the persisted version. No silent autosave — workflow edits drive every other Home surface, so an accidental save would be loud. -- Versioned saves with optimistic concurrency: if the persisted `version` has advanced (e.g. another window edited it), the save mutation rejects and the canvas offers reload or force-overwrite. - -**Architecture (heavy frontend, but the rules still hold).** - -Per R1 / R2 / R3: even though the canvas is the most visually expensive surface in the app, no business logic lives in the renderer. - -- **`WorkflowService` (main).** Thin authenticated client over `/code_workflow/*`: reads/writes the canonical `WorkflowConfig` and emits `workflow.onChanged`. The config endpoint is the only source of truth — load failures propagate so the canvas can show an offline/error state, never a fabricated config. Persistence, version bumps, validation, the default seed, and classification all live server-side in PostHog. -- **tRPC `workflow.*` router (main).** One-liners only: `workflow.get` (query, returns `WorkflowConfig`), `workflow.save` (mutation, `{ config, expectedVersion }` → new version or `ConflictError`), `workflow.resetToDefault` (mutation), `workflow.onChanged` (subscription, `WorkflowConfig` diff stream). -- **`useWorkflow` (renderer hook).** Single `useQuery` against `workflow.get`, kept fresh by the subscription registrar in `features/home/subscriptions.ts` per R9. No multi-query orchestration in the hook. -- **`workflowEditorStore` (renderer Zustand, R2-clean).** Holds *only* uncommitted edit state: `viewport: { x, y, zoom }`, `selectedNodeId`, `selectedEdgeId`, `draftConfig` (deep-clone of the persisted config the moment editing starts), `dirty`, `validationErrors` (fed from main on each draft change via `workflow.validateDraft`). The persisted `WorkflowConfig` is *not* in this store — it's the `useQuery` result. Save action is one `useMutation` call; the store's job ends there. -- **`workflowCanvasService` (renderer service, R3 escape hatch).** Drag-and-drop coordinator, snap-to-grid maths, hit-testing, edge-routing — the exact "non-trivial renderer-only UI mechanic shared across components" R3 carves out. No data fetching, no cross-store reach-ins. -- **Components (`features/home/config/`).** `ConfigCanvas.tsx`, `NodePalette.tsx`, `NodeInspector.tsx`, `EdgeInspector.tsx`, `ValidationBanner.tsx`. Components consume the `useWorkflow` query for the persisted state and the editor store for the draft. -- **Cross-feature coordination (R5).** `WorkflowService` emits `WorkflowChanged`; `HomeService` reacts by re-classifying and pushing a new `home.onSnapshotUpdated` frame. The renderer's List and Board re-render off the new snapshot. No store ever reaches across to another store. -- **Library choice.** Use [`@xyflow/react`](https://reactflow.dev/) for the canvas primitive (pan / zoom / nodes / edges / minimap). It's the lightest mainstream option and the canvas state it owns is fine to keep local-to-component — we don't lift its internal state into the store. Wrap it once so the renderer service can layer our snap/validation behaviour. - -**Empty / new-user state.** Until the user touches Config, they see the default workflow on the canvas (read-only outline + "Customise" CTA). After customisation, "Reset to default" is always one click away in the header. - -**Keyboard:** `Cmd+S` save, `Cmd+Z` / `Cmd+Shift+Z` undo/redo within the draft, `Delete` / `Backspace` remove selected node or edge, `Esc` clears selection, `v` exits to last non-config view. - -### 6.5 View persistence - -The user's choice persists across sessions via `homeUiStore.viewMode: "list" | "board" | "config"`. Default for new users is `list` (the inbox-zero shape we open with). Toggle is keyboard-accessible (`v` cycles `list → board → config → list`). - -## 7. Data sources - -The snapshot is assembled **server-side in PostHog** (`products/tasks/`, see -[docs/workflow-architecture.md](./workflow-architecture.md)) — the Electron app -reads the finished snapshot over the REST API and renders it; it does not -aggregate these sources itself. - -| Source | Data | -|---|---| -| Tasks / task runs | status, branch, PR URL; the live active-agent set | -| GitHub PR metadata | title, state, draft, mergeable, CI rollup, review decision, unresolved threads, requested reviewers | -| GitHub review requests | PRs where the current user is a requested reviewer | - -Snooze / mute / viewed (`home_attention_state`) is not implemented yet — see -[docs/workflow-architecture.md](./workflow-architecture.md) §4. - -## 8. Architecture - -The data layer — workstream grouping, PR polling, and situation classification — -runs **server-side in PostHog** (`products/tasks/`, a Temporal worker). The -Electron app is a thin authenticated client. Full server design and wire shapes: -[docs/workflow-architecture.md](./workflow-architecture.md). - -**Data layer (`packages/api-client` + `packages/ui` hooks)** — thin clients over -the REST API, no local persistence and no `gh` polling: - -- `posthog-client.ts` (`getHomeSnapshot`/`refreshHomeSnapshot`) — project-scoped authenticated fetch of `GET /code_home/`; the snapshot query polls it and validates with Zod. -- `posthog-client.ts` (`getCodeWorkflow`/`saveCodeWorkflow`/`resetCodeWorkflow`) — reads/writes the workflow config via `/code_workflow/*`; load failures propagate so the canvas shows an offline/error state rather than a fabricated config. Save/reset mutations write the fresh config back into the query cache. - -Wire shapes (Zod, the shared source of truth for the UI types): -`packages/core/src/home/schemas.ts`, `packages/core/src/workflow/schemas.ts`, -`packages/core/src/home/prSnapshot.ts`. - -**UI (`packages/ui/src/features/home/`)** - -- `components/` — `HomeView`, `HomeActiveAgentsStrip`, `HomeWorkstreamRow`, `HomeWorkstreamCard`, `HomeBoardView`, `HomeWorkstreamDetailPanel`, `HomeEmptyState`. -- `config/` — the workflow editor (`ConfigMap` and friends). -- `utils/boardColumns.ts` — pure projection of the snapshot into board columns. No I/O, no React. -- `hooks/useHomeSnapshot.ts`, `hooks/useWorkflow.ts` — one `useQuery` each; the snapshot query polls on an interval and the workflow mutations write back through the same query key. -- `stores/homeUiStore.ts` — UI state only (view mode, selection). `stores/workflowEditorStore.ts` — the uncommitted editor draft only. - -Situation classification is authoritative on the server, including the -`primarySituation` (the priority pick used for board column placement and -accents) which arrives on each workstream in the snapshot. The renderer never -re-derives situations or the primary from PR state — it reads `primarySituation` -directly and uses `situations` only to render the extra chips. - -**Rules honoured:** R1 (main owns the client + orchestration), R2 (stores are UI state + subscription caches only), R4 (one `useQuery` per surface), R5 (main emits `workflow.onChanged`; Home reacts via its subscription registrar), R6 (Zod everywhere, inferred types), R9 (subscriptions registered once at boot), R10 (router one-liners). - -## 9. PR data - -PR metadata (state, draft, mergeable, CI rollup, review decision, unresolved -threads, requested reviewers) is fetched **server-side** by the PostHog worker -via `GitHubIntegration.get_pull_request_snapshot` — the Electron app does not -poll `gh` for the home tab. The serialised shape is `PrSnapshot` -(`shared/types/pr-snapshot.ts`). - -## 10. Polling & rate-limit strategy - -- **Server worker** keeps PR snapshots fresh: a Temporal schedule fans out a per-team evaluation every ~3 min that polls each tracked PR (GitHub GraphQL via the team integration, rate-limit aware) and rebuilds the workstreams. -- **Client** just pulls the latest snapshot: `HomeService` polls `GET /code_home/` on a fixed cadence and emits `home.onSnapshotUpdated` on change; `home.refresh` triggers an on-demand server evaluation. No `gh` calls, no rate-limit handling in the app. -- Active agents stream from the existing cloud-task SSE, not from polling. - -## 11. Classification & ranking - -**Attention kinds → severity:** - -| Kind | Severity | -|---|---| -| `pr_ci_failed` | critical | -| `pr_review_requested` (on me) | attention | -| `pr_comments` (unresolved, not by me) | attention | -| `pr_ready_to_merge` | attention | -| `stale_no_pr` | quiet | -| `branch_cleanup` (merged/closed but worktree lingering) | quiet | - -**Stale thresholds (constants in `home/service.ts`):** - -- Completed run with diff on a branch and no PR: 24h → `stale_no_pr`. -- Branch/worktree with no activity and no PR: 3 days → `stale_no_pr` (escalated severity). -- Merged/closed PR with worktree still around: 1 day → `branch_cleanup`. - -**Row ordering inside "Needs attention":** severity → ownership (mine first) → staleness → `lastActivityAt`. - -## 12. Actions (registry-driven) - -| Situation | Primary | Secondary | -|---|---|---| -| `stale_no_pr` | Create PR | Resume with agent, Open changes, Archive | -| `pr_ci_failed` | Fix CI with agent | Open checks, Open PR | -| `pr_comments` | Address comments with agent | Open review thread, Open PR | -| `pr_review_requested` | **Review with agent** | Open PR, Snooze | -| `pr_ready_to_merge` | Open PR | Ask agent for final check | -| `branch_cleanup` | Archive task & worktree | Open PR | - -Agent actions prefill the task input with structured context (PR URL, branch, failing-check summary, unresolved-comment list, suggested skill id) and reuse `TaskCreationSaga` + `sendPromptToAgent`. - -Action descriptors are data, not hardcoded buttons. The registry maps `actionId → { label, icon, run: (workstream) => Effect }` and lives in `HomeService` so it remains serializable across the IPC boundary. - -## 13. The code-review skill (keystone, ships with M3) - -The existing `code-review` feature in `packages/ui/src/features/code-review/` only handles *responding* to PR comments inside an active task. We need a new marketplace skill `code-review` that: - -- Takes a PR URL as input. -- Checks out the PR into a fresh worktree. -- Runs a structured review: scope, risk areas, tests, security, style. -- Posts a single review with inline comments via `gh pr review`. -- Lives at `apps/code/skills/code-review/` so it loads through the existing `readSkillMetadataFromDir` discovery. - -This is a blocker for M3, not an open question. - -## 14. Snooze, mute, viewed - -- **Snooze** — hide until `now + N hours`. Default 24h; options 1h/4h/1d/3d. -- **Mute** — hide until the underlying state changes (new commit on the branch, new comment, CI flip, reviewer change). Cleared automatically by `HomeService` when the watermark moves. -- **markViewed** — updates `lastUserViewedAt`; powers `pr.newCommentsSinceViewed`. - -Persistence (`home_attention_state`) is server-side and not implemented yet — see [docs/workflow-architecture.md](./workflow-architecture.md) §4. - -## 15. Analytics events - -- `home_viewed` (viewMode: list | board) -- `home_view_mode_changed` (from, to) -- `home_attention_action_clicked` (kind, actionId, viewMode) -- `home_attention_snoozed` (kind, durationHours) -- `home_attention_muted` (kind) -- `home_action_started_task` (kind, skillId) -- `home_refresh_requested` (reason: manual | visible | poll) -- `home_active_agent_clicked` (taskId) -- `home_config_opened` -- `home_config_workflow_saved` (nodeCount, edgeCount, agentBindingCount, version) -- `home_config_workflow_reset_to_default` -- `home_config_node_added` (nodeKind) -- `home_config_validation_blocked_save` (errorCount) - -Follow naming conventions in `docs/conventions.md`. - -## 16. Phasing - -### M1 — Skeleton (no new I/O) - -- Home route, sidebar entry, view scaffold. -- `HomeService.getSnapshot` built from existing `useTasks` data + `WorkspaceService` state piped to main. -- **Active agents strip** wired to existing `CloudTaskService` stream (zero new infra). -- Kinds that work without new `gh` calls: `stale_no_pr`. -- **List and board ship together with the built-in default workflow.** No editor yet. The board reads the built-in default `WorkflowConfig` (`workflow/default-workflow.ts`); no server round-trip needed at this stage. Column projection lives in `utils/boardColumns.ts`. -- Ships visible value immediately and validates the layout. - -### M2 — PR enrichment - -- Server-side PR enrichment: the worker polls each tracked PR and serialises a `PrSnapshot`. -- New kinds: `pr_ci_failed`, `pr_comments`, `pr_ready_to_merge`, `branch_cleanup`. -- PR row UI; refresh button. - -### M3 — Reviewer flow + code-review skill - -- `pr_review_requested` kind (PRs where the user is a requested reviewer). -- **Ship the `code-review` skill in this same milestone.** -- "Review with agent" action wired through `TaskCreationSaga`. - -### M4 — Inbox-zero controls - -- Snooze, mute, `lastUserViewedAt`, keyboard shortcuts (`j`/`k`/`s`/`m`/`Enter`). -- Server-side `home_attention_state` for snooze/mute/viewed. - -### M5 — Config canvas (workflow editor) - -- `WorkflowService` client + server-side workflow config persistence (`code_workflow/*`); the default workflow is seeded server-side on first read. -- `workflow.*` tRPC router with Zod schemas. -- Renderer: `ConfigCanvas`, palette, inspectors, validation banner. -- `workflowEditorStore` + `workflowCanvasService` (R3 escape hatch). -- `WorkflowChanged` event flow into `HomeService` so List/Board re-render on save. -- Agent bindings on step/queue nodes drive primary/secondary actions in the action registry (replaces the M2 hardcoded action table). -- View toggle becomes three-way; `v` keyboard cycle updated. - -### M6 — Polish - -- Multi-repo chip, GitHub integration toggle. -- Optional native notifications on critical kinds (opt-in). -- Settings (stale thresholds, polling cadence). -- Workflow templates (share-and-import a `WorkflowConfig` JSON). - -## 17. Implementation checklist - -**M1–M4 (List + Board with built-in default workflow):** - -- [ ] Rename existing `HomeItem.tsx` to free the filename. -- [ ] Add `home` to `ViewType` + `navigateToHome`. -- [ ] Scaffold `HomeService` (REST client), schemas, router, DI token, root router wiring. -- [ ] Add the built-in default `WorkflowConfig` (`workflow/default-workflow.ts`); `WorkflowService` falls back to it when offline. -- [ ] Server-side `home_attention_state` for snooze/mute/viewed. -- [ ] `HomeService` poll loop → `home.onSnapshotUpdated`, kept fresh by the subscription registrar. -- [ ] M2: server-side PR enrichment; `home.refresh` triggers an on-demand evaluation. -- [ ] M3: `code-review` skill in `apps/code/skills/code-review/`. -- [ ] Renderer: `useHomeSnapshot` (query + subscription), `HomeView`, active-agents strip, row + card components, board view, view toggle in header, detail pane, empty states. -- [ ] `utils/boardColumns.ts` — column projection from `(workflow, snapshot) → BoardColumn[]` + unit tests covering every attention combination. -- [ ] `homeUiStore.viewMode` persistence + `v` keyboard shortcut for toggle. -- [ ] Action registry + `home.startAction` dispatcher (link to `TaskCreationSaga` / `sendPromptToAgent` / urlOpener). -- [ ] Analytics events (include `home_view_mode_changed`). -- [ ] Tests: workstream grouping, column projection, staleness classification, severity ranking, snooze/mute decay, action prompt builders. -- [ ] Component tests for empty / caught-up / heavy states **in both list and board layouts**. - -**M5 (Config canvas):** - -- [ ] `WorkflowService` client (default workflow, `workflow.onChanged` event) + server-side workflow config persistence. -- [ ] `workflow.*` tRPC router with Zod input/output schemas (`get`, `save`, `validateDraft`, `resetToDefault`, `onChanged`). -- [ ] `useWorkflow` hook (query + subscription); subscription wired in `features/home/subscriptions.ts`. -- [ ] `workflowEditorStore` (Zustand, UI-only) + `workflowCanvasService` (R3 escape hatch for drag/snap/hit-test). -- [ ] Components: `ConfigCanvas`, `NodePalette`, `NodeInspector`, `EdgeInspector`, `ValidationBanner`. -- [ ] Three-way view toggle + `v` cycle + `Cmd+S` save + undo/redo within draft. -- [ ] `WorkflowChanged` → `HomeService.reclassify()` → new snapshot frame; both List and Board update without any renderer-side coordination. -- [ ] Agent bindings drive primary/secondary action registry (replaces the M2 hardcoded mapping in §12). -- [ ] Tests: workflow Zod round-trip, predicate evaluation (matching `boardColumns` expectations against the default config), validation diagnostics, optimistic-concurrency reject + reload, draft → save → snapshot reclassification end-to-end. - -## 18. Open questions - -1. Should Home replace Command Center, given overlap on "see all running tasks"? Probably not — Command Center is a grid-of-agents work view, Home is workstream-grouped triage — but confirm. -2. When a PR has both `pr_ci_failed` and `pr_comments`, do we stack chips on one row (current spec) or split into two attention items? Current: stack, single row, primary action follows top severity. -3. Which GitHub identity wins when both the GitHub integration and `gh` auth are present? Suggestion: `gh` for queries, GitHub integration for permission checks. -4. Should "Review with agent" auto-checkout into a new worktree or reuse the user's current one with a stash? Suggestion: new worktree, always. -5. Cross-device snooze sync? v1: local only. -6. Native OS notifications on critical kinds? Defer to M5; opt-in. -7. Should the board support drag-to-move between columns? Current answer: **no for v1**. Columns are derived from underlying state, not user-set status — dragging would either need an override store (extra concept, sync complexity) or have to perform the underlying action (e.g. "drag to Ready to merge" = merge the PR), which is too consequential for a drag gesture. Default: cards are read-only with explicit action buttons. -8. Should the board surface snoozed and muted items in a "Watching" column, or keep the list view's behaviour (counter pill only, not a column)? Current answer: **counter pill only**. Adding a sixth column dilutes the workflow story — Watching items aren't a workflow stage. -9. Default view for new users — list or board? Current answer: **list**. Inbox-zero shape is what we open with; the board reveals itself via the toggle. -10. Should the Config canvas be per-user, per-repo, or per-project? Current answer: **per-user, global**. Cross-repo workflows are the same shape ("PR → review → merge") so a single workflow keeps the model simple. Revisit if teams ask for per-repo overrides. -11. Are workflow edits live for everyone in the org, or local? Current answer: **local for v1**. Sync via PostHog API is M6+. -12. Should `terminal: archived` automatically run the existing archive action when a workstream lands there? Current answer: **no for v1**. Terminals just hide the workstream from List/Board; actions stay user-initiated. -13. Should the canvas render the *live* workstreams (a count chip on each node) so it doubles as a board? Current answer: **yes for v1, read-only chips only**. Helps the user understand what their workflow is doing without making the canvas a third board. -14. Library: `@xyflow/react` vs roll our own. Current answer: **`@xyflow/react`**. Pan/zoom/edge routing is enough work that "prefer writing our own" doesn't apply; wrap it once so we can swap later. diff --git a/docs/workflow-architecture.md b/docs/workflow-architecture.md deleted file mode 100644 index 6e167a7986..0000000000 --- a/docs/workflow-architecture.md +++ /dev/null @@ -1,116 +0,0 @@ -# Home workflow architecture - -The Home tab's data layer – workstream grouping, PR polling, situation -classification, and workflow-config persistence – runs **server-side in -PostHog**, in the `tasks` product (`products/tasks/` in the posthog repo). The -Electron app is a thin authenticated client. - -The Electron app and the PostHog `tasks` product share wire shapes and -classification logic – if you change either, update both sides and this doc. - ---- - -## 1. What "the workflow" is - -A user-authored mapping of **situations** a piece of work can be in -(`working`, `in_review`, `ci_failing`, `changes_requested`, `comments_waiting`, -`ready_to_merge`, `stale`, `done`) → **actions** (skill + prompt) the user -wants available when work lands in each situation. - -Three concerns sit on top of that: - -1. **Storage** – the per-user bindings JSON (`CodeWorkflowConfig`). -2. **PR / signal polling** – CI status, review decision, threads, mergeability - for each tracked PR (`CodePrSnapshot`). -3. **Grouping + classification** – group a user's tasks into workstreams and - compute the situations each is in (`CodeWorkstream`). - -All three live in PostHog now. - ---- - -## 2. Server (PostHog `products/tasks/backend/`) - -**Pure logic** (ported 1:1 from the original TypeScript; unit-tested without -Django) – `code_workstreams/`: - -- `situations.py` – situation ids, priority order, attention set. -- `classify.py` – `classify(input) → set[SituationId]`, `pick_primary_situation`. -- `grouping.py` – `build_workstreams(tasks, pr_by_task, now)`: groups tasks - (PR URL → repo+branch → path), extracts the active-agent set, classifies, and - buckets into needs-attention / in-progress. -- `default_workflow.py`, `validation.py` – default bindings + save validation. - -**Models** (`models.py`): - -- `CodeWorkflowConfig` – per `(team, user)` bindings + monotonic `version`. -- `CodePrSnapshot` – per `(team, pr_url)` polled GitHub state (shared across a - team's users). -- `CodeWorkstream` – per `(team, user, key)` grouped + classified workstream; - the API reads these rows directly. - -**Temporal worker** (`temporal/code_workstreams/`, task queue -`TASKS_TASK_QUEUE`): - -- `evaluate-code-workstreams` (dispatcher) – a Temporal **Schedule** every 3 min - enumerates teams with recent code activity and fans out one child workflow per - team (bounded concurrency). -- `evaluate-team-code-workstreams` – per team: `load_team_pr_urls` → - `poll_team_pull_requests` (GitHub GraphQL via the team integration, rate-limit - aware, heartbeated) → `rebuild_team_workstreams` (group + classify + upsert - `CodeWorkstream`, prune stale). -- On-demand refresh: `client.trigger_team_code_workstreams_evaluation(team_id)`. - -PR state is fetched with `GitHubIntegration.get_pull_request_snapshot(pr_url)` -(a single GraphQL call: state, draft, mergeable, review decision, CI rollup, -unresolved threads, requested reviewers). - -**REST API** (`code_home_api.py`, registered under `/api/projects/:id/`): - -- `GET /code_workflow/` → `WorkflowConfig` (seeds the default first time) -- `POST /code_workflow/save/` → `{ config, expectedVersion }` → `SaveResult` - (`saved` | `conflict` (409) | `invalid` (422)); optimistic concurrency + validation -- `POST /code_workflow/reset/` → reseed default -- `GET /code_home/` → `HomeSnapshot { activeAgents, needsAttention, inProgress }` - (workstreams from `CodeWorkstream`; `activeAgents` computed live from `TaskRun`) -- `POST /code_home/refresh/` → trigger an on-demand evaluation (202) - -Responses use the exact camelCase wire shapes the Electron app validates. - ---- - -## 3. Electron app (`apps/code/`) - -Thin authenticated clients over the REST API – no local persistence, no `gh` -polling, no client-side classification: - -| Concern | File | -|---|---| -| Snapshot wire schema (Zod, source of truth for the UI types) | `packages/core/src/home/schemas.ts` | -| Workflow config wire schema | `packages/core/src/workflow/schemas.ts` | -| HTTP client methods (`code_home/`, `code_workflow/*`) | `packages/api-client/src/posthog-client.ts` | -| Snapshot polling query | `packages/ui/src/features/home/hooks/useHomeSnapshot.ts` | -| Workflow query + save/reset mutations (cache write-back) | `packages/ui/src/features/home/hooks/useWorkflow.ts` | -| Board column projection (pure, UI-only) | `packages/ui/src/features/home/utils/boardColumns.ts` | - -Delivery for v1 is **REST + client poll**: `HomeService` polls -`GET /code_home/` and emits `home.onSnapshotUpdated`; `WorkflowService` calls the -config endpoints and emits `workflow.onChanged`. Both subscriptions write back -into the TanStack Query cache. A realtime push channel (SSE) is a future -enhancement – the tRPC subscription contract wouldn't change. - -`WorkflowService.get()` surfaces network/load failures rather than masking them: -the config endpoint is the only source of truth, so when it can't be reached the -canvas shows an offline/error state with a retry instead of fabricating a config. - ---- - -## 4. What is intentionally NOT here yet - -- **`unresolvedThreads` for PRs the user doesn't author** – only the M3 reviewer - flow needs it; `is_current_user_requested_reviewer` defaults false. -- **Realtime push (SSE)** – v1 is client poll; the worker keeps server data fresh. -- **Snooze / mute / viewed** (`home_attention_state`) – M4, not migrated yet. -- **`auto`-trigger actions** – deliberately omitted. -- **Continue-as-new batching in the dispatcher** – current active-team counts - fan out in one pass (capped + logged); page with continue-as-new at larger scale. diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index a2fee35964..6468f58ace 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -591,7 +591,6 @@ interface CloudRunOptions { runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: ExecutionMode; - homeQuickAction?: string; /** * Local url-based MCP servers to make available inside the sandbox. The * backend merges these into the agent server's `--mcpServers` at spawn. @@ -689,9 +688,6 @@ function buildCloudRunRequestBody( if (options?.signalReportId) { body.signal_report_id = options.signalReportId; } - if (options?.homeQuickAction) { - body.home_quick_action = options.homeQuickAction; - } if (options?.importedMcpServers?.length) { body.imported_mcp_servers = options.importedMcpServers; } @@ -1839,82 +1835,6 @@ export class PostHogAPIClient { return data as Schemas.Team; } - async getHomeSnapshot(): Promise { - const teamId = await this.getTeamId(); - const urlPath = `/api/projects/${teamId}/code_home/`; - const response = await this.api.fetcher.fetch({ - method: "get", - url: new URL(`${this.api.baseUrl}${urlPath}`), - path: urlPath, - }); - if (!response.ok) { - throw new Error(`Failed to fetch home snapshot: ${response.status}`); - } - return response.json(); - } - - async refreshHomeSnapshot(): Promise { - const teamId = await this.getTeamId(); - const urlPath = `/api/projects/${teamId}/code_home/refresh/`; - const response = await this.api.fetcher.fetch({ - method: "post", - url: new URL(`${this.api.baseUrl}${urlPath}`), - path: urlPath, - }); - if (!response.ok) { - throw new Error(`Failed to request home refresh: ${response.status}`); - } - } - - async getCodeWorkflow(): Promise { - const teamId = await this.getTeamId(); - const urlPath = `/api/projects/${teamId}/code_workflow/`; - const response = await this.api.fetcher.fetch({ - method: "get", - url: new URL(`${this.api.baseUrl}${urlPath}`), - path: urlPath, - }); - if (!response.ok) { - throw new Error(`Workflow request failed: ${response.status}`); - } - return response.json(); - } - - // 409/422 carry a structured save-result body the caller validates. - async saveCodeWorkflow(body: { - config: unknown; - expectedVersion: number; - }): Promise { - const teamId = await this.getTeamId(); - const urlPath = `/api/projects/${teamId}/code_workflow/save/`; - const response = await this.api.fetcher.fetch({ - method: "post", - url: new URL(`${this.api.baseUrl}${urlPath}`), - path: urlPath, - overrides: { - body: JSON.stringify(body), - }, - }); - if (!response.ok && response.status !== 409 && response.status !== 422) { - throw new Error(`Workflow request failed: ${response.status}`); - } - return response.json(); - } - - async resetCodeWorkflow(): Promise { - const teamId = await this.getTeamId(); - const urlPath = `/api/projects/${teamId}/code_workflow/reset/`; - const response = await this.api.fetcher.fetch({ - method: "post", - url: new URL(`${this.api.baseUrl}${urlPath}`), - path: urlPath, - }); - if (!response.ok) { - throw new Error(`Workflow request failed: ${response.status}`); - } - return response.json(); - } - async listSignalSourceConfigs( projectId: number, ): Promise { diff --git a/packages/core/src/home/prSnapshot.ts b/packages/core/src/home/prSnapshot.ts deleted file mode 100644 index a93c33fc62..0000000000 --- a/packages/core/src/home/prSnapshot.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; - -// Canonical PR snapshot the home tab classifies against. Produced server-side -// by PostHog and embedded in each workstream of the home snapshot -// (docs/workflow-architecture.md §5). - -export const prSnapshotState = z.enum(["open", "draft", "merged", "closed"]); -export type PrSnapshotState = z.infer; - -export const prCiStatus = z.enum(["passing", "failing", "pending", "none"]); -export type PrCiStatus = z.infer; - -export const prReviewDecision = z.enum([ - "approved", - "changes_requested", - "review_required", -]); -export type PrReviewDecision = z.infer; - -export const prSnapshot = z - .object({ - url: z.string(), - number: z.number().int().nonnegative(), - title: z.string(), - state: prSnapshotState, - ciStatus: prCiStatus, - reviewDecision: prReviewDecision.nullable(), - unresolvedThreads: z.number().int().nonnegative(), - /** GitHub mergeability: true / false / null when unknown. */ - mergeable: z.boolean().nullable(), - isCurrentUserRequestedReviewer: z.boolean(), - isCurrentUserAuthor: z.boolean(), - author: z.string().nullable(), - /** Epoch ms of the PR's last update on GitHub. */ - lastUpdatedAt: z.number(), - }) - .strict(); -export type PrSnapshot = z.infer; diff --git a/packages/core/src/home/schemas.test.ts b/packages/core/src/home/schemas.test.ts deleted file mode 100644 index b4cd7c218e..0000000000 --- a/packages/core/src/home/schemas.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { saveResult, workflowConfig } from "../workflow/schemas"; -import { EMPTY_HOME_SNAPSHOT, homeSnapshot } from "./schemas"; - -// Wire-contract validation: zod schemas reject malformed payloads and accept -// valid ones. Fixture shapes mirror the upstream service tests so the validated -// wire contract stays identical. - -const BINDINGS = { - working: [], - in_review: [], - ci_failing: [], - changes_requested: [], - comments_waiting: [], - ready_to_merge: [], - stale: [], - done: [], -}; - -const VALID_SNAPSHOT = { - activeAgents: [ - { - taskId: "t1", - title: "Fix bug", - repoName: null, - branch: null, - status: "in_progress", - lastActivityAt: 1, - needsPermission: false, - cloudPrUrl: null, - }, - ], - needsAttention: [], - inProgress: [], -}; - -const VALID_CONFIG = { - id: "wf_1", - version: 2, - updatedAt: "2026-01-01T00:00:00Z", - bindings: BINDINGS, -}; - -describe("homeSnapshot schema", () => { - it("parses a valid snapshot", () => { - expect(homeSnapshot.safeParse(VALID_SNAPSHOT).success).toBe(true); - }); - - it("rejects a snapshot with an unknown extra key (strict)", () => { - const withExtra = { ...VALID_SNAPSHOT, unknownKey: true }; - expect(homeSnapshot.safeParse(withExtra).success).toBe(false); - }); - - it("rejects a snapshot with a bad status enum on an active agent", () => { - const withBadEnum = { - ...VALID_SNAPSHOT, - activeAgents: [ - { ...VALID_SNAPSHOT.activeAgents[0], status: "unknown_status" }, - ], - }; - expect(homeSnapshot.safeParse(withBadEnum).success).toBe(false); - }); - - it("parses EMPTY_HOME_SNAPSHOT", () => { - expect(homeSnapshot.safeParse(EMPTY_HOME_SNAPSHOT).success).toBe(true); - }); -}); - -describe("workflowConfig schema", () => { - it("parses a valid config", () => { - expect(workflowConfig.safeParse(VALID_CONFIG).success).toBe(true); - }); - - it("rejects a config missing required fields", () => { - const { id: _id, ...withoutId } = VALID_CONFIG; - expect(workflowConfig.safeParse(withoutId).success).toBe(false); - }); -}); - -describe("saveResult schema", () => { - it("parses a saved result", () => { - const result = { status: "saved", config: VALID_CONFIG }; - expect(saveResult.safeParse(result).success).toBe(true); - }); - - it("parses a conflict result without config", () => { - const result = { status: "conflict" }; - expect(saveResult.safeParse(result).success).toBe(true); - }); - - it("parses a conflict result with config", () => { - const result = { status: "conflict", config: VALID_CONFIG }; - expect(saveResult.safeParse(result).success).toBe(true); - }); - - it("parses an invalid result with diagnostics", () => { - const diagnostics = [ - { severity: "error", code: "action_empty_prompt", message: "empty" }, - ]; - const result = { status: "invalid", diagnostics }; - expect(saveResult.safeParse(result).success).toBe(true); - }); - - it("rejects a result with an unknown status discriminant", () => { - const result = { status: "unknown" }; - expect(saveResult.safeParse(result).success).toBe(false); - }); - - it("rejects a malformed saved result missing config", () => { - const result = { status: "saved" }; - expect(saveResult.safeParse(result).success).toBe(false); - }); -}); diff --git a/packages/core/src/home/schemas.ts b/packages/core/src/home/schemas.ts deleted file mode 100644 index 0ab439eb53..0000000000 --- a/packages/core/src/home/schemas.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { z } from "zod"; -import { situationId } from "../workflow/schemas"; -import { prSnapshot } from "./prSnapshot"; - -// The Home snapshot wire contract. Produced server-side by PostHog's -// `evaluate-code-workstreams` Temporal worker and served by -// `GET /api/projects/:id/code_home/`; the snapshot query validates the -// response against this schema before handing it to the UI. - -// Mirrors TaskRunStatus in @posthog/shared (the canonical type lives there; -// this is the runtime enum the snapshot schema validates against). -export const taskRunStatus = z.enum([ - "not_started", - "queued", - "in_progress", - "completed", - "failed", - "cancelled", -]); - -export const homeActiveAgent = z - .object({ - taskId: z.string(), - title: z.string(), - repoName: z.string().nullable(), - branch: z.string().nullable(), - status: taskRunStatus, - lastActivityAt: z.number(), - needsPermission: z.boolean(), - cloudPrUrl: z.string().nullable(), - }) - .strict(); -export type HomeActiveAgent = z.infer; - -export const homeWorkstreamTask = z - .object({ - id: z.string(), - title: z.string(), - status: taskRunStatus.nullable(), - isGenerating: z.boolean(), - needsPermission: z.boolean(), - // Label of the Home quick action that started this run, when it came from one. - // Optional for tolerance of snapshots produced before this field shipped. - quickAction: z.string().nullable().optional(), - }) - .strict(); -export type HomeWorkstreamTask = z.infer; - -export const homeWorkstream = z - .object({ - id: z.string(), - repoName: z.string().nullable(), - repoFullPath: z.string().nullable(), - branch: z.string().nullable(), - prUrl: z.string().nullable(), - pr: prSnapshot.nullable(), - tasks: z.array(homeWorkstreamTask), - situations: z.array(situationId), - // The board column to place this workstream in, picked server-side from - // `situations` by priority. Null when no situation applies. - primarySituation: situationId.nullable(), - lastActivityAt: z.number(), - }) - .strict(); -export type HomeWorkstream = z.infer; - -export const homeSnapshot = z - .object({ - activeAgents: z.array(homeActiveAgent), - needsAttention: z.array(homeWorkstream), - inProgress: z.array(homeWorkstream), - }) - .strict(); -export type HomeSnapshot = z.infer; - -export const EMPTY_HOME_SNAPSHOT: HomeSnapshot = { - activeAgents: [], - needsAttention: [], - inProgress: [], -}; diff --git a/packages/core/src/home/workstreamPrompt.test.ts b/packages/core/src/home/workstreamPrompt.test.ts deleted file mode 100644 index 34c3e9f9dd..0000000000 --- a/packages/core/src/home/workstreamPrompt.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { WorkflowAction } from "../workflow/schemas"; -import type { PrSnapshot } from "./prSnapshot"; -import type { HomeWorkstream } from "./schemas"; -import { - buildQuickActionPrompt, - buildSkillPrompt, - buildWorkstreamContext, -} from "./workstreamPrompt"; - -function makeAction(overrides: Partial = {}): WorkflowAction { - return { - id: "a1", - label: "Fix CI", - skillId: "fix-ci", - prompt: "Get the checks green.", - ...overrides, - }; -} - -function makePr(overrides: Partial = {}): PrSnapshot { - return { - url: "https://github.com/posthog/code/pull/2910", - number: 2910, - title: "Add the thing", - state: "open", - ciStatus: "failing", - reviewDecision: null, - unresolvedThreads: 0, - mergeable: true, - isCurrentUserRequestedReviewer: false, - isCurrentUserAuthor: true, - author: "peter", - lastUpdatedAt: 0, - ...overrides, - }; -} - -function makeWs(overrides: Partial = {}): HomeWorkstream { - return { - id: "ws_1", - repoName: "code", - repoFullPath: "PostHog/code", - branch: "feat/the-thing", - prUrl: null, - pr: null, - tasks: [], - situations: [], - primarySituation: null, - lastActivityAt: 0, - ...overrides, - }; -} - -describe("buildSkillPrompt", () => { - it.each([ - { - name: "prefixes the skill command and keeps the body", - action: makeAction({ skillId: "fix-ci", prompt: "Get it green." }), - expected: "/fix-ci\n\nGet it green.", - }, - { - name: "emits just the command when there is no body", - action: makeAction({ skillId: "fix-ci", prompt: " " }), - expected: "/fix-ci", - }, - { - name: "sends the body alone when no skill is bound", - action: makeAction({ skillId: "", prompt: "Do the work." }), - expected: "Do the work.", - }, - ])("$name", ({ action, expected }) => { - expect(buildSkillPrompt(action)).toBe(expected); - }); -}); - -interface PromptCase { - name: string; - workstream: HomeWorkstream; - contains: string[]; - notContains: string[]; -} - -const contextCases: PromptCase[] = [ - { - name: "includes repo, branch, and PR number/url/CI when a PR is present", - workstream: makeWs({ pr: makePr() }), - contains: [ - "- Repository: PostHog/code", - "- Branch: feat/the-thing", - "- Pull request #2910: Add the thing", - "https://github.com/posthog/code/pull/2910", - "CI: failing", - ], - notContains: [], - }, - { - name: "includes review decision and unresolved threads when set", - workstream: makeWs({ - pr: makePr({ reviewDecision: "changes_requested", unresolvedThreads: 3 }), - }), - contains: ["Review: changes_requested", "Unresolved review threads: 3"], - notContains: [], - }, - { - name: "omits review decision and unresolved threads when unset", - workstream: makeWs({ pr: makePr() }), - contains: [], - notContains: ["Review:", "Unresolved review threads"], - }, - { - name: "falls back to the bare PR url when there is no PR snapshot", - workstream: makeWs({ - pr: null, - prUrl: "https://github.com/posthog/code/pull/42", - }), - contains: ["- Pull request: https://github.com/posthog/code/pull/42"], - notContains: [], - }, - { - name: "emits a branch-only block when there is no PR at all", - workstream: makeWs({ pr: null, prUrl: null, branch: "wip" }), - contains: ["- Branch: wip"], - notContains: ["Pull request"], - }, -]; - -describe("buildWorkstreamContext", () => { - it.each(contextCases)("$name", ({ workstream, contains, notContains }) => { - const context = buildWorkstreamContext(workstream); - for (const text of contains) expect(context).toContain(text); - for (const text of notContains) expect(context).not.toContain(text); - }); - - // Exact-match case (asserts emptiness, not substrings), kept separate. - it("returns an empty string when there is nothing to anchor to", () => { - expect( - buildWorkstreamContext( - makeWs({ repoFullPath: null, branch: null, pr: null, prUrl: null }), - ), - ).toBe(""); - }); -}); - -const SKILL_PREFIX = "/fix-ci\n\nGet the checks green."; - -const quickActionCases: PromptCase[] = [ - { - name: "appends the workstream context after the skill prompt", - workstream: makeWs({ pr: makePr() }), - contains: [SKILL_PREFIX, "- Pull request #2910: Add the thing"], - notContains: [], - }, - { - name: "is just the skill prompt when the workstream has no context", - workstream: makeWs({ - repoFullPath: null, - branch: null, - pr: null, - prUrl: null, - }), - contains: [SKILL_PREFIX], - notContains: ["Context for this task"], - }, -]; - -describe("buildQuickActionPrompt", () => { - it.each(quickActionCases)( - "$name", - ({ workstream, contains, notContains }) => { - const prompt = buildQuickActionPrompt(makeAction(), workstream); - expect(prompt.startsWith(SKILL_PREFIX)).toBe(true); - for (const text of contains) expect(prompt).toContain(text); - for (const text of notContains) expect(prompt).not.toContain(text); - }, - ); -}); diff --git a/packages/core/src/home/workstreamPrompt.ts b/packages/core/src/home/workstreamPrompt.ts deleted file mode 100644 index 7b4ee38b14..0000000000 --- a/packages/core/src/home/workstreamPrompt.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { WorkflowAction } from "../workflow/schemas"; -import type { HomeWorkstream } from "./schemas"; - -type SkillAction = Pick; - -// The agent runs the bound skill when the prompt opens with `/`. -export function buildSkillPrompt(action: SkillAction): string { - const body = action.prompt.trim(); - const skillId = action.skillId.trim(); - if (!skillId) return body; - const command = `/${skillId}`; - return body ? `${command}\n\n${body}` : command; -} - -// Anchors a background run to the PR/branch it's meant to act on so it doesn't -// have to ask the user which one. -export function buildWorkstreamContext(workstream: HomeWorkstream): string { - const lines: string[] = []; - if (workstream.repoFullPath) { - lines.push(`- Repository: ${workstream.repoFullPath}`); - } - if (workstream.branch) { - lines.push(`- Branch: ${workstream.branch}`); - } - const pr = workstream.pr; - if (pr) { - lines.push(`- Pull request #${pr.number}: ${pr.title}`); - lines.push(` ${pr.url}`); - lines.push(` CI: ${pr.ciStatus}`); - if (pr.reviewDecision) { - lines.push(` Review: ${pr.reviewDecision}`); - } - if (pr.unresolvedThreads > 0) { - lines.push(` Unresolved review threads: ${pr.unresolvedThreads}`); - } - } else if (workstream.prUrl) { - lines.push(`- Pull request: ${workstream.prUrl}`); - } - if (lines.length === 0) return ""; - const header = - "Context for this task (already known — don't ask the user for it):"; - return `\n\n${header}\n${lines.join("\n")}`; -} - -export function buildQuickActionPrompt( - action: SkillAction, - workstream: HomeWorkstream, -): string { - return `${buildSkillPrompt(action)}${buildWorkstreamContext(workstream)}`; -} diff --git a/packages/core/src/sidebar/sidebarData.types.ts b/packages/core/src/sidebar/sidebarData.types.ts index 6efc20f4e3..706c139150 100644 --- a/packages/core/src/sidebar/sidebarData.types.ts +++ b/packages/core/src/sidebar/sidebarData.types.ts @@ -32,7 +32,6 @@ export type TaskGroup = GenericTaskGroup; export interface SidebarData { isHomeActive: boolean; - isHomeViewActive: boolean; isInboxActive: boolean; isAgentsActive: boolean; isCommandCenterActive: boolean; diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index b09c5f75d2..9a24ccd2e8 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -22,7 +22,6 @@ export interface CreateTaskRunClientOptions { runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: string; - homeQuickAction?: string; importedMcpServers?: CloudMcpServerImport[]; relayedMcpServers?: CloudMcpServerRelayDesignation[]; } diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index f6da0ae29d..2e3d0c5bad 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -408,7 +408,6 @@ export class TaskCreationSaga extends Saga< rtkEnabled: input.cloudRtkEnabled, runSource: input.cloudRunSource ?? "manual", signalReportId: input.signalReportId, - homeQuickAction: input.homeQuickActionLabel, importedMcpServers: input.importedMcpServers, relayedMcpServers: input.relayedMcpServers, initialPermissionMode: diff --git a/packages/core/src/workflow/schemas.ts b/packages/core/src/workflow/schemas.ts deleted file mode 100644 index e5baf88eff..0000000000 --- a/packages/core/src/workflow/schemas.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { z } from "zod"; - -// Order is load-bearing: the renderer iterates this list to render situations in -// a stable order. Classification itself runs server-side in PostHog. -export const SITUATIONS = [ - { - id: "working", - label: "Working", - description: "Branch with changes, no PR yet", - }, - { - id: "in_review", - label: "In review", - description: "PR open, nothing pending from you", - }, - { - id: "ci_failing", - label: "CI failing", - description: "PR open, CI is red", - }, - { - id: "changes_requested", - label: "Changes requested", - description: "A reviewer requested changes", - }, - { - id: "comments_waiting", - label: "Comments waiting", - description: "Unresolved review threads not from you", - }, - { - id: "ready_to_merge", - label: "Ready to merge", - description: "PR open, CI green, approved, mergeable", - }, - { - id: "stale", - label: "Stale", - description: "No activity for a while", - }, - { - id: "done", - label: "Done", - description: "PR merged or closed", - }, -] as const; - -export type SituationId = (typeof SITUATIONS)[number]["id"]; -export const SITUATION_IDS = SITUATIONS.map((s) => s.id) as [ - SituationId, - ...SituationId[], -]; - -export const situationId = z.enum(SITUATION_IDS); - -export const workflowAction = z - .object({ - id: z.string().min(1).max(64), - label: z.string().min(1).max(120), - skillId: z.string(), - prompt: z.string().min(1).max(8_000), - adapter: z.enum(["claude", "codex"]).optional(), - model: z.string().min(1).optional(), - auto: z.boolean().optional(), - }) - .strict(); -export type WorkflowAction = z.infer; - -export const workflowBindings = z.record(situationId, z.array(workflowAction)); -export type WorkflowBindings = z.infer; - -export const workflowConfig = z - .object({ - id: z.string().min(1), - version: z.number().int().nonnegative(), - updatedAt: z.string(), - bindings: workflowBindings, - }) - .strict(); -export type WorkflowConfig = z.infer; - -export const workflowDraft = workflowConfig - .omit({ updatedAt: true }) - .extend({ updatedAt: z.string().optional() }); -export type WorkflowDraft = z.infer; - -export const validationDiagnostic = z - .object({ - severity: z.enum(["error", "warning"]), - code: z.enum([ - "duplicate_action_id", - "action_empty_prompt", - "action_empty_label", - "action_auto_not_bool", - ]), - message: z.string(), - situationId: situationId.optional(), - actionId: z.string().optional(), - }) - .strict(); -export type ValidationDiagnostic = z.infer; - -export const validationResult = z - .object({ - diagnostics: z.array(validationDiagnostic), - canSave: z.boolean(), - }) - .strict(); -export type ValidationResult = z.infer; - -export const saveInput = z - .object({ - config: workflowDraft, - expectedVersion: z.number().int().nonnegative(), - }) - .strict(); -export type SaveInput = z.infer; - -export const saveResult = z.discriminatedUnion("status", [ - z - .object({ - status: z.literal("saved"), - config: workflowConfig, - diagnostics: z.array(validationDiagnostic).optional(), - }) - .strict(), - z - .object({ - status: z.literal("conflict"), - config: workflowConfig.optional(), - diagnostics: z.array(validationDiagnostic).optional(), - }) - .strict(), - z - .object({ - status: z.literal("invalid"), - config: workflowConfig.optional(), - diagnostics: z.array(validationDiagnostic).optional(), - }) - .strict(), -]); -export type SaveResult = z.infer; diff --git a/packages/core/src/workflow/workflowValidate.test.ts b/packages/core/src/workflow/workflowValidate.test.ts deleted file mode 100644 index 483911205e..0000000000 --- a/packages/core/src/workflow/workflowValidate.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SituationId, WorkflowAction, WorkflowDraft } from "./schemas"; -import { validateWorkflow } from "./workflowValidate"; - -function makeAction(overrides: Partial = {}): WorkflowAction { - return { - id: "action_1", - label: "Review", - skillId: "review", - prompt: "Review the diff", - ...overrides, - }; -} - -function makeDraft( - bindings: Partial>, -): WorkflowDraft { - return { - id: "wf_1", - version: 1, - bindings: bindings as WorkflowDraft["bindings"], - }; -} - -describe("validateWorkflow", () => { - it("accepts a fully populated valid draft", () => { - const result = validateWorkflow( - makeDraft({ - working: [makeAction()], - in_review: [makeAction({ id: "action_2", label: "Nudge" })], - }), - ); - expect(result.canSave).toBe(true); - expect(result.diagnostics).toEqual([]); - }); - - it("accepts an empty draft with no bindings", () => { - const result = validateWorkflow(makeDraft({})); - expect(result.canSave).toBe(true); - expect(result.diagnostics).toEqual([]); - }); - - it("flags duplicate action ids within the same situation", () => { - const result = validateWorkflow( - makeDraft({ - working: [makeAction({ id: "dupe" }), makeAction({ id: "dupe" })], - }), - ); - expect(result.canSave).toBe(false); - expect(result.diagnostics).toEqual([ - { - severity: "error", - code: "duplicate_action_id", - message: 'Duplicate action id "dupe" in working', - situationId: "working", - actionId: "dupe", - }, - ]); - }); - - it("does not flag the same id reused across different situations", () => { - const result = validateWorkflow( - makeDraft({ - working: [makeAction({ id: "shared" })], - in_review: [makeAction({ id: "shared" })], - }), - ); - expect(result.canSave).toBe(true); - expect(result.diagnostics).toEqual([]); - }); - - it("flags a whitespace-only label", () => { - const result = validateWorkflow( - makeDraft({ working: [makeAction({ label: " " })] }), - ); - expect(result.canSave).toBe(false); - expect(result.diagnostics).toHaveLength(1); - expect(result.diagnostics[0]).toMatchObject({ - severity: "error", - code: "action_empty_label", - situationId: "working", - actionId: "action_1", - }); - }); - - it("flags a whitespace-only prompt", () => { - const result = validateWorkflow( - makeDraft({ working: [makeAction({ prompt: " \n " })] }), - ); - expect(result.canSave).toBe(false); - expect(result.diagnostics).toHaveLength(1); - expect(result.diagnostics[0]).toMatchObject({ - code: "action_empty_prompt", - situationId: "working", - actionId: "action_1", - }); - }); - - it("reports both empty label and empty prompt for one action", () => { - const result = validateWorkflow( - makeDraft({ working: [makeAction({ label: " ", prompt: " " })] }), - ); - const codes = result.diagnostics.map((d) => d.code).sort(); - expect(codes).toEqual(["action_empty_label", "action_empty_prompt"]); - expect(result.canSave).toBe(false); - }); - - it("skips empty-field checks on a duplicate so it is reported once", () => { - const result = validateWorkflow( - makeDraft({ - working: [ - makeAction({ id: "dupe" }), - makeAction({ id: "dupe", label: " ", prompt: " " }), - ], - }), - ); - expect(result.diagnostics.map((d) => d.code)).toEqual([ - "duplicate_action_id", - ]); - }); - - it("blocks saving only on error-severity diagnostics", () => { - const result = validateWorkflow( - makeDraft({ working: [makeAction({ label: "" })] }), - ); - expect(result.canSave).toBe(false); - expect(result.diagnostics.every((d) => d.severity === "error")).toBe(true); - }); -}); diff --git a/packages/core/src/workflow/workflowValidate.ts b/packages/core/src/workflow/workflowValidate.ts deleted file mode 100644 index ee506de395..0000000000 --- a/packages/core/src/workflow/workflowValidate.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - SITUATION_IDS, - type SituationId, - type ValidationDiagnostic, - type ValidationResult, - type WorkflowDraft, -} from "./schemas"; - -export function validateWorkflow(draft: WorkflowDraft): ValidationResult { - const diagnostics: ValidationDiagnostic[] = []; - - for (const sid of SITUATION_IDS) { - const actions = draft.bindings[sid] ?? []; - const seenIds = new Set(); - for (const action of actions) { - if (seenIds.has(action.id)) { - diagnostics.push({ - severity: "error", - code: "duplicate_action_id", - message: `Duplicate action id "${action.id}" in ${sid}`, - situationId: sid as SituationId, - actionId: action.id, - }); - continue; - } - seenIds.add(action.id); - - if (action.label.trim() === "") { - diagnostics.push({ - severity: "error", - code: "action_empty_label", - message: `An action in ${sid} has no label`, - situationId: sid as SituationId, - actionId: action.id, - }); - } - if (action.prompt.trim() === "") { - diagnostics.push({ - severity: "error", - code: "action_empty_prompt", - message: `Action "${action.label}" in ${sid} has an empty prompt`, - situationId: sid as SituationId, - actionId: action.id, - }); - } - } - } - - const canSave = !diagnostics.some((d) => d.severity === "error"); - return { diagnostics, canSave }; -} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index fed7c06e45..9a2a1a968e 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -17,11 +17,7 @@ export interface PromptHistorySelectedProperties { type ExecutionType = "cloud" | "local"; export type RepositoryProvider = "github" | "gitlab" | "local" | "none"; -type TaskCreatedFrom = - | "cli" - | "command-menu" - | "home-quick-action" - | "sidebar-worktree"; +type TaskCreatedFrom = "cli" | "command-menu" | "sidebar-worktree"; type RepositorySelectSource = "task-creation" | "task-detail"; type GitActionType = | "push" @@ -60,6 +56,7 @@ export type CommandMenuAction = | "open-channel" | "open-command-center" | "open-inbox" + | "open-loops" | "open-usage" | "search-files" | "open-file" @@ -244,7 +241,6 @@ export interface CommandMenuActionProperties { export type SidebarNavItem = | "new_task" - | "home" | "search" | "inbox" | "agents" diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 90161b8a76..3918017164 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -2,7 +2,6 @@ export { BILLING_FLAG, DISCOVERY_RUN_FLAG, EXPERIMENT_SUGGESTIONS_FLAG, - HOME_TAB_FLAG, SYNC_CLOUD_TASKS_FLAG, } from "./flags"; diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 01185290cc..cab52efaec 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -10,7 +10,6 @@ export const EXPERIMENT_SUGGESTIONS_FLAG = export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; /** Autoresearch (metric-optimization loop). Staff-gated while it bakes. */ export const AUTORESEARCH_FLAG = "posthog-code-autoresearch"; -export const HOME_TAB_FLAG = "posthog-code-home-tab"; export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; // Gates the entire canvas feature: the app rail's Channels space, the /website // routes, channels and dashboards. diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 0c354e146a..a330efaa2d 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -100,9 +100,6 @@ export interface TaskCreationInput { * working directory, so non-code tasks (analysis, email) can run repo-less. */ allowNoRepo?: boolean; - // Label of the Home-tab quick action that started this run (e.g. "Fix CI"), so the - // workstream can show which quick actions have been run against it. - homeQuickActionLabel?: string; /** * Continue a Claude Code CLI session by importing its transcript and resuming * with replay. Local mode only; forces the claude adapter. `branch` is what the diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index f803282b2e..b3f9ebeb96 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,7 +1,6 @@ import { BrainIcon, HashIcon, - HouseIcon, PlugsConnectedIcon, RobotIcon, SquaresFourIcon, @@ -125,16 +124,9 @@ type TabRef = { // The top-level app pages that can be a tab. Keyed by useAppView's view.type; // each maps to its canonical route (a task/canvas/channel tab has its own // route, these don't) plus the strip's label + icon. -type AppView = - | "home" - | "inbox" - | "agents" - | "skills" - | "mcp-servers" - | "command-center"; +type AppView = "inbox" | "agents" | "skills" | "mcp-servers" | "command-center"; const APP_VIEW_META: Record = { - home: { label: "Home", icon: }, inbox: { label: "Inbox", icon: }, agents: { label: "Agents", icon: }, skills: { label: "Skills", icon: }, @@ -171,8 +163,8 @@ export function BrowserTabStrip() { // plain task tab (no channel) belongs to the Code experience. The space // decides where a task/blank tab navigates. const inChannels = pathname.startsWith("/website"); - // Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center, - // Home) are tab targets too. useAppView normalizes both the /code routes and + // Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center) + // are tab targets too. useAppView normalizes both the /code routes and // their /website mirrors to the same view.type, so a tab survives either space. const view = useAppView(); const routeAppView: AppView | null = isAppView(view.type) ? view.type : null; @@ -597,9 +589,6 @@ export function BrowserTabStrip() { // A top-level app page — back to its canonical route (literal `to` per // case so the router types stay checked). switch (tab.appView) { - case "home": - navigate({ to: "/code/home", state }); - break; case "inbox": navigate({ to: "/code/inbox", state }); break; diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index ee1d39516c..bed635d6a8 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -4,6 +4,7 @@ import { ChartLine, EnvelopeSimple, HashIcon, + RepeatIcon, } from "@phosphor-icons/react"; import { workspaceIdSet } from "@posthog/core/command-center/eligibility"; import { resolveService } from "@posthog/di/container"; @@ -24,7 +25,7 @@ import { DialogContent, Kbd, } from "@posthog/quill"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { LOOPS_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS, type CommandMenuAction, @@ -59,6 +60,7 @@ import { navigateToChannel, navigateToCommandCenter, navigateToInbox, + navigateToLoops, } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; @@ -141,6 +143,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, ); + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); const { channels } = useChannels({ enabled: bluebirdEnabled }); const taskChannelMap = useTaskChannelMap(channels, { enabled: open && bluebirdEnabled, @@ -278,6 +281,21 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { navigateToCommandCenter(); }, }, + ...(loopsEnabled + ? [ + { + id: "loops", + label: "Loops", + keywords: "automations schedules recurring", + icon: , + action: "open-loops" as CommandMenuAction, + onRun: () => { + closeSettingsDialog(); + navigateToLoops(); + }, + }, + ] + : []), { id: "plan-usage", label: "Plan & usage", @@ -449,6 +467,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { reviewTaskId, canSearchFiles, openFilePicker, + loopsEnabled, ]); const taskSections = useMemo(() => { diff --git a/packages/ui/src/features/home/components/HomeActiveAgentsStrip.tsx b/packages/ui/src/features/home/components/HomeActiveAgentsStrip.tsx deleted file mode 100644 index eee25d0fc2..0000000000 --- a/packages/ui/src/features/home/components/HomeActiveAgentsStrip.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { CircleNotch, GitBranch, Warning } from "@phosphor-icons/react"; -import type { HomeActiveAgent } from "@posthog/core/home/schemas"; -import { formatRelativeTimeShort } from "@posthog/shared"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { openTask } from "@posthog/ui/router/useOpenTask"; -import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -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/packages/ui/src/features/home/components/HomeArchivedSection.tsx b/packages/ui/src/features/home/components/HomeArchivedSection.tsx deleted file mode 100644 index 53db43b4bb..0000000000 --- a/packages/ui/src/features/home/components/HomeArchivedSection.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { - Archive, - CaretDown, - CaretRight, - Cloud as CloudIcon, - GitBranch as GitBranchIcon, - Laptop as LaptopIcon, -} from "@phosphor-icons/react"; -import { - type ArchivedTaskWithDetails, - formatRelativeDate, - getRepoName, -} from "@posthog/core/archive/archiveListView"; -import type { WorkspaceMode } from "@posthog/shared"; -import { useHomeUiStore } from "@posthog/ui/features/home/stores/homeUiStore"; -import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; -import { openTask } from "@posthog/ui/router/useOpenTask"; -import { Box, Flex, Text } from "@radix-ui/themes"; - -// Cap rows shown inline; the full searchable list lives in ArchivedTasksView. -const INLINE_LIMIT = 8; - -function ModeGlyph({ mode }: { mode: WorkspaceMode }) { - const Icon = - mode === "cloud" - ? CloudIcon - : mode === "worktree" - ? GitBranchIcon - : LaptopIcon; - return ; -} - -interface HomeArchivedSectionProps { - items: ArchivedTaskWithDetails[]; -} - -export function HomeArchivedSection({ items }: HomeArchivedSectionProps) { - const expanded = useHomeUiStore((s) => s.archivedExpanded); - const toggleExpanded = useHomeUiStore((s) => s.toggleArchivedExpanded); - - if (items.length === 0) return null; - - const visible = items.slice(0, INLINE_LIMIT); - const hiddenCount = items.length - visible.length; - - return ( - - - - - - - {expanded ? ( - <> - {visible.map((item) => ( - - ))} - {hiddenCount > 0 ? ( - - ) : null} - - ) : null} - - ); -} - -function HomeArchivedRow({ item }: { item: ArchivedTaskWithDetails }) { - const { task, archived } = item; - const title = task?.title ?? "Unknown task"; - const repoName = getRepoName(task?.repository); - - const onOpen = () => { - if (task) { - void openTask(task); - } else { - navigateToArchived(); - } - }; - - return ( - { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - role="button" - tabIndex={0} - aria-label={`Open ${title}`} - className="group flex cursor-pointer items-center gap-3 border-(--gray-3) border-b py-2 pr-3 pl-4 transition-colors hover:bg-(--gray-2)" - > - -
- - {title} - - {repoName !== "—" ? ( - - {repoName} - - ) : null} -
- - {formatRelativeDate(archived.archivedAt)} - -
- ); -} diff --git a/packages/ui/src/features/home/components/HomeBoardView.tsx b/packages/ui/src/features/home/components/HomeBoardView.tsx deleted file mode 100644 index b15347f455..0000000000 --- a/packages/ui/src/features/home/components/HomeBoardView.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { HomeSnapshot } from "@posthog/core/home/schemas"; -import type { SituationId } from "@posthog/core/workflow/schemas"; -import { - buildBoardColumns, - type HomeBoardColumn, -} from "@posthog/ui/features/home/utils/boardColumns"; -import { - SITUATION_VISUAL, - situationCss, -} from "@posthog/ui/features/home/utils/situationDisplay"; -import { ScrollArea } from "@radix-ui/themes"; -import { useMemo } from "react"; -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/packages/ui/src/features/home/components/HomeEmptyState.tsx b/packages/ui/src/features/home/components/HomeEmptyState.tsx deleted file mode 100644 index f1a3db68a7..0000000000 --- a/packages/ui/src/features/home/components/HomeEmptyState.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { CheckCircle, Plus } from "@phosphor-icons/react"; -import { Button } from "@posthog/quill"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; -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/packages/ui/src/features/home/components/HomeView.tsx b/packages/ui/src/features/home/components/HomeView.tsx deleted file mode 100644 index 529f0e2e47..0000000000 --- a/packages/ui/src/features/home/components/HomeView.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { - CircleHalf, - Graph, - House, - Kanban, - ListBullets, - Warning, -} from "@phosphor-icons/react"; -import { Button } from "@posthog/quill"; -import { useHomeArchivedTasks } from "@posthog/ui/features/home/hooks/useHomeArchivedTasks"; -import { useHomeSnapshot } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; -import { - type HomeViewMode, - useHomeUiStore, -} from "@posthog/ui/features/home/stores/homeUiStore"; -import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; -import { DotsCircleSpinner } from "@posthog/ui/primitives/DotsCircleSpinner"; -import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { useEffect, useMemo } from "react"; -import { ConfigMap } from "../config/ConfigMap"; -import { HomeActiveAgentsStrip } from "./HomeActiveAgentsStrip"; -import { HomeArchivedSection } from "./HomeArchivedSection"; -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 { items: archivedItems, isLoading: archivedLoading } = - useHomeArchivedTasks(); - 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 activeHasContent = activeAgents.length > 0 || totalRows > 0; - - return ( - - - - - - - Home - - - {activeHasContent ? ( - - {needsAttention.length > 0 ? ( - - ) : null} - {activeAgents.length > 0 ? ( - - ) : null} - {inProgress.length > 0 ? ( - - ) : null} - - ) : ( - - You're caught up - - )} - - - - - - - - {viewMode === "config" ? ( - - - - ) : ( - <> - - - - {viewMode === "board" ? ( - activeHasContent ? ( - - - - ) : ( - 0} /> - ) - ) : ( - - {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} - - - - {!activeHasContent && - archivedItems.length === 0 && - !archivedLoading ? ( - - ) : 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 > 0 && ( - - {count} - - )} - - {children} - - ); -} diff --git a/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx b/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx deleted file mode 100644 index aa69307734..0000000000 --- a/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { - CircleNotch, - GitBranch, - GitPullRequest, - Sparkle, - Warning, -} from "@phosphor-icons/react"; -import type { HomeWorkstream } from "@posthog/core/home/schemas"; -import { Button } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; -import { useWorkstreamPresentation } from "@posthog/ui/features/home/hooks/useWorkstreamPresentation"; -import { useHomeUiStore } from "@posthog/ui/features/home/stores/homeUiStore"; -import { SITUATION_VISUAL } from "@posthog/ui/features/home/utils/situationDisplay"; -import { Box, Flex, Text } from "@radix-ui/themes"; -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, - canArchive, - hasMenu, - runAction, - isRunningAction, - openTask, - openPr, - archive, - } = 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/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx deleted file mode 100644 index 71743b3109..0000000000 --- a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { - ArrowSquareOut, - CaretDown, - CaretRight, - ChatCircle, - CheckCircle, - CircleDashed, - GitBranch, - GitPullRequest, - Sparkle, - Spinner, - Warning, - X, - XCircle, -} from "@phosphor-icons/react"; -import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; -import type { - HomeWorkstream, - HomeWorkstreamTask, -} from "@posthog/core/home/schemas"; -import { Badge, Button } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; -import type { TaskRunStatus } from "@posthog/shared/domain-types"; -import { - type BoundAction, - useBoundActions, -} from "@posthog/ui/features/home/hooks/useBoundActions"; -import { useRunWorkstreamAction } from "@posthog/ui/features/home/hooks/useRunWorkstreamAction"; -import { useQuickActionStore } from "@posthog/ui/features/home/stores/quickActionStore"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { openTask } from "@posthog/ui/router/useOpenTask"; -import { openUrlInBrowser } from "@posthog/ui/utils/browser"; -import { Box, DropdownMenu, Flex, Text } from "@radix-ui/themes"; -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 { run: runAction } = useRunWorkstreamAction(); - const isRunningAction = useQuickActionStore( - (s) => !!s.inFlight[workstream.id], - ); - - 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/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx b/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx deleted file mode 100644 index e06711d6ff..0000000000 --- a/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { - ChatCircle, - CircleNotch, - GitBranch, - GitPullRequest, - Sparkle, - Warning, -} from "@phosphor-icons/react"; -import type { HomeWorkstream } from "@posthog/core/home/schemas"; -import { Button } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; -import { useWorkstreamPresentation } from "@posthog/ui/features/home/hooks/useWorkstreamPresentation"; -import { useHomeUiStore } from "@posthog/ui/features/home/stores/homeUiStore"; -import { Box, Flex, Text } from "@radix-ui/themes"; -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, - quickActions, - primaryBound, - restBound, - primaryIsPr, - primaryIsTask, - showPrInMenu, - showTaskInMenu, - canArchive, - hasMenu, - runAction, - isRunningAction, - openTask, - openPr, - archive, - } = 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 - - ), - }); - } - if (quickActions.length > 0) { - meta.push({ - key: "quick-actions", - node: ( - - - {quickActions.slice(0, 2).join(", ")} - {quickActions.length > 2 ? ` +${quickActions.length - 2}` : ""} - - ), - }); - } - - 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/packages/ui/src/features/home/components/SituationChip.tsx b/packages/ui/src/features/home/components/SituationChip.tsx deleted file mode 100644 index 71f995bb1b..0000000000 --- a/packages/ui/src/features/home/components/SituationChip.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { SituationId } from "@posthog/core/workflow/schemas"; -import { - SITUATION_VISUAL, - situationCss, -} from "@posthog/ui/features/home/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/packages/ui/src/features/home/components/WorkstreamBits.tsx b/packages/ui/src/features/home/components/WorkstreamBits.tsx deleted file mode 100644 index 9c4c5c0c72..0000000000 --- a/packages/ui/src/features/home/components/WorkstreamBits.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { - Archive, - ArrowSquareOut, - CheckCircle, - CircleNotch, - DotsThree, - GitPullRequest, - Sparkle, - XCircle, -} from "@phosphor-icons/react"; -import type { PrCiStatus } from "@posthog/core/home/prSnapshot"; -import type { SituationId } from "@posthog/core/workflow/schemas"; -import { Button } from "@posthog/quill"; -import type { BoundAction } from "@posthog/ui/features/home/hooks/useBoundActions"; -import { - SITUATION_VISUAL, - situationCss, -} from "@posthog/ui/features/home/utils/situationDisplay"; -import { DropdownMenu, Text } from "@radix-ui/themes"; -import { Fragment } from "react"; - -/** 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, - showArchive = false, - onRun, - onOpenPr, - onOpenTask, - onArchive, - size = "sm", - runDisabled = false, -}: { - restBound: BoundAction[]; - showPrInMenu: boolean; - showTaskInMenu: boolean; - /** Whether to offer "Archive" in the menu. */ - showArchive?: boolean; - onRun: (action: BoundAction) => void; - onOpenPr: () => void; - onOpenTask: () => void; - onArchive?: () => void; - size?: "sm" | "xs"; - /** Disables the task-starting actions while one is already in flight. */ - runDisabled?: boolean; -}) { - 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} - {showArchive && onArchive ? ( - <> - {restBound.length > 0 || showPrInMenu || showTaskInMenu ? ( - - ) : null} - - - Archive - - - ) : 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/packages/ui/src/features/home/config/ActionEditorPanel.tsx b/packages/ui/src/features/home/config/ActionEditorPanel.tsx deleted file mode 100644 index cab611fffe..0000000000 --- a/packages/ui/src/features/home/config/ActionEditorPanel.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { ArrowDown, ArrowUp, Trash, X } from "@phosphor-icons/react"; -import { - SITUATIONS, - type SituationId, - type WorkflowAction, -} from "@posthog/core/workflow/schemas"; -import { Button } from "@posthog/quill"; -import { useSkillsForPicker } from "@posthog/ui/features/home/hooks/useSkillsForPicker"; -import { useWorkflowEditorStore } from "@posthog/ui/features/home/stores/workflowEditorStore"; -import { UnifiedModelSelector } from "@posthog/ui/features/sessions/components/UnifiedModelSelector"; -import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; -import { usePreviewConfig } from "@posthog/ui/features/task-detail/hooks/usePreviewConfig"; -import { Combobox } from "@posthog/ui/primitives/combobox/Combobox"; -import type { ComboboxSearchKeys } from "@posthog/ui/primitives/combobox/useComboboxFilter"; -import { - Card, - Flex, - Switch, - Text, - TextArea, - TextField, -} from "@radix-ui/themes"; -import { useMemo } from "react"; -import { SITUATION_TONE } from "./workflowMapLayout"; - -interface Props { - situationId: SituationId; - action: WorkflowAction; - totalInSituation: number; - indexInSituation: number; -} - -type SkillOption = ReturnType["skills"][number]; - -// Name-first so a prefix match on the name wins the exact-match promotion. -const skillSearchValue = (s: SkillOption) => `${s.name} ${s.description}`; - -// Weight the skill name above its description so a name hit ranks first. -const SKILL_SEARCH_KEYS: ComboboxSearchKeys = [ - { name: "name", weight: 0.7 }, - { name: "description", weight: 0.3 }, -]; - -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 })} - /> - - - - patch({ skillId: v })} - size="2" - > - - {selectedSkill?.name} - - - {({ filtered, hasMore, moreCount }) => ( - <> - - - {isLoading ? "Loading skills…" : "No matching skills"} - - {filtered.map((s) => ( - - {s.name} - - ))} - {hasMore && ( - - {moreCount} more; type to filter - - )} - - )} - - - {selectedSkill ? ( - - {selectedSkill.description} - - ) : null} - - - -