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>
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
-
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. -
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.
-
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.
-
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).
-
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 | nulltoGraphStateinuseGraphStore.ts - 1.2 Add
previousGraphSnapshot: { nodes: Node[]; edges: Edge[] } | nulltoGraphState - 1.3 Add
proposalStatus: 'idle' | 'pending' | 'accepted' | 'rejected'toGraphState - 1.4 Add
proposeChanges(patch: GraphPatchData): voidaction — snapshots current nodes/edges, converts proposed patch to flow nodes/edges, merges with diff classes, sets status to 'pending' - 1.5 Add
acceptProposal(): voidaction — applies theproposedPatchvia the existing apply flow (setNodes, setEdges, requestLayout), clears diff classes, clears proposal state, sets status to 'accepted' then 'idle' - 1.6 Add
rejectProposal(): voidaction — restorespreviousGraphSnapshotto nodes/edges, clears proposal state, sets status to 'rejected' then 'idle' - 1.7 Add
clearProposal(): voidaction — resets proposal state without side effects - 1.8 Initialize all new fields in
reset()
- 1.1 Add
-
Task 2: Create
useProposalDiffhook for diff computation (AC: #1, #5)- 2.1 Create
apps/web/src/modules/copilot/hooks/useProposalDiff.ts - 2.2 Compute diff by comparing
previousGraphSnapshotnode/edge IDs vsproposedPatchnode/edge IDs:added(new IDs),removed(missing IDs),modified(same ID, different label/type/properties) - 2.3 Return
{ addedCount, removedCount, modifiedCount, changeSummary }—changeSummaryis a formatted string like "Adding 2 nodes, modifying 1 edge, removing 1 node" - 2.4 Memoize with
useMemokeyed onproposedPatchandpreviousGraphSnapshot
- 2.1 Create
-
Task 3: Modify mutation pipeline to propose instead of auto-apply (AC: #1, #2, #3)
- 3.1 In
useGraphMutation.ts, addproposeGraphPatch(patch: GraphPatchData): voidalongside existingapplyGraphPatch - 3.2
proposeGraphPatchcallsuseGraphStore.getState().proposeChanges(patch)— snapshots current graph, converts proposed patch to flow format, merges both sets of nodes showing diff styling - 3.3 In
CopilotPanel.tsxtool detection effect, change from callingapplyGraphPatch(result.data)to callingproposeGraphPatch(result.data)— the patch is now proposed, not auto-applied - 3.4 Export both
applyGraphPatchandproposeGraphPatchfrom hook
- 3.1 In
-
Task 4: Implement visual diff on canvas via node/edge className (AC: #1)
- 4.1 In the
proposeChangesstore action, compute diff between current and proposed nodes by ID: new nodes getclassName: "ai-diff-add", nodes in current but not in proposed getclassName: "ai-diff-remove", modified nodes getclassName: "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 getai-diff-remove
- 4.1 In the
-
Task 5: Add Accept/Reject controls inline in AssistantBubble (AC: #1, #2, #3, #5)
- 5.1 In
CopilotPanel.tsxAssistantBubble, when a tool result isoutput-availableANDproposalStatus === '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
useProposalDiffabove 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
- 5.1 In
-
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/reactPanelcomponent atposition="bottom-center"— renders inside ReactFlowProvider - 6.3 Subscribe to
proposalStatusfromuseGraphStore— only render whenproposalStatus === '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.tsxCanvasInner— renderProposalBarinside theReactFlowcomponent as a sibling toPanel(layout indicator) - 6.7 ARIA:
role="alert",aria-label="AI proposes: [summary]. Press Enter to accept, Escape to reject."
- 6.1 Create
-
Task 7: Add keyboard shortcuts for accept/reject (AC: #2, #3)
- 7.1 In
CopilotPanel.tsxhandleKeyDown, whenproposalStatus === '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, addonKeyDownhandler 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)
- 7.1 In
-
Task 8: Enhance system prompt with semantic analysis instructions (AC: #4)
- 8.1 In
system-prompt.ts, add a## Semantic analysissection 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."
- 8.1 In
-
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
useProposalDiffhook 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)
- 10.1 In
-
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
useProposalDifftests: diff computation — added nodes, removed nodes, modified nodes, mixed changes, empty graph, no changes (8+ tests) - 11.3 Store action tests:
proposeChangessets correct state,acceptProposalclears proposal and sets nodes,rejectProposalrestores 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
GraphDataviagenerateDiagramtool. The proposal layer intercepts the output, shows a visual diff, and only commits to the store on user acceptance. In Epic 4 (Liveblocks),acceptProposalwill 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
GraphDatalevel (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:
- If proposal pending + Enter + textarea empty → accept proposal
- If proposal pending + Escape → reject proposal (skip badge clearing)
- If Enter + textarea has text → send message (normal)
- 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"].$patchwith the acceptedgraphData(same as current auto-apply path inuseGraphMutation.ts). Move the persist logic fromuseGraphMutation.applyGraphPatchinto a sharedpersistGraphDatautility 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
ProposalBargoes inapps/web/src/modules/diagram/components/editor/— co-located withDiagramCanvas.tsxbecause it's a canvas UI element that uses@xyflow/reactPaneland must be insideReactFlowProvideruseProposalDiffgoes inapps/web/src/modules/copilot/hooks/— co-located withuseGraphMutation.tsbecause it's part of the copilot mutation pipeline- CSS diff classes go in
globals.cssalongside existing badge chip tokens and canvas tokens - System prompt changes go in
packages/ai/src/modules/copilot/system-prompt.ts— extendbuildCopilotSystemPrompt
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
generateDiagramtool. 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
generateDiagramtool orgraphPatchSchema— 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 — usevalidate()middleware - Do NOT put business logic in API routers — handlers call domain package functions
- Do NOT use
uuid()column type — alwaystext().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
enforceAuthmiddleware on all endpoints (already in place, unchanged)deductCreditsmiddleware 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
validateGraphPatchin thegenerateDiagramtool 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 scrollCopilotPanel.tsx— badge display, selectedElements in transport, scope indicator, Escape key to clear badgessystem-prompt.ts— scoped context section for selected elements,buildSelectedContextfunction- Schema extensions:
selectedElementSchema,selectedElementsoptional field
Key learnings:
useGraphStore.getState()pattern avoids stale closures and prevents memo invalidation — use this in all callbacksAnimatePresence+motionfor 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.Packageare valid icon exports (notIcons.User,Icons.Box,Icons.Layers)
Code review fixes from 3.3 (patterns to follow):
- Added
prefers-reduced-motionsupport viauseReducedMotion()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 proposesystem-prompt.ts— add semantic analysis sectionsystem-prompt.test.ts— add semantic analysis testsuseGraphStore.ts— add proposal stateuseGraphMutation.ts— add proposeGraphPatchDiagramCanvas.tsx— add ProposalBar, keyboard shortcutsglobals.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,proposalStatusto Zustand store withproposeChanges,acceptProposal,rejectProposal,clearProposalactions. Diff computed client-side by comparing node/edge IDs. BFS highlighting cleared on propose. - Task 2: Created
useProposalDiffhook with pure diff computation functions (countNodeDiffs,countEdgeDiffs,buildSummary) exported for testing. UsesgraphToFlowto convert proposed GraphData before comparing against snapshot. - Task 3: Extracted
patchToGraphDataandpersistGraphDatautilities fromapplyGraphPatch. AddedproposeGraphPatchthat converts patch → GraphData and calls store'sproposeChanges. CopilotPanel now callsproposeGraphPatchinstead ofapplyGraphPatch. - Task 4: Added CSS diff overlay classes (
ai-diff-addwith pulse animation,ai-diff-removewith dashed outline + opacity,ai-diff-modifiedwith accent outline). Added--ai-diff-addand--ai-diff-removedesign tokens. Edge diff classes included.prefers-reduced-motionsupport for pulse animation. - Task 5: AssistantBubble now receives
proposalStatus,changeSummary,diagramIdprops. Shows Accept/Reject buttons whenproposalStatus === 'pending', shows "Diagram updated" otherwise. Accept persists to DB, reject restores snapshot. - Task 6: Created
ProposalBar.tsxusing@xyflow/reactPanelatbottom-center. UsesAnimatePresence+motionfor enter/exit. Hasrole="alert"with descriptivearia-label. Shows keyboard hints (Enter/Esc). Added toCanvasInnerinside 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_RULESrecord 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:) andrenderWithSuggestionsfunction 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
useProposalDiffindependently 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 hookapps/web/src/modules/copilot/hooks/useProposalDiff.test.ts— 17 testsapps/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 actionsapps/web/src/modules/diagram/stores/useGraphStore.test.ts— Added 16 proposal testsapps/web/src/modules/copilot/hooks/useGraphMutation.ts— Extracted utilities, added proposeGraphPatchapps/web/src/modules/copilot/components/CopilotPanel.tsx— Proposal controls, keyboard shortcuts, suggestion stylingapps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx— ProposalBar, keyboard shortcuts, diagramId propapps/web/src/modules/diagram/components/editor/DiagramEditor.tsx— Pass diagramId to DiagramCanvaspackages/ai/src/modules/copilot/system-prompt.ts— Semantic analysis + change summary sectionspackages/ai/src/modules/copilot/system-prompt.test.ts— 12 new testsapps/web/src/assets/styles/globals.css— AI diff overlay CSS classes + design tokens