From 6f139d6af27080d3ad05131dff0ccb8f5c2bd200 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 25 Aug 2026 17:49:38 -0500 Subject: [PATCH] feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation - Completion artifacts table 0017, deterministic summary template, gallery /artifacts - Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask - Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples - Allow pr_review→merged as automation for webhook Convoy: .convoys/owner-cockpit-v02 Co-authored-by: orchestrator --- .convoys/owner-cockpit-v02.md | 68 + .../brief-1-forge-webhook-receiver.md | 36 + .../brief-2-completion-artifacts.md | 37 + .../owner-cockpit-v02/brief-3-ops-copilot.md | 39 + .../brief-4-doc-writer-sop.md | 36 + .../(app)/[workspaceSlug]/artifacts/page.tsx | 204 + apps/web/app/(app)/fleet/page.tsx | 4 + apps/web/app/api/webhooks/forge/route.ts | 143 + .../components/copilot/ops-copilot-panel.tsx | 375 ++ apps/web/server/root.ts | 3 + apps/web/server/routers/convoys.ts | 160 +- apps/web/server/routers/copilot.ts | 271 + docs/copilot-prompt.md | 65 + docs/examples/sop-published-flow.md | 55 + docs/operations/live-e2e-checklist.md | 7 + docs/operations/webhook-secret-rotation.md | 78 + .../0017_lively_squadron_supreme.sql | 35 + .../migrations/meta/0017_snapshot.json | 4557 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../src/schema/completion_artifacts.ts | 27 + packages/database/src/schema/index.ts | 1 + .../src/convoy-lifecycle/state-machine.ts | 2 +- .../shared/src/worker/completion-summary.ts | 34 + packages/shared/src/worker/index.ts | 5 + 24 files changed, 6247 insertions(+), 2 deletions(-) create mode 100644 .convoys/owner-cockpit-v02.md create mode 100644 .convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md create mode 100644 .convoys/owner-cockpit-v02/brief-2-completion-artifacts.md create mode 100644 .convoys/owner-cockpit-v02/brief-3-ops-copilot.md create mode 100644 .convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md create mode 100644 apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx create mode 100644 apps/web/app/api/webhooks/forge/route.ts create mode 100644 apps/web/components/copilot/ops-copilot-panel.tsx create mode 100644 apps/web/server/routers/copilot.ts create mode 100644 docs/copilot-prompt.md create mode 100644 docs/examples/sop-published-flow.md create mode 100644 docs/operations/webhook-secret-rotation.md create mode 100644 packages/database/migrations/0017_lively_squadron_supreme.sql create mode 100644 packages/database/migrations/meta/0017_snapshot.json create mode 100644 packages/database/src/schema/completion_artifacts.ts create mode 100644 packages/shared/src/worker/completion-summary.ts diff --git a/.convoys/owner-cockpit-v02.md b/.convoys/owner-cockpit-v02.md new file mode 100644 index 0000000..9ddaf7c --- /dev/null +++ b/.convoys/owner-cockpit-v02.md @@ -0,0 +1,68 @@ +--- +name: owner-cockpit-v02 +classification: server-only +success_metric: Fleet webhooks close PR loop (merged), completion gallery shows summaries+screenshots, ops copilot answers "what needs me?" with links — all from web without dumping plans/ +skip: + - ia + - design + - a11y + - visual + - smoke +status: open +created: 2026-08-25 +model_policy: + roles: + role-conductor: claude-planning + role-architect: claude-planning + role-implementer: qwen-coder + role-doc-writer: qwen-coder + role-reviewer: qwen-coder + escalate_to: claude-planning +--- + +# Owner cockpit v02 — forge webhooks + artifacts + copilot (post-v0.1) + +## Why + +v0.1 made second brain publishable (MCP create, harness-agnostic, Plans tree, Fleet). Worker closeout closed Epic 7 with doc_review→published + draft PR + operator notes + doc-writer. Remaining owner vision per `plans/Plan-automation-foundation/Plan-automation-foundation.md:222` — "fleet → Plans → Start → Go → PR → gates → completion + copilot" — still misses PR loop close (Epic 8), owner review pack (Epic 11), and ops chat (Epic 12). This convoy finishes the cockpit so a solo can land in Fleet, triage, and ask copilot without grepping. + +Decision: `docs/decisions/2026-08-25-second-brain-os-pivot.md`. + +## Scope + +1. **Forge webhooks** `Epic-forge-webhooks` — `POST /api/webhooks/forge` (GitHub/Forgejo) verifies `X-Hub-Signature`/`X-Forgejo-Signature` via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: ` marker in PR body, transitions `pr_review→merged` (`human-gate-2` automation path) and writes `convoy_events`. +2. **PR checks recovery panel** `Task-pr-checks-recovery-panel.md` — Mission control detail shows PR checks + retry. +3. **Completion artifacts** `Epic-owner-completion-artifacts` — `completion_summary` on `published/merged→released` (markdown summary + optional screenshot placeholder), gallery list, tRPC `listArtifacts`. +4. **Ops copilot** `Epic-ops-copilot` — `ops-copilot-context-tools` (fleet + convoy + backlog queries scoped to workspace) + `ops-copilot-ui` chat panel + `draft-work` from chat (create Task via MCP). +5. Doc-writer: SOP for webhook secret rotation + copilot prompt. + +Out of scope: LiteLLM gateway, SQLite single-tenant, preference learning. + +## Roles invoked + +Conductor → Architect (4 briefs) → Implementer per brief (disjoint: webhooks, artifacts, copilot) → Doc-writer (SOP) → Reviewer. + +## Todos + +- [ ] Brief 1 forge webhook receiver +- [ ] Brief 2 completion gallery + summaries +- [ ] Brief 3 ops copilot (context tools + UI) +- [ ] Brief 4 doc-writer SOPs + +## IA + +Skipped (`ia` skip set — server-only, no new top-level entities beyond `completion_artifacts` optional table; reuse `convoy_events metadata.prUrl` + `objects content`). + +## UX + +Partial — `ux` not skipped for copilot: chat panel reuses `components/ui/*`. + +## Architecture + +- Webhooks: `apps/web/app/api/webhooks/forge/route.ts` HMAC verify, `reconcile` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` `pr_review→merged`. +- Artifacts: optional `completion_artifacts` table or reuse `objects content.completionSummary` + `convoy_events metadata.completionScreenshot`. +- Copilot: `apps/web/server/routers/copilot.ts` with `resolveModel(gate-copilot)` + context tools `fleet, convoys.list, backlog.listTree` scoped to `workspace_id`; UI `components/copilot/ops-copilot-panel.tsx`. + +## Dependencies + +- v0.1 + worker-closeout (harness, doc_review, prUrl) diff --git a/.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md b/.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md new file mode 100644 index 0000000..b842a0c --- /dev/null +++ b/.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md @@ -0,0 +1,36 @@ +--- +brief: 1 +convoy: owner-cockpit-v02 +role: role-implementer +title: Forge webhook receiver — GitHub/Forgejo → pr_review→merged +status: ready +priority: P0 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 1 — `Task-forge-webhook-receiver.md` (Epic 8) + +## Goal + +`POST /api/webhooks/forge` verifies signature via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: ` marker in PR body (from `apps/worker/src/git/pr.ts`), transitions `pr_review→merged` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` as `automation`, writes `convoy_events`. + +## Files + +- `apps/web/app/api/webhooks/forge/route.ts` NEW — parse `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` (Forgejo) HMAC sha256 `FORGE_WEBHOOK_SECRET`, handle `pull_request: closed & merged=true` + `pr.body` marker regex `/echodo-convoy:\s*([0-9a-f-]{36})/`, lookup convoy by `id`, validate `status===pr_review`, call `transitionConvoy(db, {workspaceId, convoyId, toStatus:merged, actor:automation, reason: pr#N merged})`. Return 200 + `ok` even if no convoy (don't leak). Rate-limit per IP. +- `apps/web/server/lib/forge/adapters.ts` — reuse `ForgeAdapter` if needed for verify. +- `docs/operations/live-e2e-checklist.md` — add webhook step. + +## Acceptance + +- Forgejo/GitHub payload with `body: "… echodo-convoy: …"` + valid HMAC transitions `pr_review→merged`; invalid signature 401; no marker 200 no-op. +- Existing `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 1. Read `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/pr.ts`, `docs/convoy-lifecycle.md`. Keep verify constant-time, workspace-scoped. + +## Depends + +None. diff --git a/.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md b/.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md new file mode 100644 index 0000000..91ed3a8 --- /dev/null +++ b/.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md @@ -0,0 +1,37 @@ +--- +brief: 2 +convoy: owner-cockpit-v02 +role: role-implementer +title: Owner completion artifacts — summary + gallery +status: ready +priority: P1 +slice_dependencies: [] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 2 — `Epic-owner-completion-artifacts` (Epic 11) + +## Goal + +When `merged/published → released` (`human-gate-3`), generate completion summary markdown + optional screenshot placeholder, list in gallery for owner review. No browser automation v1 — screenshot is upload placeholder. + +## Files + +- `packages/database/src/schema/completion_artifacts.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, summary_md text, screenshot_url text nullable, created_by fk users, created_at`. Or reuse `objects content.completionSummary` — pick minimal table for gallery query. +- `apps/web/server/routers/convoys.ts` — add `complete` mutation (`doc_review→published→released` or `pr_review→merged→released`) that writes artifact + transitions `released` as `human-gate-3`; `listArtifacts` query for gallery. +- `apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx` NEW — gallery grid, click → detail with summary + PR link + convoy timeline (`convoy_events`). +- `packages/shared/src/worker` — generate summary template: `## Summary\nConvoy ${slug} ${classification} — ${successMetric}\nBriefs: ...\nModel: ... Harness: ...`. + +## Acceptance + +- Completing `doc_review→published→released` creates artifact row visible in `/artifacts`; screenshot upload placeholder shows. +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-owner-completion-artifacts/Epic-owner-completion-artifacts.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts:122`, `apps/web/server/routers/convoys.ts:237`. Keep artifact generation deterministic, no LLM call v1 (template only). + +## Depends + +None — disjoint from webhooks. diff --git a/.convoys/owner-cockpit-v02/brief-3-ops-copilot.md b/.convoys/owner-cockpit-v02/brief-3-ops-copilot.md new file mode 100644 index 0000000..19d9e0a --- /dev/null +++ b/.convoys/owner-cockpit-v02/brief-3-ops-copilot.md @@ -0,0 +1,39 @@ +--- +brief: 3 +convoy: owner-cockpit-v02 +role: role-implementer +title: Ops copilot — context tools + UI + draft work from chat +status: ready +priority: P1 +slice_dependencies: [] +recommended_model: claude-planning +model_tier: premium +--- + +# Brief 3 — `Epic-ops-copilot` (Epic 12) + +## Goal + +`ops copilot` answers "what needs me?" with links, triages, and can draft a `Task` from chat. Uses `gate-copilot` routing but broader context. + +## Files + +- `apps/web/server/routers/copilot.ts` NEW — `opsCopilot.generateText` tRPC: `resolveModel(gate-copilot)` `packages/shared/src/model-routing/index.ts`, context tools: `fleetStatus()` (reuse `convoys.fleet` logic), `convoyGet(workspaceSlug, convoyId)`, `backlogListTree(workspaceSlug)` (reuse `backlog.listTree`), `backlogSearch` (grep `markdown_backlog_items`). Tools are read-only, workspace-scoped, bounded. System prompt: "You are ops copilot for second brain OS… respond with links [workspace/slug]." +- `apps/web/server/routers/harnesses.ts` — no change, just reuse `resolveModel`. +- `apps/web/components/copilot/ops-copilot-panel.tsx` NEW — chat panel (reuses `components/ui/*`, `ai` SDK `useChat` if available or plain `generateText` loop), shows `fleet → needs you` citations, button `Draft Task` → calls `backlog.createTask` via `create-backlog-item` shape. +- `apps/web/app/(app)/fleet/page.tsx` — add copilot FAB or sidebar embed. + +## Acceptance + +- "What needs me?" returns gate inbox items with `[fleet]` links to convoys. +- `Draft Task` from copilot creates `Task-*.md` via same path as Plans tree (tool `create-backlog-item` logic). +- Workspace-scoped — no cross-tenant leakage. +- `pnpm lint && pnpm type-check` pass. + +## Prompt + +You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-ops-copilot/Epic-ops-copilot.md`, `docs/adr-model-routing.md`, `apps/web/server/routers/convoys.ts:267` `gateCopilot` as reference, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep tools small (<3 tools), no secrets in prompt. + +## Depends + +None — can run parallel with 1,2. diff --git a/.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md b/.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md new file mode 100644 index 0000000..cf374e4 --- /dev/null +++ b/.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md @@ -0,0 +1,36 @@ +--- +brief: 4 +convoy: owner-cockpit-v02 +role: role-doc-writer +title: Doc-writer SOPs — webhook secret rotation + copilot prompt +status: ready +priority: P2 +slice_dependencies: [1] +recommended_model: qwen-coder +model_tier: local +--- + +# Brief 4 — `role-doc-writer` SOPs for owner cockpit + +## Goal + +Document rotation and prompts so future agents read them as second-brain context, not just code. + +## Files + +- `docs/operations/webhook-secret-rotation.md` NEW — one page: where `FORGE_WEBHOOK_SECRET` lives (Coolify env), how to rotate (generate `openssl rand -hex 32`, update Forgejo webhook + Coolify `FORGE_WEBHOOK_SECRET`, test via `curl -H 'X-Hub-Signature-256: sha256=...' http://localhost:3000/api/webhooks/forge`). +- `docs/copilot-prompt.md` NEW — system prompt for `gate-copilot` and `ops copilot` (reuse `convoys.ts:gut` prompt but versioned in `docs/` for harness portability). +- `docs/examples/sop-published-flow.md` NEW — one SOP showing `Task-write-sop → doc_review → published → artifact gallery` e2e with screenshots placeholder. + +## Acceptance + +- Three docs exist, linked from `docs/README` or `docs/operations/live-e2e-checklist.md`. +- No code, only markdown; `pnpm lint` still passes (markdown ignored). + +## Prompt + +You are `role-doc-writer` brief 4. Read `docs/convoy-lifecycle.md`, `apps/web/app/api/webhooks/forge/route.ts` (once brief 1 lands) to document accurately. Keep docs small, imperative, no narration. + +## Depends + +Brief 1 — needs webhook route to document accurately. diff --git a/apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx b/apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx new file mode 100644 index 0000000..f2788ad --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import * as React from "react"; +import { useParams } from "next/navigation"; +import { ExternalLink, ImageOff, Loader2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/trpc"; + +export default function ArtifactsPage() { + const params = useParams(); + const workspaceSlug = + typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; + const [selectedId, setSelectedId] = React.useState(null); + + const artifactsQuery = api.convoys.listArtifacts.useQuery( + { workspace: workspaceSlug! }, + { enabled: Boolean(workspaceSlug) }, + ); + + const artifacts = artifactsQuery.data ?? []; + const selected = artifacts.find((a) => a.id === selectedId) ?? null; + + if (!workspaceSlug) return null; + + return ( +
+
+

