Files
turbostarter/_bmad-output/implementation-artifacts/3-4-ai-semantic-suggestions-and-accept-reject-workflow.md
Alejandro Gutiérrez c4379afe1f
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
feat: implement Stories 3.4, 3.5, 3.6 — AI proposals, wizard, hover & palette
Story 3.4: AI semantic suggestions with accept/reject workflow
- ProposalBar overlay with visual diff
- Accept/reject flow with graph snapshot restore
- useProposalDiff hook for change summary
- System prompt scoping for selected elements

Story 3.5: New diagram wizard with AI type inference
- CreateDiagramDialog with AI type inference (Haiku)
- initialDescription prop for chat-first flow
- Auto-send on mount with hasSentInitial ref guard
- DB migration for diagram description column

Story 3.6: Hover affordances and command palette
- HoverAffordances toolbar (5 AI actions, debounced)
- CommandPalette (Cmd+K) with AI, nav, Go to Node
- prefillChat/fitViewRequested/focusNodeId actions
- Code review: getNodesBounds, onOpenRightPanel,
  timer cleanup, test count fix

374 tests passing (251 web + 123 AI).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:55:06 +00:00

35 KiB

Story 3.4: AI Semantic Suggestions and Accept/Reject Workflow

Status: done

Story

As a user, I want the AI to suggest improvements and let me review changes before applying them, so that I maintain control and the AI helps me build better diagrams.

Acceptance Criteria

  1. Given the AI generates a diagram modification, When the response is ready, Then the canvas shows a visual diff: new nodes pulse with --ai-diff-add (green overlay), removed nodes fade with --ai-diff-remove (red overlay), modified nodes show before/after state (FR6), And Accept/Reject controls appear both inline in the chat message AND as a floating bar on the canvas.

  2. Given I see a proposed change with visual diff, When I press Enter or click Accept, Then the proposed changes are committed to the graph data, And ELK.js re-layouts the diagram smoothly, And the diff highlights fade away.

  3. Given I see a proposed change, When I press Escape or click Reject, Then the proposed changes are discarded, And the canvas reverts to its previous state, And badge chips remain so I can immediately refine my request.

  4. Given the AI analyzes a diagram, When it detects semantic issues (FR5), Then it proactively mentions them in chat: "Note: Your BPMN process has no error boundary" or "Consider a junction table for this M:N relationship", And suggestions appear as distinct message types (info/suggestion styling).

  5. Given the AI proposes changes, When I want to see what will change, Then the chat message includes a summary of changes (e.g., "Adding 2 nodes, modifying 1 edge, removing 1 node").

