feat: implement Story 3.3 — badge-based element referencing for targeted modifications
Adds badge chips in the copilot chat input that reference selected diagram elements, enabling scoped AI modifications. Includes code review fixes for reduced-motion support, scope indicator, callback stability, schema validation, neighbor limits, and buildSelectedContext test coverage (103 tests passing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -17,6 +18,7 @@ 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 { BadgeChip } from "./BadgeChip";
|
||||
|
||||
import type { DiagramType } from "~/modules/diagram/types/graph";
|
||||
|
||||
@@ -48,6 +50,50 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
|
||||
const { applyGraphPatch } = useGraphMutation(diagramId, diagramType);
|
||||
|
||||
// Subscribe to selected nodes for badge chips
|
||||
const selectedNodeIds = useGraphStore((s) => s.selectedNodeIds);
|
||||
const storeNodes = useGraphStore((s) => s.nodes);
|
||||
const storeEdges = useGraphStore((s) => s.edges);
|
||||
|
||||
const selectedElements = useMemo(() => {
|
||||
if (selectedNodeIds.length === 0) return [];
|
||||
const nodeMap = new Map(storeNodes.map((n) => [n.id, n]));
|
||||
return selectedNodeIds
|
||||
.map((id) => {
|
||||
const node = nodeMap.get(id);
|
||||
if (!node) return null;
|
||||
const data = node.data as { type?: string; label?: string };
|
||||
return { id: node.id, type: data.type ?? "unknown", label: data.label ?? node.id };
|
||||
})
|
||||
.filter((e): e is { id: string; type: string; label: string } => e !== null);
|
||||
}, [selectedNodeIds, storeNodes]);
|
||||
|
||||
const handleDismissBadge = useCallback((nodeId: string) => {
|
||||
const current = useGraphStore.getState().selectedNodeIds;
|
||||
useGraphStore.getState().setSelectedNodeIds(current.filter((id) => id !== nodeId));
|
||||
}, []);
|
||||
|
||||
// Scope indicator: show what context the AI will see
|
||||
const scopeInfo = useMemo(() => {
|
||||
if (selectedElements.length === 0) return null;
|
||||
const selectedIds = new Set(selectedNodeIds);
|
||||
const connectedCount = storeEdges.filter(
|
||||
(e) => selectedIds.has(e.source) || selectedIds.has(e.target),
|
||||
).length;
|
||||
const label =
|
||||
selectedElements.length === 1
|
||||
? selectedElements[0]!.label
|
||||
: `${selectedElements.length} elements`;
|
||||
return `Context: ${label} + ${connectedCount} connected edge${connectedCount !== 1 ? "s" : ""}`;
|
||||
}, [selectedElements, selectedNodeIds, storeEdges]);
|
||||
|
||||
// Dynamic placeholder text
|
||||
const placeholder = useMemo(() => {
|
||||
if (selectedElements.length === 0) return "Describe what you want to build...";
|
||||
if (selectedElements.length === 1) return `Describe changes to ${selectedElements[0]!.label}...`;
|
||||
return `Describe changes to ${selectedElements.length} elements...`;
|
||||
}, [selectedElements]);
|
||||
|
||||
// Fetch existing chat history on mount (H1 fix)
|
||||
const { data: initialMessages } = useQuery({
|
||||
queryKey: ["copilot", "messages", chatId],
|
||||
@@ -83,6 +129,20 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Build selected element context for targeted modifications
|
||||
const currentSelectedIds = useGraphStore.getState().selectedNodeIds;
|
||||
const selectedEls =
|
||||
currentSelectedIds.length > 0
|
||||
? currentSelectedIds
|
||||
.map((nid) => {
|
||||
const node = currentNodes.find((n) => n.id === nid);
|
||||
if (!node) return null;
|
||||
const data = node.data as { type?: string; label?: string };
|
||||
return { id: node.id, type: data.type ?? "unknown", label: data.label ?? node.id };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
body: {
|
||||
...lastMessage,
|
||||
@@ -90,6 +150,7 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
diagramId,
|
||||
diagramType,
|
||||
graphContext,
|
||||
selectedElements: selectedEls,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -213,8 +274,12 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
if (e.key === "Escape" && selectedNodeIds.length > 0) {
|
||||
e.preventDefault();
|
||||
useGraphStore.getState().setSelectedNodeIds([]);
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
[handleSend, selectedNodeIds.length],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -278,13 +343,34 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
|
||||
|
||||
{/* Input area */}
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
{/* Badge chips for selected elements */}
|
||||
{selectedElements.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1" role="list" aria-label="Selected elements">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{selectedElements.map((el) => (
|
||||
<BadgeChip
|
||||
key={el.id}
|
||||
nodeId={el.id}
|
||||
label={el.label}
|
||||
nodeType={el.type}
|
||||
onDismiss={handleDismissBadge}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{scopeInfo && (
|
||||
<p className="w-full text-[10px] text-muted-foreground/60 mt-0.5">
|
||||
{scopeInfo}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Describe what you want to build..."
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 pr-20 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user