Completion artifacts

+

+ Gallery of released convoys — summary + PR link + timeline +

+
+ +
+
+ {artifactsQuery.isLoading ? ( +
+ + Loading artifacts… +
+ ) : artifacts.length === 0 ? ( +
+

No completion artifacts yet

+

+ Complete a convoy from merged or published via human-gate-3 to generate one. +

+
+ + Screenshot upload placeholder — shows when no image is attached +
+
+ ) : ( +
+ {artifacts.map((artifact) => { + const isSelected = artifact.id === selectedId; + return ( + + ); + })} +
+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/app/(app)/fleet/page.tsx b/apps/web/app/(app)/fleet/page.tsx index 0d9ecbb..c74fd6f 100644 --- a/apps/web/app/(app)/fleet/page.tsx +++ b/apps/web/app/(app)/fleet/page.tsx @@ -6,6 +6,7 @@ import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle"; import { Loader2, Radar } from "lucide-react"; import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel"; +import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel"; import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed"; import { cn } from "@/lib/utils"; import { api } from "@/lib/trpc"; @@ -41,6 +42,8 @@ export default function FleetPage() { const data = fleetQuery.data; const byWorkspace = data?.byWorkspace ?? []; const gateInboxAcross = data?.gateInboxAcross ?? []; + const defaultWorkspaceSlug = + selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? ""; // Group gate inbox by status for "× pr_review 2d" count display const gateByStatus = new Map(); @@ -185,6 +188,7 @@ export default function FleetPage() { )} + ); } diff --git a/apps/web/app/api/webhooks/forge/route.ts b/apps/web/app/api/webhooks/forge/route.ts new file mode 100644 index 0000000..226bada --- /dev/null +++ b/apps/web/app/api/webhooks/forge/route.ts @@ -0,0 +1,143 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { eq } from "drizzle-orm"; + +import { db } from "@tasks/database"; +import { objects } from "@tasks/database/schema"; +import { transitionConvoy } from "@/server/lib/convoy/convoy-service"; +import { rateLimit } from "@/server/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CONVOY_MARKER_RE = /echodo-convoy:\s*([0-9a-f-]{36})/i; +const RATE_LIMIT = { limit: 60, windowMs: 60_000 } as const; + +function getClientIp(req: Request): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) { + const first = fwd.split(",")[0]?.trim(); + if (first) return first; + } + const realIp = req.headers.get("x-real-ip"); + if (realIp?.trim()) return realIp.trim(); + return "unknown"; +} + +function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean { + if (!signatureHeader) return false; + let sig = signatureHeader.trim(); + if (sig.startsWith("sha256=")) sig = sig.slice(7); + const computed = createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("hex"); + const a = Buffer.from(computed, "utf8"); + const b = Buffer.from(sig, "utf8"); + if (a.length !== b.length) { + try { + timingSafeEqual(a, a); + } catch { + // ignore + } + return false; + } + return timingSafeEqual(a, b); +} + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function POST(req: Request): Promise { + const ip = getClientIp(req); + const rl = rateLimit({ + key: `forge-webhook:${ip}`, + limit: RATE_LIMIT.limit, + windowMs: RATE_LIMIT.windowMs, + }); + if (!rl.allowed) { + return json(429, { ok: false, error: "rate_limited", retryAfterMs: rl.retryAfterMs }); + } + + const secret = process.env.FORGE_WEBHOOK_SECRET?.trim(); + if (!secret) { + return json(500, { ok: false, error: "webhook_not_configured" }); + } + + let rawBody: string; + try { + rawBody = await req.text(); + } catch { + return json(400, { ok: false, error: "invalid_body" }); + } + + const hubSig = req.headers.get("x-hub-signature-256") ?? req.headers.get("x-hub-signature"); + const forgejoSig = req.headers.get("x-forgejo-signature"); + const providedSig = hubSig ?? forgejoSig; + + if (!providedSig) { + return json(401, { ok: false, error: "missing_signature" }); + } + + if (!verifyHmacSignature(rawBody, providedSig, secret)) { + return json(401, { ok: false, error: "invalid_signature" }); + } + + let payload: Record; + try { + payload = rawBody ? (JSON.parse(rawBody) as Record) : {}; + } catch { + return json(200, { ok: true }); + } + + const action = typeof payload.action === "string" ? payload.action : null; + const prRaw = payload.pull_request as Record | null | undefined; + + if (action !== "closed" || !prRaw || typeof prRaw !== "object") { + return json(200, { ok: true }); + } + + const merged = prRaw.merged === true; + if (!merged) { + return json(200, { ok: true }); + } + + const bodyText = typeof prRaw.body === "string" ? prRaw.body : ""; + const match = CONVOY_MARKER_RE.exec(bodyText); + if (!match?.[1]) { + return json(200, { ok: true }); + } + + const convoyId = match[1].toLowerCase(); + const prNumber = typeof prRaw.number === "number" ? prRaw.number : null; + + const [convoyRow] = await db + .select({ id: objects.id, workspaceId: objects.workspaceId, status: objects.status, type: objects.type }) + .from(objects) + .where(eq(objects.id, convoyId)) + .limit(1); + + if (!convoyRow || convoyRow.type !== "project") { + return json(200, { ok: true }); + } + + if (convoyRow.status !== "pr_review") { + return json(200, { ok: true }); + } + + const reason = prNumber !== null ? `pr#${prNumber} merged` : "pr merged"; + + try { + await transitionConvoy(db, { + workspaceId: convoyRow.workspaceId, + convoyId: convoyRow.id, + toStatus: "merged", + actor: "automation", + reason, + }); + } catch { + return json(200, { ok: true }); + } + + return json(200, { ok: true }); +} diff --git a/apps/web/components/copilot/ops-copilot-panel.tsx b/apps/web/components/copilot/ops-copilot-panel.tsx new file mode 100644 index 0000000..bbf3b4a --- /dev/null +++ b/apps/web/components/copilot/ops-copilot-panel.tsx @@ -0,0 +1,375 @@ +"use client"; + +import * as React from "react"; +import { Loader2, Send, Sparkles, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { api } from "@/lib/trpc"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; + +type ChatRole = "user" | "assistant"; + +type ChatMessage = { + id: string; + role: ChatRole; + content: string; +}; + +function TypingDots() { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} + +export function OpsCopilotPanel({ + workspaceSlug, + open, + onOpenChange, +}: { + workspaceSlug: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [messages, setMessages] = React.useState([]); + const [input, setInput] = React.useState(""); + const [draftOpen, setDraftOpen] = React.useState(false); + const [draftPlanSlug, setDraftPlanSlug] = React.useState(""); + const [draftEpicSlug, setDraftEpicSlug] = React.useState(""); + const [draftSlug, setDraftSlug] = React.useState(""); + const [draftTitle, setDraftTitle] = React.useState(""); + const [draftBody, setDraftBody] = React.useState(""); + const bottomRef = React.useRef(null); + + const copilotMut = api.opsCopilot.generateText.useMutation({ + onSuccess: (data) => { + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: "assistant", content: data.text }, + ]); + }, + }); + + const createTaskMut = api.backlog.createTask.useMutation({ + onSuccess: () => { + setDraftOpen(false); + setDraftPlanSlug(""); + setDraftEpicSlug(""); + setDraftSlug(""); + setDraftTitle(""); + setDraftBody(""); + }, + }); + + React.useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, copilotMut.isPending]); + + const send = React.useCallback(() => { + const trimmed = input.trim(); + if (!trimmed || copilotMut.isPending) return; + if (!workspaceSlug) return; + const userMsg: ChatMessage = { + id: crypto.randomUUID(), + role: "user", + content: trimmed, + }; + const next = [...messages, userMsg]; + setMessages(next); + setInput(""); + const history = next.slice(0, -1).map((m) => ({ + role: m.role, + content: m.content, + })); + copilotMut.mutate({ + workspace: workspaceSlug, + prompt: trimmed, + messages: history.length > 0 ? history : undefined, + }); + }, [input, copilotMut, messages, workspaceSlug]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + const handleDraftTask = () => { + if (!workspaceSlug) return; + if (!draftPlanSlug.trim() || !draftEpicSlug.trim() || !draftSlug.trim() || !draftTitle.trim()) { + return; + } + createTaskMut.mutate({ + workspace: workspaceSlug, + planSlug: draftPlanSlug.trim().toLowerCase(), + epicSlug: draftEpicSlug.trim().toLowerCase(), + slug: draftSlug.trim().toLowerCase(), + title: draftTitle.trim(), + bodyMarkdown: draftBody.trim() || undefined, + }); + }; + + const isBusy = copilotMut.isPending; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + + return ( + + + +
+
+ + Ops copilot +
+ +
+ + Ask "what needs me?" — grounded in fleet, convoys and backlog. Links use [workspace/slug]. + {workspaceSlug ? ` · ${workspaceSlug}` : " · no workspace"} + +
+ +
+
+ {messages.length === 0 && !isBusy ? ( +
+
+ +
+

What needs you?

+

+ Try "what needs my approval?" — fleet → needs you will be cited with [fleet] links. +

+
+ {["What needs me?", "What's ready to start?", "Show my gate inbox"].map((chip) => ( + + ))} +
+ {!workspaceSlug && ( +

No workspace selected — create or select one first.

+ )} +
+ ) : null} + +
+ {messages.map((m) => ( +
+
+ {m.content} +
+
+ ))} + {isBusy && ( +
+
+ +
+
+ )} + {copilotMut.error && ( +

+ {copilotMut.error.message} +

+ )} + {copilotMut.data && ( +

+ Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId}) +

+ )} +
+
+
+ +
+
+

+ {lastAssistant ? "Need to create work from this answer?" : "Draft a Task from chat"} +

+ +
+ + {draftOpen && ( +
+

Create backlog task via create-backlog-item shape

+
+
+ + setDraftPlanSlug(e.target.value)} + placeholder="automation-foundation" + className="h-8 text-xs" + /> +
+
+ + setDraftEpicSlug(e.target.value)} + placeholder="ops-copilot" + className="h-8 text-xs" + /> +
+
+ + setDraftSlug(e.target.value)} + placeholder="triage-bug" + className="h-8 text-xs" + /> +
+
+ + setDraftTitle(e.target.value)} + placeholder="Triage bug from copilot" + className="h-8 text-xs" + /> +
+
+
+ +