Tasks / Subtasks

  • Task 1: Add proposal state to Zustand store (AC: #1, #2, #3)

    • 1.1 Add proposedPatch: GraphPatchData | null to GraphState in useGraphStore.ts
    • 1.2 Add previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | null to GraphState
    • 1.3 Add proposalStatus: 'idle' | 'pending' | 'accepted' | 'rejected' to GraphState
    • 1.4 Add proposeChanges(patch: GraphPatchData): void action — snapshots current nodes/edges, converts proposed patch to flow nodes/edges, merges with diff classes, sets status to 'pending'
    • 1.5 Add acceptProposal(): void action — applies the proposedPatch via the existing apply flow (setNodes, setEdges, requestLayout), clears diff classes, clears proposal state, sets status to 'accepted' then 'idle'
    • 1.6 Add rejectProposal(): void action — restores previousGraphSnapshot to nodes/edges, clears proposal state, sets status to 'rejected' then 'idle'
    • 1.7 Add clearProposal(): void action — resets proposal state without side effects
    • 1.8 Initialize all new fields in reset()
  • Task 2: Create useProposalDiff hook for diff computation (AC: #1, #5)

    • 2.1 Create apps/web/src/modules/copilot/hooks/useProposalDiff.ts
    • 2.2 Compute diff by comparing previousGraphSnapshot node/edge IDs vs proposedPatch node/edge IDs: added (new IDs), removed (missing IDs), modified (same ID, different label/type/properties)
    • 2.3 Return { addedCount, removedCount, modifiedCount, changeSummary }changeSummary is a formatted string like "Adding 2 nodes, modifying 1 edge, removing 1 node"
    • 2.4 Memoize with useMemo keyed on proposedPatch and previousGraphSnapshot
  • Task 3: Modify mutation pipeline to propose instead of auto-apply (AC: #1, #2, #3)

    • 3.1 In useGraphMutation.ts, add proposeGraphPatch(patch: GraphPatchData): void alongside existing applyGraphPatch
    • 3.2 proposeGraphPatch calls useGraphStore.getState().proposeChanges(patch) — snapshots current graph, converts proposed patch to flow format, merges both sets of nodes showing diff styling
    • 3.3 In CopilotPanel.tsx tool detection effect, change from calling applyGraphPatch(result.data) to calling proposeGraphPatch(result.data) — the patch is now proposed, not auto-applied
    • 3.4 Export both applyGraphPatch and proposeGraphPatch from hook
  • Task 4: Implement visual diff on canvas via node/edge className (AC: #1)

    • 4.1 In the proposeChanges store action, compute diff between current and proposed nodes by ID: new nodes get className: "ai-diff-add", nodes in current but not in proposed get className: "ai-diff-remove", modified nodes get className: "ai-diff-modified"
    • 4.2 Merge both sets: show proposed state for new/modified nodes + keep removed nodes visible with diff styling. Set this merged set via setNodes/setEdges
    • 4.3 Add CSS for diff classes in globals.css: .ai-diff-add (green pulsing overlay using --ai-diff-add), .ai-diff-remove (red fade using --ai-diff-remove), .ai-diff-modified (blue outline using --ai-accent)
    • 4.4 Add @media (prefers-reduced-motion: reduce) — diff classes use static overlays instead of animations
    • 4.5 Edges follow same pattern: new edges get ai-diff-add, removed edges get ai-diff-remove
  • Task 5: Add Accept/Reject controls inline in AssistantBubble (AC: #1, #2, #3, #5)

    • 5.1 In CopilotPanel.tsx AssistantBubble, when a tool result is output-available AND proposalStatus === 'pending', render Accept/Reject buttons instead of "Diagram updated"
    • 5.2 Accept button: calls useGraphStore.getState().acceptProposal() then persists the accepted patch to DB via the existing API (api.diagrams[":id"].$patch)
    • 5.3 Reject button: calls useGraphStore.getState().rejectProposal()
    • 5.4 Show change summary from useProposalDiff above the buttons: "Adding 2 nodes, removing 1 node"
    • 5.5 After accept/reject, replace buttons with status text: "Diagram updated" or "Changes discarded"
    • 5.6 Style: Accept = primary variant, Reject = ghost variant. Icons: Check for accept, X for reject
  • Task 6: Add floating Accept/Reject bar on canvas (AC: #1, #2, #3)

    • 6.1 Create apps/web/src/modules/diagram/components/editor/ProposalBar.tsx
    • 6.2 Use @xyflow/react Panel component at position="bottom-center" — renders inside ReactFlowProvider
    • 6.3 Subscribe to proposalStatus from useGraphStore — only render when proposalStatus === 'pending'
    • 6.4 Display: change summary text + Accept button + Reject button
    • 6.5 Animate in/out with Motion animate + AnimatePresence
    • 6.6 Add to DiagramCanvas.tsx CanvasInner — render ProposalBar inside the ReactFlow component as a sibling to Panel (layout indicator)
    • 6.7 ARIA: role="alert", aria-label="AI proposes: [summary]. Press Enter to accept, Escape to reject."
  • Task 7: Add keyboard shortcuts for accept/reject (AC: #2, #3)

    • 7.1 In CopilotPanel.tsx handleKeyDown, when proposalStatus === 'pending': Enter = accept proposal (prevent default send), Escape = reject proposal (clear badges behavior changes: only reject proposal, don't clear badges)
    • 7.2 In DiagramCanvas.tsx, add onKeyDown handler on the ReactFlow wrapper: Enter = accept, Escape = reject (when proposal pending). This ensures keyboard shortcuts work regardless of focus location
    • 7.3 Ensure Enter in textarea only triggers accept when textarea is empty (non-empty Enter = send new message)
  • Task 8: Enhance system prompt with semantic analysis instructions (AC: #4)

    • 8.1 In system-prompt.ts, add a ## Semantic analysis section to the system prompt with diagram-type-specific validation rules
    • 8.2 BPMN rules: check for missing error boundaries, missing end events, gateways without merge, pools without message flows
    • 8.3 E-R rules: check for M:N relationships without junction tables, entities without primary keys, circular foreign key chains
    • 8.4 Architecture rules: check for single points of failure, services without database connections, missing load balancers
    • 8.5 Flowchart rules: check for unreachable nodes, decisions with single outgoing path, missing terminal nodes
    • 8.6 Orgchart rules: check for employees without managers (except root), excessive span of control (>10 direct reports)
    • 8.7 Sequence rules: check for messages without return, participants with no interactions
    • 8.8 Instruct the AI: "After generating or modifying a diagram, briefly note any semantic issues you detect. Present these as helpful suggestions in your chat response, NOT as blocking errors. Use phrasing like 'Note:' or 'Consider:' followed by the observation."
  • Task 9: Add suggestion message styling in CopilotPanel (AC: #4)

    • 9.1 Detect suggestion patterns in assistant message text: lines starting with "Note:" or "Consider:" or "Suggestion:"
    • 9.2 In AssistantBubble, render suggestion lines with a distinct visual: left border accent (using --ai-accent), info icon (Icons.Lightbulb or Icons.Info), slightly different background (bg-muted/30)
    • 9.3 Keep suggestions inline in the markdown flow — do NOT extract them into separate components. Just wrap matching paragraphs with the styled container
  • Task 10: Add change summary to AI response (AC: #5)

    • 10.1 In system-prompt.ts, add instruction: "When modifying an existing diagram, include a brief change summary in your response before calling the tool. Format: 'Changes: Adding N nodes, modifying N edges, removing N nodes.' This helps users understand what will change before reviewing the visual diff."
    • 10.2 The client-side useProposalDiff hook independently computes the same summary from actual graph comparison — the AI's text summary is informational; the hook's summary is authoritative (shown in accept/reject controls)
  • Task 11: Write tests (AC: all)

    • 11.1 System prompt tests: verify semantic analysis section is included for each diagram type, verify change summary instruction is present (6+ tests)
    • 11.2 useProposalDiff tests: diff computation — added nodes, removed nodes, modified nodes, mixed changes, empty graph, no changes (8+ tests)
    • 11.3 Store action tests: proposeChanges sets correct state, acceptProposal clears proposal and sets nodes, rejectProposal restores snapshot (6+ tests)
    • 11.4 Component rendering tests deferred to E2E per project standards

Dev Notes

Architecture Compliance

  • AI mutations through CRDT: Per Winston architecture decision (Decision 3), all graph mutations flow through the Zustand store. This story adds a PROPOSAL LAYER between AI output and store mutation. The AI still outputs a complete GraphData via generateDiagram tool. The proposal layer intercepts the output, shows a visual diff, and only commits to the store on user acceptance. In Epic 4 (Liveblocks), acceptProposal will become a CRDT mutation — the proposal layer naturally fits as a pre-commit gate.
  • ELK.js in Web Worker: Layout runs ONLY on accept (not on propose). During proposal, nodes are shown at their current positions with diff styling overlays. On accept, requestLayout() triggers the Web Worker for smooth re-layout.
  • Unified graph model: Diff is computed at the GraphData level (DiagramNode/DiagramEdge IDs), then translated to @xyflow/react Node/Edge className attributes for visual rendering.
  • No new API endpoints: The propose/accept/reject cycle is entirely client-side. The DB persist call (api.diagrams[":id"].$patch) only fires on accept — same as the current auto-apply flow.

Critical Implementation Patterns (from Stories 3.1-3.3)

Current auto-apply flow (to be modified):

// CopilotPanel.tsx — current behavior (Story 3.2/3.3):
if (result.success) {
  applyGraphPatch(result.data); // ← AUTO-APPLIES immediately
}

// Story 3.4 changes to:
if (result.success) {
  proposeGraphPatch(result.data); // ← PROPOSES for review
}

Proposal state in Zustand store:

// New fields in GraphState interface:
proposedPatch: GraphPatchData | null;
previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | null;
proposalStatus: 'idle' | 'pending' | 'accepted' | 'rejected';

// proposeChanges action:
proposeChanges: (patch: GraphPatchData) => {
  const { nodes, edges } = get();
  // Snapshot current state for revert
  set({ previousGraphSnapshot: { nodes: [...nodes], edges: [...edges] } });
  set({ proposedPatch: patch, proposalStatus: 'pending' });

  // Convert proposed patch to flow format
  const proposed = graphToFlow(patchToGraphData(patch));

  // Compute diff: compare current node IDs vs proposed node IDs
  const currentIds = new Set(nodes.map(n => n.id));
  const proposedIds = new Set(proposed.nodes.map(n => n.id));

  // Merge: show proposed state + keep removed nodes visible
  const mergedNodes = [
    ...proposed.nodes.map(n => ({
      ...n,
      className: !currentIds.has(n.id) ? 'ai-diff-add'
        : isDifferent(n, nodes.find(c => c.id === n.id)) ? 'ai-diff-modified'
        : undefined,
    })),
    ...nodes
      .filter(n => !proposedIds.has(n.id))
      .map(n => ({ ...n, className: 'ai-diff-remove' })),
  ];

  set({ nodes: mergedNodes, edges: mergedEdges });
};

// acceptProposal action:
acceptProposal: () => {
  const { proposedPatch } = get();
  if (!proposedPatch) return;
  const graphData = patchToGraphData(proposedPatch);
  const { nodes, edges } = graphToFlow(graphData);
  set({
    nodes, edges,
    proposedPatch: null,
    previousGraphSnapshot: null,
    proposalStatus: 'idle',
  });
  get().requestLayout(); // Trigger ELK.js re-layout
};

// rejectProposal action:
rejectProposal: () => {
  const { previousGraphSnapshot } = get();
  if (!previousGraphSnapshot) return;
  set({
    nodes: previousGraphSnapshot.nodes,
    edges: previousGraphSnapshot.edges,
    proposedPatch: null,
    previousGraphSnapshot: null,
    proposalStatus: 'idle',
  });
};

Visual diff CSS classes:

/* In globals.css — next to existing badge chip tokens */

/* AI Diff Overlay States */
.react-flow__node.ai-diff-add {
  outline: 2px solid var(--ai-diff-add);
  outline-offset: 2px;
  animation: ai-diff-pulse 1.5s ease-in-out infinite;
}
.react-flow__node.ai-diff-remove {
  opacity: 0.4;
  outline: 2px dashed var(--ai-diff-remove);
  outline-offset: 2px;
  text-decoration: line-through;
}
.react-flow__node.ai-diff-modified {
  outline: 2px solid var(--ai-accent);
  outline-offset: 2px;
}
@keyframes ai-diff-pulse {
  0%, 100% { outline-color: var(--ai-diff-add); }
  50% { outline-color: transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .react-flow__node.ai-diff-add { animation: none; }
}

/* Edge diff classes */
.react-flow__edge.ai-diff-add path { stroke: oklch(0.80 0.18 152); stroke-dasharray: 8 4; }
.react-flow__edge.ai-diff-remove path { stroke: oklch(0.58 0.25 27); opacity: 0.4; }

Floating ProposalBar on canvas:

// ProposalBar.tsx — inside ReactFlowProvider (Panel component)
import { Panel } from "@xyflow/react";
import { AnimatePresence, motion } from "motion/react";

function ProposalBar() {
  const proposalStatus = useGraphStore(s => s.proposalStatus);
  const acceptProposal = useGraphStore(s => s.acceptProposal);
  const rejectProposal = useGraphStore(s => s.rejectProposal);
  const { changeSummary } = useProposalDiff();

  return (
    <Panel position="bottom-center">
      <AnimatePresence>
        {proposalStatus === 'pending' && (
          <motion.div
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 20 }}
            role="alert"
            aria-label={`AI proposes: ${changeSummary}. Press Enter to accept, Escape to reject.`}
          >
            <span>{changeSummary}</span>
            <Button onClick={acceptProposal}>Accept (Enter)</Button>
            <Button variant="ghost" onClick={rejectProposal}>Reject (Esc)</Button>
          </motion.div>
        )}
      </AnimatePresence>
    </Panel>
  );
}

Accept/Reject in AssistantBubble:

// In CopilotPanel.tsx AssistantBubble — replace "Diagram updated" indicator
const proposalStatus = useGraphStore(s => s.proposalStatus);

{hasToolResult && proposalStatus === 'pending' && (
  <div className="mt-2 flex items-center gap-2">
    <span className="text-xs text-muted-foreground">{changeSummary}</span>
    <Button size="sm" onClick={handleAccept}>
      <Icons.Check className="size-3 mr-1" /> Accept
    </Button>
    <Button size="sm" variant="ghost" onClick={handleReject}>
      <Icons.X className="size-3 mr-1" /> Reject
    </Button>
  </div>
)}
{hasToolResult && proposalStatus === 'idle' && (
  <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
    <Icons.Check className="size-3 text-green-500" />
    Diagram updated
  </div>
)}

Semantic analysis system prompt addition:

// Add after the Constraints section in buildCopilotSystemPrompt:
const SEMANTIC_RULES: Record<DiagramType, string> = {
  bpmn: `- Check for processes without error boundaries or exception handling
- Check for gateways without corresponding merge/join
- Check for pools without inter-pool message flows
- Check for missing end events in subprocess branches`,
  er: `- Check for M:N relationships that may need a junction/associative table
- Check for entities without primary keys
- Check for potential circular foreign key dependencies
- Check for denormalization opportunities or concerns`,
  // ... other types
};

// Added to system prompt:
`## Semantic analysis
After generating or modifying a diagram, briefly note any semantic issues:
${SEMANTIC_RULES[diagramType]}
Present as helpful inline suggestions using "Note:" or "Consider:" prefix.
Do not block diagram generation for semantic issues.`

Keyboard Shortcut Conflict Resolution

Current keyboard mappings in CopilotPanel:

  • Enter (no shift) = send message
  • Escape (with badges) = clear badges

Story 3.4 adds:

  • Enter (when proposal pending AND textarea empty) = accept proposal
  • Escape (when proposal pending) = reject proposal (NOT clear badges — badges should persist per AC#3)

Priority order in handleKeyDown:

  1. If proposal pending + Enter + textarea empty → accept proposal
  2. If proposal pending + Escape → reject proposal (skip badge clearing)
  3. If Enter + textarea has text → send message (normal)
  4. If Escape + no proposal + badges → clear badges (normal)

DB Persistence Strategy

  • On propose: No DB write. The proposal is ephemeral client state.
  • On accept: Call api.diagrams[":id"].$patch with the accepted graphData (same as current auto-apply path in useGraphMutation.ts). Move the persist logic from useGraphMutation.applyGraphPatch into a shared persistGraphData utility or call it from the accept handler.
  • On reject: No DB write. The diagram state reverts to what was already persisted.

Suggestion Styling Pattern

// Detect suggestion patterns in AssistantBubble text:
// Lines starting with "Note:", "Consider:", "Suggestion:", "Tip:"
const SUGGESTION_PATTERN = /^(Note|Consider|Suggestion|Tip):/m;

// Wrap matching paragraphs in styled container:
// - Left border with --ai-accent color
// - Info icon
// - Slightly muted background

Do NOT create a separate "suggestion" message type or separate component. Suggestions are part of the normal AI response text. The styling is applied at the markdown rendering level by detecting the pattern.

Existing Infrastructure (Stories 2.9 + 3.1-3.3)

Selection state (Story 2.9): selectedNodeIds in store — untouched by this story. Badge chips persist through accept/reject per AC#3.

Graph mutation hook (Story 3.2): useGraphMutation.ts — extend with proposeGraphPatch. Keep applyGraphPatch for direct application (used by acceptProposal).

System prompt (Story 3.3): buildCopilotSystemPrompt — extend with semantic analysis section. No changes to scoped context logic.

Node className (Story 2.9): DiagramCanvas already uses className on nodes for BFS highlighting (highlighted, dimmed). Diff classes (ai-diff-add, ai-diff-remove, ai-diff-modified) are additional classes. Important: when a proposal is active, BFS highlighting should be suppressed or cleared to avoid class conflicts. Clear highlightedNodeId when proposalStatus becomes pending.

Graph converter (Story 3.2): graphToFlow converts GraphData to @xyflow nodes/edges. Used by proposeChanges to convert the proposed patch to flow format for rendering.

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 — used for diff comparison
  type: string;    // Required — diagram-type-specific
  label: string;   // Required — display text
  // ... additional type-specific fields
}

interface DiagramEdge {
  id: string;      // Required — used for diff comparison
  from: string;    // Required — source node ID
  to: string;      // Required — target node ID
  label?: string;
  type?: string;
  cardinality?: string;
}

Diff comparison is by node/edge ID:

  • Same ID, different properties → modified
  • New ID in proposed → added
  • ID only in current → removed

File Structure

Files to CREATE:

apps/web/src/modules/copilot/hooks/
  └── useProposalDiff.ts              # Diff computation hook

apps/web/src/modules/diagram/components/editor/
  └── ProposalBar.tsx                 # Floating accept/reject panel on canvas

Files to MODIFY:

apps/web/src/modules/diagram/stores/
  └── useGraphStore.ts                # Add proposal state + actions

apps/web/src/modules/copilot/hooks/
  └── useGraphMutation.ts             # Add proposeGraphPatch alongside applyGraphPatch

apps/web/src/modules/copilot/components/
  └── CopilotPanel.tsx                # Change auto-apply to propose, add accept/reject in AssistantBubble, keyboard shortcuts, suggestion styling

apps/web/src/modules/diagram/components/editor/
  └── DiagramCanvas.tsx               # Add ProposalBar, keyboard shortcuts

packages/ai/src/modules/copilot/
  ├── system-prompt.ts                # Add semantic analysis section + change summary instruction
  └── system-prompt.test.ts           # Tests for new prompt sections

apps/web/src/assets/styles/
  └── globals.css                     # Add ai-diff-add, ai-diff-remove, ai-diff-modified CSS classes

Files to REFERENCE (read-only):

apps/web/src/modules/diagram/lib/graph-converter.ts      # graphToFlow, flowToGraph
apps/web/src/modules/diagram/types/graph.ts              # GraphData, DiagramNode, DiagramEdge
packages/ai/src/modules/copilot/mutation-schema.ts       # graphPatchSchema, validation functions
packages/ai/src/modules/copilot/api.ts                   # streamCopilot (no changes needed)
packages/ai/src/modules/copilot/schema.ts                # CopilotMessagePayload (no changes)
packages/ai/src/modules/copilot/types.ts                 # DiagramType, SelectedElement (no changes)

Project Structure Notes

  • ProposalBar goes in apps/web/src/modules/diagram/components/editor/ — co-located with DiagramCanvas.tsx because it's a canvas UI element that uses @xyflow/react Panel and must be inside ReactFlowProvider
  • useProposalDiff goes in apps/web/src/modules/copilot/hooks/ — co-located with useGraphMutation.ts because it's part of the copilot mutation pipeline
  • CSS diff classes go in globals.css alongside existing badge chip tokens and canvas tokens
  • System prompt changes go in packages/ai/src/modules/copilot/system-prompt.ts — extend buildCopilotSystemPrompt

Anti-Patterns to AVOID

  • Do NOT create a separate "diff mode" or "review mode" on the canvas — the visual diff is CSS-only on the same ReactFlow instance. No mode switching, no separate rendering path.
  • Do NOT send partial graph data — the AI still outputs a COMPLETE GraphData via generateDiagram tool. The diff is computed client-side by comparing the full proposed graph against the full current graph.
  • Do NOT create a new API endpoint for accept/reject — accept uses the existing api.diagrams[":id"].$patch. Reject does nothing server-side.
  • Do NOT modify the generateDiagram tool or graphPatchSchema — the AI output format is unchanged. The proposal layer is purely client-side.
  • Do NOT create a separate "suggestion" message type in the schema — suggestions are regular AI text responses with specific phrasing patterns. Styling is applied at the rendering level.
  • Do NOT delay layout during proposal — layout ONLY runs on accept. During proposal, nodes keep their current positions with diff overlay styling.
  • 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 use uuid() column type — always text().primaryKey().$defaultFn(generateId)
  • Do NOT run ELK.js on main thread — use existing Web Worker

Performance Requirements

  • Visual diff appears: < 100ms after AI tool output is available (client-side diff computation only)
  • Accept animation: < 200ms fade-out of diff highlights, then ELK.js layout (< 500ms per NFR)
  • Reject revert: < 50ms (Zustand state restore, no layout needed)
  • Keyboard shortcut response: < 50ms (direct store action calls)
  • Diff overlay rendering: CSS-only, no JavaScript animation loop (use CSS animation + outline)
  • Respect prefers-reduced-motion — diff pulse animations become static outlines

Security Requirements

  • enforceAuth middleware on all endpoints (already in place, unchanged)
  • deductCredits middleware before AI generation calls (already in place, unchanged)
  • Proposal state is entirely client-side — no new server endpoints, no new attack surface
  • AI output still validated through validateGraphPatch in the generateDiagram tool execution before reaching the client

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 commands: pnpm --filter @turbostarter/ai test (system prompt tests), pnpm test (all)
  • Expected new tests: ~20-25 (system prompt semantic analysis + useProposalDiff diff computation + store proposal actions)

Previous Story Intelligence (Story 3.3)

What was built:

  • BadgeChip.tsx — badge component with animation, dismiss, canvas scroll
  • CopilotPanel.tsx — badge display, selectedElements in transport, scope indicator, Escape key to clear badges
  • system-prompt.ts — scoped context section for selected elements, buildSelectedContext function
  • Schema extensions: selectedElementSchema, selectedElements optional field

Key learnings:

  • useGraphStore.getState() pattern avoids stale closures and prevents memo invalidation — use this in all callbacks
  • AnimatePresence + motion for enter/exit animations — same pattern for ProposalBar
  • Cross-component communication via Zustand store (not React Flow hooks) — CopilotPanel is outside ReactFlowProvider
  • Icons.User2, Icons.Server, Icons.Package are valid icon exports (not Icons.User, Icons.Box, Icons.Layers)

Code review fixes from 3.3 (patterns to follow):

  • Added prefers-reduced-motion support via useReducedMotion() hook — do the same for diff animations
  • getState() pattern instead of closure — already adopted, continue using
  • .min(1) constraints on string fields in schemas — already adopted
  • MAX limits to prevent prompt bloat (MAX_NEIGHBOR_NODES=10) — already adopted

No new dependencies needed — Motion (animations), shadcn/ui (buttons), @xyflow/react (Panel) are already in workspace.

Git Intelligence

Recent commit pattern: feat: implement Story X.Y — <description>. Follow this convention.

Story 3.3 modified these files (which Story 3.4 will modify again):

  • CopilotPanel.tsx — add proposal controls, change auto-apply to propose
  • system-prompt.ts — add semantic analysis section
  • system-prompt.test.ts — add semantic analysis tests
  • useGraphStore.ts — add proposal state
  • useGraphMutation.ts — add proposeGraphPatch
  • DiagramCanvas.tsx — add ProposalBar, keyboard shortcuts
  • globals.css — add diff CSS classes

References

  • [Source: _bmad-output/planning-artifacts/epics.md#Story 3.4] — Acceptance criteria, technical notes (diff state in Zustand, visual diff via CSS, semantic analysis)
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#AIDiffOverlay] — Component spec: green highlights (additions), red fades (removals), modified before/after, Accept (Enter), Reject (Esc)
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Interaction States] — Proposing state: diff overlay on canvas, "Here's what I'd change" + explanation, green/red highlights, Accept/Reject
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Design Tokens] — --ai-diff-add: oklch(0.80 0.18 152 / 20%), --ai-diff-remove: oklch(0.58 0.25 27 / 20%), --ai-streaming, --ai-accent
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Core Interaction] — "Propose → preview → accept" pattern (Cursor's diff pattern adapted for canvas)
  • [Source: _bmad-output/planning-artifacts/architecture.md#Decision 3] — AI Mutation Pipeline: client-side relay with soft-lock, structured JSON patches, SSE streaming
  • [Source: _bmad-output/project-context.md] — Framework rules, coding standards, anti-patterns, testing standards
  • [Source: _bmad-output/implementation-artifacts/3-3-badge-based-element-referencing-for-targeted-modifications.md] — Previous story patterns, code review fixes, established conventions

Dev Agent Record

Agent Model Used

Claude Opus 4.6 (claude-opus-4-6)

Debug Log References

No debug issues encountered.

Completion Notes List

  • Task 1: Added proposedPatch, previousGraphSnapshot, proposalStatus to Zustand store with proposeChanges, acceptProposal, rejectProposal, clearProposal actions. Diff computed client-side by comparing node/edge IDs. BFS highlighting cleared on propose.
  • Task 2: Created useProposalDiff hook with pure diff computation functions (countNodeDiffs, countEdgeDiffs, buildSummary) exported for testing. Uses graphToFlow to convert proposed GraphData before comparing against snapshot.
  • Task 3: Extracted patchToGraphData and persistGraphData utilities from applyGraphPatch. Added proposeGraphPatch that converts patch → GraphData and calls store's proposeChanges. CopilotPanel now calls proposeGraphPatch instead of applyGraphPatch.
  • Task 4: Added CSS diff overlay classes (ai-diff-add with pulse animation, ai-diff-remove with dashed outline + opacity, ai-diff-modified with accent outline). Added --ai-diff-add and --ai-diff-remove design tokens. Edge diff classes included. prefers-reduced-motion support for pulse animation.
  • Task 5: AssistantBubble now receives proposalStatus, changeSummary, diagramId props. Shows Accept/Reject buttons when proposalStatus === 'pending', shows "Diagram updated" otherwise. Accept persists to DB, reject restores snapshot.
  • Task 6: Created ProposalBar.tsx using @xyflow/react Panel at bottom-center. Uses AnimatePresence + motion for enter/exit. Has role="alert" with descriptive aria-label. Shows keyboard hints (Enter/Esc). Added to CanvasInner inside ReactFlow.
  • Task 7: Keyboard priority in CopilotPanel: proposal pending + Enter + empty textarea → accept; proposal pending + Escape → reject (preserves badges per AC#3); normal Enter → send; normal Escape → clear badges. DiagramCanvas wrapper also handles Enter/Escape when proposal pending.
  • Task 8: Added SEMANTIC_RULES record with diagram-type-specific validation rules for all 6 types. Added "Semantic analysis" section to system prompt instructing AI to use "Note:" or "Consider:" prefix for suggestions, non-blocking.
  • Task 9: Added suggestion detection pattern (Note:|Consider:|Suggestion:|Tip:) and renderWithSuggestions function in AssistantBubble. Matching paragraphs wrapped with left-border accent, Lightbulb icon, muted background. Inline in markdown flow.
  • Task 10: Added "Change summary" section to system prompt instructing AI to include "Changes: ..." before tool calls. Client-side useProposalDiff independently computes authoritative summary.
  • Task 11: Added 28 new tests — 12 system prompt tests (semantic analysis for all 6 types + change summary), 17 useProposalDiff tests (node/edge diff + summary formatting), 16 store proposal action tests. All pass with zero regressions (470 total tests).

Change Log

  • 2026-02-28: Implemented Story 3.4 — AI semantic suggestions and accept/reject workflow. Added proposal layer between AI output and graph mutation, visual diff on canvas, accept/reject controls (inline + floating bar + keyboard), semantic analysis in system prompt, suggestion styling.
  • 2026-02-28: Code review fixes (9 issues). H1: Fixed stale useEffect dep (applyGraphPatch→proposeGraphPatch). H2: Added lastProposalOutcome to store, AssistantBubble now shows "Changes discarded" after reject. H3+H4: Guarded BFS highlighting and clearHighlight during proposals. M1: Extracted shared acceptCurrentProposal/rejectCurrentProposal utilities (deduplicated 4 locations). M2: Fixed proposalStatus type from string→ProposalStatus. L1+L2: Added edge modification detection and deeper node property comparison (columns, tag). Added 6 new tests, CSS for edge ai-diff-modified.

File List

Created:

  • apps/web/src/modules/copilot/hooks/useProposalDiff.ts — Diff computation hook
  • apps/web/src/modules/copilot/hooks/useProposalDiff.test.ts — 17 tests
  • apps/web/src/modules/diagram/components/editor/ProposalBar.tsx — Floating accept/reject panel

Modified:

  • apps/web/src/modules/diagram/stores/useGraphStore.ts — Added proposal state + 4 actions
  • apps/web/src/modules/diagram/stores/useGraphStore.test.ts — Added 16 proposal tests
  • apps/web/src/modules/copilot/hooks/useGraphMutation.ts — Extracted utilities, added proposeGraphPatch
  • apps/web/src/modules/copilot/components/CopilotPanel.tsx — Proposal controls, keyboard shortcuts, suggestion styling
  • apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx — ProposalBar, keyboard shortcuts, diagramId prop
  • apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx — Pass diagramId to DiagramCanvas
  • packages/ai/src/modules/copilot/system-prompt.ts — Semantic analysis + change summary sections
  • packages/ai/src/modules/copilot/system-prompt.test.ts — 12 new tests
  • apps/web/src/assets/styles/globals.css — AI diff overlay CSS classes + design tokens