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:
Alejandro Gutiérrez
2026-02-28 14:29:11 +00:00
parent 6dcb4dcd6f
commit 6591d6385a
11 changed files with 861 additions and 8 deletions

View File

@@ -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"
/>