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

@@ -0,0 +1,105 @@
"use client";
import { memo, useCallback } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-web/icons";
import { useGraphStore } from "~/modules/diagram/stores/useGraphStore";
interface BadgeChipProps {
nodeId: string;
label: string;
nodeType: string;
onDismiss: (nodeId: string) => void;
}
export const BadgeChip = memo<BadgeChipProps>(
({ nodeId, label, nodeType, onDismiss }) => {
const prefersReducedMotion = useReducedMotion();
const handleClick = useCallback(() => {
// Highlight the node on canvas via Zustand store
useGraphStore.getState().setHighlightedNodeId(nodeId);
}, [nodeId]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(nodeId);
},
[nodeId, onDismiss],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault();
onDismiss(nodeId);
}
},
[nodeId, onDismiss],
);
return (
<motion.button
layout
initial={prefersReducedMotion ? false : { opacity: 0, x: -8, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, scale: prefersReducedMotion ? 1 : 0.9 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: "easeOut" }}
type="button"
role="listitem"
aria-label={`Selected element: ${label}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium",
"border transition-colors duration-150",
"bg-[var(--badge-chip-bg)] border-[var(--badge-chip-border)] text-[var(--badge-chip-text)]",
"hover:bg-[var(--badge-chip-border)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"max-w-[160px] cursor-pointer",
)}
>
<NodeTypeIcon nodeType={nodeType} />
<span className="truncate">{label}</span>
<span
role="button"
tabIndex={0}
aria-label={`Remove ${label}`}
onClick={handleDismiss}
className="ml-0.5 shrink-0 rounded-sm p-0.5 hover:bg-[var(--badge-chip-border)] transition-colors"
>
<Icons.X className="size-2.5" />
</span>
</motion.button>
);
},
);
BadgeChip.displayName = "BadgeChip";
/** Diagram-type-aware node icon */
function NodeTypeIcon({ nodeType }: { nodeType: string }) {
// Map common node types to icons
if (nodeType.includes("gateway") || nodeType.includes("decision")) {
return <Icons.GitBranch className="size-3 shrink-0" />;
}
if (nodeType.includes("database") || nodeType.includes("entity")) {
return <Icons.Database className="size-3 shrink-0" />;
}
if (nodeType.includes("person") || nodeType.includes("participant") || nodeType.includes("actor")) {
return <Icons.User2 className="size-3 shrink-0" />;
}
if (nodeType.includes("service") || nodeType.includes("process") || nodeType.includes("activity")) {
return <Icons.Server className="size-3 shrink-0" />;
}
if (nodeType.includes("queue")) {
return <Icons.Package className="size-3 shrink-0" />;
}
if (nodeType.includes("external") || nodeType.includes("loadbalancer")) {
return <Icons.Globe className="size-3 shrink-0" />;
}
// Default
return <Icons.Circle className="size-3 shrink-0" />;
}

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