# 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
- [x] Task 1: Add proposal state to Zustand store (AC: #1, #2, #3)
- [x] 1.1 Add `proposedPatch: GraphPatchData | null` to `GraphState` in `useGraphStore.ts`
- [x] 1.2 Add `previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | null` to `GraphState`
- [x] 1.3 Add `proposalStatus: 'idle' | 'pending' | 'accepted' | 'rejected'` to `GraphState`
- [x] 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'
- [x] 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'
- [x] 1.6 Add `rejectProposal(): void` action — restores `previousGraphSnapshot` to nodes/edges, clears proposal state, sets status to 'rejected' then 'idle'
- [x] 1.7 Add `clearProposal(): void` action — resets proposal state without side effects
- [x] 1.8 Initialize all new fields in `reset()`
- [x] Task 2: Create `useProposalDiff` hook for diff computation (AC: #1, #5)
- [x] 2.1 Create `apps/web/src/modules/copilot/hooks/useProposalDiff.ts`
- [x] 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)
- [x] 2.3 Return `{ addedCount, removedCount, modifiedCount, changeSummary }` — `changeSummary` is a formatted string like "Adding 2 nodes, modifying 1 edge, removing 1 node"
- [x] 2.4 Memoize with `useMemo` keyed on `proposedPatch` and `previousGraphSnapshot`
- [x] Task 3: Modify mutation pipeline to propose instead of auto-apply (AC: #1, #2, #3)
- [x] 3.1 In `useGraphMutation.ts`, add `proposeGraphPatch(patch: GraphPatchData): void` alongside existing `applyGraphPatch`
- [x] 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
- [x] 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
- [x] 3.4 Export both `applyGraphPatch` and `proposeGraphPatch` from hook
- [x] Task 4: Implement visual diff on canvas via node/edge className (AC: #1)
- [x] 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"`
- [x] 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`
- [x] 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`)
- [x] 4.4 Add `@media (prefers-reduced-motion: reduce)` — diff classes use static overlays instead of animations
- [x] 4.5 Edges follow same pattern: new edges get `ai-diff-add`, removed edges get `ai-diff-remove`
- [x] Task 5: Add Accept/Reject controls inline in AssistantBubble (AC: #1, #2, #3, #5)
- [x] 5.1 In `CopilotPanel.tsx` `AssistantBubble`, when a tool result is `output-available` AND `proposalStatus === 'pending'`, render Accept/Reject buttons instead of "Diagram updated"
- [x] 5.2 Accept button: calls `useGraphStore.getState().acceptProposal()` then persists the accepted patch to DB via the existing API (`api.diagrams[":id"].$patch`)
- [x] 5.3 Reject button: calls `useGraphStore.getState().rejectProposal()`
- [x] 5.4 Show change summary from `useProposalDiff` above the buttons: "Adding 2 nodes, removing 1 node"
- [x] 5.5 After accept/reject, replace buttons with status text: "Diagram updated" or "Changes discarded"
- [x] 5.6 Style: Accept = primary variant, Reject = ghost variant. Icons: Check for accept, X for reject
- [x] Task 6: Add floating Accept/Reject bar on canvas (AC: #1, #2, #3)
- [x] 6.1 Create `apps/web/src/modules/diagram/components/editor/ProposalBar.tsx`
- [x] 6.2 Use `@xyflow/react` `Panel` component at `position="bottom-center"` — renders inside ReactFlowProvider
- [x] 6.3 Subscribe to `proposalStatus` from `useGraphStore` — only render when `proposalStatus === 'pending'`
- [x] 6.4 Display: change summary text + Accept button + Reject button
- [x] 6.5 Animate in/out with Motion `animate` + `AnimatePresence`
- [x] 6.6 Add to `DiagramCanvas.tsx` `CanvasInner` — render `ProposalBar` inside the `ReactFlow` component as a sibling to `Panel` (layout indicator)
- [x] 6.7 ARIA: `role="alert"`, `aria-label="AI proposes: [summary]. Press Enter to accept, Escape to reject."`
- [x] Task 7: Add keyboard shortcuts for accept/reject (AC: #2, #3)
- [x] 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)
- [x] 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
- [x] 7.3 Ensure Enter in textarea only triggers accept when textarea is empty (non-empty Enter = send new message)
- [x] Task 8: Enhance system prompt with semantic analysis instructions (AC: #4)
- [x] 8.1 In `system-prompt.ts`, add a `## Semantic analysis` section to the system prompt with diagram-type-specific validation rules
- [x] 8.2 BPMN rules: check for missing error boundaries, missing end events, gateways without merge, pools without message flows
- [x] 8.3 E-R rules: check for M:N relationships without junction tables, entities without primary keys, circular foreign key chains
- [x] 8.4 Architecture rules: check for single points of failure, services without database connections, missing load balancers
- [x] 8.5 Flowchart rules: check for unreachable nodes, decisions with single outgoing path, missing terminal nodes
- [x] 8.6 Orgchart rules: check for employees without managers (except root), excessive span of control (>10 direct reports)
- [x] 8.7 Sequence rules: check for messages without return, participants with no interactions
- [x] 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."
- [x] Task 9: Add suggestion message styling in CopilotPanel (AC: #4)
- [x] 9.1 Detect suggestion patterns in assistant message text: lines starting with "Note:" or "Consider:" or "Suggestion:"
- [x] 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`)
- [x] 9.3 Keep suggestions inline in the markdown flow — do NOT extract them into separate components. Just wrap matching paragraphs with the styled container
- [x] Task 10: Add change summary to AI response (AC: #5)
- [x] 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."
- [x] 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)
- [x] Task 11: Write tests (AC: all)
- [x] 11.1 System prompt tests: verify semantic analysis section is included for each diagram type, verify change summary instruction is present (6+ tests)
- [x] 11.2 `useProposalDiff` tests: diff computation — added nodes, removed nodes, modified nodes, mixed changes, empty graph, no changes (8+ tests)
- [x] 11.3 Store action tests: `proposeChanges` sets correct state, `acceptProposal` clears proposal and sets nodes, `rejectProposal` restores snapshot (6+ tests)
- [x] 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):**
```typescript
// 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:**
```typescript
// 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:**
```css
/* 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:**
```typescript
// 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 (
{proposalStatus === 'pending' && (
{changeSummary}
)}
);
}
```
**Accept/Reject in AssistantBubble:**
```typescript
// In CopilotPanel.tsx AssistantBubble — replace "Diagram updated" indicator
const proposalStatus = useGraphStore(s => s.proposalStatus);
{hasToolResult && proposalStatus === 'pending' && (
)}
```
**Semantic analysis system prompt addition:**
```typescript
// Add after the Constraints section in buildCopilotSystemPrompt:
const SEMANTIC_RULES: Record = {
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
```typescript
// 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`
```typescript
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 — `. 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