Submatter/apps/web/components/copilot/ops-copilot-panel.tsx
Randall Stillwell 6f139d6af2 feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- 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
2026-08-25 17:49:38 -05:00

375 lines
14 KiB
TypeScript

"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 (
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
);
}
export function OpsCopilotPanel({
workspaceSlug,
open,
onOpenChange,
}: {
workspaceSlug: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
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<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full max-w-md flex-col p-0 sm:max-w-md">
<SheetHeader className="shrink-0 border-b px-4 py-3 text-left">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="size-5 text-primary" />
<SheetTitle className="text-base">Ops copilot</SheetTitle>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<SheetDescription className="text-xs">
Ask &quot;what needs me?&quot; — grounded in fleet, convoys and backlog. Links use [workspace/slug].
{workspaceSlug ? ` · ${workspaceSlug}` : " · no workspace"}
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{messages.length === 0 && !isBusy ? (
<div className="space-y-3 px-1 py-6 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Sparkles className="size-6 text-primary" />
</div>
<p className="text-sm font-medium">What needs you?</p>
<p className="mx-auto max-w-[22rem] text-xs text-muted-foreground">
Try &quot;what needs my approval?&quot; — fleet → needs you will be cited with [fleet] links.
</p>
<div className="flex flex-wrap justify-center gap-2">
{["What needs me?", "What's ready to start?", "Show my gate inbox"].map((chip) => (
<button
key={chip}
type="button"
onClick={() => setInput(chip)}
className="rounded-full border bg-card px-3 py-1.5 text-xs hover:bg-muted"
>
{chip}
</button>
))}
</div>
{!workspaceSlug && (
<p className="text-xs text-destructive">No workspace selected — create or select one first.</p>
)}
</div>
) : null}
<div className="space-y-3">
{messages.map((m) => (
<div
key={m.id}
className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
m.role === "user"
? "bg-primary text-primary-foreground"
: "border bg-muted",
)}
>
{m.content}
</div>
</div>
))}
{isBusy && (
<div className="flex gap-2">
<div className="rounded-lg border bg-card px-3 py-2">
<TypingDots />
</div>
</div>
)}
{copilotMut.error && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{copilotMut.error.message}
</p>
)}
{copilotMut.data && (
<p className="px-1 text-[10px] text-muted-foreground">
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
</p>
)}
<div ref={bottomRef} />
</div>
</div>
<div className="shrink-0 border-t bg-muted/20 px-3 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[11px] text-muted-foreground">
{lastAssistant ? "Need to create work from this answer?" : "Draft a Task from chat"}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setDraftOpen((v) => !v)}
>
Draft Task
</Button>
</div>
{draftOpen && (
<div className="mb-3 rounded-md border bg-background p-3">
<p className="mb-2 text-xs font-medium">Create backlog task via create-backlog-item shape</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Plan slug</label>
<Input
value={draftPlanSlug}
onChange={(e) => setDraftPlanSlug(e.target.value)}
placeholder="automation-foundation"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Epic slug</label>
<Input
value={draftEpicSlug}
onChange={(e) => setDraftEpicSlug(e.target.value)}
placeholder="ops-copilot"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Task slug</label>
<Input
value={draftSlug}
onChange={(e) => setDraftSlug(e.target.value)}
placeholder="triage-bug"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Title</label>
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="Triage bug from copilot"
className="h-8 text-xs"
/>
</div>
</div>
<div className="mt-2 space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Body markdown</label>
<textarea
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
placeholder="One or two sentences. Will become Task-*.md"
rows={3}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
/>
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setDraftOpen(false)}
className="h-7 text-xs"
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="h-7 text-xs"
disabled={
createTaskMut.isPending ||
!draftPlanSlug.trim() ||
!draftEpicSlug.trim() ||
!draftSlug.trim() ||
!draftTitle.trim() ||
!workspaceSlug
}
onClick={handleDraftTask}
>
{createTaskMut.isPending ? <Loader2 className="size-3 animate-spin" /> : "Create Task"}
</Button>
</div>
{createTaskMut.error && (
<p className="mt-2 text-xs text-destructive">{createTaskMut.error.message}</p>
)}
{createTaskMut.isSuccess && (
<p className="mt-2 text-xs text-green-600">Task created — check Plans tree.</p>
)}
</div>
)}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={workspaceSlug ? "What needs me?" : "Select workspace first"}
disabled={isBusy || !workspaceSlug}
rows={2}
className={cn(
"min-h-[44px] flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
/>
<Button
type="button"
size="icon"
className="h-[44px] w-[44px] shrink-0"
onClick={send}
disabled={isBusy || !input.trim() || !workspaceSlug}
aria-label="Send"
>
{isBusy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</Button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}
export function OpsCopilotFab({ workspaceSlug }: { workspaceSlug: string }) {
const [open, setOpen] = React.useState(false);
return (
<>
<Button
type="button"
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full shadow-lg"
size="icon"
aria-label="Open ops copilot"
>
<Sparkles className="size-5" />
</Button>
<OpsCopilotPanel workspaceSlug={workspaceSlug} open={open} onOpenChange={setOpen} />
</>
);
}