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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user