Files
turbostarter/_bmad-output/implementation-artifacts/3-3-badge-based-element-referencing-for-targeted-modifications.md
Alejandro Gutiérrez 6591d6385a 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>
2026-02-28 14:29:11 +00:00

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

  1. 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]..."

  2. 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

  3. 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)

  4. 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

  5. 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 + exit with AnimatePresence
    • 1.3 Click badge → scroll canvas to element + highlight (reuse existing highlightedNodeId store state)
    • 1.4 Click X → call setSelectedNodeIds to 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-text CSS custom properties (from UX design tokens)
  • Task 2: Integrate BadgeChip display into CopilotPanel (AC: #1, #3, #4)

    • 2.1 Subscribe to selectedNodeIds from useGraphStore in CopilotPanel
    • 2.2 Map selected IDs to node data (label, type) by reading nodes from 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 AnimatePresence for enter/exit animations
    • 2.6 Handle Escape key in textarea: clear all badges (deselect all nodes on canvas)
  • Task 3: Extend copilotMessageSchema with selectedElements (AC: #2, #4)

    • 3.1 Add selectedElements optional field to copilotMessageSchema in packages/ai/src/modules/copilot/schema.ts — array of { id: string, type: string, label: string }
    • 3.2 Export SelectedElement type from packages/ai/src/modules/copilot/types.ts
  • Task 4: Pass selected element context from CopilotPanel to API (AC: #2, #4)

    • 4.1 In prepareSendMessagesRequest, serialize selected nodes: for each selectedNodeIds, extract the node's graph-level data (id, type, label, connected edges, neighbor node labels) via flowToGraph filtered to selected
    • 4.2 Include selectedElements in the request body alongside existing graphContext
    • 4.3 Include connected edges of selected nodes as selectedEdges for neighbor context
  • Task 5: Update system prompt for scoped AI context (AC: #2, #5)

    • 5.1 Extend buildCopilotSystemPrompt options to accept selectedElements?: SelectedElement[] and selectedContext?: string (JSON of selected nodes + their edges + 1-hop neighbors)
    • 5.2 When selectedElements are provided, add a ## Scoped context section 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.ts to cover scoped context variations
  • Task 6: Update API handler to pass selectedElements (AC: #2)

    • 6.1 In streamCopilot in packages/ai/src/modules/copilot/api.ts, destructure selectedElements from the validated payload
    • 6.2 Pass selectedElements and formatted selectedContext to buildCopilotSystemPrompt
  • Task 7: Add CSS custom properties for badge chip tokens (AC: #1)

    • 7.1 Add --badge-chip-bg, --badge-chip-border, --badge-chip-text to 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
  • 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 GraphData patch via the generateDiagram tool. 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 DiagramNode properties from the unified GraphData interface. Badge chips display the node's label field.
  • Selection state already exists: useGraphStore has selectedNodeIds: string[] and setSelectedNodeIds(ids: string[]) — already wired to onSelectionChange in DiagramCanvas.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:

  1. DiagramCanvas.tsx:314-319handleSelectionChange callback from @xyflow/react updates selectedNodeIds in Zustand store
  2. useGraphStore.ts:26,37,76selectedNodeIds: string[] state + setSelectedNodeIds action
  3. DiagramCanvas.tsx:332-333onSelectionChange + onPaneClick (clear) bound to ReactFlow
  4. @xyflow/react handles 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 uses copilot not chat as the module name. Use apps/web/src/modules/copilot/components/BadgeChip.tsx
  • Schema changes go in packages/ai/src/modules/copilot/schema.ts — extend existing copilotMessageSchema
  • System prompt changes go in packages/ai/src/modules/copilot/system-prompt.ts — extend buildCopilotSystemPrompt options

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 adds selectedElements as additional context.
  • Do NOT modify canvas selection behavior — selection is already working via @xyflow/react built-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 — use validate() middleware
  • Do NOT put business logic in API routers — handlers call domain package functions
  • Do NOT re-create selection UI — use @xyflow/react's existing elementsSelectable + onSelectionChange
  • Do NOT use uuid() column type — always text().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

  • enforceAuth middleware on all endpoints (already in place)
  • deductCredits middleware before AI generation calls (already in place)
  • selectedElements data comes from client-side store (trusted) — validate schema structure only
  • AI output still validated through validateGraphPatch mutation 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:

  • generateDiagramTool with graphPatchSchema — AI outputs full GraphData via tool call
  • useGraphMutation.ts hook — 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 isGenerateDiagramTool type guard
  • graphContext serialization in transport's prepareSendMessagesRequest
  • layoutRequestId counter 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 + validation
  • packages/ai/src/modules/copilot/mutation-schema.test.ts — 38 tests
  • apps/web/src/modules/copilot/hooks/useGraphMutation.ts — graph patch application hook

Review fixes from 3.2 (already in codebase):

  • diagramType validated as z.enum(VALID_DIAGRAM_TYPES) in mutation schema
  • toChatMessage() preserves tool invocation parts
  • validateUniqueIds() prevents duplicate node/edge IDs
  • graphData structurally validated in updateDiagramBodySchema

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.UserIcons.User2, Icons.BoxIcons.Server, Icons.LayersIcons.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 AnimatePresence for 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-motion support via useReducedMotion() 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 handleDismissBadge to use getState() instead of closure — eliminates stale closure risk and prevents memo invalidation
  • M3: Moved buildSelectedContext from api.ts to system-prompt.ts for testability; added 6 unit tests
  • L1: Changed X dismiss button tabIndex from -1 to 0 for keyboard accessibility
  • L2: Replaced O(N*M) .find() in selectedElements memo with O(N) Map lookup
  • L3: Added .min(1) constraints to selectedElementSchema string 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 indicator
  • packages/ai/src/modules/copilot/schema.tsselectedElementSchema, selectedElements field
  • packages/ai/src/modules/copilot/types.tsSelectedElement type export
  • packages/ai/src/modules/copilot/system-prompt.tsbuildScopedContextSection, buildSelectedContext, extended options
  • packages/ai/src/modules/copilot/system-prompt.test.ts — 8 scoped context tests + 6 buildSelectedContext tests
  • packages/ai/src/modules/copilot/schema.test.ts — 6 selectedElements validation tests
  • packages/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)