376 lines
14 KiB
TypeScript
376 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 "what needs me?" — 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 "what needs my approval?" — 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} />
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|