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>
21 KiB
Story 3.3: Badge-Based Element Referencing for Targeted Modifications
Status: done
Story
As a user, I want to click diagram elements to reference them in my chat messages, so that I can tell the AI exactly which elements to modify.
Acceptance Criteria
-
Given I click a node on the canvas, When the node is selected, Then a badge chip appears near the chat input area showing the node's label/name, And the badge animates in with a slide effect (200ms ease-out), And the chat input placeholder changes to "Describe changes to [node name]..."
-
Given I have badge(s) in the chat input area, When I type a message and send it, Then the AI receives the selected element context along with my message (FR4), And the AI operates in targeted scope — only modifying the referenced elements, And the AI response specifically addresses the badged elements
-
Given I have a badge chip displayed, When I click the X on the badge chip or click empty canvas, Then the badge is removed, And the chat returns to whole-diagram scope (FR3)
-
Given I select multiple elements (multi-select or rectangle drag), When badges are created, Then multiple badge chips appear near the chat input, And the AI receives all selected elements as context for the modification
-
Given I select an element and type "split this into two steps", When the AI processes the scoped request, Then it generates a minimal JSON patch affecting only the referenced element and its immediate connections, And the canvas updates only the affected area (not full re-render)
Tasks / Subtasks
-
Task 1: Create BadgeChip component (AC: #1, #3)
- 1.1 Create
apps/web/src/modules/copilot/components/BadgeChip.tsx— shadcn/ui Badge variant with dismiss (X) button, truncated node label, diagram-type-aware icon - 1.2 Implement slide-in animation (200ms ease-out) using Motion
animate+exitwithAnimatePresence - 1.3 Click badge → scroll canvas to element + highlight (reuse existing
highlightedNodeIdstore state) - 1.4 Click X → call
setSelectedNodeIdsto remove that node ID from selection array - 1.5 Add ARIA label
"Selected element: [name]", keyboard dismissible via Backspace/Delete - 1.6 Style with
--badge-chip-bg,--badge-chip-border,--badge-chip-textCSS custom properties (from UX design tokens)
- 1.1 Create
-
Task 2: Integrate BadgeChip display into CopilotPanel (AC: #1, #3, #4)
- 2.1 Subscribe to
selectedNodeIdsfromuseGraphStorein CopilotPanel - 2.2 Map selected IDs to node data (label, type) by reading
nodesfrom store - 2.3 Render badge chips in a flex-wrap container above the textarea input, inside the border-t input area
- 2.4 Update placeholder text: when badges present → "Describe changes to [first badge name]..." (or "Describe changes to N elements..." for multi-select)
- 2.5 Wrap badge chips in
AnimatePresencefor enter/exit animations - 2.6 Handle Escape key in textarea: clear all badges (deselect all nodes on canvas)
- 2.1 Subscribe to
-
Task 3: Extend copilotMessageSchema with selectedElements (AC: #2, #4)
- 3.1 Add
selectedElementsoptional field tocopilotMessageSchemainpackages/ai/src/modules/copilot/schema.ts— array of{ id: string, type: string, label: string } - 3.2 Export
SelectedElementtype frompackages/ai/src/modules/copilot/types.ts
- 3.1 Add
-
Task 4: Pass selected element context from CopilotPanel to API (AC: #2, #4)
- 4.1 In
prepareSendMessagesRequest, serialize selected nodes: for eachselectedNodeIds, extract the node's graph-level data (id, type, label, connected edges, neighbor node labels) viaflowToGraphfiltered to selected - 4.2 Include
selectedElementsin the request body alongside existinggraphContext - 4.3 Include connected edges of selected nodes as
selectedEdgesfor neighbor context
- 4.1 In
-
Task 5: Update system prompt for scoped AI context (AC: #2, #5)
- 5.1 Extend
buildCopilotSystemPromptoptions to acceptselectedElements?: SelectedElement[]andselectedContext?: string(JSON of selected nodes + their edges + 1-hop neighbors) - 5.2 When selectedElements are provided, add a
## Scoped contextsection to the system prompt specifying: "The user has selected specific elements for targeted modification. Focus your changes on these elements and their immediate connections. Include ALL nodes and edges in your output, but only modify the selected ones." - 5.3 Include selected nodes' full data (properties, connected edges, neighbor labels) in the scoped context section
- 5.4 Add instruction: "When elements are selected, prefer minimal changes. Modify, split, merge, or restructure only the referenced elements. Preserve all other nodes and edges unchanged."
- 5.5 Update
system-prompt.test.tsto cover scoped context variations
- 5.1 Extend
-
Task 6: Update API handler to pass selectedElements (AC: #2)
- 6.1 In
streamCopilotinpackages/ai/src/modules/copilot/api.ts, destructureselectedElementsfrom the validated payload - 6.2 Pass
selectedElementsand formattedselectedContexttobuildCopilotSystemPrompt
- 6.1 In
-
Task 7: Add CSS custom properties for badge chip tokens (AC: #1)
- 7.1 Add
--badge-chip-bg,--badge-chip-border,--badge-chip-textto the diagram editor's CSS (in the existing design token location) — values from UX spec:oklch(0.623 0.214 260 / 10%),oklch(0.623 0.214 260 / 30%),oklch(0.45 0.20 260) - 7.2 Add dark mode variants for badge chip tokens
- 7.1 Add
-
Task 8: Write tests (AC: all)
- 8.1 System prompt tests: scoped context presence when selectedElements provided, absence when not, correct element data formatting (8 tests)
- 8.2 Schema tests: selectedElements validation — valid arrays, empty arrays, missing optional field (5 tests)
- 8.3 Component rendering tests deferred to E2E (per project standards from Story 3.1)
Dev Notes
Architecture Compliance
- AI mutations through CRDT: Per Winston architecture decision, all graph mutations flow through the Zustand store (which syncs to Liveblocks CRDT in future Epic 4). Badge-based targeted modifications use the same mutation pipeline as whole-diagram operations — the AI still outputs a complete
GraphDatapatch via thegenerateDiagramtool. The "targeted" scope is an AI behavioral constraint (system prompt), NOT a different code path. - ELK.js in Web Worker: Layout continues to run in Web Worker after targeted modifications. Use the existing
requestLayout()trigger from Story 3.2. - Unified graph model: Selected elements are referenced by their
DiagramNodeproperties from the unifiedGraphDatainterface. Badge chips display the node'slabelfield. - Selection state already exists:
useGraphStorehasselectedNodeIds: string[]andsetSelectedNodeIds(ids: string[])— already wired toonSelectionChangeinDiagramCanvas.tsx(Story 2.9). This story CONSUMES the existing selection state; it does NOT create new selection mechanisms.
Critical Implementation Patterns (from Stories 3.1 & 3.2)
Transport pattern — extend body with selectedElements:
// In CopilotPanel prepareSendMessagesRequest:
const selectedNodeIds = useGraphStore.getState().selectedNodeIds;
const allNodes = useGraphStore.getState().nodes;
const allEdges = useGraphStore.getState().edges;
// Build selected element context for AI
const selectedElements = selectedNodeIds.length > 0
? selectedNodeIds.map(id => {
const node = allNodes.find(n => n.id === 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(Boolean)
: undefined;
return {
body: {
...lastMessage,
chatId: id,
diagramId,
diagramType,
graphContext,
selectedElements, // NEW
},
};
System prompt scoping pattern:
// In buildCopilotSystemPrompt, when selectedElements provided:
## Scoped context — targeted modification
The user has selected ${selectedElements.length} element(s) for modification:
${selectedElements.map(e => `- ${e.label} (${e.type})`).join('\n')}
Connected edges: [edges connecting to/from selected nodes]
Neighbor nodes: [1-hop neighbors for context]
IMPORTANT: The user wants to modify ONLY these elements. Include ALL nodes
and edges in your generateDiagram output, but focus changes on the selected
elements and their immediate connections. Preserve everything else unchanged.
Badge chip click → canvas scroll pattern:
// BadgeChip onClick handler — use React Flow's fitView or setCenter
// CopilotPanel is OUTSIDE ReactFlowProvider, so direct React Flow hooks won't work.
// Instead, use the same Zustand store pattern from Story 3.2:
// Set highlightedNodeId in store → DiagramCanvas reacts to it.
const handleBadgeClick = (nodeId: string) => {
useGraphStore.getState().setHighlightedNodeId(nodeId);
};
Existing Selection Infrastructure (Story 2.9)
The selection system is already fully wired:
DiagramCanvas.tsx:314-319—handleSelectionChangecallback from@xyflow/reactupdatesselectedNodeIdsin Zustand storeuseGraphStore.ts:26,37,76—selectedNodeIds: string[]state +setSelectedNodeIdsactionDiagramCanvas.tsx:332-333—onSelectionChange+onPaneClick(clear) bound to ReactFlow@xyflow/reacthandles multi-select (Cmd+Click) and lasso (rectangle drag) natively
What this story adds: Consuming selectedNodeIds in CopilotPanel to show badge chips and pass scoped context to the AI. No changes to the canvas selection logic itself.
Unified Graph Model (Source of Truth)
Location: apps/web/src/modules/diagram/types/graph.ts
type DiagramType = "bpmn" | "er" | "orgchart" | "architecture" | "sequence" | "flowchart";
interface DiagramNode {
id: string; // Required
type: string; // Required - diagram-type-specific
label: string; // Required - display text (shown on badge chip)
// ... (see Story 3.2 dev notes for full interface)
}
interface DiagramEdge {
id: string; // Required
from: string; // Required - source node ID
to: string; // Required - target node ID
label?: string;
type?: string;
cardinality?: string;
}
Badge Chip UX Requirements (from UX Spec)
| Property | Value |
|---|---|
| Animation | Slide-in 200ms ease-out (entry), fade-out (exit) |
| Content | Node icon + truncated label + X dismiss button |
| Click badge | Scroll canvas to element + highlight |
| Click X | Deselect element on canvas (remove from selectedNodeIds) |
| Backspace/Delete | Remove last badge (like tag input pattern) |
| Escape (in textarea) | Clear all badges |
| Max display | Show all selected (scroll horizontally if overflow) |
| Placeholder | "Describe changes to [name]..." or "Describe changes to N elements..." |
| ARIA | role="listitem", aria-label="Selected element: [name]" |
| Tokens | --badge-chip-bg: oklch(0.623 0.214 260 / 10%), --badge-chip-border: oklch(0.623 0.214 260 / 30%), --badge-chip-text: oklch(0.45 0.20 260) |
Scope Indicator (UX Spec)
When badges are active, display a subtle scope indicator in the chat: "Context: [node name] + N connected edges" — so users know what the AI sees. This is a small text below the badge area, not a modal or tooltip.
File Structure
Files to CREATE:
apps/web/src/modules/copilot/components/
└── BadgeChip.tsx # Badge chip component with animation + dismiss
Files to MODIFY:
apps/web/src/modules/copilot/components/
└── CopilotPanel.tsx # Add badge display area, selectedElements in transport, placeholder
packages/ai/src/modules/copilot/
├── schema.ts # Add selectedElements optional field
├── types.ts # Export SelectedElement type
├── system-prompt.ts # Add scoped context section when elements selected
├── system-prompt.test.ts # Add scoped context tests
└── api.ts # Pass selectedElements to system prompt
Files to REFERENCE (read-only):
apps/web/src/modules/diagram/stores/useGraphStore.ts # selectedNodeIds state (consume)
apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Selection handling (no changes)
apps/web/src/modules/diagram/lib/graph-converter.ts # flowToGraph, flowNodeToGraphNode
apps/web/src/modules/diagram/types/graph.ts # DiagramNode, DiagramEdge interfaces
Project Structure Notes
- BadgeChip goes in
apps/web/src/modules/copilot/components/— co-located with CopilotPanel, NOT in diagram module (badge is a copilot UI concern) - Per the epics file, the suggested path was
apps/web/src/modules/chat/BadgeChip.tsx— but the project usescopilotnotchatas the module name. Useapps/web/src/modules/copilot/components/BadgeChip.tsx - Schema changes go in
packages/ai/src/modules/copilot/schema.ts— extend existingcopilotMessageSchema - System prompt changes go in
packages/ai/src/modules/copilot/system-prompt.ts— extendbuildCopilotSystemPromptoptions
Anti-Patterns to AVOID
- Do NOT create a separate API endpoint for scoped modifications — extend the existing copilot POST. The AI already receives
graphContext; this story addsselectedElementsas additional context. - Do NOT modify canvas selection behavior — selection is already working via
@xyflow/reactbuilt-in + Zustand store from Story 2.9. This story READS from the store, not writes. - Do NOT create a "patch mode" that only sends partial graph data — the AI always outputs a COMPLETE GraphData. "Targeted" scope is a system prompt instruction that tells the AI to focus changes on selected elements while preserving everything else.
- Do NOT use
require()or CommonJS — all packages are ESM-only - Do NOT inline
.parse()in Hono handlers — usevalidate()middleware - Do NOT put business logic in API routers — handlers call domain package functions
- Do NOT re-create selection UI — use
@xyflow/react's existingelementsSelectable+onSelectionChange - Do NOT use
uuid()column type — alwaystext().primaryKey().$defaultFn(generateId) - Do NOT run ELK.js on main thread — use existing Web Worker
Performance Requirements
- Badge chip animation: ≤ 200ms (UX spec)
- Badge appear after selection: < 50ms (instant feel — just Zustand state read)
- AI targeted mutation applied and rendered < 3 seconds (NFR4, same as whole-diagram)
- Respect
prefers-reduced-motion— badges appear instantly instead of slide-in
Security Requirements
enforceAuthmiddleware on all endpoints (already in place)deductCreditsmiddleware before AI generation calls (already in place)selectedElementsdata comes from client-side store (trusted) — validate schema structure only- AI output still validated through
validateGraphPatchmutation schema before applying to graph
Testing Standards
- Test runner: Vitest with explicit imports (
import { describe, it, expect } from 'vitest') - Test location: co-located with source files
- Factory pattern for test data
- Component rendering tests deferred to E2E per project standards
- Workspace command:
pnpm --filter @turbostarter/ai test - Expected new tests: ~10-15 (system prompt scoped context + schema selectedElements validation)
Previous Story Intelligence (Story 3.2)
What was built:
generateDiagramToolwithgraphPatchSchema— AI outputs full GraphData via tool calluseGraphMutation.tshook — applies AI patches to Zustand store + persists to DB- System prompt with generation/modification instructions, node/edge type references, examples
- Tool invocation detection in CopilotPanel via
isGenerateDiagramTooltype guard graphContextserialization in transport'sprepareSendMessagesRequestlayoutRequestIdcounter for cross-component layout triggering
Key learnings:
- AI SDK v4 uses
tool-${toolName}type pattern (e.g.,tool-generateDiagram) - AI SDK v4 tool part states:
input-streaming,input-available,output-available,output-error useGraphStore.getState()in transport callback avoids transport re-creation- Cross-component communication via Zustand store counters (not React Flow hooks)
Files created in 3.2 (do NOT recreate):
packages/ai/src/modules/copilot/mutation-schema.ts— Zod schema + validationpackages/ai/src/modules/copilot/mutation-schema.test.ts— 38 testsapps/web/src/modules/copilot/hooks/useGraphMutation.ts— graph patch application hook
Review fixes from 3.2 (already in codebase):
diagramTypevalidated asz.enum(VALID_DIAGRAM_TYPES)in mutation schematoChatMessage()preserves tool invocation partsvalidateUniqueIds()prevents duplicate node/edge IDsgraphDatastructurally validated inupdateDiagramBodySchema
No new dependencies needed — Motion (for animations) is already in workspace, shadcn/ui Badge is available.
Git Intelligence
Recent commit pattern: feat: implement Story X.Y — <description>. Follow this convention.
Last 5 commits all follow the pattern of implementing one story per commit. Files modified in Story 3.2:
- CopilotPanel.tsx (extended — will extend further)
- system-prompt.ts (extended — will extend further)
- api.ts (extended — will extend further)
- schema.ts (extended — will extend further, minor)
- types.ts (extended — will extend further, minor)
References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.3] — Acceptance criteria, technical notes
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#BadgeChip] — Component spec: animation, content, states, interactions, accessibility, tokens
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Core User Experience] — Badge referencing as signature interaction, conversational design loop
- [Source: _bmad-output/planning-artifacts/architecture.md] — AI mutation pipeline (client-side relay), soft-lock, bidirectional canvas↔chat state
- [Source: _bmad-output/project-context.md] — Framework rules, coding standards, anti-patterns
- [Source: apps/web/src/modules/diagram/stores/useGraphStore.ts] — selectedNodeIds state (lines 26, 37, 76)
- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — onSelectionChange handler (lines 314-319)
- [Source: _bmad-output/implementation-artifacts/3-2-ai-diagram-generation-from-natural-language.md] — Previous story patterns, review fixes, established conventions
Dev Agent Record
Agent Model Used
Claude Opus 4.6
Debug Log References
- Fixed BadgeChip icon names:
Icons.User→Icons.User2,Icons.Box→Icons.Server,Icons.Layers→Icons.Package(not exported in project's icons.tsx) - Pre-existing TS errors in bpmn-layout.ts and CopilotPanel setMessages type (not from this story)
Completion Notes List
- All 8 tasks completed, all subtasks done
- 103 copilot tests passing (33 system-prompt, 38 mutation-schema, 15 schema, 17 chunking)
- 8 scoped context tests + 6 buildSelectedContext tests + 6 selectedElements schema tests = 20 new tests
- No new TypeScript errors introduced (verified via
tsc --noEmit) - Badge chips use Motion animations with
AnimatePresencefor enter/exit - AI receives
selectedElements+selectedContext(selected nodes, connected edges, 1-hop neighbors) - System prompt adds "Scoped context" section instructing AI to focus modifications on selected elements
Code Review Fixes Applied (2026-02-28)
- H1: Added
prefers-reduced-motionsupport viauseReducedMotion()hook in BadgeChip — animations skip when OS reduced-motion enabled - H2: Added scope indicator text below badges: "Context: [name] + N connected edges"
- M1/M2: Fixed
handleDismissBadgeto usegetState()instead of closure — eliminates stale closure risk and prevents memo invalidation - M3: Moved
buildSelectedContextfrom api.ts to system-prompt.ts for testability; added 6 unit tests - L1: Changed X dismiss button
tabIndexfrom -1 to 0 for keyboard accessibility - L2: Replaced O(N*M)
.find()in selectedElements memo with O(N)Maplookup - L3: Added
.min(1)constraints toselectedElementSchemastring fields - L4: Added MAX_NEIGHBOR_NODES=10 limit to prevent prompt bloat from highly-connected nodes
File List
Created:
apps/web/src/modules/copilot/components/BadgeChip.tsx
Modified:
apps/web/src/modules/copilot/components/CopilotPanel.tsx— badge display, Escape key, selectedElements in transport, dynamic placeholder, scope indicatorpackages/ai/src/modules/copilot/schema.ts—selectedElementSchema,selectedElementsfieldpackages/ai/src/modules/copilot/types.ts—SelectedElementtype exportpackages/ai/src/modules/copilot/system-prompt.ts—buildScopedContextSection,buildSelectedContext, extended optionspackages/ai/src/modules/copilot/system-prompt.test.ts— 8 scoped context tests + 6 buildSelectedContext testspackages/ai/src/modules/copilot/schema.test.ts— 6 selectedElements validation testspackages/ai/src/modules/copilot/api.ts— selectedElements passthrough (buildSelectedContext moved to system-prompt.ts)apps/web/src/assets/styles/globals.css— badge chip CSS tokens (light + dark)