diff --git a/.changeset/quiet-comments-route.md b/.changeset/quiet-comments-route.md
new file mode 100644
index 0000000000..2f56b34694
--- /dev/null
+++ b/.changeset/quiet-comments-route.md
@@ -0,0 +1,5 @@
+---
+"@agent-native/core": patch
+---
+
+Add public-safe review reads and client gating, root-thread agent routing, durable resolution notes, and capability-aware review panels.
diff --git a/packages/core/docs/content/toolkit-comments-review.mdx b/packages/core/docs/content/toolkit-comments-review.mdx
index 051585344b..94cde93f92 100644
--- a/packages/core/docs/content/toolkit-comments-review.mdx
+++ b/packages/core/docs/content/toolkit-comments-review.mdx
@@ -24,9 +24,9 @@ status such as draft, review, approved, or needs changes.
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerReviewableResource()` | Register a resource type and optional access resolver for review actions. |
| Comment actions | `list-review-comments`, `create-review-comment`, `reply-review-comment`, `resolve-review-thread`, and `delete-review-comment`. |
-| Feedback actions | `get-review-feedback` and `consume-review-feedback` for agent-readable review queues. |
+| Feedback actions | `get-review-feedback`, `send-review-thread-to-agent`, and `consume-review-feedback` for root-thread agent queues and human handoffs. |
| Review status action | `set-review-status` for draft, in review, approved, and changes requested states. |
-| `ReviewThreadPanel` | Drop-in React panel with comment composer, replies, resolve, and delete controls. |
+| `ReviewThreadPanel` | Drop-in React panel with capability-gated composer, reply, resolve, delete, and custom thread-action controls. |
| `ReviewStatusBadge` | Controlled status badge for app headers, sidebars, and review panels. |
| Review hooks | `useReviewComments()`, `useCreateReviewComment()`, `useReplyReviewComment()`, `useResolveReviewThread()`, `useDeleteReviewComment()`, and `useSetReviewStatus()`. |
| Mentions helpers | `extractReviewMentions()` and `normalizeReviewMentions()` for `@[Label](mailto:email@example.com)` tokens. |
@@ -42,6 +42,13 @@ specialize anchor shapes, but the common model preserves:
- `parentCommentId` for replies
- optional `mentions`
- optional `anchor` or `resolutionTarget`
+- optional `resolutionNote` on a resolved root thread
+
+`list-review-comments` supports anonymous reads for resources whose access
+resolver grants public viewer access. Public viewer responses redact ownership,
+organization, email, and actor metadata while preserving safe display names and
+the caller-specific `canDelete` capability. Its `summary` reports unpaginated
+open-root and agent-queue counts even when the returned comment page is bounded.
## Register A Resource {#register-resource}
@@ -76,6 +83,9 @@ export function DocumentReview({ documentId }: { documentId: string }) {
resourceType="document"
resourceId={documentId}
targetId="intro-section"
+ canReply
+ canResolve={false}
+ canDeleteComment={(comment) => comment.canDelete === true}
/>
);
}
@@ -93,6 +103,10 @@ actions and thread UI.
- Agents can read open review feedback before changing code or content.
- `consumedAt` and `resolved` are separate. An agent can consume a piece of
feedback without closing the reviewer-visible thread.
+- The root comment owns routing. A human-targeted root is absent from the agent
+ queue; sending it back to the agent clears its previous consumption marker.
+- Agents can pass `resolutionNote` to `resolve-review-thread` so verification
+ evidence remains attached to the resolved root.
## What's next
diff --git a/packages/core/package.json b/packages/core/package.json
index 4720615b71..ce934d857f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -156,6 +156,7 @@
"./review/actions/consume-review-feedback": "./dist/review/actions/consume-review-feedback.js",
"./review/actions/get-review-feedback": "./dist/review/actions/get-review-feedback.js",
"./review/actions/set-review-status": "./dist/review/actions/set-review-status.js",
+ "./review/actions/send-review-thread-to-agent": "./dist/review/actions/send-review-thread-to-agent.js",
"./comments-review/actions/list-review-comments": "./dist/review/actions/list-review-comments.js",
"./comments-review/actions/create-review-comment": "./dist/review/actions/create-review-comment.js",
"./comments-review/actions/reply-review-comment": "./dist/review/actions/reply-review-comment.js",
@@ -164,6 +165,7 @@
"./comments-review/actions/consume-review-feedback": "./dist/review/actions/consume-review-feedback.js",
"./comments-review/actions/get-review-feedback": "./dist/review/actions/get-review-feedback.js",
"./comments-review/actions/set-review-status": "./dist/review/actions/set-review-status.js",
+ "./comments-review/actions/send-review-thread-to-agent": "./dist/review/actions/send-review-thread-to-agent.js",
"./sharing": "./dist/sharing/index.js",
"./sharing/actions/share-resource": "./dist/sharing/actions/share-resource.js",
"./sharing/actions/unshare-resource": "./dist/sharing/actions/unshare-resource.js",
diff --git a/packages/core/src/client/analytics.spec.ts b/packages/core/src/client/analytics.spec.ts
index 211916155c..072eb1b899 100644
--- a/packages/core/src/client/analytics.spec.ts
+++ b/packages/core/src/client/analytics.spec.ts
@@ -211,6 +211,21 @@ describe("browser analytics pageviews", () => {
});
});
+ it("can skip the authenticated engine-status probe on public routes", async () => {
+ installBrowser("https://design.agent-native.com/present/public-design");
+ const { fetchMock } = installFetch();
+ const { configureTracking } = await freshAnalytics();
+
+ configureTracking({ llmConnectionStatus: false });
+ await tick();
+
+ expect(
+ fetchMock.mock.calls.some(([url]) =>
+ String(url).includes("/_agent-native/agent-engine/status"),
+ ),
+ ).toBe(false);
+ });
+
it("keeps sanitized tracking but disables content capture on local Plan routes", async () => {
const { gtag } = installBrowser(
"https://plan.agent-native.com/local-plans/local#bridge=secret",
diff --git a/packages/core/src/client/analytics.ts b/packages/core/src/client/analytics.ts
index 01d5360eef..8b53c07d74 100644
--- a/packages/core/src/client/analytics.ts
+++ b/packages/core/src/client/analytics.ts
@@ -96,6 +96,11 @@ export type ConfigureTrackingOptions = {
contentCapture?: boolean;
/** Resolve content capture synchronously for each browser pathname. */
contentCaptureForPath?: (pathname: string) => boolean;
+ /**
+ * Whether tracking may read the authenticated agent-engine status endpoint.
+ * Disable this on anonymous/public routes to avoid an expected 401 request.
+ */
+ llmConnectionStatus?: boolean;
sessionReplay?: boolean | SessionReplayOptions;
/**
* First-party, Sentry-style error capture. Auto-captures uncaught errors
@@ -1042,7 +1047,9 @@ export function configureTracking(options: ConfigureTrackingOptions): void {
ensureSentry();
ensureAmplitude();
captureFirstTouchAttribution();
- installLlmConnectionRefresh();
+ if (options.llmConnectionStatus !== false) {
+ installLlmConnectionRefresh();
+ }
installTrackingAuthSessionRefresh();
installPageviewTracking();
maybeInstallSessionReplay(
diff --git a/packages/core/src/client/guided-questions.flow.spec.tsx b/packages/core/src/client/guided-questions.flow.spec.tsx
index ac0b253ad8..9cdf070910 100644
--- a/packages/core/src/client/guided-questions.flow.spec.tsx
+++ b/packages/core/src/client/guided-questions.flow.spec.tsx
@@ -161,6 +161,20 @@ describe("useGuidedQuestionFlow scoped reads", () => {
expect(requestedKeys).not.toContain("guided-questions:undefined");
});
+ it("does not read application state when disabled", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const result = await renderFlow({
+ enabled: false,
+ stateKey: "guided-questions",
+ queryKey: ["guided-questions"],
+ });
+
+ expect(result.current().questions).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
it("refetches on a key-specific DB-sync wakeup without fixed polling", async () => {
let hasQuestion = false;
const fetchMock = vi.fn(
diff --git a/packages/core/src/client/guided-questions.tsx b/packages/core/src/client/guided-questions.tsx
index fe278bbce2..f71af8f53f 100644
--- a/packages/core/src/client/guided-questions.tsx
+++ b/packages/core/src/client/guided-questions.tsx
@@ -959,6 +959,8 @@ function normalizeBrowserTabId(browserTabId?: string): string | undefined {
}
export interface UseGuidedQuestionFlowOptions {
+ /** Disable application-state reads for signed-out or otherwise inactive surfaces. */
+ enabled?: boolean;
stateKey?: string;
/**
* The current browser tab id. Agent actions that write the guided-questions
@@ -980,6 +982,7 @@ export interface UseGuidedQuestionFlowOptions {
}
export function useGuidedQuestionFlow({
+ enabled = true,
stateKey = "show-questions",
browserTabId,
queryKey = ["show-questions"],
@@ -1030,6 +1033,7 @@ export function useGuidedQuestionFlow({
const { data } = useQuery({
queryKey: resolvedQueryKey,
+ enabled,
queryFn: async () => {
const read = async (key: string) => {
const res = await fetch(endpointFor(key));
diff --git a/packages/core/src/client/i18n.tsx b/packages/core/src/client/i18n.tsx
index 1c7308ebfa..c173ede2b9 100644
--- a/packages/core/src/client/i18n.tsx
+++ b/packages/core/src/client/i18n.tsx
@@ -413,6 +413,7 @@ export function AgentNativeI18nProvider({
}, []);
useEffect(() => {
+ if (!persistPreference) return;
void setClientAppState(
"localization",
{
@@ -424,7 +425,7 @@ export function AgentNativeI18nProvider({
).catch(() => {
// Public/anonymous pages cannot write app-state; localization still works.
});
- }, [locale, preference]);
+ }, [locale, persistPreference, preference]);
const setPreference = useCallback(
async (next: LocalePreference) => {
diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts
index 1c90ef1061..1e736265d8 100644
--- a/packages/core/src/client/index.ts
+++ b/packages/core/src/client/index.ts
@@ -864,6 +864,7 @@ export {
type VersionHistoryPanelProps,
} from "./history/index.js";
export {
+ ReviewCommentComposer,
ReviewStatusBadge,
ReviewThreadPanel,
buildReviewThreads,
@@ -874,6 +875,7 @@ export {
useResolveReviewThread,
useReviewComments,
useReviewFeedback,
+ useSendReviewThreadToAgent,
useSetReviewStatus,
type ConsumeReviewFeedbackInput,
type CreateReviewCommentInput,
@@ -885,9 +887,11 @@ export {
type ReplyReviewCommentInput,
type ResolveReviewThreadInput,
type ReviewStatusBadgeProps,
+ type ReviewCommentComposerProps,
type ReviewThread,
type ReviewThreadPanelProps,
type SetReviewStatusInput,
+ type SendReviewThreadToAgentInput,
} from "./review/index.js";
export type {
AppToFrameMessage,
diff --git a/packages/core/src/client/review/ReviewCommentComposer.tsx b/packages/core/src/client/review/ReviewCommentComposer.tsx
new file mode 100644
index 0000000000..e4277cb09c
--- /dev/null
+++ b/packages/core/src/client/review/ReviewCommentComposer.tsx
@@ -0,0 +1,108 @@
+import { Button } from "@agent-native/toolkit/ui/button";
+import { Spinner } from "@agent-native/toolkit/ui/spinner";
+import { Textarea } from "@agent-native/toolkit/ui/textarea";
+import { IconMessageCircle, IconSend } from "@tabler/icons-react";
+
+import type { ReviewResolutionTarget } from "../../review/types.js";
+import { cn } from "../utils.js";
+
+export interface ReviewCommentComposerProps {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: (resolutionTarget: ReviewResolutionTarget) => void;
+ submittingTarget?: ReviewResolutionTarget | null;
+ disabled?: boolean;
+ showAgentAction?: boolean;
+ placeholder?: string;
+ commentLabel?: string;
+ agentLabel?: string;
+ autoFocus?: boolean;
+ submitOnEnter?: boolean;
+ onEscape?: () => void;
+ className?: string;
+}
+
+export function ReviewCommentComposer({
+ value,
+ onChange,
+ onSubmit,
+ submittingTarget = null,
+ disabled = false,
+ showAgentAction = false,
+ placeholder = "Add a comment...",
+ commentLabel = "Comment",
+ agentLabel = "Send to agent",
+ autoFocus = false,
+ submitOnEnter = false,
+ onEscape,
+ className,
+}: ReviewCommentComposerProps) {
+ const canSubmit = Boolean(value.trim()) && !disabled;
+ const submit = (resolutionTarget: ReviewResolutionTarget) => {
+ if (!canSubmit) return;
+ onSubmit(resolutionTarget);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/core/src/client/review/ReviewThreadPanel.spec.tsx b/packages/core/src/client/review/ReviewThreadPanel.spec.tsx
new file mode 100644
index 0000000000..0f23ea59c7
--- /dev/null
+++ b/packages/core/src/client/review/ReviewThreadPanel.spec.tsx
@@ -0,0 +1,324 @@
+// @vitest-environment happy-dom
+
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ReviewComment } from "../../review/types.js";
+
+const mutate = vi.hoisted(() => vi.fn());
+const rootComment = vi.hoisted(
+ () =>
+ ({
+ id: "comment-1",
+ resourceType: "design",
+ resourceId: "design-1",
+ threadId: "thread-1",
+ parentCommentId: null,
+ targetId: "screen-1",
+ kind: "comment",
+ status: "open",
+ anchor: null,
+ body: "Make the heading clearer",
+ authorEmail: "reviewer@example.com",
+ authorName: null,
+ createdBy: "human",
+ resolutionTarget: "human",
+ mentions: [],
+ ownerEmail: "owner@example.com",
+ orgId: null,
+ visibility: "private",
+ resolvedBy: null,
+ resolvedAt: null,
+ consumedAt: null,
+ deletedBy: null,
+ deletedAt: null,
+ createdAt: "2026-07-13T13:00:00.000Z",
+ updatedAt: "2026-07-13T13:00:00.000Z",
+ metadata: null,
+ }) satisfies ReviewComment,
+);
+
+vi.mock("./use-review.js", () => ({
+ useReviewComments: () => ({
+ data: {
+ comments: [rootComment],
+ reviewStatus: { status: "draft" },
+ },
+ isLoading: false,
+ }),
+ useCreateReviewComment: () => ({ mutate, isPending: false }),
+ useDeleteReviewComment: () => ({ mutate, isPending: false }),
+ useReplyReviewComment: () => ({ mutate, isPending: false }),
+ useResolveReviewThread: () => ({ mutate, isPending: false }),
+}));
+
+import { ReviewThreadPanel } from "./ReviewThreadPanel.js";
+
+function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(
+ Object.getPrototypeOf(textarea),
+ "value",
+ )?.set;
+ act(() => {
+ setter?.call(textarea, value);
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ textarea.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+}
+
+describe("ReviewThreadPanel sidebar layout", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ const comment = rootComment as ReviewComment & {
+ resolutionNote?: string;
+ };
+ comment.status = "open";
+ comment.metadata = null;
+ delete comment.resolutionNote;
+ mutate.mockReset();
+ vi.unstubAllGlobals();
+ });
+
+ it("uses a flat container and progressively discloses reply and narrow actions", () => {
+ act(() => {
+ root.render(
+ (
+
+ Send to agent
+
+ )}
+ />,
+ );
+ });
+
+ const section = container.querySelector("section");
+ expect(section?.className).toContain("@container/review");
+ expect(section?.className).toContain("bg-transparent");
+ expect(section?.className).not.toContain("rounded-lg");
+ expect(container.textContent).not.toContain("Draft");
+ expect(container.textContent).toContain("Make the heading clearer");
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull();
+ expect(
+ Array.from(container.querySelectorAll("button")).some(
+ (button) => button.textContent?.trim() === "Comment",
+ ),
+ ).toBe(true);
+ expect(
+ Array.from(container.querySelectorAll("button")).some(
+ (button) => button.textContent?.trim() === "Send to agent",
+ ),
+ ).toBe(true);
+
+ const composer = container.querySelector(
+ 'textarea[placeholder="Leave feedback"]',
+ );
+ expect(composer).not.toBeNull();
+ setTextareaValue(composer!, "Ship this feedback");
+ const composerButtons = Array.from(container.querySelectorAll("button"));
+ const commentButton = composerButtons.find(
+ (button) => button.textContent?.trim() === "Comment",
+ );
+ const agentButton = composerButtons.find(
+ (button) => button.textContent?.trim() === "Send to agent",
+ );
+ act(() => commentButton?.click());
+ expect(mutate).toHaveBeenLastCalledWith(
+ expect.objectContaining({ resolutionTarget: "human" }),
+ expect.any(Object),
+ );
+ act(() => agentButton?.click());
+ expect(mutate).toHaveBeenLastCalledWith(
+ expect.objectContaining({ resolutionTarget: "agent" }),
+ expect.any(Object),
+ );
+
+ const resolveButton = container.querySelector(
+ 'button[aria-label="Resolve"]',
+ );
+ expect(resolveButton?.querySelector("span")?.className).toContain(
+ "@xs/review:inline",
+ );
+
+ const replyButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Reply",
+ );
+ expect(replyButton).toBeTruthy();
+ act(() => replyButton?.click());
+
+ expect(
+ container.querySelector('input[placeholder="Reply to this thread"]'),
+ ).not.toBeNull();
+ expect(
+ container.querySelector('button[aria-label="Cancel reply"]'),
+ ).not.toBeNull();
+ });
+
+ it("fails closed when reply, resolve, and delete capabilities are omitted", () => {
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+
+ expect(container.querySelector("form")).toBeNull();
+ expect(container.querySelector('button[aria-label="Reply"]')).toBeNull();
+ expect(container.querySelector('button[aria-label="Resolve"]')).toBeNull();
+ expect(
+ container.querySelector('button[aria-label="More actions"]'),
+ ).toBeNull();
+ });
+
+ it("shows only the controls authorized for the current viewer", () => {
+ act(() => {
+ root.render(
+
+ comment.authorEmail === "someone-else@example.com"
+ }
+ />,
+ );
+ });
+
+ const replyButton = container.querySelector(
+ 'button[aria-label="Reply"]',
+ );
+ expect(replyButton).not.toBeNull();
+ expect(container.querySelector('button[aria-label="Resolve"]')).toBeNull();
+ expect(
+ container.querySelector('button[aria-label="More actions"]'),
+ ).toBeNull();
+
+ act(() => replyButton?.click());
+ expect(
+ container.querySelector('input[placeholder="Reply..."]'),
+ ).not.toBeNull();
+
+ act(() => {
+ root.render(
+
+ comment.authorEmail === "reviewer@example.com"
+ }
+ />,
+ );
+ });
+
+ expect(container.querySelector('button[aria-label="Reply"]')).toBeNull();
+ expect(
+ container.querySelector('button[aria-label="Resolve"]'),
+ ).not.toBeNull();
+ expect(
+ container.querySelector('button[aria-label="More actions"]'),
+ ).not.toBeNull();
+ });
+
+ it("renders resolution notes from metadata and a future typed field", () => {
+ const comment = rootComment as ReviewComment & {
+ resolutionNote?: string;
+ };
+ comment.status = "resolved";
+ comment.metadata = {
+ resolutionNote: "Tightened the hero headline to six words.",
+ };
+
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+
+ expect(container.textContent).toContain(
+ "Tightened the hero headline to six words.",
+ );
+
+ comment.metadata = null;
+ comment.resolutionNote = "Updated the spacing tokens.";
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+
+ expect(container.textContent).toContain("Updated the spacing tokens.");
+ });
+
+ it("does not render resolution notes on open comments", () => {
+ const comment = rootComment as ReviewComment & {
+ resolutionNote?: string;
+ };
+ comment.status = "open";
+ comment.metadata = {
+ resolutionNote: "This thread has not actually been resolved.",
+ };
+ comment.resolutionNote = "This thread has not actually been resolved.";
+
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+
+ expect(container.textContent).not.toContain(
+ "This thread has not actually been resolved.",
+ );
+ expect(container.querySelector('[aria-label="Resolved"]')).toBeNull();
+ });
+});
diff --git a/packages/core/src/client/review/ReviewThreadPanel.tsx b/packages/core/src/client/review/ReviewThreadPanel.tsx
index 388ef933f9..a7b82c9692 100644
--- a/packages/core/src/client/review/ReviewThreadPanel.tsx
+++ b/packages/core/src/client/review/ReviewThreadPanel.tsx
@@ -1,14 +1,31 @@
+import { Avatar, AvatarFallback } from "@agent-native/toolkit/ui/avatar";
+import { Button } from "@agent-native/toolkit/ui/button";
import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@agent-native/toolkit/ui/dropdown-menu";
+import { Input } from "@agent-native/toolkit/ui/input";
+import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
+import { Spinner } from "@agent-native/toolkit/ui/spinner";
+import {
+ IconCircleCheck,
+ IconDots,
IconMessageCircle,
- IconPlus,
IconSend,
IconTrash,
- IconCircleCheck,
+ IconX,
} from "@tabler/icons-react";
-import { useMemo, useState } from "react";
+import { useMemo, useState, type ReactNode } from "react";
-import type { ReviewComment } from "../../review/types.js";
+import type {
+ ReviewComment,
+ ReviewResolutionTarget,
+} from "../../review/types.js";
+import { useFormatters } from "../i18n.js";
import { cn } from "../utils.js";
+import { ReviewCommentComposer } from "./ReviewCommentComposer.js";
import { ReviewStatusBadge } from "./ReviewStatusBadge.js";
import {
useCreateReviewComment,
@@ -23,6 +40,14 @@ export interface ReviewThread {
replies: ReviewComment[];
}
+export type ReviewThreadCapability =
+ | boolean
+ | ((thread: ReviewThread) => boolean);
+
+export type ReviewCommentCapability =
+ | boolean
+ | ((comment: ReviewComment, thread: ReviewThread) => boolean);
+
export interface ReviewThreadPanelProps {
resourceType: string;
resourceId: string;
@@ -31,9 +56,33 @@ export interface ReviewThreadPanelProps {
className?: string;
includeResolved?: boolean;
showComposer?: boolean;
+ showHeader?: boolean;
+ variant?: "card" | "plain";
placeholder?: string;
emptyState?: string;
+ loadingLabel?: string;
+ replyLabel?: string;
+ replyPlaceholder?: string;
+ cancelReplyLabel?: string;
+ resolveLabel?: string;
+ deleteLabel?: string;
+ moreActionsLabel?: string;
+ resolvedLabel?: string;
+ reviewerLabel?: string;
onSelectThread?: (thread: ReviewThread) => void;
+ onCommentCreated?: (comment: ReviewComment) => void;
+ /** Allow signed-in commenters to reply. Omitted capabilities fail closed. */
+ canReply?: ReviewThreadCapability;
+ /** Allow editors to resolve a thread. Omitted capabilities fail closed. */
+ canResolve?: ReviewThreadCapability;
+ /** Allow deletion only for comments the caller has authorized. */
+ canDeleteComment?: ReviewCommentCapability;
+ /** Extra per-thread controls rendered next to reply/resolve/delete. */
+ renderThreadActions?: (thread: ReviewThread) => ReactNode;
+ /** Show a separate agent-submit action when the host supports agent routing. */
+ showComposerTargetPicker?: boolean;
+ composerCommentLabel?: string;
+ composerAgentLabel?: string;
}
export function ReviewThreadPanel({
@@ -44,12 +93,35 @@ export function ReviewThreadPanel({
className,
includeResolved = true,
showComposer = true,
+ showHeader = true,
+ variant = "card",
placeholder = "Add a comment...",
emptyState = "No review comments yet.",
+ loadingLabel = "Loading comments",
+ replyLabel = "Reply",
+ replyPlaceholder = "Reply...",
+ cancelReplyLabel = "Cancel reply",
+ resolveLabel = "Resolve",
+ deleteLabel = "Delete comment",
+ moreActionsLabel = "More actions",
+ resolvedLabel = "Resolved",
+ reviewerLabel = "Reviewer",
onSelectThread,
+ onCommentCreated,
+ canReply = false,
+ canResolve = false,
+ canDeleteComment = false,
+ renderThreadActions,
+ showComposerTargetPicker = false,
+ composerCommentLabel = "Comment",
+ composerAgentLabel = "Send to agent",
}: ReviewThreadPanelProps) {
const [draft, setDraft] = useState("");
+ const [submittingTarget, setSubmittingTarget] =
+ useState(null);
+ const [replyingThreadId, setReplyingThreadId] = useState(null);
const [replyDrafts, setReplyDrafts] = useState>({});
+ const { formatDate } = useFormatters();
const comments = useReviewComments({
resourceType,
resourceId,
@@ -65,153 +137,291 @@ export function ReviewThreadPanel({
[comments.data?.comments],
);
+ const submitDraft = (resolutionTarget: ReviewResolutionTarget) => {
+ const body = draft.trim();
+ if (!body || createComment.isPending) return;
+ setSubmittingTarget(resolutionTarget);
+ createComment.mutate(
+ {
+ resourceType,
+ resourceId,
+ targetId,
+ body,
+ ...(showComposerTargetPicker ? { resolutionTarget } : {}),
+ },
+ {
+ onSuccess: (comment) => {
+ setDraft("");
+ onCommentCreated?.(comment);
+ },
+ onSettled: () => setSubmittingTarget(null),
+ },
+ );
+ };
+
return (
-
-
-
-
{title}
+ {showHeader ? (
+
-
-
+ ) : null}
{showComposer ? (
-
{
- event.preventDefault();
- const body = draft.trim();
- if (!body) return;
- createComment.mutate(
- { resourceType, resourceId, targetId, body },
- { onSuccess: () => setDraft("") },
- );
- }}
- >
- setDraft(event.currentTarget.value)}
- placeholder={placeholder}
- className="min-h-20 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
- />
-
-
-
- Comment
-
-
-
+
) : null}
{comments.isLoading ? (
-
- Loading comments...
+
) : threads.length ? (
- threads.map((thread) => (
-
onSelectThread?.(thread)}
- >
-
- {thread.replies.length ? (
-
- {thread.replies.map((reply) => (
-
- ))}
-
- ) : null}
- {
- event.preventDefault();
- const body = replyDrafts[thread.root.id]?.trim();
- if (!body) return;
- replyComment.mutate(
- {
- resourceType,
- resourceId,
- commentId: thread.root.id,
- body,
- },
- {
- onSuccess: () =>
- setReplyDrafts((current) => ({
- ...current,
- [thread.root.id]: "",
- })),
- },
- );
- }}
+ threads.map((thread) => {
+ const replyDraft = replyDrafts[thread.root.id] ?? "";
+ const replying = replyingThreadId === thread.root.threadId;
+ const threadIsOpen = thread.root.status === "open";
+ const replyAllowed =
+ threadIsOpen && capabilityAllowsThread(canReply, thread);
+ const resolveAllowed =
+ threadIsOpen && capabilityAllowsThread(canResolve, thread);
+ const deleteAllowed = capabilityAllowsComment(
+ canDeleteComment,
+ thread.root,
+ thread,
+ );
+ const threadActions = renderThreadActions?.(thread);
+ const hasActions =
+ replyAllowed ||
+ resolveAllowed ||
+ deleteAllowed ||
+ Boolean(threadActions);
+ return (
+ onSelectThread?.(thread)}
>
-
- setReplyDrafts((current) => ({
- ...current,
- [thread.root.id]: event.currentTarget.value,
- }))
- }
- placeholder="Reply..."
- className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
+
-
-
-
- {thread.root.status !== "resolved" ? (
-
- resolveThread.mutate({
- resourceType,
- resourceId,
- threadId: thread.root.threadId,
- })
- }
+ {thread.replies.length ? (
+
+ {thread.replies.map((reply) => (
+
+ ))}
+
+ ) : null}
+
+ {replying && replyAllowed ? (
+ event.stopPropagation()}
+ onSubmit={(event) => {
+ event.preventDefault();
+ const body = replyDraft.trim();
+ if (!body || replyComment.isPending) return;
+ replyComment.mutate(
+ {
+ resourceType,
+ resourceId,
+ commentId: thread.root.id,
+ body,
+ },
+ {
+ onSuccess: () => {
+ setReplyDrafts((current) => ({
+ ...current,
+ [thread.root.id]: "",
+ }));
+ setReplyingThreadId(null);
+ },
+ },
+ );
+ }}
+ >
+
+ setReplyDrafts((current) => ({
+ ...current,
+ [thread.root.id]: event.currentTarget.value,
+ }))
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ setReplyingThreadId(null);
+ }
+ }}
+ placeholder={replyPlaceholder}
+ className="h-8 min-w-0 flex-1 text-sm"
+ />
+
+ {replyComment.isPending ? (
+
+ ) : (
+
+ )}
+
+ setReplyingThreadId(null)}
+ >
+
+
+
+ ) : hasActions ? (
+ event.stopPropagation()}
>
-
-
+ {replyAllowed ? (
+
+ setReplyingThreadId(thread.root.threadId)
+ }
+ >
+
+
+ {replyLabel}
+
+
+ ) : null}
+
+ {threadActions}
+ {resolveAllowed ? (
+
+ resolveThread.mutate({
+ resourceType,
+ resourceId,
+ threadId: thread.root.threadId,
+ })
+ }
+ >
+ {resolveThread.isPending ? (
+
+ ) : (
+
+ )}
+
+ {resolveLabel}
+
+
+ ) : null}
+ {deleteAllowed ? (
+
+
+
+
+
+
+
+
+ deleteComment.mutate({
+ resourceType,
+ resourceId,
+ commentId: thread.root.id,
+ })
+ }
+ >
+
+ {deleteLabel}
+
+
+
+ ) : null}
+
+
) : null}
-
- deleteComment.mutate({
- resourceType,
- resourceId,
- commentId: thread.root.id,
- })
- }
- >
-
-
-
-
- ))
+
+ );
+ })
) : (
-
+
{emptyState}
)}
@@ -243,36 +453,158 @@ export function buildReviewThreads(comments: ReviewComment[]): ReviewThread[] {
function CommentBubble({
comment,
compact = false,
+ resolvedLabel,
+ reviewerLabel,
+ formatDate,
}: {
comment: ReviewComment;
compact?: boolean;
+ resolvedLabel: string;
+ reviewerLabel: string;
+ formatDate: ReturnType
["formatDate"];
}) {
+ const author = comment.authorName ?? comment.authorEmail ?? reviewerLabel;
+ const resolutionNote =
+ comment.status === "resolved" ? getReviewResolutionNote(comment) : null;
+ const bodyIsResolutionNote =
+ resolutionNote !== null &&
+ resolutionNote === comment.body.trim() &&
+ isResolutionNoteMetadata(comment.metadata);
return (
-
-
-
- {comment.authorName ?? comment.authorEmail ?? "Reviewer"}
-
-
- {formatCommentDate(comment.createdAt)}
-
- {comment.status === "resolved" ? (
-
- Resolved
-
+
+
+
+ {authorInitials(author)}
+
+
+
+
+ {author}
+
+ {formatCommentDate(comment.createdAt, formatDate)}
+
+ {comment.status === "resolved" ? (
+
+ {resolvedLabel}
+
+ ) : null}
+
+ {!bodyIsResolutionNote ? (
+
+ {comment.body}
+
+ ) : null}
+ {resolutionNote ? (
+
+
+
+ {resolutionNote}
+
+
) : null}
-
- {comment.body}
-
);
}
-function formatCommentDate(value: string): string {
+function capabilityAllowsThread(
+ capability: ReviewThreadCapability,
+ thread: ReviewThread,
+): boolean {
+ return typeof capability === "function" ? capability(thread) : capability;
+}
+
+function capabilityAllowsComment(
+ capability: ReviewCommentCapability,
+ comment: ReviewComment,
+ thread: ReviewThread,
+): boolean {
+ return typeof capability === "function"
+ ? capability(comment, thread)
+ : capability;
+}
+
+function getReviewResolutionNote(comment: ReviewComment): string | null {
+ const direct = comment.resolutionNote;
+ const metadata = comment.metadata;
+ const nestedResolution = metadata?.resolution;
+ const nestedNote =
+ isRecord(nestedResolution) && typeof nestedResolution.note === "string"
+ ? nestedResolution.note
+ : null;
+ const metadataNote =
+ typeof metadata?.resolutionNote === "string"
+ ? metadata.resolutionNote
+ : typeof metadata?.resolvedNote === "string"
+ ? metadata.resolvedNote
+ : isResolutionNoteMetadata(metadata) &&
+ typeof metadata?.note === "string"
+ ? metadata.note
+ : isResolutionNoteMetadata(metadata)
+ ? comment.body
+ : null;
+ const candidate =
+ typeof direct === "string" ? direct : (metadataNote ?? nestedNote);
+ const normalized = candidate?.trim();
+ return normalized || null;
+}
+
+function isResolutionNoteMetadata(
+ metadata: Record
| null,
+): boolean {
+ const marker = metadata?.type ?? metadata?.kind;
+ return (
+ marker === "resolution" ||
+ marker === "resolution_note" ||
+ marker === "resolution-note"
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function authorInitials(value: string): string {
+ const normalized = value.split("@")[0]?.trim() ?? "";
+ const parts = normalized.split(/[\s._+-]+/).filter(Boolean);
+ const initials = parts
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("");
+ return initials || "R";
+}
+
+function formatCommentDate(
+ value: string,
+ formatDate: ReturnType["formatDate"],
+): string {
const date = new Date(value);
- if (Number.isNaN(date.getTime())) {
- return value;
+ if (Number.isNaN(date.getTime())) return value;
+ const now = new Date();
+ const sameDay =
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth() &&
+ date.getDate() === now.getDate();
+ if (sameDay) {
+ return formatDate(date, { hour: "numeric", minute: "2-digit" });
}
- return date.toLocaleString();
+ if (date.getFullYear() === now.getFullYear()) {
+ return formatDate(date, { month: "short", day: "numeric" });
+ }
+ return formatDate(date, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
}
diff --git a/packages/core/src/client/review/index.ts b/packages/core/src/client/review/index.ts
index b7fac15707..152ef3b58f 100644
--- a/packages/core/src/client/review/index.ts
+++ b/packages/core/src/client/review/index.ts
@@ -1,3 +1,7 @@
+export {
+ ReviewCommentComposer,
+ type ReviewCommentComposerProps,
+} from "./ReviewCommentComposer.js";
export {
ReviewStatusBadge,
type ReviewStatusBadgeProps,
@@ -6,6 +10,8 @@ export {
ReviewThreadPanel,
buildReviewThreads,
type ReviewThread,
+ type ReviewCommentCapability,
+ type ReviewThreadCapability,
type ReviewThreadPanelProps,
} from "./ReviewThreadPanel.js";
export {
@@ -17,6 +23,7 @@ export {
useReviewComments,
useReviewFeedback,
useSetReviewStatus,
+ useSendReviewThreadToAgent,
type ConsumeReviewFeedbackInput,
type CreateReviewCommentInput,
type DeleteReviewCommentInput,
@@ -27,4 +34,5 @@ export {
type ReplyReviewCommentInput,
type ResolveReviewThreadInput,
type SetReviewStatusInput,
+ type SendReviewThreadToAgentInput,
} from "./use-review.js";
diff --git a/packages/core/src/client/review/use-review.ts b/packages/core/src/client/review/use-review.ts
index 4f27db8714..a282afff9a 100644
--- a/packages/core/src/client/review/use-review.ts
+++ b/packages/core/src/client/review/use-review.ts
@@ -20,6 +20,10 @@ export interface ListReviewCommentsParams {
export interface ListReviewCommentsResult {
comments: ReviewComment[];
reviewStatus: ReviewStatusEntry | null;
+ summary: {
+ openCount: number;
+ agentQueueCount: number;
+ };
}
export interface GetReviewFeedbackParams {
@@ -62,6 +66,7 @@ export interface ResolveReviewThreadInput {
resourceId: string;
threadId?: string;
commentId?: string;
+ resolutionNote?: string;
}
export interface DeleteReviewCommentInput {
@@ -76,6 +81,12 @@ export interface ConsumeReviewFeedbackInput {
commentIds: string[];
}
+export interface SendReviewThreadToAgentInput {
+ resourceType: string;
+ resourceId: string;
+ threadId: string;
+}
+
export interface SetReviewStatusInput {
resourceType: string;
resourceId: string;
@@ -126,7 +137,13 @@ export function useReplyReviewComment() {
export function useResolveReviewThread() {
return useActionMutation<
- { threadId: string; resolved: true; updatedCount: number },
+ {
+ threadId: string;
+ resolved: true;
+ updatedCount: number;
+ resolutionNote: string | null;
+ comment: ReviewComment;
+ },
ResolveReviewThreadInput
>("resolve-review-thread");
}
@@ -145,6 +162,23 @@ export function useConsumeReviewFeedback() {
>("consume-review-feedback");
}
+export function useSendReviewThreadToAgent() {
+ return useActionMutation<
+ {
+ resourceType: string;
+ resourceId: string;
+ threadId: string;
+ resolutionTarget: "agent";
+ consumedAt: null;
+ updatedCount: number;
+ ownerEmail: string | null;
+ orgId: string | null;
+ visibility: "private" | "org" | "public";
+ },
+ SendReviewThreadToAgentInput
+ >("send-review-thread-to-agent");
+}
+
export function useSetReviewStatus() {
return useActionMutation(
"set-review-status",
diff --git a/packages/core/src/client/use-agent-chat-context.spec.tsx b/packages/core/src/client/use-agent-chat-context.spec.tsx
new file mode 100644
index 0000000000..ee82c2d315
--- /dev/null
+++ b/packages/core/src/client/use-agent-chat-context.spec.tsx
@@ -0,0 +1,61 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { refreshAgentChatContext } from "./agent-chat.js";
+import { useAgentChatContext } from "./use-agent-chat-context.js";
+
+const { agentChatContextState } = vi.hoisted(() => ({
+ agentChatContextState: { items: [], updatedAt: 0 },
+}));
+
+vi.mock("./agent-chat.js", () => ({
+ clearAgentChatContext: vi.fn(),
+ getAgentChatContextState: vi.fn(() => agentChatContextState),
+ refreshAgentChatContext: vi.fn(async () => agentChatContextState),
+ removeAgentChatContextItem: vi.fn(),
+ setAgentChatContextItem: vi.fn(),
+ subscribeAgentChatContext: vi.fn(() => () => {}),
+}));
+
+function Probe({ enabled }: { enabled: boolean }) {
+ useAgentChatContext(enabled);
+ return null;
+}
+
+describe("useAgentChatContext", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ vi.mocked(refreshAgentChatContext).mockClear();
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ });
+
+ it("does not read application state while disabled", async () => {
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+
+ expect(refreshAgentChatContext).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+
+ expect(refreshAgentChatContext).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/core/src/client/use-agent-chat-context.ts b/packages/core/src/client/use-agent-chat-context.ts
index 4cce28c8d2..b03643d2e5 100644
--- a/packages/core/src/client/use-agent-chat-context.ts
+++ b/packages/core/src/client/use-agent-chat-context.ts
@@ -26,7 +26,7 @@ export interface UseAgentChatContextResult extends AgentChatContextState {
* composer's staged context chips. Simple send/prefill flows should use
* `sendToAgentChat({ message, context, submit })` directly.
*/
-export function useAgentChatContext(): UseAgentChatContextResult {
+export function useAgentChatContext(enabled = true): UseAgentChatContextResult {
const appStateVersion = useChangeVersion("app-state");
const state = useSyncExternalStore(
subscribeAgentChatContext,
@@ -35,8 +35,9 @@ export function useAgentChatContext(): UseAgentChatContextResult {
);
useEffect(() => {
+ if (!enabled) return;
void refreshAgentChatContext();
- }, [appStateVersion]);
+ }, [appStateVersion, enabled]);
const set = useCallback((item: AgentChatContextSetOptions) => {
setAgentChatContextItem(item);
diff --git a/packages/core/src/review/actions/create-review-comment.ts b/packages/core/src/review/actions/create-review-comment.ts
index a07c77b4cd..061870178a 100644
--- a/packages/core/src/review/actions/create-review-comment.ts
+++ b/packages/core/src/review/actions/create-review-comment.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { defineAction } from "../../action.js";
+import { reviewAuthorNameFromContext } from "../identity.js";
import { extractReviewMentions, normalizeReviewMentions } from "../mentions.js";
import {
assertReviewableResourceAccess,
@@ -68,7 +69,7 @@ export default defineAction({
anchor: args.anchor ?? null,
body: args.body,
authorEmail: actionCtx?.userEmail ?? null,
- authorName: args.authorName ?? actionCtx?.userEmail ?? null,
+ authorName: args.authorName ?? reviewAuthorNameFromContext(actionCtx),
createdBy: actorKindFromContext(actionCtx),
resolutionTarget,
mentions,
diff --git a/packages/core/src/review/actions/get-review-feedback.ts b/packages/core/src/review/actions/get-review-feedback.ts
index cd3c48bc52..39e5377836 100644
--- a/packages/core/src/review/actions/get-review-feedback.ts
+++ b/packages/core/src/review/actions/get-review-feedback.ts
@@ -37,18 +37,13 @@ export default defineAction({
bypassScope: true,
includeResolved: false,
includeDeleted: false,
+ rootOnly: true,
+ resolutionTargets: args.includeHumanTargeted
+ ? undefined
+ : ["agent", null],
+ unconsumedOnly: true,
limit: args.limit,
});
- return {
- comments: comments.filter((comment) => {
- if (comment.consumedAt) {
- return false;
- }
- if (args.includeHumanTargeted) {
- return true;
- }
- return comment.resolutionTarget !== "human";
- }),
- };
+ return { comments };
},
});
diff --git a/packages/core/src/review/actions/list-review-comments.ts b/packages/core/src/review/actions/list-review-comments.ts
index 36a6c132bb..0083c85a50 100644
--- a/packages/core/src/review/actions/list-review-comments.ts
+++ b/packages/core/src/review/actions/list-review-comments.ts
@@ -1,8 +1,17 @@
import { z } from "zod";
import { defineAction } from "../../action.js";
+import {
+ redactPublicReviewCommentIdentity,
+ redactPublicReviewStatusIdentity,
+ shouldRedactReviewIdentity,
+} from "../identity.js";
import { assertReviewableResourceAccess } from "../registry.js";
-import { getReviewStatus, queryReviewComments } from "../store.js";
+import {
+ getReviewStatus,
+ getReviewThreadSummary,
+ queryReviewComments,
+} from "../store.js";
import type { ReviewResourceContext } from "../types.js";
const schema = z.object({
@@ -19,11 +28,12 @@ export default defineAction({
"List inline comments, annotations, and review threads for a resource.",
schema,
http: { method: "GET" },
+ requiresAuth: false,
readOnly: true,
parallelSafe: true,
run: async (args, ctx) => {
const actionCtx = ctx as ReviewResourceContext | undefined;
- await assertReviewableResourceAccess(
+ const access = await assertReviewableResourceAccess(
args.resourceType,
args.resourceId,
actionCtx,
@@ -33,7 +43,7 @@ export default defineAction({
userEmail: actionCtx?.userEmail ?? null,
orgId: actionCtx?.orgId ?? null,
};
- const [comments, reviewStatus] = await Promise.all([
+ const [comments, reviewStatus, summary] = await Promise.all([
queryReviewComments({
resourceType: args.resourceType,
resourceId: args.resourceId,
@@ -47,7 +57,31 @@ export default defineAction({
getReviewStatus(args.resourceType, args.resourceId, scope, {
bypassScope: true,
}),
+ getReviewThreadSummary({
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ scope,
+ bypassScope: true,
+ targetId: args.targetId,
+ }),
]);
- return { comments, reviewStatus };
+ const redactIdentity = shouldRedactReviewIdentity(actionCtx, access);
+ const commentsWithCapabilities = comments.map((comment) => ({
+ ...comment,
+ canDelete:
+ access.role !== "viewer" ||
+ Boolean(
+ actionCtx?.userEmail && comment.authorEmail === actionCtx.userEmail,
+ ),
+ }));
+ return redactIdentity
+ ? {
+ comments: commentsWithCapabilities.map(
+ redactPublicReviewCommentIdentity,
+ ),
+ reviewStatus: redactPublicReviewStatusIdentity(reviewStatus),
+ summary,
+ }
+ : { comments: commentsWithCapabilities, reviewStatus, summary };
},
});
diff --git a/packages/core/src/review/actions/reply-review-comment.ts b/packages/core/src/review/actions/reply-review-comment.ts
index fb40c45586..56c0ef38d0 100644
--- a/packages/core/src/review/actions/reply-review-comment.ts
+++ b/packages/core/src/review/actions/reply-review-comment.ts
@@ -1,12 +1,13 @@
import { z } from "zod";
import { defineAction } from "../../action.js";
+import { reviewAuthorNameFromContext } from "../identity.js";
import { extractReviewMentions, normalizeReviewMentions } from "../mentions.js";
import {
assertReviewableResourceAccess,
normalizeReviewVisibility,
} from "../registry.js";
-import { getReviewCommentById, insertReviewComment } from "../store.js";
+import { getReviewCommentById, insertReviewReply } from "../store.js";
import type { ReviewActorKind, ReviewResourceContext } from "../types.js";
const mentionSchema = z.object({
@@ -51,32 +52,42 @@ export default defineAction({
) {
throw new Error("Review comment not found");
}
+ if (parent.status !== "open") {
+ throw new Error("Review thread is not open");
+ }
const mentions = normalizeReviewMentions([
...normalizeReviewMentions(args.mentions),
...extractReviewMentions(args.body),
]);
- return insertReviewComment({
- resourceType: args.resourceType,
- resourceId: args.resourceId,
- threadId: parent.threadId,
- parentCommentId: parent.id,
- targetId: parent.targetId,
- kind: parent.kind,
- anchor: parent.anchor,
- body: args.body,
- authorEmail: actionCtx?.userEmail ?? null,
- authorName: args.authorName ?? actionCtx?.userEmail ?? null,
- createdBy: actorKindFromContext(actionCtx),
- resolutionTarget:
- args.resolutionTarget ??
- (mentions.length > 0 ? "human" : parent.resolutionTarget),
- mentions,
- ownerEmail: access.ownerEmail ?? actionCtx?.userEmail ?? null,
- orgId: access.orgId ?? actionCtx?.orgId ?? null,
- visibility: normalizeReviewVisibility(access.visibility),
- metadata: args.metadata,
- });
+ const routeTarget =
+ args.resolutionTarget ?? (mentions.length > 0 ? "human" : null);
+ return insertReviewReply(
+ {
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ threadId: parent.threadId,
+ parentCommentId: parent.id,
+ targetId: parent.targetId,
+ kind: parent.kind,
+ anchor: parent.anchor,
+ body: args.body,
+ authorEmail: actionCtx?.userEmail ?? null,
+ authorName: args.authorName ?? reviewAuthorNameFromContext(actionCtx),
+ createdBy: actorKindFromContext(actionCtx),
+ resolutionTarget: null,
+ mentions,
+ ownerEmail: access.ownerEmail ?? actionCtx?.userEmail ?? null,
+ orgId: access.orgId ?? actionCtx?.orgId ?? null,
+ visibility: normalizeReviewVisibility(access.visibility),
+ metadata: args.metadata,
+ },
+ routeTarget,
+ {
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ },
+ );
},
audit: {
target: (args, result) => {
diff --git a/packages/core/src/review/actions/resolve-review-thread.ts b/packages/core/src/review/actions/resolve-review-thread.ts
index 656a5998dd..2f16935872 100644
--- a/packages/core/src/review/actions/resolve-review-thread.ts
+++ b/packages/core/src/review/actions/resolve-review-thread.ts
@@ -2,7 +2,11 @@ import { z } from "zod";
import { defineAction } from "../../action.js";
import { assertReviewableResourceAccess } from "../registry.js";
-import { getReviewCommentById, resolveReviewThread } from "../store.js";
+import {
+ getReviewCommentById,
+ getReviewThreadRoot,
+ resolveReviewThread,
+} from "../store.js";
import type { ReviewResourceContext } from "../types.js";
const schema = z.object({
@@ -10,6 +14,7 @@ const schema = z.object({
resourceId: z.string().min(1),
threadId: z.string().optional(),
commentId: z.string().optional(),
+ resolutionNote: z.string().trim().min(1).max(2_000).optional(),
});
export default defineAction({
@@ -52,11 +57,33 @@ export default defineAction({
resourceType: args.resourceType,
resourceId: args.resourceId,
},
+ args.resolutionNote,
);
if (updatedCount < 1) {
throw new Error("Review thread not found");
}
- return { threadId, resolved: true as const, updatedCount };
+ const comment = await getReviewThreadRoot(
+ threadId,
+ {
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ },
+ {
+ userEmail: actionCtx?.userEmail ?? null,
+ orgId: actionCtx?.orgId ?? null,
+ },
+ { bypassScope: true },
+ );
+ if (!comment) {
+ throw new Error("Resolved review thread could not be verified");
+ }
+ return {
+ threadId,
+ resolved: true as const,
+ updatedCount,
+ resolutionNote: comment.resolutionNote ?? null,
+ comment,
+ };
},
audit: {
target: (args) => ({
diff --git a/packages/core/src/review/actions/review-actions.spec.ts b/packages/core/src/review/actions/review-actions.spec.ts
new file mode 100644
index 0000000000..8cf8522938
--- /dev/null
+++ b/packages/core/src/review/actions/review-actions.spec.ts
@@ -0,0 +1,578 @@
+import Database from "better-sqlite3";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+let sqlite: Database.Database;
+
+const rawClient = {
+ execute: vi.fn(async (input: string | { sql: string; args?: unknown[] }) => {
+ if (typeof input === "string") {
+ sqlite.exec(input);
+ return { rows: [], rowsAffected: 0 };
+ }
+ const stmt = sqlite.prepare(input.sql);
+ const args = (input.args ?? []) as unknown[];
+ if (/^\s*(select|with)/i.test(input.sql)) {
+ return { rows: stmt.all(...args), rowsAffected: 0 };
+ }
+ const info = stmt.run(...args);
+ return { rows: [], rowsAffected: info.changes };
+ }),
+};
+
+vi.mock("../../db/client.js", () => ({
+ getDbExec: () => rawClient,
+ isPostgres: () => false,
+}));
+
+const createReviewCommentAction = (await import("./create-review-comment.js"))
+ .default;
+const getReviewFeedbackAction = (await import("./get-review-feedback.js"))
+ .default;
+const listReviewCommentsAction = (await import("./list-review-comments.js"))
+ .default;
+const replyReviewCommentAction = (await import("./reply-review-comment.js"))
+ .default;
+const resolveReviewThreadAction = (await import("./resolve-review-thread.js"))
+ .default;
+const sendReviewThreadToAgentAction = (
+ await import("./send-review-thread-to-agent.js")
+).default;
+const { __resetReviewableResourcesForTests, registerReviewableResource } =
+ await import("../registry.js");
+const {
+ __resetReviewInitForTests,
+ consumeReviewFeedback,
+ ensureReviewTables,
+ insertReviewComment,
+ insertReviewReply,
+ queryReviewComments,
+ upsertReviewStatus,
+} = await import("../store.js");
+
+const OWNER_EMAIL = "owner@example.com";
+const EDITOR_EMAIL = "editor@example.com";
+
+beforeEach(async () => {
+ sqlite = new Database(":memory:");
+ rawClient.execute.mockClear();
+ __resetReviewInitForTests();
+ __resetReviewableResourcesForTests();
+ registerReviewableResource({
+ type: "doc",
+ resolveAccess: (resourceId, ctx) => {
+ if (ctx?.userEmail === EDITOR_EMAIL) {
+ return {
+ role: "editor",
+ ownerEmail: OWNER_EMAIL,
+ orgId: resourceId === "org" ? "org-1" : null,
+ visibility:
+ resourceId === "public"
+ ? "public"
+ : resourceId === "org"
+ ? "org"
+ : "private",
+ };
+ }
+ if (resourceId === "public") {
+ return {
+ role: "viewer",
+ ownerEmail: OWNER_EMAIL,
+ orgId: "owner-org",
+ visibility: "public",
+ };
+ }
+ if (resourceId === "org" && ctx?.orgId === "org-1") {
+ return {
+ role: "viewer",
+ ownerEmail: OWNER_EMAIL,
+ orgId: "org-1",
+ visibility: "org",
+ };
+ }
+ return null;
+ },
+ });
+ await ensureReviewTables();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ __resetReviewableResourcesForTests();
+ sqlite.close();
+ vi.clearAllMocks();
+});
+
+describe("review actions", () => {
+ it("allows anonymous public reads and redacts ownership and identity metadata", async () => {
+ expect(listReviewCommentsAction.requiresAuth).toBe(false);
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "public",
+ body: "Public feedback",
+ authorEmail: OWNER_EMAIL,
+ authorName: "Alice Reviewer",
+ mentions: [
+ {
+ label: "Bob Reviewer",
+ email: "bob@example.com",
+ id: "user-bob",
+ },
+ ],
+ ownerEmail: OWNER_EMAIL,
+ orgId: "owner-org",
+ visibility: "public",
+ metadata: {
+ ownerEmail: OWNER_EMAIL,
+ orgId: "owner-org",
+ nested: {
+ email: "bob@example.com",
+ assignee: "bob@example.com",
+ preserve: "visible",
+ },
+ },
+ });
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "public",
+ body: "Legacy email display name",
+ authorEmail: OWNER_EMAIL,
+ authorName: OWNER_EMAIL,
+ ownerEmail: OWNER_EMAIL,
+ orgId: "owner-org",
+ visibility: "public",
+ });
+ await upsertReviewStatus({
+ resourceType: "doc",
+ resourceId: "public",
+ status: "in_review",
+ updatedBy: OWNER_EMAIL,
+ ownerEmail: OWNER_EMAIL,
+ orgId: "owner-org",
+ visibility: "public",
+ metadata: { updatedBy: OWNER_EMAIL, preserve: "status metadata" },
+ });
+
+ const result = await listReviewCommentsAction.run({
+ resourceType: "doc",
+ resourceId: "public",
+ });
+
+ expect(result.comments).toHaveLength(2);
+ expect(result.comments[0]).toMatchObject({
+ authorEmail: null,
+ authorName: "Alice Reviewer",
+ ownerEmail: null,
+ orgId: null,
+ resolvedBy: null,
+ deletedBy: null,
+ mentions: [{ label: "Bob Reviewer" }],
+ metadata: { nested: { assignee: null, preserve: "visible" } },
+ });
+ expect(result.comments[0].mentions[0]).not.toHaveProperty("email");
+ expect(result.comments[0].mentions[0]).not.toHaveProperty("id");
+ expect(result.comments[1].authorName).toBeNull();
+ expect(result.reviewStatus).toMatchObject({
+ updatedBy: null,
+ ownerEmail: null,
+ orgId: null,
+ metadata: { preserve: "status metadata" },
+ });
+
+ const signedPublicResult = await listReviewCommentsAction.run(
+ { resourceType: "doc", resourceId: "public" },
+ { userEmail: "public-viewer@example.com", caller: "frontend" },
+ );
+ expect(signedPublicResult.comments[0]).toMatchObject({
+ authorEmail: null,
+ ownerEmail: null,
+ orgId: null,
+ canDelete: false,
+ });
+
+ const ownPublicComment = await createReviewCommentAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "public",
+ body: "My public feedback",
+ },
+ {
+ userEmail: "public-viewer@example.com",
+ userName: "Public Reviewer",
+ caller: "frontend",
+ },
+ );
+ const ownPublicResult = await listReviewCommentsAction.run(
+ { resourceType: "doc", resourceId: "public" },
+ { userEmail: "public-viewer@example.com", caller: "frontend" },
+ );
+ expect(
+ ownPublicResult.comments.find(
+ (comment) => comment.id === ownPublicComment.id,
+ ),
+ ).toMatchObject({
+ authorEmail: null,
+ authorName: "Public Reviewer",
+ canDelete: true,
+ });
+ });
+
+ it("denies anonymous private reads while allowing signed-in editors and org members", async () => {
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Private feedback",
+ authorEmail: OWNER_EMAIL,
+ authorName: "Owner",
+ ownerEmail: OWNER_EMAIL,
+ });
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "org",
+ body: "Org feedback",
+ authorEmail: OWNER_EMAIL,
+ authorName: "Owner",
+ ownerEmail: OWNER_EMAIL,
+ orgId: "org-1",
+ visibility: "org",
+ });
+
+ await expect(
+ listReviewCommentsAction.run({
+ resourceType: "doc",
+ resourceId: "private",
+ }),
+ ).rejects.toThrow(/Not allowed/);
+
+ const editorResult = await listReviewCommentsAction.run(
+ { resourceType: "doc", resourceId: "private" },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+ expect(editorResult.comments[0]).toMatchObject({
+ authorEmail: OWNER_EMAIL,
+ ownerEmail: OWNER_EMAIL,
+ });
+
+ const memberResult = await listReviewCommentsAction.run(
+ { resourceType: "doc", resourceId: "org" },
+ {
+ userEmail: "member@example.com",
+ orgId: "org-1",
+ caller: "frontend",
+ },
+ );
+ expect(memberResult.comments[0].body).toBe("Org feedback");
+ });
+
+ it("returns untruncated root-thread counts alongside a bounded comment page", async () => {
+ for (let index = 0; index < 500; index += 1) {
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: `Human feedback ${index}`,
+ resolutionTarget: "human",
+ ownerEmail: OWNER_EMAIL,
+ });
+ }
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Queued feedback beyond the first page",
+ resolutionTarget: "agent",
+ ownerEmail: OWNER_EMAIL,
+ });
+
+ const result = await listReviewCommentsAction.run(
+ { resourceType: "doc", resourceId: "private", limit: 1 },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+
+ expect(result.comments).toHaveLength(1);
+ expect(result.summary).toEqual({
+ openCount: 501,
+ agentQueueCount: 1,
+ });
+ });
+
+ it("routes an agent thread to a human and back to the agent at the root only", async () => {
+ const root = await createReviewCommentAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Please update this",
+ resolutionTarget: "agent",
+ },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+ expect(root.authorName).toBeNull();
+ await consumeReviewFeedback([root.id], "2026-07-10T00:00:00.000Z", {
+ resourceType: "doc",
+ resourceId: "private",
+ });
+
+ const humanReply = await replyReviewCommentAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ commentId: root.id,
+ body: "A human should decide this",
+ resolutionTarget: "human",
+ },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+ expect(humanReply.authorName).toBeNull();
+ expect(humanReply.resolutionTarget).toBeNull();
+
+ const humanQueue = await getReviewFeedbackAction.run(
+ { resourceType: "doc", resourceId: "private" },
+ { userEmail: EDITOR_EMAIL, caller: "tool" },
+ );
+ expect(humanQueue.comments).toEqual([]);
+
+ const routed = await sendReviewThreadToAgentAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: root.threadId,
+ },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+ expect(routed).toMatchObject({
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: root.threadId,
+ resolutionTarget: "agent",
+ consumedAt: null,
+ updatedCount: 1,
+ ownerEmail: OWNER_EMAIL,
+ visibility: "private",
+ });
+
+ const comments = await queryReviewComments({
+ resourceType: "doc",
+ resourceId: "private",
+ scope: { userEmail: OWNER_EMAIL },
+ });
+ const persistedRoot = comments.find((comment) => comment.id === root.id);
+ expect(persistedRoot).toMatchObject({
+ resolutionTarget: "agent",
+ consumedAt: null,
+ });
+ expect(
+ comments.find((comment) => comment.id === humanReply.id)
+ ?.resolutionTarget,
+ ).toBeNull();
+
+ const neutralReply = await replyReviewCommentAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ commentId: humanReply.id,
+ body: "Thanks for the update",
+ },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+ expect(neutralReply.resolutionTarget).toBeNull();
+
+ const agentQueue = await getReviewFeedbackAction.run(
+ { resourceType: "doc", resourceId: "private" },
+ { userEmail: EDITOR_EMAIL, caller: "tool" },
+ );
+ expect(agentQueue.comments.map((comment) => comment.id)).toEqual([root.id]);
+
+ const auditTarget = sendReviewThreadToAgentAction.audit?.target?.(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: root.threadId,
+ },
+ routed,
+ {
+ status: "success",
+ caller: "frontend",
+ userEmail: EDITOR_EMAIL,
+ orgId: null,
+ },
+ );
+ expect(auditTarget).toEqual({
+ type: "doc",
+ id: "private",
+ ownerEmail: OWNER_EMAIL,
+ orgId: null,
+ visibility: "private",
+ });
+ });
+
+ it("removes a reply when non-transactional root routing fails", async () => {
+ await expect(
+ insertReviewReply(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: "missing-thread",
+ parentCommentId: "missing-comment",
+ body: "This reply must not become an orphan",
+ ownerEmail: OWNER_EMAIL,
+ },
+ "human",
+ { resourceType: "doc", resourceId: "private" },
+ ),
+ ).rejects.toThrow("Open review thread not found");
+
+ const comments = await queryReviewComments({
+ resourceType: "doc",
+ resourceId: "private",
+ scope: { userEmail: OWNER_EMAIL },
+ });
+ expect(comments).toEqual([]);
+ });
+
+ it("filters the distinct root queue before limit and returns oldest first", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-01T00:00:00.000Z"));
+ for (let index = 0; index < 6; index += 1) {
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: `Human-only ${index}`,
+ resolutionTarget: "human",
+ ownerEmail: OWNER_EMAIL,
+ });
+ vi.advanceTimersByTime(1_000);
+ }
+ const consumed = await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Already consumed",
+ resolutionTarget: "agent",
+ ownerEmail: OWNER_EMAIL,
+ });
+ await consumeReviewFeedback([consumed.id], "2026-07-01T00:00:06.500Z", {
+ resourceType: "doc",
+ resourceId: "private",
+ });
+ vi.advanceTimersByTime(1_000);
+ const oldest = await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Oldest queued root",
+ resolutionTarget: "agent",
+ ownerEmail: OWNER_EMAIL,
+ });
+ vi.advanceTimersByTime(1_000);
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: oldest.threadId,
+ body: "Duplicate malformed root",
+ resolutionTarget: "agent",
+ ownerEmail: OWNER_EMAIL,
+ });
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: oldest.threadId,
+ parentCommentId: oldest.id,
+ body: "Legacy agent-targeted reply",
+ resolutionTarget: "agent",
+ ownerEmail: OWNER_EMAIL,
+ });
+ vi.advanceTimersByTime(1_000);
+ const newer = await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Newer queued root",
+ resolutionTarget: null,
+ ownerEmail: OWNER_EMAIL,
+ });
+
+ const result = await getReviewFeedbackAction.run(
+ { resourceType: "doc", resourceId: "private", limit: 2 },
+ { userEmail: EDITOR_EMAIL, caller: "tool" },
+ );
+
+ expect(result.comments.map((comment) => comment.id)).toEqual([
+ oldest.id,
+ newer.id,
+ ]);
+ expect(
+ new Set(result.comments.map((comment) => comment.threadId)).size,
+ ).toBe(2);
+ expect(result.comments.every((comment) => !comment.parentCommentId)).toBe(
+ true,
+ );
+ });
+
+ it("persists a bounded resolution note on root metadata and returns it", async () => {
+ const root = await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ body: "Clarify this section",
+ resolutionTarget: "agent",
+ ownerEmail: OWNER_EMAIL,
+ metadata: { severity: "medium" },
+ });
+ const reply = await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: root.threadId,
+ parentCommentId: root.id,
+ body: "Additional context",
+ ownerEmail: OWNER_EMAIL,
+ metadata: { source: "reviewer" },
+ });
+
+ const result = await resolveReviewThreadAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: root.threadId,
+ resolutionNote: "Updated the section and verified the example.",
+ },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ );
+
+ expect(result).toMatchObject({
+ resolved: true,
+ updatedCount: 2,
+ resolutionNote: "Updated the section and verified the example.",
+ comment: {
+ id: root.id,
+ resolutionNote: "Updated the section and verified the example.",
+ metadata: {
+ severity: "medium",
+ resolutionNote: "Updated the section and verified the example.",
+ },
+ },
+ });
+
+ const persisted = await queryReviewComments({
+ resourceType: "doc",
+ resourceId: "private",
+ scope: { userEmail: OWNER_EMAIL },
+ includeResolved: true,
+ });
+ expect(persisted.find((comment) => comment.id === root.id)).toMatchObject({
+ resolutionNote: "Updated the section and verified the example.",
+ metadata: {
+ severity: "medium",
+ resolutionNote: "Updated the section and verified the example.",
+ },
+ });
+ expect(persisted.find((comment) => comment.id === reply.id)).toMatchObject({
+ resolutionNote: null,
+ metadata: { source: "reviewer" },
+ });
+
+ await expect(
+ resolveReviewThreadAction.run(
+ {
+ resourceType: "doc",
+ resourceId: "private",
+ threadId: root.threadId,
+ resolutionNote: "x".repeat(2_001),
+ },
+ { userEmail: EDITOR_EMAIL, caller: "frontend" },
+ ),
+ ).rejects.toThrow();
+ });
+});
diff --git a/packages/core/src/review/actions/send-review-thread-to-agent.ts b/packages/core/src/review/actions/send-review-thread-to-agent.ts
new file mode 100644
index 0000000000..9f0bc754a4
--- /dev/null
+++ b/packages/core/src/review/actions/send-review-thread-to-agent.ts
@@ -0,0 +1,62 @@
+import { z } from "zod";
+
+import { defineAction } from "../../action.js";
+import { assertReviewableResourceAccess } from "../registry.js";
+import { sendReviewThreadToAgent } from "../store.js";
+import type { ReviewResourceContext } from "../types.js";
+
+const schema = z.object({
+ resourceType: z.string().min(1),
+ resourceId: z.string().min(1),
+ threadId: z.string().min(1),
+});
+
+export default defineAction({
+ description:
+ "Send one open review thread to the agent queue without routing other threads.",
+ schema,
+ run: async (args, ctx) => {
+ const actionCtx = ctx as ReviewResourceContext | undefined;
+ const access = await assertReviewableResourceAccess(
+ args.resourceType,
+ args.resourceId,
+ actionCtx,
+ "editor",
+ );
+ const updatedCount = await sendReviewThreadToAgent(args.threadId, {
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ });
+ if (updatedCount < 1) {
+ throw new Error("Open review thread not found");
+ }
+ return {
+ resourceType: args.resourceType,
+ resourceId: args.resourceId,
+ threadId: args.threadId,
+ resolutionTarget: "agent" as const,
+ consumedAt: null,
+ updatedCount,
+ ownerEmail: access.ownerEmail ?? actionCtx?.userEmail ?? null,
+ orgId: access.orgId ?? actionCtx?.orgId ?? null,
+ visibility: access.visibility ?? "private",
+ };
+ },
+ audit: {
+ target: (args, result) => {
+ const routed = result as {
+ ownerEmail?: string | null;
+ orgId?: string | null;
+ visibility?: "private" | "org" | "public";
+ };
+ return {
+ type: args.resourceType,
+ id: args.resourceId,
+ ownerEmail: routed.ownerEmail,
+ orgId: routed.orgId,
+ visibility: routed.visibility,
+ };
+ },
+ summary: (args) => `Sent review thread ${args.threadId} to the agent`,
+ },
+});
diff --git a/packages/core/src/review/identity.ts b/packages/core/src/review/identity.ts
new file mode 100644
index 0000000000..7451ad9ca6
--- /dev/null
+++ b/packages/core/src/review/identity.ts
@@ -0,0 +1,101 @@
+import type {
+ ReviewComment,
+ ReviewResourceAccess,
+ ReviewResourceContext,
+ ReviewStatusEntry,
+} from "./types.js";
+
+export function reviewAuthorNameFromContext(
+ ctx: ReviewResourceContext | undefined,
+): string | null {
+ const value = ctx?.userName;
+ if (typeof value !== "string") return null;
+ const name = value.trim();
+ if (!name || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(name)) return null;
+ return name;
+}
+
+export function shouldRedactReviewIdentity(
+ ctx: ReviewResourceContext | undefined,
+ access: Pick,
+): boolean {
+ return (
+ !ctx?.userEmail ||
+ (access.visibility === "public" && access.role === "viewer")
+ );
+}
+
+export function redactPublicReviewCommentIdentity(
+ comment: ReviewComment,
+): ReviewComment {
+ return {
+ ...comment,
+ authorEmail: null,
+ authorName: safeReviewDisplayName(comment.authorName),
+ mentions: comment.mentions.map((mention) => ({
+ label: safeReviewDisplayName(mention.label) ?? "Mentioned user",
+ })),
+ ownerEmail: null,
+ orgId: null,
+ resolvedBy: null,
+ deletedBy: null,
+ metadata: redactPublicReviewMetadata(comment.metadata),
+ };
+}
+
+export function redactPublicReviewStatusIdentity(
+ status: ReviewStatusEntry | null,
+): ReviewStatusEntry | null {
+ return status
+ ? {
+ ...status,
+ updatedBy: null,
+ ownerEmail: null,
+ orgId: null,
+ metadata: redactPublicReviewMetadata(status.metadata),
+ }
+ : null;
+}
+
+function safeReviewDisplayName(
+ value: string | null | undefined,
+): string | null {
+ const name = value?.trim();
+ if (!name || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(name)) {
+ return null;
+ }
+ return name;
+}
+
+function redactPublicReviewMetadata(
+ metadata: Record | null,
+): Record | null {
+ if (!metadata) {
+ return null;
+ }
+ return Object.fromEntries(
+ Object.entries(metadata).flatMap(([key, value]) =>
+ /^(authorEmail|ownerEmail|orgId|updatedBy|resolvedBy|deletedBy|email|userEmail|userId)$/i.test(
+ key,
+ )
+ ? []
+ : [[key, redactPublicReviewMetadataValue(value)]],
+ ),
+ );
+}
+
+function redactPublicReviewMetadataValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(redactPublicReviewMetadataValue);
+ }
+ if (typeof value === "object" && value !== null) {
+ return redactPublicReviewMetadata(value as Record);
+ }
+ if (
+ typeof value === "string" &&
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
+ ) {
+ return null;
+ }
+ return value;
+}
diff --git a/packages/core/src/review/index.ts b/packages/core/src/review/index.ts
index 19115fc124..6b2a0e8ed6 100644
--- a/packages/core/src/review/index.ts
+++ b/packages/core/src/review/index.ts
@@ -13,6 +13,12 @@ export type {
ReviewStatusEntry,
ReviewableResourceRegistration,
} from "./types.js";
+export {
+ redactPublicReviewCommentIdentity,
+ redactPublicReviewStatusIdentity,
+ reviewAuthorNameFromContext,
+ shouldRedactReviewIdentity,
+} from "./identity.js";
export { extractReviewMentions, normalizeReviewMentions } from "./mentions.js";
export {
__resetReviewableResourcesForTests,
@@ -27,5 +33,13 @@ export {
ensureReviewTables,
getReviewCommentById,
getReviewStatus,
+ getReviewThreadSummary,
+ getReviewThreadRoot,
queryReviewComments,
+ routeReviewThread,
+ sendReviewThreadToAgent,
+} from "./store.js";
+export type {
+ GetReviewThreadSummaryInput,
+ ReviewThreadSummary,
} from "./store.js";
diff --git a/packages/core/src/review/store.spec.ts b/packages/core/src/review/store.spec.ts
index 36edb322ea..fdaa9df22c 100644
--- a/packages/core/src/review/store.spec.ts
+++ b/packages/core/src/review/store.spec.ts
@@ -32,6 +32,7 @@ const {
insertReviewComment,
queryReviewComments,
resolveReviewThread,
+ sendReviewThreadToAgent,
upsertReviewStatus,
} = await import("./store.js");
@@ -155,6 +156,45 @@ describe("review store", () => {
expect(comments[0].consumedAt).toBe("2026-07-07T00:00:00.000Z");
});
+ it("routes only the selected open thread to the agent", async () => {
+ const selected = await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "d1",
+ body: "Shorten this heading",
+ resolutionTarget: "human",
+ ownerEmail: "alice@example.com",
+ });
+ await insertReviewComment({
+ resourceType: "doc",
+ resourceId: "d1",
+ body: "Keep this as a human decision",
+ resolutionTarget: "human",
+ ownerEmail: "alice@example.com",
+ });
+
+ await expect(
+ sendReviewThreadToAgent(selected.threadId, {
+ resourceType: "doc",
+ resourceId: "d1",
+ }),
+ ).resolves.toBe(1);
+
+ const comments = await queryReviewComments({
+ resourceType: "doc",
+ resourceId: "d1",
+ scope: { userEmail: "alice@example.com" },
+ });
+ expect(comments).toHaveLength(2);
+ expect(
+ comments.find((comment) => comment.threadId === selected.threadId)
+ ?.resolutionTarget,
+ ).toBe("agent");
+ expect(
+ comments.find((comment) => comment.threadId !== selected.threadId)
+ ?.resolutionTarget,
+ ).toBe("human");
+ });
+
it("returns zero when consuming unmatched comment ids", async () => {
await expect(
consumeReviewFeedback(["missing"], "2026-07-07T00:00:00.000Z", {
diff --git a/packages/core/src/review/store.ts b/packages/core/src/review/store.ts
index 73b6e4eb10..c744061039 100644
--- a/packages/core/src/review/store.ts
+++ b/packages/core/src/review/store.ts
@@ -1,4 +1,4 @@
-import { getDbExec, isPostgres } from "../db/client.js";
+import { getDbExec, isPostgres, type DbExec } from "../db/client.js";
import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
import type { Visibility } from "../sharing/schema.js";
import type {
@@ -43,9 +43,25 @@ export interface QueryReviewCommentsInput {
includeResolved?: boolean;
includeDeleted?: boolean;
targetId?: string | null;
+ rootOnly?: boolean;
+ resolutionTargets?: readonly (ReviewResolutionTarget | null)[];
+ unconsumedOnly?: boolean;
limit?: number;
}
+export interface GetReviewThreadSummaryInput {
+ resourceType: string;
+ resourceId: string;
+ scope: ReviewScope;
+ bypassScope?: boolean;
+ targetId?: string | null;
+}
+
+export interface ReviewThreadSummary {
+ openCount: number;
+ agentQueueCount: number;
+}
+
export interface UpsertReviewStatusInput {
resourceType: string;
resourceId: string;
@@ -112,6 +128,15 @@ export async function ensureReviewTables(): Promise {
ON agent_review_comments (owner_email, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_agent_review_comments_org
ON agent_review_comments (org_id, created_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_agent_review_comments_queue
+ ON agent_review_comments (
+ resource_type,
+ resource_id,
+ parent_comment_id,
+ resolution_target,
+ consumed_at,
+ created_at
+ )`,
`CREATE INDEX IF NOT EXISTS idx_agent_review_statuses_resource
ON agent_review_statuses (resource_type, resource_id)`,
];
@@ -126,9 +151,10 @@ export async function ensureReviewTables(): Promise {
await ensureIndexExists("idx_agent_review_comments_thread", indexes[1]);
await ensureIndexExists("idx_agent_review_comments_owner", indexes[2]);
await ensureIndexExists("idx_agent_review_comments_org", indexes[3]);
+ await ensureIndexExists("idx_agent_review_comments_queue", indexes[4]);
await ensureIndexExists(
"idx_agent_review_statuses_resource",
- indexes[4],
+ indexes[5],
);
} else {
await client.execute(createCommentsSql);
@@ -145,9 +171,62 @@ export async function ensureReviewTables(): Promise {
export async function insertReviewComment(
input: InsertReviewCommentInput,
+): Promise {
+ await ensureReviewTables();
+ return insertReviewCommentWithClient(input, getDbExec());
+}
+
+export async function insertReviewReply(
+ input: InsertReviewCommentInput & {
+ threadId: string;
+ parentCommentId: string;
+ },
+ routeTarget: ReviewResolutionTarget | null,
+ resource: { resourceType: string; resourceId: string },
): Promise {
await ensureReviewTables();
const client = getDbExec();
+ const insertAndRoute = async (tx: DbExec) => {
+ if (routeTarget) {
+ const routedCount = await routeReviewThreadWithClient(
+ tx,
+ input.threadId,
+ routeTarget,
+ resource,
+ );
+ if (routedCount < 1) {
+ throw new Error("Open review thread not found");
+ }
+ }
+ return insertReviewCommentWithClient(input, tx);
+ };
+ if (client.transaction) return client.transaction(insertAndRoute);
+
+ const reply = await insertReviewCommentWithClient(input, client);
+ if (!routeTarget) return reply;
+ try {
+ const routedCount = await routeReviewThreadWithClient(
+ client,
+ input.threadId,
+ routeTarget,
+ resource,
+ );
+ if (routedCount < 1) throw new Error("Open review thread not found");
+ return reply;
+ } catch (error) {
+ await client.execute({
+ sql: `DELETE FROM agent_review_comments
+ WHERE id = ? AND resource_type = ? AND resource_id = ?`,
+ args: [reply.id, resource.resourceType, resource.resourceId],
+ });
+ throw error;
+ }
+}
+
+async function insertReviewCommentWithClient(
+ input: InsertReviewCommentInput,
+ client: DbExec,
+): Promise {
const id = createReviewId("comment");
const now = new Date().toISOString();
const comment: ReviewComment = {
@@ -180,6 +259,10 @@ export async function insertReviewComment(
createdAt: now,
updatedAt: now,
metadata: input.metadata ?? null,
+ resolutionNote:
+ typeof input.metadata?.resolutionNote === "string"
+ ? input.metadata.resolutionNote
+ : null,
};
await client.execute({
@@ -273,18 +356,107 @@ export async function queryReviewComments(
filterParams.push(input.targetId);
}
}
+ if (input.rootOnly) {
+ filters.push("parent_comment_id IS NULL");
+ }
+ if (input.unconsumedOnly) {
+ filters.push("consumed_at IS NULL");
+ }
+ if (input.resolutionTargets !== undefined) {
+ const targets = Array.from(
+ new Set(
+ input.resolutionTargets.filter(
+ (target): target is ReviewResolutionTarget => target !== null,
+ ),
+ ),
+ );
+ const resolutionFilters: string[] = [];
+ if (targets.length > 0) {
+ resolutionFilters.push(
+ `resolution_target IN (${targets.map(() => "?").join(", ")})`,
+ );
+ filterParams.push(...targets);
+ }
+ if (input.resolutionTargets.includes(null)) {
+ resolutionFilters.push("resolution_target IS NULL");
+ }
+ filters.push(
+ resolutionFilters.length > 0
+ ? `(${resolutionFilters.join(" OR ")})`
+ : "1 = 0",
+ );
+ }
+ const selectSql = input.rootOnly
+ ? `SELECT ${commentColumns()}
+ FROM (
+ SELECT ${commentColumns()},
+ ROW_NUMBER() OVER (
+ PARTITION BY thread_id
+ ORDER BY created_at ASC, id ASC
+ ) AS review_thread_rank
+ FROM agent_review_comments
+ WHERE ${filters.join(" AND ")}
+ ) AS distinct_review_threads
+ WHERE review_thread_rank = 1`
+ : `SELECT ${commentColumns()}
+ FROM agent_review_comments
+ WHERE ${filters.join(" AND ")}`;
const result = await client.execute({
- sql: `SELECT ${commentColumns()}
- FROM agent_review_comments
- WHERE ${filters.join(" AND ")}
- ORDER BY created_at ASC
+ sql: `${selectSql}
+ ORDER BY created_at ASC${input.rootOnly ? ", id ASC" : ""}
LIMIT ?`,
args: [...filterParams, clampLimit(input.limit)],
});
return (result.rows ?? []).map(mapCommentRow);
}
+export async function getReviewThreadSummary(
+ input: GetReviewThreadSummaryInput,
+): Promise {
+ await ensureReviewTables();
+ const { clause, params } = input.bypassScope
+ ? { clause: "1 = 1", params: [] as unknown[] }
+ : scopedReviewClause(input.scope);
+ const filters = [
+ "resource_type = ?",
+ "resource_id = ?",
+ clause,
+ "parent_comment_id IS NULL",
+ "deleted_at IS NULL",
+ "status = 'open'",
+ ];
+ const filterParams: unknown[] = [
+ input.resourceType,
+ input.resourceId,
+ ...params,
+ ];
+ if (input.targetId !== undefined) {
+ if (input.targetId === null) {
+ filters.push("target_id IS NULL");
+ } else {
+ filters.push("target_id = ?");
+ filterParams.push(input.targetId);
+ }
+ }
+ const result = await getDbExec().execute({
+ sql: `SELECT COUNT(DISTINCT thread_id) AS open_count,
+ COUNT(DISTINCT CASE
+ WHEN (resolution_target IS NULL OR resolution_target <> 'human')
+ AND consumed_at IS NULL
+ THEN thread_id
+ END) AS agent_queue_count
+ FROM agent_review_comments
+ WHERE ${filters.join(" AND ")}`,
+ args: filterParams,
+ });
+ const row = result.rows?.[0];
+ return {
+ openCount: Number(row?.open_count ?? 0),
+ agentQueueCount: Number(row?.agent_queue_count ?? 0),
+ };
+}
+
export async function getReviewCommentById(
id: string,
scope: ReviewScope,
@@ -306,27 +478,106 @@ export async function getReviewCommentById(
return row ? mapCommentRow(row) : null;
}
+export async function getReviewThreadRoot(
+ threadId: string,
+ resource: { resourceType: string; resourceId: string },
+ scope: ReviewScope,
+ options: { bypassScope?: boolean } = {},
+): Promise {
+ await ensureReviewTables();
+ const { clause, params } = options.bypassScope
+ ? { clause: "1 = 1", params: [] as unknown[] }
+ : scopedReviewClause(scope);
+ const result = await getDbExec().execute({
+ sql: `SELECT ${commentColumns()}
+ FROM agent_review_comments
+ WHERE thread_id = ?
+ AND resource_type = ?
+ AND resource_id = ?
+ AND parent_comment_id IS NULL
+ AND ${clause}
+ ORDER BY created_at ASC, id ASC
+ LIMIT 1`,
+ args: [threadId, resource.resourceType, resource.resourceId, ...params],
+ });
+ const row = result.rows?.[0];
+ return row ? mapCommentRow(row) : null;
+}
+
export async function resolveReviewThread(
threadId: string,
resolvedBy?: string | null,
resource?: { resourceType: string; resourceId: string },
+ resolutionNote?: string,
): Promise {
await ensureReviewTables();
+ const client = getDbExec();
+ const resolve = (tx: DbExec) =>
+ resolveReviewThreadWithClient(
+ tx,
+ threadId,
+ resolvedBy,
+ resource,
+ resolutionNote,
+ );
+ return client.transaction ? client.transaction(resolve) : resolve(client);
+}
+
+async function resolveReviewThreadWithClient(
+ client: DbExec,
+ threadId: string,
+ resolvedBy?: string | null,
+ resource?: { resourceType: string; resourceId: string },
+ resolutionNote?: string,
+): Promise {
const now = new Date().toISOString();
const resourceClause = resource
? "AND resource_type = ? AND resource_id = ?"
: "";
- const result = await getDbExec().execute({
+ let rootMetadata: Record | null = null;
+ if (resolutionNote !== undefined) {
+ const root = await client.execute({
+ sql: `SELECT metadata_json
+ FROM agent_review_comments
+ WHERE thread_id = ?
+ AND parent_comment_id IS NULL
+ AND deleted_at IS NULL
+ ${resourceClause}
+ ORDER BY created_at ASC, id ASC
+ LIMIT 1`,
+ args: [
+ threadId,
+ ...(resource ? [resource.resourceType, resource.resourceId] : []),
+ ],
+ });
+ if (!root.rows?.[0]) {
+ return 0;
+ }
+ rootMetadata = {
+ ...(parseObject(root.rows[0].metadata_json) ?? {}),
+ resolutionNote,
+ };
+ }
+
+ const metadataAssignment =
+ resolutionNote === undefined
+ ? ""
+ : ", metadata_json = CASE WHEN parent_comment_id IS NULL THEN ? ELSE metadata_json END";
+ const result = await client.execute({
sql: `UPDATE agent_review_comments
SET status = 'resolved',
resolved_by = ?,
resolved_at = ?,
updated_at = ?
+ ${metadataAssignment}
WHERE thread_id = ? AND deleted_at IS NULL ${resourceClause}`,
args: [
resolvedBy ?? null,
now,
now,
+ ...(resolutionNote === undefined
+ ? []
+ : [stringifyOptionalJson(rootMetadata)]),
threadId,
...(resource ? [resource.resourceType, resource.resourceId] : []),
],
@@ -334,6 +585,57 @@ export async function resolveReviewThread(
return result.rowsAffected ?? 0;
}
+export async function routeReviewThread(
+ threadId: string,
+ resolutionTarget: ReviewResolutionTarget,
+ resource: { resourceType: string; resourceId: string },
+): Promise {
+ await ensureReviewTables();
+ return routeReviewThreadWithClient(
+ getDbExec(),
+ threadId,
+ resolutionTarget,
+ resource,
+ );
+}
+
+async function routeReviewThreadWithClient(
+ client: DbExec,
+ threadId: string,
+ resolutionTarget: ReviewResolutionTarget,
+ resource: { resourceType: string; resourceId: string },
+): Promise {
+ const now = new Date().toISOString();
+ const result = await client.execute({
+ sql: `UPDATE agent_review_comments
+ SET resolution_target = ?,
+ consumed_at = CASE WHEN ? = 'agent' THEN NULL ELSE consumed_at END,
+ updated_at = ?
+ WHERE thread_id = ?
+ AND resource_type = ?
+ AND resource_id = ?
+ AND parent_comment_id IS NULL
+ AND status = 'open'
+ AND deleted_at IS NULL`,
+ args: [
+ resolutionTarget,
+ resolutionTarget,
+ now,
+ threadId,
+ resource.resourceType,
+ resource.resourceId,
+ ],
+ });
+ return result.rowsAffected ?? 0;
+}
+
+export async function sendReviewThreadToAgent(
+ threadId: string,
+ resource: { resourceType: string; resourceId: string },
+): Promise {
+ return routeReviewThread(threadId, "agent", resource);
+}
+
export async function deleteReviewComment(
id: string,
deletedBy?: string | null,
@@ -560,6 +862,7 @@ function scopedReviewClause(scope: ReviewScope): {
}
function mapCommentRow(row: Record): ReviewComment {
+ const metadata = parseObject(row.metadata_json);
return {
id: String(row.id),
resourceType: String(row.resource_type),
@@ -586,7 +889,11 @@ function mapCommentRow(row: Record): ReviewComment {
deletedAt: nullableString(row.deleted_at),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
- metadata: parseObject(row.metadata_json),
+ metadata,
+ resolutionNote:
+ typeof metadata?.resolutionNote === "string"
+ ? metadata.resolutionNote
+ : null,
};
}
diff --git a/packages/core/src/review/types.ts b/packages/core/src/review/types.ts
index 835ff3421b..39358d03a0 100644
--- a/packages/core/src/review/types.ts
+++ b/packages/core/src/review/types.ts
@@ -75,6 +75,10 @@ export interface ReviewComment {
createdAt: string;
updatedAt: string;
metadata: Record | null;
+ /** Persisted on the root comment's metadata when a thread is resolved. */
+ resolutionNote?: string | null;
+ /** Caller-specific capability computed by list-review-comments. */
+ canDelete?: boolean;
}
export interface ReviewStatusEntry {
diff --git a/packages/core/src/server/action-discovery.spec.ts b/packages/core/src/server/action-discovery.spec.ts
index 2e0a7beb40..527b2cfb28 100644
--- a/packages/core/src/server/action-discovery.spec.ts
+++ b/packages/core/src/server/action-discovery.spec.ts
@@ -374,6 +374,7 @@ describe("action discovery", () => {
"consume-review-feedback",
"get-review-feedback",
"set-review-status",
+ "send-review-thread-to-agent",
]) {
expect(registry[name], `${name} should be merged`).toBeDefined();
}
diff --git a/packages/core/src/server/action-discovery.ts b/packages/core/src/server/action-discovery.ts
index 5da948647e..3ae898feb2 100644
--- a/packages/core/src/server/action-discovery.ts
+++ b/packages/core/src/server/action-discovery.ts
@@ -686,6 +686,10 @@ export async function mergeCoreSharingActions(
"set-review-status",
() => import("../review/actions/set-review-status.js"),
],
+ [
+ "send-review-thread-to-agent",
+ () => import("../review/actions/send-review-thread-to-agent.js"),
+ ],
// Org service tokens (CI credentials, e.g. PLAN_RECAP_TOKEN). Mint/revoke
// are toolCallable:false — preserved via preserveActionFlags below.
[
diff --git a/packages/core/src/vite/action-types-plugin.ts b/packages/core/src/vite/action-types-plugin.ts
index 854978ffba..317673a370 100644
--- a/packages/core/src/vite/action-types-plugin.ts
+++ b/packages/core/src/vite/action-types-plugin.ts
@@ -141,6 +141,10 @@ const CORE_SHARING_ACTIONS: Array<{ name: string; specifier: string }> = [
name: "set-review-status",
specifier: "@agent-native/core/review/actions/set-review-status",
},
+ {
+ name: "send-review-thread-to-agent",
+ specifier: "@agent-native/core/review/actions/send-review-thread-to-agent",
+ },
];
function isRuntimeSourceFile(filename: string): boolean {
diff --git a/templates/design/.agents/skills/design-generation/SKILL.md b/templates/design/.agents/skills/design-generation/SKILL.md
index 87128d5f3f..bdb8e2ad25 100644
--- a/templates/design/.agents/skills/design-generation/SKILL.md
+++ b/templates/design/.agents/skills/design-generation/SKILL.md
@@ -661,6 +661,9 @@ regeneration is slow, expensive, and regresses unrelated parts.
1. **Read before you edit.** Pull the current file with `get-design-snapshot`
(or `get-design`) so you edit the live content, not a stale memory of it.
+ If the design has persisted review comments, fetch the open queue with
+ `get-review-feedback` and use `.agents/skills/design-review-feedback` to
+ apply and verify one anchored thread at a time.
2. **Prefer `edit-design` for small changes.** It applies one or more
search/replace blocks to a file's HTML — surgical, cheap, and it preserves
everything you didn't touch (Alpine state, scroll, other screens):
diff --git a/templates/design/.agents/skills/design-review-feedback/SKILL.md b/templates/design/.agents/skills/design-review-feedback/SKILL.md
new file mode 100644
index 0000000000..8121f98038
--- /dev/null
+++ b/templates/design/.agents/skills/design-review-feedback/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: design-review-feedback
+description: >-
+ Work through persisted, element-anchored review comments on shared Design
+ prototypes and close the loop with verified edits.
+---
+
+# Design Review Feedback
+
+Use the shared review actions for design feedback:
+
+1. Call `view-screen` first so the active design, screen, inspector tab, and
+ review queue are current.
+2. Call `get-review-feedback` for the open queue and work on one root thread at
+ a time. Keep the thread id, target screen id, anchor node id, and nearby
+ context together while editing.
+3. For element-anchored feedback, prefer the stable
+ `data-agent-native-node-id` from the anchor. Use the stored percentage point
+ only as a visual fallback when the node cannot be resolved.
+4. Read the affected file with `get-design-snapshot`, make the smallest
+ persisted edit with `edit-design` or the appropriate design action, then
+ verify the saved result with `get-design-snapshot` and a screenshot or audit
+ when the change is visual.
+5. Resolve the thread with `resolve-review-thread` only after the edit is
+ persisted and verified, and pass `resolutionNote` with a one-line description
+ of the persisted change. If the user needs to decide something, reply with
+ `reply-review-comment` and set `resolutionTarget: "human"`; do not silently
+ resolve it.
+6. After applying a thread, call `consume-review-feedback` for the applied
+ agent-targeted root comment so the queue does not repeatedly resurface the
+ same work.
+
+Reviewers can leave a human-only comment or choose **Send to agent** when
+composing. Editors can selectively route an existing open thread with
+`send-review-thread-to-agent`; that action only changes the selected thread.
+When a thread is sent from the Design UI, the agent chat handoff includes its
+thread id and instructs the agent not to apply other queued feedback.
+
+Use `create-review-comment` for agent-authored follow-up notes when a durable
+record is more useful than a chat-only explanation. Keep review comments
+scoped to the design resource and the target screen; never copy large HTML,
+screenshots, or provider payloads into a comment.
diff --git a/templates/design/.generated/bridge/hit-test.generated.ts b/templates/design/.generated/bridge/hit-test.generated.ts
index 85ac542708..624bd5afde 100644
--- a/templates/design/.generated/bridge/hit-test.generated.ts
+++ b/templates/design/.generated/bridge/hit-test.generated.ts
@@ -440,9 +440,152 @@ export const hitTestBridgeScript: string = `"use strict";
guide.style.height = "2px";
}
}
+ function reviewAnchorElementAtPoint(clientX, clientY) {
+ var element = elementFromEditorPoint(clientX, clientY);
+ if (!element) return null;
+ return element.closest(
+ "[data-agent-native-node-id],[data-code-layer-id],[data-layer-id],[data-builder-id],[id]"
+ );
+ }
+ function reviewNodeElements(nodeIds) {
+ var wanted = {};
+ var found = {};
+ for (var index = 0; index < nodeIds.length; index += 1) {
+ var nodeId = nodeIds[index];
+ if (nodeId) wanted[nodeId] = true;
+ }
+ var candidates = document.querySelectorAll(
+ "[data-agent-native-node-id],[data-code-layer-id],[data-layer-id],[data-builder-id],[id]"
+ );
+ for (var candidateIndex = 0; candidateIndex < candidates.length; candidateIndex += 1) {
+ var candidate = candidates[candidateIndex];
+ var candidateId = getNodeId(candidate);
+ if (candidateId && wanted[candidateId] && !found[candidateId]) {
+ found[candidateId] = candidate;
+ }
+ }
+ return found;
+ }
+ function postReviewLayout() {
+ try {
+ window.parent.postMessage(
+ { type: "agent-native:review-layout" },
+ "*"
+ );
+ } catch (_err) {
+ }
+ }
+ var reviewLayoutFrame = 0;
+ function scheduleReviewLayout() {
+ if (reviewLayoutFrame) return;
+ reviewLayoutFrame = window.requestAnimationFrame(function() {
+ reviewLayoutFrame = 0;
+ postReviewLayout();
+ });
+ }
+ window.addEventListener("scroll", scheduleReviewLayout, true);
+ window.addEventListener("resize", scheduleReviewLayout);
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", scheduleReviewLayout, {
+ once: true
+ });
+ } else {
+ scheduleReviewLayout();
+ }
window.addEventListener("message", function(e) {
if (e.source !== window.parent) return;
if (!e.data) return;
+ if (e.data.type === "agent-native:review-anchor-at-point") {
+ var reviewPointCorrelationId = e.data.correlationId;
+ var reviewPointX = Number(e.data.x);
+ var reviewPointY = Number(e.data.y);
+ if (!reviewPointCorrelationId) return;
+ var reviewPointElement = reviewAnchorElementAtPoint(
+ reviewPointX,
+ reviewPointY
+ );
+ try {
+ window.parent.postMessage(
+ {
+ type: "agent-native:review-anchor-at-point-result",
+ correlationId: reviewPointCorrelationId,
+ nodeId: reviewPointElement ? getNodeId(reviewPointElement) || void 0 : void 0,
+ layerName: reviewPointElement?.getAttribute(
+ "data-agent-native-layer-name"
+ ) || void 0,
+ tagName: reviewPointElement?.tagName?.toLowerCase() || void 0
+ },
+ "*"
+ );
+ } catch (_err) {
+ }
+ return;
+ }
+ if (e.data.type === "agent-native:review-node-rects") {
+ var reviewRectsCorrelationId = e.data.correlationId;
+ var rawReviewNodeIds = e.data.nodeIds;
+ if (!reviewRectsCorrelationId || !Array.isArray(rawReviewNodeIds)) return;
+ var reviewNodeIds = rawReviewNodeIds.filter(function(value) {
+ return typeof value === "string" && value.length > 0;
+ }).slice(0, 500);
+ var reviewElements = reviewNodeElements(reviewNodeIds);
+ var reviewRects = {};
+ for (var reviewIndex = 0; reviewIndex < reviewNodeIds.length; reviewIndex += 1) {
+ var reviewNodeId = reviewNodeIds[reviewIndex];
+ var reviewElement = reviewElements[reviewNodeId];
+ if (!reviewElement) continue;
+ var reviewRect = reviewElement.getBoundingClientRect();
+ reviewRects[reviewNodeId] = {
+ left: reviewRect.left,
+ top: reviewRect.top,
+ width: reviewRect.width,
+ height: reviewRect.height
+ };
+ }
+ try {
+ window.parent.postMessage(
+ {
+ type: "agent-native:review-node-rects-result",
+ correlationId: reviewRectsCorrelationId,
+ rects: reviewRects,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight
+ },
+ "*"
+ );
+ } catch (_err) {
+ }
+ return;
+ }
+ if (e.data.type === "agent-native:review-focus") {
+ var reviewFocusCorrelationId = e.data.correlationId;
+ var reviewFocusNodeId = String(e.data.nodeId || "");
+ if (!reviewFocusCorrelationId || !reviewFocusNodeId) return;
+ var reviewFocusElement = reviewNodeElements([reviewFocusNodeId])[reviewFocusNodeId];
+ if (reviewFocusElement) {
+ reviewFocusElement.scrollIntoView({
+ block: "center",
+ inline: "center"
+ });
+ var previousReviewBoxShadow = reviewFocusElement.style.boxShadow;
+ reviewFocusElement.style.boxShadow = "0 0 0 2px var(--design-editor-accent-color, #2563eb)";
+ window.setTimeout(function() {
+ reviewFocusElement.style.boxShadow = previousReviewBoxShadow;
+ }, 700);
+ }
+ try {
+ window.parent.postMessage(
+ {
+ type: "agent-native:review-focus-result",
+ correlationId: reviewFocusCorrelationId,
+ focused: Boolean(reviewFocusElement)
+ },
+ "*"
+ );
+ } catch (_err) {
+ }
+ return;
+ }
if (e.data.type === "agent-native:hit-test-preview-clear") {
hideInsertionGuide();
return;
diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md
index 5762f62073..339b248e3c 100644
--- a/templates/design/AGENTS.md
+++ b/templates/design/AGENTS.md
@@ -33,6 +33,12 @@ patterns live in `.agents/skills/`.
tool. The action schema is the source of truth for parameters.
- Call `view-screen` before editing a specific design if the current design or
selected file is not already clear from context.
+- For shared prototype feedback, use the persisted review actions
+ (`list-review-comments`, `get-review-feedback`, `create-review-comment`,
+ `reply-review-comment`, `resolve-review-thread`, `consume-review-feedback`,
+ `send-review-thread-to-agent`, and `set-review-status`). Work one thread at a time, prefer its stable node
+ anchor, verify saved edits before resolving, and read
+ `.agents/skills/design-review-feedback/SKILL.md` for the full loop.
- Generated files must be complete, standalone HTML unless the user asks for a
different export format. They should render in the iframe without a build step.
- For design generation, ground the work in a concrete audience, primary job,
diff --git a/templates/design/README.md b/templates/design/README.md
index fab06a02de..fdd9e07f36 100644
--- a/templates/design/README.md
+++ b/templates/design/README.md
@@ -18,6 +18,7 @@ prompts and visual tweak controls.
- Save and reuse design-system preferences to stay on-brand.
- Import existing HTML or reference material as context.
- Export real files: HTML, ZIP, or PDF.
+- Share prototypes with element-anchored review comments and apply verified feedback through the agent.
## Develop locally
diff --git a/templates/design/actions/navigate.ts b/templates/design/actions/navigate.ts
index 9ef6df4306..2d3522d1a8 100644
--- a/templates/design/actions/navigate.ts
+++ b/templates/design/actions/navigate.ts
@@ -18,7 +18,7 @@
* --view View name (list, editor, design-systems, present, settings)
* --designId Design ID (for editor/present views)
* --editorView Editor mode for designs: single or overview
- * --inspectorTab Inspector tab for designs: design or tweaks (extensions opens Tools for compatibility)
+ * --inspectorTab Inspector tab for designs: design, comments, or tweaks (extensions opens Tools for compatibility)
* --leftPanel Left editor panel: file, agent, assets, import, tools, tokens, or code
* --fileId Screen/file id to focus in the design editor
* --filename Screen filename to focus in the design editor
@@ -61,7 +61,7 @@ const designLeftPanelSchema = z.enum([
export default defineAction({
description:
- "Navigate the UI to a specific view or path. Views: list, templates, editor, design-systems, present, settings. Use --templateId with templates, --designId with editor/present views, and --designSystemId with design-systems. For designs, use editorView=overview to show the infinite screens canvas, or editorView=single with fileId/filename/screen to focus a screen. Use leftPanel=file|agent|assets|import|tools|tokens|code to focus the left rail, including Import and the wide Code workspace. Legacy inspectorTab=extensions opens Tools. Use tool to activate a design editor tool.",
+ "Navigate the UI to a specific view or path. Views: list, templates, editor, design-systems, present, settings. Use --templateId with templates, --designId with editor/present views, and --designSystemId with design-systems. For designs, use editorView=overview to show the infinite screens canvas, or editorView=single with fileId/filename/screen to focus a screen. Use inspectorTab=design|comments|tweaks to focus the inspector, or leftPanel=file|agent|assets|import|tools|tokens|code to focus the left rail, including Import and the wide Code workspace. Legacy inspectorTab=extensions opens Tools. Use tool to activate a design editor tool.",
schema: z
.object({
view: z
@@ -87,11 +87,11 @@ export default defineAction({
.optional()
.describe("Alias for editorView"),
inspectorTab: z
- .enum(["design", "tweaks", "extensions"])
+ .enum(["design", "comments", "tweaks", "extensions"])
.optional()
.describe("Design editor inspector tab to focus"),
inspector: z
- .enum(["design", "tweaks", "extensions"])
+ .enum(["design", "comments", "tweaks", "extensions"])
.optional()
.describe("Alias for inspectorTab"),
leftPanel: designLeftPanelSchema
diff --git a/templates/design/actions/view-screen.test.ts b/templates/design/actions/view-screen.test.ts
index d37b1585f8..f62673228d 100644
--- a/templates/design/actions/view-screen.test.ts
+++ b/templates/design/actions/view-screen.test.ts
@@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => {
readAppState: vi.fn(),
readAppStateForCurrentTab: vi.fn(),
resolveAccess: vi.fn(),
+ getReviewStatus: vi.fn(),
+ getReviewThreadSummary: vi.fn(),
+ queryReviewComments: vi.fn(),
eq: vi.fn((left, right) => ({ left, right })),
selectChain,
};
@@ -29,6 +32,33 @@ vi.mock("@agent-native/core/sharing", () => ({
resolveAccess: mocks.resolveAccess,
}));
+vi.mock("@agent-native/core/review", () => ({
+ getReviewStatus: mocks.getReviewStatus,
+ getReviewThreadSummary: mocks.getReviewThreadSummary,
+ queryReviewComments: mocks.queryReviewComments,
+ shouldRedactReviewIdentity: (
+ ctx: { userEmail?: string | null } | undefined,
+ access: { role: string; visibility?: string | null },
+ ) =>
+ !ctx?.userEmail ||
+ (access.visibility === "public" && access.role === "viewer"),
+ redactPublicReviewCommentIdentity: (comment: Record) => ({
+ ...comment,
+ authorEmail: null,
+ authorName:
+ typeof comment.authorName === "string" &&
+ !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(comment.authorName)
+ ? comment.authorName
+ : null,
+ ownerEmail: null,
+ orgId: null,
+ }),
+ redactPublicReviewStatusIdentity: (status: Record | null) =>
+ status
+ ? { ...status, updatedBy: null, ownerEmail: null, orgId: null }
+ : null,
+}));
+
vi.mock("drizzle-orm", () => ({
eq: mocks.eq,
sql: vi.fn((strings, ...values) => ({ strings, values })),
@@ -58,6 +88,9 @@ describe("view-screen", () => {
mocks.readAppState.mockReset();
mocks.readAppStateForCurrentTab.mockReset();
mocks.resolveAccess.mockReset();
+ mocks.getReviewStatus.mockReset();
+ mocks.getReviewThreadSummary.mockReset();
+ mocks.queryReviewComments.mockReset();
mocks.selectChain.where.mockReset();
mocks.resolveAccess.mockResolvedValue({
role: "viewer",
@@ -67,6 +100,12 @@ describe("view-screen", () => {
},
});
mocks.readAppState.mockResolvedValue(undefined);
+ mocks.getReviewStatus.mockResolvedValue(null);
+ mocks.getReviewThreadSummary.mockResolvedValue({
+ openCount: 0,
+ agentQueueCount: 0,
+ });
+ mocks.queryReviewComments.mockResolvedValue([]);
});
it("uses active file before overview multi-selection", async () => {
@@ -200,4 +239,182 @@ describe("view-screen", () => {
filename: "index.html",
});
});
+
+ it("includes the open review queue for the active screen", async () => {
+ mocks.readAppStateForCurrentTab
+ .mockResolvedValueOnce({
+ view: "editor",
+ editorView: "single",
+ designId: "design_123",
+ fileId: "file_checkout",
+ })
+ .mockResolvedValueOnce(null);
+ mocks.selectChain.where.mockResolvedValue([
+ {
+ id: "file_checkout",
+ filename: "checkout.html",
+ fileType: "html",
+ updatedAt: "2026-06-29T00:00:00.000Z",
+ },
+ ]);
+ mocks.getReviewStatus.mockResolvedValue({ status: "changes_requested" });
+ mocks.getReviewThreadSummary.mockResolvedValue({
+ openCount: 1,
+ agentQueueCount: 1,
+ });
+ mocks.queryReviewComments.mockResolvedValue([
+ {
+ id: "comment_1",
+ threadId: "thread_1",
+ parentCommentId: null,
+ targetId: "file_checkout",
+ status: "open",
+ body: "Make the button more prominent",
+ anchor: { nodeId: "node_button", point: { xPct: 50, yPct: 50 } },
+ resolutionTarget: "agent",
+ consumedAt: null,
+ authorName: "Reviewer",
+ authorEmail: null,
+ createdBy: "human",
+ },
+ ]);
+ const result = JSON.parse(await action.run({}));
+
+ expect(result.design.review).toMatchObject({
+ status: "changes_requested",
+ openCount: 1,
+ agentQueueCount: 1,
+ activeScreenThreads: [
+ {
+ threadId: "thread_1",
+ nodeId: "node_button",
+ },
+ ],
+ });
+ });
+
+ it("redacts reviewer emails from a signed-in public viewer", async () => {
+ mocks.resolveAccess.mockResolvedValue({
+ role: "viewer",
+ resource: {
+ title: "Shared checkout",
+ data: '{"canvasFrames":[]}',
+ visibility: "public",
+ },
+ });
+ mocks.readAppStateForCurrentTab
+ .mockResolvedValueOnce({
+ view: "editor",
+ editorView: "single",
+ designId: "design_123",
+ fileId: "file_checkout",
+ })
+ .mockResolvedValueOnce(null);
+ mocks.selectChain.where.mockResolvedValue([
+ {
+ id: "file_checkout",
+ filename: "checkout.html",
+ fileType: "html",
+ updatedAt: "2026-06-29T00:00:00.000Z",
+ },
+ ]);
+ mocks.queryReviewComments.mockResolvedValue([
+ {
+ id: "comment_private_identity",
+ threadId: "thread_private_identity",
+ parentCommentId: null,
+ targetId: "file_checkout",
+ status: "open",
+ body: "Make the button more prominent",
+ anchor: null,
+ resolutionTarget: "human",
+ consumedAt: null,
+ authorName: "reviewer@example.com",
+ authorEmail: "reviewer@example.com",
+ ownerEmail: "owner@example.com",
+ orgId: "example-org",
+ createdBy: "human",
+ },
+ ]);
+
+ const result = JSON.parse(
+ await action.run({}, {
+ userEmail: "public-viewer@example.com",
+ caller: "agent",
+ } as any),
+ );
+
+ expect(result.design.review.activeScreenThreads).toEqual([
+ expect.objectContaining({
+ threadId: "thread_private_identity",
+ author: "human",
+ }),
+ ]);
+ expect(JSON.stringify(result.design.review)).not.toContain(
+ "reviewer@example.com",
+ );
+ expect(JSON.stringify(result.design.review)).not.toContain(
+ "owner@example.com",
+ );
+ });
+
+ it("uses the root routing target instead of counting agent-targeted replies", async () => {
+ mocks.readAppStateForCurrentTab
+ .mockResolvedValueOnce({
+ view: "editor",
+ editorView: "single",
+ designId: "design_123",
+ fileId: "file_checkout",
+ })
+ .mockResolvedValueOnce(null);
+ mocks.selectChain.where.mockResolvedValue([
+ {
+ id: "file_checkout",
+ filename: "checkout.html",
+ fileType: "html",
+ updatedAt: "2026-06-29T00:00:00.000Z",
+ },
+ ]);
+ mocks.queryReviewComments.mockResolvedValue([
+ {
+ id: "root_human",
+ threadId: "thread_human",
+ parentCommentId: null,
+ targetId: "file_checkout",
+ status: "open",
+ body: "The agent asked for clarification",
+ anchor: null,
+ resolutionTarget: "human",
+ consumedAt: null,
+ authorName: "Reviewer",
+ authorEmail: null,
+ createdBy: "human",
+ },
+ {
+ id: "reply_agent",
+ threadId: "thread_human",
+ parentCommentId: "root_human",
+ targetId: "file_checkout",
+ status: "open",
+ body: "Question for the reviewer",
+ anchor: null,
+ resolutionTarget: "agent",
+ consumedAt: null,
+ authorName: "Agent",
+ authorEmail: null,
+ createdBy: "agent",
+ },
+ ]);
+ mocks.getReviewThreadSummary.mockResolvedValue({
+ openCount: 1,
+ agentQueueCount: 0,
+ });
+
+ const result = JSON.parse(await action.run({}));
+
+ expect(result.design.review).toMatchObject({
+ openCount: 1,
+ agentQueueCount: 0,
+ });
+ });
});
diff --git a/templates/design/actions/view-screen.ts b/templates/design/actions/view-screen.ts
index b40cb27864..e45b83eb70 100644
--- a/templates/design/actions/view-screen.ts
+++ b/templates/design/actions/view-screen.ts
@@ -12,6 +12,16 @@ import {
readAppState,
readAppStateForCurrentTab,
} from "@agent-native/core/application-state";
+import {
+ getReviewStatus,
+ queryReviewComments,
+ redactPublicReviewCommentIdentity,
+ redactPublicReviewStatusIdentity,
+ shouldRedactReviewIdentity,
+ type ReviewComment,
+ type ReviewResourceContext,
+} from "@agent-native/core/review";
+import * as reviewRuntime from "@agent-native/core/review";
import { resolveAccess } from "@agent-native/core/sharing";
import { eq } from "drizzle-orm";
import { z } from "zod";
@@ -21,6 +31,22 @@ import { parseCanvasFrameGeometryById } from "../shared/canvas-frames.js";
import { getDesignTemplatePreset } from "../shared/design-template-presets.js";
import { designGenerationSessionKey } from "../shared/generation-session.js";
+interface ReviewThreadSummary {
+ openCount: number;
+ agentQueueCount: number;
+}
+
+const getReviewThreadSummary = (
+ reviewRuntime as unknown as {
+ getReviewThreadSummary?: (input: {
+ resourceType: string;
+ resourceId: string;
+ scope: { userEmail?: string | null; orgId?: string | null };
+ bypassScope?: boolean;
+ }) => Promise;
+ }
+).getReviewThreadSummary;
+
function stringProp(value: unknown, key: string): string | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = (value as Record)[key];
@@ -135,12 +161,57 @@ function resolveActiveCodeFile(
};
}
+function reviewAnchorNodeId(anchor: unknown): string | null {
+ if (!anchor || typeof anchor !== "object") return null;
+ const nodeId = (anchor as Record).nodeId;
+ return typeof nodeId === "string" && nodeId.trim() ? nodeId : null;
+}
+
+function buildReviewSummary(
+ comments: ReviewComment[],
+ status: Awaited>,
+ activeScreenId: string | undefined,
+ summary?: ReviewThreadSummary,
+) {
+ const openRoots = new Map();
+ for (const comment of comments) {
+ if (comment.status !== "open") continue;
+ if (!comment.parentCommentId && !openRoots.has(comment.threadId)) {
+ openRoots.set(comment.threadId, comment);
+ }
+ }
+ const agentQueueThreads = new Set(
+ Array.from(openRoots.values())
+ .filter(
+ (comment) =>
+ comment.resolutionTarget !== "human" && !comment.consumedAt,
+ )
+ .map((comment) => comment.threadId),
+ );
+ const activeScreenThreads = Array.from(openRoots.values())
+ .filter((comment) => comment.targetId === activeScreenId)
+ .map((comment) => ({
+ id: comment.id,
+ threadId: comment.threadId,
+ body: comment.body.slice(0, 180),
+ nodeId: reviewAnchorNodeId(comment.anchor),
+ author: comment.authorName ?? comment.authorEmail ?? comment.createdBy,
+ }));
+
+ return {
+ status: status?.status ?? "draft",
+ openCount: summary?.openCount ?? openRoots.size,
+ agentQueueCount: summary?.agentQueueCount ?? agentQueueThreads.size,
+ activeScreenThreads,
+ };
+}
+
export default defineAction({
description:
- "See what the user is currently looking at on screen. Returns the current navigation state including which design or template is open, which view they are on (list, templates, editor, design-systems, present, settings), active/focused design screen, selected element, active inspector tab (design or tweaks), active left rail panel (file, agent, assets, import, tools, tokens, or code), active code file metadata, overview canvas state, plus any pending question overlay. Always call this first before taking any action.",
+ "See what the user is currently looking at on screen. Returns the current navigation state including which design or template is open, which view they are on (list, templates, editor, design-systems, present, settings), active/focused design screen, selected element, active inspector tab (design, comments, or tweaks), active left rail panel (file, agent, assets, import, tools, tokens, or code), active code file metadata, overview canvas state, review status and feedback queue summary, plus any pending question overlay. Always call this first before taking any action.",
schema: z.object({}),
http: false,
- run: async () => {
+ run: async (_, ctx) => {
const [navigation, designSelection] = await Promise.all([
readAppStateForCurrentTab("navigation"),
readAppStateForCurrentTab("design-selection"),
@@ -233,6 +304,49 @@ export default defineAction({
activeCodeFile: resolveActiveCodeFile(files, designSelection),
canvasFrames: parseCanvasFrameGeometryById(data.canvasFrames),
};
+ const reviewContext = ctx as ReviewResourceContext | undefined;
+ const reviewScope = {
+ userEmail: reviewContext?.userEmail ?? null,
+ orgId: reviewContext?.orgId ?? null,
+ };
+ const [reviewComments, reviewStatus, reviewSummary] = await Promise.all(
+ [
+ queryReviewComments({
+ resourceType: "design",
+ resourceId: designId,
+ scope: reviewScope,
+ bypassScope: true,
+ includeResolved: false,
+ includeDeleted: false,
+ limit: 500,
+ }),
+ getReviewStatus("design", designId, reviewScope, {
+ bypassScope: true,
+ }),
+ getReviewThreadSummary
+ ? getReviewThreadSummary({
+ resourceType: "design",
+ resourceId: designId,
+ scope: reviewScope,
+ bypassScope: true,
+ })
+ : Promise.resolve(undefined),
+ ],
+ );
+ const redactReviewIdentity = shouldRedactReviewIdentity(reviewContext, {
+ role: access.role,
+ visibility: access.resource.visibility,
+ });
+ (screen.design as Record).review = buildReviewSummary(
+ redactReviewIdentity
+ ? reviewComments.map(redactPublicReviewCommentIdentity)
+ : reviewComments,
+ redactReviewIdentity
+ ? redactPublicReviewStatusIdentity(reviewStatus)
+ : reviewStatus,
+ resolveActiveScreen(files, navigation, designSelection)?.id,
+ reviewSummary,
+ );
}
}
if (showQuestions) {
diff --git a/templates/design/agent-native.app-skill.json b/templates/design/agent-native.app-skill.json
index 981fdf1e1a..314b3068f2 100644
--- a/templates/design/agent-native.app-skill.json
+++ b/templates/design/agent-native.app-skill.json
@@ -46,6 +46,11 @@
"path": ".agents/skills/visual-edit",
"visibility": "both",
"exportAs": "visual-edit"
+ },
+ {
+ "path": ".agents/skills/design-review-feedback",
+ "visibility": "both",
+ "exportAs": "design-review-feedback"
}
],
"hostAdapters": [
diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx
index d14c8e054b..4f9c70af4d 100644
--- a/templates/design/app/components/design/DesignCanvas.tsx
+++ b/templates/design/app/components/design/DesignCanvas.tsx
@@ -1,8 +1,10 @@
import { usePinchZoom, useT } from "@agent-native/core/client";
+import type { ReviewThread } from "@agent-native/core/client";
import {
injectSessionReplayIframeBootstrap,
SESSION_REPLAY_IFRAME_ATTRIBUTE,
} from "@agent-native/core/client";
+import type { ReviewComment } from "@agent-native/core/review";
import {
DEFAULT_CANVAS_MAX_ZOOM,
DEFAULT_CANVAS_MIN_ZOOM,
@@ -23,7 +25,14 @@ import {
type PenPoint,
} from "@shared/pen-path";
import { IconPlugConnectedX, IconRefresh } from "@tabler/icons-react";
-import { useRef, useEffect, useCallback, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -33,8 +42,8 @@ import { Button } from "@/components/ui/button";
// exist for now and can be reconciled in a follow-up. Don't import both.
import {
DrawOverlay as SharedDrawOverlay,
- CanvasCommentPins,
- type CanvasPin,
+ ReviewCanvasPins,
+ type ReviewFocusRequest,
} from "@/components/visual-editor";
import { sendToDesignAgentChatAndConfirm } from "@/lib/agent-chat";
import {
@@ -503,9 +512,9 @@ interface DesignCanvasProps {
pinMode?: boolean;
/**
* When true, suppresses rendering of comment pins on this canvas (e.g. the
- * Figma-style Shift+C "hide comments" toggle in single-screen mode). Only
- * affects pin *visibility* — pin drop mode (`pinMode`) still behaves
- * normally underneath so toggling this back off doesn't lose anything.
+ * Figma-style Shift+C "hide comments" toggle in single-screen mode). Hiding
+ * comments while pin mode is active also exits pin mode so an invisible
+ * click target cannot keep intercepting canvas interactions.
*/
commentPinsHidden?: boolean;
selectedSelector?: string | null;
@@ -521,6 +530,18 @@ interface DesignCanvasProps {
onExitPinMode?: () => void;
/** Stable id of the open design (used for pin scoping + agent prompt). */
designId?: string;
+ /** Whether the current signed-in viewer may create review comments. */
+ reviewCanPost?: boolean;
+ /** Whether the current viewer may resolve review threads. */
+ reviewCanResolve?: boolean;
+ /** A panel-driven request to focus an anchored review comment. */
+ reviewFocusRequest?: ReviewFocusRequest | null;
+ /** Dispatch a newly created agent-targeted comment to the local agent chat. */
+ onDispatchCommentToAgent?: (comment: ReviewComment) => void;
+ /** Dispatch one existing review thread to the local agent chat. */
+ onSendThreadToAgent?: (thread: ReviewThread) => void;
+ /** Thread currently being routed to the agent. */
+ reviewSendingThreadId?: string | null;
/** Human-readable label for the design (used in agent prompt). */
designTitle?: string;
/** Stable id for comment pins, usually scoped to the active screen. */
@@ -1022,11 +1043,16 @@ export function DesignCanvas({
onExitPinMode,
registerRuntimeBridge = true,
designId,
+ reviewCanPost = false,
+ reviewCanResolve = false,
+ reviewFocusRequest,
+ onDispatchCommentToAgent,
+ onSendThreadToAgent,
+ reviewSendingThreadId,
designTitle,
commentContextId,
screenId,
previewFrameId,
- commentContextLabel,
onPrototypeNavigate,
motionTracks,
motionDefaultEase,
@@ -1050,6 +1076,7 @@ export function DesignCanvas({
null,
);
const scrollContainerRef = useRef(null);
+ const reviewCanvasId = useId();
const zoomLayerRef = useRef(null);
const zoomRef = useRef(zoom);
// Zoom-invariant chrome: the non-embedded-frame render path below wraps the
@@ -1119,8 +1146,6 @@ export function DesignCanvas({
return true;
}, []);
const [renderedContent, setRenderedContent] = useState(content);
- const [annotationPins, setAnnotationPins] = useState([]);
- const [pinSubmitSignal, setPinSubmitSignal] = useState(0);
// True while a drawing send is capturing/compositing/uploading the
// annotated screenshot (see design-canvas/annotation-snapshot.ts). Drives
// SharedDrawOverlay's busy Send state so a slow capture can't be triggered
@@ -1919,14 +1944,6 @@ export function DesignCanvas({
setExternalSnapshotRetryNonce((nonce) => nonce + 1);
}, []);
- const queuedAnnotationPins = useMemo(
- () =>
- annotationPins.filter(
- (pin) => pin.queued && !pin.submitted && (pin.draft || "").trim(),
- ),
- [annotationPins],
- );
-
useEffect(() => {
if (previousContentKeyRef.current !== contentKey) {
previousContentKeyRef.current = contentKey;
@@ -4009,6 +4026,7 @@ export function DesignCanvas({
// visible when a design background matches the editor canvas.
const iframeElement = (
onExitDrawMode?.()}
@@ -4318,33 +4336,14 @@ export function DesignCanvas({
: `[label "${a.text}" at ${a.position.x.toFixed(0)},${a.position.y.toFixed(0)}]`,
)
.join("\n");
- const pinSummary = queuedAnnotationPins
- .flatMap((pin, index) => {
- const lines = [
- `[${index + 1}] Comment pin on ${commentContextLabel || designTitle || commentContextId || designId || "design"}`,
- `Position: ${pin.xPct.toFixed(1)}% from left, ${pin.yPct.toFixed(1)}% from top`,
- ];
- if (pin.targetAnchorId)
- lines.push(`Anchor id: ${pin.targetAnchorId}`);
- if (pin.targetSelector)
- lines.push(`Element: ${pin.targetSelector}`);
- if (pin.targetText)
- lines.push(`Nearby text: "${pin.targetText}"`);
- lines.push("");
- lines.push((pin.draft || "").trim());
- return [...lines, ""];
- })
- .join("\n");
const lines = [
`[Annotations on design ${designId || ""}${designTitle ? ` (${designTitle})` : ""}]`,
`Canvas size: ${canvasSize.width.toFixed(0)}x${canvasSize.height.toFixed(0)}`,
...(summary ? ["", "[Drawing]", summary] : []),
- ...(pinSummary ? ["", "[Comment pins]", pinSummary] : []),
"",
instruction || "Apply these annotations to the design.",
];
const message = lines.join("\n");
- const hasQueuedPins = queuedAnnotationPins.length > 0;
// Best-effort: render the annotated screen + composited drawing as
// ONE image the agent can see (see design-canvas/annotation-snapshot.ts),
@@ -4368,7 +4367,7 @@ export function DesignCanvas({
.then((imageUrl) =>
submitDesignAnnotations({
message,
- hasQueuedPins,
+ hasQueuedPins: false,
// Ack-confirmed send: only exit draw mode / mark pins
// submitted once the message is CONFIRMED to have reached
// the chat (became a visible turn). A fire-and-forget send
@@ -4389,9 +4388,7 @@ export function DesignCanvas({
);
}
},
- markQueuedPinsSubmitted: () => {
- setPinSubmitSignal((signal) => signal + 1);
- },
+ markQueuedPinsSubmitted: () => {},
exitDrawMode: () => onExitDrawMode?.(),
onError: (error) => {
console.error(
@@ -4536,27 +4533,23 @@ export function DesignCanvas({
)}
- {/* Canvas comment pins — anchored to the iframe wrapper. The pins
- themselves render via fixed positioning, so we mount them outside
- the zoom-transformed container to keep coordinates stable.
- Suppressed entirely when commentPinsHidden (Figma-style Shift+C
- "hide comments" toggle) is set — existing pins disappear and pin
- drop-mode has nothing to render into until it's toggled back on. */}
- {!commentPinsHidden && (
- onExitPinMode?.()}
- canvasSelector=".design-canvas-iframe-wrapper"
- contextId={commentContextId || designId || "design"}
- contextLabel={
- commentContextLabel || designTitle || commentContextId || designId
- }
+ canvasSelector={`[data-review-canvas-id="${reviewCanvasId}"]`}
+ resourceType="design"
+ resourceId={designId}
+ targetId={screenId ?? commentContextId ?? ""}
+ canPost={reviewCanPost}
+ canResolve={reviewCanResolve}
+ focusRequest={reviewFocusRequest}
+ onDispatchCommentToAgent={onDispatchCommentToAgent}
+ onSendThreadToAgent={onSendThreadToAgent}
+ sendingThreadId={reviewSendingThreadId}
/>
- )}
+ ) : null}
);
}
diff --git a/templates/design/app/components/design/EditPanel.tsx b/templates/design/app/components/design/EditPanel.tsx
index 3b3250ada3..9c4fccf6d4 100644
--- a/templates/design/app/components/design/EditPanel.tsx
+++ b/templates/design/app/components/design/EditPanel.tsx
@@ -28,6 +28,7 @@ import {
type InteractionState,
} from "@shared/interaction-states";
import {
+ IconAdjustmentsHorizontal,
IconAlignCenter,
IconAlignJustified,
IconAlignLeft,
@@ -73,6 +74,7 @@ import {
IconLink,
IconLinkOff,
IconMinus,
+ IconMessageCircle,
IconPerspective,
IconPhoto,
IconPlus,
@@ -336,6 +338,10 @@ import {
GlslShaderEffectSection,
type GlslShaderPanelContext,
} from "./inspector/GlslShaderPanel";
+import {
+ ReviewCommentsPanel,
+ type ReviewCommentsPanelProps,
+} from "./ReviewCommentsPanel";
import { ReviewPanel } from "./ReviewPanel";
import type { ReviewPanelProps } from "./ReviewPanel";
import type { StatesPanelProps } from "./StatesPanel";
@@ -393,7 +399,7 @@ export function mergeOptimisticInteractionStateStyles(
return { ...(persisted ?? {}), ...(pending ?? {}) };
}
-export type InspectorTab = "design" | "tweaks";
+export type InspectorTab = "design" | "tweaks" | "comments";
interface EditPanelProps {
selectedElement: ElementInfo | null;
@@ -456,6 +462,10 @@ interface EditPanelProps {
statesPanelProps?: Omit
;
/** Props forwarded to the ReviewPanel (§6.5). */
reviewPanelProps?: Omit;
+ /** Persisted design review comments and feedback queue. */
+ reviewCommentsPanelProps?: ReviewCommentsPanelProps;
+ /** Open review-thread count shown on the inspector tab. */
+ reviewCommentsCount?: number;
// -------------------------------------------------------------------------
// Component section (§6.1)
// When a component instance is selected, pass its node id here to unlock
@@ -1091,31 +1101,82 @@ function InspectorTabsHeader({
activeTab,
onActiveTabChange,
trailing,
+ commentsCount = 0,
+ compact = false,
}: {
activeTab: InspectorTab;
onActiveTabChange: (tab: InspectorTab) => void;
trailing?: ReactNode;
+ commentsCount?: number;
+ compact?: boolean;
}) {
const t = useT();
return (
-
+
onActiveTabChange(value as InspectorTab)}
+ className="min-w-0"
>
-
+
- {"Design" /* i18n-ignore design inspector tab */}
+ {compact ? (
+
+ ) : (
+ t("navigation.brand")
+ )}
+
+ 0
+ ? t("review.commentsTab", { count: commentsCount })
+ : t("review.comments")
+ }
+ className={cn(
+ "group h-6 min-w-0 rounded-md !text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none",
+ compact ? "w-auto gap-0.5 px-1" : "gap-1 px-1.5",
+ )}
+ >
+ {compact ? (
+
+ ) : (
+ {t("review.comments")}
+ )}
+ {commentsCount > 0 ? (
+
+ {commentsCount > 99 ? "99+" : commentsCount}
+
+ ) : null}
- {t("designEditor.tweaks")}
+ {compact ? (
+
+ ) : (
+ t("designEditor.tweaks")
+ )}
@@ -1367,6 +1428,8 @@ export const EditPanel = memo(function EditPanel({
designId,
onComponentPropApplied,
reviewPanelProps,
+ reviewCommentsPanelProps,
+ reviewCommentsCount = 0,
componentNodeId,
componentSwapPickerRequest,
sourceCapabilities = [],
@@ -1707,11 +1770,12 @@ export const EditPanel = memo(function EditPanel({
const userScrollIntentRef = useRef(false);
const scrollTimerRef = useRef | null>(null);
- // Figma replaces the entire right panel with the size-preset list while the
- // Frame tool is armed — regardless of which inspector tab (Design/Tweaks)
- // was showing beforehand — so this takes priority over `activeTab` below.
+ // Frame presets belong to the Design inspector. Keep Comments and Tweaks
+ // visible when the Frame tool remains armed while another tab is active.
const showFramePresets =
- activeTool === "frame" && Boolean(onCreateScreenFromPreset);
+ activeTab === "design" &&
+ activeTool === "frame" &&
+ Boolean(onCreateScreenFromPreset);
return (
@@ -1726,6 +1790,8 @@ export const EditPanel = memo(function EditPanel({
activeTab={activeTab}
onActiveTabChange={handleActiveTabChange}
trailing={headerTrailing}
+ commentsCount={reviewCommentsCount}
+ compact={width < 280}
/>
{showFramePresets ? (
@@ -2061,6 +2127,8 @@ export const EditPanel = memo(function EditPanel({
/>
+ ) : activeTab === "comments" && reviewCommentsPanelProps ? (
+
) : null}
diff --git a/templates/design/app/components/design/ReviewCommentsPanel.tsx b/templates/design/app/components/design/ReviewCommentsPanel.tsx
new file mode 100644
index 0000000000..685deb2511
--- /dev/null
+++ b/templates/design/app/components/design/ReviewCommentsPanel.tsx
@@ -0,0 +1,161 @@
+import {
+ ReviewThreadPanel,
+ type ReviewThread,
+ useT,
+} from "@agent-native/core/client";
+import type { ReviewComment } from "@agent-native/core/review";
+import { IconSend } from "@tabler/icons-react";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
+
+export interface ReviewCommentsPanelProps {
+ designId: string;
+ activeFileId?: string | null;
+ canComment: boolean;
+ /** Caller-derived editor capability for resolving threads. */
+ canResolve?: boolean;
+ /** Caller authorization for deleting a specific root comment. */
+ canDeleteComment?: (comment: ReviewComment, thread: ReviewThread) => boolean;
+ showComposer?: boolean;
+ signInHref?: string;
+ onSelectThread?: (thread: ReviewThread) => void;
+ canDispatchToAgent?: boolean;
+ sendingThreadId?: string | null;
+ onDispatchCommentToAgent?: (comment: ReviewComment) => void;
+ onSendThreadToAgent?: (thread: ReviewThread) => void;
+ className?: string;
+}
+
+export function ReviewCommentsPanel({
+ designId,
+ activeFileId,
+ canComment,
+ canResolve,
+ canDeleteComment,
+ showComposer = true,
+ signInHref,
+ onSelectThread,
+ canDispatchToAgent = false,
+ sendingThreadId,
+ onDispatchCommentToAgent,
+ onSendThreadToAgent,
+ className,
+}: ReviewCommentsPanelProps) {
+ const t = useT();
+ const [scope, setScope] = useState<"screen" | "all">("screen");
+ const targetId = scope === "screen" ? activeFileId : undefined;
+
+ return (
+
+
+ setScope(value as "screen" | "all")}
+ className="w-full"
+ >
+
+
+ {t("review.thisScreen")}
+
+
+ {t("review.allScreens")}
+
+
+
+
+
+ {!canComment && signInHref ? (
+
+ {t("review.signInToComment")}
+
+ ) : null}
+
+
+ {
+ if (comment.resolutionTarget === "agent") {
+ onDispatchCommentToAgent?.(comment);
+ }
+ }}
+ onSelectThread={onSelectThread}
+ renderThreadActions={
+ canDispatchToAgent && onSendThreadToAgent
+ ? (thread) => {
+ if (thread.root.status !== "open") return null;
+ const alreadyQueued =
+ thread.root.resolutionTarget !== "human" &&
+ !thread.root.consumedAt;
+ if (alreadyQueued) return null;
+ const sending = sendingThreadId === thread.root.threadId;
+ const dispatchPending = Boolean(sendingThreadId);
+ return (
+ {
+ event.stopPropagation();
+ onSendThreadToAgent(thread);
+ }}
+ >
+ {sending ? (
+
+ ) : (
+
+ )}
+
+ {sending
+ ? t("review.sendingToAgent")
+ : t("review.sendToAgent")}
+
+
+ );
+ }
+ : undefined
+ }
+ />
+
+
+ );
+}
diff --git a/templates/design/app/components/design/ReviewStatusControl.tsx b/templates/design/app/components/design/ReviewStatusControl.tsx
new file mode 100644
index 0000000000..47588f56d6
--- /dev/null
+++ b/templates/design/app/components/design/ReviewStatusControl.tsx
@@ -0,0 +1,96 @@
+import {
+ ReviewStatusBadge,
+ useSetReviewStatus,
+ useT,
+} from "@agent-native/core/client";
+import type { ReviewStatus } from "@agent-native/core/review";
+import { IconChevronDown } from "@tabler/icons-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const REVIEW_STATUSES: ReviewStatus[] = [
+ "draft",
+ "in_review",
+ "approved",
+ "changes_requested",
+];
+
+export interface ReviewStatusControlProps {
+ designId: string;
+ status?: ReviewStatus | null;
+ /** Explicit caller-derived capability. Pass true only for the owner. */
+ editable?: boolean;
+}
+
+export function ReviewStatusControl({
+ designId,
+ status,
+ editable = false,
+}: ReviewStatusControlProps) {
+ const t = useT();
+ const setStatus = useSetReviewStatus();
+ const currentStatus = status ?? "draft";
+ const label = (value: ReviewStatus) => t(`review.status.${value}`);
+ const compactBadgeClassName =
+ "max-w-[8.5rem] truncate px-1.5 py-0 text-[11px] leading-5";
+
+ if (!editable) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {REVIEW_STATUSES.map((nextStatus) => (
+ {
+ if (nextStatus === currentStatus || setStatus.isPending) return;
+ setStatus.mutate(
+ {
+ resourceType: "design",
+ resourceId: designId,
+ status: nextStatus,
+ },
+ {
+ onError: () => toast.error(t("review.status.saveFailed")),
+ },
+ );
+ }}
+ >
+ {label(nextStatus)}
+
+ ))}
+
+
+ );
+}
diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts
index df59eedbec..cc7fd059a7 100644
--- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts
+++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts
@@ -6830,6 +6830,89 @@ function hydratedHitTestBridgeScript(): string {
return hitTestBridgeScript;
}
+it(
+ "hit-test bridge exposes review anchor, rect, and focus messages for opaque preview frames",
+ { timeout: 30_000 },
+ async () => {
+ const browser = await chromium.launch({ headless: true });
+ const pageErrors: string[] = [];
+ try {
+ const page = await browser.newPage({
+ viewport: { width: 900, height: 700 },
+ });
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ await page.setContent(`
+
+
+ Hello
+
+`);
+ await page.addScriptTag({ content: hydratedHitTestBridgeScript() });
+
+ const result = await page.evaluate(async () => {
+ const request = (data: Record, responseType: string) =>
+ new Promise>((resolve) => {
+ const onMessage = (event: MessageEvent) => {
+ if (
+ event.data?.type !== responseType ||
+ event.data?.correlationId !== data.correlationId
+ ) {
+ return;
+ }
+ window.removeEventListener("message", onMessage);
+ resolve(event.data);
+ };
+ window.addEventListener("message", onMessage);
+ window.postMessage(data, "*");
+ });
+ const anchor = await request(
+ {
+ type: "agent-native:review-anchor-at-point",
+ correlationId: "review-point",
+ x: 100,
+ y: 70,
+ },
+ "agent-native:review-anchor-at-point-result",
+ );
+ const rects = await request(
+ {
+ type: "agent-native:review-node-rects",
+ correlationId: "review-rects",
+ nodeIds: ["hero-title"],
+ },
+ "agent-native:review-node-rects-result",
+ );
+ const focus = await request(
+ {
+ type: "agent-native:review-focus",
+ correlationId: "review-focus",
+ nodeId: "hero-title",
+ },
+ "agent-native:review-focus-result",
+ );
+ return { anchor, rects, focus };
+ });
+
+ expect(result.anchor).toMatchObject({
+ nodeId: "hero-title",
+ layerName: "Hero title",
+ tagName: "h1",
+ });
+ expect(result.rects).toMatchObject({
+ rects: {
+ "hero-title": { left: 40, top: 40, width: 320, height: 80 },
+ },
+ viewportWidth: 900,
+ viewportHeight: 700,
+ });
+ expect(result.focus).toMatchObject({ focused: true });
+ expect(pageErrors).toEqual([]);
+ } finally {
+ await browser.close();
+ }
+ },
+);
+
it(
"hit-test bridge mints a stable pendingNodeId for an anchor with no stable id, without spamming re-mints across repeated hovers",
{ timeout: 30_000 },
diff --git a/templates/design/app/components/design/bridge/hit-test.bridge.ts b/templates/design/app/components/design/bridge/hit-test.bridge.ts
index 0e8913dcb7..57222d112d 100644
--- a/templates/design/app/components/design/bridge/hit-test.bridge.ts
+++ b/templates/design/app/components/design/bridge/hit-test.bridge.ts
@@ -733,9 +733,175 @@
}
}
+ function reviewAnchorElementAtPoint(
+ clientX: number,
+ clientY: number,
+ ): Element | null {
+ var element = elementFromEditorPoint(clientX, clientY);
+ if (!element) return null;
+ return element.closest(
+ "[data-agent-native-node-id],[data-code-layer-id],[data-layer-id],[data-builder-id],[id]",
+ );
+ }
+
+ function reviewNodeElements(nodeIds: string[]): Record {
+ var wanted: Record = {};
+ var found: Record = {};
+ for (var index = 0; index < nodeIds.length; index += 1) {
+ var nodeId = nodeIds[index];
+ if (nodeId) wanted[nodeId] = true;
+ }
+ var candidates = document.querySelectorAll(
+ "[data-agent-native-node-id],[data-code-layer-id],[data-layer-id],[data-builder-id],[id]",
+ );
+ for (
+ var candidateIndex = 0;
+ candidateIndex < candidates.length;
+ candidateIndex += 1
+ ) {
+ var candidate = candidates[candidateIndex];
+ var candidateId = getNodeId(candidate);
+ if (candidateId && wanted[candidateId] && !found[candidateId]) {
+ found[candidateId] = candidate;
+ }
+ }
+ return found;
+ }
+
+ function postReviewLayout(): void {
+ try {
+ (window.parent as Window).postMessage(
+ { type: "agent-native:review-layout" },
+ "*",
+ );
+ } catch (_err) {}
+ }
+
+ var reviewLayoutFrame = 0;
+ function scheduleReviewLayout(): void {
+ if (reviewLayoutFrame) return;
+ reviewLayoutFrame = window.requestAnimationFrame(function () {
+ reviewLayoutFrame = 0;
+ postReviewLayout();
+ });
+ }
+
+ window.addEventListener("scroll", scheduleReviewLayout, true);
+ window.addEventListener("resize", scheduleReviewLayout);
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", scheduleReviewLayout, {
+ once: true,
+ });
+ } else {
+ scheduleReviewLayout();
+ }
+
window.addEventListener("message", function (e: MessageEvent) {
if (e.source !== window.parent) return;
if (!e.data) return;
+ if (e.data.type === "agent-native:review-anchor-at-point") {
+ var reviewPointCorrelationId: string = e.data.correlationId;
+ var reviewPointX: number = Number(e.data.x);
+ var reviewPointY: number = Number(e.data.y);
+ if (!reviewPointCorrelationId) return;
+ var reviewPointElement = reviewAnchorElementAtPoint(
+ reviewPointX,
+ reviewPointY,
+ );
+ try {
+ (window.parent as Window).postMessage(
+ {
+ type: "agent-native:review-anchor-at-point-result",
+ correlationId: reviewPointCorrelationId,
+ nodeId: reviewPointElement
+ ? getNodeId(reviewPointElement) || undefined
+ : undefined,
+ layerName:
+ reviewPointElement?.getAttribute(
+ "data-agent-native-layer-name",
+ ) || undefined,
+ tagName: reviewPointElement?.tagName?.toLowerCase() || undefined,
+ },
+ "*",
+ );
+ } catch (_err) {}
+ return;
+ }
+ if (e.data.type === "agent-native:review-node-rects") {
+ var reviewRectsCorrelationId: string = e.data.correlationId;
+ var rawReviewNodeIds: unknown = e.data.nodeIds;
+ if (!reviewRectsCorrelationId || !Array.isArray(rawReviewNodeIds)) return;
+ var reviewNodeIds = rawReviewNodeIds
+ .filter(function (value): value is string {
+ return typeof value === "string" && value.length > 0;
+ })
+ .slice(0, 500);
+ var reviewElements = reviewNodeElements(reviewNodeIds);
+ var reviewRects: Record<
+ string,
+ { left: number; top: number; width: number; height: number }
+ > = {};
+ for (
+ var reviewIndex = 0;
+ reviewIndex < reviewNodeIds.length;
+ reviewIndex += 1
+ ) {
+ var reviewNodeId = reviewNodeIds[reviewIndex];
+ var reviewElement = reviewElements[reviewNodeId];
+ if (!reviewElement) continue;
+ var reviewRect = reviewElement.getBoundingClientRect();
+ reviewRects[reviewNodeId] = {
+ left: reviewRect.left,
+ top: reviewRect.top,
+ width: reviewRect.width,
+ height: reviewRect.height,
+ };
+ }
+ try {
+ (window.parent as Window).postMessage(
+ {
+ type: "agent-native:review-node-rects-result",
+ correlationId: reviewRectsCorrelationId,
+ rects: reviewRects,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ },
+ "*",
+ );
+ } catch (_err) {}
+ return;
+ }
+ if (e.data.type === "agent-native:review-focus") {
+ var reviewFocusCorrelationId: string = e.data.correlationId;
+ var reviewFocusNodeId: string = String(e.data.nodeId || "");
+ if (!reviewFocusCorrelationId || !reviewFocusNodeId) return;
+ var reviewFocusElement = reviewNodeElements([reviewFocusNodeId])[
+ reviewFocusNodeId
+ ] as HTMLElement | undefined;
+ if (reviewFocusElement) {
+ reviewFocusElement.scrollIntoView({
+ block: "center",
+ inline: "center",
+ });
+ var previousReviewBoxShadow = reviewFocusElement.style.boxShadow;
+ reviewFocusElement.style.boxShadow =
+ "0 0 0 2px var(--design-editor-accent-color, #2563eb)";
+ window.setTimeout(function () {
+ reviewFocusElement!.style.boxShadow = previousReviewBoxShadow;
+ }, 700);
+ }
+ try {
+ (window.parent as Window).postMessage(
+ {
+ type: "agent-native:review-focus-result",
+ correlationId: reviewFocusCorrelationId,
+ focused: Boolean(reviewFocusElement),
+ },
+ "*",
+ );
+ } catch (_err) {}
+ return;
+ }
if (e.data.type === "agent-native:hit-test-preview-clear") {
hideInsertionGuide();
return;
diff --git a/templates/design/app/components/layout/Layout.tsx b/templates/design/app/components/layout/Layout.tsx
index c18cb8e5b0..03db9c6708 100644
--- a/templates/design/app/components/layout/Layout.tsx
+++ b/templates/design/app/components/layout/Layout.tsx
@@ -81,6 +81,7 @@ export function Layout({ children }: LayoutProps) {
? `show-questions:${designScope.id}`
: "show-questions";
const { questions: pendingDesignQuestions } = useGuidedQuestionFlow({
+ enabled: hasSession,
stateKey: designQuestionStateKey,
queryKey: [designQuestionStateKey],
browserTabId,
diff --git a/templates/design/app/components/visual-editor/ReviewCanvasPins.spec.tsx b/templates/design/app/components/visual-editor/ReviewCanvasPins.spec.tsx
new file mode 100644
index 0000000000..deab89f859
--- /dev/null
+++ b/templates/design/app/components/visual-editor/ReviewCanvasPins.spec.tsx
@@ -0,0 +1,306 @@
+// @vitest-environment happy-dom
+
+import type { ReviewComment } from "@agent-native/core/review";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ createMutate: vi.fn(),
+ replyMutate: vi.fn(),
+ resolveMutate: vi.fn(),
+}));
+
+const comment = vi.hoisted(
+ () =>
+ ({
+ id: "comment-1",
+ resourceType: "design",
+ resourceId: "design-1",
+ threadId: "thread-1",
+ parentCommentId: null,
+ targetId: "screen-1",
+ kind: "annotation",
+ status: "open",
+ anchor: { point: { xPct: 25, yPct: 30 } },
+ body: "Keep this popover open",
+ authorEmail: "reviewer@example.com",
+ authorName: null,
+ createdBy: "human",
+ resolutionTarget: "human",
+ mentions: [],
+ ownerEmail: "owner@example.com",
+ orgId: null,
+ visibility: "private",
+ resolvedBy: null,
+ resolvedAt: null,
+ consumedAt: null,
+ deletedBy: null,
+ deletedAt: null,
+ createdAt: "2026-07-13T13:00:00.000Z",
+ updatedAt: "2026-07-13T13:00:00.000Z",
+ metadata: null,
+ }) satisfies ReviewComment,
+);
+
+vi.mock("@agent-native/core/client", () => ({
+ buildReviewThreads: (comments: ReviewComment[]) =>
+ comments.map((root) => ({ root, replies: [] })),
+ ReviewCommentComposer: (props: {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: (target: "human" | "agent") => void;
+ }) => (
+
+ props.onChange("Pinned feedback")}
+ />
+ props.onSubmit("human")}
+ />
+
+ ),
+ cn: (...values: Array) =>
+ values.filter(Boolean).join(" "),
+ useCreateReviewComment: () => ({
+ mutate: mocks.createMutate,
+ isPending: false,
+ }),
+ useReplyReviewComment: () => ({
+ mutate: mocks.replyMutate,
+ isPending: false,
+ }),
+ useResolveReviewThread: () => ({
+ mutate: mocks.resolveMutate,
+ isPending: false,
+ }),
+ useReviewComments: () => ({ data: { comments: [comment] } }),
+ useT: () => (key: string) => key,
+}));
+
+import { ReviewCanvasPins } from "./ReviewCanvasPins";
+
+class ResizeObserverMock {
+ observe() {}
+ disconnect() {}
+}
+
+describe("ReviewCanvasPins persisted thread popover", () => {
+ let container: HTMLDivElement;
+ let canvas: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
+ mocks.createMutate.mockReset();
+ mocks.replyMutate.mockReset();
+ mocks.resolveMutate.mockReset();
+ canvas = document.createElement("div");
+ canvas.className = "review-test-canvas";
+ canvas.getBoundingClientRect = () =>
+ ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 800,
+ bottom: 600,
+ width: 800,
+ height: 600,
+ toJSON: () => ({}),
+ }) as DOMRect;
+ document.body.appendChild(canvas);
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ canvas.remove();
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps a clicked persisted thread open outside pin-placement mode", async () => {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+
+ const pin = document.querySelector("[data-review-pin]");
+ expect(pin).not.toBeNull();
+ await act(async () => pin?.click());
+
+ expect(document.body.textContent).toContain("Keep this popover open");
+
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ expect(document.body.textContent).not.toContain("Keep this popover open");
+ });
+
+ it("moves one empty draft and persists only after feedback is entered", async () => {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+
+ const clickPlane = document.querySelector(
+ "[data-review-click-plane]",
+ );
+ expect(clickPlane).not.toBeNull();
+ await act(async () => {
+ clickPlane?.dispatchEvent(
+ new MouseEvent("click", {
+ bubbles: true,
+ clientX: 200,
+ clientY: 180,
+ }),
+ );
+ });
+ await act(async () => {
+ clickPlane?.dispatchEvent(
+ new MouseEvent("click", {
+ bubbles: true,
+ clientX: 300,
+ clientY: 240,
+ }),
+ );
+ });
+
+ expect(document.querySelectorAll("[data-review-pin]")).toHaveLength(2);
+ expect(mocks.createMutate).not.toHaveBeenCalled();
+
+ await act(async () => {
+ document
+ .querySelector("[data-review-test-type]")
+ ?.click();
+ });
+ await act(async () => {
+ document
+ .querySelector("[data-review-test-submit]")
+ ?.click();
+ });
+
+ expect(mocks.createMutate).toHaveBeenCalledTimes(1);
+ expect(mocks.createMutate.mock.calls[0]?.[0]).toMatchObject({
+ body: "Pinned feedback",
+ anchor: { point: { xPct: 37.5, yPct: 40 } },
+ resolutionTarget: "human",
+ });
+ });
+
+ it("enriches opaque iframe clicks with a bridge-resolved node anchor", async () => {
+ const iframe = document.createElement("iframe");
+ iframe.setAttribute("data-design-preview-iframe", "");
+ iframe.getBoundingClientRect = () =>
+ ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 800,
+ bottom: 600,
+ width: 800,
+ height: 600,
+ toJSON: () => ({}),
+ }) as DOMRect;
+ Object.defineProperty(iframe, "clientWidth", { value: 800 });
+ Object.defineProperty(iframe, "clientHeight", { value: 600 });
+ canvas.appendChild(iframe);
+ const postMessage = vi.fn();
+ Object.defineProperty(iframe.contentWindow, "postMessage", {
+ configurable: true,
+ value: postMessage,
+ });
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ await act(async () => {
+ document
+ .querySelector("[data-review-click-plane]")
+ ?.dispatchEvent(
+ new MouseEvent("click", {
+ bubbles: true,
+ clientX: 400,
+ clientY: 300,
+ }),
+ );
+ });
+
+ const bridgeRequest = postMessage.mock.calls
+ .map(([message]) => message as Record)
+ .find(
+ (message) => message.type === "agent-native:review-anchor-at-point",
+ );
+ expect(bridgeRequest).toMatchObject({ x: 400, y: 300 });
+
+ await act(async () => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ source: iframe.contentWindow,
+ data: {
+ type: "agent-native:review-anchor-at-point-result",
+ correlationId: bridgeRequest?.correlationId,
+ nodeId: "hero-title",
+ layerName: "Hero title",
+ tagName: "h1",
+ },
+ }),
+ );
+ document
+ .querySelector("[data-review-test-type]")
+ ?.click();
+ });
+ await act(async () => {
+ document
+ .querySelector("[data-review-test-submit]")
+ ?.click();
+ });
+
+ expect(mocks.createMutate.mock.calls[0]?.[0]).toMatchObject({
+ anchor: {
+ nodeId: "hero-title",
+ point: { xPct: 50, yPct: 50 },
+ },
+ metadata: { layerName: "Hero title", tagName: "h1" },
+ });
+ });
+});
diff --git a/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx b/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx
new file mode 100644
index 0000000000..bbd6830ab4
--- /dev/null
+++ b/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx
@@ -0,0 +1,1077 @@
+import {
+ buildReviewThreads,
+ ReviewCommentComposer,
+ useCreateReviewComment,
+ useReplyReviewComment,
+ useResolveReviewThread,
+ useReviewComments,
+ useT,
+ type ReviewThread,
+} from "@agent-native/core/client";
+import type { ReviewComment } from "@agent-native/core/review";
+import {
+ IconArrowUp,
+ IconCircleCheck,
+ IconMessageCircle,
+ IconRobot,
+ IconX,
+} from "@tabler/icons-react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { toast } from "sonner";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { cn } from "@/lib/utils";
+
+import {
+ resolveReviewAnchor,
+ type DesignReviewAnchor,
+ type ReviewAnchorPoint,
+} from "../../../shared/review-anchor";
+import {
+ getReviewPopoverPlacement,
+ placeReviewDraftPin,
+ type ReviewDraftPin,
+} from "./review-canvas-state";
+
+export interface ReviewFocusRequest {
+ nonce: number;
+ anchor: unknown;
+ targetId?: string;
+}
+
+interface ReviewCanvasPinsProps {
+ active: boolean;
+ hidden?: boolean;
+ onClose: () => void;
+ canvasSelector?: string;
+ resourceType: string;
+ resourceId: string;
+ targetId: string;
+ canPost: boolean;
+ canResolve: boolean;
+ focusRequest?: ReviewFocusRequest | null;
+ onDispatchCommentToAgent?: (comment: ReviewComment) => void;
+ onSendThreadToAgent?: (thread: ReviewThread) => void;
+ sendingThreadId?: string | null;
+}
+
+interface PinPosition {
+ point: ReviewAnchorPoint;
+ source: "node" | "point";
+}
+
+interface ReviewFrameNodeGeometry {
+ rect: { left: number; top: number; width: number; height: number };
+ viewportWidth: number;
+ viewportHeight: number;
+}
+
+type ReviewPopoverPlacement = ReturnType;
+
+function findNodeElement(canvas: HTMLElement, nodeId: string): Element | null {
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ try {
+ const document = iframe?.contentDocument;
+ if (!document) return null;
+ const escape = globalThis.CSS?.escape;
+ if (escape) {
+ return document.querySelector(
+ `[data-agent-native-node-id="${escape(nodeId)}"],` +
+ `[data-code-layer-id="${escape(nodeId)}"],` +
+ `[data-layer-id="${escape(nodeId)}"],` +
+ `[data-builder-id="${escape(nodeId)}"],#${escape(nodeId)}`,
+ );
+ }
+ return (
+ Array.from(
+ document.querySelectorAll(
+ "[data-agent-native-node-id],[data-code-layer-id],[data-layer-id],[data-builder-id],[id]",
+ ),
+ ).find((element) =>
+ [
+ "data-agent-native-node-id",
+ "data-code-layer-id",
+ "data-layer-id",
+ "data-builder-id",
+ "id",
+ ].some((attribute) => element.getAttribute(attribute) === nodeId),
+ ) ?? null
+ );
+ } catch {
+ return null;
+ }
+}
+
+function nodePoint(
+ canvas: HTMLElement,
+ nodeId: string,
+ frameGeometry?: ReviewFrameNodeGeometry,
+): ReviewAnchorPoint | null {
+ const element = findNodeElement(canvas, nodeId);
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ if (!iframe) return null;
+ const canvasRect = canvas.getBoundingClientRect();
+ const iframeRect = iframe.getBoundingClientRect();
+ if (canvasRect.width <= 0 || canvasRect.height <= 0) return null;
+ const elementRect = element?.getBoundingClientRect() ?? frameGeometry?.rect;
+ if (!elementRect) return null;
+ const scaleX =
+ iframeRect.width /
+ Math.max(1, frameGeometry?.viewportWidth ?? iframe.clientWidth);
+ const scaleY =
+ iframeRect.height /
+ Math.max(1, frameGeometry?.viewportHeight ?? iframe.clientHeight);
+ return {
+ xPct:
+ ((iframeRect.left +
+ elementRect.left * scaleX +
+ (elementRect.width * scaleX) / 2 -
+ canvasRect.left) /
+ canvasRect.width) *
+ 100,
+ yPct:
+ ((iframeRect.top +
+ elementRect.top * scaleY +
+ (elementRect.height * scaleY) / 2 -
+ canvasRect.top) /
+ canvasRect.height) *
+ 100,
+ };
+}
+
+function elementAnchorAtPoint(
+ canvas: HTMLElement,
+ clientX: number,
+ clientY: number,
+): { nodeId?: string; layerName?: string; tagName?: string } {
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ try {
+ const document = iframe?.contentDocument;
+ const iframeRect = iframe?.getBoundingClientRect();
+ if (!document || !iframe || !iframeRect) return {};
+ const scaleX = iframe.clientWidth / Math.max(1, iframeRect.width);
+ const scaleY = iframe.clientHeight / Math.max(1, iframeRect.height);
+ const element = document.elementFromPoint(
+ (clientX - iframeRect.left) * scaleX,
+ (clientY - iframeRect.top) * scaleY,
+ );
+ const anchor = element?.closest(
+ "[data-agent-native-node-id],[data-code-layer-id],[data-layer-id],[data-builder-id],[id]",
+ );
+ if (!anchor) return {};
+ const nodeId =
+ anchor.getAttribute("data-agent-native-node-id") ??
+ anchor.getAttribute("data-code-layer-id") ??
+ anchor.getAttribute("data-layer-id") ??
+ anchor.getAttribute("data-builder-id") ??
+ anchor.getAttribute("id") ??
+ undefined;
+ const layerName =
+ anchor.getAttribute("data-agent-native-layer-name") ?? undefined;
+ return {
+ ...(nodeId ? { nodeId } : {}),
+ ...(layerName ? { layerName } : {}),
+ tagName: anchor.tagName.toLowerCase(),
+ };
+ } catch {
+ return {};
+ }
+}
+
+function anchorAtPoint(
+ canvas: HTMLElement,
+ clientX: number,
+ clientY: number,
+): { anchor: DesignReviewAnchor; metadata: Record } | null {
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return null;
+ const xPct = ((clientX - rect.left) / rect.width) * 100;
+ const yPct = ((clientY - rect.top) / rect.height) * 100;
+ if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null; // i18n-ignore canvas coordinate guard
+ const element = elementAnchorAtPoint(canvas, clientX, clientY);
+ return {
+ anchor: {
+ ...(element.nodeId ? { nodeId: element.nodeId } : {}),
+ point: { xPct, yPct },
+ },
+ metadata: {
+ ...(element.layerName ? { layerName: element.layerName } : {}),
+ ...(element.tagName ? { tagName: element.tagName } : {}),
+ },
+ };
+}
+
+function resolvePinPosition(
+ canvas: HTMLElement,
+ anchor: unknown,
+ frameGeometry: Record,
+): PinPosition | null {
+ const resolved = resolveReviewAnchor(anchor, (nodeId) =>
+ nodePoint(canvas, nodeId, frameGeometry[nodeId]),
+ );
+ return resolved ? { point: resolved.point, source: resolved.source } : null;
+}
+
+export function ReviewCanvasPins({
+ active,
+ hidden = false,
+ onClose,
+ canvasSelector,
+ resourceType,
+ resourceId,
+ targetId,
+ canPost,
+ canResolve,
+ focusRequest,
+ onDispatchCommentToAgent,
+ onSendThreadToAgent,
+ sendingThreadId,
+}: ReviewCanvasPinsProps) {
+ const t = useT();
+ const comments = useReviewComments(
+ {
+ resourceType,
+ resourceId,
+ targetId,
+ includeResolved: false,
+ limit: 500,
+ },
+ {
+ enabled: Boolean(!hidden && resourceType && resourceId && targetId),
+ },
+ );
+ const createComment = useCreateReviewComment();
+ const replyComment = useReplyReviewComment();
+ const resolveThread = useResolveReviewThread();
+ const [canvas, setCanvas] = useState(null);
+ const [layoutTick, setLayoutTick] = useState(0);
+ const [draftPin, setDraftPin] = useState(null);
+ const [draftComposerOpen, setDraftComposerOpen] = useState(false);
+ const [activeThreadId, setActiveThreadId] = useState(null);
+ const [replyDraft, setReplyDraft] = useState("");
+ const [frameNodeGeometry, setFrameNodeGeometry] = useState<
+ Record
+ >({});
+ const lastFocusNonceRef = useRef(null);
+ const pendingFocusNonceRef = useRef(null);
+ const frameCallbacksRef = useRef<
+ Map) => void>
+ >(new Map());
+
+ const cancelDraft = useCallback(() => {
+ setDraftPin(null);
+ setDraftComposerOpen(false);
+ }, []);
+
+ const threads = useMemo(
+ () => buildReviewThreads(comments.data?.comments ?? []),
+ [comments.data?.comments],
+ );
+
+ useEffect(() => {
+ if (!canvasSelector) {
+ setCanvas(null);
+ return;
+ }
+ const findCanvas = () => {
+ setCanvas(document.querySelector(canvasSelector) as HTMLElement | null);
+ };
+ findCanvas();
+ const timer = window.setTimeout(findCanvas, 60);
+ return () => window.clearTimeout(timer);
+ }, [canvasSelector, targetId]);
+
+ useEffect(() => {
+ if (!canvas) return;
+ let animationFrame = 0;
+ const bump = () => {
+ if (animationFrame) return;
+ animationFrame = window.requestAnimationFrame(() => {
+ animationFrame = 0;
+ setLayoutTick((current) => current + 1);
+ });
+ };
+ const resizeObserver = new ResizeObserver(bump);
+ resizeObserver.observe(canvas);
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ if (iframe) resizeObserver.observe(iframe);
+ window.addEventListener("resize", bump);
+ window.addEventListener("scroll", bump, { capture: true, passive: true });
+ iframe?.addEventListener("load", bump);
+ return () => {
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
+ resizeObserver.disconnect();
+ window.removeEventListener("resize", bump);
+ window.removeEventListener("scroll", bump, true);
+ iframe?.removeEventListener("load", bump);
+ };
+ }, [canvas]);
+
+ useEffect(() => {
+ if (!canvas) return;
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ if (!iframe) return;
+ const handleMessage = (event: MessageEvent) => {
+ if (event.source !== iframe.contentWindow || !event.data) return;
+ if (event.data.type === "agent-native:review-layout") {
+ setLayoutTick((current) => current + 1);
+ return;
+ }
+ if (event.data.type === "agent-native:review-node-rects-result") {
+ const viewportWidth = Number(event.data.viewportWidth);
+ const viewportHeight = Number(event.data.viewportHeight);
+ const rects = event.data.rects;
+ if (
+ !rects ||
+ typeof rects !== "object" ||
+ !Number.isFinite(viewportWidth) ||
+ !Number.isFinite(viewportHeight)
+ ) {
+ return;
+ }
+ const next: Record = {};
+ for (const [nodeId, rawRect] of Object.entries(rects)) {
+ if (!rawRect || typeof rawRect !== "object") continue;
+ const rect = rawRect as Record;
+ const left = Number(rect.left);
+ const top = Number(rect.top);
+ const width = Number(rect.width);
+ const height = Number(rect.height);
+ if (![left, top, width, height].every(Number.isFinite)) continue;
+ next[nodeId] = {
+ rect: { left, top, width, height },
+ viewportWidth,
+ viewportHeight,
+ };
+ }
+ setFrameNodeGeometry(next);
+ return;
+ }
+ const correlationId = event.data.correlationId;
+ if (typeof correlationId !== "string") return;
+ const callback = frameCallbacksRef.current.get(correlationId);
+ if (!callback) return;
+ frameCallbacksRef.current.delete(correlationId);
+ callback(event.data as Record);
+ };
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, [canvas]);
+
+ const anchoredNodeIds = useMemo(() => {
+ const nodeIds = new Set();
+ for (const thread of threads) {
+ const resolved = resolveReviewAnchor(thread.root.anchor, () => null);
+ if (resolved?.anchor.nodeId) nodeIds.add(resolved.anchor.nodeId);
+ }
+ const draft = draftPin
+ ? resolveReviewAnchor(draftPin.anchor, () => null)
+ : null;
+ if (draft?.anchor.nodeId) nodeIds.add(draft.anchor.nodeId);
+ return [...nodeIds].sort();
+ }, [draftPin, threads]);
+
+ useEffect(() => {
+ if (!canvas || anchoredNodeIds.length === 0) {
+ setFrameNodeGeometry((current) =>
+ Object.keys(current).length > 0 ? {} : current,
+ );
+ return;
+ }
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ iframe?.contentWindow?.postMessage(
+ {
+ type: "agent-native:review-node-rects",
+ correlationId: crypto.randomUUID(),
+ nodeIds: anchoredNodeIds,
+ },
+ "*",
+ );
+ }, [anchoredNodeIds, canvas, layoutTick, targetId]);
+
+ const focusAnchor = useCallback(
+ (anchor: unknown, nonce: number): boolean => {
+ if (!canvas) return false;
+ const resolved = resolveReviewAnchor(anchor, (nodeId) =>
+ nodePoint(canvas, nodeId, frameNodeGeometry[nodeId]),
+ );
+ if (!resolved) return true;
+ if (resolved.anchor.nodeId) {
+ const element = findNodeElement(canvas, resolved.anchor.nodeId);
+ if (element instanceof HTMLElement || element instanceof SVGElement) {
+ element.scrollIntoView({ block: "center", inline: "center" });
+ const previousBoxShadow = element.style.boxShadow;
+ element.style.boxShadow =
+ "0 0 0 2px var(--design-editor-accent-color, #2563eb)";
+ window.setTimeout(() => {
+ element.style.boxShadow = previousBoxShadow;
+ }, 700);
+ return true;
+ }
+ if (pendingFocusNonceRef.current === nonce) return false;
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ if (!iframe?.contentWindow) return false;
+ const correlationId = crypto.randomUUID();
+ pendingFocusNonceRef.current = nonce;
+ frameCallbacksRef.current.set(correlationId, (payload) => {
+ pendingFocusNonceRef.current = null;
+ if (payload.focused === true) {
+ lastFocusNonceRef.current = nonce;
+ setLayoutTick((current) => current + 1);
+ }
+ });
+ iframe.contentWindow.postMessage(
+ {
+ type: "agent-native:review-focus",
+ correlationId,
+ nodeId: resolved.anchor.nodeId,
+ },
+ "*",
+ );
+ return false;
+ }
+ return true;
+ },
+ [canvas, frameNodeGeometry],
+ );
+
+ useEffect(() => {
+ if (
+ !focusRequest ||
+ focusRequest.nonce === lastFocusNonceRef.current ||
+ (focusRequest.targetId && focusRequest.targetId !== targetId)
+ )
+ return;
+ if (focusAnchor(focusRequest.anchor, focusRequest.nonce)) {
+ lastFocusNonceRef.current = focusRequest.nonce;
+ }
+ }, [focusAnchor, focusRequest, layoutTick, targetId]);
+
+ useEffect(() => {
+ if (!active) cancelDraft();
+ }, [active, cancelDraft]);
+
+ useEffect(() => {
+ if (!active && !activeThreadId && !draftComposerOpen) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ if (draftComposerOpen && draftPin) {
+ cancelDraft();
+ return;
+ }
+ if (activeThreadId) {
+ setActiveThreadId(null);
+ setReplyDraft("");
+ return;
+ }
+ if (active) onClose();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [
+ active,
+ activeThreadId,
+ cancelDraft,
+ draftComposerOpen,
+ draftPin,
+ onClose,
+ ]);
+
+ useEffect(() => {
+ cancelDraft();
+ setActiveThreadId(null);
+ setReplyDraft("");
+ setFrameNodeGeometry({});
+ frameCallbacksRef.current.clear();
+ pendingFocusNonceRef.current = null;
+ }, [cancelDraft, resourceId, targetId]);
+
+ useEffect(() => {
+ if (!hidden) return;
+ cancelDraft();
+ setActiveThreadId(null);
+ setReplyDraft("");
+ if (active) onClose();
+ }, [active, cancelDraft, hidden, onClose]);
+
+ const dropPin = useCallback(
+ (clientX: number, clientY: number) => {
+ if (!canvas || !canPost) return;
+ const next = anchorAtPoint(canvas, clientX, clientY);
+ if (!next) return;
+ setActiveThreadId(null);
+ setReplyDraft("");
+ setDraftPin((current) =>
+ placeReviewDraftPin(current, {
+ id: crypto.randomUUID(),
+ anchor: next.anchor,
+ metadata: next.metadata,
+ }),
+ );
+ setDraftComposerOpen(true);
+
+ if (next.anchor.nodeId) return;
+ const iframe = canvas.querySelector(
+ "iframe[data-design-preview-iframe]",
+ );
+ const iframeRect = iframe?.getBoundingClientRect();
+ if (!iframe?.contentWindow || !iframeRect?.width || !iframeRect.height) {
+ return;
+ }
+ const correlationId = crypto.randomUUID();
+ frameCallbacksRef.current.set(correlationId, (payload) => {
+ const nodeId =
+ typeof payload.nodeId === "string" ? payload.nodeId : undefined;
+ const layerName =
+ typeof payload.layerName === "string" ? payload.layerName : undefined;
+ const tagName =
+ typeof payload.tagName === "string" ? payload.tagName : undefined;
+ if (!nodeId && !layerName && !tagName) return;
+ setDraftPin((current) => {
+ if (
+ !current ||
+ current.anchor.point.xPct !== next.anchor.point.xPct ||
+ current.anchor.point.yPct !== next.anchor.point.yPct
+ ) {
+ return current;
+ }
+ return {
+ ...current,
+ anchor: {
+ ...(nodeId ? { nodeId } : {}),
+ point: current.anchor.point,
+ },
+ metadata: {
+ ...current.metadata,
+ ...(layerName ? { layerName } : {}),
+ ...(tagName ? { tagName } : {}),
+ },
+ };
+ });
+ });
+ window.setTimeout(
+ () => frameCallbacksRef.current.delete(correlationId),
+ 2_000,
+ );
+ iframe.contentWindow.postMessage(
+ {
+ type: "agent-native:review-anchor-at-point",
+ correlationId,
+ x:
+ (clientX - iframeRect.left) *
+ (iframe.clientWidth / iframeRect.width),
+ y:
+ (clientY - iframeRect.top) *
+ (iframe.clientHeight / iframeRect.height),
+ },
+ "*",
+ );
+ },
+ [canPost, canvas],
+ );
+
+ const postDraft = useCallback(
+ (pin: ReviewDraftPin) => {
+ const body = pin.draft.trim();
+ if (!body || createComment.isPending) return;
+ createComment.mutate(
+ {
+ resourceType,
+ resourceId,
+ targetId,
+ kind: "annotation",
+ anchor: pin.anchor,
+ body,
+ resolutionTarget: pin.resolutionTarget,
+ metadata: pin.metadata,
+ },
+ {
+ onSuccess: (comment) => {
+ cancelDraft();
+ if (pin.resolutionTarget === "agent") {
+ onDispatchCommentToAgent?.(comment);
+ }
+ },
+ onError: () => toast.error(t("review.postFailed")),
+ },
+ );
+ },
+ [
+ cancelDraft,
+ createComment,
+ onDispatchCommentToAgent,
+ resourceId,
+ resourceType,
+ t,
+ targetId,
+ ],
+ );
+
+ if (hidden || !canvas) return null;
+ const rect = canvas.getBoundingClientRect();
+ void layoutTick;
+
+ const openThreads = threads.filter(
+ (thread) => thread.root.status === "open" && thread.root.anchor,
+ );
+ const draftPinPosition = draftPin
+ ? resolvePinPosition(canvas, draftPin.anchor, frameNodeGeometry)
+ : null;
+
+ return (
+ <>
+ {active && canPost ? (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ dropPin(event.clientX, event.clientY);
+ }}
+ />
+ ) : null}
+ {active && canPost ? (
+
+
+ {t("review.clickToPin")}
+
+ {t("review.escToExit")}
+
+
+ ) : null}
+ {openThreads.map((thread, index) => {
+ const position = resolvePinPosition(
+ canvas,
+ thread.root.anchor,
+ frameNodeGeometry,
+ );
+ if (!position) return null;
+ return (
+
{
+ if (!draftPin?.draft.trim()) setDraftPin(null);
+ setDraftComposerOpen(false);
+ setReplyDraft("");
+ setActiveThreadId(thread.root.threadId);
+ }}
+ active={activeThreadId === thread.root.threadId}
+ >
+ {activeThreadId === thread.root.threadId ? (
+ {
+ setActiveThreadId(null);
+ setReplyDraft("");
+ }}
+ onReply={() => {
+ const body = replyDraft.trim();
+ if (!body) return;
+ replyComment.mutate(
+ {
+ resourceType,
+ resourceId,
+ commentId: thread.root.id,
+ body,
+ },
+ {
+ onSuccess: () => setReplyDraft(""),
+ onError: () => toast.error(t("review.replyFailed")),
+ },
+ );
+ }}
+ onResolve={() =>
+ resolveThread.mutate(
+ {
+ resourceType,
+ resourceId,
+ threadId: thread.root.threadId,
+ },
+ {
+ onSuccess: () => setActiveThreadId(null),
+ onError: () => toast.error(t("review.resolveFailed")),
+ },
+ )
+ }
+ onSendToAgent={
+ onSendThreadToAgent &&
+ (thread.root.resolutionTarget === "human" ||
+ Boolean(thread.root.consumedAt))
+ ? () => onSendThreadToAgent(thread)
+ : undefined
+ }
+ replying={replyComment.isPending}
+ resolving={resolveThread.isPending}
+ />
+ ) : null}
+
+ );
+ })}
+ {draftPin && draftPinPosition ? (
+
{
+ setActiveThreadId(null);
+ setReplyDraft("");
+ setDraftComposerOpen(true);
+ }}
+ active={draftComposerOpen}
+ >
+ {draftComposerOpen ? (
+
+ setDraftPin((current) =>
+ current ? { ...current, draft: value } : current,
+ )
+ }
+ onCancel={cancelDraft}
+ onSubmit={(resolutionTarget) => {
+ setDraftPin((current) =>
+ current ? { ...current, resolutionTarget } : current,
+ );
+ postDraft({ ...draftPin, resolutionTarget });
+ }}
+ resolutionTarget={draftPin.resolutionTarget}
+ placement={getReviewPopoverPlacement(draftPinPosition.point)}
+ submitting={createComment.isPending}
+ />
+ ) : null}
+
+ ) : null}
+ >
+ );
+}
+
+function ReviewPin({
+ index,
+ point,
+ canvasRect,
+ active,
+ draft = false,
+ onClick,
+ children,
+}: {
+ index: number;
+ point: ReviewAnchorPoint;
+ canvasRect: DOMRect;
+ active: boolean;
+ draft?: boolean;
+ onClick: () => void;
+ children?: ReactNode;
+}) {
+ const t = useT();
+ return (
+
+ {
+ event.stopPropagation();
+ onClick();
+ }}
+ aria-label={t("review.commentNumber", { count: index + 1 })}
+ >
+ {index + 1}
+
+ {children}
+
+ );
+}
+
+function DraftComposer({
+ value,
+ onChange,
+ onCancel,
+ onSubmit,
+ resolutionTarget,
+ placement,
+ submitting,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ onCancel: () => void;
+ onSubmit: (target: "agent" | "human") => void;
+ resolutionTarget: "agent" | "human";
+ placement: ReviewPopoverPlacement;
+ submitting: boolean;
+}) {
+ const t = useT();
+ return (
+
event.stopPropagation()}
+ >
+
+ {t("review.newComment")}
+
+
+
+
+
+
+ );
+}
+
+function ReviewThreadPopover({
+ thread,
+ canResolve,
+ sending,
+ replying,
+ resolving,
+ canReply,
+ placement,
+ replyDraft,
+ onReplyDraftChange,
+ onClose,
+ onReply,
+ onResolve,
+ onSendToAgent,
+}: {
+ thread: ReviewThread;
+ canResolve: boolean;
+ sending: boolean;
+ replying: boolean;
+ resolving: boolean;
+ canReply: boolean;
+ placement: ReviewPopoverPlacement;
+ replyDraft: string;
+ onReplyDraftChange: (value: string) => void;
+ onClose: () => void;
+ onReply: () => void;
+ onResolve: () => void;
+ onSendToAgent?: () => void;
+}) {
+ const t = useT();
+ const rootAuthor = reviewAuthorLabel(thread.root, t("review.reviewer"));
+ return (
+
event.stopPropagation()}
+ >
+
+
+
+ {reviewAuthorInitials(rootAuthor)}
+
+
+
+
+ {rootAuthor}
+
+
+ {thread.root.body}
+
+
+
+
+
+
+ {thread.replies.length ? (
+
+ {thread.replies.map((reply) => (
+
+
+ {reviewAuthorLabel(reply, t("review.reviewer"))}
+
+
+ {reply.body}
+
+
+ ))}
+
+ ) : null}
+ {canReply || (canResolve && thread.root.status === "open") ? (
+
+ {canReply ? (
+
+
+ onReplyDraftChange(event.currentTarget.value)
+ }
+ placeholder={t("review.replyPlaceholder")}
+ className="h-8 min-w-0 flex-1 bg-background text-xs"
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && replyDraft.trim() && !replying) {
+ event.preventDefault();
+ onReply();
+ }
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ event.preventDefault();
+ onClose();
+ }
+ }}
+ />
+
+ {replying ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null}
+ {canResolve && thread.root.status === "open" ? (
+
+ {onSendToAgent ? (
+
+ {sending ? (
+
+ ) : (
+
+ )}
+ {sending
+ ? t("review.sendingToAgent")
+ : t("review.sendToAgent")}
+
+ ) : null}
+
+
+ {resolving ? (
+
+ ) : (
+
+ )}
+ {resolving ? t("review.resolving") : t("review.resolve")}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+function reviewAuthorLabel(comment: ReviewComment, fallback: string): string {
+ return comment.authorName ?? comment.authorEmail ?? fallback;
+}
+
+function reviewAuthorInitials(value: string): string {
+ const localPart = value.split("@")[0]?.trim() ?? "";
+ const initials = localPart
+ .split(/[\s._+-]+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("");
+ return initials || "R";
+}
diff --git a/templates/design/app/components/visual-editor/index.ts b/templates/design/app/components/visual-editor/index.ts
index e5a7a20eaf..2319090907 100644
--- a/templates/design/app/components/visual-editor/index.ts
+++ b/templates/design/app/components/visual-editor/index.ts
@@ -1,3 +1,4 @@
export { SaveStatusIndicator } from "./SaveStatusIndicator";
export { DrawOverlay, type DrawAnnotation } from "./DrawOverlay";
export { CanvasCommentPins, type CanvasPin } from "./CanvasCommentPins";
+export { ReviewCanvasPins, type ReviewFocusRequest } from "./ReviewCanvasPins";
diff --git a/templates/design/app/components/visual-editor/review-canvas-state.spec.ts b/templates/design/app/components/visual-editor/review-canvas-state.spec.ts
new file mode 100644
index 0000000000..86447a5567
--- /dev/null
+++ b/templates/design/app/components/visual-editor/review-canvas-state.spec.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getReviewPopoverPlacement,
+ placeReviewDraftPin,
+ type ReviewDraftPin,
+} from "./review-canvas-state";
+
+const firstLocation = {
+ id: "draft-1",
+ anchor: { point: { xPct: 20, yPct: 30 } },
+ metadata: { layerName: "Hero" },
+};
+
+describe("review canvas draft state", () => {
+ it("creates one human-targeted draft at the clicked location", () => {
+ expect(placeReviewDraftPin(null, firstLocation)).toEqual({
+ ...firstLocation,
+ draft: "",
+ resolutionTarget: "human",
+ });
+ });
+
+ it("moves an empty draft instead of accumulating empty pins", () => {
+ const current = placeReviewDraftPin(null, firstLocation);
+ const moved = placeReviewDraftPin(current, {
+ id: "ignored-new-id",
+ anchor: { point: { xPct: 70, yPct: 80 } },
+ metadata: { layerName: "Footer" },
+ });
+
+ expect(moved.id).toBe(current.id);
+ expect(moved.anchor).toEqual({ point: { xPct: 70, yPct: 80 } });
+ expect(moved.metadata).toEqual({ layerName: "Footer" });
+ });
+
+ it("does not move or replace a draft after the reviewer starts typing", () => {
+ const current: ReviewDraftPin = {
+ ...placeReviewDraftPin(null, firstLocation),
+ draft: "Keep this feedback",
+ resolutionTarget: "agent",
+ };
+
+ expect(
+ placeReviewDraftPin(current, {
+ id: "draft-2",
+ anchor: { point: { xPct: 80, yPct: 90 } },
+ metadata: {},
+ }),
+ ).toBe(current);
+ });
+
+ it("opens popovers inward near the right and bottom canvas edges", () => {
+ expect(getReviewPopoverPlacement({ xPct: 95, yPct: 90 })).toEqual({
+ horizontal: "end",
+ vertical: "above",
+ });
+ expect(getReviewPopoverPlacement({ xPct: 20, yPct: 30 })).toEqual({
+ horizontal: "start",
+ vertical: "below",
+ });
+ });
+});
diff --git a/templates/design/app/components/visual-editor/review-canvas-state.ts b/templates/design/app/components/visual-editor/review-canvas-state.ts
new file mode 100644
index 0000000000..c2a29f74d4
--- /dev/null
+++ b/templates/design/app/components/visual-editor/review-canvas-state.ts
@@ -0,0 +1,42 @@
+import type {
+ DesignReviewAnchor,
+ ReviewAnchorPoint,
+} from "../../../shared/review-anchor";
+
+export interface ReviewDraftPin {
+ id: string;
+ anchor: DesignReviewAnchor;
+ draft: string;
+ resolutionTarget: "agent" | "human";
+ metadata: Record
;
+}
+
+export interface ReviewDraftLocation {
+ id: string;
+ anchor: DesignReviewAnchor;
+ metadata: Record;
+}
+
+export function placeReviewDraftPin(
+ current: ReviewDraftPin | null,
+ location: ReviewDraftLocation,
+): ReviewDraftPin {
+ if (current?.draft.trim()) return current;
+ return {
+ id: current?.id ?? location.id,
+ anchor: location.anchor,
+ draft: current?.draft ?? "",
+ resolutionTarget: current?.resolutionTarget ?? "human",
+ metadata: location.metadata,
+ };
+}
+
+export function getReviewPopoverPlacement(point: ReviewAnchorPoint): {
+ horizontal: "start" | "end";
+ vertical: "above" | "below";
+} {
+ return {
+ horizontal: point.xPct > 60 ? "end" : "start",
+ vertical: point.yPct > 65 ? "above" : "below",
+ };
+}
diff --git a/templates/design/app/hooks/use-design-systems.ts b/templates/design/app/hooks/use-design-systems.ts
index bf554617b9..7abc540b7c 100644
--- a/templates/design/app/hooks/use-design-systems.ts
+++ b/templates/design/app/hooks/use-design-systems.ts
@@ -10,10 +10,10 @@ type DesignSystemSummary = {
createdAt: string;
};
-export function useDesignSystems() {
+export function useDesignSystems(enabled = true) {
const { data, isLoading, error, refetch } = useActionQuery<{
designSystems: DesignSystemSummary[];
- }>("list-design-systems");
+ }>("list-design-systems", undefined, { enabled });
const designSystems: DesignSystemSummary[] = data?.designSystems ?? [];
const defaultSystem = designSystems.find((ds) => ds.isDefault);
diff --git a/templates/design/app/hooks/use-navigation-state.ts b/templates/design/app/hooks/use-navigation-state.ts
index 5c3ede04e4..d57a1fdc11 100644
--- a/templates/design/app/hooks/use-navigation-state.ts
+++ b/templates/design/app/hooks/use-navigation-state.ts
@@ -12,8 +12,8 @@ export interface NavigationState {
designSystemId?: string;
templateId?: string;
editorView?: "single" | "overview";
- inspectorTab?: "design" | "tweaks" | "extensions";
- inspector?: "design" | "tweaks" | "extensions";
+ inspectorTab?: "design" | "comments" | "tweaks" | "extensions";
+ inspector?: "design" | "comments" | "tweaks" | "extensions";
leftPanel?:
| "file"
| "agent"
@@ -54,8 +54,8 @@ export interface DesignEditorCommand {
designId: string;
editorView?: "single" | "overview";
viewMode?: "single" | "overview";
- inspectorTab?: "design" | "tweaks" | "extensions";
- inspector?: "design" | "tweaks" | "extensions";
+ inspectorTab?: "design" | "comments" | "tweaks" | "extensions";
+ inspector?: "design" | "comments" | "tweaks" | "extensions";
leftPanel?:
| "file"
| "agent"
@@ -118,8 +118,11 @@ function normalizeEditorView(
function normalizeInspectorTab(
value: unknown,
-): "design" | "tweaks" | "extensions" | undefined {
- return value === "design" || value === "tweaks" || value === "extensions"
+): "design" | "comments" | "tweaks" | "extensions" | undefined {
+ return value === "design" ||
+ value === "comments" ||
+ value === "tweaks" ||
+ value === "extensions"
? value
: undefined;
}
diff --git a/templates/design/app/hooks/use-question-flow.ts b/templates/design/app/hooks/use-question-flow.ts
index ae13630be1..f1d37fbc32 100644
--- a/templates/design/app/hooks/use-question-flow.ts
+++ b/templates/design/app/hooks/use-question-flow.ts
@@ -8,6 +8,7 @@ import { useCallback } from "react";
import { sendToDesignAgentChat } from "@/lib/agent-chat";
interface UseQuestionFlowOptions {
+ enabled?: boolean;
continuationTabId?: string | null;
onContinue?: (tabId: string) => void;
}
@@ -27,10 +28,15 @@ const RESPONSIVE_GENERATION_REQUIREMENTS =
*/
export function useQuestionFlow(
designId: string | undefined,
- { continuationTabId, onContinue }: UseQuestionFlowOptions = {},
+ {
+ enabled = true,
+ continuationTabId,
+ onContinue,
+ }: UseQuestionFlowOptions = {},
) {
const stateKey = designQuestionsStateKey(designId);
const flow = useGuidedQuestionFlow({
+ enabled,
stateKey,
queryKey: [stateKey],
submitMessage: "Here are my answers — go ahead.",
diff --git a/templates/design/app/i18n/ar-SA.ts b/templates/design/app/i18n/ar-SA.ts
index 725c896df6..966d1a97ea 100644
--- a/templates/design/app/i18n/ar-SA.ts
+++ b/templates/design/app/i18n/ar-SA.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["ar-SA"];
+const messages = {
+ ...messagesByLocale["ar-SA"],
+ review: {
+ comments: "التعليقات",
+ commentsTab: "التعليقات · {{count}}",
+ commentsTitle: "تعليقات المراجعة",
+ commentNumber: "تعليق المراجعة رقم {{count}}",
+ thisScreen: "هذه الشاشة",
+ allScreens: "جميع الشاشات",
+ signInToComment: "سجّل الدخول للتعليق",
+ panelTitle: "الملاحظات",
+ placeholder: "اترك ملاحظاتك…",
+ emptyState: "لا توجد تعليقات مراجعة بعد.",
+ clickToPin: "انقر في أي مكان لتثبيت ملاحظة",
+ escToExit: "اضغط Esc للخروج",
+ newComment: "تعليق جديد",
+ commentMode: "إضافة تعليق",
+ sendToAgent: "إرسال إلى الوكيل",
+ sendingToAgent: "جارٍ الإرسال إلى الوكيل…",
+ enterToPost: "اضغط Enter للنشر · وShift+Enter لسطر جديد",
+ post: "نشر",
+ posting: "جارٍ النشر…",
+ postFailed: "تعذر نشر هذا التعليق",
+ loading: "جارٍ تحميل التعليقات",
+ replyPlaceholder: "الرد على هذه المحادثة…",
+ reply: "رد",
+ cancelReply: "إلغاء الرد",
+ replyFailed: "تعذر نشر هذا الرد",
+ resolve: "حل",
+ resolving: "جارٍ الحل…",
+ resolveFailed: "تعذر حل هذه المحادثة",
+ deleteComment: "حذف التعليق",
+ moreActions: "مزيد من الإجراءات",
+ resolved: "تم الحل",
+ reviewer: "المراجع",
+ applyFeedback: "تطبيق الملاحظات ({{count}})",
+ applyingFeedback: "جارٍ تطبيق الملاحظات…",
+ applyFeedbackFailed: "تعذر تطبيق ملاحظات المراجعة",
+ sendToAgentFailed: "تعذر إرسال هذه المحادثة إلى الوكيل",
+ shareLinkDescription:
+ "يمكن لأي شخص لديه هذا الرابط عرض التصميم. ويمكن للمراجعين المسجلين الدخول التعليق.",
+ presentComments: "التعليقات",
+ presentCommentMode: "وضع التعليق",
+ closeComments: "إغلاق التعليقات",
+ status: {
+ draft: "مسودة",
+ in_review: "قيد المراجعة",
+ approved: "تمت الموافقة",
+ changes_requested: "طُلبت تغييرات",
+ change: "تغيير حالة المراجعة",
+ saveFailed: "تعذر تحديث حالة المراجعة",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/de-DE.ts b/templates/design/app/i18n/de-DE.ts
index 9eb8c9b026..879b9e630f 100644
--- a/templates/design/app/i18n/de-DE.ts
+++ b/templates/design/app/i18n/de-DE.ts
@@ -1,3 +1,59 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["de-DE"];
+const messages = {
+ ...messagesByLocale["de-DE"],
+ review: {
+ comments: "Kommentare",
+ commentsTab: "Kommentare · {{count}}",
+ commentsTitle: "Prüfkommentare",
+ commentNumber: "Prüfkommentar {{count}}",
+ thisScreen: "Dieser Bildschirm",
+ allScreens: "Alle Bildschirme",
+ signInToComment: "Zum Kommentieren anmelden",
+ panelTitle: "Feedback",
+ placeholder: "Feedback hinterlassen…",
+ emptyState: "Noch keine Prüfkommentare.",
+ clickToPin: "Klicke an eine beliebige Stelle, um Feedback anzuheften",
+ escToExit: "Esc zum Beenden",
+ newComment: "Neuer Kommentar",
+ commentMode: "Kommentieren",
+ sendToAgent: "An den Agenten senden",
+ sendingToAgent: "Wird an den Agenten gesendet…",
+ enterToPost: "Mit Enter posten · Mit Umschalt+Enter neue Zeile",
+ post: "Posten",
+ posting: "Wird gepostet…",
+ postFailed: "Dieser Kommentar konnte nicht gepostet werden",
+ loading: "Kommentare werden geladen",
+ replyPlaceholder: "Auf diesen Thread antworten…",
+ reply: "Antworten",
+ cancelReply: "Antwort abbrechen",
+ replyFailed: "Diese Antwort konnte nicht gepostet werden",
+ resolve: "Erledigen",
+ resolving: "Wird erledigt…",
+ resolveFailed: "Dieser Thread konnte nicht erledigt werden",
+ deleteComment: "Kommentar löschen",
+ moreActions: "Weitere Aktionen",
+ resolved: "Erledigt",
+ reviewer: "Prüfende Person",
+ applyFeedback: "Feedback anwenden ({{count}})",
+ applyingFeedback: "Feedback wird angewendet…",
+ applyFeedbackFailed: "Prüfungsfeedback konnte nicht angewendet werden",
+ sendToAgentFailed:
+ "Dieser Thread konnte nicht an den Agenten gesendet werden",
+ shareLinkDescription:
+ "Jeder mit diesem Link kann das Design ansehen. Angemeldete Personen mit Prüfrechten können kommentieren.",
+ presentComments: "Kommentare",
+ presentCommentMode: "Kommentarmodus",
+ closeComments: "Kommentare schließen",
+ status: {
+ draft: "Entwurf",
+ in_review: "In Prüfung",
+ approved: "Freigegeben",
+ changes_requested: "Änderungen angefordert",
+ change: "Prüfstatus ändern",
+ saveFailed: "Prüfstatus konnte nicht aktualisiert werden",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/en-US.ts b/templates/design/app/i18n/en-US.ts
index 2f667bf5f2..d8bb2cd572 100644
--- a/templates/design/app/i18n/en-US.ts
+++ b/templates/design/app/i18n/en-US.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["en-US"];
+const messages = {
+ ...messagesByLocale["en-US"],
+ review: {
+ comments: "Comments",
+ commentsTab: "Comments · {{count}}",
+ commentsTitle: "Review comments",
+ commentNumber: "Review comment {{count}}",
+ thisScreen: "This screen",
+ allScreens: "All screens",
+ signInToComment: "Sign in to comment",
+ panelTitle: "Feedback",
+ placeholder: "Leave feedback…",
+ emptyState: "No review comments yet.",
+ clickToPin: "Click anywhere to pin feedback",
+ escToExit: "Esc to exit",
+ newComment: "New comment",
+ commentMode: "Comment",
+ sendToAgent: "Send to agent",
+ sendingToAgent: "Sending to agent…",
+ enterToPost: "Enter to post · Shift+Enter for a new line",
+ post: "Post",
+ posting: "Posting…",
+ postFailed: "Could not post this comment",
+ loading: "Loading comments",
+ replyPlaceholder: "Reply to this thread…",
+ reply: "Reply",
+ cancelReply: "Cancel reply",
+ replyFailed: "Could not post this reply",
+ resolve: "Resolve",
+ resolving: "Resolving…",
+ resolveFailed: "Could not resolve this thread",
+ deleteComment: "Delete comment",
+ moreActions: "More actions",
+ resolved: "Resolved",
+ reviewer: "Reviewer",
+ applyFeedback: "Apply feedback ({{count}})",
+ applyingFeedback: "Applying feedback…",
+ applyFeedbackFailed: "Could not apply review feedback",
+ sendToAgentFailed: "Could not send this thread to the agent",
+ shareLinkDescription:
+ "Anyone with this link can view the design. Signed-in reviewers can comment.",
+ presentComments: "Comments",
+ presentCommentMode: "Comment mode",
+ closeComments: "Close comments",
+ status: {
+ draft: "Draft",
+ in_review: "In review",
+ approved: "Approved",
+ changes_requested: "Changes requested",
+ change: "Change review status",
+ saveFailed: "Could not update review status",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/es-ES.ts b/templates/design/app/i18n/es-ES.ts
index d9f084317a..3be17ca269 100644
--- a/templates/design/app/i18n/es-ES.ts
+++ b/templates/design/app/i18n/es-ES.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["es-ES"];
+const messages = {
+ ...messagesByLocale["es-ES"],
+ review: {
+ comments: "Comentarios",
+ commentsTab: "Comentarios · {{count}}",
+ commentsTitle: "Comentarios de revisión",
+ commentNumber: "Comentario de revisión {{count}}",
+ thisScreen: "Esta pantalla",
+ allScreens: "Todas las pantallas",
+ signInToComment: "Inicia sesión para comentar",
+ panelTitle: "Opiniones",
+ placeholder: "Deja tus comentarios…",
+ emptyState: "Aún no hay comentarios de revisión.",
+ clickToPin: "Haz clic en cualquier lugar para fijar un comentario",
+ escToExit: "Pulsa Esc para salir",
+ newComment: "Nuevo comentario",
+ commentMode: "Comentar",
+ sendToAgent: "Enviar al agente",
+ sendingToAgent: "Enviando al agente…",
+ enterToPost: "Pulsa Enter para publicar · Shift+Enter para una línea nueva",
+ post: "Publicar",
+ posting: "Publicando…",
+ postFailed: "No se pudo publicar este comentario",
+ loading: "Cargando comentarios",
+ replyPlaceholder: "Responde a este hilo…",
+ reply: "Responder",
+ cancelReply: "Cancelar respuesta",
+ replyFailed: "No se pudo publicar esta respuesta",
+ resolve: "Resolver",
+ resolving: "Resolviendo…",
+ resolveFailed: "No se pudo resolver este hilo",
+ deleteComment: "Eliminar comentario",
+ moreActions: "Más acciones",
+ resolved: "Resuelto",
+ reviewer: "Revisor",
+ applyFeedback: "Aplicar comentarios ({{count}})",
+ applyingFeedback: "Aplicando comentarios…",
+ applyFeedbackFailed: "No se pudieron aplicar los comentarios de revisión",
+ sendToAgentFailed: "No se pudo enviar este hilo al agente",
+ shareLinkDescription:
+ "Cualquiera que tenga este enlace puede ver el diseño. Los revisores que hayan iniciado sesión pueden comentar.",
+ presentComments: "Comentarios",
+ presentCommentMode: "Modo de comentarios",
+ closeComments: "Cerrar comentarios",
+ status: {
+ draft: "Borrador",
+ in_review: "En revisión",
+ approved: "Aprobado",
+ changes_requested: "Cambios solicitados",
+ change: "Cambiar el estado de revisión",
+ saveFailed: "No se pudo actualizar el estado de revisión",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/fr-FR.ts b/templates/design/app/i18n/fr-FR.ts
index 61d26117c3..43828a3ee0 100644
--- a/templates/design/app/i18n/fr-FR.ts
+++ b/templates/design/app/i18n/fr-FR.ts
@@ -1,3 +1,59 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["fr-FR"];
+const messages = {
+ ...messagesByLocale["fr-FR"],
+ review: {
+ comments: "Commentaires",
+ commentsTab: "Commentaires · {{count}}",
+ commentsTitle: "Commentaires de révision",
+ commentNumber: "Commentaire de révision {{count}}",
+ thisScreen: "Cet écran",
+ allScreens: "Tous les écrans",
+ signInToComment: "Connectez-vous pour commenter",
+ panelTitle: "Retours",
+ placeholder: "Laissez un commentaire…",
+ emptyState: "Aucun commentaire de révision pour le moment.",
+ clickToPin: "Cliquez n’importe où pour épingler un commentaire",
+ escToExit: "Appuyez sur Esc pour quitter",
+ newComment: "Nouveau commentaire",
+ commentMode: "Commenter",
+ sendToAgent: "Envoyer à l’agent",
+ sendingToAgent: "Envoi à l’agent…",
+ enterToPost:
+ "Appuyez sur Entrée pour publier · Maj+Entrée pour insérer une nouvelle ligne",
+ post: "Publier",
+ posting: "Publication…",
+ postFailed: "Impossible de publier ce commentaire",
+ loading: "Chargement des commentaires",
+ replyPlaceholder: "Répondre à cette discussion…",
+ reply: "Répondre",
+ cancelReply: "Annuler la réponse",
+ replyFailed: "Impossible de publier cette réponse",
+ resolve: "Résoudre",
+ resolving: "Résolution…",
+ resolveFailed: "Impossible de résoudre cette discussion",
+ deleteComment: "Supprimer le commentaire",
+ moreActions: "Plus d’actions",
+ resolved: "Résolu",
+ reviewer: "Évaluateur",
+ applyFeedback: "Appliquer les retours ({{count}})",
+ applyingFeedback: "Application des retours…",
+ applyFeedbackFailed: "Impossible d’appliquer les retours de révision",
+ sendToAgentFailed: "Impossible d’envoyer cette discussion à l’agent",
+ shareLinkDescription:
+ "Toute personne disposant de ce lien peut voir le design. Les évaluateurs connectés peuvent commenter.",
+ presentComments: "Commentaires",
+ presentCommentMode: "Mode commentaire",
+ closeComments: "Fermer les commentaires",
+ status: {
+ draft: "Brouillon",
+ in_review: "En révision",
+ approved: "Approuvé",
+ changes_requested: "Modifications demandées",
+ change: "Modifier le statut de révision",
+ saveFailed: "Impossible de mettre à jour le statut de révision",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/hi-IN.ts b/templates/design/app/i18n/hi-IN.ts
index c4d8653bbb..867833c306 100644
--- a/templates/design/app/i18n/hi-IN.ts
+++ b/templates/design/app/i18n/hi-IN.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["hi-IN"];
+const messages = {
+ ...messagesByLocale["hi-IN"],
+ review: {
+ comments: "टिप्पणियाँ",
+ commentsTab: "टिप्पणियाँ · {{count}}",
+ commentsTitle: "समीक्षा टिप्पणियाँ",
+ commentNumber: "समीक्षा टिप्पणी {{count}}",
+ thisScreen: "यह स्क्रीन",
+ allScreens: "सभी स्क्रीन",
+ signInToComment: "टिप्पणी करने के लिए साइन इन करें",
+ panelTitle: "प्रतिक्रिया",
+ placeholder: "अपनी प्रतिक्रिया दें…",
+ emptyState: "अभी तक कोई समीक्षा टिप्पणी नहीं है।",
+ clickToPin: "प्रतिक्रिया पिन करने के लिए कहीं भी क्लिक करें",
+ escToExit: "बाहर निकलने के लिए Esc दबाएँ",
+ newComment: "नई टिप्पणी",
+ commentMode: "टिप्पणी करें",
+ sendToAgent: "एजेंट को भेजें",
+ sendingToAgent: "एजेंट को भेजा जा रहा है…",
+ enterToPost: "पोस्ट करने के लिए Enter · नई पंक्ति के लिए Shift+Enter",
+ post: "पोस्ट करें",
+ posting: "पोस्ट किया जा रहा है…",
+ postFailed: "यह टिप्पणी पोस्ट नहीं की जा सकी",
+ loading: "टिप्पणियाँ लोड हो रही हैं",
+ replyPlaceholder: "इस थ्रेड का उत्तर दें…",
+ reply: "उत्तर दें",
+ cancelReply: "उत्तर रद्द करें",
+ replyFailed: "यह उत्तर पोस्ट नहीं किया जा सका",
+ resolve: "हल करें",
+ resolving: "हल किया जा रहा है…",
+ resolveFailed: "यह थ्रेड हल नहीं किया जा सका",
+ deleteComment: "टिप्पणी हटाएँ",
+ moreActions: "अधिक कार्रवाइयाँ",
+ resolved: "हल किया गया",
+ reviewer: "समीक्षक",
+ applyFeedback: "प्रतिक्रिया लागू करें ({{count}})",
+ applyingFeedback: "प्रतिक्रिया लागू की जा रही है…",
+ applyFeedbackFailed: "समीक्षा प्रतिक्रिया लागू नहीं की जा सकी",
+ sendToAgentFailed: "यह थ्रेड एजेंट को नहीं भेजा जा सका",
+ shareLinkDescription:
+ "इस लिंक वाला कोई भी व्यक्ति डिज़ाइन देख सकता है। साइन इन किए हुए समीक्षक टिप्पणी कर सकते हैं।",
+ presentComments: "टिप्पणियाँ",
+ presentCommentMode: "टिप्पणी मोड",
+ closeComments: "टिप्पणियाँ बंद करें",
+ status: {
+ draft: "मसौदा",
+ in_review: "समीक्षाधीन",
+ approved: "स्वीकृत",
+ changes_requested: "बदलाव का अनुरोध किया गया",
+ change: "समीक्षा स्थिति बदलें",
+ saveFailed: "समीक्षा स्थिति अपडेट नहीं की जा सकी",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/ja-JP.ts b/templates/design/app/i18n/ja-JP.ts
index e818c97ddf..d641b577eb 100644
--- a/templates/design/app/i18n/ja-JP.ts
+++ b/templates/design/app/i18n/ja-JP.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["ja-JP"];
+const messages = {
+ ...messagesByLocale["ja-JP"],
+ review: {
+ comments: "コメント",
+ commentsTab: "コメント · {{count}}",
+ commentsTitle: "レビューコメント",
+ commentNumber: "レビューコメント {{count}}",
+ thisScreen: "この画面",
+ allScreens: "すべての画面",
+ signInToComment: "コメントするにはサインイン",
+ panelTitle: "フィードバック",
+ placeholder: "フィードバックを入力…",
+ emptyState: "レビューコメントはまだありません。",
+ clickToPin: "任意の場所をクリックしてフィードバックを固定",
+ escToExit: "Esc で終了",
+ newComment: "新しいコメント",
+ commentMode: "コメント",
+ sendToAgent: "エージェントに送信",
+ sendingToAgent: "エージェントに送信中…",
+ enterToPost: "Enter で投稿 · Shift+Enter で改行",
+ post: "投稿",
+ posting: "投稿中…",
+ postFailed: "このコメントを投稿できませんでした",
+ loading: "コメントを読み込み中",
+ replyPlaceholder: "このスレッドに返信…",
+ reply: "返信",
+ cancelReply: "返信をキャンセル",
+ replyFailed: "この返信を投稿できませんでした",
+ resolve: "解決",
+ resolving: "解決中…",
+ resolveFailed: "このスレッドを解決できませんでした",
+ deleteComment: "コメントを削除",
+ moreActions: "その他の操作",
+ resolved: "解決済み",
+ reviewer: "レビュアー",
+ applyFeedback: "フィードバックを適用 ({{count}})",
+ applyingFeedback: "フィードバックを適用中…",
+ applyFeedbackFailed: "レビューフィードバックを適用できませんでした",
+ sendToAgentFailed: "このスレッドをエージェントに送信できませんでした",
+ shareLinkDescription:
+ "このリンクを知っている人は誰でもデザインを閲覧できます。サインイン済みのレビュアーはコメントできます。",
+ presentComments: "コメント",
+ presentCommentMode: "コメントモード",
+ closeComments: "コメントを閉じる",
+ status: {
+ draft: "下書き",
+ in_review: "レビュー中",
+ approved: "承認済み",
+ changes_requested: "変更をリクエスト",
+ change: "レビューステータスを変更",
+ saveFailed: "レビューステータスを更新できませんでした",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/ko-KR.ts b/templates/design/app/i18n/ko-KR.ts
index d1651f8da3..684c132e9c 100644
--- a/templates/design/app/i18n/ko-KR.ts
+++ b/templates/design/app/i18n/ko-KR.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["ko-KR"];
+const messages = {
+ ...messagesByLocale["ko-KR"],
+ review: {
+ comments: "댓글",
+ commentsTab: "댓글 · {{count}}",
+ commentsTitle: "검토 댓글",
+ commentNumber: "검토 댓글 {{count}}",
+ thisScreen: "이 화면",
+ allScreens: "모든 화면",
+ signInToComment: "댓글을 작성하려면 로그인",
+ panelTitle: "피드백",
+ placeholder: "피드백을 남겨 주세요…",
+ emptyState: "아직 검토 댓글이 없습니다.",
+ clickToPin: "아무 곳이나 클릭하여 피드백 고정",
+ escToExit: "Esc를 눌러 종료",
+ newComment: "새 댓글",
+ commentMode: "댓글 달기",
+ sendToAgent: "에이전트에게 보내기",
+ sendingToAgent: "에이전트에게 보내는 중…",
+ enterToPost: "Enter로 게시 · Shift+Enter로 줄 바꿈",
+ post: "게시",
+ posting: "게시 중…",
+ postFailed: "이 댓글을 게시할 수 없습니다",
+ loading: "댓글 불러오는 중",
+ replyPlaceholder: "이 스레드에 답글 작성…",
+ reply: "답글",
+ cancelReply: "답글 취소",
+ replyFailed: "이 답글을 게시할 수 없습니다",
+ resolve: "해결",
+ resolving: "해결 중…",
+ resolveFailed: "이 스레드를 해결할 수 없습니다",
+ deleteComment: "댓글 삭제",
+ moreActions: "추가 작업",
+ resolved: "해결됨",
+ reviewer: "검토자",
+ applyFeedback: "피드백 적용 ({{count}})",
+ applyingFeedback: "피드백 적용 중…",
+ applyFeedbackFailed: "검토 피드백을 적용할 수 없습니다",
+ sendToAgentFailed: "이 스레드를 에이전트에게 보낼 수 없습니다",
+ shareLinkDescription:
+ "이 링크가 있는 사람은 누구나 디자인을 볼 수 있습니다. 로그인한 검토자는 댓글을 남길 수 있습니다.",
+ presentComments: "댓글",
+ presentCommentMode: "댓글 모드",
+ closeComments: "댓글 닫기",
+ status: {
+ draft: "초안",
+ in_review: "검토 중",
+ approved: "승인됨",
+ changes_requested: "변경 요청됨",
+ change: "검토 상태 변경",
+ saveFailed: "검토 상태를 업데이트할 수 없습니다",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/pt-BR.ts b/templates/design/app/i18n/pt-BR.ts
index 25fa1f992c..6b99bc6a42 100644
--- a/templates/design/app/i18n/pt-BR.ts
+++ b/templates/design/app/i18n/pt-BR.ts
@@ -1,3 +1,59 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["pt-BR"];
+const messages = {
+ ...messagesByLocale["pt-BR"],
+ review: {
+ comments: "Comentários",
+ commentsTab: "Comentários · {{count}}",
+ commentsTitle: "Comentários da revisão",
+ commentNumber: "Comentário da revisão {{count}}",
+ thisScreen: "Esta tela",
+ allScreens: "Todas as telas",
+ signInToComment: "Entre para comentar",
+ panelTitle: "Feedback",
+ placeholder: "Deixe seu feedback…",
+ emptyState: "Ainda não há comentários de revisão.",
+ clickToPin: "Clique em qualquer lugar para fixar o feedback",
+ escToExit: "Pressione Esc para sair",
+ newComment: "Novo comentário",
+ commentMode: "Comentar",
+ sendToAgent: "Enviar ao agente",
+ sendingToAgent: "Enviando ao agente…",
+ enterToPost:
+ "Pressione Enter para publicar · Shift+Enter para uma nova linha",
+ post: "Publicar",
+ posting: "Publicando…",
+ postFailed: "Não foi possível publicar este comentário",
+ loading: "Carregando comentários",
+ replyPlaceholder: "Responda a esta conversa…",
+ reply: "Responder",
+ cancelReply: "Cancelar resposta",
+ replyFailed: "Não foi possível publicar esta resposta",
+ resolve: "Resolver",
+ resolving: "Resolvendo…",
+ resolveFailed: "Não foi possível resolver esta conversa",
+ deleteComment: "Excluir comentário",
+ moreActions: "Mais ações",
+ resolved: "Resolvido",
+ reviewer: "Revisor",
+ applyFeedback: "Aplicar feedback ({{count}})",
+ applyingFeedback: "Aplicando feedback…",
+ applyFeedbackFailed: "Não foi possível aplicar o feedback da revisão",
+ sendToAgentFailed: "Não foi possível enviar esta conversa ao agente",
+ shareLinkDescription:
+ "Qualquer pessoa com este link pode ver o design. Revisores que entraram na conta podem comentar.",
+ presentComments: "Comentários",
+ presentCommentMode: "Modo de comentários",
+ closeComments: "Fechar comentários",
+ status: {
+ draft: "Rascunho",
+ in_review: "Em revisão",
+ approved: "Aprovado",
+ changes_requested: "Alterações solicitadas",
+ change: "Alterar o status da revisão",
+ saveFailed: "Não foi possível atualizar o status da revisão",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/zh-CN.ts b/templates/design/app/i18n/zh-CN.ts
index 1614fe60e9..999fae2317 100644
--- a/templates/design/app/i18n/zh-CN.ts
+++ b/templates/design/app/i18n/zh-CN.ts
@@ -1,3 +1,58 @@
import { messagesByLocale } from "../i18n-data";
-export default messagesByLocale["zh-CN"];
+const messages = {
+ ...messagesByLocale["zh-CN"],
+ review: {
+ comments: "评论",
+ commentsTab: "评论 · {{count}}",
+ commentsTitle: "审阅评论",
+ commentNumber: "审阅评论 {{count}}",
+ thisScreen: "当前画面",
+ allScreens: "所有画面",
+ signInToComment: "登录后评论",
+ panelTitle: "反馈",
+ placeholder: "留下反馈…",
+ emptyState: "暂无审阅评论。",
+ clickToPin: "点击任意位置固定反馈",
+ escToExit: "按 Esc 退出",
+ newComment: "新评论",
+ commentMode: "评论",
+ sendToAgent: "发送给智能体",
+ sendingToAgent: "正在发送给智能体…",
+ enterToPost: "按 Enter 发布 · 按 Shift+Enter 换行",
+ post: "发布",
+ posting: "正在发布…",
+ postFailed: "无法发布此评论",
+ loading: "正在加载评论",
+ replyPlaceholder: "回复此讨论…",
+ reply: "回复",
+ cancelReply: "取消回复",
+ replyFailed: "无法发布此回复",
+ resolve: "解决",
+ resolving: "正在解决…",
+ resolveFailed: "无法解决此讨论",
+ deleteComment: "删除评论",
+ moreActions: "更多操作",
+ resolved: "已解决",
+ reviewer: "审阅者",
+ applyFeedback: "应用反馈 ({{count}})",
+ applyingFeedback: "正在应用反馈…",
+ applyFeedbackFailed: "无法应用审阅反馈",
+ sendToAgentFailed: "无法将此讨论发送给智能体",
+ shareLinkDescription:
+ "任何获得此链接的人都可以查看设计。已登录的审阅者可以发表评论。",
+ presentComments: "评论",
+ presentCommentMode: "评论模式",
+ closeComments: "关闭评论",
+ status: {
+ draft: "草稿",
+ in_review: "审阅中",
+ approved: "已批准",
+ changes_requested: "已请求更改",
+ change: "更改审阅状态",
+ saveFailed: "无法更新审阅状态",
+ },
+ },
+};
+
+export default messages;
diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts
index a80e600e09..5297dc96e1 100644
--- a/templates/design/app/i18n/zh-TW.ts
+++ b/templates/design/app/i18n/zh-TW.ts
@@ -43,6 +43,57 @@ const messages = {
notFoundBackToDesigns: "返回設計",
teamCreateOrgDescription: "設定團隊,與同事共用設計。",
},
+ review: {
+ comments: "評論",
+ commentsTab: "評論 · {{count}}",
+ commentsTitle: "審閱評論",
+ commentNumber: "審閱評論 {{count}}",
+ thisScreen: "目前畫面",
+ allScreens: "所有畫面",
+ signInToComment: "登入後評論",
+ panelTitle: "意見回饋",
+ placeholder: "留下意見…",
+ emptyState: "目前沒有審閱評論。",
+ clickToPin: "點選任意位置以固定意見",
+ escToExit: "按 Esc 退出",
+ newComment: "新增評論",
+ commentMode: "評論",
+ sendToAgent: "傳送給代理",
+ sendingToAgent: "正在傳送給代理…",
+ enterToPost: "按 Enter 發布 · 按 Shift+Enter 換行",
+ post: "發布",
+ posting: "正在發布…",
+ postFailed: "無法發布這則評論",
+ loading: "正在載入評論",
+ replyPlaceholder: "回覆此討論串…",
+ reply: "回覆",
+ cancelReply: "取消回覆",
+ replyFailed: "無法發布這則回覆",
+ resolve: "解決",
+ resolving: "正在解決…",
+ resolveFailed: "無法解決此討論串",
+ deleteComment: "刪除評論",
+ moreActions: "更多操作",
+ resolved: "已解決",
+ reviewer: "審閱者",
+ applyFeedback: "套用意見 ({{count}})",
+ applyingFeedback: "正在套用意見…",
+ applyFeedbackFailed: "無法套用審閱意見",
+ sendToAgentFailed: "無法將此討論串傳送給代理",
+ shareLinkDescription:
+ "任何取得此連結的人都能檢視設計。已登入的審閱者可以評論。",
+ presentComments: "評論",
+ presentCommentMode: "評論模式",
+ closeComments: "關閉評論",
+ status: {
+ draft: "草稿",
+ in_review: "審閱中",
+ approved: "已核准",
+ changes_requested: "已要求變更",
+ change: "變更審閱狀態",
+ saveFailed: "無法更新審閱狀態",
+ },
+ },
chat: {
emptyState: "描述要建立的設計",
suggestionLandingPage: "為我的初創公司設計落地頁面",
diff --git a/templates/design/app/pages/DesignEditor.selection.test.ts b/templates/design/app/pages/DesignEditor.selection.test.ts
index aa10fd74ee..47f1ff13cd 100644
--- a/templates/design/app/pages/DesignEditor.selection.test.ts
+++ b/templates/design/app/pages/DesignEditor.selection.test.ts
@@ -99,6 +99,7 @@ import {
import {
getDesignToolActivationState,
getMoveGroupToolPresentation,
+ shouldAutoEnableDrawOverlay,
} from "./design-editor/tool-state";
describe("DesignEditor overview selection state", () => {
@@ -165,6 +166,23 @@ describe("DesignEditor command tool activation", () => {
pinMode: false,
});
});
+
+ it("does not auto-enable the draw overlay while comment pins are active", () => {
+ expect(
+ shouldAutoEnableDrawOverlay({
+ mode: "annotate",
+ activeTool: "comment",
+ pinMode: true,
+ }),
+ ).toBe(false);
+ expect(
+ shouldAutoEnableDrawOverlay({
+ mode: "annotate",
+ activeTool: "draw",
+ pinMode: false,
+ }),
+ ).toBe(true);
+ });
});
describe("DesignEditor move-group toolbar presentation", () => {
diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx
index 90ab4240f5..4443676a4a 100644
--- a/templates/design/app/pages/DesignEditor.tsx
+++ b/templates/design/app/pages/DesignEditor.tsx
@@ -21,6 +21,8 @@ import {
useReconciledState,
usePresence,
useFollowUser,
+ useReviewComments,
+ useSendReviewThreadToAgent,
LiveCursorOverlay,
RemoteSelectionRings,
RecentEditHighlights,
@@ -35,7 +37,9 @@ import {
type AttributedRecentEdit,
type OtherPresence,
type PromptComposerSubmitOptions,
+ type ReviewThread,
} from "@agent-native/core/client";
+import type { ReviewComment } from "@agent-native/core/review";
import type { TweakDefinition } from "@shared/api";
import {
computeReparentedChildPosition,
@@ -106,6 +110,7 @@ import {
breakpointUpperBoundPx,
utilityStem,
} from "@shared/responsive-classes";
+import { readDesignReviewSummary } from "@shared/review-summary";
import { normalizeDesignSourceType } from "@shared/source-mode";
import { sourceContentHash } from "@shared/source-workspace";
import {
@@ -158,6 +163,7 @@ import {
IconKeyboard,
IconTemplate,
IconAdjustmentsHorizontal,
+ IconMessageCircle,
} from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import {
@@ -276,7 +282,12 @@ import { isWheelCameraGestureActive } from "@/components/design/multi-screen/whe
import { MultiScreenCanvas } from "@/components/design/MultiScreenCanvas";
import { QuestionFlow } from "@/components/design/QuestionFlow";
import { ReadOnlyDesignBanner } from "@/components/design/ReadOnlyDesignBanner";
+import {
+ ReviewCommentsPanel,
+ type ReviewCommentsPanelProps,
+} from "@/components/design/ReviewCommentsPanel";
import type { ReviewPanelProps } from "@/components/design/ReviewPanel";
+import { ReviewStatusControl } from "@/components/design/ReviewStatusControl";
import { TokensPanel } from "@/components/design/TokensPanel";
import type {
CanvasLayerHitCandidate,
@@ -779,6 +790,7 @@ import {
MOVE_GROUP_TOOL_PRESENTATIONS,
normalizeDesignLeftPanel,
normalizeDesignTool,
+ shouldAutoEnableDrawOverlay,
} from "./design-editor/tool-state";
import {
classifyTweakSaveFailure,
@@ -959,6 +971,16 @@ function buildSignInHrefForDesignIntent(intent: PostAuthDesignIntent): string {
return `${base}?return=${encodeURIComponent(ret)}`;
}
+function buildSignInHrefForComment(): string {
+ const base = agentNativePath("/_agent-native/sign-in");
+ if (typeof window === "undefined") return base;
+ const returnUrl = new URL(window.location.href);
+ returnUrl.search = "";
+ returnUrl.hash = "";
+ const ret = returnUrl.pathname + returnUrl.search + returnUrl.hash;
+ return `${base}?return=${encodeURIComponent(ret)}`;
+}
+
type PatchProofStatus =
| "runtime"
| "queued"
@@ -1466,13 +1488,7 @@ function DesignToolbarTool({
? "bg-[var(--design-editor-accent-color)] text-white"
: "hover:bg-white/10 hover:text-white",
)}
- onClick={(event) => {
- if (event.detail === 0) onPrimary();
- }}
- onPointerDown={(event) => {
- if (event.button !== 0) return;
- onPrimary();
- }}
+ onClick={onPrimary}
aria-label={label}
aria-pressed={active}
>
@@ -1848,15 +1864,6 @@ function DesignBottomToolbar({
disabled: !hasActiveFile || isOverview,
onSelect: onCommentPin,
},
- {
- key: "draw",
- label: t("designEditor.modes.draw"),
- icon: ,
- shortcut: "Y",
- active: activeTool === "draw" && mode === "annotate" && drawMode,
- disabled: !hasActiveFile,
- onSelect: onDraw,
- },
],
},
];
@@ -1962,6 +1969,10 @@ export default function DesignEditorRoute() {
function DesignEditor() {
const t = useT();
const { id } = useParams<{ id: string }>();
+ const { session, isLoading: sessionLoading } = useSession();
+ const isSignedIn = Boolean(session?.email);
+ const sessionResolved = !sessionLoading;
+ const designSaveActorScope = session?.userId ?? "anonymous";
const navigate = useNavigate();
const location = useLocation();
// Long overview sessions (pan/zoom/select/undo across many screens) build
@@ -2309,6 +2320,12 @@ function DesignEditor() {
const [contentRenderRevision, setContentRenderRevision] = useState(0);
const [activeInspectorTab, setActiveInspectorTab] =
useState("design");
+ const [reviewFocusRequest, setReviewFocusRequest] = useState<{
+ nonce: number;
+ anchor: unknown;
+ targetId?: string;
+ } | null>(null);
+ const reviewFocusNonceRef = useRef(0);
const [activeLeftPanel, setActiveLeftPanel] =
useState("file");
const [activeCodeFile, setActiveCodeFile] =
@@ -3308,6 +3325,7 @@ function DesignEditor() {
>(undefined);
useEffect(() => {
+ if (!isSignedIn) return;
return () => {
void (async () => {
const keys = designSelectionStateKeys();
@@ -3335,7 +3353,7 @@ function DesignEditor() {
}
})();
};
- }, []);
+ }, [isSignedIn]);
// When generation stalls we keep the original prompt + files around so the
// user can retry with one click instead of re-typing. Cleared as soon as the
// user kicks off a new run (retry or fresh prompt).
@@ -3483,6 +3501,8 @@ function DesignEditor() {
clearStoredRunLivenessTimer();
},
});
+ const { generating: reviewFeedbackApplying, submit: submitReviewFeedback } =
+ useAgentGenerating();
const handleQuestionFlowContinue = useCallback(
(runTabId: string) => {
clearGenerationCompleteTimer();
@@ -3518,6 +3538,7 @@ function DesignEditor() {
handleSubmit: handleQuestionsSubmit,
handleSkip: handleQuestionsSkip,
} = useQuestionFlow(id, {
+ enabled: isSignedIn,
continuationTabId: generationChatTabId,
onContinue: handleQuestionFlowContinue,
});
@@ -3525,11 +3546,6 @@ function DesignEditor() {
pendingQuestions && pendingQuestions.length > 0,
);
- const { session, isLoading: sessionLoading } = useSession();
- const isSignedIn = Boolean(session?.email);
- const sessionResolved = !sessionLoading;
- const designSaveActorScope = session?.userId ?? "anonymous";
-
useEffect(() => {
return () => clearGenerationCompleteTimer();
}, [clearGenerationCompleteTimer]);
@@ -3574,6 +3590,7 @@ function DesignEditor() {
);
const signInToSaveHref = buildSignInHrefForDesignIntent("save");
const signInToShareHref = buildSignInHrefForDesignIntent("share");
+ const signInToCommentHref = buildSignInHrefForComment();
const handleSignInToSave = useCallback(() => {
window.location.href = buildSignInHrefForDesignIntent("save");
}, []);
@@ -3624,6 +3641,61 @@ function DesignEditor() {
designAccessRole === "owner" || designAccessRole === "admin";
const canEditDesign = canShareDesign || designAccessRole === "editor";
const canRenderAuthenticatedShare = isSignedIn || canEditDesign;
+ const reviewResult = useReviewComments(
+ {
+ resourceType: "design",
+ resourceId: id ?? "",
+ includeResolved: false,
+ limit: 500,
+ },
+ { enabled: Boolean(id) },
+ );
+ const reviewComments = reviewResult.data?.comments ?? [];
+ const reviewOpenThreadIds = useMemo(
+ () =>
+ new Set(
+ reviewComments
+ .filter(
+ (comment) =>
+ comment.status === "open" && comment.parentCommentId === null,
+ )
+ .map((comment) => comment.threadId),
+ ),
+ [reviewComments],
+ );
+ const reviewAgentQueueThreadIds = useMemo(
+ () =>
+ new Set(
+ reviewComments
+ .filter(
+ (comment) =>
+ comment.status === "open" &&
+ comment.parentCommentId === null &&
+ comment.resolutionTarget !== "human" &&
+ !comment.consumedAt,
+ )
+ .map((comment) => comment.threadId),
+ ),
+ [reviewComments],
+ );
+ const persistedReviewSummary = readDesignReviewSummary(reviewResult.data);
+ const reviewOpenCount =
+ persistedReviewSummary?.openCount ?? reviewOpenThreadIds.size;
+ const reviewAgentQueueCount =
+ persistedReviewSummary?.agentQueueCount ?? reviewAgentQueueThreadIds.size;
+ const reviewStatus = reviewResult.data?.reviewStatus?.status ?? "draft";
+ const sendReviewThreadToAgent = useSendReviewThreadToAgent();
+ const [reviewSendingThreadId, setReviewSendingThreadId] = useState<
+ string | null
+ >(null);
+ useEffect(() => {
+ if (
+ reviewSendingThreadId &&
+ reviewAgentQueueThreadIds.has(reviewSendingThreadId)
+ ) {
+ setReviewSendingThreadId(null);
+ }
+ }, [reviewAgentQueueThreadIds, reviewSendingThreadId]);
const canEditDesignRef = useRef(canEditDesign);
const pendingLocalFileContentsRef = useRef<
Map<
@@ -4560,7 +4632,7 @@ function DesignEditor() {
designSystems,
defaultSystem,
isLoading: designSystemsLoading,
- } = useDesignSystems();
+ } = useDesignSystems(isSignedIn);
useEffect(() => {
if (!id || !design || !isSignedIn || !postAuthIntent) return;
@@ -5886,7 +5958,7 @@ function DesignEditor() {
// immediately on a local write so the resulting poll tick is a no-op
// instead of a redundant re-apply).
useEffect(() => {
- if (!id) return;
+ if (!id || !isSignedIn) return;
let cancelled = false;
void (async () => {
if (activeBreakpointWriteQueueRef.current?.hasPending()) return;
@@ -5921,14 +5993,14 @@ function DesignEditor() {
return () => {
cancelled = true;
};
- }, [appStateVersion, designBreakpoints, id]);
+ }, [appStateVersion, designBreakpoints, id, isSignedIn]);
// Agent→UI: open the write-consent dialog when the agent requests local file
// write access via request-localhost-write-consent (granting stays human-only).
// One-shot: consume the app-state key, open the dialog, then clear it so
// echoed app-state bumps don't re-open it.
useEffect(() => {
- if (!id) return;
+ if (!id || !isSignedIn) return;
let cancelled = false;
const key = `design-localhost-write-consent-request:${id}`;
void (async () => {
@@ -5961,7 +6033,7 @@ function DesignEditor() {
return () => {
cancelled = true;
};
- }, [appStateVersion, id]);
+ }, [appStateVersion, id, isSignedIn]);
// §6.4 — The active screen's primary-frame width (the BASE editing
// context). Overrides written at a narrower active breakpoint apply below
@@ -5998,7 +6070,7 @@ function DesignEditor() {
"get-motion-timeline",
motionTimelineQueryParams,
{
- enabled: Boolean(id && activeFile?.id),
+ enabled: Boolean(isSignedIn && id && activeFile?.id),
refetchOnMount: "always",
},
);
@@ -6215,9 +6287,13 @@ function DesignEditor() {
if (target && !targetFile) return false;
const inspectorTab =
- command.inspectorTab === "design" || command.inspectorTab === "tweaks"
+ command.inspectorTab === "design" ||
+ command.inspectorTab === "comments" ||
+ command.inspectorTab === "tweaks"
? command.inspectorTab
- : command.inspector === "design" || command.inspector === "tweaks"
+ : command.inspector === "design" ||
+ command.inspector === "comments" ||
+ command.inspector === "tweaks"
? command.inspector
: undefined;
if (inspectorTab) setActiveInspectorTab(inspectorTab);
@@ -6301,7 +6377,7 @@ function DesignEditor() {
);
useEffect(() => {
- if (!id) return;
+ if (!id || !isSignedIn) return;
if (initialSearchCommandAppliedForIdRef.current === id) return;
const command = designEditorCommandFromSearchParams(
id,
@@ -6318,7 +6394,7 @@ function DesignEditor() {
}, [applyDesignEditorCommand, id, initialSearchParams]);
useEffect(() => {
- if (!id) return;
+ if (!id || !isSignedIn) return;
let cancelled = false;
const keys = browserTabId
? [designEditorCommandKey(browserTabId), designEditorCommandKey()]
@@ -6339,7 +6415,7 @@ function DesignEditor() {
return () => {
cancelled = true;
};
- }, [appStateVersion, applyDesignEditorCommand, browserTabId, id]);
+ }, [appStateVersion, applyDesignEditorCommand, browserTabId, id, isSignedIn]);
const optimisticallyInsertCreatedFile = useCallback(
(args: {
@@ -10309,6 +10385,130 @@ function DesignEditor() {
reviewFindings,
]);
+ const dispatchReviewFeedbackToAgent = useCallback(
+ (root: ReviewComment, replies: ReviewComment[] = []) => {
+ if (!id) return;
+ const replyText = replies
+ .map((reply) => `${reply.authorName ?? "Reviewer"}: ${reply.body}`)
+ .join("\n");
+ sendToDesignAgentChat({
+ message: "Apply this selected design review thread only.", // i18n-ignore agent dispatch prompt
+ context: [
+ `Design id: ${id}`,
+ `Review thread id: ${root.threadId}`,
+ `Screen id: ${root.targetId ?? "unknown"}`,
+ `Feedback: ${root.body}`,
+ replyText ? `Replies:\n${replyText}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ submit: true,
+ openSidebar: true,
+ newTab: true,
+ });
+ },
+ [id],
+ );
+
+ const handleDispatchCommentToAgent = useCallback(
+ (comment: ReviewComment) => {
+ if (!canEditDesign) return;
+ dispatchReviewFeedbackToAgent(comment);
+ },
+ [canEditDesign, dispatchReviewFeedbackToAgent],
+ );
+
+ const handleSendReviewThreadToAgent = useCallback(
+ (thread: ReviewThread) => {
+ if (
+ !id ||
+ !canEditDesign ||
+ thread.root.status !== "open" ||
+ reviewSendingThreadId
+ ) {
+ return;
+ }
+ setReviewSendingThreadId(thread.root.threadId);
+ sendReviewThreadToAgent.mutate(
+ {
+ resourceType: "design",
+ resourceId: id,
+ threadId: thread.root.threadId,
+ },
+ {
+ onSuccess: () => {
+ dispatchReviewFeedbackToAgent(thread.root, thread.replies);
+ },
+ onError: () => {
+ setReviewSendingThreadId(null);
+ toast.error(t("review.sendToAgentFailed"));
+ },
+ },
+ );
+ },
+ [
+ canEditDesign,
+ dispatchReviewFeedbackToAgent,
+ id,
+ reviewSendingThreadId,
+ sendReviewThreadToAgent,
+ t,
+ ],
+ );
+
+ const handleReviewThreadSelect = useCallback((thread: ReviewThread) => {
+ const targetId = thread.root.targetId;
+ if (targetId) {
+ viewModeRef.current = "single";
+ setViewMode("single");
+ setScreenZoom(FOCUSED_SCREEN_ZOOM);
+ setActiveFileId(targetId);
+ }
+ setActiveInspectorTab("comments");
+ reviewFocusNonceRef.current += 1;
+ setReviewFocusRequest({
+ nonce: reviewFocusNonceRef.current,
+ anchor: thread.root.anchor,
+ targetId: targetId ?? undefined,
+ });
+ }, []);
+
+ const reviewCommentsPanelProps = useMemo<
+ ReviewCommentsPanelProps | undefined
+ >(
+ () =>
+ id
+ ? {
+ designId: id,
+ activeFileId: activeFile?.id,
+ canComment: isSignedIn,
+ canResolve: canEditDesign,
+ canDeleteComment: (comment) =>
+ canEditDesign ||
+ ("canDelete" in comment && comment.canDelete === true) ||
+ comment.authorEmail === session?.email,
+ signInHref: signInToCommentHref,
+ canDispatchToAgent: canEditDesign,
+ sendingThreadId: reviewSendingThreadId,
+ onDispatchCommentToAgent: handleDispatchCommentToAgent,
+ onSendThreadToAgent: handleSendReviewThreadToAgent,
+ onSelectThread: handleReviewThreadSelect,
+ }
+ : undefined,
+ [
+ activeFile?.id,
+ handleReviewThreadSelect,
+ handleDispatchCommentToAgent,
+ handleSendReviewThreadToAgent,
+ id,
+ isSignedIn,
+ canEditDesign,
+ reviewSendingThreadId,
+ session?.email,
+ signInToCommentHref,
+ ],
+ );
+
const handleCreatePrimitive = useCallback(
(screenId: string, primitive: CanvasPrimitiveInsert) => {
if (!canEditDesign) return false;
@@ -11314,7 +11514,7 @@ function DesignEditor() {
// Expose selection state for agent context
useEffect(() => {
- if (!id) return;
+ if (!id || !isSignedIn) return;
const selection = {
designId: id,
designTitle: design?.title ?? null,
@@ -11513,6 +11713,7 @@ function DesignEditor() {
responsiveEditScope,
designDataJson,
selectedStateId,
+ isSignedIn,
]);
// R69: once the composer sends this selection as context, the chip must
@@ -11544,6 +11745,7 @@ function DesignEditor() {
const composerContextHasOurKeyRef = useRef(true);
useEffect(() => {
const key = "design:selected-element";
+ if (!isSignedIn) return;
if (!id || !shouldMirrorSelectedElementToAgentChat(selectedElement)) {
mirroredSelectionIdRef.current = null;
sentSelectionIdRef.current = null;
@@ -11613,7 +11815,14 @@ function DesignEditor() {
focus: false,
});
composerContextHasOurKeyRef.current = true;
- }, [activeFile, design?.title, id, selectedCodeLayerNode, selectedElement]);
+ }, [
+ activeFile,
+ design?.title,
+ id,
+ isSignedIn,
+ selectedCodeLayerNode,
+ selectedElement,
+ ]);
// Bookkeeping only — mirrors "does the shared composer context still carry
// our key" into a ref for the effect above to read. This is intentionally
@@ -11621,7 +11830,8 @@ function DesignEditor() {
// writes a ref, never calls setAgentChatContextItem or any other state
// setter, so it can run on every store change without feeding back into a
// re-render loop.
- const composerContextItemsForBookkeeping = useAgentChatContext().items;
+ const composerContextItemsForBookkeeping =
+ useAgentChatContext(isSignedIn).items;
useEffect(() => {
const key = "design:selected-element";
composerContextHasOurKeyRef.current =
@@ -11630,6 +11840,7 @@ function DesignEditor() {
useEffect(() => {
const key = "design:design-system";
+ if (!isSignedIn) return;
const designSystemId = design?.designSystemId;
if (!designSystemId) {
removeAgentChatContextItem(key);
@@ -11651,7 +11862,7 @@ function DesignEditor() {
cancelled = true;
removeAgentChatContextItem(key);
};
- }, [design?.designSystemId]);
+ }, [design?.designSystemId, isSignedIn]);
const handleAssetInserted = useCallback(
(selection: {
@@ -20595,10 +20806,16 @@ function DesignEditor() {
);
useEffect(() => {
- if (embedded || mode !== "annotate" || !activeFile) return;
+ if (
+ embedded ||
+ !activeFile ||
+ !shouldAutoEnableDrawOverlay({ mode, activeTool, pinMode })
+ ) {
+ return;
+ }
if (!canEditDesign) return;
setDrawMode(true);
- }, [activeFile?.id, canEditDesign, embedded, mode]);
+ }, [activeFile?.id, activeTool, canEditDesign, embedded, mode, pinMode]);
const handleViewModeToggle = useCallback(() => {
if (viewModeRef.current === "overview") {
@@ -20657,16 +20874,20 @@ function DesignEditor() {
getRestoredOverviewSelection,
]);
+ const handleExitReviewCommentMode = useCallback(() => {
+ setPinMode(false);
+ setDrawMode(false);
+ setActiveTool("move");
+ setMode("edit");
+ }, []);
+
const handlePinToolToggle = useCallback(() => {
- if (!activeFile || !canEditDesign) return;
+ if (!activeFile || (!canEditDesign && !isSignedIn)) return;
if (pinMode) {
- setPinMode(false);
- if (mode === "annotate") {
- setActiveTool("draw");
- setDrawMode(true);
- }
+ handleExitReviewCommentMode();
return;
}
+ setCommentsHidden(false);
// Comments are placed on a single screen, not the overview. If we're in the
// overview, enter the active screen AND arm pin mode in the SAME view
// transition — calling enterSingleScreen() separately would reset pinMode to
@@ -20696,7 +20917,8 @@ function DesignEditor() {
}, [
activeFile,
canEditDesign,
- mode,
+ handleExitReviewCommentMode,
+ isSignedIn,
pinMode,
viewMode,
runEditorViewTransition,
@@ -20747,6 +20969,11 @@ function DesignEditor() {
else handleExitFocusedDrawMode();
return;
}
+ // ReviewCanvasPins owns Escape while comment mode is active so it can
+ // dismiss in context: first an open draft/thread, then pin mode itself.
+ // Letting this global handler continue would exit the tool on the same
+ // keypress that only meant to close the composer.
+ if (pinMode) return;
// BP-DEEP item 5 — Framer-style click-to-target: Escape's first job when
// a breakpoint is the active edit target is to return to Base, matching
// "click the base frame / empty canvas" — mirrors the other early-return
@@ -21300,7 +21527,7 @@ function DesignEditor() {
onTextTool: canEditDesign ? handleTextTool : undefined,
onPenTool: canEditDesign ? handlePenTool : undefined,
onHandTool: canEditDesign ? handleHandTool : undefined,
- onCommentTool: canEditDesign ? handlePinToolToggle : undefined,
+ onCommentTool: isSignedIn ? handlePinToolToggle : undefined,
onDrawTool: canEditDesign ? handleDrawTool : undefined,
onScaleTool: canEditDesign ? handleScaleTool : undefined,
onCopy: handleCopySelection,
@@ -23116,24 +23343,29 @@ function DesignEditor() {
);
const shareLinkFooter = (
-
-
void handleCopyShareLink()}
- disabled={!editorShareUrl}
- className="h-8 min-w-[8.75rem] gap-1.5 rounded-md px-3 text-[12px]"
- >
- {shareLinkCopied ? (
-
- ) : (
-
- )}
- {
- shareLinkCopied
- ? "Copied" /* i18n-ignore share copy action copied */
- : "Copy share link" /* i18n-ignore share copy action */
- }
-
+
+
+ {t("review.shareLinkDescription")}
+
+
+ void handleCopyShareLink()}
+ disabled={!editorShareUrl}
+ className="h-8 min-w-[8.75rem] gap-1.5 rounded-md px-3 text-[12px]"
+ >
+ {shareLinkCopied ? (
+
+ ) : (
+
+ )}
+ {
+ shareLinkCopied
+ ? "Copied" /* i18n-ignore share copy action copied */
+ : "Copy share link" /* i18n-ignore share copy action */
+ }
+
+
);
const designShareTabLabelClassName =
@@ -26754,6 +26986,7 @@ function DesignEditor() {
tweakValues={cssVarValues}
drawMode={false}
pinMode={false}
+ commentPinsHidden
designId={id}
designTitle={design?.title}
commentContextId={`${id}:${screen.id}`}
@@ -27220,6 +27453,27 @@ function DesignEditor() {
[handleOverviewFrameAction],
);
+ const handleApplyReviewFeedback = useCallback(() => {
+ if (
+ !id ||
+ !canEditDesign ||
+ reviewAgentQueueCount === 0 ||
+ reviewFeedbackApplying
+ )
+ return;
+ submitReviewFeedback(
+ "Apply all open design review feedback for this design. After each change, verify it in the affected screen and resolve the corresponding thread only after the saved edit is confirmed.",
+ `Design id: ${id}. Start by reading the current screen and fetching the open review feedback queue. Apply one thread at a time with persisted edits and verification.`,
+ { openSidebar: true, newTab: true },
+ );
+ }, [
+ canEditDesign,
+ id,
+ reviewAgentQueueCount,
+ reviewFeedbackApplying,
+ submitReviewFeedback,
+ ]);
+
// Hooks must not be called conditionally; keep navigate as an effect so the
// render phase stays pure. This branch is unreachable in practice because the
// design.$id.tsx route always supplies an id param.
@@ -27471,7 +27725,7 @@ function DesignEditor() {
{pinMode
@@ -27699,6 +27953,39 @@ function DesignEditor() {
+ {isSignedIn && !canEditDesign && activeFile ? (
+
+
+ {pinMode
+ ? t("designEditor.stopPinningComments")
+ : t("designEditor.pinComment")}
+
+ ) : null}
+ {canEditDesign && reviewAgentQueueCount > 0 ? (
+
+ {reviewFeedbackApplying ? (
+
+ ) : (
+
+ )}
+ {reviewFeedbackApplying
+ ? t("review.applyingFeedback")
+ : t("review.applyFeedback", { count: reviewAgentQueueCount })}
+
+ ) : null}
{
@@ -28004,6 +28291,13 @@ function DesignEditor() {
>
{projectTitleControl}
+ {id ? (
+
+ ) : null}
{
- setPinMode(false);
- if (mode === "annotate") {
- setActiveTool("draw");
- }
- }}
+ onExitPinMode={handleExitReviewCommentMode}
designId={id}
+ reviewCanPost={isSignedIn}
+ reviewCanResolve={canEditDesign}
+ reviewFocusRequest={reviewFocusRequest}
+ onDispatchCommentToAgent={
+ canEditDesign
+ ? handleDispatchCommentToAgent
+ : undefined
+ }
+ onSendThreadToAgent={
+ canEditDesign
+ ? handleSendReviewThreadToAgent
+ : undefined
+ }
+ reviewSendingThreadId={reviewSendingThreadId}
designTitle={design?.title}
commentContextId={`${id}:${activeFile.id}`}
commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
@@ -29187,6 +29490,8 @@ function DesignEditor() {
inspectCode={inspectCodeData}
statesPanelProps={statesPanelProps}
reviewPanelProps={resolvedReviewPanelProps}
+ reviewCommentsPanelProps={reviewCommentsPanelProps}
+ reviewCommentsCount={reviewOpenCount}
onAlignSelection={
canEditDesign ? handleAlignSelection : undefined
}
@@ -29275,6 +29580,8 @@ function DesignEditor() {
inspectCode={inspectCodeData}
statesPanelProps={statesPanelProps}
reviewPanelProps={resolvedReviewPanelProps}
+ reviewCommentsPanelProps={reviewCommentsPanelProps}
+ reviewCommentsCount={reviewOpenCount}
onAlignSelection={
canEditDesign ? handleAlignSelection : undefined
}
diff --git a/templates/design/app/pages/Present.tsx b/templates/design/app/pages/Present.tsx
index 47b5f63fc7..4cd5c764b8 100644
--- a/templates/design/app/pages/Present.tsx
+++ b/templates/design/app/pages/Present.tsx
@@ -1,14 +1,36 @@
import {
+ ReviewStatusBadge,
+ agentNativePath,
injectSessionReplayIframeBootstrap,
SESSION_REPLAY_IFRAME_ATTRIBUTE,
useActionQuery,
+ useReviewComments,
+ useSession,
useT,
} from "@agent-native/core/client";
-import { useState, useEffect, useCallback } from "react";
+import { readDesignReviewSummary } from "@shared/review-summary";
+import { IconMessageCircle } from "@tabler/icons-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams, useNavigate } from "react-router";
+import { appendHitTestResponder } from "@/components/design/design-canvas/hit-test";
+import { ReviewCommentsPanel } from "@/components/design/ReviewCommentsPanel";
import { QueryErrorState } from "@/components/QueryErrorState";
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
+import { ReviewCanvasPins } from "@/components/visual-editor/ReviewCanvasPins";
+
+import {
+ resolvePresentEscapeAction,
+ shouldBlockPresentPageNavigation,
+} from "./present-review-state";
interface DesignFile {
id: string;
@@ -21,13 +43,17 @@ interface DesignData {
id: string;
title: string;
files: DesignFile[];
+ accessRole?: "viewer" | "editor" | "admin" | "owner";
}
export default function Present() {
const t = useT();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { session } = useSession();
const [currentPage, setCurrentPage] = useState(0);
+ const [commentMode, setCommentMode] = useState(false);
+ const [commentsOpen, setCommentsOpen] = useState(false);
const {
data: design,
@@ -38,14 +64,67 @@ export default function Present() {
} = useActionQuery("get-design", { id: id! });
const files: DesignFile[] = design?.files ?? [];
+ const activeFile = files[currentPage] ?? files[0];
+ const reviewQuery = useReviewComments(
+ {
+ resourceType: "design",
+ resourceId: id ?? "",
+ targetId: activeFile?.id ?? undefined,
+ includeResolved: false,
+ limit: 500,
+ },
+ { enabled: Boolean(id) },
+ );
+ const canPost = Boolean(session?.email);
+ const canResolve = Boolean(
+ design?.accessRole === "owner" ||
+ design?.accessRole === "admin" ||
+ design?.accessRole === "editor",
+ );
+ const reviewableContent = useMemo(
+ () =>
+ injectSessionReplayIframeBootstrap(
+ appendHitTestResponder(activeFile?.content ?? ""),
+ ),
+ [activeFile?.content],
+ );
+ const reviewCommentCount =
+ readDesignReviewSummary(reviewQuery.data)?.openCount ??
+ new Set(
+ (reviewQuery.data?.comments ?? [])
+ .filter(
+ (comment) =>
+ comment.status === "open" && comment.parentCommentId === null,
+ )
+ .map((comment) => comment.threadId),
+ ).size;
+ const signInHref = (() => {
+ const base = agentNativePath("/_agent-native/sign-in");
+ if (typeof window === "undefined") return base;
+ const returnUrl = new URL(window.location.href);
+ returnUrl.search = "";
+ returnUrl.hash = "";
+ return `${base}?return=${encodeURIComponent(returnUrl.pathname)}`;
+ })();
// Keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
- navigate(`/design/${id}`);
+ const action = resolvePresentEscapeAction({
+ commentsOpen,
+ commentMode,
+ });
+ if (action === "close-comments") setCommentsOpen(false);
+ if (action === "exit-presentation") navigate(`/design/${id}`);
+ // ReviewCanvasPins owns "defer-to-comment-mode" so it can dismiss an
+ // active draft before it exits the tool.
return;
}
+ // Freeze slide navigation while review UI is active so typing a space
+ // or using arrow keys in the sheet cannot change the anchored screen.
+ if (shouldBlockPresentPageNavigation({ commentsOpen, commentMode }))
+ return;
if (files.length <= 1) return;
if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === " ") {
e.preventDefault();
@@ -56,7 +135,7 @@ export default function Present() {
setCurrentPage((p) => Math.max(p - 1, 0));
}
},
- [id, navigate, files.length],
+ [commentMode, commentsOpen, files.length, id, navigate],
);
useEffect(() => {
@@ -99,17 +178,96 @@ export default function Present() {
);
}
- const activeFile = files[currentPage] ?? files[0];
-
return (
-
-
+
+
+
+ setCommentMode(false)}
+ canvasSelector=".present-review-canvas"
+ resourceType="design"
+ resourceId={id}
+ targetId={activeFile.id}
+ canPost={canPost}
+ canResolve={canResolve}
+ />
+
+
+
+
+ {
+ setCommentMode(false);
+ setCommentsOpen(true);
+ }}
+ >
+
+ {t("review.presentComments")}
+ {reviewCommentCount > 0 ? ` · ${reviewCommentCount}` : ""}
+
+
+
+
+
+
+
+
+ {t("review.presentComments")}
+
+
+ {t("review.commentsTitle")}
+
+
+
+ canResolve ||
+ ("canDelete" in comment && comment.canDelete === true) ||
+ comment.authorEmail === session?.email
+ }
+ showComposer={false}
+ signInHref={signInHref}
+ className="min-h-0 flex-1"
+ />
+ {canPost ? (
+
+ {
+ setCommentsOpen(false);
+ setCommentMode(true);
+ }}
+ >
+
+ {t("review.presentCommentMode")}
+
+
+ ) : null}
+
+
{/* Page indicator */}
{files.length > 1 && (
@@ -127,7 +285,7 @@ export default function Present() {
)}
{/* Exit hint */}
-
+
{t("pages.presentExitHint")}
diff --git a/templates/design/app/pages/design-editor/tool-state.ts b/templates/design/app/pages/design-editor/tool-state.ts
index 15d7a1116d..2dcd70a7dd 100644
--- a/templates/design/app/pages/design-editor/tool-state.ts
+++ b/templates/design/app/pages/design-editor/tool-state.ts
@@ -92,6 +92,16 @@ export function getDesignToolActivationState(tool: DesignTool): {
return { mode: "edit", drawMode: false, pinMode: false };
}
+export function shouldAutoEnableDrawOverlay(args: {
+ mode: EditorMode;
+ activeTool: DesignTool;
+ pinMode: boolean;
+}): boolean {
+ return (
+ args.mode === "annotate" && args.activeTool === "draw" && !args.pinMode
+ );
+}
+
export function getSingleScreenCreationTool(args: {
activeTool: DesignTool;
viewMode: "single" | "overview";
diff --git a/templates/design/app/pages/present-review-state.spec.ts b/templates/design/app/pages/present-review-state.spec.ts
new file mode 100644
index 0000000000..ba3aaeb8e3
--- /dev/null
+++ b/templates/design/app/pages/present-review-state.spec.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ resolvePresentEscapeAction,
+ shouldBlockPresentPageNavigation,
+} from "./present-review-state";
+
+describe("present review keyboard state", () => {
+ it("closes the comments sheet before leaving presentation mode", () => {
+ expect(
+ resolvePresentEscapeAction({ commentsOpen: true, commentMode: false }),
+ ).toBe("close-comments");
+ });
+
+ it("defers Escape to the staged pin composer while comment mode is active", () => {
+ expect(
+ resolvePresentEscapeAction({ commentsOpen: false, commentMode: true }),
+ ).toBe("defer-to-comment-mode");
+ });
+
+ it("leaves presentation mode only when no review UI is active", () => {
+ expect(
+ resolvePresentEscapeAction({ commentsOpen: false, commentMode: false }),
+ ).toBe("exit-presentation");
+ });
+
+ it("blocks slide navigation while either review surface is active", () => {
+ expect(
+ shouldBlockPresentPageNavigation({
+ commentsOpen: true,
+ commentMode: false,
+ }),
+ ).toBe(true);
+ expect(
+ shouldBlockPresentPageNavigation({
+ commentsOpen: false,
+ commentMode: true,
+ }),
+ ).toBe(true);
+ expect(
+ shouldBlockPresentPageNavigation({
+ commentsOpen: false,
+ commentMode: false,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/templates/design/app/pages/present-review-state.ts b/templates/design/app/pages/present-review-state.ts
new file mode 100644
index 0000000000..ebb4fb6fc3
--- /dev/null
+++ b/templates/design/app/pages/present-review-state.ts
@@ -0,0 +1,18 @@
+export interface PresentReviewState {
+ commentsOpen: boolean;
+ commentMode: boolean;
+}
+
+export function resolvePresentEscapeAction(
+ state: PresentReviewState,
+): "close-comments" | "defer-to-comment-mode" | "exit-presentation" {
+ if (state.commentsOpen) return "close-comments";
+ if (state.commentMode) return "defer-to-comment-mode";
+ return "exit-presentation";
+}
+
+export function shouldBlockPresentPageNavigation(
+ state: PresentReviewState,
+): boolean {
+ return state.commentsOpen || state.commentMode;
+}
diff --git a/templates/design/app/public-routes.spec.ts b/templates/design/app/public-routes.spec.ts
new file mode 100644
index 0000000000..df0fb70dcd
--- /dev/null
+++ b/templates/design/app/public-routes.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+
+import { isPublicDesignAppPath } from "./public-routes";
+
+describe("isPublicDesignAppPath", () => {
+ it.each([
+ "/visual-edit",
+ "/design",
+ "/design/public-design-id",
+ "/present/public-design-id",
+ ])("allows anonymous access to %s", (pathname) => {
+ expect(isPublicDesignAppPath(pathname)).toBe(true);
+ });
+
+ it.each(["/", "/templates", "/settings", "/design-systems"])(
+ "keeps %s behind the session gate",
+ (pathname) => {
+ expect(isPublicDesignAppPath(pathname)).toBe(false);
+ },
+ );
+});
diff --git a/templates/design/app/public-routes.ts b/templates/design/app/public-routes.ts
new file mode 100644
index 0000000000..170902be27
--- /dev/null
+++ b/templates/design/app/public-routes.ts
@@ -0,0 +1,8 @@
+export function isPublicDesignAppPath(pathname: string): boolean {
+ return (
+ pathname === "/visual-edit" ||
+ pathname === "/design" ||
+ pathname.startsWith("/design/") ||
+ pathname.startsWith("/present/")
+ );
+}
diff --git a/templates/design/app/root.tsx b/templates/design/app/root.tsx
index 774c5a462b..d9472f1f84 100644
--- a/templates/design/app/root.tsx
+++ b/templates/design/app/root.tsx
@@ -33,10 +33,14 @@ import { AppToolkitProvider } from "@/components/ui/toolkit-provider";
import changelog from "../CHANGELOG.md?raw";
import { i18nCatalog } from "./i18n";
+import { isPublicDesignAppPath } from "./public-routes";
import stylesheet from "./global.css?url";
configureTracking({
+ llmConnectionStatus:
+ typeof window === "undefined" ||
+ !isPublicDesignAppPath(window.location.pathname),
getDefaultProps: (_name, properties) => ({
...properties,
app: "design",
@@ -182,16 +186,13 @@ function RootContent() {
export default function Root() {
const [queryClient] = useState(() => createAgentNativeQueryClient());
const location = useLocation();
- const sessionBypass =
- location.pathname === "/visual-edit" ||
- location.pathname === "/design" ||
- location.pathname.startsWith("/design/");
+ const isPublicPath = isPublicDesignAppPath(location.pathname);
return (
diff --git a/templates/design/changelog/2026-07-13-reviewers-can-pin-comments-on-shared-designs-and-apply-verif.md b/templates/design/changelog/2026-07-13-reviewers-can-pin-comments-on-shared-designs-and-apply-verif.md
new file mode 100644
index 0000000000..90c3fd1a4d
--- /dev/null
+++ b/templates/design/changelog/2026-07-13-reviewers-can-pin-comments-on-shared-designs-and-apply-verif.md
@@ -0,0 +1,6 @@
+---
+type: added
+date: 2026-07-13
+---
+
+Reviewers can pin comments on shared designs and apply verified feedback through the agent
diff --git a/templates/design/server/plugins/agent-chat.instructions.spec.ts b/templates/design/server/plugins/agent-chat.instructions.spec.ts
new file mode 100644
index 0000000000..25f7678470
--- /dev/null
+++ b/templates/design/server/plugins/agent-chat.instructions.spec.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from "node:fs";
+
+import { describe, expect, it } from "vitest";
+
+const agentChatSource = readFileSync(
+ new URL("./agent-chat.ts", import.meta.url),
+ "utf8",
+);
+const reviewFeedbackSkill = readFileSync(
+ new URL(
+ "../../.agents/skills/design-review-feedback/SKILL.md",
+ import.meta.url,
+ ),
+ "utf8",
+);
+
+describe("design review agent instructions", () => {
+ it.each([
+ ["agent chat system prompt", agentChatSource],
+ ["design-review-feedback skill", reviewFeedbackSkill],
+ ])("requires resolution notes in the %s", (_surface, instructions) => {
+ expect(instructions).toContain("resolutionNote");
+ expect(instructions).toContain("one-line description");
+ expect(instructions).toContain("persisted change");
+ });
+});
diff --git a/templates/design/server/plugins/agent-chat.ts b/templates/design/server/plugins/agent-chat.ts
index 665ab29d0a..6afe870b7b 100644
--- a/templates/design/server/plugins/agent-chat.ts
+++ b/templates/design/server/plugins/agent-chat.ts
@@ -12,6 +12,14 @@ const DESIGN_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS = 12 * 60_000;
const INITIAL_TOOL_NAMES = [
"view-screen",
+ "list-review-comments",
+ "get-review-feedback",
+ "create-review-comment",
+ "reply-review-comment",
+ "send-review-thread-to-agent",
+ "resolve-review-thread",
+ "consume-review-feedback",
+ "set-review-status",
"list-designs",
"list-design-templates",
"get-design",
@@ -60,6 +68,8 @@ When the user asks to start from a template, call list-design-templates and then
When the user asks you to refine an existing design, call view-screen if the open design is unclear, then read the live current file with get-design-snapshot before editing. For small localized changes, call edit-design with exact search/replace edits. For broad copy-only changes such as translating all visible text, call edit-design in replace-file mode with the complete updated file content from the snapshot so the HTML structure, scripts, styles, and tweaks are preserved without dozens of fragile search blocks. Do not claim the design is updated until the mutating action succeeds.
+When open review feedback exists, call get-review-feedback and work one anchored thread at a time. Prefer the stable node anchor, verify each persisted edit before resolving its thread, pass resolutionNote with a one-line description of the persisted change, and call consume-review-feedback after applying agent-targeted feedback. If a reviewer selectively sends one thread to the agent, use that thread id as the scope and do not apply other open feedback. If a thread needs a human decision, reply with resolutionTarget "human" instead of resolving it; follow the design-review-feedback skill for the complete loop.
+
When the user picks one direction from a set of presented variants, delete each unchosen variant screen at most once, then call get-design-snapshot exactly once for the kept screen's fileId and call edit-design on that same fileId. Use edit-design replace-file when expanding the placeholder into a complete but compact product UI in the chosen direction. Prioritize the primary workflow and render secondary details as visible controls, states, or affordances if the feature list is too large for one reliable edit. Do not call generate-design after a variant pick unless the user explicitly asks to create a separate new screen.
When the user asks to visually inspect or edit a running local app, use open-visual-edit. It registers the localhost bridge, creates or reuses the Design project, places URL-backed iframe screens, stores the active visual-edit context, and navigates to overview mode in one authenticated step. For follow-ups like adding a mobile viewport or another route state, reuse the current designId and connectionId and call open-visual-edit or add-localhost-screens with explicit routes/paths and viewport sizes.
diff --git a/templates/design/server/plugins/auth.spec.ts b/templates/design/server/plugins/auth.spec.ts
index adf5a25e08..9b29f40d84 100644
--- a/templates/design/server/plugins/auth.spec.ts
+++ b/templates/design/server/plugins/auth.spec.ts
@@ -14,15 +14,37 @@ vi.mock("@agent-native/core/server", () => ({
import authPlugin from "./auth.js";
describe("design auth plugin", () => {
- it("lets signed-out public design pages load the native asset catalog", () => {
+ it("lets signed-out viewers open presentation routes", () => {
expect(authPlugin).toMatchObject({ kind: "auth-plugin" });
+ expect(mocks.createAuthPlugin).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceAppPublicPaths: expect.arrayContaining(["/present"]),
+ }),
+ );
+ });
+
+ it("lets signed-out viewers read designs, assets, and review comments", () => {
expect(mocks.createAuthPlugin).toHaveBeenCalledWith(
expect.objectContaining({
publicPaths: expect.arrayContaining([
"/_agent-native/actions/get-design",
"/_agent-native/actions/list-design-native-assets",
+ "/_agent-native/actions/list-review-comments",
]),
}),
);
});
+
+ it("does not expose review comment mutations", () => {
+ const options = mocks.createAuthPlugin.mock.calls[0]?.[0];
+
+ expect(options.publicPaths).not.toEqual(
+ expect.arrayContaining([
+ "/_agent-native/actions/create-review-comment",
+ "/_agent-native/actions/reply-review-comment",
+ "/_agent-native/actions/resolve-review-thread",
+ "/_agent-native/actions/delete-review-comment",
+ ]),
+ );
+ });
});
diff --git a/templates/design/server/plugins/auth.ts b/templates/design/server/plugins/auth.ts
index 33f74588b8..cab7b959d8 100644
--- a/templates/design/server/plugins/auth.ts
+++ b/templates/design/server/plugins/auth.ts
@@ -2,10 +2,10 @@ import { createAuthPlugin } from "@agent-native/core/server";
export default createAuthPlugin({
workspaceAppAudience: "internal",
- // The visual-edit entry route and public design editor links can load without
- // a session. Creating, mutating, generating, and sharing designs still go
- // through authenticated actions.
- workspaceAppPublicPaths: ["/visual-edit", "/design"],
+ // Visual-edit, public design editor links, and presentation links can load
+ // without a session. Creating, mutating, generating, and sharing designs
+ // still go through authenticated actions.
+ workspaceAppPublicPaths: ["/visual-edit", "/design", "/present"],
marketing: {
appName: "Agent-Native Design",
tagline:
@@ -23,11 +23,13 @@ export default createAuthPlugin({
// server/routes/api/design-handoff/[id].get.ts — verifyShortLivedToken).
// publicPaths uses prefix matching, so this covers
// /api/design-handoff/?token=... while keeping every other /api/* and
- // /_agent-native/* route behind auth.
+ // /_agent-native/* route behind auth. The listed action routes are read-only;
+ // review comment mutations remain protected by action auth and resource ACLs.
publicPaths: [
"/api/design-handoff",
"/__manifest",
"/_agent-native/actions/get-design",
"/_agent-native/actions/list-design-native-assets",
+ "/_agent-native/actions/list-review-comments",
],
});
diff --git a/templates/design/server/plugins/review.spec.ts b/templates/design/server/plugins/review.spec.ts
new file mode 100644
index 0000000000..9bbeb3c2a8
--- /dev/null
+++ b/templates/design/server/plugins/review.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ defineNitroPlugin: vi.fn((setup) => {
+ setup();
+ return { kind: "nitro-plugin" };
+ }),
+ registerReviewableResource: vi.fn(),
+}));
+
+vi.mock("@agent-native/core/server", () => ({
+ defineNitroPlugin: mocks.defineNitroPlugin,
+}));
+
+vi.mock("@agent-native/core/review", () => ({
+ registerReviewableResource: mocks.registerReviewableResource,
+}));
+
+import reviewPlugin from "./review.js";
+
+describe("design review plugin", () => {
+ it("registers designs with the shared review kit", () => {
+ expect(reviewPlugin).toMatchObject({ kind: "nitro-plugin" });
+ expect(mocks.registerReviewableResource).toHaveBeenCalledWith({
+ type: "design",
+ displayName: "Design",
+ });
+ });
+});
diff --git a/templates/design/server/plugins/review.ts b/templates/design/server/plugins/review.ts
new file mode 100644
index 0000000000..2091eb56a5
--- /dev/null
+++ b/templates/design/server/plugins/review.ts
@@ -0,0 +1,9 @@
+import { registerReviewableResource } from "@agent-native/core/review";
+import { defineNitroPlugin } from "@agent-native/core/server";
+
+export default defineNitroPlugin(() => {
+ registerReviewableResource({
+ type: "design",
+ displayName: "Design",
+ });
+});
diff --git a/templates/design/shared/review-anchor.spec.ts b/templates/design/shared/review-anchor.spec.ts
new file mode 100644
index 0000000000..aefae9b1fa
--- /dev/null
+++ b/templates/design/shared/review-anchor.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import { parseReviewAnchor, resolveReviewAnchor } from "./review-anchor";
+
+describe("review anchors", () => {
+ const point = { xPct: 24, yPct: 68 };
+
+ it("uses the node position when the node id resolves", () => {
+ expect(
+ resolveReviewAnchor({ nodeId: "hero-title", point }, (nodeId) =>
+ nodeId === "hero-title" ? { xPct: 40, yPct: 12 } : null,
+ ),
+ ).toMatchObject({
+ source: "node",
+ point: { xPct: 40, yPct: 12 },
+ });
+ });
+
+ it("falls back to the stored point when the node is gone", () => {
+ expect(
+ resolveReviewAnchor({ nodeId: "deleted", point }, () => null),
+ ).toMatchObject({ source: "point", point });
+ });
+
+ it("falls back to the stored point when node resolution leaves the canvas", () => {
+ expect(
+ resolveReviewAnchor({ nodeId: "oversized-section", point }, () => ({
+ xPct: 42,
+ yPct: 399,
+ })),
+ ).toMatchObject({ source: "point", point });
+ });
+
+ it("keeps malformed anchors panel-only", () => {
+ expect(parseReviewAnchor({ nodeId: "missing-point" })).toBeNull();
+ expect(
+ resolveReviewAnchor({ point: { xPct: "nope", yPct: 20 } }, () => null),
+ ).toBeNull();
+ });
+});
diff --git a/templates/design/shared/review-anchor.ts b/templates/design/shared/review-anchor.ts
new file mode 100644
index 0000000000..e68f188539
--- /dev/null
+++ b/templates/design/shared/review-anchor.ts
@@ -0,0 +1,83 @@
+export interface ReviewAnchorPoint {
+ xPct: number;
+ yPct: number;
+}
+
+export interface DesignReviewAnchor {
+ nodeId?: string;
+ point: ReviewAnchorPoint;
+}
+
+export interface ResolvedReviewAnchor {
+ anchor: DesignReviewAnchor;
+ point: ReviewAnchorPoint;
+ source: "node" | "point";
+}
+
+function finitePercentage(value: unknown): number | null {
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
+ return Math.min(100, Math.max(0, value));
+}
+
+function inBoundsPercentage(value: unknown): number | null {
+ if (
+ typeof value !== "number" ||
+ !Number.isFinite(value) ||
+ value < 0 ||
+ value > 100
+ ) {
+ return null;
+ }
+ return value;
+}
+
+/**
+ * Parse the persisted anchor contract used by Design review comments.
+ * Malformed anchors intentionally return null so a thread remains visible in
+ * the panel without creating a misleading canvas pin.
+ */
+export function parseReviewAnchor(value: unknown): DesignReviewAnchor | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+ const record = value as Record;
+ const point =
+ record.point &&
+ typeof record.point === "object" &&
+ !Array.isArray(record.point)
+ ? (record.point as Record)
+ : null;
+ const xPct = finitePercentage(point?.xPct);
+ const yPct = finitePercentage(point?.yPct);
+ if (xPct === null || yPct === null) return null;
+
+ const nodeId = typeof record.nodeId === "string" ? record.nodeId.trim() : "";
+ return {
+ ...(nodeId ? { nodeId } : {}),
+ point: { xPct, yPct },
+ };
+}
+
+/** Resolve a node-id position first, then degrade to the stored click point. */
+export function resolveReviewAnchor(
+ value: unknown,
+ resolveNodePoint: (nodeId: string) => ReviewAnchorPoint | null,
+): ResolvedReviewAnchor | null {
+ const anchor = parseReviewAnchor(value);
+ if (!anchor) return null;
+ if (anchor.nodeId) {
+ const nodePoint = resolveNodePoint(anchor.nodeId);
+ if (nodePoint) {
+ const xPct = inBoundsPercentage(nodePoint.xPct);
+ const yPct = inBoundsPercentage(nodePoint.yPct);
+ if (xPct !== null && yPct !== null) {
+ return {
+ anchor,
+ point: { xPct, yPct },
+ source: "node",
+ };
+ }
+ }
+ }
+ return { anchor, point: anchor.point, source: "point" };
+}
diff --git a/templates/design/shared/review-summary.spec.ts b/templates/design/shared/review-summary.spec.ts
new file mode 100644
index 0000000000..8f62377094
--- /dev/null
+++ b/templates/design/shared/review-summary.spec.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+
+import { readDesignReviewSummary } from "./review-summary";
+
+describe("readDesignReviewSummary", () => {
+ it("reads server-computed root-thread counts", () => {
+ expect(
+ readDesignReviewSummary({
+ summary: { openCount: 702, agentQueueCount: 31 },
+ }),
+ ).toEqual({ openCount: 702, agentQueueCount: 31 });
+ });
+
+ it("rejects missing or malformed summaries", () => {
+ expect(readDesignReviewSummary(null)).toBeNull();
+ expect(
+ readDesignReviewSummary({
+ summary: { openCount: -1, agentQueueCount: "many" },
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/templates/design/shared/review-summary.ts b/templates/design/shared/review-summary.ts
new file mode 100644
index 0000000000..a8be724abd
--- /dev/null
+++ b/templates/design/shared/review-summary.ts
@@ -0,0 +1,25 @@
+export interface DesignReviewSummary {
+ openCount: number;
+ agentQueueCount: number;
+}
+
+export function readDesignReviewSummary(
+ result: unknown,
+): DesignReviewSummary | null {
+ if (!result || typeof result !== "object") return null;
+ const summary = (result as { summary?: unknown }).summary;
+ if (!summary || typeof summary !== "object") return null;
+ const openCount = Number((summary as { openCount?: unknown }).openCount);
+ const agentQueueCount = Number(
+ (summary as { agentQueueCount?: unknown }).agentQueueCount,
+ );
+ if (
+ !Number.isInteger(openCount) ||
+ openCount < 0 ||
+ !Number.isInteger(agentQueueCount) ||
+ agentQueueCount < 0
+ ) {
+ return null;
+ }
+ return { openCount, agentQueueCount };
+}