feat: implement Stories 3.4, 3.5, 3.6 — AI proposals, wizard, hover & palette
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>
This commit is contained in:
Alejandro Gutiérrez
2026-03-01 08:55:06 +00:00
parent 6591d6385a
commit c4379afe1f
32 changed files with 5828 additions and 81 deletions

View File

@@ -18,6 +18,7 @@ export const modelStrategies = customProvider({
[Model.GEMINI_2_5_PRO]: cached(google("gemini-2.5-pro")),
[Model.GEMINI_2_5_FLASH]: cached(google("gemini-2.5-flash")),
[Model.CLAUDE_4_SONNET]: cached(anthropic("claude-sonnet-4-5")),
[Model.CLAUDE_HAIKU_4_5]: cached(anthropic("claude-haiku-4-5-latest")),
[Model.CLAUDE_3_7_SONNET]: cached(anthropic("claude-3-7-sonnet-latest")),
[Model.GROK_4]: cached(xai("grok-4")),
[Model.GROK_3]: cached(xai("grok-3-mini-fast")),

View File

@@ -29,6 +29,7 @@ export const Model = {
GEMINI_2_5_PRO: "gemini-2-5-pro",
GEMINI_2_5_FLASH: "gemini-2-5-flash",
CLAUDE_4_SONNET: "claude-4-sonnet",
CLAUDE_HAIKU_4_5: "claude-haiku-4-5",
CLAUDE_3_7_SONNET: "claude-3-7-sonnet",
GROK_4: "grok-4",
GROK_3: "grok-3",

View File

@@ -237,6 +237,96 @@ describe("buildCopilotSystemPrompt", () => {
expect(prompt).toContain("Scoped context");
});
});
describe("semantic analysis section", () => {
it("should include semantic analysis section for all diagram types", () => {
const types: DiagramType[] = [
"bpmn",
"er",
"orgchart",
"architecture",
"sequence",
"flowchart",
];
for (const type of types) {
const prompt = buildCopilotSystemPrompt(type);
expect(prompt).toContain("## Semantic analysis");
expect(prompt).toContain("Note:");
expect(prompt).toContain("Consider:");
}
});
it("should include BPMN-specific semantic rules", () => {
const prompt = buildCopilotSystemPrompt("bpmn");
expect(prompt).toContain("error boundaries");
expect(prompt).toContain("gateways without corresponding merge");
expect(prompt).toContain("inter-pool message flows");
expect(prompt).toContain("missing end events");
});
it("should include E-R-specific semantic rules", () => {
const prompt = buildCopilotSystemPrompt("er");
expect(prompt).toContain("M:N relationships");
expect(prompt).toContain("junction/associative table");
expect(prompt).toContain("without primary keys");
expect(prompt).toContain("circular foreign key");
});
it("should include architecture-specific semantic rules", () => {
const prompt = buildCopilotSystemPrompt("architecture");
expect(prompt).toContain("single points of failure");
expect(prompt).toContain("missing load balancers");
});
it("should include flowchart-specific semantic rules", () => {
const prompt = buildCopilotSystemPrompt("flowchart");
expect(prompt).toContain("unreachable nodes");
expect(prompt).toContain("decisions with single outgoing path");
expect(prompt).toContain("missing terminal nodes");
});
it("should include orgchart-specific semantic rules", () => {
const prompt = buildCopilotSystemPrompt("orgchart");
expect(prompt).toContain("employees without managers");
expect(prompt).toContain("span of control");
});
it("should include sequence-specific semantic rules", () => {
const prompt = buildCopilotSystemPrompt("sequence");
expect(prompt).toContain("messages without return");
expect(prompt).toContain("participants with no interactions");
});
it("should instruct non-blocking behavior for semantic issues", () => {
const prompt = buildCopilotSystemPrompt("bpmn");
expect(prompt).toContain("Do not block diagram generation");
});
});
describe("change summary instruction", () => {
it("should include change summary section", () => {
const prompt = buildCopilotSystemPrompt("bpmn");
expect(prompt).toContain("## Change summary");
expect(prompt).toContain("**Changes:**");
});
it("should include change summary for all diagram types", () => {
const types: DiagramType[] = [
"bpmn",
"er",
"orgchart",
"architecture",
"sequence",
"flowchart",
];
for (const type of types) {
const prompt = buildCopilotSystemPrompt(type);
expect(prompt).toContain("Change summary");
}
});
});
});
interface ContextResult {

View File

@@ -1,5 +1,26 @@
import type { DiagramType, SelectedElement } from "./types";
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`,
orgchart: `- Check for employees without managers (except root)
- Check for excessive span of control (>10 direct reports)`,
architecture: `- Check for single points of failure
- Check for services without database connections when data persistence is expected
- Check for missing load balancers in multi-instance deployments`,
sequence: `- Check for messages without return responses
- Check for participants with no interactions`,
flowchart: `- Check for unreachable nodes
- Check for decisions with single outgoing path
- Check for missing terminal nodes`,
};
const DIAGRAM_DESCRIPTIONS: Record<DiagramType, string> = {
bpmn: "BPMN (Business Process Model and Notation) — processes with activities, gateways, events, pools, and lanes",
er: "Entity-Relationship — database schemas with entities, attributes, and relationships (1:1, 1:N, M:N)",
@@ -50,7 +71,7 @@ const EDGE_TYPE_REFERENCE: Record<DiagramType, string> = {
flowchart: `- (default): Flow. Use label for conditions ("Yes", "No")`,
};
const TYPE_INFERENCE_RULES = `If the diagram type is not established, infer from the user's description:
export const TYPE_INFERENCE_RULES = `If the diagram type is not established, infer from the user's description:
- Business processes, workflows, approvals, order handling → bpmn
- Database schemas, tables, entities, data models → er
- Team structures, org hierarchies, reporting lines → orgchart
@@ -211,5 +232,14 @@ ${graphContext ? `The diagram currently contains:\n\`\`\`json\n${graphContext}\n
- Generate IDs as "n1", "n2", ... for nodes and "e1", "e2", ... for edges
- Keep text responses concise and diagram-focused
- Use markdown formatting (bold, lists, code blocks) — no h1 headings
- Today's date is ${date}${buildScopedContextSection(selectedElements, selectedContext)}`;
- Today's date is ${date}
## Change summary
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.
## Semantic analysis
After generating or modifying a diagram, briefly note any semantic issues you detect:
${SEMANTIC_RULES[diagramType]}
Present as helpful inline suggestions using "Note:" or "Consider:" prefix.
Do not block diagram generation for semantic issues.${buildScopedContextSection(selectedElements, selectedContext)}`;
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
// Mock the AI SDK generateObject before importing the module
vi.mock("ai", () => ({
generateObject: vi.fn(),
}));
// Mock model strategies
vi.mock("../chat/strategies", () => ({
modelStrategies: {
languageModel: vi.fn(() => "mock-model"),
},
}));
import { generateObject } from "ai";
import { inferDiagramType } from "./type-inference";
const mockGenerateObject = vi.mocked(generateObject);
describe("inferDiagramType", () => {
it("should return the inferred type and confidence for ER description", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "er", confidence: 0.95 },
} as never);
const result = await inferDiagramType("database schema for user management");
expect(result.type).toBe("er");
expect(result.confidence).toBe(0.95);
});
it("should return the inferred type for BPMN description", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "bpmn", confidence: 0.88 },
} as never);
const result = await inferDiagramType("order approval workflow with manager review");
expect(result.type).toBe("bpmn");
expect(result.confidence).toBeGreaterThan(0);
});
it("should return the inferred type for architecture description", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "architecture", confidence: 0.92 },
} as never);
const result = await inferDiagramType("microservices system design with API gateway");
expect(result.type).toBe("architecture");
});
it("should return the inferred type for orgchart description", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "orgchart", confidence: 0.9 },
} as never);
const result = await inferDiagramType("company team structure with reporting lines");
expect(result.type).toBe("orgchart");
});
it("should return the inferred type for sequence description", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "sequence", confidence: 0.85 },
} as never);
const result = await inferDiagramType("API call flow between browser and server");
expect(result.type).toBe("sequence");
});
it("should return the inferred type for flowchart description", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "flowchart", confidence: 0.87 },
} as never);
const result = await inferDiagramType("decision tree for loan approval");
expect(result.type).toBe("flowchart");
});
it("should pass the description in the prompt", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "er", confidence: 0.9 },
} as never);
const description = "user registration database tables";
await inferDiagramType(description);
expect(mockGenerateObject).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining(description) as string,
}),
);
});
it("should include type inference rules in the prompt", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "bpmn", confidence: 0.9 },
} as never);
await inferDiagramType("some description");
expect(mockGenerateObject).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining("Business processes") as string,
}),
);
});
it("should use a schema that covers all 6 diagram types", async () => {
mockGenerateObject.mockResolvedValueOnce({
object: { type: "er", confidence: 0.9 },
} as never);
await inferDiagramType("test");
const call = mockGenerateObject.mock.calls[0]![0] as { schema: { shape: { type: { options: string[] } } } };
const typeOptions = call.schema.shape.type.options as string[];
expect(typeOptions).toEqual(
expect.arrayContaining(["bpmn", "er", "orgchart", "architecture", "sequence", "flowchart"]),
);
expect(typeOptions).toHaveLength(6);
});
it("should propagate errors from generateObject", async () => {
mockGenerateObject.mockRejectedValueOnce(new Error("API error"));
await expect(inferDiagramType("test description")).rejects.toThrow("API error");
});
});

View File

@@ -0,0 +1,40 @@
import { generateObject } from "ai";
import { z } from "zod";
import { modelStrategies } from "../chat/strategies";
import { Model } from "../chat/types";
import type { DiagramType } from "./types";
import { TYPE_INFERENCE_RULES } from "./system-prompt";
const typeInferenceSchema = z.object({
type: z.enum([
"bpmn",
"er",
"orgchart",
"architecture",
"sequence",
"flowchart",
]),
confidence: z.number().min(0).max(1),
});
export type TypeInferenceResult = z.infer<typeof typeInferenceSchema>;
const TYPE_INFERENCE_PROMPT = `Classify this description into a diagram type.
${TYPE_INFERENCE_RULES}
Return the most likely diagram type and your confidence (0-1).`;
export async function inferDiagramType(
description: string,
): Promise<TypeInferenceResult> {
const result = await generateObject({
model: modelStrategies.languageModel(Model.CLAUDE_HAIKU_4_5),
schema: typeInferenceSchema,
prompt: `${TYPE_INFERENCE_PROMPT}\n\nDescription: "${description}"`,
});
return result.object;
}

View File

@@ -3,6 +3,7 @@ import * as z from "zod";
import { getCopilotHistory, streamCopilot } from "@turbostarter/ai/copilot/api";
import { copilotMessageSchema } from "@turbostarter/ai/copilot/schema";
import { inferDiagramType } from "@turbostarter/ai/copilot/type-inference";
import { enforceAuth, deductCredits, rateLimiter, validate } from "../../../middleware";
@@ -12,6 +13,10 @@ const chatIdQuerySchema = z.object({
chatId: z.string(),
});
const inferTypeSchema = z.object({
description: z.string().min(3).max(500),
});
export const copilotRouter = new Hono<{
Variables: {
user: User;
@@ -27,6 +32,17 @@ export const copilotRouter = new Hono<{
return c.json(messages);
},
)
.post(
"/infer-type",
enforceAuth,
rateLimiter,
validate("json", inferTypeSchema),
async (c) => {
const { description } = c.req.valid("json");
const result = await inferDiagramType(description);
return c.json(result);
},
)
.post(
"/",
enforceAuth,

View File

@@ -24,12 +24,14 @@ export const createDiagramSchema = z.object({
"sequence",
"flowchart",
]),
description: z.string().max(500).optional(),
projectId: z.string().optional(),
});
export const updateDiagramBodySchema = z
.object({
title: z.string().min(1).max(255).optional(),
description: z.string().max(500).optional(),
projectId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
graphData: z
@@ -45,6 +47,7 @@ export const updateDiagramBodySchema = z
.refine(
(data) =>
data.title !== undefined ||
data.description !== undefined ||
data.projectId !== undefined ||
data.sortOrder !== undefined ||
data.graphData !== undefined,

View File

@@ -0,0 +1 @@
ALTER TABLE "diagram" ADD COLUMN "description" text;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1772245471347,
"tag": "0002_numerous_siren",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1772312586761,
"tag": "0003_motionless_peter_parker",
"breakpoints": true
}
]
}

View File

@@ -44,6 +44,7 @@ export const diagram = pgTable("diagram", {
title: text().notNull(),
type: diagramTypeEnum().notNull(),
graphData: jsonb().$type<object>().default({}),
description: text(),
userId: text()
.references(() => user.id, { onDelete: "cascade" })
.notNull(),