Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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>
659 lines
22 KiB
TypeScript
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";
|