Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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>
566 lines
35 KiB
Markdown
566 lines
35 KiB
Markdown
# Story 3.4: AI Semantic Suggestions and Accept/Reject Workflow
|
|
|
|
Status: done
|
|
|
|
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
|
|
|
|
## 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 (
|
|
<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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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
|
|
|
|
```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 — <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
|