diff --git a/mcp-server/src/server.js b/mcp-server/src/server.js index d975269..505d699 100644 --- a/mcp-server/src/server.js +++ b/mcp-server/src/server.js @@ -11,6 +11,7 @@ import { connectionTools, handleConnectionTool } from "./tools/connection-tools. import { documentTools, handleDocumentTool } from "./tools/document-tools.js"; import { modelTools, handleModelTool } from "./tools/model-tools.js"; import { annotationTools, handleAnnotationTool } from "./tools/annotation-tools.js"; +import { commentTools, handleCommentTool } from "./tools/comment-tools.js"; import { generationTools, handleGenerationTool } from "./tools/generation-tools.js"; const FILE_TOOL_NAMES = new Set(fileTools.map((t) => t.name)); @@ -20,6 +21,7 @@ const CONNECTION_TOOL_NAMES = new Set(connectionTools.map((t) => t.name)); const DOCUMENT_TOOL_NAMES = new Set(documentTools.map((t) => t.name)); const MODEL_TOOL_NAMES = new Set(modelTools.map((t) => t.name)); const ANNOTATION_TOOL_NAMES = new Set(annotationTools.map((t) => t.name)); +const COMMENT_TOOL_NAMES = new Set(commentTools.map((t) => t.name)); const GENERATION_TOOL_NAMES = new Set(generationTools.map((t) => t.name)); // filePath is injected into every non-file tool so callers can establish @@ -51,6 +53,7 @@ const ALL_TOOLS = [ ...withFilePath(documentTools), ...withFilePath(modelTools), ...withFilePath(annotationTools), + ...withFilePath(commentTools), ...withFilePath(generationTools), ]; @@ -90,6 +93,8 @@ export function createServer(state, renderer) { result = handleModelTool(name, args, state); } else if (ANNOTATION_TOOL_NAMES.has(name)) { result = handleAnnotationTool(name, args, state); + } else if (COMMENT_TOOL_NAMES.has(name)) { + result = handleCommentTool(name, args, state); } else if (GENERATION_TOOL_NAMES.has(name)) { result = handleGenerationTool(name, args, state); } else { diff --git a/mcp-server/src/state.js b/mcp-server/src/state.js index 85553e0..9bba339 100644 --- a/mcp-server/src/state.js +++ b/mcp-server/src/state.js @@ -18,6 +18,7 @@ export class FlowState { this.dataModels = []; this.stickyNotes = []; this.screenGroups = []; + this.comments = []; this.metadata = { name: DEFAULT_FLOW_NAME, featureBrief: "", @@ -41,6 +42,7 @@ export class FlowState { this.dataModels = data.dataModels || []; this.stickyNotes = data.stickyNotes || []; this.screenGroups = data.screenGroups || []; + this.comments = data.comments || []; this.metadata = data.metadata || {}; this.viewport = data.viewport || { pan: { x: 0, y: 0 }, zoom: 1 }; this.filePath = filePath; @@ -65,6 +67,7 @@ export class FlowState { this.dataModels, this.stickyNotes, this.screenGroups, + this.comments, ); const dir = path.dirname(target); @@ -92,6 +95,7 @@ export class FlowState { this.dataModels = []; this.stickyNotes = []; this.screenGroups = []; + this.comments = []; this.metadata = { name: options.name || DEFAULT_FLOW_NAME, featureBrief: options.featureBrief || "", @@ -117,6 +121,8 @@ export class FlowState { dataModelCount: this.dataModels.length, stickyNoteCount: this.stickyNotes.length, screenGroupCount: this.screenGroups.length, + commentCount: this.comments.length, + unresolvedCommentCount: this.comments.filter((c) => !c.resolved).length, screens: this.screens.map((s) => ({ id: s.id, name: s.name, @@ -223,6 +229,8 @@ export class FlowState { group.screenIds = group.screenIds.filter((id) => id !== screenId); } + this.comments = this.comments.filter((c) => c.screenId !== screenId); + this._autoSave(); return { removedConnectionCount: removed.length }; } @@ -347,6 +355,9 @@ export class FlowState { // Remove associated connections this.connections = this.connections.filter((c) => c.hotspotId !== hotspotId); + this.comments = this.comments.filter( + (c) => !(c.targetType === "hotspot" && c.targetId === hotspotId) + ); this._autoSave(); } @@ -408,6 +419,9 @@ export class FlowState { const idx = this.connections.findIndex((c) => c.id === connectionId); if (idx === -1) throw new Error(`Connection not found: ${connectionId}`); this.connections.splice(idx, 1); + this.comments = this.comments.filter( + (c) => !(c.targetType === "connection" && c.targetId === connectionId) + ); this._autoSave(); } @@ -537,6 +551,78 @@ export class FlowState { this._autoSave(); } + // ── Comment Operations ────────────────────── + + addComment(options) { + const now = new Date().toISOString(); + const comment = { + id: generateId(), + text: (options.text || "").trim(), + authorName: options.authorName || "MCP Agent", + authorPeerId: null, + authorColor: options.authorColor || "#61afef", + targetType: options.targetType || "screen", + targetId: options.targetId || null, + screenId: options.screenId || options.targetId || null, + anchor: options.anchor || { xPct: 50, yPct: 50 }, + resolved: false, + resolvedAt: null, + resolvedBy: null, + createdAt: now, + updatedAt: now, + }; + this.comments.push(comment); + this._autoSave(); + return comment; + } + + updateComment(commentId, text) { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) throw new Error(`Comment not found: ${commentId}`); + comment.text = text.trim(); + comment.updatedAt = new Date().toISOString(); + this._autoSave(); + return comment; + } + + resolveComment(commentId, resolvedBy = "MCP Agent") { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) throw new Error(`Comment not found: ${commentId}`); + const now = new Date().toISOString(); + comment.resolved = true; + comment.resolvedAt = now; + comment.resolvedBy = resolvedBy; + comment.updatedAt = now; + this._autoSave(); + return comment; + } + + unresolveComment(commentId) { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) throw new Error(`Comment not found: ${commentId}`); + comment.resolved = false; + comment.resolvedAt = null; + comment.resolvedBy = null; + comment.updatedAt = new Date().toISOString(); + this._autoSave(); + return comment; + } + + deleteComment(commentId) { + const idx = this.comments.findIndex((c) => c.id === commentId); + if (idx === -1) throw new Error(`Comment not found: ${commentId}`); + this.comments.splice(idx, 1); + this._autoSave(); + } + + listComments(options = {}) { + let result = [...this.comments]; + if (options.targetId) result = result.filter((c) => c.targetId === options.targetId); + if (options.targetType) result = result.filter((c) => c.targetType === options.targetType); + if (options.resolved !== undefined) result = result.filter((c) => c.resolved === options.resolved); + return result; + } + // ── Metadata Operations ───────────────────── updateMetadata(updates) { diff --git a/mcp-server/src/state.test.js b/mcp-server/src/state.test.js new file mode 100644 index 0000000..e913645 --- /dev/null +++ b/mcp-server/src/state.test.js @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { FlowState } from "./state.js"; + +// Prevent filesystem writes in all tests by mocking _autoSave on each instance. +// The mutation methods (addComment, updateComment, etc.) all call _autoSave() as +// their final step; mocking it lets us test the state logic without touching the fs. +const makeState = () => { + const state = new FlowState(); + state._autoSave = vi.fn(); + return state; +}; + +// ── Constructor ─────────────────────────────────────────────────────────────── + +describe("FlowState constructor", () => { + it("initializes comments as an empty array", () => { + const state = makeState(); + expect(state.comments).toEqual([]); + }); +}); + +// ── addComment ──────────────────────────────────────────────────────────────── + +describe("FlowState.addComment", () => { + it("adds a comment and returns the created object", () => { + const state = makeState(); + const comment = state.addComment({ text: "hello", targetType: "screen", targetId: "s1" }); + expect(state.comments).toHaveLength(1); + expect(comment).toBe(state.comments[0]); + }); + + it("trims whitespace from text", () => { + const state = makeState(); + const comment = state.addComment({ text: " hello ", targetType: "screen", targetId: "s1" }); + expect(comment.text).toBe("hello"); + }); + + it("defaults authorName to 'MCP Agent'", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + expect(comment.authorName).toBe("MCP Agent"); + }); + + it("uses provided authorName when given", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1", authorName: "Alice" }); + expect(comment.authorName).toBe("Alice"); + }); + + it("defaults screenId to targetId for screen-targeted comments", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + expect(comment.screenId).toBe("s1"); + }); + + it("uses provided screenId for hotspot-targeted comments", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "hotspot", targetId: "h1", screenId: "s1" }); + expect(comment.screenId).toBe("s1"); + }); + + it("sets resolved: false initially", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + expect(comment.resolved).toBe(false); + expect(comment.resolvedAt).toBeNull(); + expect(comment.resolvedBy).toBeNull(); + }); + + it("calls _autoSave", () => { + const state = makeState(); + state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + expect(state._autoSave).toHaveBeenCalled(); + }); +}); + +// ── updateComment ───────────────────────────────────────────────────────────── + +describe("FlowState.updateComment", () => { + it("updates text and updatedAt for the matching comment", () => { + const state = makeState(); + const comment = state.addComment({ text: "original", targetType: "screen", targetId: "s1" }); + const before = comment.updatedAt; + const updated = state.updateComment(comment.id, " new text "); + expect(updated.text).toBe("new text"); + expect(updated.updatedAt).toBeDefined(); + // updatedAt may be the same millisecond; just verify the field was set + expect(typeof updated.updatedAt).toBe("string"); + expect(updated).toBe(comment); // same object mutated + }); + + it("throws when commentId is not found", () => { + const state = makeState(); + expect(() => state.updateComment("nonexistent", "text")).toThrow("Comment not found"); + }); +}); + +// ── resolveComment ──────────────────────────────────────────────────────────── + +describe("FlowState.resolveComment", () => { + it("sets resolved: true, resolvedAt, and resolvedBy", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + state.resolveComment(comment.id, "Bob"); + expect(comment.resolved).toBe(true); + expect(comment.resolvedAt).not.toBeNull(); + expect(comment.resolvedBy).toBe("Bob"); + }); + + it("defaults resolvedBy to 'MCP Agent'", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + state.resolveComment(comment.id); + expect(comment.resolvedBy).toBe("MCP Agent"); + }); + + it("throws when commentId is not found", () => { + const state = makeState(); + expect(() => state.resolveComment("nonexistent")).toThrow("Comment not found"); + }); +}); + +// ── unresolveComment ────────────────────────────────────────────────────────── + +describe("FlowState.unresolveComment", () => { + it("clears resolved, resolvedAt, and resolvedBy", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + state.resolveComment(comment.id, "Bob"); + state.unresolveComment(comment.id); + expect(comment.resolved).toBe(false); + expect(comment.resolvedAt).toBeNull(); + expect(comment.resolvedBy).toBeNull(); + }); + + it("throws when commentId is not found", () => { + const state = makeState(); + expect(() => state.unresolveComment("nonexistent")).toThrow("Comment not found"); + }); +}); + +// ── deleteComment ───────────────────────────────────────────────────────────── + +describe("FlowState.deleteComment", () => { + it("removes the comment with the matching id", () => { + const state = makeState(); + const comment = state.addComment({ text: "hi", targetType: "screen", targetId: "s1" }); + state.deleteComment(comment.id); + expect(state.comments).toHaveLength(0); + }); + + it("throws when commentId is not found", () => { + const state = makeState(); + expect(() => state.deleteComment("nonexistent")).toThrow("Comment not found"); + }); +}); + +// ── listComments ────────────────────────────────────────────────────────────── + +describe("FlowState.listComments", () => { + let state; + + beforeEach(() => { + state = makeState(); + // Seed a variety of comments directly to avoid _autoSave complexity + state.comments = [ + { id: "c1", targetId: "s1", targetType: "screen", resolved: false }, + { id: "c2", targetId: "s1", targetType: "screen", resolved: true }, + { id: "c3", targetId: "h1", targetType: "hotspot", resolved: false }, + { id: "c4", targetId: "conn1", targetType: "connection", resolved: true }, + ]; + }); + + it("returns all comments when no filters are provided", () => { + expect(state.listComments()).toHaveLength(4); + }); + + it("filters by targetId", () => { + const result = state.listComments({ targetId: "s1" }); + expect(result).toHaveLength(2); + expect(result.every((c) => c.targetId === "s1")).toBe(true); + }); + + it("filters by targetType", () => { + const result = state.listComments({ targetType: "hotspot" }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("c3"); + }); + + it("filters by resolved: true", () => { + const result = state.listComments({ resolved: true }); + expect(result).toHaveLength(2); + expect(result.every((c) => c.resolved === true)).toBe(true); + }); + + it("filters by resolved: false", () => { + const result = state.listComments({ resolved: false }); + expect(result).toHaveLength(2); + expect(result.every((c) => c.resolved === false)).toBe(true); + }); + + it("combines multiple filters", () => { + const result = state.listComments({ targetId: "s1", resolved: false }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("c1"); + }); + + it("returns empty array when no comments match", () => { + const result = state.listComments({ targetId: "nonexistent" }); + expect(result).toEqual([]); + }); +}); + +// ── getSummary comment counts ───────────────────────────────────────────────── + +describe("FlowState.getSummary comment counts", () => { + it("includes commentCount reflecting total comments", () => { + const state = makeState(); + state.comments = [ + { id: "c1", resolved: false }, + { id: "c2", resolved: true }, + { id: "c3", resolved: false }, + ]; + const summary = state.getSummary(); + expect(summary.commentCount).toBe(3); + }); + + it("unresolvedCommentCount excludes resolved comments", () => { + const state = makeState(); + state.comments = [ + { id: "c1", resolved: false }, + { id: "c2", resolved: true }, + { id: "c3", resolved: false }, + ]; + const summary = state.getSummary(); + expect(summary.unresolvedCommentCount).toBe(2); + }); +}); + +// ── createNew ───────────────────────────────────────────────────────────────── + +describe("FlowState.createNew", () => { + it("resets comments to empty array", () => { + const state = makeState(); + state.comments = [{ id: "c1", resolved: false }]; + // Pass null filePath to skip the file write + state.createNew(null); + expect(state.comments).toEqual([]); + }); +}); diff --git a/mcp-server/src/tools/comment-tools.js b/mcp-server/src/tools/comment-tools.js new file mode 100644 index 0000000..80c6bc1 --- /dev/null +++ b/mcp-server/src/tools/comment-tools.js @@ -0,0 +1,112 @@ +export const commentTools = [ + { + name: "list_comments", + description: "List comments in the flow. Can filter by target, type, or resolved status.", + inputSchema: { + type: "object", + properties: { + targetId: { type: "string", description: "Filter by screenId, hotspotId, or connectionId" }, + targetType: { type: "string", enum: ["screen", "hotspot", "connection"], description: "Filter by target type" }, + resolved: { type: "boolean", description: "Filter by resolved status (omit for all)" }, + }, + }, + }, + { + name: "create_comment", + description: "Add a comment anchored to a screen, hotspot, or connection.", + inputSchema: { + type: "object", + properties: { + text: { type: "string", description: "Comment text" }, + targetType: { type: "string", enum: ["screen", "hotspot", "connection"], description: "What the comment is anchored to" }, + targetId: { type: "string", description: "ID of the target (screenId, hotspotId, or connectionId)" }, + screenId: { type: "string", description: "Parent screenId (required for hotspot targets; same as targetId for screen targets)" }, + authorName: { type: "string", description: "Display name for the comment author (default: 'MCP Agent')" }, + anchorXPct: { type: "number", description: "Horizontal anchor position as % of target width (0–100, default 50)" }, + anchorYPct: { type: "number", description: "Vertical anchor position as % of target height (0–100, default 50)" }, + }, + required: ["text", "targetType", "targetId"], + }, + }, + { + name: "update_comment", + description: "Edit the text of an existing comment.", + inputSchema: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to update" }, + text: { type: "string", description: "New comment text" }, + }, + required: ["commentId", "text"], + }, + }, + { + name: "resolve_comment", + description: "Mark a comment as resolved.", + inputSchema: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to resolve" }, + resolvedBy: { type: "string", description: "Name of the resolver (default: 'MCP Agent')" }, + }, + required: ["commentId"], + }, + }, + { + name: "delete_comment", + description: "Permanently delete a comment.", + inputSchema: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to delete" }, + }, + required: ["commentId"], + }, + }, +]; + +export function handleCommentTool(name, args, state) { + switch (name) { + case "list_comments": { + const comments = state.listComments({ + targetId: args.targetId, + targetType: args.targetType, + resolved: args.resolved, + }); + return { comments, count: comments.length }; + } + + case "create_comment": { + const comment = state.addComment({ + text: args.text, + targetType: args.targetType, + targetId: args.targetId, + screenId: args.screenId || (args.targetType === "screen" ? args.targetId : undefined), + authorName: args.authorName || "MCP Agent", + anchor: { + xPct: args.anchorXPct ?? 50, + yPct: args.anchorYPct ?? 50, + }, + }); + return { commentId: comment.id, authorName: comment.authorName, targetType: comment.targetType }; + } + + case "update_comment": { + const comment = state.updateComment(args.commentId, args.text); + return { success: true, commentId: comment?.id ?? args.commentId }; + } + + case "resolve_comment": { + const comment = state.resolveComment(args.commentId, args.resolvedBy || "MCP Agent"); + return { success: true, commentId: comment?.id ?? args.commentId, resolvedBy: comment?.resolvedBy ?? args.resolvedBy ?? "MCP Agent" }; + } + + case "delete_comment": { + state.deleteComment(args.commentId); + return { success: true }; + } + + default: + throw new Error(`Unknown comment tool: ${name}`); + } +} diff --git a/mcp-server/src/tools/comment-tools.test.js b/mcp-server/src/tools/comment-tools.test.js new file mode 100644 index 0000000..3aedd1c --- /dev/null +++ b/mcp-server/src/tools/comment-tools.test.js @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleCommentTool } from "./comment-tools.js"; + +const makeMockState = () => ({ + listComments: vi.fn(() => [{ id: "c1" }, { id: "c2" }]), + addComment: vi.fn((opts) => ({ + id: "c-new", + authorName: opts.authorName || "MCP Agent", + targetType: opts.targetType, + screenId: opts.screenId, + anchor: opts.anchor, + })), + updateComment: vi.fn((id) => ({ id })), + resolveComment: vi.fn((id, resolvedBy) => ({ id, resolvedBy })), + deleteComment: vi.fn(), +}); + +// ── list_comments ───────────────────────────────────────────────────────────── + +describe("handleCommentTool — list_comments", () => { + it("passes filter args to state.listComments", () => { + const state = makeMockState(); + handleCommentTool("list_comments", { targetId: "s1", targetType: "screen", resolved: false }, state); + expect(state.listComments).toHaveBeenCalledWith({ + targetId: "s1", + targetType: "screen", + resolved: false, + }); + }); + + it("returns { comments, count } with count matching array length", () => { + const state = makeMockState(); + const result = handleCommentTool("list_comments", {}, state); + expect(result.comments).toHaveLength(2); + expect(result.count).toBe(2); + }); +}); + +// ── create_comment ──────────────────────────────────────────────────────────── + +describe("handleCommentTool — create_comment", () => { + it("maps anchorXPct/anchorYPct to an anchor object", () => { + const state = makeMockState(); + handleCommentTool("create_comment", { + text: "hi", + targetType: "screen", + targetId: "s1", + anchorXPct: 30, + anchorYPct: 70, + }, state); + expect(state.addComment).toHaveBeenCalledWith( + expect.objectContaining({ anchor: { xPct: 30, yPct: 70 } }) + ); + }); + + it("defaults anchor to { xPct: 50, yPct: 50 } when not provided", () => { + const state = makeMockState(); + handleCommentTool("create_comment", { + text: "hi", + targetType: "screen", + targetId: "s1", + }, state); + expect(state.addComment).toHaveBeenCalledWith( + expect.objectContaining({ anchor: { xPct: 50, yPct: 50 } }) + ); + }); + + it("defaults screenId to targetId for screen-targeted comments", () => { + const state = makeMockState(); + handleCommentTool("create_comment", { + text: "hi", + targetType: "screen", + targetId: "s1", + }, state); + expect(state.addComment).toHaveBeenCalledWith( + expect.objectContaining({ screenId: "s1" }) + ); + }); + + it("does not override screenId for hotspot-targeted comments", () => { + const state = makeMockState(); + handleCommentTool("create_comment", { + text: "hi", + targetType: "hotspot", + targetId: "h1", + screenId: "s2", + }, state); + expect(state.addComment).toHaveBeenCalledWith( + expect.objectContaining({ screenId: "s2" }) + ); + }); + + it("defaults authorName to 'MCP Agent'", () => { + const state = makeMockState(); + handleCommentTool("create_comment", { + text: "hi", + targetType: "screen", + targetId: "s1", + }, state); + expect(state.addComment).toHaveBeenCalledWith( + expect.objectContaining({ authorName: "MCP Agent" }) + ); + }); + + it("returns { commentId, authorName, targetType }", () => { + const state = makeMockState(); + const result = handleCommentTool("create_comment", { + text: "hi", + targetType: "screen", + targetId: "s1", + }, state); + expect(result).toHaveProperty("commentId", "c-new"); + expect(result).toHaveProperty("authorName", "MCP Agent"); + expect(result).toHaveProperty("targetType", "screen"); + }); +}); + +// ── update_comment ──────────────────────────────────────────────────────────── + +describe("handleCommentTool — update_comment", () => { + it("calls state.updateComment with commentId and text", () => { + const state = makeMockState(); + handleCommentTool("update_comment", { commentId: "c1", text: "new text" }, state); + expect(state.updateComment).toHaveBeenCalledWith("c1", "new text"); + }); + + it("returns { success: true, commentId }", () => { + const state = makeMockState(); + const result = handleCommentTool("update_comment", { commentId: "c1", text: "new" }, state); + expect(result).toEqual({ success: true, commentId: "c1" }); + }); +}); + +// ── resolve_comment ─────────────────────────────────────────────────────────── + +describe("handleCommentTool — resolve_comment", () => { + it("defaults resolvedBy to 'MCP Agent' when not provided", () => { + const state = makeMockState(); + handleCommentTool("resolve_comment", { commentId: "c1" }, state); + expect(state.resolveComment).toHaveBeenCalledWith("c1", "MCP Agent"); + }); + + it("passes custom resolvedBy through to state.resolveComment", () => { + const state = makeMockState(); + handleCommentTool("resolve_comment", { commentId: "c1", resolvedBy: "Alice" }, state); + expect(state.resolveComment).toHaveBeenCalledWith("c1", "Alice"); + }); +}); + +// ── delete_comment ──────────────────────────────────────────────────────────── + +describe("handleCommentTool — delete_comment", () => { + it("calls state.deleteComment with commentId", () => { + const state = makeMockState(); + handleCommentTool("delete_comment", { commentId: "c1" }, state); + expect(state.deleteComment).toHaveBeenCalledWith("c1"); + }); + + it("returns { success: true }", () => { + const state = makeMockState(); + const result = handleCommentTool("delete_comment", { commentId: "c1" }, state); + expect(result).toEqual({ success: true }); + }); +}); + +// ── Unknown tool ────────────────────────────────────────────────────────────── + +describe("handleCommentTool — unknown tool", () => { + it("throws for an unrecognized tool name", () => { + const state = makeMockState(); + expect(() => handleCommentTool("bad_tool", {}, state)).toThrow("Unknown comment tool"); + }); +}); diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 2a94a76..c0d27bb 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -18,6 +18,7 @@ import { useInstructionGeneration } from "./hooks/useInstructionGeneration"; import { useFileActions } from "./hooks/useFileActions"; import { diffPayload } from "./utils/diffPayload"; import { useCollabSync } from "./hooks/useCollabSync"; +import { useCommentManager } from "./hooks/useCommentManager"; import { useInteractionCallbacks } from "./hooks/useInteractionCallbacks"; import { useDerivedCanvasState } from "./hooks/useDerivedCanvasState"; import { useTemplateInserter } from "./hooks/useTemplateInserter"; @@ -45,6 +46,19 @@ export default function Drawd({ initialRoomCode }) { isSpaceHeld, spaceHeld, handleDragStart, handleMultiDragStart, handleMouseMove, handleMouseUp, handleCanvasMouseDown, } = useCanvas(activeTool); + // ── Comments (before useScreenManager so cleanup callbacks are stable) ──── + const { + comments, setComments, + addComment, updateComment, resolveComment, unresolveComment, deleteComment, + deleteCommentsForScreen, deleteCommentsForScreens, + deleteCommentsForHotspot, deleteCommentsForHotspots, + deleteCommentsForConnection, deleteCommentsForConnections, + } = useCommentManager(); + const [showComments, setShowComments] = useState(false); + const [selectedCommentId, setSelectedCommentId] = useState(null); + // commentComposer: { targetType, targetId, screenId, anchor, clientX, clientY } | null + const [commentComposer, setCommentComposer] = useState(null); + const { screens, connections, documents, selectedScreen, setSelectedScreen, fileInputRef, addScreen, addScreenAtCenter, removeScreen, removeScreens, renameScreen, moveScreen, moveScreens, @@ -58,7 +72,14 @@ export default function Drawd({ initialRoomCode }) { pushHistory, canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot, updateScreenStatus, markAllExisting, updateWireframe, - } = useScreenManager(pan, zoom, canvasRef); + } = useScreenManager(pan, zoom, canvasRef, { + onDeleteCommentsForScreen: deleteCommentsForScreen, + onDeleteCommentsForScreens: deleteCommentsForScreens, + onDeleteCommentsForHotspot: deleteCommentsForHotspot, + onDeleteCommentsForHotspots: deleteCommentsForHotspots, + onDeleteCommentsForConnection: deleteCommentsForConnection, + onDeleteCommentsForConnections: deleteCommentsForConnections, + }); // ── Canvas multi-object selection ──────────────────────────────────────── const { @@ -101,15 +122,16 @@ export default function Drawd({ initialRoomCode }) { const { collab, isReadOnly, + canEditFlow, canComment, canModerateComments, showShareModal, setShowShareModal, showParticipants, setShowParticipants, pendingRemoteStateRef, applyPendingRemoteState, } = useCollabSync({ screens, connections, documents, featureBrief, taskLink, techStack, - dataModels, stickyNotes, screenGroups, + dataModels, stickyNotes, screenGroups, comments, replaceAll, setFeatureBrief, setTaskLink, setTechStack, - setDataModels, setStickyNotes, setScreenGroups, + setDataModels, setStickyNotes, setScreenGroups, setComments, draggingRef, hotspotInteractionRef, patchScreenImage, canvasRef, pan, zoom, initialRoomCode, }); @@ -143,7 +165,7 @@ export default function Drawd({ initialRoomCode }) { const { connectedFileName, saveStatus, isFileSystemSupported, openFile, saveAs, saveNow, connectHandle, disconnect, - } = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, onExternalChange); + } = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, comments, onExternalChange); // ── File actions ─────────────────────────────────────────────────── const { applyPayload, onOpen, onSaveAs, onNew } = useFileActions({ @@ -151,7 +173,7 @@ export default function Drawd({ initialRoomCode }) { replaceAll, pushHistory, setPan, setZoom, setFeatureBrief, setTaskLink, setTechStack, - setDataModels, setStickyNotes, setScreenGroups, + setDataModels, setStickyNotes, setScreenGroups, setComments, setScopeRoot, openFile, saveAs, disconnect, }); // Complete the ref bridge so the poller can call applyPayload. @@ -289,7 +311,7 @@ export default function Drawd({ initialRoomCode }) { // ── Import / export ──────────────────────────────────────────────────────────────── const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } = - useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups }); + useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, comments, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups, setComments }); // ── Toast notification ───────────────────────────────────────────────────────────── const [toast, setToast] = useState(null); @@ -384,7 +406,7 @@ export default function Drawd({ initialRoomCode }) { hotspotInteraction, cancelHotspotInteraction, selectedConnection, setSelectedConnection, selectedHotspots, setSelectedHotspots, - canvasSelection, setCanvasSelection, clearSelection, removeScreens, deleteStickyNote, addScreenGroup, screens, + canvasSelection, setCanvasSelection, clearSelection, removeScreens, deleteStickyNote, addScreenGroup, screens, stickyNotes, scopeScreenIds, connections, deleteHotspot, deleteHotspots, deleteConnection, deleteConnectionGroup, selectedScreen, removeScreen, selectedStickyNote, setSelectedStickyNote, @@ -393,6 +415,7 @@ export default function Drawd({ initialRoomCode }) { setActiveTool, onTemplates, isReadOnly, + canComment, duplicateSelection, onAddWireframe: handleAddWireframe, }); @@ -471,6 +494,10 @@ export default function Drawd({ initialRoomCode }) { onToggleParticipants={() => setShowParticipants((v) => !v)} showParticipants={showParticipants} onTemplates={onTemplates} + canComment={canComment} + showComments={showComments} + onToggleComments={() => setShowComments((v) => !v)} + unresolvedCommentCount={comments.filter((c) => !c.resolved).length} />
@@ -593,6 +620,31 @@ export default function Drawd({ initialRoomCode }) { const s = screens.find((sc) => sc.id === screenId); if (s?.wireframe) setWireframeEditor({ screenId, components: s.wireframe.components, viewport: s.wireframe.viewport }); }} + comments={comments} + canComment={canComment} + onCommentImageClick={(e, screenId, xPct, yPct) => { + setCommentComposer({ + targetType: "screen", + targetId: screenId, + screenId, + anchor: { xPct, yPct }, + clientX: e.clientX, + clientY: e.clientY, + }); + }} + onCommentConnectionClick={(e, connectionId, t) => { + setCommentComposer({ + targetType: "connection", + targetId: connectionId, + screenId: null, + anchor: { t }, + clientX: e.clientX, + clientY: e.clientY, + }); + }} + selectedCommentId={selectedCommentId} + onCommentPinClick={(id) => setSelectedCommentId((prev) => (prev === id ? null : id))} + onDeselectComment={() => setSelectedCommentId(null)} /> {selectedScreenData && ( @@ -681,6 +733,36 @@ export default function Drawd({ initialRoomCode }) { showTemplateBrowser={showTemplateBrowser} setShowTemplateBrowser={setShowTemplateBrowser} onInsertTemplate={onInsertTemplate} + showComments={showComments} + setShowComments={setShowComments} + comments={comments} + connections={connections} + canModerate={canModerateComments} + selfPeerId={collab.isConnected ? collab.selfPeerId : null} + selfDisplayName={collab.selfDisplayName} + onResolveComment={(id) => resolveComment(id, collab.selfDisplayName || "Anonymous")} + onUnresolveComment={unresolveComment} + onDeleteComment={deleteComment} + selectedCommentId={selectedCommentId} + setSelectedCommentId={setSelectedCommentId} + commentComposer={commentComposer} + setCommentComposer={setCommentComposer} + onCommentSubmit={(text) => { + if (!commentComposer) return; + addComment({ + text, + authorName: collab.selfDisplayName || "Me", + authorPeerId: collab.isConnected ? (collab.selfPeerId || null) : null, + authorColor: collab.selfColor || "#61afef", + targetType: commentComposer.targetType, + targetId: commentComposer.targetId, + screenId: commentComposer.screenId, + anchor: commentComposer.anchor, + }); + setCommentComposer(null); + setActiveTool("select"); + if (!showComments) setShowComments(true); + }} /> {wireframeEditor && ( diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx index 27da8da..a5d9833 100644 --- a/src/components/CanvasArea.jsx +++ b/src/components/CanvasArea.jsx @@ -62,9 +62,17 @@ export function CanvasArea({ onTemplates, // MCP flash mcpFlashIds, + // Comments + comments, canComment, onCommentImageClick, onCommentConnectionClick, + selectedCommentId, onCommentPinClick, onDeselectComment, }) { return ( + <> + {activeTool === "comment" && ( + + )}
{ if (connectionTypePrompt) { onConnectionTypeNavigate(); return; } @@ -196,6 +204,13 @@ export function CanvasArea({ isReadOnly={isReadOnly} onFormSummary={onFormSummary} mcpFlash={mcpFlashIds?.has(screen.id)} + commentPins={(comments || []).filter( + (c) => c.screenId === screen.id && c.targetType === "screen" && !c.resolved + )} + onCommentImageClick={onCommentImageClick} + selectedCommentId={selectedCommentId} + onCommentPinClick={onCommentPinClick} + onDeselectComment={onDeselectComment} /> ))} {stickyNotes.map((note) => ( @@ -564,6 +579,7 @@ export function CanvasArea({ onUpload={handleImageUpload} onAddBlank={() => addScreenAtCenter()} isReadOnly={isReadOnly} + canComment={canComment} onAddStickyNote={() => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); @@ -575,5 +591,6 @@ export function CanvasArea({ onAddWireframe={onAddWireframe} />
+ ); } diff --git a/src/components/CommentComposer.jsx b/src/components/CommentComposer.jsx new file mode 100644 index 0000000..a2e4582 --- /dev/null +++ b/src/components/CommentComposer.jsx @@ -0,0 +1,133 @@ +import { useState, useRef, useEffect } from "react"; +import { COLORS, FONTS, Z_INDEX } from "../styles/theme"; + +/** + * Floating inline composer that appears where the user clicked in comment mode. + * Props: + * clientX, clientY — viewport coordinates for positioning + * onSubmit(text) — called when the user confirms + * onCancel() — called on Escape or outside click + */ +export function CommentComposer({ clientX, clientY, onSubmit, onCancel }) { + const [text, setText] = useState(""); + const textareaRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + // Close on outside click + useEffect(() => { + const onMouseDown = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + onCancel(); + } + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [onCancel]); + + const handleKeyDown = (e) => { + if (e.key === "Escape") { e.stopPropagation(); onCancel(); return; } + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleSubmit = () => { + const trimmed = text.trim(); + if (!trimmed) return; + onSubmit(trimmed); + }; + + // Keep composer in viewport bounds (both axes) + const COMPOSER_WIDTH = 280; + const COMPOSER_HEIGHT = 120; + const left = Math.min(clientX, window.innerWidth - COMPOSER_WIDTH - 12); + const top = Math.min(clientY + 12, window.innerHeight - COMPOSER_HEIGHT - 12); + + return ( +
+