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 ( +
{ + event.preventDefault(); + submit("human"); + }} + > +