Files
turbostarter/apps/web/src/modules/copilot/components/CopilotPanel.tsx
Alejandro Gutiérrez c4379afe1f
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
feat: implement Stories 3.4, 3.5, 3.6 — AI proposals, wizard, hover & palette
Story 3.4: AI semantic suggestions with accept/reject workflow
- ProposalBar overlay with visual diff
- Accept/reject flow with graph snapshot restore
- useProposalDiff hook for change summary
- System prompt scoping for selected elements

Story 3.5: New diagram wizard with AI type inference
- CreateDiagramDialog with AI type inference (Haiku)
- initialDescription prop for chat-first flow
- Auto-send on mount with hasSentInitial ref guard
- DB migration for diagram description column

Story 3.6: Hover affordances and command palette
- HoverAffordances toolbar (5 AI actions, debounced)
- CommandPalette (Cmd+K) with AI, nav, Go to Node
- prefillChat/fitViewRequested/focusNodeId actions
- Code review: getNodesBounds, onOpenRightPanel,
  timer cleanup, test count fix

374 tests passing (251 web + 123 AI).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:55:06 +00:00

659 lines
22 KiB
TypeScript

"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence } from "motion/react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { api } from "~/lib/api/client";
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
import { Prose } from "~/modules/common/prose";
import { useGraphStore } from "~/modules/diagram/stores/useGraphStore";
import { flowToGraph } from "~/modules/diagram/lib/graph-converter";
import { useGraphMutation, persistGraphData } from "../hooks/useGraphMutation";
import { useProposalDiff } from "../hooks/useProposalDiff";
import { BadgeChip } from "./BadgeChip";
import type { DiagramType } from "~/modules/diagram/types/graph";
type ProposalStatus = "idle" | "pending" | "accepted" | "rejected";
/** Shared accept handler — used by CopilotPanel, AssistantBubble, ProposalBar, DiagramCanvas */
export function acceptCurrentProposal(diagramId: string) {
const store = useGraphStore.getState();
const patch = store.proposedPatch;
store.acceptProposal();
if (patch) {
persistGraphData(diagramId, patch);
}
}
/** Shared reject handler */
export function rejectCurrentProposal() {
useGraphStore.getState().rejectProposal();
}
// Type helper for tool invocation parts from AI SDK
interface ToolPart {
type: string;
toolCallId: string;
state: "input-streaming" | "input-available" | "output-available" | "output-error";
output?: unknown;
input?: unknown;
}
function isGenerateDiagramTool(part: { type: string }): part is ToolPart {
return part.type === "tool-generateDiagram";
}
interface CopilotPanelProps {
diagramId: string;
diagramType: DiagramType;
initialDescription?: string;
isSharedView?: boolean;
}
export function CopilotPanel({ diagramId, diagramType, initialDescription, isSharedView }: CopilotPanelProps) {
const chatId = useMemo(() => `copilot-${diagramId}`, [diagramId]);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const userScrolledRef = useRef(false);
const appliedToolCallIds = useRef(new Set<string>());
const hasSentInitial = useRef(false);
const { proposeGraphPatch } = useGraphMutation(diagramId, diagramType);
const proposalStatus = useGraphStore((s) => s.proposalStatus);
const lastProposalOutcome = useGraphStore((s) => s.lastProposalOutcome);
const { changeSummary } = useProposalDiff();
// Subscribe to prefillChat from hover affordances / command palette
const prefillChat = useGraphStore((s) => s.prefillChat);
useEffect(() => {
if (prefillChat) {
setInput(prefillChat.text);
// Position cursor at end of pre-filled text
requestAnimationFrame(() => {
const el = inputRef.current;
if (el) {
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
});
useGraphStore.getState().clearPrefillChat();
}
}, [prefillChat]);
// Subscribe to selected nodes for badge chips
const selectedNodeIds = useGraphStore((s) => s.selectedNodeIds);
const storeNodes = useGraphStore((s) => s.nodes);
const storeEdges = useGraphStore((s) => s.edges);
const selectedElements = useMemo(() => {
if (selectedNodeIds.length === 0) return [];
const nodeMap = new Map(storeNodes.map((n) => [n.id, n]));
return selectedNodeIds
.map((id) => {
const node = nodeMap.get(id);
if (!node) return null;
const data = node.data as { type?: string; label?: string };
return { id: node.id, type: data.type ?? "unknown", label: data.label ?? node.id };
})
.filter((e): e is { id: string; type: string; label: string } => e !== null);
}, [selectedNodeIds, storeNodes]);
const handleDismissBadge = useCallback((nodeId: string) => {
const current = useGraphStore.getState().selectedNodeIds;
useGraphStore.getState().setSelectedNodeIds(current.filter((id) => id !== nodeId));
}, []);
// Scope indicator: show what context the AI will see
const scopeInfo = useMemo(() => {
if (selectedElements.length === 0) return null;
const selectedIds = new Set(selectedNodeIds);
const connectedCount = storeEdges.filter(
(e) => selectedIds.has(e.source) || selectedIds.has(e.target),
).length;
const label =
selectedElements.length === 1
? selectedElements[0]!.label
: `${selectedElements.length} elements`;
return `Context: ${label} + ${connectedCount} connected edge${connectedCount !== 1 ? "s" : ""}`;
}, [selectedElements, selectedNodeIds, storeEdges]);
// Dynamic placeholder text
const placeholder = useMemo(() => {
if (selectedElements.length === 0) return "Describe what you want to build...";
if (selectedElements.length === 1) return `Describe changes to ${selectedElements[0]!.label}...`;
return `Describe changes to ${selectedElements.length} elements...`;
}, [selectedElements]);
// Fetch existing chat history on mount (H1 fix)
const { data: initialMessages } = useQuery({
queryKey: ["copilot", "messages", chatId],
queryFn: async () => {
const res = await api.ai.copilot.messages.$get({
query: { chatId },
});
if (!res.ok) return [];
return res.json();
},
staleTime: Infinity,
});
// Memoize transport to avoid recreation on every render (M2 fix)
const transport = useMemo(
() =>
new DefaultChatTransport({
api: api.ai.copilot.$url().toString(),
prepareSendMessagesRequest: ({ messages, id }) => {
const lastMessage = messages.at(-1);
// Serialize current graph state for AI context
const currentNodes = useGraphStore.getState().nodes;
const currentEdges = useGraphStore.getState().edges;
const graphContext =
currentNodes.length > 0
? JSON.stringify(
flowToGraph(currentNodes, currentEdges, {
version: "1",
title: "",
diagramType,
}),
)
: undefined;
// Build selected element context for targeted modifications
const currentSelectedIds = useGraphStore.getState().selectedNodeIds;
const selectedEls =
currentSelectedIds.length > 0
? currentSelectedIds
.map((nid) => {
const node = currentNodes.find((n) => n.id === nid);
if (!node) return null;
const data = node.data as { type?: string; label?: string };
return { id: node.id, type: data.type ?? "unknown", label: data.label ?? node.id };
})
.filter(Boolean)
: undefined;
return {
body: {
...lastMessage,
chatId: id,
diagramId,
diagramType,
graphContext,
selectedElements: selectedEls,
},
};
},
}),
[diagramId, diagramType],
);
const { messages, sendMessage, status, error, stop, setMessages } = useChat({
id: chatId,
transport,
onError: (err) => {
console.error("[copilot]", err);
toast.error("Failed to get AI response");
},
});
// Seed chat with persisted history once loaded
useEffect(() => {
if (initialMessages && initialMessages.length > 0 && messages.length === 0) {
setMessages(
initialMessages.map((m) => ({
...m,
createdAt: new Date(),
})),
);
}
}, [initialMessages, messages.length, setMessages]);
// Auto-send initial description from wizard (chat-first onboarding)
useEffect(() => {
if (
initialDescription &&
!hasSentInitial.current &&
messages.length === 0 &&
!initialMessages?.length
) {
hasSentInitial.current = true;
void sendMessage({ text: initialDescription, metadata: {} });
// Clean only the desc URL param without navigation
const url = new URL(window.location.href);
url.searchParams.delete("desc");
window.history.replaceState({}, "", url.pathname + url.search);
}
}, [initialDescription, messages.length, initialMessages, sendMessage]);
// Detect and apply graph patches from tool invocations
useEffect(() => {
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (
isGenerateDiagramTool(part) &&
part.state === "output-available" &&
!appliedToolCallIds.current.has(part.toolCallId)
) {
appliedToolCallIds.current.add(part.toolCallId);
const result = part.output as
| { success: true; data: Parameters<typeof applyGraphPatch>[0] }
| { success: false; errors: string[] };
if (result.success) {
proposeGraphPatch(result.data);
} else {
toast.error("Diagram generation failed: invalid graph structure");
console.error("[copilot] Graph validation errors:", result.errors);
}
}
}
}
}, [messages, proposeGraphPatch]);
const isSubmitting = status === "submitted" || status === "streaming";
// Check if currently generating a diagram (tool call in progress)
const isGeneratingDiagram = useMemo(() => {
const lastMessage = messages.at(-1);
if (!lastMessage || lastMessage.role !== "assistant") return false;
return lastMessage.parts.some(
(p) =>
isGenerateDiagramTool(p) &&
(p.state === "input-streaming" || p.state === "input-available"),
);
}, [messages]);
// Auto-scroll on new content, but pause if user scrolled up
useEffect(() => {
if (userScrolledRef.current) return;
const viewport = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
);
if (viewport) {
viewport.scrollTop = viewport.scrollHeight;
}
}, [messages]);
// Detect user scroll
useEffect(() => {
const viewport = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
);
if (!viewport) return;
let timeoutId: NodeJS.Timeout;
const handleScroll = () => {
const isAtBottom =
Math.abs(
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight,
) < 80;
userScrolledRef.current = !isAtBottom;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
userScrolledRef.current = false;
}, 2000);
};
viewport.addEventListener("scroll", handleScroll);
return () => {
viewport.removeEventListener("scroll", handleScroll);
clearTimeout(timeoutId);
};
}, []);
const handleSend = useCallback(() => {
const text = input.trim();
if (!text || isSubmitting) return;
void sendMessage({
text,
metadata: {},
});
setInput("");
userScrolledRef.current = false;
}, [input, isSubmitting, sendMessage]);
const handleAccept = useCallback(() => {
acceptCurrentProposal(diagramId);
}, [diagramId]);
const handleReject = useCallback(() => {
rejectCurrentProposal();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const currentProposalStatus = useGraphStore.getState().proposalStatus;
// Proposal pending + Enter + empty textarea → accept
if (e.key === "Enter" && !e.shiftKey && currentProposalStatus === "pending" && !input.trim()) {
e.preventDefault();
handleAccept();
return;
}
// Proposal pending + Escape → reject (don't clear badges)
if (e.key === "Escape" && currentProposalStatus === "pending") {
e.preventDefault();
handleReject();
return;
}
// Normal Enter → send message
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Normal Escape → clear badges
if (e.key === "Escape" && selectedNodeIds.length > 0) {
e.preventDefault();
useGraphStore.getState().setSelectedNodeIds([]);
}
},
[handleSend, handleAccept, handleReject, selectedNodeIds.length, input],
);
return (
<div className="flex h-full flex-col">
{/* Messages area */}
<ScrollArea ref={scrollRef} className="flex-1">
<div className="flex flex-col gap-1 p-3">
{messages.length === 0 && (
<EmptyState isSharedView={isSharedView} />
)}
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex flex-col gap-1",
message.role === "user" ? "items-end" : "items-start",
)}
>
{message.role === "user" ? (
<UserBubble message={message} />
) : (
<AssistantBubble
message={message}
isStreaming={
status === "streaming" &&
message.id === messages.at(-1)?.id
}
diagramId={diagramId}
proposalStatus={proposalStatus}
lastProposalOutcome={lastProposalOutcome}
changeSummary={changeSummary}
/>
)}
</div>
))}
{/* Typing indicator */}
{status === "submitted" && (
<div className="flex items-start gap-2 py-2">
<div className="flex gap-1">
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:300ms]" />
</div>
</div>
)}
{/* Diagram generation indicator */}
{isGeneratingDiagram && (
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<Icons.Loader2 className="size-3 animate-spin" />
Generating diagram...
</div>
)}
{/* Error display */}
{error && (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Something went wrong. Please try again.
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="shrink-0 border-t border-border p-3">
{/* Badge chips for selected elements */}
{selectedElements.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1" role="list" aria-label="Selected elements">
<AnimatePresence mode="popLayout">
{selectedElements.map((el) => (
<BadgeChip
key={el.id}
nodeId={el.id}
label={el.label}
nodeType={el.type}
onDismiss={handleDismissBadge}
/>
))}
</AnimatePresence>
{scopeInfo && (
<p className="w-full text-[10px] text-muted-foreground/60 mt-0.5">
{scopeInfo}
</p>
)}
</div>
)}
<div className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 pr-20 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
{/* Mic button placeholder (Epic 5) */}
<Button
size="icon"
variant="ghost"
className="size-7"
disabled
title="Voice input (coming soon)"
>
<Icons.Mic className="size-3.5 text-muted-foreground" />
</Button>
{/* Send / Stop button */}
{isSubmitting ? (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => stop()}
>
<Icons.Square className="size-3 fill-current" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
className="size-7"
disabled={!input.trim()}
onClick={handleSend}
>
<Icons.ArrowUp className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}
function EmptyState({ isSharedView }: { isSharedView?: boolean }) {
if (isSharedView) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Icons.MessageSquare className="mb-3 size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">
Join the conversation
</p>
<p className="mt-1 text-xs text-muted-foreground/60">
This diagram was shared with you. Type below to start collaborating.
</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Icons.Sparkles className="mb-3 size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">
What are you designing today?
</p>
<p className="mt-1 text-xs text-muted-foreground/60">
Describe your diagram and I'll generate it for you
</p>
</div>
);
}
const UserBubble = memo<{ message: { id: string; parts: Array<{ type: string; text?: string }> } }>(
({ message }) => (
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-primary-foreground">
{message.parts.map((part, i) =>
part.type === "text" ? (
<p key={`${message.id}-${i}`} className="text-sm whitespace-pre-wrap">
{part.text}
</p>
) : null,
)}
</div>
),
);
UserBubble.displayName = "UserBubble";
const SUGGESTION_PATTERN = /^(Note|Consider|Suggestion|Tip):/m;
/** Wrap lines matching suggestion patterns with styled container */
function renderWithSuggestions(text: string, messageId: string, index: number) {
if (!SUGGESTION_PATTERN.test(text)) {
return (
<MemoizedMarkdown
key={`${messageId}-${index}`}
content={text}
id={`copilot-${messageId}-${index}`}
/>
);
}
const lines = text.split("\n");
const segments: Array<{ isSuggestion: boolean; content: string }> = [];
let currentSegment = { isSuggestion: false, content: "" };
for (const line of lines) {
const isSuggestionLine = /^(Note|Consider|Suggestion|Tip):/.test(line.trim());
if (isSuggestionLine !== currentSegment.isSuggestion && currentSegment.content) {
segments.push({ ...currentSegment });
currentSegment = { isSuggestion: isSuggestionLine, content: line };
} else {
currentSegment.content += (currentSegment.content ? "\n" : "") + line;
}
}
if (currentSegment.content) segments.push(currentSegment);
return segments.map((seg, si) =>
seg.isSuggestion ? (
<div
key={`${messageId}-${index}-sug-${si}`}
className="my-1.5 flex items-start gap-2 rounded-md border-l-2 border-[var(--ai-accent)] bg-muted/30 px-3 py-2"
>
<Icons.Lightbulb className="mt-0.5 size-3.5 shrink-0 text-[var(--ai-accent)]" />
<div className="min-w-0 flex-1">
<MemoizedMarkdown
content={seg.content}
id={`copilot-${messageId}-${index}-sug-${si}`}
/>
</div>
</div>
) : (
<MemoizedMarkdown
key={`${messageId}-${index}-txt-${si}`}
content={seg.content}
id={`copilot-${messageId}-${index}-txt-${si}`}
/>
),
);
}
const AssistantBubble = memo<{
message: { id: string; parts: Array<{ type: string; text?: string; state?: string }> };
isStreaming: boolean;
diagramId: string;
proposalStatus: ProposalStatus;
lastProposalOutcome: "accepted" | "rejected" | null;
changeSummary: string;
}>(({ message, isStreaming, diagramId, proposalStatus, lastProposalOutcome, changeSummary }) => {
const hasToolResult = message.parts.some(
(p) => isGenerateDiagramTool(p) && p.state === "output-available",
);
const handleAccept = useCallback(() => {
acceptCurrentProposal(diagramId);
}, [diagramId]);
const handleReject = useCallback(() => {
rejectCurrentProposal();
}, []);
return (
<div className="max-w-[95%]">
<Prose className="text-sm">
{message.parts.map((part, i) =>
part.type === "text" && part.text
? renderWithSuggestions(part.text, message.id, i)
: null,
)}
{isStreaming && message.parts.length === 0 && (
<span className="inline-block size-2 animate-pulse rounded-full bg-muted-foreground/50" />
)}
</Prose>
{hasToolResult && proposalStatus === "pending" && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">{changeSummary}</span>
<Button size="sm" onClick={handleAccept}>
<Icons.Check className="mr-1 size-3" /> Accept
</Button>
<Button size="sm" variant="ghost" onClick={handleReject}>
<Icons.X className="mr-1 size-3" /> Reject
</Button>
</div>
)}
{hasToolResult && proposalStatus !== "pending" && lastProposalOutcome === "rejected" && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Icons.X className="size-3 text-red-500" />
Changes discarded
</div>
)}
{hasToolResult && proposalStatus !== "pending" && lastProposalOutcome !== "rejected" && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Icons.Check className="size-3 text-green-500" />
Diagram updated
</div>
)}
</div>
);
});
AssistantBubble.displayName = "AssistantBubble";