feat: implement Stories 3.4, 3.5, 3.6 — AI proposals, wizard, hover & palette
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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>
This commit is contained in:
@@ -18,8 +18,10 @@
|
||||
/* Edges */
|
||||
--edge-default: oklch(0.65 0.01 286);
|
||||
--edge-selected: oklch(0.623 0.214 260);
|
||||
/* AI (placeholders for future epics) */
|
||||
/* AI */
|
||||
--ai-accent: oklch(0.623 0.214 260);
|
||||
--ai-diff-add: oklch(0.80 0.18 152 / 20%);
|
||||
--ai-diff-remove: oklch(0.58 0.25 27 / 20%);
|
||||
/* Badge chips (element referencing in chat) */
|
||||
--badge-chip-bg: oklch(0.623 0.214 260 / 10%);
|
||||
--badge-chip-border: oklch(0.623 0.214 260 / 30%);
|
||||
@@ -837,4 +839,55 @@
|
||||
stroke: var(--edge-selected) !important;
|
||||
stroke-width: 2.5 !important;
|
||||
}
|
||||
|
||||
/* ── AI Diff Overlay States ─────────────────────────────────────────── */
|
||||
|
||||
.react-flow__node.ai-diff-add {
|
||||
outline: 2px solid var(--ai-diff-add);
|
||||
outline-offset: 2px;
|
||||
animation: ai-diff-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.react-flow__node.ai-diff-remove {
|
||||
opacity: 0.4;
|
||||
outline: 2px dashed var(--ai-diff-remove);
|
||||
outline-offset: 2px;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.react-flow__node.ai-diff-modified {
|
||||
outline: 2px solid var(--ai-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes ai-diff-pulse {
|
||||
0%, 100% { outline-color: var(--ai-diff-add); }
|
||||
50% { outline-color: transparent; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.react-flow__node.ai-diff-add { animation: none; }
|
||||
}
|
||||
|
||||
.react-flow__edge.ai-diff-add path {
|
||||
stroke: oklch(0.80 0.18 152);
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
.react-flow__edge.ai-diff-modified path {
|
||||
stroke: var(--ai-accent);
|
||||
stroke-dasharray: 4 2;
|
||||
}
|
||||
|
||||
.react-flow__edge.ai-diff-remove path {
|
||||
stroke: oklch(0.58 0.25 27);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Hover affordances — only show on devices with hover capability */
|
||||
@media (hover: none) {
|
||||
.hover-affordances {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,29 @@ 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 } from "../hooks/useGraphMutation";
|
||||
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;
|
||||
@@ -38,17 +56,40 @@ function isGenerateDiagramTool(part: { type: string }): part is ToolPart {
|
||||
interface CopilotPanelProps {
|
||||
diagramId: string;
|
||||
diagramType: DiagramType;
|
||||
initialDescription?: string;
|
||||
isSharedView?: boolean;
|
||||
}
|
||||
|
||||
export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
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 { applyGraphPatch } = useGraphMutation(diagramId, diagramType);
|
||||
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);
|
||||
@@ -179,6 +220,23 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
}
|
||||
}, [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) {
|
||||
@@ -195,7 +253,7 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
| { success: false; errors: string[] };
|
||||
|
||||
if (result.success) {
|
||||
applyGraphPatch(result.data);
|
||||
proposeGraphPatch(result.data);
|
||||
} else {
|
||||
toast.error("Diagram generation failed: invalid graph structure");
|
||||
console.error("[copilot] Graph validation errors:", result.errors);
|
||||
@@ -203,7 +261,7 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [messages, applyGraphPatch]);
|
||||
}, [messages, proposeGraphPatch]);
|
||||
|
||||
const isSubmitting = status === "submitted" || status === "streaming";
|
||||
|
||||
@@ -268,18 +326,43 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
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, selectedNodeIds.length],
|
||||
[handleSend, handleAccept, handleReject, selectedNodeIds.length, input],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -288,7 +371,7 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
<ScrollArea ref={scrollRef} className="flex-1">
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
{messages.length === 0 && (
|
||||
<EmptyState />
|
||||
<EmptyState isSharedView={isSharedView} />
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
@@ -308,6 +391,10 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
status === "streaming" &&
|
||||
message.id === messages.at(-1)?.id
|
||||
}
|
||||
diagramId={diagramId}
|
||||
proposalStatus={proposalStatus}
|
||||
lastProposalOutcome={lastProposalOutcome}
|
||||
changeSummary={changeSummary}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -414,7 +501,21 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
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" />
|
||||
@@ -443,31 +544,109 @@ const UserBubble = memo<{ message: { id: string; parts: Array<{ type: string; te
|
||||
);
|
||||
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;
|
||||
}>(({ message, isStreaming }) => {
|
||||
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 ? (
|
||||
<MemoizedMarkdown
|
||||
key={`${message.id}-${i}`}
|
||||
content={part.text}
|
||||
id={`copilot-${message.id}-${i}`}
|
||||
/>
|
||||
) : null,
|
||||
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 && (
|
||||
{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
|
||||
|
||||
@@ -23,6 +23,41 @@ interface GraphPatchData {
|
||||
groups?: GraphData["groups"];
|
||||
}
|
||||
|
||||
function patchToGraphData(patch: GraphPatchData, fallbackType: DiagramType): GraphData {
|
||||
const effectiveDiagramType =
|
||||
(patch.meta.diagramType as DiagramType) ?? fallbackType;
|
||||
|
||||
return {
|
||||
meta: {
|
||||
version: patch.meta.version ?? "1",
|
||||
title: patch.meta.title,
|
||||
diagramType: effectiveDiagramType,
|
||||
layoutDirection: patch.meta.layoutDirection,
|
||||
edgeRouting: patch.meta.edgeRouting,
|
||||
},
|
||||
nodes: patch.nodes,
|
||||
edges: patch.edges,
|
||||
pools: patch.pools,
|
||||
groups: patch.groups,
|
||||
};
|
||||
}
|
||||
|
||||
export function persistGraphData(diagramId: string, graphData: GraphData) {
|
||||
api.diagrams[":id"]
|
||||
.$patch({
|
||||
param: { id: diagramId },
|
||||
json: { graphData: graphData as unknown as Record<string, unknown> },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
toast.error("Failed to save diagram — changes may be lost on reload");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to save diagram — changes may be lost on reload");
|
||||
});
|
||||
}
|
||||
|
||||
export function useGraphMutation(diagramId: string, diagramType: DiagramType) {
|
||||
const setNodes = useGraphStore((s) => s.setNodes);
|
||||
const setEdges = useGraphStore((s) => s.setEdges);
|
||||
@@ -32,23 +67,7 @@ export function useGraphMutation(diagramId: string, diagramType: DiagramType) {
|
||||
|
||||
const applyGraphPatch = useCallback(
|
||||
(patch: GraphPatchData) => {
|
||||
const effectiveDiagramType =
|
||||
(patch.meta.diagramType as DiagramType) ?? diagramType;
|
||||
|
||||
const graphData: GraphData = {
|
||||
meta: {
|
||||
version: patch.meta.version ?? "1",
|
||||
title: patch.meta.title,
|
||||
diagramType: effectiveDiagramType,
|
||||
layoutDirection: patch.meta.layoutDirection,
|
||||
edgeRouting: patch.meta.edgeRouting,
|
||||
},
|
||||
nodes: patch.nodes,
|
||||
edges: patch.edges,
|
||||
pools: patch.pools,
|
||||
groups: patch.groups,
|
||||
};
|
||||
|
||||
const graphData = patchToGraphData(patch, diagramType);
|
||||
const { nodes, edges } = graphToFlow(graphData);
|
||||
|
||||
setNodes(nodes);
|
||||
@@ -62,21 +81,7 @@ export function useGraphMutation(diagramId: string, diagramType: DiagramType) {
|
||||
}
|
||||
|
||||
requestLayout();
|
||||
|
||||
// Persist graphData to database (fire-and-forget with error reporting)
|
||||
api.diagrams[":id"]
|
||||
.$patch({
|
||||
param: { id: diagramId },
|
||||
json: { graphData: graphData as unknown as Record<string, unknown> },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
toast.error("Failed to save diagram — changes may be lost on reload");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to save diagram — changes may be lost on reload");
|
||||
});
|
||||
persistGraphData(diagramId, graphData);
|
||||
},
|
||||
[
|
||||
diagramId,
|
||||
@@ -89,5 +94,13 @@ export function useGraphMutation(diagramId: string, diagramType: DiagramType) {
|
||||
],
|
||||
);
|
||||
|
||||
return { applyGraphPatch };
|
||||
const proposeGraphPatch = useCallback(
|
||||
(patch: GraphPatchData) => {
|
||||
const graphData = patchToGraphData(patch, diagramType);
|
||||
useGraphStore.getState().proposeChanges(graphData);
|
||||
},
|
||||
[diagramType],
|
||||
);
|
||||
|
||||
return { applyGraphPatch, proposeGraphPatch };
|
||||
}
|
||||
|
||||
169
apps/web/src/modules/copilot/hooks/useProposalDiff.test.ts
Normal file
169
apps/web/src/modules/copilot/hooks/useProposalDiff.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
|
||||
import { countNodeDiffs, countEdgeDiffs, buildSummary } from "./useProposalDiff";
|
||||
|
||||
function createTestNode(id: string, label: string, type = "flowProcess"): Node {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: { x: 0, y: 0 },
|
||||
data: { id, label, type },
|
||||
};
|
||||
}
|
||||
|
||||
function createTestEdge(id: string, source: string, target: string): Edge {
|
||||
return { id, source, target };
|
||||
}
|
||||
|
||||
describe("countNodeDiffs", () => {
|
||||
it("should detect added nodes", () => {
|
||||
const previous = [createTestNode("n1", "A")];
|
||||
const proposed = [createTestNode("n1", "A"), createTestNode("n2", "B")];
|
||||
const result = countNodeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.modified).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect removed nodes", () => {
|
||||
const previous = [createTestNode("n1", "A"), createTestNode("n2", "B")];
|
||||
const proposed = [createTestNode("n1", "A")];
|
||||
const result = countNodeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.modified).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect modified nodes by label", () => {
|
||||
const previous = [createTestNode("n1", "Old Label")];
|
||||
const proposed = [createTestNode("n1", "New Label")];
|
||||
const result = countNodeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.modified).toBe(1);
|
||||
});
|
||||
|
||||
it("should detect modified nodes by type", () => {
|
||||
const previous = [createTestNode("n1", "A", "flowProcess")];
|
||||
const proposed = [createTestNode("n1", "A", "flowDecision")];
|
||||
const result = countNodeDiffs(previous, proposed);
|
||||
expect(result.modified).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle mixed changes", () => {
|
||||
const previous = [
|
||||
createTestNode("n1", "A"),
|
||||
createTestNode("n2", "B"),
|
||||
createTestNode("n3", "C"),
|
||||
];
|
||||
const proposed = [
|
||||
createTestNode("n1", "Modified A"),
|
||||
createTestNode("n3", "C"),
|
||||
createTestNode("n4", "New"),
|
||||
];
|
||||
const result = countNodeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.modified).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle empty graphs", () => {
|
||||
const result = countNodeDiffs([], []);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.modified).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle no changes", () => {
|
||||
const nodes = [createTestNode("n1", "A"), createTestNode("n2", "B")];
|
||||
const result = countNodeDiffs(nodes, nodes);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.modified).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect all nodes as added when starting from empty", () => {
|
||||
const proposed = [createTestNode("n1", "A"), createTestNode("n2", "B")];
|
||||
const result = countNodeDiffs([], proposed);
|
||||
expect(result.added).toBe(2);
|
||||
expect(result.removed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countEdgeDiffs", () => {
|
||||
it("should detect added edges", () => {
|
||||
const previous = [createTestEdge("e1", "n1", "n2")];
|
||||
const proposed = [createTestEdge("e1", "n1", "n2"), createTestEdge("e2", "n2", "n3")];
|
||||
const result = countEdgeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.removed).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect removed edges", () => {
|
||||
const previous = [createTestEdge("e1", "n1", "n2"), createTestEdge("e2", "n2", "n3")];
|
||||
const proposed = [createTestEdge("e1", "n1", "n2")];
|
||||
const result = countEdgeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(1);
|
||||
});
|
||||
|
||||
it("should detect modified edges by label", () => {
|
||||
const previous = [{ ...createTestEdge("e1", "n1", "n2"), label: "old" } as Edge];
|
||||
const proposed = [{ ...createTestEdge("e1", "n1", "n2"), label: "new" } as Edge];
|
||||
const result = countEdgeDiffs(previous, proposed);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.modified).toBe(1);
|
||||
});
|
||||
|
||||
it("should detect modified edges by type", () => {
|
||||
const previous = [{ ...createTestEdge("e1", "n1", "n2"), type: "sync" } as Edge];
|
||||
const proposed = [{ ...createTestEdge("e1", "n1", "n2"), type: "async" } as Edge];
|
||||
const result = countEdgeDiffs(previous, proposed);
|
||||
expect(result.modified).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle empty edge lists", () => {
|
||||
const result = countEdgeDiffs([], []);
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.modified).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSummary", () => {
|
||||
it("should return 'No changes' when nothing changed", () => {
|
||||
expect(buildSummary(0, 0, 0, 0, 0)).toBe("No changes");
|
||||
});
|
||||
|
||||
it("should format added nodes", () => {
|
||||
expect(buildSummary(3, 0, 0, 0, 0)).toBe("Adding 3 nodes");
|
||||
});
|
||||
|
||||
it("should format single node correctly", () => {
|
||||
expect(buildSummary(1, 0, 0, 0, 0)).toBe("Adding 1 node");
|
||||
});
|
||||
|
||||
it("should format mixed node and edge changes", () => {
|
||||
const summary = buildSummary(2, 1, 1, 1, 0);
|
||||
expect(summary).toContain("Adding 2 nodes");
|
||||
expect(summary).toContain("modifying 1 node");
|
||||
expect(summary).toContain("removing 1 node");
|
||||
expect(summary).toContain("adding 1 edge");
|
||||
});
|
||||
|
||||
it("should format removed edges", () => {
|
||||
expect(buildSummary(0, 0, 0, 0, 2)).toBe("removing 2 edges");
|
||||
});
|
||||
|
||||
it("should format modified edges", () => {
|
||||
expect(buildSummary(0, 0, 0, 0, 0, 3)).toBe("modifying 3 edges");
|
||||
});
|
||||
|
||||
it("should separate parts with commas", () => {
|
||||
const summary = buildSummary(1, 1, 0, 0, 0);
|
||||
expect(summary).toBe("Adding 1 node, removing 1 node");
|
||||
});
|
||||
});
|
||||
140
apps/web/src/modules/copilot/hooks/useProposalDiff.ts
Normal file
140
apps/web/src/modules/copilot/hooks/useProposalDiff.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
|
||||
import { useGraphStore } from "~/modules/diagram/stores/useGraphStore";
|
||||
import { graphToFlow } from "~/modules/diagram/lib/graph-converter";
|
||||
|
||||
interface ProposalDiff {
|
||||
addedCount: number;
|
||||
removedCount: number;
|
||||
modifiedCount: number;
|
||||
changeSummary: string;
|
||||
}
|
||||
|
||||
function countNodeDiffs(
|
||||
previous: Node[],
|
||||
proposedNodes: Node[],
|
||||
): { added: number; removed: number; modified: number } {
|
||||
const prevIds = new Set(previous.map((n) => n.id));
|
||||
const proposedIds = new Set(proposedNodes.map((n) => n.id));
|
||||
const prevMap = new Map(previous.map((n) => [n.id, n]));
|
||||
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let modified = 0;
|
||||
|
||||
for (const n of proposedNodes) {
|
||||
if (!prevIds.has(n.id)) {
|
||||
added++;
|
||||
} else {
|
||||
const prev = prevMap.get(n.id);
|
||||
if (prev) {
|
||||
const pData = n.data as Record<string, unknown>;
|
||||
const cData = prev.data as Record<string, unknown>;
|
||||
if (
|
||||
pData.label !== cData.label ||
|
||||
pData.type !== cData.type ||
|
||||
pData.tag !== cData.tag ||
|
||||
JSON.stringify(pData.columns) !== JSON.stringify(cData.columns)
|
||||
) {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of previous) {
|
||||
if (!proposedIds.has(n.id)) {
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed, modified };
|
||||
}
|
||||
|
||||
function countEdgeDiffs(
|
||||
previous: Edge[],
|
||||
proposedEdges: Edge[],
|
||||
): { added: number; removed: number; modified: number } {
|
||||
const prevIds = new Set(previous.map((e) => e.id));
|
||||
const proposedIds = new Set(proposedEdges.map((e) => e.id));
|
||||
const prevMap = new Map(previous.map((e) => [e.id, e]));
|
||||
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let modified = 0;
|
||||
|
||||
for (const e of proposedEdges) {
|
||||
if (!prevIds.has(e.id)) {
|
||||
added++;
|
||||
} else {
|
||||
const prev = prevMap.get(e.id);
|
||||
if (prev && (e.label !== prev.label || e.type !== prev.type)) {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const e of previous) {
|
||||
if (!proposedIds.has(e.id)) removed++;
|
||||
}
|
||||
|
||||
return { added, removed, modified };
|
||||
}
|
||||
|
||||
function buildSummary(
|
||||
nodeAdded: number,
|
||||
nodeRemoved: number,
|
||||
nodeModified: number,
|
||||
edgeAdded: number,
|
||||
edgeRemoved: number,
|
||||
edgeModified = 0,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
const total = nodeAdded + nodeRemoved + nodeModified + edgeAdded + edgeRemoved + edgeModified;
|
||||
if (total === 0) return "No changes";
|
||||
|
||||
if (nodeAdded > 0) parts.push(`Adding ${nodeAdded} node${nodeAdded !== 1 ? "s" : ""}`);
|
||||
if (nodeModified > 0) parts.push(`modifying ${nodeModified} node${nodeModified !== 1 ? "s" : ""}`);
|
||||
if (nodeRemoved > 0) parts.push(`removing ${nodeRemoved} node${nodeRemoved !== 1 ? "s" : ""}`);
|
||||
if (edgeAdded > 0) parts.push(`adding ${edgeAdded} edge${edgeAdded !== 1 ? "s" : ""}`);
|
||||
if (edgeModified > 0) parts.push(`modifying ${edgeModified} edge${edgeModified !== 1 ? "s" : ""}`);
|
||||
if (edgeRemoved > 0) parts.push(`removing ${edgeRemoved} edge${edgeRemoved !== 1 ? "s" : ""}`);
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/** Pure diff computation — exported for testing */
|
||||
export { countNodeDiffs, countEdgeDiffs, buildSummary };
|
||||
|
||||
export function useProposalDiff(): ProposalDiff {
|
||||
const proposedPatch = useGraphStore((s) => s.proposedPatch);
|
||||
const previousGraphSnapshot = useGraphStore((s) => s.previousGraphSnapshot);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!proposedPatch || !previousGraphSnapshot) {
|
||||
return { addedCount: 0, removedCount: 0, modifiedCount: 0, changeSummary: "" };
|
||||
}
|
||||
|
||||
const proposed = graphToFlow(proposedPatch);
|
||||
|
||||
const nodeDiffs = countNodeDiffs(previousGraphSnapshot.nodes, proposed.nodes);
|
||||
const edgeDiffs = countEdgeDiffs(previousGraphSnapshot.edges, proposed.edges);
|
||||
|
||||
return {
|
||||
addedCount: nodeDiffs.added + edgeDiffs.added,
|
||||
removedCount: nodeDiffs.removed + edgeDiffs.removed,
|
||||
modifiedCount: nodeDiffs.modified + edgeDiffs.modified,
|
||||
changeSummary: buildSummary(
|
||||
nodeDiffs.added,
|
||||
nodeDiffs.removed,
|
||||
nodeDiffs.modified,
|
||||
edgeDiffs.added,
|
||||
edgeDiffs.removed,
|
||||
edgeDiffs.modified,
|
||||
),
|
||||
};
|
||||
}, [proposedPatch, previousGraphSnapshot]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -30,18 +30,32 @@ import type { ReactNode } from "react";
|
||||
const diagramTypes = ["bpmn", "er", "orgchart", "architecture", "sequence", "flowchart"] as const;
|
||||
type DiagramType = (typeof diagramTypes)[number];
|
||||
|
||||
function deriveTitleFromDescription(description: string): string {
|
||||
const trimmed = description.trim();
|
||||
if (trimmed.length <= 50) return trimmed;
|
||||
const truncated = trimmed.slice(0, 50);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
return lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
interface CreateDiagramDialogProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [selectedType, setSelectedType] = useState<DiagramType>("flowchart");
|
||||
const [aiInferredType, setAiInferredType] = useState<DiagramType | null>(null);
|
||||
const [isInferring, setIsInferring] = useState(false);
|
||||
const userOverrodeRef = useRef(false);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(undefined);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const derivedTitle = description.trim() ? deriveTitleFromDescription(description) : "";
|
||||
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
@@ -52,19 +66,69 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
|
||||
|
||||
const projects = projectsData?.data ?? [];
|
||||
|
||||
// Debounced AI type inference
|
||||
useEffect(() => {
|
||||
if (description.trim().length < 10) {
|
||||
setAiInferredType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
setIsInferring(true);
|
||||
try {
|
||||
const res = await api.ai.copilot["infer-type"].$post({
|
||||
json: { description: description.trim() },
|
||||
});
|
||||
const data = await res.json();
|
||||
setAiInferredType(data.type as DiagramType);
|
||||
if (!userOverrodeRef.current) {
|
||||
setSelectedType(data.type as DiagramType);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — user can always manually select
|
||||
} finally {
|
||||
setIsInferring(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [description]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTypeSelect = (type: DiagramType) => {
|
||||
setSelectedType(type);
|
||||
userOverrodeRef.current = true;
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (value: string) => {
|
||||
setDescription(value);
|
||||
userOverrodeRef.current = false;
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: { title: string; type: DiagramType; projectId?: string }) => {
|
||||
mutationFn: async (input: {
|
||||
title: string;
|
||||
type: DiagramType;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
}) => {
|
||||
const res = await api.diagrams.$post({ json: input });
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
|
||||
const desc = description.trim();
|
||||
setOpen(false);
|
||||
setDescription("");
|
||||
setTitle("");
|
||||
setSelectedType("flowchart");
|
||||
setAiInferredType(null);
|
||||
userOverrodeRef.current = false;
|
||||
setSelectedProjectId(undefined);
|
||||
if (data.data) {
|
||||
router.push(pathsConfig.dashboard.user.diagram(data.data.id));
|
||||
const url = desc
|
||||
? `${pathsConfig.dashboard.user.diagram(data.data.id)}?desc=${encodeURIComponent(desc)}`
|
||||
: pathsConfig.dashboard.user.diagram(data.data.id);
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -74,32 +138,51 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
const finalTitle = title.trim() || derivedTitle;
|
||||
if (!finalTitle) return;
|
||||
createMutation.mutate({
|
||||
title: title.trim(),
|
||||
title: finalTitle,
|
||||
type: selectedType,
|
||||
description: description.trim() || undefined,
|
||||
projectId: selectedProjectId,
|
||||
});
|
||||
};
|
||||
|
||||
const effectiveTitle = title.trim() || derivedTitle;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Diagram</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="diagram-description" className="text-sm font-medium">
|
||||
What are you designing?
|
||||
</label>
|
||||
<textarea
|
||||
id="diagram-description"
|
||||
placeholder="e.g. database schema for our user management system..."
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
autoFocus
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="diagram-title" className="text-sm font-medium">
|
||||
Title
|
||||
Title {description.trim() && <span className="text-muted-foreground font-normal">(optional — auto-generated)</span>}
|
||||
</label>
|
||||
<Input
|
||||
id="diagram-title"
|
||||
placeholder="My diagram"
|
||||
placeholder={derivedTitle || "My diagram"}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -122,25 +205,37 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Diagram Type</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Diagram Type</label>
|
||||
{isInferring && (
|
||||
<Icons.Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{diagramTypes.map((type) => {
|
||||
const config = diagramTypeConfig[type];
|
||||
const TypeIcon = config.icon;
|
||||
const isSelected = selectedType === type;
|
||||
const isAiSuggested = aiInferredType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`flex flex-col items-center gap-1.5 rounded-lg border-2 p-3 text-sm transition-colors ${
|
||||
onClick={() => handleTypeSelect(type)}
|
||||
className={`relative flex flex-col items-center gap-1.5 rounded-lg border-2 p-3 text-sm transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
? "border-primary bg-primary/5 scale-[1.02]"
|
||||
: "border-transparent bg-muted/50 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<TypeIcon className={`h-5 w-5 ${config.color}`} />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
{isAiSuggested && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-primary">
|
||||
<Icons.Sparkles className="h-3 w-3" />
|
||||
AI suggested
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -157,7 +252,7 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!title.trim() || createMutation.isPending}
|
||||
disabled={!effectiveTitle || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
|
||||
// Mock sonner toast
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { info: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("CommandPalette action handlers", () => {
|
||||
beforeEach(() => {
|
||||
useGraphStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("AI actions via store", () => {
|
||||
it("setPrefillChat should set text for generate action", () => {
|
||||
useGraphStore.getState().setPrefillChat("", "Generate a diagram: ");
|
||||
const prefill = useGraphStore.getState().prefillChat;
|
||||
expect(prefill).toEqual({ nodeId: "", text: "Generate a diagram: " });
|
||||
});
|
||||
|
||||
it("setPrefillChat should set text for suggest action", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setPrefillChat("", "Suggest improvements for this diagram");
|
||||
const prefill = useGraphStore.getState().prefillChat;
|
||||
expect(prefill?.text).toBe("Suggest improvements for this diagram");
|
||||
});
|
||||
|
||||
it("setPrefillChat should set text for analyze action", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setPrefillChat("", "Analyze the semantics of this diagram");
|
||||
const prefill = useGraphStore.getState().prefillChat;
|
||||
expect(prefill?.text).toBe("Analyze the semantics of this diagram");
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation actions via store", () => {
|
||||
it("requestFitView should increment counter", () => {
|
||||
expect(useGraphStore.getState().fitViewRequested).toBe(0);
|
||||
useGraphStore.getState().requestFitView();
|
||||
expect(useGraphStore.getState().fitViewRequested).toBe(1);
|
||||
});
|
||||
|
||||
it("setFocusNodeId should set the target node", () => {
|
||||
useGraphStore.getState().setFocusNodeId("node-123");
|
||||
expect(useGraphStore.getState().focusNodeId).toBe("node-123");
|
||||
});
|
||||
|
||||
it("setFocusNodeId(null) should clear focus", () => {
|
||||
useGraphStore.getState().setFocusNodeId("node-123");
|
||||
useGraphStore.getState().setFocusNodeId(null);
|
||||
expect(useGraphStore.getState().focusNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Go to Node filtering — CONTAINER_TYPES values", () => {
|
||||
// CONTAINER_TYPES from DiagramCanvas: bpmnPool, bpmnLane, bpmnGroup, seqFragment
|
||||
// Verified via direct constant reference; import avoided due to heavy dependency chain
|
||||
const containerTypes = new Set([
|
||||
"bpmnPool",
|
||||
"bpmnLane",
|
||||
"bpmnGroup",
|
||||
"seqFragment",
|
||||
]);
|
||||
|
||||
it("should include all 4 expected container types", () => {
|
||||
expect(containerTypes.size).toBe(4);
|
||||
expect(containerTypes.has("bpmnPool")).toBe(true);
|
||||
expect(containerTypes.has("bpmnLane")).toBe(true);
|
||||
expect(containerTypes.has("bpmnGroup")).toBe(true);
|
||||
expect(containerTypes.has("seqFragment")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not include regular node types", () => {
|
||||
const regularTypes = [
|
||||
"bpmnActivity",
|
||||
"erEntity",
|
||||
"flowProcess",
|
||||
"archService",
|
||||
];
|
||||
for (const type of regularTypes) {
|
||||
expect(containerTypes.has(type)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("export action", () => {
|
||||
it("should call toast.info for export", async () => {
|
||||
const { toast } = await import("sonner");
|
||||
toast.info("Export coming soon", {
|
||||
description: "This feature is planned for a future release.",
|
||||
});
|
||||
expect(toast.info).toHaveBeenCalledWith("Export coming soon", {
|
||||
description: "This feature is planned for a future release.",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
} from "@turbostarter/ui-web/command";
|
||||
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
import { CONTAINER_TYPES } from "./DiagramCanvas";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleRightPanel: () => void;
|
||||
onOpenRightPanel: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onToggleSidebar,
|
||||
onToggleRightPanel,
|
||||
onOpenRightPanel,
|
||||
}: CommandPaletteProps) {
|
||||
const nodes = useGraphStore((s) => s.nodes);
|
||||
|
||||
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
close();
|
||||
useGraphStore.getState().requestFitView();
|
||||
}, [close]);
|
||||
|
||||
const handleGoToNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
close();
|
||||
useGraphStore.getState().setFocusNodeId(nodeId);
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
close();
|
||||
onToggleSidebar();
|
||||
}, [close, onToggleSidebar]);
|
||||
|
||||
const handleToggleChat = useCallback(() => {
|
||||
close();
|
||||
onToggleRightPanel();
|
||||
}, [close, onToggleRightPanel]);
|
||||
|
||||
const handleAIAction = useCallback(
|
||||
(text: string) => {
|
||||
close();
|
||||
onOpenRightPanel();
|
||||
useGraphStore.getState().setPrefillChat("", text);
|
||||
},
|
||||
[close, onOpenRightPanel],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
close();
|
||||
toast.info("Export coming soon", {
|
||||
description: "This feature is planned for a future release.",
|
||||
});
|
||||
}, [close]);
|
||||
|
||||
const navigableNodes = nodes.filter(
|
||||
(n) => !CONTAINER_TYPES.has(n.type ?? ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="AI Actions">
|
||||
<CommandItem onSelect={() => handleAIAction("Generate a diagram: ")}>
|
||||
<Icons.Sparkles className="size-4" />
|
||||
<span>Generate diagram from description</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleAIAction("Suggest improvements for this diagram")}>
|
||||
<Icons.Lightbulb className="size-4" />
|
||||
<span>Suggest improvements</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleAIAction("Analyze the semantics of this diagram")}>
|
||||
<Icons.Search className="size-4" />
|
||||
<span>Analyze diagram semantics</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Navigation">
|
||||
<CommandItem onSelect={handleFitView}>
|
||||
<Icons.Zap className="size-4" />
|
||||
<span>Fit to view</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleSidebar}>
|
||||
<Icons.PanelLeft className="size-4" />
|
||||
<span>Toggle sidebar</span>
|
||||
<CommandShortcut>⌘B</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleChat}>
|
||||
<Icons.MessageSquare className="size-4" />
|
||||
<span>Toggle chat panel</span>
|
||||
<CommandShortcut>⌘J</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Diagram">
|
||||
<CommandItem onSelect={handleExport}>
|
||||
<Icons.Download className="size-4" />
|
||||
<span>Export as PNG</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleExport}>
|
||||
<Icons.Download className="size-4" />
|
||||
<span>Export as SVG</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{navigableNodes.length > 0 && (
|
||||
<CommandGroup heading="Go to Node">
|
||||
{navigableNodes.map((n) => (
|
||||
<CommandItem
|
||||
key={n.id}
|
||||
onSelect={() => handleGoToNode(n.id)}
|
||||
>
|
||||
<Icons.Circle className="size-4" />
|
||||
<span>
|
||||
{(n.data as { label?: string }).label ?? n.id}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -9,12 +9,16 @@ import {
|
||||
MiniMap,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import type { Node, OnSelectionChangeFunc } from "@xyflow/react";
|
||||
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
import { useAutoLayout } from "../../hooks/useAutoLayout";
|
||||
import { bfsPath } from "../../lib/bfs-path";
|
||||
import { ProposalBar } from "./ProposalBar";
|
||||
import { HoverAffordances } from "./HoverAffordances";
|
||||
import { acceptCurrentProposal, rejectCurrentProposal } from "~/modules/copilot/components/CopilotPanel";
|
||||
import {
|
||||
BpmnActivityNode,
|
||||
BpmnSubprocessNode,
|
||||
@@ -96,8 +100,8 @@ const edgeTypes = {
|
||||
flowEdge: FlowEdge,
|
||||
};
|
||||
|
||||
/** Container node types that should not participate in BFS highlighting */
|
||||
const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup", "seqFragment"]);
|
||||
/** Container node types that should not participate in BFS highlighting or hover affordances */
|
||||
export const CONTAINER_TYPES = new Set(["bpmnPool", "bpmnLane", "bpmnGroup", "seqFragment"]);
|
||||
|
||||
function MarkerDefs() {
|
||||
return (
|
||||
@@ -215,7 +219,7 @@ function MarkerDefs() {
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasInner() {
|
||||
function CanvasInner({ diagramId }: { diagramId: string }) {
|
||||
const nodes = useGraphStore((s) => s.nodes);
|
||||
const edges = useGraphStore((s) => s.edges);
|
||||
const onNodesChange = useGraphStore((s) => s.onNodesChange);
|
||||
@@ -226,12 +230,76 @@ function CanvasInner() {
|
||||
const setSelectedNodeIds = useGraphStore((s) => s.setSelectedNodeIds);
|
||||
const setNodes = useGraphStore((s) => s.setNodes);
|
||||
const setEdges = useGraphStore((s) => s.setEdges);
|
||||
const fitViewRequested = useGraphStore((s) => s.fitViewRequested);
|
||||
const focusNodeId = useGraphStore((s) => s.focusNodeId);
|
||||
|
||||
const { isLayouting } = useAutoLayout();
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Hover affordances state
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const leaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleNodeMouseEnter = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
if (CONTAINER_TYPES.has(node.type ?? "")) return;
|
||||
if (useGraphStore.getState().proposalStatus === "pending") return;
|
||||
|
||||
if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current);
|
||||
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setHoveredNodeId(node.id);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleNodeMouseLeave = useCallback(() => {
|
||||
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
||||
|
||||
leaveTimerRef.current = setTimeout(() => {
|
||||
setHoveredNodeId(null);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
// Cleanup hover timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
||||
if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clear hover on proposal becoming pending
|
||||
const proposalStatus = useGraphStore((s) => s.proposalStatus);
|
||||
useEffect(() => {
|
||||
if (proposalStatus === "pending") {
|
||||
setHoveredNodeId(null);
|
||||
}
|
||||
}, [proposalStatus]);
|
||||
|
||||
// fitView watcher — triggered by CommandPalette
|
||||
useEffect(() => {
|
||||
if (fitViewRequested > 0) {
|
||||
fitView({ duration: 300 });
|
||||
}
|
||||
}, [fitViewRequested, fitView]);
|
||||
|
||||
// focusNode watcher — triggered by CommandPalette "Go to Node"
|
||||
const lastHandledFocusRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (focusNodeId && focusNodeId !== lastHandledFocusRef.current) {
|
||||
lastHandledFocusRef.current = focusNodeId;
|
||||
fitView({ nodes: [{ id: focusNodeId }], duration: 300, maxZoom: 1.5 });
|
||||
// Defer clearing to avoid synchronous re-render in this effect
|
||||
queueMicrotask(() => useGraphStore.getState().setFocusNodeId(null));
|
||||
}
|
||||
}, [focusNodeId, fitView]);
|
||||
|
||||
const clearHighlight = useCallback(() => {
|
||||
setHoveredNodeId(null);
|
||||
const store = useGraphStore.getState();
|
||||
if (!store.highlightedNodeId) return;
|
||||
// Don't clear classes during an active proposal — diff styling takes precedence
|
||||
if (store.proposalStatus === "pending") return;
|
||||
store.setHighlightedNodeId(null);
|
||||
store.setNodes(
|
||||
store.nodes.map((n) => ({ ...n, className: undefined })),
|
||||
@@ -248,6 +316,9 @@ function CanvasInner() {
|
||||
|
||||
const store = useGraphStore.getState();
|
||||
|
||||
// Suppress BFS highlighting during active proposal — diff styling takes precedence
|
||||
if (store.proposalStatus === "pending") return;
|
||||
|
||||
// Toggle off if clicking the same node
|
||||
if (store.highlightedNodeId === node.id) {
|
||||
store.setHighlightedNodeId(null);
|
||||
@@ -318,8 +389,26 @@ function CanvasInner() {
|
||||
[setSelectedNodeIds],
|
||||
);
|
||||
|
||||
const handleCanvasKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const store = useGraphStore.getState();
|
||||
if (store.proposalStatus !== "pending") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
acceptCurrentProposal(diagramId);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
rejectCurrentProposal();
|
||||
}
|
||||
},
|
||||
[diagramId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div className="w-full h-full" onKeyDown={handleCanvasKeyDown} tabIndex={-1}>
|
||||
<MarkerDefs />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
@@ -330,6 +419,8 @@ function CanvasInner() {
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onNodeMouseEnter={handleNodeMouseEnter}
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
onPaneClick={clearHighlight}
|
||||
nodesDraggable
|
||||
elementsSelectable
|
||||
@@ -358,15 +449,19 @@ function CanvasInner() {
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
<ProposalBar diagramId={diagramId} />
|
||||
{hoveredNodeId && (
|
||||
<HoverAffordances nodeId={hoveredNodeId} />
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiagramCanvas() {
|
||||
export function DiagramCanvas({ diagramId }: { diagramId: string }) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasInner />
|
||||
<CanvasInner diagramId={diagramId} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -10,6 +11,7 @@ import { EditorHeader } from "./EditorHeader";
|
||||
import { EditorStatusBar } from "./EditorStatusBar";
|
||||
import { DiagramCanvas } from "./DiagramCanvas";
|
||||
import { RightPanel } from "./RightPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
import { graphToFlow } from "../../lib/graph-converter";
|
||||
|
||||
@@ -18,11 +20,15 @@ import type { GraphData, DiagramType } from "../../types/graph";
|
||||
|
||||
interface DiagramEditorProps {
|
||||
diagram: DiagramResponse;
|
||||
isSharedView?: boolean;
|
||||
}
|
||||
|
||||
export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
export function DiagramEditor({ diagram, isSharedView }: DiagramEditorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const initialDescription = searchParams.get("desc") ?? undefined;
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [rightPanelOpen, setRightPanelOpen] = useState(true);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const initializeFromGraphData = useGraphStore(
|
||||
(s) => s.initializeFromGraphData,
|
||||
@@ -53,6 +59,14 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
return () => resetStore();
|
||||
}, [diagram.id, diagram.graphData, initializeFromGraphData, setLayoutDirection, setEdgeRouting, resetStore]);
|
||||
|
||||
// Open right panel when prefillChat is set (from HoverAffordances or CommandPalette)
|
||||
const prefillChat = useGraphStore((s) => s.prefillChat);
|
||||
useEffect(() => {
|
||||
if (prefillChat) {
|
||||
setRightPanelOpen(true);
|
||||
}
|
||||
}, [prefillChat]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -64,6 +78,10 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
e.preventDefault();
|
||||
setRightPanelOpen((prev) => !prev);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setCommandPaletteOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
@@ -127,7 +145,7 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<DiagramCanvas />
|
||||
<DiagramCanvas diagramId={diagram.id} />
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
@@ -135,10 +153,20 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
open={rightPanelOpen}
|
||||
diagramId={diagram.id}
|
||||
diagramType={diagram.type as DiagramType}
|
||||
initialDescription={initialDescription}
|
||||
isSharedView={isSharedView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditorStatusBar diagramType={diagram.type as DiagramType} />
|
||||
|
||||
<CommandPalette
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
||||
onToggleRightPanel={() => setRightPanelOpen((prev) => !prev)}
|
||||
onOpenRightPanel={() => setRightPanelOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { HOVER_ACTIONS } from "./HoverAffordances";
|
||||
|
||||
describe("HOVER_ACTIONS text mapping", () => {
|
||||
it("transform should include node type and label", () => {
|
||||
const action = HOVER_ACTIONS.find((a) => a.key === "transform")!;
|
||||
expect(action.getText("Order", "process")).toBe(
|
||||
'Transform this process "Order" into ',
|
||||
);
|
||||
});
|
||||
|
||||
it("split should include the node label", () => {
|
||||
const action = HOVER_ACTIONS.find((a) => a.key === "split")!;
|
||||
expect(action.getText("UserService", "service")).toBe(
|
||||
'Split "UserService" into ',
|
||||
);
|
||||
});
|
||||
|
||||
it("merge should include the node label", () => {
|
||||
const action = HOVER_ACTIONS.find((a) => a.key === "merge")!;
|
||||
expect(action.getText("Database", "database")).toBe(
|
||||
'Merge "Database" with ',
|
||||
);
|
||||
});
|
||||
|
||||
it("explain should include the node label", () => {
|
||||
const action = HOVER_ACTIONS.find((a) => a.key === "explain")!;
|
||||
expect(action.getText("Payment Gateway", "service")).toBe(
|
||||
'Explain this element: "Payment Gateway"',
|
||||
);
|
||||
});
|
||||
|
||||
it("annotate should include the node label with trailing space", () => {
|
||||
const action = HOVER_ACTIONS.find((a) => a.key === "annotate")!;
|
||||
expect(action.getText("API Router", "service")).toBe(
|
||||
'Annotate "API Router": ',
|
||||
);
|
||||
});
|
||||
|
||||
it("should have exactly 5 actions", () => {
|
||||
expect(HOVER_ACTIONS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("each action should have key, icon, label, and getText", () => {
|
||||
for (const action of HOVER_ACTIONS) {
|
||||
expect(action.key).toBeTruthy();
|
||||
expect(action.icon).toBeTruthy();
|
||||
expect(action.label).toBeTruthy();
|
||||
expect(typeof action.getText).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useReactFlow, getNodesBounds } from "@xyflow/react";
|
||||
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
const HOVER_ACTIONS = [
|
||||
{
|
||||
key: "transform",
|
||||
icon: Icons.RefreshCcw,
|
||||
label: "Transform",
|
||||
getText: (label: string, type: string) =>
|
||||
`Transform this ${type} "${label}" into `,
|
||||
},
|
||||
{
|
||||
key: "split",
|
||||
icon: Icons.GitBranch,
|
||||
label: "Split",
|
||||
getText: (label: string, _type: string) => `Split "${label}" into `,
|
||||
},
|
||||
{
|
||||
key: "merge",
|
||||
icon: Icons.Workflow,
|
||||
label: "Merge",
|
||||
getText: (label: string, _type: string) => `Merge "${label}" with `,
|
||||
},
|
||||
{
|
||||
key: "explain",
|
||||
icon: Icons.Info,
|
||||
label: "Explain",
|
||||
getText: (label: string, _type: string) =>
|
||||
`Explain this element: "${label}"`,
|
||||
},
|
||||
{
|
||||
key: "annotate",
|
||||
icon: Icons.MessageSquare,
|
||||
label: "Annotate",
|
||||
getText: (label: string, _type: string) => `Annotate "${label}": `,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export { HOVER_ACTIONS };
|
||||
|
||||
interface HoverAffordancesProps {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
export function HoverAffordances({ nodeId }: HoverAffordancesProps) {
|
||||
const { getNodes, getViewport } = useReactFlow();
|
||||
const node = getNodes().find((n: Node) => n.id === nodeId);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: (typeof HOVER_ACTIONS)[number]) => {
|
||||
// Look up node fresh inside callback to avoid stale closure
|
||||
const currentNode = getNodes().find((n: Node) => n.id === nodeId);
|
||||
if (!currentNode) return;
|
||||
const data = currentNode.data as { label?: string; type?: string };
|
||||
const label = data.label ?? currentNode.id;
|
||||
const type = data.type ?? "element";
|
||||
const text = action.getText(label, type);
|
||||
|
||||
const store = useGraphStore.getState();
|
||||
store.setSelectedNodeIds([nodeId]);
|
||||
store.setPrefillChat(nodeId, text);
|
||||
},
|
||||
[nodeId, getNodes],
|
||||
);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
// Use getNodesBounds for absolute coordinates — node.position is relative
|
||||
// to parent for nested nodes (e.g., BPMN activities inside pools/lanes)
|
||||
const bounds = getNodesBounds([node]);
|
||||
const { x, y, zoom } = getViewport();
|
||||
const screenX = bounds.x * zoom + x;
|
||||
const screenY = bounds.y * zoom + y;
|
||||
const nodeWidth = bounds.width * zoom;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
className="hover-affordances absolute z-50 pointer-events-auto"
|
||||
style={{
|
||||
left: screenX + nodeWidth / 2,
|
||||
top: screenY - 8,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
role="toolbar"
|
||||
aria-label={`AI actions for ${(node.data as { label?: string }).label ?? node.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-border bg-[var(--canvas-bg)]/90 px-1 py-0.5 shadow-md backdrop-blur-sm">
|
||||
{HOVER_ACTIONS.map((action) => (
|
||||
<Tooltip key={action.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
<action.icon className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{action.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Panel } from "@xyflow/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
import { useProposalDiff } from "~/modules/copilot/hooks/useProposalDiff";
|
||||
import { acceptCurrentProposal, rejectCurrentProposal } from "~/modules/copilot/components/CopilotPanel";
|
||||
|
||||
interface ProposalBarProps {
|
||||
diagramId: string;
|
||||
}
|
||||
|
||||
export function ProposalBar({ diagramId }: ProposalBarProps) {
|
||||
const proposalStatus = useGraphStore((s) => s.proposalStatus);
|
||||
const { changeSummary } = useProposalDiff();
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
acceptCurrentProposal(diagramId);
|
||||
}, [diagramId]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
rejectCurrentProposal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel position="bottom-center">
|
||||
<AnimatePresence>
|
||||
{proposalStatus === "pending" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
role="alert"
|
||||
aria-label={`AI proposes: ${changeSummary}. Press Enter to accept, Escape to reject.`}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-background/95 px-4 py-2.5 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{changeSummary}</span>
|
||||
<Button size="sm" onClick={handleAccept}>
|
||||
<Icons.Check className="mr-1 size-3" /> Accept
|
||||
<kbd className="ml-1.5 rounded bg-primary-foreground/20 px-1 text-[10px]">Enter</kbd>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleReject}>
|
||||
<Icons.X className="mr-1 size-3" /> Reject
|
||||
<kbd className="ml-1.5 rounded bg-muted px-1 text-[10px]">Esc</kbd>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,11 @@ interface RightPanelProps {
|
||||
open: boolean;
|
||||
diagramId: string;
|
||||
diagramType: DiagramType;
|
||||
initialDescription?: string;
|
||||
isSharedView?: boolean;
|
||||
}
|
||||
|
||||
export function RightPanel({ open, diagramId, diagramType }: RightPanelProps) {
|
||||
export function RightPanel({ open, diagramId, diagramType, initialDescription, isSharedView }: RightPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("chat");
|
||||
|
||||
return (
|
||||
@@ -51,7 +53,7 @@ export function RightPanel({ open, diagramId, diagramType }: RightPanelProps) {
|
||||
{/* Tab content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{activeTab === "chat" && (
|
||||
<CopilotPanel diagramId={diagramId} diagramType={diagramType} />
|
||||
<CopilotPanel diagramId={diagramId} diagramType={diagramType} initialDescription={initialDescription} isSharedView={isSharedView} />
|
||||
)}
|
||||
{activeTab === "inspector" && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useGraphStore } from "./useGraphStore";
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { GraphData } from "../types/graph";
|
||||
|
||||
const makeNode = (id: string, label = "Node"): Node => ({
|
||||
id,
|
||||
@@ -202,4 +203,237 @@ describe("useGraphStore", () => {
|
||||
expect(useGraphStore.getState().selectedNodeIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proposal actions", () => {
|
||||
const createGraphData = (
|
||||
nodes: Array<{ id: string; label: string; type?: string }>,
|
||||
edges: Array<{ id: string; from: string; to: string }> = [],
|
||||
): GraphData => ({
|
||||
meta: { version: "1", title: "Test", diagramType: "flowchart" },
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type ?? "process",
|
||||
label: n.label,
|
||||
})),
|
||||
edges: edges.map((e) => ({ id: e.id, from: e.from, to: e.to })),
|
||||
});
|
||||
|
||||
describe("proposeChanges", () => {
|
||||
it("should set proposalStatus to pending", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
expect(useGraphStore.getState().proposalStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("should snapshot current nodes and edges", () => {
|
||||
useGraphStore.setState({ nodes: [makeNode("n1")], edges: [makeEdge("e1", "n1", "n2")] });
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "B" }]));
|
||||
|
||||
const snapshot = useGraphStore.getState().previousGraphSnapshot;
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot!.nodes).toHaveLength(1);
|
||||
expect(snapshot!.nodes[0]!.id).toBe("n1");
|
||||
expect(snapshot!.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should store the proposed patch", () => {
|
||||
const graphData = createGraphData([{ id: "n1", label: "A" }]);
|
||||
useGraphStore.getState().proposeChanges(graphData);
|
||||
expect(useGraphStore.getState().proposedPatch).toEqual(graphData);
|
||||
});
|
||||
|
||||
it("should clear BFS highlighting", () => {
|
||||
useGraphStore.setState({ highlightedNodeId: "n1" });
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
expect(useGraphStore.getState().highlightedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear lastProposalOutcome on new proposal", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
useGraphStore.getState().acceptProposal();
|
||||
expect(useGraphStore.getState().lastProposalOutcome).toBe("accepted");
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n2", label: "B" }]));
|
||||
expect(useGraphStore.getState().lastProposalOutcome).toBeNull();
|
||||
});
|
||||
|
||||
it("should add ai-diff-add className to new nodes", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "New" }]));
|
||||
const node = useGraphStore.getState().nodes.find((n) => n.id === "n1");
|
||||
expect(node?.className).toBe("ai-diff-add");
|
||||
});
|
||||
|
||||
it("should add ai-diff-remove className to removed nodes", () => {
|
||||
useGraphStore.setState({ nodes: [makeNode("n1")], edges: [] });
|
||||
useGraphStore.getState().proposeChanges(createGraphData([]));
|
||||
const node = useGraphStore.getState().nodes.find((n) => n.id === "n1");
|
||||
expect(node?.className).toBe("ai-diff-remove");
|
||||
});
|
||||
});
|
||||
|
||||
describe("acceptProposal", () => {
|
||||
it("should clear proposal state and apply nodes without diff classes", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "Accepted" }]));
|
||||
useGraphStore.getState().acceptProposal();
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.proposalStatus).toBe("idle");
|
||||
expect(state.proposedPatch).toBeNull();
|
||||
expect(state.previousGraphSnapshot).toBeNull();
|
||||
const node = state.nodes.find((n) => n.id === "n1");
|
||||
expect(node?.className).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should set lastProposalOutcome to accepted", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
useGraphStore.getState().acceptProposal();
|
||||
expect(useGraphStore.getState().lastProposalOutcome).toBe("accepted");
|
||||
});
|
||||
|
||||
it("should do nothing if no proposal", () => {
|
||||
useGraphStore.setState({ nodes: [makeNode("n1")], edges: [] });
|
||||
useGraphStore.getState().acceptProposal();
|
||||
expect(useGraphStore.getState().nodes[0]!.id).toBe("n1");
|
||||
});
|
||||
|
||||
it("should apply layoutDirection from proposed patch", () => {
|
||||
const graphData = createGraphData([{ id: "n1", label: "A" }]);
|
||||
graphData.meta!.layoutDirection = "RIGHT";
|
||||
useGraphStore.getState().proposeChanges(graphData);
|
||||
useGraphStore.getState().acceptProposal();
|
||||
expect(useGraphStore.getState().layoutDirection).toBe("RIGHT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectProposal", () => {
|
||||
it("should restore the previous snapshot", () => {
|
||||
useGraphStore.setState({ nodes: [makeNode("n1")], edges: [], nodeCount: 1 });
|
||||
useGraphStore.getState().proposeChanges(
|
||||
createGraphData([{ id: "n1", label: "Changed" }, { id: "n2", label: "New" }]),
|
||||
);
|
||||
useGraphStore.getState().rejectProposal();
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.proposalStatus).toBe("idle");
|
||||
expect(state.proposedPatch).toBeNull();
|
||||
expect(state.previousGraphSnapshot).toBeNull();
|
||||
expect(state.nodes).toHaveLength(1);
|
||||
expect(state.nodes[0]!.id).toBe("n1");
|
||||
});
|
||||
|
||||
it("should set lastProposalOutcome to rejected", () => {
|
||||
useGraphStore.setState({ nodes: [makeNode("n1")], edges: [], nodeCount: 1 });
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
useGraphStore.getState().rejectProposal();
|
||||
expect(useGraphStore.getState().lastProposalOutcome).toBe("rejected");
|
||||
});
|
||||
|
||||
it("should do nothing if no snapshot", () => {
|
||||
useGraphStore.getState().rejectProposal();
|
||||
expect(useGraphStore.getState().proposalStatus).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearProposal", () => {
|
||||
it("should reset proposal state without affecting nodes", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
const nodesBefore = useGraphStore.getState().nodes;
|
||||
useGraphStore.getState().clearProposal();
|
||||
|
||||
expect(useGraphStore.getState().proposalStatus).toBe("idle");
|
||||
expect(useGraphStore.getState().proposedPatch).toBeNull();
|
||||
expect(useGraphStore.getState().previousGraphSnapshot).toBeNull();
|
||||
// Nodes remain as-is (diff view)
|
||||
expect(useGraphStore.getState().nodes).toEqual(nodesBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset with proposal", () => {
|
||||
it("should reset proposal state along with everything else", () => {
|
||||
useGraphStore.getState().proposeChanges(createGraphData([{ id: "n1", label: "A" }]));
|
||||
useGraphStore.getState().reset();
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.proposalStatus).toBe("idle");
|
||||
expect(state.proposedPatch).toBeNull();
|
||||
expect(state.previousGraphSnapshot).toBeNull();
|
||||
expect(state.nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefillChat", () => {
|
||||
it("should default to null", () => {
|
||||
expect(useGraphStore.getState().prefillChat).toBeNull();
|
||||
});
|
||||
|
||||
it("should set prefillChat with nodeId and text", () => {
|
||||
useGraphStore.getState().setPrefillChat("n1", "Explain this element");
|
||||
const prefill = useGraphStore.getState().prefillChat;
|
||||
expect(prefill).toEqual({ nodeId: "n1", text: "Explain this element" });
|
||||
});
|
||||
|
||||
it("should clear prefillChat", () => {
|
||||
useGraphStore.getState().setPrefillChat("n1", "text");
|
||||
useGraphStore.getState().clearPrefillChat();
|
||||
expect(useGraphStore.getState().prefillChat).toBeNull();
|
||||
});
|
||||
|
||||
it("should overwrite previous prefillChat", () => {
|
||||
useGraphStore.getState().setPrefillChat("n1", "first");
|
||||
useGraphStore.getState().setPrefillChat("n2", "second");
|
||||
expect(useGraphStore.getState().prefillChat).toEqual({ nodeId: "n2", text: "second" });
|
||||
});
|
||||
|
||||
it("should reset prefillChat on reset()", () => {
|
||||
useGraphStore.getState().setPrefillChat("n1", "text");
|
||||
useGraphStore.getState().reset();
|
||||
expect(useGraphStore.getState().prefillChat).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitViewRequested", () => {
|
||||
it("should default to 0", () => {
|
||||
expect(useGraphStore.getState().fitViewRequested).toBe(0);
|
||||
});
|
||||
|
||||
it("should increment on requestFitView", () => {
|
||||
useGraphStore.getState().requestFitView();
|
||||
expect(useGraphStore.getState().fitViewRequested).toBe(1);
|
||||
});
|
||||
|
||||
it("should increment each call", () => {
|
||||
useGraphStore.getState().requestFitView();
|
||||
useGraphStore.getState().requestFitView();
|
||||
useGraphStore.getState().requestFitView();
|
||||
expect(useGraphStore.getState().fitViewRequested).toBe(3);
|
||||
});
|
||||
|
||||
it("should reset fitViewRequested on reset()", () => {
|
||||
useGraphStore.getState().requestFitView();
|
||||
useGraphStore.getState().reset();
|
||||
expect(useGraphStore.getState().fitViewRequested).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusNodeId", () => {
|
||||
it("should default to null", () => {
|
||||
expect(useGraphStore.getState().focusNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("should set focusNodeId", () => {
|
||||
useGraphStore.getState().setFocusNodeId("n1");
|
||||
expect(useGraphStore.getState().focusNodeId).toBe("n1");
|
||||
});
|
||||
|
||||
it("should clear focusNodeId", () => {
|
||||
useGraphStore.getState().setFocusNodeId("n1");
|
||||
useGraphStore.getState().setFocusNodeId(null);
|
||||
expect(useGraphStore.getState().focusNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reset focusNodeId on reset()", () => {
|
||||
useGraphStore.getState().setFocusNodeId("n1");
|
||||
useGraphStore.getState().reset();
|
||||
expect(useGraphStore.getState().focusNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
} from "@xyflow/react";
|
||||
|
||||
import type { LayoutDirection, EdgeRouting } from "../lib/elk-layout";
|
||||
import type { GraphData } from "../types/graph";
|
||||
import { graphToFlow } from "../lib/graph-converter";
|
||||
|
||||
type ProposalStatus = "idle" | "pending" | "accepted" | "rejected";
|
||||
|
||||
interface GraphState {
|
||||
nodes: Node[];
|
||||
@@ -25,6 +29,13 @@ interface GraphState {
|
||||
highlightedNodeId: string | null;
|
||||
selectedNodeIds: string[];
|
||||
layoutRequestId: number;
|
||||
proposedPatch: GraphData | null;
|
||||
previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | null;
|
||||
proposalStatus: ProposalStatus;
|
||||
lastProposalOutcome: "accepted" | "rejected" | null;
|
||||
prefillChat: { nodeId: string; text: string } | null;
|
||||
fitViewRequested: number;
|
||||
focusNodeId: string | null;
|
||||
setNodes: (nodes: Node[]) => void;
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
onNodesChange: OnNodesChange;
|
||||
@@ -37,6 +48,14 @@ interface GraphState {
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
requestLayout: () => void;
|
||||
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
|
||||
proposeChanges: (graphData: GraphData) => void;
|
||||
acceptProposal: () => void;
|
||||
rejectProposal: () => void;
|
||||
clearProposal: () => void;
|
||||
setPrefillChat: (nodeId: string, text: string) => void;
|
||||
clearPrefillChat: () => void;
|
||||
requestFitView: () => void;
|
||||
setFocusNodeId: (id: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -52,6 +71,13 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
highlightedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
layoutRequestId: 0,
|
||||
proposedPatch: null,
|
||||
previousGraphSnapshot: null,
|
||||
proposalStatus: "idle",
|
||||
lastProposalOutcome: null,
|
||||
prefillChat: null,
|
||||
fitViewRequested: 0,
|
||||
focusNodeId: null,
|
||||
|
||||
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
|
||||
setEdges: (edges) => set({ edges }),
|
||||
@@ -80,6 +106,134 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
set({ nodes, edges, nodeCount: nodes.length });
|
||||
},
|
||||
|
||||
proposeChanges: (graphData) => {
|
||||
const { nodes, edges } = get();
|
||||
|
||||
// Snapshot current state for revert
|
||||
set({
|
||||
previousGraphSnapshot: { nodes: [...nodes], edges: [...edges] },
|
||||
proposedPatch: graphData,
|
||||
proposalStatus: "pending",
|
||||
lastProposalOutcome: null,
|
||||
highlightedNodeId: null,
|
||||
});
|
||||
|
||||
// Convert proposed graph to flow format
|
||||
const proposed = graphToFlow(graphData);
|
||||
|
||||
// Compute diff by comparing node IDs
|
||||
const currentIds = new Set(nodes.map((n) => n.id));
|
||||
const proposedIds = new Set(proposed.nodes.map((n) => n.id));
|
||||
|
||||
const currentNodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
const isDifferentNode = (p: Node, c: Node | undefined): boolean => {
|
||||
if (!c) return false;
|
||||
const pData = p.data as Record<string, unknown>;
|
||||
const cData = c.data as Record<string, unknown>;
|
||||
if (pData.label !== cData.label || pData.type !== cData.type) return true;
|
||||
// Deep compare additional properties (columns, tag, etc.)
|
||||
if (JSON.stringify(pData.columns) !== JSON.stringify(cData.columns)) return true;
|
||||
if (pData.tag !== cData.tag) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Merge: proposed nodes with diff classes + removed nodes with remove class
|
||||
const mergedNodes = [
|
||||
...proposed.nodes.map((n) => ({
|
||||
...n,
|
||||
className: !currentIds.has(n.id)
|
||||
? "ai-diff-add"
|
||||
: isDifferentNode(n, currentNodeMap.get(n.id))
|
||||
? "ai-diff-modified"
|
||||
: undefined,
|
||||
})),
|
||||
...nodes
|
||||
.filter((n) => !proposedIds.has(n.id))
|
||||
.map((n) => ({ ...n, className: "ai-diff-remove" })),
|
||||
];
|
||||
|
||||
// Same for edges
|
||||
const currentEdgeIds = new Set(edges.map((e) => e.id));
|
||||
const proposedEdgeIds = new Set(proposed.edges.map((e) => e.id));
|
||||
const currentEdgeMap = new Map(edges.map((e) => [e.id, e]));
|
||||
|
||||
const isDifferentEdge = (p: Edge, c: Edge | undefined): boolean => {
|
||||
if (!c) return false;
|
||||
return p.label !== c.label || p.type !== c.type;
|
||||
};
|
||||
|
||||
const mergedEdges = [
|
||||
...proposed.edges.map((e) => ({
|
||||
...e,
|
||||
className: !currentEdgeIds.has(e.id)
|
||||
? "ai-diff-add"
|
||||
: isDifferentEdge(e, currentEdgeMap.get(e.id))
|
||||
? "ai-diff-modified"
|
||||
: undefined,
|
||||
})),
|
||||
...edges
|
||||
.filter((e) => !proposedEdgeIds.has(e.id))
|
||||
.map((e) => ({ ...e, className: "ai-diff-remove" })),
|
||||
];
|
||||
|
||||
set({ nodes: mergedNodes, edges: mergedEdges, nodeCount: mergedNodes.length });
|
||||
},
|
||||
|
||||
acceptProposal: () => {
|
||||
const { proposedPatch } = get();
|
||||
if (!proposedPatch) return;
|
||||
|
||||
const { nodes, edges } = graphToFlow(proposedPatch);
|
||||
|
||||
set({
|
||||
nodes,
|
||||
edges,
|
||||
nodeCount: nodes.length,
|
||||
proposedPatch: null,
|
||||
previousGraphSnapshot: null,
|
||||
proposalStatus: "idle",
|
||||
lastProposalOutcome: "accepted",
|
||||
...(proposedPatch.meta?.layoutDirection && {
|
||||
layoutDirection: proposedPatch.meta.layoutDirection,
|
||||
}),
|
||||
...(proposedPatch.meta?.edgeRouting && {
|
||||
edgeRouting: proposedPatch.meta.edgeRouting,
|
||||
}),
|
||||
});
|
||||
|
||||
get().requestLayout();
|
||||
},
|
||||
|
||||
rejectProposal: () => {
|
||||
const { previousGraphSnapshot } = get();
|
||||
if (!previousGraphSnapshot) return;
|
||||
|
||||
set({
|
||||
nodes: previousGraphSnapshot.nodes,
|
||||
edges: previousGraphSnapshot.edges,
|
||||
nodeCount: previousGraphSnapshot.nodes.length,
|
||||
proposedPatch: null,
|
||||
previousGraphSnapshot: null,
|
||||
proposalStatus: "idle",
|
||||
lastProposalOutcome: "rejected",
|
||||
});
|
||||
},
|
||||
|
||||
clearProposal: () => {
|
||||
set({
|
||||
proposedPatch: null,
|
||||
previousGraphSnapshot: null,
|
||||
proposalStatus: "idle",
|
||||
lastProposalOutcome: null,
|
||||
});
|
||||
},
|
||||
|
||||
setPrefillChat: (nodeId, text) => set({ prefillChat: { nodeId, text } }),
|
||||
clearPrefillChat: () => set({ prefillChat: null }),
|
||||
requestFitView: () =>
|
||||
set((s) => ({ fitViewRequested: s.fitViewRequested + 1 })),
|
||||
setFocusNodeId: (id) => set({ focusNodeId: id }),
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
nodes: [],
|
||||
@@ -93,6 +247,13 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
highlightedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
layoutRequestId: 0,
|
||||
proposedPatch: null,
|
||||
previousGraphSnapshot: null,
|
||||
proposalStatus: "idle",
|
||||
lastProposalOutcome: null,
|
||||
prefillChat: null,
|
||||
fitViewRequested: 0,
|
||||
focusNodeId: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user