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 (
+
+ );
+}
diff --git a/src/components/CommentPin.jsx b/src/components/CommentPin.jsx
new file mode 100644
index 0000000..c0520dc
--- /dev/null
+++ b/src/components/CommentPin.jsx
@@ -0,0 +1,200 @@
+import { useRef, useEffect, useState, useLayoutEffect } from "react";
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+
+function timeAgo(isoString) {
+ const diff = Date.now() - new Date(isoString).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ return `${Math.floor(hrs / 24)}d ago`;
+}
+
+/**
+ * A small pin marker rendered on the canvas anchored to a target.
+ * For screen/hotspot targets, position it with left/top absolute inside
+ * the screen image area. For connections, position via SVG transform.
+ */
+export function CommentPin({ comment, count, isSelected, onClick, onDeselect }) {
+ const color = comment.authorColor || "#61afef";
+ const resolved = comment.resolved;
+ const pinRef = useRef(null);
+ const [rect, setRect] = useState(null);
+
+ // Compute fixed-position for the popover based on the pin's viewport rect.
+ // useLayoutEffect ensures the DOM has settled before reading getBoundingClientRect.
+ useLayoutEffect(() => {
+ if (isSelected) setRect(pinRef.current?.getBoundingClientRect() ?? null);
+ else setRect(null);
+ }, [isSelected]);
+
+ // Close popover when clicking outside the pin+popover container
+ useEffect(() => {
+ if (!isSelected) return;
+ const handler = (e) => {
+ if (pinRef.current && !pinRef.current.contains(e.target)) {
+ onDeselect?.();
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [isSelected, onDeselect]);
+ const POPOVER_WIDTH = 240;
+ const popoverLeft = rect
+ ? Math.min(rect.left + rect.width / 2 + 8, window.innerWidth - POPOVER_WIDTH - 12)
+ : 0;
+ const popoverTop = rect ? rect.bottom + 6 : 0;
+
+ return (
+
{ e.stopPropagation(); onClick?.(comment.id); }}
+ style={{
+ position: "absolute",
+ left: `${comment.anchor.xPct}%`,
+ top: `${comment.anchor.yPct}%`,
+ transform: "translate(-50%, -100%)",
+ cursor: "pointer",
+ zIndex: 20,
+ userSelect: "none",
+ }}
+ >
+ {/* Pin body */}
+
+
+ {count > 1 && (
+
+ {count > 9 ? "9+" : count}
+
+ )}
+
+
+
+ {/* Expanded popover — rendered fixed to escape overflow:hidden on ScreenNode */}
+ {isSelected && rect && (
+
e.stopPropagation()}
+ style={{
+ position: "fixed",
+ left: popoverLeft,
+ top: popoverTop,
+ width: POPOVER_WIDTH,
+ background: COLORS.surface,
+ border: `1px solid ${color}`,
+ borderRadius: 10,
+ boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
+ zIndex: Z_INDEX.modal,
+ padding: "10px 12px",
+ }}
+ >
+ {/* Header: avatar + author + time */}
+
+
+
+ {comment.authorName || "Anonymous"}
+
+
+ {timeAgo(comment.createdAt)}
+
+
+
+ {/* Comment text */}
+
+ {comment.text}
+
+
+ {/* Resolved indicator */}
+ {resolved && (
+
+ {comment.resolvedBy ? `Resolved by ${comment.resolvedBy}` : "Resolved"}
+
+ )}
+
+ )}
+
+ );
+}
+
+/**
+ * SVG-based pin for connection comments, positioned at parametric t along a bezier.
+ */
+export function ConnectionCommentPin({ cx, cy, comment, isSelected, onClick }) {
+ const color = comment.authorColor || "#61afef";
+ const resolved = comment.resolved;
+
+ return (
+
{ e.stopPropagation(); onClick?.(comment.id); }}
+ style={{ cursor: "pointer" }}
+ opacity={resolved ? 0.45 : 1}
+ >
+
+
+
+ );
+}
diff --git a/src/components/CommentsPanel.jsx b/src/components/CommentsPanel.jsx
new file mode 100644
index 0000000..c958d1f
--- /dev/null
+++ b/src/components/CommentsPanel.jsx
@@ -0,0 +1,307 @@
+import { useState } from "react";
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+import { TOPBAR_HEIGHT } from "../constants";
+import { timeAgo, targetLabel } from "../utils/commentUtils";
+
+const PANEL_WIDTH = 300;
+
+function CommentCard({
+ comment, screens, connections,
+ canModerate, selfPeerId,
+ onResolve, onUnresolve, onDelete,
+ isSelected, onSelect,
+}) {
+ const canDelete = canModerate || (comment.authorPeerId && comment.authorPeerId === selfPeerId);
+
+ return (
+
onSelect(comment.id)}
+ style={{
+ padding: "10px 12px",
+ borderRadius: 8,
+ marginBottom: 6,
+ background: isSelected ? COLORS.accent008 : "rgba(255,255,255,0.02)",
+ border: `1px solid ${isSelected ? COLORS.accent03 : COLORS.border}`,
+ cursor: "pointer",
+ transition: "all 0.12s",
+ opacity: comment.resolved ? 0.6 : 1,
+ }}
+ >
+ {/* Header */}
+
+
+
+ {comment.authorName || "Anonymous"}
+
+
+ {timeAgo(comment.createdAt)}
+
+
+
+ {/* Target context */}
+
+ {targetLabel(comment, screens, connections)}
+
+
+ {/* Comment text */}
+
+ {comment.text}
+
+
+ {/* Resolved note */}
+ {comment.resolved && comment.resolvedBy && (
+
+ Resolved by {comment.resolvedBy}
+
+ )}
+
+ {/* Actions */}
+
+ {!comment.resolved && (
+
+ )}
+ {comment.resolved && (canModerate || (comment.authorPeerId && comment.authorPeerId === selfPeerId)) && (
+
+ )}
+ {canDelete && (
+
+ )}
+
+
+ );
+}
+
+export function CommentsPanel({
+ comments, screens, connections,
+ canModerate, selfPeerId, selfDisplayName,
+ onResolve, onUnresolve, onDelete,
+ selectedCommentId, onSelectComment,
+ onClose,
+}) {
+ const [showResolved, setShowResolved] = useState(false);
+
+ const open = comments.filter((c) => !c.resolved);
+ const resolved = comments.filter((c) => c.resolved);
+
+ return (
+
+ {/* Header */}
+
+
+
+ Comments
+
+ {open.length > 0 && (
+
+ {open.length}
+
+ )}
+
+
+
+
+ {/* Comment list */}
+
+ {open.length === 0 && !showResolved && (
+
+ No open comments.
+
+
+ Use the comment tool (C) to add one.
+
+
+ )}
+ {open.map((c) => (
+
+ ))}
+
+ {resolved.length > 0 && (
+
+ )}
+
+ {showResolved && resolved.map((c) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/ModalsLayer.jsx b/src/components/ModalsLayer.jsx
index 6789c61..b24ef39 100644
--- a/src/components/ModalsLayer.jsx
+++ b/src/components/ModalsLayer.jsx
@@ -12,6 +12,8 @@ import { ShareModal } from "./ShareModal";
import { HostLeftModal } from "./HostLeftModal";
import { FormSummaryPanel } from "./FormSummaryPanel";
import { TemplateBrowserModal } from "./TemplateBrowserModal";
+import { CommentsPanel } from "./CommentsPanel";
+import { CommentComposer } from "./CommentComposer";
export function ModalsLayer({
// Hotspot modal
@@ -46,6 +48,14 @@ export function ModalsLayer({
formSummaryScreen, setFormSummaryScreen,
// Template browser
showTemplateBrowser, setShowTemplateBrowser, onInsertTemplate,
+ // Comments
+ showComments, setShowComments,
+ comments, connections,
+ canModerate, selfPeerId, selfDisplayName,
+ onResolveComment, onUnresolveComment, onDeleteComment,
+ selectedCommentId, setSelectedCommentId,
+ commentComposer, setCommentComposer,
+ onCommentSubmit,
}) {
return (
<>
@@ -229,6 +239,32 @@ export function ModalsLayer({
onClose={() => setFormSummaryScreen(null)}
/>
)}
+
+ {showComments && (
+
setShowComments(false)}
+ />
+ )}
+
+ {commentComposer && (
+ setCommentComposer(null)}
+ />
+ )}
>
);
}
diff --git a/src/components/ParticipantsPanel.jsx b/src/components/ParticipantsPanel.jsx
index 6c1c98d..f739e15 100644
--- a/src/components/ParticipantsPanel.jsx
+++ b/src/components/ParticipantsPanel.jsx
@@ -31,7 +31,7 @@ function RoleDropdown({ peer, onSetRole }) {
fontWeight: 500,
}}
>
- {peer.role === "editor" ? "Editor" : "Viewer"} ▾
+ {peer.role === "editor" ? "Editor" : peer.role === "reviewer" ? "Reviewer" : "Viewer"} ▾
{open && (
- {["editor", "viewer"].map((r) => (
+ {[
+ { value: "editor", label: "Editor" },
+ { value: "reviewer", label: "Reviewer (comment-only)" },
+ { value: "viewer", label: "Viewer (read-only)" },
+ ].map(({ value, label }) => (
))}
@@ -91,6 +95,19 @@ function RoleBadge({ role }) {
);
}
+ if (role === "reviewer") {
+ return (
+
+ Reviewer
+
+ );
+ }
if (role === "viewer") {
return (
{
if (isSpaceHeld?.current) return;
- if (e.target.closest(".hotspot-area") || e.target.closest(".hotspot-drag-handle") || e.target.closest(".resize-handle")) return;
+ if (e.target.closest(".hotspot-area") || e.target.closest(".hotspot-drag-handle") || e.target.closest(".resize-handle") || e.target.closest(".comment-pin")) return;
+ if (activeTool === "comment" && onCommentImageClick) {
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ const xPct = ((e.clientX - rect.left) / rect.width) * 100;
+ const yPct = ((e.clientY - rect.top) / rect.height) * 100;
+ onCommentImageClick(e, screen.id, xPct, yPct);
+ return;
+ }
if (screen.imageData && onImageAreaMouseDown) {
onImageAreaMouseDown(e, screen.id);
}
@@ -509,6 +524,18 @@ export function ScreenNode({
}}
/>
)}
+ {/* Comment pins on screen image */}
+ {(commentPins || []).map((comment) => (
+
+
+
+ ))}
>
) : (
(
);
+const CommentIcon = () => (
+
+);
+
const PanIcon = () => (
);
-const TOOLS = [
+const BASE_TOOLS = [
{ id: "select", label: "Select", icon: SelectIcon, key: "V" },
{ id: "pan", label: "Pan", icon: PanIcon, key: "H" },
];
+const COMMENT_TOOL = { id: "comment", label: "Comment", icon: CommentIcon, key: "C" };
const dividerStyle = {
width: 1,
@@ -102,7 +109,8 @@ function ActionButton({ icon: Icon, label, shortcutKey, onClick }) {
const TemplateIcon = () =>
;
-export function ToolBar({ activeTool, onToolChange, onUpload, onAddBlank, onAddStickyNote, onAddWireframe, isReadOnly, onTemplates }) {
+export function ToolBar({ activeTool, onToolChange, onUpload, onAddBlank, onAddStickyNote, onAddWireframe, isReadOnly, onTemplates, canComment }) {
+ const TOOLS = canComment ? [...BASE_TOOLS, COMMENT_TOOL] : BASE_TOOLS;
return (
+
+
+ );
+}
+
function FileMenuIcon() {
return (