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:
105
apps/web/src/modules/copilot/components/BadgeChip.tsx
Normal file
105
apps/web/src/modules/copilot/components/BadgeChip.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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