import { SIDEBAR_STATE_CHANGE_EVENT, BuilderSetupCard, sendToAgentChat, setAgentChatContextItem, useAgentEngineConfigured, type AgentSidebarStateChangeDetail, } from "@agent-native/core/client/agent-chat"; import { track } from "@agent-native/core/client/analytics"; import { appPath, agentNativePath } from "@agent-native/core/client/api-path"; import { emailToColor, emailToName } from "@agent-native/core/client/collab"; import { PromptComposer } from "@agent-native/core/client/composer"; import { useActionQuery, useSession } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { useAcceptInvitation, useJoinByDomain, useOrg, } from "@agent-native/core/client/org"; import { ShareButton } from "@agent-native/core/client/sharing"; import { buildSignInReturnHref, ErrorReportActions, type ErrorReportDebugItem, } from "@agent-native/core/client/ui"; import { useSetHeaderActions, useSetPageTitle, } from "@agent-native/toolkit/app-shell"; import { type RichMarkdownCollabUser } from "@agent-native/toolkit/editor"; import { SOURCE_AUTHOR_COMMENT_MENTION_EMAIL, extractCommentMentions, formatPlanCommentAnchorForAgent, formatPlanCommentMentionToken, normalizePlanCommentResolutionTarget, parsePlanCommentAnchor, planCommentAnchorDetails, type PlanCommentMention, type PlanCommentResolutionTarget, } from "@shared/comment-context"; import type { PlanAnnotation, PlanBlock, PlanContent, PlanContentPatch, } from "@shared/plan-content"; import { diffPlanVersions, formatVersionDiffSummary, } from "@shared/plan-version-diff"; import { PLAN_SHARE_SURFACE, readPlanShareAttribution, withPlanShareAttribution, } from "@shared/share-attribution"; import { type PlanBundle, type PlanKind, type PlanReportReason, type PlanSource, type PlanStatus, type PlanSummary, type PlanVersionDetail, type PlanVersionSummary, } from "@shared/types"; import { IconAt, IconArrowLeft, IconArrowsMaximize, IconArrowsMinimize, IconAlertTriangle, IconChevronDown, IconClipboardText, IconCopy, IconArchive, IconArchiveOff, IconDots, IconDownload, IconExternalLink, IconFileZip, IconFlag, IconFolder, IconDotsVertical, IconHelpCircle, IconHistory, IconLayoutSidebarRight, IconLock, IconLogin2, IconMail, IconLoader2, IconPencil, IconCircleCheck, IconMessageCircle, IconMoon, IconPlus, IconShare3, IconLink, IconWorld, IconSun, IconX, IconSend, IconSearch, IconRefresh, IconRestore, IconUserPlus, IconTrash, } from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent, type PointerEvent, type ReactNode, type SyntheticEvent, } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router"; import { toast } from "sonner"; import type { CanvasMarkupCreateContext, CanvasMarkupMode, } from "@/components/plan/CanvasArea"; import { GuestModeBanner } from "@/components/plan/GuestModeBanner"; import { PlanContentRenderer } from "@/components/plan/PlanContentRenderer"; import type { PlanVisualSurfaceMode } from "@/components/plan/PlanVisualSurface"; import { toggleWireframeStyle, useWireframeStyle, } from "@/components/plan/wireframe/use-wireframe-style"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { planBundleQueryKey, localPlanBundleQueryKey, localPlanBundleQueryParams, ALL_PLANS_QUERY_ARGS, ALL_PLANS_QUERY_KEY, ACTIVE_PLANS_QUERY_KEY, usePlan, usePlanAccessStatus, usePlans, usePlanVersion, usePlanVersions, usePromoteLocalPlan, usePublishVisualPlan, useReportVisualPlan, useRestorePlanVersion, useUpdatePlan, useUpdateLocalPlan, useUpdateLocalPlanComments, useUpdatePlanComments, useUpdatePlanStatus, useDeletePlan, useDeletePlanComment, useExportPlan, useImportPlanSource, useRequestPlanAccess, type DeletePlanInput, type PlanCommentInput, type PlanAccessStatusResponse, type PublishVisualPlanResult, } from "@/hooks/use-plans"; import { assessPlanPrompt, isProbablyImportedPlan, type CreatePlanKind, } from "@/lib/create-plan-routing"; import { getDesktopPlanFiles, type DesktopPlanFilesFolder, } from "@/lib/desktop-plan-files"; import { syncLocalControlResources } from "@/lib/local-control-resources"; import { resolvePlanOrgAccessPrompt, type PlanOrgAccessPrompt, } from "@/lib/plan-access-prompt"; import { PREFERRED_EDITOR_VALUES, PREFERRED_EDITOR_STORAGE_KEY, DESKTOP_PLAN_SYNC_AUTO_KEY_PREFIX, injectAnnotationRuntime, type PreferredEditor, type RuntimeAnnotation, type RuntimeAnnotationComment, type RuntimeAnnotationParticipant, } from "@/lib/plan-annotation-runtime"; import { appendMessageToEditor, canSubmitInlineCommentDraft, commentBodyText, createMentionChip, displayNameForMention, mentionQueryAtCaret, serializeCommentEditor, type CommentDraft, } from "@/lib/plan-comment-editor-helpers"; import { planDocumentTitle } from "@/lib/plan-document-title"; import { fetchLocalPlanBridgeComments, fetchLocalPlanBridgeBundle, localNetworkAccessPermissionState, localPlanBridgeUrlFromLocation, localPlanBridgeQueryKey, localPlanBridgeRetryDelay, localPlanRoutePath, localPlanRouteUrl, mergeLocalBridgeComments, planReturnPathFromLocation, shouldRetryLocalPlanBridgeBundle, shouldShowLocalPlanLoadError, shouldShowPlanLoadError, updateLocalPlanBridgeComments, LocalPlanBridgePermissionError, type LocalPlanBundle, type LocalNetworkAccessPermissionState, type PlanBundleWithHtml, type PlanCommentItem, } from "@/lib/plan-local-bridge"; import { buildNativeAnchorFromElement, clamp, findPlanBlockById, nativeMarkerPlacementForAnchor, nativePointForAnchor, prototypeScreenIdForAnchor, resolveNativeAnchorTarget, selectorForElementWithin, textQuoteContextForBlock, type PlanAnnotationAnchor, } from "@/lib/plan-native-anchors"; import { cn } from "@/lib/utils"; export { resolvePlanOrgAccessPrompt, buildNativeAnchorFromElement, nativeMarkerPlacementForAnchor, nativePointForAnchor, prototypeScreenIdForAnchor, resolveNativeAnchorTarget, selectorForElementWithin, localPlanBridgeRetryDelay, shouldRetryLocalPlanBridgeBundle, shouldShowPlanLoadError, }; export type { NativeMarkerPlacement } from "@/lib/plan-native-anchors"; export type { PlanOrgAccessPrompt } from "@/lib/plan-access-prompt"; export { canSubmitInlineCommentDraft, mentionQueryAtCaret }; export type { CommentDraft } from "@/lib/plan-comment-editor-helpers"; const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; function GoogleLogoIcon({ className }: { className?: string }) { return ( ); } const SOURCE_OPTIONS: Array<{ value: PlanSource }> = [ { value: "codex" }, { value: "claude-code" }, { value: "cursor" }, { value: "pi" }, { value: "manual" }, { value: "imported" }, ]; const REPORT_REASON_OPTIONS: Array<{ value: PlanReportReason; }> = [ { value: "spam" }, { value: "harassment" }, { value: "hate" }, { value: "sexual" }, { value: "violence" }, { value: "self-harm" }, { value: "privacy" }, { value: "illegal" }, { value: "other" }, ]; const PLAN_READER_VIEW_EVENT = "plans-reader-view-change"; const RECAP_SCREENSHOT_QUERY_PARAM = "recapScreenshot"; const RECAP_SCREENSHOT_THEME_QUERY_PARAM = "recapScreenshotTheme"; const ENABLE_PLAN_STATUS_FEATURE = false; const GITHUB_LIGHT_CANVAS_BACKGROUND = "#ffffff"; const GITHUB_DARK_CANVAS_BACKGROUND = "#0d1117"; const LOCAL_PLAN_OWNER_EMAIL = "local@agent-native.local"; const PLAN_DOCS_URL = "https://www.agent-native.com/docs/template-plan"; const LOCAL_FILES_DOCS_URL = `${PLAN_DOCS_URL}#local-files`; const AUTO_DEV_COMMENT_EMAILS = new Set(["dev@local.test", "dev@local"]); const CURRENT_USER_FALLBACK_NAME = "You"; const CURRENT_USER_FALLBACK_INITIALS = "You"; const CURRENT_USER_FALLBACK_COLOR = "#2563eb"; function readPreferredEditor(): PreferredEditor { if (typeof window === "undefined") return "vscode"; const stored = window.localStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY); return PREFERRED_EDITOR_VALUES.includes(stored as PreferredEditor) ? (stored as PreferredEditor) : "vscode"; } function desktopPlanAutoSyncKey(planId: string): string { return `${DESKTOP_PLAN_SYNC_AUTO_KEY_PREFIX}${planId}`; } function readDesktopPlanAutoSync(planId: string | undefined): boolean { if (typeof window === "undefined" || !planId) return false; return window.localStorage.getItem(desktopPlanAutoSyncKey(planId)) === "1"; } type OrgMemberSuggestion = { email: string; role?: string; }; type InlineCommentPosition = { left: number; top: number; pinLeft: number; pinTop: number; width: number; }; type NativeSelectionComment = { anchor: PlanAnnotationAnchor; toolbarLeft: number; toolbarTop: number; position: InlineCommentPosition; }; type PlanDocumentState = { scrollX: number; scrollY: number; scrollWidth: number; scrollHeight: number; clientWidth: number; clientHeight: number; }; export function resetPlanReaderScrollPosition(reader: HTMLElement | null) { if (reader) { reader.scrollTo({ left: 0, top: 0, behavior: "auto" }); reader.scrollLeft = 0; reader.scrollTop = 0; } if (typeof window !== "undefined") { window.scrollTo(0, 0); } } function shortDate(value: string) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(value)); } function statusLabel(status: string) { return status.replace(/_/g, " "); } function newCommentId() { return `cmt_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; } type CommentIdentitySource = { createdBy?: string | null; authorEmail?: string | null; authorName?: string | null; }; type CommentAuthorPresentation = { name: string; email: string | null; initials: string; color: string; avatarUrl: string | null; }; type CurrentCommentAuthor = { email: string | null; name: string | null; avatarUrl: string | null; color: string; }; type CommentThread = { id: string; root: PlanCommentItem; replies: PlanCommentItem[]; comments: PlanCommentItem[]; anchor: PlanAnnotationAnchor | null; }; export type CommentVisibility = "hidden" | "open" | "all"; type DeleteCommentRequest = { commentId: string; replyCount: number; }; type DeletePlanTarget = Pick< PlanSummary, "id" | "title" | "kind" | "deletedAt" | "canDelete" >; type DeletePlanRequest = { plan: DeletePlanTarget; mode: "soft" | "hard"; }; function withPlanComments( bundle: PlanBundleWithHtml, comments: PlanCommentItem[], ): PlanBundleWithHtml { return { ...bundle, comments, summary: { ...bundle.summary, commentCount: comments.length, openCommentCount: comments.filter((comment) => comment.status === "open") .length, }, }; } export function addPlanCommentToBundle( bundle: PlanBundleWithHtml, comment: PlanCommentItem, ): PlanBundleWithHtml { const exists = bundle.comments.some((item) => item.id === comment.id); return withPlanComments( bundle, exists ? bundle.comments.map((item) => (item.id === comment.id ? comment : item)) : [...bundle.comments, comment], ); } export function removePlanCommentFromBundle( bundle: PlanBundleWithHtml, commentId: string, ): PlanBundleWithHtml { return withPlanComments( bundle, bundle.comments.filter((comment) => comment.id !== commentId), ); } export function removePlanCommentThreadFromBundle( bundle: PlanBundleWithHtml, commentId: string, ): PlanBundleWithHtml { const childrenByParent = new Map(); for (const comment of bundle.comments) { if (!comment.parentCommentId) continue; const children = childrenByParent.get(comment.parentCommentId) ?? []; children.push(comment); childrenByParent.set(comment.parentCommentId, children); } const removedIds = new Set(); const stack = [commentId]; while (stack.length > 0) { const id = stack.pop(); if (!id || removedIds.has(id)) continue; removedIds.add(id); stack.push( ...(childrenByParent.get(id) ?? []).map((comment) => comment.id), ); } return withPlanComments( bundle, bundle.comments.filter((comment) => !removedIds.has(comment.id)), ); } function commentDescendantCount( comments: Array<{ id: string; parentCommentId?: string | null }>, commentId: string, ) { const childrenByParent = new Map(); for (const comment of comments) { if (!comment.parentCommentId) continue; const children = childrenByParent.get(comment.parentCommentId) ?? []; children.push(comment.id); childrenByParent.set(comment.parentCommentId, children); } let count = 0; const seen = new Set(); const stack = [...(childrenByParent.get(commentId) ?? [])]; while (stack.length > 0) { const id = stack.pop(); if (!id || seen.has(id)) continue; seen.add(id); count += 1; stack.push(...(childrenByParent.get(id) ?? [])); } return count; } function deleteCommentLabel(replyCount: number, t: ReturnType) { return replyCount > 0 ? t("plansPage.comments.deleteThread") : t("plansPage.comments.deleteComment"); } export function shouldKeepCommentPopoverOpenForTarget( target: EventTarget | null, popover: HTMLElement | null, ) { if (!target) return false; if (target instanceof Node && popover?.contains(target)) return true; if (!(target instanceof Element)) return false; if (target.closest("[data-comment-marker]")) return true; if (target.closest("[data-comment-popover-portal]")) return true; return false; } function normalizeCommentEmail(email: string | null | undefined) { const trimmed = email?.trim().toLowerCase(); return trimmed || null; } function isLocalCurrentUserEmail(email: string | null) { return ( email === LOCAL_PLAN_OWNER_EMAIL || (email !== null && AUTO_DEV_COMMENT_EMAILS.has(email)) ); } function currentCommentAuthorPresentation( source: CommentIdentitySource, currentUser?: CurrentCommentAuthor | null, ): CommentAuthorPresentation | null { if (source.createdBy && source.createdBy !== "human") return null; const sourceEmail = normalizeCommentEmail(source.authorEmail); const rawCurrentEmail = normalizeCommentEmail(currentUser?.email); const currentIsSynthetic = isLocalCurrentUserEmail(rawCurrentEmail); const currentEmail = currentIsSynthetic ? null : rawCurrentEmail; const currentName = currentIsSynthetic ? null : currentUser?.name?.trim(); const currentAvatarUrl = currentIsSynthetic ? null : currentUser?.avatarUrl; const isCurrentEmail = Boolean( sourceEmail && currentEmail && sourceEmail === currentEmail, ); const isLocalIdentity = isLocalCurrentUserEmail(sourceEmail); const isAnonymousHuman = !sourceEmail && !source.authorName?.trim() && source.createdBy === "human"; if (!isCurrentEmail && !isLocalIdentity && !isAnonymousHuman) return null; const name = currentName || (currentEmail ? emailToName(currentEmail) : CURRENT_USER_FALLBACK_NAME); const hasPersonalIdentity = Boolean(currentEmail || currentName); return { name, email: currentEmail, initials: hasPersonalIdentity ? commentAuthorInitials(name) : CURRENT_USER_FALLBACK_INITIALS, color: currentUser?.color ?? CURRENT_USER_FALLBACK_COLOR, avatarUrl: currentAvatarUrl ?? null, }; } function commentAuthorName( source: CommentIdentitySource, currentUser?: CurrentCommentAuthor | null, ) { const current = currentCommentAuthorPresentation(source, currentUser); if (current) return current.name; const explicitName = source.authorName?.trim(); if (explicitName) return explicitName; const email = normalizeCommentEmail(source.authorEmail); if (email) return emailToName(email); if (source.createdBy === "agent") return "Agent"; // i18n-key-ignore stable actor id fallback if (source.createdBy === "import") return "Imported"; // i18n-key-ignore stable import actor fallback return "Reviewer"; // i18n-key-ignore stable anonymous reviewer fallback } function commentAuthorInitials(name: string) { const parts = name .replace(/@.*/, "") .split(/[\s._-]+/) .map((part) => part.trim()) .filter(Boolean); const raw = parts.length >= 2 ? `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}` : (parts[0]?.slice(0, 2) ?? name.slice(0, 2)); return raw.toUpperCase() || "?"; } function commentAuthorColor( source: CommentIdentitySource, currentUser?: CurrentCommentAuthor | null, ) { const current = currentCommentAuthorPresentation(source, currentUser); if (current) return current.color; const email = normalizeCommentEmail(source.authorEmail); if (email) return emailToColor(email); if (source.createdBy === "agent") return "#0ea5e9"; if (source.createdBy === "import") return "#737373"; return "#525252"; } function commentAuthorPresentation( source: CommentIdentitySource, avatarUrl?: string | null, currentUser?: CurrentCommentAuthor | null, ): CommentAuthorPresentation { const current = currentCommentAuthorPresentation(source, currentUser); if (current) return current; const name = commentAuthorName(source); return { name, email: normalizeCommentEmail(source.authorEmail), initials: commentAuthorInitials(name), color: commentAuthorColor(source), avatarUrl: avatarUrl ?? null, }; } function commentAuthorLabel(source: CommentIdentitySource) { const author = commentAuthorPresentation(source); return author.email ? `${author.name} <${author.email}>` : author.name; } function commentAuthorAvatarUrl( source: CommentIdentitySource, avatarUrls: Record, ) { const email = normalizeCommentEmail(source.authorEmail); return email ? (avatarUrls[email] ?? null) : null; } export function commentAuthorEmails( comments: Array, currentEmail?: string | null, ) { const emails = new Set(); for (const comment of comments) { const email = normalizeCommentEmail(comment.authorEmail); if (email) emails.add(email); } const normalizedCurrent = normalizeCommentEmail(currentEmail); if (normalizedCurrent) emails.add(normalizedCurrent); return Array.from(emails).sort(); } async function fetchCommentAvatar(email: string) { const response = await fetch( agentNativePath(`/_agent-native/avatar/${encodeURIComponent(email)}`), ); if (!response.ok) return null; const data = (await response.json()) as { image?: unknown }; return typeof data.image === "string" ? data.image : null; } function useCommentAvatarUrls(emails: string[]) { const cacheRef = useRef>({}); const [urls, setUrls] = useState>({}); const emailKey = emails.join("|"); useEffect(() => { let cancelled = false; const missing = emails.filter((email) => !(email in cacheRef.current)); if (missing.length === 0) { setUrls({ ...cacheRef.current }); return; } void Promise.all( missing.map(async (email) => { cacheRef.current[email] = await fetchCommentAvatar(email).catch( () => null, ); }), ).then(() => { if (!cancelled) setUrls({ ...cacheRef.current }); }); return () => { cancelled = true; }; }, [emailKey]); return urls; } function useOrgMemberMentionSearch(query: string | null) { const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(false); const requestRef = useRef(0); useEffect(() => { const search = query?.trim() ?? ""; if (query === null) { setMembers([]); setIsLoading(false); return; } const requestId = ++requestRef.current; const controller = new AbortController(); setIsLoading(true); const params = new URLSearchParams(); if (search) params.set("search", search); params.set("limit", "8"); fetch(`${agentNativePath("/_agent-native/org/members")}?${params}`, { credentials: "include", signal: controller.signal, }) .then((response) => { if (!response.ok) throw new Error("Could not load members"); return response.json() as Promise<{ members?: unknown }>; }) .then((data) => { if (controller.signal.aborted || requestId !== requestRef.current) { return; } const next = Array.isArray(data.members) ? data.members .map((member) => { if (!member || typeof member !== "object") return null; const value = member as { email?: unknown; role?: unknown }; const email = typeof value.email === "string" ? normalizeCommentEmail(value.email) : null; if (!email) return null; const suggestion: OrgMemberSuggestion = { email }; if (typeof value.role === "string") { suggestion.role = value.role; } return suggestion; }) .filter((member): member is OrgMemberSuggestion => Boolean(member), ) : []; setMembers(next); }) .catch(() => { if (!controller.signal.aborted && requestId === requestRef.current) { setMembers([]); } }) .finally(() => { if (!controller.signal.aborted && requestId === requestRef.current) { setIsLoading(false); } }); return () => controller.abort(); }, [query]); return { members, isLoading }; } function elementForShortcutTarget(target: EventTarget | null) { if (target instanceof Element) return target; if (target instanceof Node) return target.parentElement; return null; } export function isPlanCommentShortcutEditableTarget( target: EventTarget | null, ) { const element = elementForShortcutTarget(target); if (!element) return false; return Boolean( element.closest( "input, textarea, select, [contenteditable]:not([contenteditable='false']), [role='textbox']", ), ); } export function shouldHandlePlanCommentShortcut( event: Pick< KeyboardEvent, | "altKey" | "ctrlKey" | "defaultPrevented" | "key" | "metaKey" | "shiftKey" | "target" >, ) { if (event.defaultPrevented) return false; const activeElement = typeof document === "undefined" ? null : document.activeElement; if ( isPlanCommentShortcutEditableTarget(activeElement) || isPlanCommentShortcutEditableTarget(event.target) ) { return false; } const key = event.key.toLowerCase(); if ( key === "c" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey ) { return true; } return ( key === "m" && event.metaKey && event.shiftKey && !event.ctrlKey && !event.altKey ); } export function defaultInlineCommentDraftForPlanContext(input: { planKind?: PlanKind | null; ownerEmail?: string | null; sourceAuthorName?: string | null; sourceAuthorLogin?: string | null; accessRole?: NonNullable["role"] | null; currentEmail?: string | null; }): CommentDraft { const currentEmail = normalizeCommentEmail(input.currentEmail); if (input.planKind === "recap") { const targetLabel = input.sourceAuthorName?.trim() || input.sourceAuthorLogin?.trim(); if (!targetLabel || input.accessRole === "owner") { return { message: "", mentions: [], resolutionTarget: "agent" }; } const mention: PlanCommentMention = { email: SOURCE_AUTHOR_COMMENT_MENTION_EMAIL, label: targetLabel, role: "source-author", }; return { message: `${formatPlanCommentMentionToken(mention)} `, mentions: [mention], resolutionTarget: "human", }; } const targetEmail = normalizeCommentEmail(input.ownerEmail); if ( !targetEmail || input.accessRole === "owner" || targetEmail === currentEmail ) { return { message: "", mentions: [], resolutionTarget: "agent" }; } const mention = { email: targetEmail, label: displayNameForMention(targetEmail), }; return { message: `${formatPlanCommentMentionToken(mention)} `, mentions: [mention], resolutionTarget: "human", }; } function safeDecodeURIComponent(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } function renderCommentMessage(message: string) { const parts: ReactNode[] = []; let lastIndex = 0; const pattern = /@\[([^\]]+)\]\(mailto:([^)]+)\)/g; let match: RegExpExecArray | null; while ((match = pattern.exec(message)) !== null) { if (match.index > lastIndex) { parts.push(message.slice(lastIndex, match.index)); } const label = match[1] ?? ""; const email = safeDecodeURIComponent(match[2] ?? ""); parts.push( {label || email} , ); lastIndex = pattern.lastIndex; } if (lastIndex < message.length) parts.push(message.slice(lastIndex)); return parts.length > 0 ? parts : message; } function CommentAvatar({ author, size = "sm", className, }: { author: CommentAuthorPresentation; size?: "pin" | "sm" | "md"; className?: string; }) { const sizeClass = size === "pin" ? "size-7" : size === "md" ? "size-8" : "size-7"; return ( {author.avatarUrl && ( )} {author.initials} ); } function CommentThreadMarker({ participants, count, title, className, style, onClick, }: { participants: CommentAuthorPresentation[]; count: number; title: string; className?: string; style?: CSSProperties; onClick: () => void; }) { const visibleParticipants = participants.slice(0, 2); const single = visibleParticipants.length <= 1 && count <= 1; return ( ); } function commentCreatedTime(comment: { createdAt?: string }) { const time = Date.parse(comment.createdAt ?? ""); return Number.isFinite(time) ? time : 0; } function sortCommentsByCreatedAt( comments: T[], ) { return [...comments].sort((a, b) => { const delta = commentCreatedTime(a) - commentCreatedTime(b); return delta === 0 ? a.id.localeCompare(b.id) : delta; }); } function findThreadRoot( comment: PlanCommentItem, byId: Map, ) { let current = comment; const seen = new Set(); while (current.parentCommentId) { if (seen.has(current.id)) break; seen.add(current.id); const parent = byId.get(current.parentCommentId); if (!parent) break; current = parent; } return current; } export function buildCommentThreads( comments: PlanCommentItem[], ): CommentThread[] { const sorted = sortCommentsByCreatedAt(comments); const byId = new Map(sorted.map((comment) => [comment.id, comment])); const threads = new Map(); for (const comment of sorted) { const root = findThreadRoot(comment, byId); const thread = threads.get(root.id) ?? ({ id: root.id, root, replies: [], comments: [], anchor: parseAnchorForComment(root), } satisfies CommentThread); thread.comments.push(comment); threads.set(root.id, thread); } return Array.from(threads.values()) .map((thread) => { const commentsInThread = sortCommentsByCreatedAt(thread.comments); const root = commentsInThread.find((comment) => comment.id === thread.id) ?? thread.root; const anchor = parseAnchorForComment(root) ?? commentsInThread .map((comment) => parseAnchorForComment(comment)) .find(Boolean) ?? null; return { ...thread, root, comments: commentsInThread, replies: commentsInThread.filter((comment) => comment.id !== root.id), anchor, }; }) .sort((a, b) => { const delta = commentCreatedTime(a.root) - commentCreatedTime(b.root); return delta === 0 ? a.id.localeCompare(b.id) : delta; }); } function commentThreadStatus(thread: CommentThread) { return thread.comments.some((comment) => comment.status === "open") ? "open" : "resolved"; } export function commentThreadsForVisibility( threads: CommentThread[], visibility: CommentVisibility, ) { if (visibility === "hidden") return []; if (visibility === "all") return threads; return threads.filter((thread) => commentThreadStatus(thread) === "open"); } function visualSurfaceModeForAnchor( anchor: PlanAnnotationAnchor | null, ): PlanVisualSurfaceMode | null { if (!anchor) return null; const targetSelector = anchor.targetSelector ?? ""; if ( anchor.targetKind === "prototype" || anchor.screenId || prototypeScreenIdForAnchor(anchor) ) { return "prototype"; } if ( anchor.targetKind === "wireframe" || anchor.targetKind === "canvas" || anchor.planAnnotationId || anchor.canvasX !== undefined || anchor.canvasY !== undefined || targetSelector.includes("data-plan-canvas-world") || targetSelector.includes("plan-canvas-world") || anchor.targetNodeId ) { return "wireframes"; } return null; } export function commentThreadsForVisualSurfaceMode( threads: CommentThread[], visualSurfaceMode: PlanVisualSurfaceMode, ) { return threads.filter((thread) => { const threadSurfaceMode = visualSurfaceModeForAnchor(thread.anchor); return !threadSurfaceMode || threadSurfaceMode === visualSurfaceMode; }); } function commentIdentityKey(source: CommentIdentitySource, fallbackId: string) { return ( normalizeCommentEmail(source.authorEmail) ?? `${source.createdBy ?? "human"}:${commentAuthorName(source)}:${fallbackId}` ); } function commentThreadParticipants( thread: CommentThread, avatarUrls: Record, currentUser?: CurrentCommentAuthor | null, ) { const seen = new Set(); const participants: CommentAuthorPresentation[] = []; for (const comment of thread.comments) { const author = commentAuthorPresentation( comment, commentAuthorAvatarUrl(comment, avatarUrls), currentUser, ); const key = author.email ?? (author.name === CURRENT_USER_FALLBACK_NAME ? "current-user" : commentIdentityKey(comment, comment.id)); if (seen.has(key)) continue; seen.add(key); participants.push(author); } return participants; } function runtimeCommentFromPlanComment( comment: PlanCommentItem, avatarUrls: Record, currentUser?: CurrentCommentAuthor | null, ): RuntimeAnnotationComment { const author = commentAuthorPresentation( comment, commentAuthorAvatarUrl(comment, avatarUrls), currentUser, ); return { id: comment.id, message: comment.message, status: comment.status, createdBy: comment.createdBy, parentCommentId: comment.parentCommentId, authorEmail: author.email, authorName: author.name, authorAvatarUrl: author.avatarUrl, authorColor: author.color, authorInitials: author.initials, createdAt: comment.createdAt, }; } function runtimeParticipantFromAuthor( author: CommentAuthorPresentation, ): RuntimeAnnotationParticipant { return { id: author.email ?? author.name, authorEmail: author.email, authorName: author.name, authorAvatarUrl: author.avatarUrl, authorColor: author.color, authorInitials: author.initials, }; } export function runtimeAnnotationFromThread( thread: CommentThread, index: number, avatarUrls: Record, currentUser?: CurrentCommentAuthor | null, ): RuntimeAnnotation | null { if (!thread.anchor) return null; const root = runtimeCommentFromPlanComment( thread.root, avatarUrls, currentUser, ); return { ...root, index: index + 1, kind: thread.root.kind, status: commentThreadStatus(thread), sectionId: thread.root.sectionId, anchor: thread.anchor, replies: thread.replies.map((reply) => runtimeCommentFromPlanComment(reply, avatarUrls, currentUser), ), participants: commentThreadParticipants( thread, avatarUrls, currentUser, ).map(runtimeParticipantFromAuthor), commentCount: thread.comments.length, }; } function runtimeCommentFromAuthor( comment: RuntimeAnnotationComment, ): CommentAuthorPresentation { const author = commentAuthorPresentation( { createdBy: comment.createdBy, authorEmail: comment.authorEmail, authorName: comment.authorName, }, comment.authorAvatarUrl, ); return { ...author, color: comment.authorColor ?? author.color, initials: comment.authorInitials ?? author.initials, }; } function runtimeAnnotationRootComment( annotation: RuntimeAnnotation, ): RuntimeAnnotationComment { return { id: annotation.id, message: annotation.message, status: annotation.status, createdBy: annotation.createdBy, parentCommentId: annotation.parentCommentId, authorEmail: annotation.authorEmail, authorName: annotation.authorName, authorAvatarUrl: annotation.authorAvatarUrl, authorColor: annotation.authorColor, authorInitials: annotation.authorInitials, createdAt: annotation.createdAt, }; } function runtimeAnnotationComments(annotation: RuntimeAnnotation) { return [ runtimeAnnotationRootComment(annotation), ...(annotation.replies ?? []), ]; } function runtimeParticipantPresentation( participant: RuntimeAnnotationParticipant, ) { const author = commentAuthorPresentation( { authorEmail: participant.authorEmail, authorName: participant.authorName, }, participant.authorAvatarUrl, ); return { ...author, color: participant.authorColor ?? author.color, initials: participant.authorInitials ?? author.initials, }; } function runtimeAnnotationMarkerTitle(annotation: RuntimeAnnotation) { const names = annotation.participants .map((participant) => runtimeParticipantPresentation(participant).name) .filter(Boolean) .slice(0, 3) .join(", "); const countLabel = `${annotation.commentCount} comment${ annotation.commentCount === 1 ? "" : "s" }`; return names ? `${countLabel} by ${names}: ${annotation.message}` : `${countLabel}: ${annotation.message}`; } function planExportFilename( title: string | undefined, extension: "html" | "md" | "zip", ) { const slug = (title || "visual-plan") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 72) || "visual-plan"; return `${slug}.${extension}`; } function downloadTextFile( filename: string, contents: string, type = "text/html;charset=utf-8", ) { const blob = new Blob([contents], { type }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.rel = "noopener"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function downloadBlob(filename: string, blob: Blob) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.rel = "noopener"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function resolveInlineCommentPosition(input: { pointX: number; pointY: number; viewportWidth: number; viewportHeight: number; }): InlineCommentPosition { const popoverWidth = Math.min(360, Math.max(260, input.viewportWidth - 32)); const popoverHeight = 158; const gap = 14; const opensRight = input.pointX + popoverWidth + gap + 16 <= input.viewportWidth; const left = opensRight ? input.pointX + gap : input.pointX - popoverWidth - gap; return { pinLeft: clamp(input.pointX, 12, Math.max(12, input.viewportWidth - 12)), pinTop: clamp(input.pointY, 12, Math.max(12, input.viewportHeight - 12)), left: clamp( left, 12, Math.max(12, input.viewportWidth - popoverWidth - 12), ), top: clamp( input.pointY - 18, 12, Math.max(12, input.viewportHeight - popoverHeight - 12), ), width: popoverWidth, }; } function buildPlanAgentContext(input: { bundle: PlanBundle & { html?: string }; documentHtml: string; url: string; screenshotNote?: string; }) { const isLocalPlan = "localOnly" in input.bundle && input.bundle.localOnly === true; const contentBlockCount = input.bundle.plan.content?.blocks.length ?? 0; const contentBlocks = input.bundle.plan.content?.blocks .slice(0, 12) .map( (block) => `- ${block.id}: ${block.type}${block.title ? `, "${block.title}"` : ""}`, ) .join("\n"); const openThreads = buildCommentThreads(input.bundle.comments).filter( (thread) => commentThreadStatus(thread) === "open", ); const actionableThreads = openThreads.filter( (thread) => normalizePlanCommentResolutionTarget(thread.anchor?.resolutionTarget) === "agent", ); const humanReviewThreads = openThreads.filter( (thread) => normalizePlanCommentResolutionTarget(thread.anchor?.resolutionTarget) === "human", ); const formatThreadGroup = ( threads: CommentThread[], input: { offset?: number; limit?: number } = {}, ) => threads .slice(0, input.limit ?? 20) .map((thread, index) => { const commentNumber = (input.offset ?? 0) + index + 1; const anchorDetails = planCommentAnchorDetails(thread.anchor); const messages = thread.comments .map((comment, messageIndex) => { const prefix = messageIndex === 0 ? "" : " Reply "; return `${prefix}${comment.id} / ${commentAuthorLabel( comment, )}: ${comment.message}`; }) .join("\n"); return [ `${commentNumber}. Thread ${thread.id}`, messages, ...anchorDetails.map((detail) => ` ${detail}`), ] .filter(Boolean) .join("\n"); }) .join("\n"); const actionableComments = formatThreadGroup(actionableThreads, { limit: 16, }); const humanReviewComments = formatThreadGroup(humanReviewThreads, { offset: actionableThreads.length, limit: 8, }); const omittedComments = Math.max(0, actionableThreads.length - 16) + Math.max(0, humanReviewThreads.length - 8); const omittedCommentNote = omittedComments > 0 ? isLocalPlan ? `\n${omittedComments} additional open thread(s) omitted from this composer context. Reopen the local plan or inspect comments.json for the full list before editing.` : `\n${omittedComments} additional open thread(s) omitted from this composer context. Call get-plan-feedback for the full list before editing.` : ""; const legacyUnroutedThreads = openThreads.filter( (thread) => !thread.anchor?.resolutionTarget, ); const legacyUnroutedComments = legacyUnroutedThreads .filter((thread) => !actionableThreads.includes(thread)) .slice(0, 4) .map((thread, index) => { const anchorDetails = planCommentAnchorDetails(thread.anchor); const messages = thread.comments .map((comment, messageIndex) => { const prefix = messageIndex === 0 ? "" : " Reply "; return `${prefix}${comment.id} / ${commentAuthorLabel( comment, )}: ${comment.message}`; }) .join("\n"); return [ `${index + 1}. Thread ${thread.id}`, messages, ...anchorDetails.map((detail) => ` ${detail}`), ] .filter(Boolean) .join("\n"); }) .join("\n"); const recentReviewEvents = input.bundle.events .filter((event) => event.type === "plan.updated") .slice(-6) .map((event) => { const payload = event.payload ? `\n Payload: ${JSON.stringify(event.payload)}` : ""; return `- ${event.createdAt} / ${event.createdBy}: ${event.message}${payload}`; }) .join("\n"); return [ "Current Agent-Native Plan review context:", `Plan ID: ${input.bundle.plan.id}`, `Title: ${input.bundle.plan.title}`, ...(ENABLE_PLAN_STATUS_FEATURE ? [`Status: ${input.bundle.plan.status}`] : []), ...(isLocalPlan ? [`Local folder: ${input.bundle.plan.repoPath}`] : []), `URL: ${input.url}`, input.bundle.plan.content ? `Structured content blocks: ${contentBlockCount}` : `Legacy rendered HTML length: ${input.documentHtml.length} characters`, "", ...(isLocalPlan ? [ "Local-files workflow:", "1. This is a local-only Agent-Native Plan or recap. Do not call hosted Plan tools such as get-visual-plan, get-plan-feedback, or update-visual-plan for this plan.", "2. Use the local MDX files and the feedback threads included below. If your host exposes local Plan tools, read the local folder and comments.json before editing.", "3. Preserve the user's existing annotation comments and intent unless the user asks to remove or resolve them.", "4. Keep the output as a refined document with rich text, tables, sketch diagrams, wireframes, implementation maps, code tabs, and bounded custom HTML fragments.", "5. After applying feedback, rerun the local Plan check/serve/verify path available in your environment and report the updated local URL or folder.", "6. Work the actionable agent comments first. Treat human-review comments as FYI/questions/approval items; do not silently resolve those unless the user explicitly asks.", "7. When visual screenshots are attached, each crop is centered near a comment marker and has a red ring on the exact commented point. Use the comment IDs and anchor details below to connect screenshots to threads.", "8. For text comments, use the quoted text plus Text before/Text after and Block type details. If a quote is marked ambiguous, ask instead of editing the wrong span.", ] : [ "Fast iteration workflow:", "1. Call get-visual-plan with this plan ID to read structured content, exported HTML, sections, comments, and activity.", '2. Prefer update-visual-plan contentPatches for targeted edits. Examples: update-rich-text for copy, patch-prototype-html / update-prototype-screen for live prototype states, update-wireframe-node for one kit-tree node, update-canvas-frame for frame layout, append-canvas-annotation / update-canvas-annotation for canvas markup, append-block/remove-block for document changes, or replace-block for a single block. When the user asks for higher fidelity, polished mockups, production-like UI, real design, or anything "not sketchy," update this same plan in place: rewrite the relevant screen HTML/CSS and include set-visual-render-mode with renderMode design. Do not create a second plan, put CSS in style tags, or treat the viewer-local Clean toggle as a fidelity upgrade. Use full content only for broad restructuring. Use html only when preserving or importing a legacy standalone HTML artifact.', "3. Preserve the user's existing annotation comments and intent unless the user asks to remove or resolve them.", "4. Match the requested fidelity: ordinary UI planning can use sketch diagrams and wireframes; high-fidelity requests need the persisted Design surface with deliberate branded HTML/CSS and stable data-design-id targets.", "5. After applying feedback, keep the plan scannable, editable, and serious instead of turning it into a marketing page.", "6. Work the actionable agent comments first. Treat human-review comments as FYI/questions/approval items; do not silently resolve those unless the user explicitly asks.", "7. When visual screenshots are attached, each crop is centered near a comment marker and has a red ring on the exact commented point. Use the comment IDs and anchor details below to connect screenshots to threads. If a visual comment is listed as overflow, rely on its anchorDetails/coordinates and call get-plan-feedback for the full manifest.", "8. For text comments, use the quoted text plus Text before/Text after and Block type details. If a quote is marked ambiguous, ask instead of editing the wrong span.", ]), contentBlocks ? `\nStructured content blocks:\n${contentBlocks}` : "", recentReviewEvents ? `\nRecent review/edit events:\n${recentReviewEvents}` : "", input.screenshotNote ? `\nScreenshot context:\n${input.screenshotNote}` : "", actionableComments ? `\nActionable agent comments:\n${actionableComments}` : "", humanReviewComments ? `\nHuman-review / FYI comments:\n${humanReviewComments}` : "", legacyUnroutedComments ? `\nLegacy unrouted comments:\n${legacyUnroutedComments}` : "", omittedCommentNote, ] .filter(Boolean) .join("\n"); } function buildApplyFeedbackMessage( openCommentCount: number, options: { local?: boolean } = {}, ) { if (options.local) { return `Apply the ${openCommentCount} open comment${openCommentCount === 1 ? "" : "s"} on this local visual plan. Use the local files and feedback context in this handoff; do not call hosted Plan tools for this local-only plan. Use any attached focused screenshots to understand visual comments, then update the local MDX plan/recap files as needed.`; } return `Apply the ${openCommentCount} open comment${openCommentCount === 1 ? "" : "s"} on this visual plan. Read the plan with get-visual-plan, read feedback with get-plan-feedback, use any attached focused screenshots to understand visual comments, then update structured content blocks, prototype screens, and related implementation details as needed. Use HTML only for legacy imported artifacts.`; } function buildQuestionFormRevisionMessage(summary: string) { return [ "Use these answered open questions to revise the existing Agent-Native Plan.", "Read the plan with get-visual-plan, then update the structured content with update-visual-plan contentPatches. Preserve the user's answers as direction, remove or update the answered question block if it is no longer useful, and keep any remaining unanswered decisions at the bottom as a question-form block.", "", summary, ].join("\n"); } function newCanvasMarkupId() { const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; return `ann_${id.replace(/-/g, "").slice(0, 16)}`; } function buildCanvasMarkupFeedbackMessage(annotation: PlanAnnotation) { const prefix = annotation.type === "callout" ? "Canvas callout" : "Canvas note"; return `${prefix}: ${annotation.text}`; } const MAX_FEEDBACK_SCREENSHOTS = 8; function shouldCaptureAnchor(anchor: PlanAnnotationAnchor | null) { if (!anchor) return false; if (anchor.anchorKind === "text" && anchor.textQuote) return false; return ( anchor.anchorKind === "visual" || anchor.anchorKind === "point" || anchor.targetKind === "image" || anchor.targetKind === "prototype" || anchor.targetKind === "wireframe" || anchor.targetKind === "canvas" || anchor.targetKind === "diagram" || Boolean(anchor.planAnnotationId) ); } function feedbackScreenshotPriority(thread: CommentThread) { const anchor = thread.anchor; const resolver = normalizePlanCommentResolutionTarget(anchor?.resolutionTarget) === "agent" ? 2 : 0; const visualWeight = anchor?.targetKind === "canvas" || anchor?.targetKind === "prototype" || anchor?.targetKind === "wireframe" || anchor?.targetKind === "diagram" || anchor?.targetKind === "image" || Boolean(anchor?.planAnnotationId) ? 1 : 0; const replyWeight = Math.min(thread.replies.length, 3) * 0.2; const latestTime = Math.max( ...thread.comments.map((comment) => { const time = Date.parse(comment.createdAt ?? ""); return Number.isFinite(time) ? time : 0; }), ); return ( resolver + visualWeight + replyWeight + latestTime / 10_000_000_000_000 ); } function nextFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function cropFeedbackScreenshot(input: { canvas: HTMLCanvasElement; surfaceWidth: number; surfaceHeight: number; pointX: number; pointY: number; label: string; }) { const scaleX = input.canvas.width / Math.max(input.surfaceWidth, 1); const scaleY = input.canvas.height / Math.max(input.surfaceHeight, 1); const cropCssWidth = Math.min(760, input.surfaceWidth); const cropCssHeight = Math.min(520, input.surfaceHeight); const cropWidth = Math.round(cropCssWidth * scaleX); const cropHeight = Math.round(cropCssHeight * scaleY); const centerX = input.pointX * scaleX; const centerY = input.pointY * scaleY; const cropX = clamp( centerX - cropWidth / 2, 0, input.canvas.width - cropWidth, ); const cropY = clamp( centerY - cropHeight / 2, 0, input.canvas.height - cropHeight, ); const output = document.createElement("canvas"); output.width = cropWidth; output.height = cropHeight; const ctx = output.getContext("2d"); if (!ctx) return null; ctx.drawImage( input.canvas, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight, ); const markerX = centerX - cropX; const markerY = centerY - cropY; ctx.save(); ctx.lineWidth = Math.max(4, 2.5 * scaleX); ctx.strokeStyle = "#ff334e"; ctx.fillStyle = "rgba(255, 51, 78, 0.14)"; ctx.beginPath(); ctx.arc(markerX, markerY, Math.max(22, 14 * scaleX), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(12, 12, 14, 0.88)"; ctx.strokeStyle = "rgba(255, 255, 255, 0.42)"; ctx.lineWidth = 1; const label = input.label.slice(0, 72); ctx.font = `${Math.max(13, 12 * scaleX)}px ui-sans-serif, system-ui`; const labelWidth = Math.min( cropWidth - 24, ctx.measureText(label).width + 24, ); const labelX = clamp(markerX + 18, 12, cropWidth - labelWidth - 12); const labelY = clamp(markerY - 36, 12, cropHeight - 34); ctx.beginPath(); ctx.roundRect(labelX, labelY, labelWidth, 28, 8); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#ffffff"; ctx.fillText(label, labelX + 12, labelY + 19); ctx.restore(); return output.toDataURL("image/png"); } type PlanAccessRole = "owner" | "viewer" | "editor" | "admin"; /** * Status options available in the reviewer approval workflow. * "archived" is intentionally omitted — it lives in the kebab menu. */ const APPROVAL_STATUSES: PlanStatus[] = [ "draft", "review", "approved", "in_progress", "complete", ]; function statusBadgeClasses(status: PlanStatus): string { if (status === "approved") { return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"; } if (status === "complete") { return "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400"; } if (status === "in_progress") { return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"; } // draft, review, archived — neutral return ""; } /** * Compact status badge/chip for the plan detail toolbar. * * - Editors (owner/admin/editor): clicking the badge opens a DropdownMenu to * transition the plan's status. The update is optimistic with rollback. * - Viewers / anonymous: the badge is inert (shows current status, no menu). * - Recaps: the parent must not render this component at all. */ function PlanStatusControl({ planId, status, canEdit, }: { planId: string; status: PlanStatus; canEdit: boolean; }) { const t = useT(); const qc = useQueryClient(); const updateStatus = useUpdatePlanStatus(); const handleSelect = useCallback( (newStatus: PlanStatus) => { if (newStatus === status) return; // Optimistic: patch both the bundle cache and the list cache. const bundleKey = planBundleQueryKey(planId); const prevBundle = qc.getQueryData(bundleKey); const prevActiveList = qc.getQueryData( ACTIVE_PLANS_QUERY_KEY, ); const prevAllList = qc.getQueryData(ALL_PLANS_QUERY_KEY); qc.setQueryData(bundleKey, (prev: PlanBundleWithHtml | undefined) => prev ? { ...prev, plan: { ...prev.plan, status: newStatus } } : prev, ); for (const listKey of [ACTIVE_PLANS_QUERY_KEY, ALL_PLANS_QUERY_KEY]) { qc.setQueryData(listKey, (prev: PlanSummary[] | undefined) => prev?.map((p) => (p.id === planId ? { ...p, status: newStatus } : p)), ); } updateStatus.mutate( { planId, status: newStatus }, { onError: () => { // Roll back optimistic updates. if (prevBundle !== undefined) qc.setQueryData(bundleKey, prevBundle); if (prevActiveList !== undefined) qc.setQueryData(ACTIVE_PLANS_QUERY_KEY, prevActiveList); if (prevAllList !== undefined) qc.setQueryData(ALL_PLANS_QUERY_KEY, prevAllList); toast.error(t("plansPage.status.updateFailed")); }, }, ); }, [planId, qc, status, t, updateStatus], ); const badgeClassName = cn( "pointer-events-auto flex h-7 items-center gap-1 rounded-md border px-2 text-xs font-medium", statusBadgeClasses(status), canEdit && "cursor-pointer select-none hover:opacity-80", ); const badgeInner = ( <> {status === "approved" && ( )} {t(`plansPage.status.labels.${status}`)} {canEdit && } ); if (!canEdit) { return {badgeInner}; } return ( {t("plansPage.status.setStatus")} {APPROVAL_STATUSES.map((s) => ( handleSelect(s)} > {s === "approved" ? ( ) : ( )} {t(`plansPage.status.labels.${s}`)} ))} ); } function LocalModeBadge() { const t = useT(); return ( {t("plansPage.localMode.badge")}

{t("plansPage.localMode.title")}

{t("plansPage.localMode.description")}

{t("plansPage.localMode.openDocs")}

); } export function canEditPlanContentRole(role?: PlanAccessRole | null) { return role === "owner" || role === "admin" || role === "editor"; } export function PlansPage({ localPlanSlug }: { localPlanSlug?: string } = {}) { const t = useT(); const params = useParams<{ id?: string }>(); const navigate = useNavigate(); const location = useLocation(); const iframeRef = useRef(null); const nativeReaderRef = useRef(null); const nativeCommentPointerRef = useRef<{ clientX: number; clientY: number; } | null>(null); const documentStateRef = useRef(null); const pendingDocumentRestoreRef = useRef(null); const pendingDocumentRestoreTimerRef = useRef(null); const nativeScrollFrameRef = useRef(null); const initialReaderScrollResetKeyRef = useRef(null); const [createOpen, setCreateOpen] = useState(false); const [annotationsOpen, setAnnotationsOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [promoteLocalPlanOpen, setPromoteLocalPlanOpen] = useState(false); const [promoteLocalPlanTargetPath, setPromoteLocalPlanTargetPath] = useState(""); const [promoteLocalPlanOverwrite, setPromoteLocalPlanOverwrite] = useState(false); const [planFullscreen, setPlanFullscreen] = useState(true); const [annotateMode, setAnnotateMode] = useState(false); const [canvasMarkupMode, setCanvasMarkupMode] = useState("none"); const [visualSurfaceMode, setVisualSurfaceMode] = useState("none"); const [preferredEditor, setPreferredEditor] = useState(() => readPreferredEditor(), ); const [agentSidebarOpen, setAgentSidebarOpen] = useState(false); const [sendingFeedback, setSendingFeedback] = useState(false); const [localBridgeCommentPending, setLocalBridgeCommentPending] = useState(false); const [pendingAnnotation, setPendingAnnotation] = useState(null); const [inlineCommentPosition, setInlineCommentPosition] = useState(null); const [nativeSelectionComment, setNativeSelectionComment] = useState(null); const [activeAnnotation, setActiveAnnotation] = useState<{ annotation: RuntimeAnnotation; position: InlineCommentPosition; } | null>(null); const [deleteCommentRequest, setDeleteCommentRequest] = useState(null); const [deletePlanRequest, setDeletePlanRequest] = useState(null); const [nativeMarkerVersion, setNativeMarkerVersion] = useState(0); const [commentVisibility, setCommentVisibility] = useState("open"); // When a comment submit fails, stash the draft here so the popover can // re-open with the user's text pre-filled (Issue 2a). const [failedCommentDraft, setFailedCommentDraft] = useState(null); // Ref that signals the 3-second poll to pause while a comment mutation is // in-flight. Prevents poll-driven cache replacement from evicting optimistic // comments before the server write commits (Issue 4a). const { session, isLoading: sessionLoading } = useSession(); const localPlanMode = Boolean(localPlanSlug); const routeSearchParams = useMemo( () => new URLSearchParams(location.search), [location.search], ); const localPlanBridgeUrl = localPlanMode && localPlanSlug ? localPlanBridgeUrlFromLocation(location.hash, localPlanSlug) : null; const localPlanRepoPath = localPlanMode ? routeSearchParams.get("path") : null; const routeSelectedId = params.id; const [localNetworkPermission, setLocalNetworkPermission] = useState( localPlanBridgeUrl ? "checking" : "unsupported", ); const [localBridgeConnectionRequested, setLocalBridgeConnectionRequested] = useState(false); const checkLocalNetworkPermission = useCallback(async () => { if (!localPlanBridgeUrl) { setLocalNetworkPermission("unsupported"); return; } setLocalNetworkPermission("checking"); setLocalBridgeConnectionRequested(false); const state = await localNetworkAccessPermissionState(); setLocalNetworkPermission(state); }, [localPlanBridgeUrl]); useEffect(() => { void checkLocalNetworkPermission(); }, [checkLocalNetworkPermission]); const localBridgeFetchEnabled = Boolean( localPlanMode && localPlanSlug && localPlanBridgeUrl && (localNetworkPermission === "granted" || localNetworkPermission === "unsupported" || localBridgeConnectionRequested), ); const localPlanBridgeQuery = useQuery({ queryKey: localPlanBridgeQueryKey( localPlanSlug ?? "", localPlanBridgeUrl ?? "", ), enabled: localBridgeFetchEnabled, refetchOnWindowFocus: false, retry: shouldRetryLocalPlanBridgeBundle, retryDelay: localPlanBridgeRetryDelay, queryFn: () => fetchLocalPlanBridgeBundle(localPlanBridgeUrl ?? "", localPlanSlug ?? ""), }); const localPlanQuery = useActionQuery( "get-local-plan-folder", localPlanBundleQueryParams(localPlanSlug ?? "", localPlanRepoPath), { enabled: localPlanMode && Boolean(localPlanSlug) && !localPlanBridgeUrl, refetchInterval: false, }, ); // Bridge bundles carry no comments; load comments.json from the colocated // folder so they render and survive refresh in bridge mode too. const localPlanBridgeCommentsQuery = useQuery({ queryKey: ["local-plan-bridge-comments", localPlanBridgeUrl], enabled: localPlanMode && Boolean(localPlanSlug && localPlanBridgeUrl), refetchInterval: false, retry: false, queryFn: () => fetchLocalPlanBridgeComments(localPlanBridgeUrl ?? ""), }); const localPlanData = useMemo( () => localPlanBridgeUrl ? mergeLocalBridgeComments( localPlanBridgeQuery.data, localPlanBridgeCommentsQuery.data, ) : localPlanQuery.data, [ localPlanBridgeQuery.data, localPlanBridgeCommentsQuery.data, localPlanBridgeUrl, localPlanQuery.data, ], ); const localPlanError = localPlanBridgeUrl ? localPlanBridgeQuery.error : localPlanQuery.error; useEffect(() => { if (!(localPlanError instanceof LocalPlanBridgePermissionError)) return; setLocalNetworkPermission(localPlanError.permissionState); setLocalBridgeConnectionRequested(false); }, [localPlanError]); const localPlanLoading = localPlanBridgeUrl ? localPlanBridgeQuery.isLoading : localPlanQuery.isLoading; const localPlanFetching = localPlanBridgeUrl ? localPlanBridgeQuery.isFetching : localPlanQuery.isFetching; const refetchLocalPlan = useCallback(() => { if (localPlanBridgeUrl) return localPlanBridgeQuery.refetch(); return localPlanQuery.refetch(); }, [localPlanBridgeQuery, localPlanBridgeUrl, localPlanQuery]); const selectedId = localPlanMode ? (localPlanData?.plan.id ?? (localPlanSlug ? `local-${localPlanSlug}` : undefined)) : routeSelectedId; const plansQuery = usePlans(ALL_PLANS_QUERY_ARGS, { enabled: Boolean(session && !selectedId && !localPlanMode), }); const plans = plansQuery.data ?? []; // Identity for collaborative cursor labels. Only a signed-in user enables // real-time multi-user prose editing; guests/anonymous keep single-user editing. const collabUser = useMemo( () => session?.email ? { name: session.name?.trim() || emailToName(session.email), email: session.email, color: emailToColor(session.email), } : null, [session?.email, session?.name], ); // Redirect to sign-in, returning to wherever the guest currently is. const openSignIn = useCallback((returnOverride?: string) => { window.location.href = buildSignInReturnHref({ returnTo: returnOverride ?? planReturnPathFromLocation(window.location), }); }, []); const requestCreatePlan = useCallback(() => { if (sessionLoading) return; if (!session) { openSignIn("/plans?create=1"); return; } setCreateOpen(true); }, [openSignIn, session, sessionLoading]); // Refetch once a session appears so account-scoped plans show up immediately. const wasSignedInRef = useRef(false); useEffect(() => { if (sessionLoading) return; const signedIn = Boolean(session); if (signedIn && !wasSignedInRef.current && !selectedId && !localPlanMode) { void plansQuery.refetch(); } wasSignedInRef.current = signedIn; }, [localPlanMode, selectedId, session, sessionLoading, plansQuery]); useEffect(() => { const search = new URLSearchParams(location.search); if (search.get("create") !== "1" || sessionLoading) return; if (!session) { openSignIn("/plans?create=1"); return; } setCreateOpen(true); search.delete("create"); const nextSearch = search.toString(); navigate( { pathname: location.pathname, search: nextSearch ? `?${nextSearch}` : "", hash: location.hash, }, { replace: true }, ); }, [ location.hash, location.pathname, location.search, navigate, openSignIn, session, sessionLoading, ]); const prototypeOnly = useMemo(() => { return routeSearchParams.get("prototype") === "1"; }, [routeSearchParams]); const recapScreenshotMode = useMemo(() => { return routeSearchParams.get(RECAP_SCREENSHOT_QUERY_PARAM) === "1"; }, [routeSearchParams]); const recapScreenshotTheme = useMemo<"light" | "dark" | null>(() => { if (!recapScreenshotMode) return null; return routeSearchParams.get(RECAP_SCREENSHOT_THEME_QUERY_PARAM) === "dark" ? "dark" : "light"; }, [recapScreenshotMode, routeSearchParams]); const recapScreenshotBackground = recapScreenshotTheme === "dark" ? GITHUB_DARK_CANVAS_BACKGROUND : recapScreenshotTheme === "light" ? GITHUB_LIGHT_CANVAS_BACKGROUND : null; const recapScreenshotBackgroundStyle = useMemo( () => recapScreenshotBackground ? { backgroundColor: recapScreenshotBackground } : undefined, [recapScreenshotBackground], ); const immersiveReader = Boolean( selectedId && (planFullscreen || prototypeOnly), ); const planQuery = usePlan(localPlanMode ? undefined : selectedId); const bundle = localPlanMode ? localPlanData : planQuery.data; const localPlanBundle = localPlanMode && bundle && "localOnly" in bundle ? (bundle as LocalPlanBundle) : null; const localPlanDisplayFolder = localPlanMode && bundle && "folder" in bundle && typeof bundle.folder === "string" && bundle.folder ? bundle.folder : (localPlanSlug ?? "local plan files"); const localPlanSuggestedRepoPath = localPlanBundle?.suggestedRepoPath ?? (localPlanSlug ? `plans/${localPlanSlug}` : "plans"); const localPlanMenuPath = localPlanRepoPath ?? localPlanBundle?.repoPath ?? localPlanSuggestedRepoPath ?? localPlanSlug ?? "local plan files"; const planAccessStatusQuery = usePlanAccessStatus( selectedId, Boolean(selectedId && !bundle && !localPlanMode), ); const planAccessStatus = planAccessStatusQuery.data ?? null; const planQueryInitialPending = planQuery.isLoading; const planAccessStatusInitialPending = planAccessStatusQuery.isLoading; const showPlanLoadError = shouldShowPlanLoadError({ hasSelectedId: Boolean(selectedId), localPlanMode, hasBundle: Boolean(bundle), planQueryInitialPending, planQueryError: planQuery.isError, planQueryPaused: planQuery.isPaused, accessStatusInitialPending: planAccessStatusInitialPending, accessStatusPaused: planAccessStatusQuery.isPaused, accessDenied: Boolean(planAccessStatus && !planAccessStatus.hasAccess), }); const showLocalPlanLoadError = shouldShowLocalPlanLoadError({ localPlanMode, hasBundle: Boolean(bundle), hasBridgeUrl: Boolean(localPlanBridgeUrl), bridgeFetchEnabled: localBridgeFetchEnabled, error: localPlanError, loading: localPlanLoading, fetching: localPlanFetching, permissionState: localNetworkPermission, }); const showLocalPlanConnection = Boolean( localPlanMode && localPlanBridgeUrl && !bundle && !localBridgeConnectionRequested && (localNetworkPermission === "prompt" || localNetworkPermission === "denied"), ); const showInitialPlanSkeleton = Boolean( selectedId && !bundle && !showPlanLoadError && !showLocalPlanLoadError && !showLocalPlanConnection, ); const requestPlanAccessMutation = useRequestPlanAccess(); const [accessRequestSentPlanId, setAccessRequestSentPlanId] = useState< string | null >(null); useEffect(() => { if (accessRequestSentPlanId && accessRequestSentPlanId !== selectedId) { setAccessRequestSentPlanId(null); } }, [accessRequestSentPlanId, selectedId]); const startGoogleSignIn = useCallback(async () => { const returnPath = planReturnPathFromLocation(window.location); try { const res = await fetch( `${agentNativePath("/_agent-native/google/auth-url")}?return=${encodeURIComponent(returnPath)}`, { cache: "no-store" }, ); const data = (await res.json().catch(() => null)) as { url?: string; error?: string; message?: string; } | null; if (res.ok && data?.url) { window.location.href = data.url; return; } if (data?.error || data?.message) { toast.error(data.error ?? data.message); } } catch { // Fall through to the full sign-in page, which has the same auth options. } openSignIn(returnPath); }, [openSignIn]); const requestPlanAccess = useCallback(() => { if (!selectedId || localPlanMode) return; requestPlanAccessMutation.mutate( { planId: selectedId }, { onSuccess: (result) => { setAccessRequestSentPlanId(selectedId); toast.success(result.message); if (result.alreadyHasAccess) void planQuery.refetch(); }, }, ); }, [localPlanMode, planQuery, requestPlanAccessMutation, selectedId]); const queryClient = useQueryClient(); const selectedPlanQueryKey = useMemo( () => selectedId && !localPlanMode ? planBundleQueryKey(selectedId) : localPlanMode && localPlanSlug ? localPlanBridgeUrl ? localPlanBridgeQueryKey(localPlanSlug, localPlanBridgeUrl) : localPlanBundleQueryKey(localPlanSlug, localPlanRepoPath) : null, [ localPlanBridgeUrl, localPlanMode, localPlanRepoPath, localPlanSlug, selectedId, ], ); // Reflect a structural block edit (drag-to-columns, reorder) into the // `get-visual-plan` cache IMMEDIATELY so the editor's authoritative content // tracks the new layout instead of lagging the debounced (600ms) save. This // keeps every reader of the plan content consistent with what the editor shows // the moment the drop lands. The reconcile's own non-collab stale-poll guard is // what actually stops a lagging refetch from reverting the layout, so this does // NOT bump `updatedAt` — leaving the server's timestamp intact so a genuinely // newer agent/external edit still wins. const writeBlocksOptimistically = useCallback( (blocks: PlanBlock[]) => { if (!selectedPlanQueryKey) return; queryClient.setQueryData( selectedPlanQueryKey, (prev: PlanBundleWithHtml | undefined) => { if (!prev?.plan?.content) return prev; return { ...prev, plan: { ...prev.plan, content: { ...prev.plan.content, blocks }, }, }; }, ); }, [queryClient, selectedPlanQueryKey], ); // Recaps are read-only review surfaces: text can't be edited inline (the agent // owns the content), but highlighting + commenting stay available because those // affordances key off `bundle`/`session`, not `canEditPlanContent`. const isRecap = bundle?.plan.kind === "recap"; const effectivePlanAccessRole = bundle?.access?.role ?? null; const canEditLocalPlanContent = localPlanMode && !localPlanBridgeUrl && !isRecap && Boolean(localPlanSlug && bundle?.plan.content); const canEditPlanContent = canEditLocalPlanContent || (!localPlanMode && !isRecap && canEditPlanContentRole(effectivePlanAccessRole)); const canManagePlan = !localPlanMode && canEditPlanContentRole(effectivePlanAccessRole); const canDeleteCurrentPlan = !localPlanMode && effectivePlanAccessRole === "owner"; const currentPlanDeleteTarget = useMemo(() => { if (!bundle || !canDeleteCurrentPlan) return null; return { id: bundle.plan.id, title: bundle.plan.title, kind: bundle.plan.kind, deletedAt: bundle.plan.deletedAt, canDelete: true, }; }, [ bundle?.plan.deletedAt, bundle?.plan.id, bundle?.plan.kind, bundle?.plan.title, bundle, canDeleteCurrentPlan, ]); const effectivePlanVisibility = bundle?.access?.visibility ?? null; const canReportPlan = !localPlanMode && Boolean(bundle) && effectivePlanVisibility === "public" && !canManagePlan; const canResolveCommentThreads = Boolean( bundle && (localPlanMode || session || canEditPlanContent), ); const defaultInlineCommentDraft = useMemo(() => { return defaultInlineCommentDraftForPlanContext({ planKind: bundle?.plan.kind, ownerEmail: bundle?.access?.ownerEmail, sourceAuthorName: bundle?.plan.sourceAuthorName, sourceAuthorLogin: bundle?.plan.sourceAuthorLogin, accessRole: effectivePlanAccessRole, currentEmail: collabUser?.email, }); }, [ bundle?.access?.ownerEmail, bundle?.plan.kind, bundle?.plan.sourceAuthorName, bundle?.plan.sourceAuthorLogin, collabUser?.email, effectivePlanAccessRole, ]); const commentThreads = useMemo( () => buildCommentThreads(bundle?.comments ?? []), [bundle?.comments], ); const visibleCommentThreads = useMemo( () => commentThreadsForVisibility(commentThreads, commentVisibility), [commentThreads, commentVisibility], ); const visibleMarkerCommentThreads = useMemo( () => commentThreadsForVisualSurfaceMode( visibleCommentThreads, visualSurfaceMode, ), [visibleCommentThreads, visualSurfaceMode], ); const visiblePlanComments = useMemo(() => { if (!bundle) return [] as PlanBundle["comments"]; const visibleIds = new Set( visibleCommentThreads.flatMap((thread) => thread.comments.map((comment) => comment.id), ), ); return bundle.comments.filter((comment) => visibleIds.has(comment.id)); }, [bundle, visibleCommentThreads]); const commentAvatarEmails = useMemo( () => commentAuthorEmails(bundle?.comments ?? [], collabUser?.email), [bundle?.comments, collabUser?.email], ); const commentAvatarUrls = useCommentAvatarUrls(commentAvatarEmails); const sessionWithImage = session as | (typeof session & { image?: string | null }) | null; const sessionImage = sessionWithImage?.image?.trim() || null; const currentCommentAuthor = useMemo(() => { const email = normalizeCommentEmail(collabUser?.email); if (isLocalCurrentUserEmail(email)) { return { email: null, name: null, avatarUrl: null, color: CURRENT_USER_FALLBACK_COLOR, }; } const storedAvatar = email ? (commentAvatarUrls[email] ?? null) : null; const avatarUrl = storedAvatar ?? sessionImage; const name = collabUser?.name?.trim() || null; if (!email && !name && !avatarUrl) return null; return { email, name, avatarUrl, color: email ? emailToColor(email) : CURRENT_USER_FALLBACK_COLOR, }; }, [collabUser?.email, collabUser?.name, commentAvatarUrls, sessionImage]); const pendingCommentAuthor = useMemo( () => commentAuthorPresentation( { createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, }, currentCommentAuthor?.avatarUrl ?? null, currentCommentAuthor, ), [collabUser?.email, collabUser?.name, currentCommentAuthor], ); const runtimeCommentThreads = useMemo( () => visibleMarkerCommentThreads .map((thread, index) => runtimeAnnotationFromThread( thread, index, commentAvatarUrls, currentCommentAuthor, ), ) .filter((annotation): annotation is RuntimeAnnotation => Boolean(annotation), ), [commentAvatarUrls, currentCommentAuthor, visibleMarkerCommentThreads], ); const canDeletePlanComment = useCallback( (comment: { authorEmail?: string | null }) => { if (canManagePlan) return true; const currentEmail = normalizeCommentEmail(collabUser?.email); const authorEmail = normalizeCommentEmail(comment.authorEmail); if (!authorEmail) return false; if (currentEmail && authorEmail === currentEmail) return true; return ( isLocalCurrentUserEmail(authorEmail) && (!currentEmail || isLocalCurrentUserEmail(currentEmail)) ); }, [canManagePlan, collabUser?.email], ); const canDeleteCommentThread = useCallback( (thread: CommentThread) => canDeletePlanComment(thread.root), [canDeletePlanComment], ); const activeCommentThread = useMemo( () => activeAnnotation ? (commentThreads.find( (item) => item.id === activeAnnotation.annotation.id, ) ?? null) : null, [activeAnnotation, commentThreads], ); useEffect(() => { setActiveAnnotation((current) => { if (!current) return current; const fresh = runtimeCommentThreads.find( (annotation) => annotation.id === current.annotation.id, ); return fresh ? { ...current, annotation: fresh } : null; }); }, [runtimeCommentThreads]); const updatePlan = useUpdatePlan(); const updateLocalPlan = useUpdateLocalPlan(); const promoteLocalPlan = usePromoteLocalPlan(); // Stable ref so closures (e.g. message-event handler) always call the latest // mutate without needing to be in a dependency array. const updatePlanMutateRef = useRef(updatePlan.mutate); updatePlanMutateRef.current = updatePlan.mutate; // Separate mutation instance for comment-only writes (reply / resolve / // reopen). Keeping it separate from the prose-autosave `updatePlan` instance // means the autosave `isPending` state cannot bleed into comment button // disabled states (Issue 3). const updateCommentMutation = useUpdatePlanComments(); // Local-files plans write comments to comments.json (no DB) via this action. const updateLocalCommentMutation = useUpdateLocalPlanComments(); const deleteCommentMutation = useDeletePlanComment(); const deletePlanMutation = useDeletePlan(); /** * Archive or unarchive a plan from the overview. Optimistically updates the * list-visual-plans cache so the card disappears/reappears immediately. * Rolls back on error with a toast. */ const handleArchivePlan = useCallback( (planId: string, archive: boolean) => { const newStatus = archive ? "archived" : "draft"; const prevActive = queryClient.getQueryData( ACTIVE_PLANS_QUERY_KEY, ); const prevAll = queryClient.getQueryData(ALL_PLANS_QUERY_KEY); // Optimistic update for (const listKey of [ACTIVE_PLANS_QUERY_KEY, ALL_PLANS_QUERY_KEY]) { queryClient.setQueryData(listKey, (old: PlanSummary[] | undefined) => old?.map((p) => (p.id === planId ? { ...p, status: newStatus } : p)), ); } updatePlan.mutate( { planId, status: newStatus }, { onError: () => { // Roll back if (prevActive !== undefined) queryClient.setQueryData(ACTIVE_PLANS_QUERY_KEY, prevActive); if (prevAll !== undefined) queryClient.setQueryData(ALL_PLANS_QUERY_KEY, prevAll); toast.error( archive ? t("plansPage.reader.archiveFailed") : t("plansPage.reader.unarchiveFailed"), ); }, }, ); }, [queryClient, t, updatePlan], ); const updatePlanListCaches = useCallback( (updater: (plans: PlanSummary[]) => PlanSummary[]) => { for (const listKey of [ACTIVE_PLANS_QUERY_KEY, ALL_PLANS_QUERY_KEY]) { queryClient.setQueryData(listKey, (old: PlanSummary[] | undefined) => old ? updater(old) : old, ); } }, [queryClient], ); const requestDeletePlan = useCallback( (plan: DeletePlanTarget, initialMode: "soft" | "hard" = "soft") => { setDeletePlanRequest({ plan, mode: initialMode }); }, [], ); const confirmDeletePlan = useCallback( async (input: DeletePlanInput, targetOverride?: DeletePlanTarget) => { const target = targetOverride ?? deletePlanRequest?.plan; if (!target) return; try { const result = await deletePlanMutation.mutateAsync(input); if (result.mode === "soft") { queryClient.setQueryData( ACTIVE_PLANS_QUERY_KEY, (items: PlanSummary[] | undefined) => items?.filter((plan) => plan.id !== target.id), ); queryClient.setQueryData( ALL_PLANS_QUERY_KEY, (items: PlanSummary[] | undefined) => items?.map((plan) => plan.id === target.id ? { ...plan, deletedAt: result.deletedAt } : plan, ), ); toast.success( target.kind === "recap" ? t("plansPage.reader.recapMovedToDeleted") : t("plansPage.reader.planMovedToDeleted"), ); if (selectedId === target.id) navigate("/plans"); } else if (result.mode === "restore") { updatePlanListCaches((items) => items.map((plan) => plan.id === target.id ? { ...plan, deletedAt: null, deletedBy: null } : plan, ), ); toast.success( target.kind === "recap" ? t("plansPage.reader.recapRestored") : t("plansPage.reader.planRestored"), ); } else { updatePlanListCaches((items) => items.filter((plan) => plan.id !== target.id), ); queryClient.removeQueries({ queryKey: planBundleQueryKey(target.id), }); toast.success( target.kind === "recap" ? t("plansPage.reader.recapPermanentlyDeleted") : t("plansPage.reader.planPermanentlyDeleted"), ); if (selectedId === target.id) navigate("/plans"); } if (!targetOverride) setDeletePlanRequest(null); } catch { // The hook already shows a normalized action error toast. } }, [ deletePlanMutation, deletePlanRequest?.plan, navigate, queryClient, selectedId, t, updatePlanListCaches, ], ); /** * Persist question-form answers as an agent-targeted comment so * share-link reviewers' answers are visible to get-plan-feedback even when * no agent is attached on their machine. Fire-and-forget: the existing * sendToAgentChat fast-path runs first, and this is a best-effort backup. */ const persistQuestionFormAnswers = useCallback( (summary: string, planId: string | undefined) => { if (!planId) return; const comment: PlanCommentInput = { kind: "comment", status: "open", message: summary, createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, resolutionTarget: "agent", }; if (localPlanMode && localPlanSlug) { void (async () => { if (localPlanBridgeUrl) setLocalBridgeCommentPending(true); try { const updated = localPlanBridgeUrl ? await updateLocalPlanBridgeComments( localPlanBridgeUrl, localPlanSlug, { comments: [comment] }, ) : await updateLocalCommentMutation.mutateAsync({ slug: localPlanSlug, ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), comments: [comment], }); if (selectedPlanQueryKey) { queryClient.setQueryData(selectedPlanQueryKey, updated); } } catch { toast.error(t("plansPage.reader.saveAnswersFailed")); } finally { if (localPlanBridgeUrl) setLocalBridgeCommentPending(false); } })(); return; } updatePlanMutateRef.current( { planId, comments: [comment], }, { onError: () => { toast.error(t("plansPage.reader.saveAnswersFailed")); }, }, ); }, [ collabUser?.email, collabUser?.name, localPlanBridgeUrl, localPlanMode, localPlanRepoPath, localPlanSlug, queryClient, selectedPlanQueryKey, t, updateLocalCommentMutation, ], ); const exportPlan = useExportPlan(localPlanMode ? undefined : selectedId); const importPlanSource = useImportPlanSource(); const [desktopPlanFolder, setDesktopPlanFolder] = useState(null); const [desktopPlanSyncing, setDesktopPlanSyncing] = useState(false); const [desktopPlanImporting, setDesktopPlanImporting] = useState(false); const [desktopPlanAutoSync, setDesktopPlanAutoSync] = useState(() => readDesktopPlanAutoSync(selectedId), ); const desktopAutoSyncedVersionRef = useRef>({}); const desktopPlanFilesAvailable = Boolean(getDesktopPlanFiles()); const { resolvedTheme, setTheme } = useTheme(); const isDarkTheme = recapScreenshotTheme ? recapScreenshotTheme === "dark" : resolvedTheme !== "light"; const wireframeStyle = useWireframeStyle(); const planTheme = isDarkTheme ? "dark" : "light"; const iframeRuntimeDefaultsRef = useRef<{ planTheme: "dark" | "light"; preferredEditor: PreferredEditor; }>({ planTheme, preferredEditor }); iframeRuntimeDefaultsRef.current = { planTheme, preferredEditor }; const reviewMode: CanvasMarkupMode = annotateMode ? "comment" : canvasMarkupMode; const hasOpenThreads = (bundle?.summary.openCommentCount ?? 0) > 0; const resolvedCommentThreadCount = commentThreads.filter( (thread) => commentThreadStatus(thread) === "resolved", ).length; const visibleCommentThreadCount = visibleCommentThreads.length; const commentMarkersVisible = commentVisibility !== "hidden" && (annotationsOpen || annotateMode || Boolean(activeAnnotation) || visibleCommentThreadCount > 0); useEffect(() => { if (!recapScreenshotTheme || !recapScreenshotBackground) return; const root = document.documentElement; const body = document.body; const previousRootClass = root.className; const previousDataTheme = root.getAttribute("data-theme"); const previousColorScheme = root.style.colorScheme; const previousRootBackground = root.style.backgroundColor; const previousBodyBackground = body.style.backgroundColor; root.classList.remove("light", "dark"); root.classList.add(recapScreenshotTheme); root.setAttribute("data-theme", recapScreenshotTheme); root.style.colorScheme = recapScreenshotTheme; root.style.backgroundColor = recapScreenshotBackground; body.style.backgroundColor = recapScreenshotBackground; return () => { root.className = previousRootClass; if (previousDataTheme === null) { root.removeAttribute("data-theme"); } else { root.setAttribute("data-theme", previousDataTheme); } root.style.colorScheme = previousColorScheme; root.style.backgroundColor = previousRootBackground; body.style.backgroundColor = previousBodyBackground; }; }, [recapScreenshotBackground, recapScreenshotTheme]); const showingPrototypeSurface = prototypeOnly || visualSurfaceMode === "prototype"; useEffect(() => { if (visualSurfaceMode !== "wireframes" && canvasMarkupMode !== "none") { setCanvasMarkupMode("none"); } }, [canvasMarkupMode, visualSurfaceMode]); useEffect(() => { const title = bundle?.plan.title; if (title) document.title = planDocumentTitle(title, document.title); }, [bundle?.plan.title]); useSetPageTitle(bundle?.plan.title || (isRecap ? "Recap" : "Plan")); useSetHeaderActions( !sessionLoading && !session && !selectedId ? ( ) : null, ); useEffect(() => { setDesktopPlanFolder(null); setDesktopPlanAutoSync(readDesktopPlanAutoSync(selectedId)); const planFiles = getDesktopPlanFiles(); if (!planFiles || !selectedId || localPlanMode) return; let cancelled = false; void planFiles.getFolder({ planId: selectedId }).then((result) => { if (cancelled) return; setDesktopPlanFolder(result.ok ? result.folder : null); if (result.ok) { void syncLocalControlResources({ folderName: result.folder.name, files: result.controlResources, }) .then((synced) => { if (synced.count > 0) { queryClient.invalidateQueries({ queryKey: ["resources"] }); } }) .catch(() => { // Resource refresh is best-effort when restoring a remembered folder. }); } }); return () => { cancelled = true; }; }, [localPlanMode, queryClient, selectedId]); useEffect(() => { if (!selectedId) return; const view = immersiveReader ? "immersive" : "app"; document.documentElement.dataset.planReaderView = view; window.dispatchEvent( new CustomEvent(PLAN_READER_VIEW_EVENT, { detail: { view, immersive: immersiveReader }, }), ); return () => { delete document.documentElement.dataset.planReaderView; window.dispatchEvent( new CustomEvent(PLAN_READER_VIEW_EVENT, { detail: { view: "app", immersive: false }, }), ); }; }, [immersiveReader, selectedId]); const documentHtml = useMemo(() => { if (!bundle) return ""; return ( bundle.html || bundle.plan.html || buildClientPlanHtml(bundle, { workingPlan: t("plansPage.reader.clientHtmlWorkingPlan"), }) ); }, [bundle, t]); const annotatedDocumentHtml = useMemo(() => { if (!bundle) return ""; const defaults = iframeRuntimeDefaultsRef.current; const annotations = buildCommentThreads(visiblePlanComments) .map((thread, index) => runtimeAnnotationFromThread( thread, index, commentAvatarUrls, currentCommentAuthor, ), ) .filter((annotation): annotation is RuntimeAnnotation => Boolean(annotation), ); return injectAnnotationRuntime({ html: documentHtml, annotations, annotateMode: false, theme: defaults.planTheme, preferredEditor: defaults.preferredEditor, labels: { closeCodePreview: t("plansPage.reader.runtimeCloseCodePreview"), }, }); }, [ bundle, commentAvatarUrls, currentCommentAuthor, documentHtml, t, visiblePlanComments, ]); const planAgentContext = useMemo(() => { if (!bundle) return ""; if (localPlanMode) { const url = localPlanRouteUrl(localPlanSlug ?? "", localPlanRepoPath); return buildPlanAgentContext({ bundle, documentHtml, url }); } const base = bundle.plan.kind === "recap" ? "recaps" : "plans"; const path = appPath(`/${base}/${selectedId ?? bundle.plan.id}`); const url = typeof window === "undefined" ? path : `${window.location.origin}${path}`; return buildPlanAgentContext({ bundle, documentHtml, url }); }, [ bundle, documentHtml, localPlanMode, localPlanRepoPath, localPlanSlug, selectedId, ]); const planShareUrl = useMemo(() => { if (typeof window === "undefined") return undefined; if (localPlanMode) { return localPlanRouteUrl(localPlanSlug ?? "", localPlanRepoPath); } if (!selectedId) return undefined; const base = bundle?.plan.kind === "recap" ? "recaps" : "plans"; const url = `${window.location.origin}${appPath(`/${base}/${selectedId}`)}`; // Viral attribution: tag the shared/public plan link so signups arriving // from it can be attributed even when `document.referrer` is empty. `via` // is a non-PII owner id and is only set when the current viewer is the // owner (the only person whose session userId is the plan owner's id). const ownerViaId = effectivePlanAccessRole === "owner" ? (session?.userId ?? null) : null; return withPlanShareAttribution(url, ownerViaId); }, [ bundle?.plan.kind, effectivePlanAccessRole, localPlanMode, localPlanRepoPath, localPlanSlug, selectedId, session?.userId, ]); // Viral attribution: read the `ref`/`via` the visitor arrived on (from a // tagged share link) so funnel events carry the same attribution the // framework first-touch cookie captured. Read once from the URL on mount. const shareAttribution = useMemo( () => readPlanShareAttribution( typeof window === "undefined" ? "" : window.location.search, ), [], ); // A logged-out visitor looking at a public plan/recap is the share funnel // audience. Their CTAs (comment, sign in) route through `openSignIn`. const isLoggedOutPublicPlanView = !sessionLoading && !session && !localPlanMode && Boolean(selectedId) && effectivePlanVisibility === "public"; // share_cta_click — fire alongside (never instead of) the real navigation. // `track` is non-throwing, but guard anyway so analytics can never break a // CTA. Only fires for the logged-out public-plan funnel audience. const fireShareCtaClick = useCallback( (cta: string) => { if (!isLoggedOutPublicPlanView) return; try { void track("share_cta_click", { surface: PLAN_SHARE_SURFACE, plan_id: selectedId ?? "", cta, ref: shareAttribution.ref, via: shareAttribution.via, }); } catch { // Never let analytics break a CTA. } }, [ isLoggedOutPublicPlanView, selectedId, shareAttribution.ref, shareAttribution.via, ], ); // share_view — fire once when a logged-out visitor views a public plan. The // ref guard prevents double-fire across re-renders / StrictMode double-invoke. const shareViewFiredRef = useRef(false); useEffect(() => { if (!isLoggedOutPublicPlanView) return; if (shareViewFiredRef.current) return; shareViewFiredRef.current = true; try { void track("share_view", { surface: PLAN_SHARE_SURFACE, plan_id: selectedId ?? "", ref: shareAttribution.ref, via: shareAttribution.via, }); } catch { // Never let analytics break the page render. } }, [ isLoggedOutPublicPlanView, selectedId, shareAttribution.ref, shareAttribution.via, ]); useEffect(() => { const onSidebarState = (event: Event) => { const detail = (event as CustomEvent) .detail; setAgentSidebarOpen(detail?.open === true); }; window.addEventListener(SIDEBAR_STATE_CHANGE_EVENT, onSidebarState); return () => window.removeEventListener(SIDEBAR_STATE_CHANGE_EVENT, onSidebarState); }, []); const postRuntimeState = useCallback( (restoreScroll?: PlanDocumentState | null) => { iframeRef.current?.contentWindow?.postMessage( { type: "agent-native-plan-runtime-state", annotateMode, theme: planTheme, preferredEditor, parentOrigin: window.location.origin, restoreScroll: restoreScroll ?? null, }, "*", ); }, [annotateMode, planTheme, preferredEditor], ); const clearPendingDocumentRestore = useCallback(() => { if (pendingDocumentRestoreTimerRef.current !== null) { window.clearTimeout(pendingDocumentRestoreTimerRef.current); pendingDocumentRestoreTimerRef.current = null; } pendingDocumentRestoreRef.current = null; }, []); const expirePendingDocumentRestore = useCallback(() => { if (pendingDocumentRestoreTimerRef.current !== null) { window.clearTimeout(pendingDocumentRestoreTimerRef.current); } pendingDocumentRestoreTimerRef.current = window.setTimeout(() => { pendingDocumentRestoreRef.current = null; pendingDocumentRestoreTimerRef.current = null; }, 5000); }, []); const readNativeDocumentState = useCallback((): PlanDocumentState | null => { const reader = nativeReaderRef.current; if (!reader) return null; return { scrollX: reader.scrollLeft, scrollY: reader.scrollTop, scrollWidth: reader.scrollWidth, scrollHeight: reader.scrollHeight, clientWidth: reader.clientWidth, clientHeight: reader.clientHeight, }; }, []); const capturePlanDocumentState = useCallback(() => { const state = readNativeDocumentState() ?? documentStateRef.current; if (state) documentStateRef.current = state; return state; }, [readNativeDocumentState]); const restoreNativeDocumentScroll = useCallback( (state: PlanDocumentState | null) => { const reader = nativeReaderRef.current; if (!reader || !state) return false; const scrollWidth = Math.max(reader.scrollWidth, 1); const scrollHeight = Math.max(reader.scrollHeight, 1); reader.scrollLeft = state.scrollX * (scrollWidth / Math.max(state.scrollWidth || scrollWidth, 1)); reader.scrollTop = state.scrollY * (scrollHeight / Math.max(state.scrollHeight || scrollHeight, 1)); documentStateRef.current = readNativeDocumentState() ?? state; return true; }, [readNativeDocumentState], ); const schedulePlanDocumentRestore = useCallback( (state: PlanDocumentState | null) => { if (!state) return; const restore = () => { if (!restoreNativeDocumentScroll(state)) { postRuntimeState(state); } }; requestAnimationFrame(() => { restore(); requestAnimationFrame(restore); }); }, [postRuntimeState, restoreNativeDocumentScroll], ); const rememberPlanReaderScroll = useCallback(() => { const state = capturePlanDocumentState(); if (state) { pendingDocumentRestoreRef.current = state; expirePendingDocumentRestore(); } return state; }, [capturePlanDocumentState, expirePendingDocumentRestore]); const preservePlanReaderScrollAfterToolbarEvent = useCallback(() => { schedulePlanDocumentRestore(rememberPlanReaderScroll()); }, [rememberPlanReaderScroll, schedulePlanDocumentRestore]); const preservePlanReaderScroll = useCallback( (action: () => void) => { const state = rememberPlanReaderScroll(); action(); schedulePlanDocumentRestore(state); }, [rememberPlanReaderScroll, schedulePlanDocumentRestore], ); const handleIframeLoad = useCallback(() => { const restoreScroll = pendingDocumentRestoreRef.current; postRuntimeState(restoreScroll); clearPendingDocumentRestore(); }, [clearPendingDocumentRestore, postRuntimeState]); useEffect(() => clearPendingDocumentRestore, [clearPendingDocumentRestore]); useBrowserLayoutEffect(() => { if (!selectedId || !bundle?.plan.content || location.hash) return; const resetKey = `${bundle.plan.kind}:${selectedId}`; if (initialReaderScrollResetKeyRef.current === resetKey) return; initialReaderScrollResetKeyRef.current = resetKey; clearPendingDocumentRestore(); documentStateRef.current = null; const reset = () => resetPlanReaderScrollPosition(nativeReaderRef.current); const frameIds: number[] = []; const timeoutIds: number[] = []; const scheduleFrame = () => { frameIds.push(window.requestAnimationFrame(reset)); }; const scheduleTimeout = (delay: number) => { timeoutIds.push( window.setTimeout(() => { reset(); scheduleFrame(); }, delay), ); }; reset(); scheduleFrame(); scheduleTimeout(0); scheduleTimeout(120); scheduleTimeout(500); return () => { frameIds.forEach((id) => window.cancelAnimationFrame(id)); timeoutIds.forEach((id) => window.clearTimeout(id)); }; }, [ bundle?.plan.content, bundle?.plan.kind, clearPendingDocumentRestore, location.hash, selectedId, ]); useEffect(() => { const frame = requestAnimationFrame(() => postRuntimeState()); return () => cancelAnimationFrame(frame); }, [annotatedDocumentHtml, postRuntimeState]); useEffect(() => { if (!bundle?.plan.content || !pendingDocumentRestoreRef.current) return; const restoreScroll = pendingDocumentRestoreRef.current; const frame = requestAnimationFrame(() => { if (restoreNativeDocumentScroll(restoreScroll)) { clearPendingDocumentRestore(); } }); return () => cancelAnimationFrame(frame); }, [ bundle?.plan.content, bundle?.plan.updatedAt, clearPendingDocumentRestore, restoreNativeDocumentScroll, ]); useEffect(() => { if (!bundle?.plan.content) return; const bump = () => setNativeMarkerVersion((version) => version + 1); const frame = requestAnimationFrame(bump); window.addEventListener("resize", bump); return () => { cancelAnimationFrame(frame); window.removeEventListener("resize", bump); }; }, [bundle?.comments, bundle?.plan.content]); const getPositionFromAnchor = useCallback((anchor: PlanAnnotationAnchor) => { const nativeReader = nativeReaderRef.current; if (nativeReader) { const rect = nativeReader.getBoundingClientRect(); const point = nativePointForAnchor(anchor, nativeReader); if (!point) return null; return resolveInlineCommentPosition({ pointX: point.left, pointY: point.top, viewportWidth: rect.width, viewportHeight: rect.height, }); } const rect = iframeRef.current?.getBoundingClientRect(); if (!rect) return null; const doc = documentStateRef.current; const pointX = doc ? ((anchor.x / 100) * doc.scrollWidth - doc.scrollX) * (rect.width / Math.max(doc.clientWidth, 1)) : (anchor.x / 100) * rect.width; const pointY = doc ? ((anchor.y / 100) * doc.scrollHeight - doc.scrollY) * (rect.height / Math.max(doc.clientHeight, 1)) : (anchor.y / 100) * rect.height; return resolveInlineCommentPosition({ pointX, pointY, viewportWidth: rect.width, viewportHeight: rect.height, }); }, []); const closeInlineComment = useCallback(() => { setAnnotateMode(false); setCanvasMarkupMode("none"); setPendingAnnotation(null); setInlineCommentPosition(null); setNativeSelectionComment(null); setFailedCommentDraft(null); window.getSelection()?.removeAllRanges(); }, []); useEffect(() => { const onMessage = (event: MessageEvent) => { if (event.source !== iframeRef.current?.contentWindow) return; if (event.origin !== "null" && event.origin !== window.location.origin) { return; } if (!event.data || typeof event.data !== "object") return; const data = event.data as | { type?: string; anchor?: PlanAnnotationAnchor; comment?: RuntimeAnnotation; editor?: PreferredEditor; href?: string; state?: PlanDocumentState; summary?: string; answers?: unknown; title?: string; } | undefined; if (data?.type === "agent-native-plan-annotate" && data.anchor) { setActiveAnnotation(null); setPendingAnnotation(data.anchor); setInlineCommentPosition(getPositionFromAnchor(data.anchor)); } if ( data?.type === "agent-native-plan-open-comment" && data.comment?.anchor ) { const position = getPositionFromAnchor(data.comment.anchor); if (position) { closeInlineComment(); setAnnotationsOpen(false); setActiveAnnotation({ annotation: data.comment, position }); } } if (data?.type === "agent-native-plan-close-comment-popover") { setActiveAnnotation(null); } if (data?.type === "agent-native-plan-exit-comment-mode") { closeInlineComment(); setActiveAnnotation(null); setAnnotationsOpen(false); } if (data?.type === "agent-native-plan-open-editor" && data.href) { if ( /^(vscode|cursor|xcode|terminal|ghostty):/i.test(data.href) || /^file:\/\//i.test(data.href) ) { window.location.href = data.href; toast.info(t("plansPage.reader.openingFile")); } } if (data?.type === "agent-native-plan-editor-preference") { const editor = PREFERRED_EDITOR_VALUES.includes( data.editor as PreferredEditor, ) ? (data.editor as PreferredEditor) : "vscode"; setPreferredEditor(editor); window.localStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editor); } if (data?.type === "agent-native-plan-link-blocked") { toast.info(t("plansPage.reader.linksDisabled")); } if (data?.type === "agent-native-plan-doc-state" && data.state) { documentStateRef.current = data.state; setActiveAnnotation((current) => { if (!current) return current; const position = getPositionFromAnchor(current.annotation.anchor); return position ? { ...current, position } : current; }); } if ( data?.type === "agent-native-visual-questions-copy" && typeof data.summary === "string" ) { void navigator.clipboard.writeText(data.summary).then(() => { toast.success(t("plansPage.reader.visualPromptCopied")); }); } if ( data?.type === "agent-native-visual-questions-send-to-agent" && typeof data.summary === "string" ) { sendToAgentChat({ type: "content", submit: true, context: planAgentContext, message: buildQuestionFormRevisionMessage(data.summary), }); persistQuestionFormAnswers(data.summary, bundle?.plan.id); toast.success(t("plansPage.reader.sentAnswers")); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [ bundle?.plan.id, closeInlineComment, getPositionFromAnchor, persistQuestionFormAnswers, planAgentContext, ]); const clearInlineCommentDraft = () => { setPendingAnnotation(null); setInlineCommentPosition(null); setNativeSelectionComment(null); }; const copyPlanLink = async () => { if (!planShareUrl) return; await navigator.clipboard.writeText(planShareUrl); toast.success( isRecap ? t("plansPage.reader.recapLinkCopied") : t("plansPage.reader.planLinkCopied"), ); }; const copyLocalPlanFolder = async () => { await navigator.clipboard.writeText(localPlanDisplayFolder); toast.success(t("plansPage.reader.localPathCopied")); }; const openPromoteLocalPlanDialog = () => { setPromoteLocalPlanTargetPath( localPlanBundle?.repoPath || localPlanSuggestedRepoPath, ); setPromoteLocalPlanOverwrite(false); setPromoteLocalPlanOpen(true); }; const submitPromoteLocalPlan = async (event?: FormEvent) => { event?.preventDefault(); if (!localPlanSlug || localPlanBridgeUrl) return; const targetPath = promoteLocalPlanTargetPath.trim(); if (!targetPath) { toast.error(t("plansPage.reader.enterRepoFolder")); return; } const result = await promoteLocalPlan.mutateAsync({ slug: localPlanSlug, ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), targetPath, overwrite: promoteLocalPlanOverwrite, }); setPromoteLocalPlanOpen(false); toast.success( result.alreadyPromoted ? t("plansPage.reader.localPlanAlreadySaved") : t("plansPage.reader.savedLocalFiles", { count: result.localFiles?.files.length ?? 0, path: result.targetPath ?? targetPath, }), ); navigate(localPlanRoutePath(result.slug, result.repoPath ?? targetPath)); }; const openPrototypeWindow = () => { if (typeof window === "undefined") return; const url = new URL(window.location.href); url.searchParams.set("prototype", "1"); window.open(url.toString(), "_blank", "noopener,noreferrer"); }; const leavePrototypeOnlyMode = () => { const search = new URLSearchParams(location.search); search.delete("prototype"); const nextSearch = search.toString(); navigate( { pathname: location.pathname, search: nextSearch ? `?${nextSearch}` : "", hash: location.hash, }, { replace: true }, ); }; const readPlanExport = useCallback(async () => { if (localPlanMode) { const localBundle = bundle as LocalPlanBundle | undefined; if (!localBundle) { throw new Error(t("plansPage.reader.localSourceUnavailable")); } const mdx = localBundle.mdx ?? (localBundle.plan.markdown ? { "plan.mdx": localBundle.plan.markdown } : undefined); if (!mdx?.["plan.mdx"]) { throw new Error(t("plansPage.reader.localSourceFilesUnavailable")); } return { markdown: localBundle.plan.markdown ?? mdx["plan.mdx"], html: localBundle.html || localBundle.plan.html || documentHtml, json: localBundle, mdx, path: localBundle.path ?? (localPlanSlug ? localPlanRoutePath(localPlanSlug, localPlanRepoPath) : window.location.pathname), url: localBundle.url ?? (localPlanSlug ? localPlanRouteUrl(localPlanSlug, localPlanRepoPath) : planShareUrl), }; } const result = await exportPlan.refetch(); const data = result.data ?? exportPlan.data; if (!data) { throw new Error(t("plansPage.reader.exportUnavailable")); } return data; }, [ bundle, documentHtml, exportPlan, localPlanMode, localPlanRepoPath, localPlanSlug, planShareUrl, ]); const syncPlanToDesktopFolder = useCallback( async (options: { choose?: boolean; quiet?: boolean } = {}) => { const plan = bundle?.plan; const planFiles = getDesktopPlanFiles(); if (!plan || !planFiles) { throw new Error(t("plansPage.reader.desktopSyncUnavailable")); } setDesktopPlanSyncing(true); try { let folder = desktopPlanFolder; if (options.choose || !folder) { const chosen = await planFiles.chooseFolder({ planId: plan.id, title: plan.title, }); if (chosen.ok === false) { if (chosen.canceled) return null; throw new Error(chosen.error); } folder = chosen.folder; setDesktopPlanFolder(folder); const synced = await syncLocalControlResources({ folderName: folder.name, files: chosen.controlResources, }); if (synced.count > 0) { queryClient.invalidateQueries({ queryKey: ["resources"] }); } } const data = await readPlanExport(); if (!data.mdx || !data.mdx["plan.mdx"]) { throw new Error(t("plansPage.reader.sourceFilesUnavailable")); } const written = await planFiles.writePlan({ planId: plan.id, title: plan.title, mdx: data.mdx, }); if (written.ok === false) throw new Error(written.error); setDesktopPlanFolder(written.folder); const synced = await syncLocalControlResources({ folderName: written.folder.name, files: written.controlResources, }); if (synced.count > 0) { queryClient.invalidateQueries({ queryKey: ["resources"] }); } desktopAutoSyncedVersionRef.current[plan.id] = plan.updatedAt; if (!options.quiet) { toast.success( t("plansPage.reader.syncedLocalFiles", { count: written.files?.length ?? 0, }), ); } return written.folder; } finally { setDesktopPlanSyncing(false); } }, [bundle?.plan, desktopPlanFolder, queryClient, readPlanExport], ); const importPlanFromDesktopFolder = useCallback(async () => { const plan = bundle?.plan; const planFiles = getDesktopPlanFiles(); if (!plan || !planFiles) { throw new Error(t("plansPage.reader.desktopSyncUnavailable")); } setDesktopPlanImporting(true); try { const result = await planFiles.readPlan({ planId: plan.id }); if (result.ok === false) throw new Error(result.error); if (!result.mdx) throw new Error(t("plansPage.reader.noSourceFiles")); const synced = await syncLocalControlResources({ folderName: result.folder.name, files: result.controlResources, }); if (synced.count > 0) { queryClient.invalidateQueries({ queryKey: ["resources"] }); } const imported = await importPlanSource.mutateAsync({ planId: plan.id, expectedUpdatedAt: plan.updatedAt, mdx: result.mdx, currentFocus: "desktop local files sync", }); setDesktopPlanFolder(result.folder); toast.success(t("plansPage.reader.importedLocalSource")); const updatedAt = imported.plan?.updatedAt; if (updatedAt) { desktopAutoSyncedVersionRef.current[plan.id] = updatedAt; } } finally { setDesktopPlanImporting(false); } }, [bundle?.plan, importPlanSource, queryClient]); const setDesktopPlanAutoSyncEnabled = useCallback( (enabled: boolean) => { if (!selectedId) return; setDesktopPlanAutoSync(enabled); if (enabled) { window.localStorage.setItem(desktopPlanAutoSyncKey(selectedId), "1"); void syncPlanToDesktopFolder({ choose: !desktopPlanFolder }).catch( (error) => { setDesktopPlanAutoSync(false); window.localStorage.removeItem(desktopPlanAutoSyncKey(selectedId)); toast.error( error instanceof Error ? error.message : t("plansPage.reader.enableLocalSyncFailed"), ); }, ); } else { window.localStorage.removeItem(desktopPlanAutoSyncKey(selectedId)); } }, [desktopPlanFolder, selectedId, syncPlanToDesktopFolder], ); useEffect(() => { const plan = bundle?.plan; if (!plan || !desktopPlanAutoSync || !desktopPlanFolder) return; if (desktopAutoSyncedVersionRef.current[plan.id] === plan.updatedAt) { return; } desktopAutoSyncedVersionRef.current[plan.id] = plan.updatedAt; void syncPlanToDesktopFolder({ quiet: true }).catch((error) => { setDesktopPlanAutoSync(false); window.localStorage.removeItem(desktopPlanAutoSyncKey(plan.id)); toast.error( error instanceof Error ? error.message : t("plansPage.reader.syncLocalFailed"), ); }); }, [ bundle?.plan, desktopPlanAutoSync, desktopPlanFolder, syncPlanToDesktopFolder, ]); const copyPlanHtml = async () => { const data = await readPlanExport(); await navigator.clipboard.writeText(data.html); toast.success(t("plansPage.reader.planHtmlCopied")); }; const copyPlanMarkdown = async () => { const data = await readPlanExport(); await navigator.clipboard.writeText(data.markdown); toast.success(t("plansPage.reader.planMarkdownCopied")); }; const downloadPlanHtml = async () => { const data = await readPlanExport(); downloadTextFile(planExportFilename(bundle?.plan.title, "html"), data.html); }; const downloadPlanMarkdown = async () => { const data = await readPlanExport(); downloadTextFile( planExportFilename(bundle?.plan.title, "md"), data.markdown, "text/markdown;charset=utf-8", ); }; const downloadPlanSource = async () => { const data = await readPlanExport(); const files = data.mdx; if (!files || Object.keys(files).length === 0) { throw new Error(t("plansPage.reader.sourceFilesUnavailable")); } const { default: JSZip } = await import("jszip"); const zip = new JSZip(); for (const [name, content] of Object.entries(files)) { if (name.endsWith("/")) { zip.folder(name.replace(/\/+$/, "")); continue; } if (typeof content === "string") { zip.file(name, content); } } const blob = await zip.generateAsync({ type: "blob" }); downloadBlob(planExportFilename(bundle?.plan.title, "zip"), blob); toast.success(t("plansPage.reader.planSourceDownloaded")); }; const runPlanExportAction = (action: () => Promise) => { preservePlanReaderScroll(() => { void action().catch((error) => { toast.error( error instanceof Error ? error.message : t("plansPage.reader.exportUnavailable"), ); }); }); }; const chooseCommentVisibility = (visibility: CommentVisibility) => { preservePlanReaderScroll(() => { setCommentVisibility(visibility); closeInlineComment(); setActiveAnnotation(null); if (visibility === "hidden") { setAnnotationsOpen(false); setAnnotateMode(false); return; } setAnnotationsOpen(true); }); }; const startCommenting = useCallback(() => { setCanvasMarkupMode("none"); setActiveAnnotation(null); setAnnotationsOpen(false); setCommentVisibility("open"); setAnnotateMode(true); }, []); const selectReviewMode = (mode: CanvasMarkupMode) => { preservePlanReaderScroll(() => { if (mode !== "comment") { closeInlineComment(); setCanvasMarkupMode("none"); setAnnotateMode(false); return; } startCommenting(); }); }; useEffect(() => { if (reviewMode === "none") return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape" || event.defaultPrevented) return; event.preventDefault(); closeInlineComment(); setActiveAnnotation(null); setAnnotationsOpen(false); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [closeInlineComment, reviewMode]); const scheduleNativeMarkerUpdate = useCallback(() => { if (nativeScrollFrameRef.current !== null) return; nativeScrollFrameRef.current = requestAnimationFrame(() => { nativeScrollFrameRef.current = null; setNativeMarkerVersion((version) => version + 1); setActiveAnnotation((current) => { if (!current) return current; const position = getPositionFromAnchor(current.annotation.anchor); return position ? { ...current, position } : current; }); }); }, [getPositionFromAnchor]); useEffect( () => () => { if (nativeScrollFrameRef.current !== null) { cancelAnimationFrame(nativeScrollFrameRef.current); nativeScrollFrameRef.current = null; } }, [], ); const handleNativeReaderScroll = () => { documentStateRef.current = readNativeDocumentState(); setNativeSelectionComment(null); scheduleNativeMarkerUpdate(); }; const readNativeSelectionComment = useCallback((): NativeSelectionComment | null => { const reader = nativeReaderRef.current; const selection = window.getSelection(); if (!reader || !selection || selection.rangeCount === 0) return null; if (selection.isCollapsed) return null; const textQuote = selection.toString().replace(/\s+/g, " ").trim(); if (!textQuote) return null; const range = selection.getRangeAt(0); if (!reader.contains(range.commonAncestorContainer)) return null; const rects = Array.from(range.getClientRects()).filter( (rect) => rect.width > 0 && rect.height > 0, ); const selectionRect = rects[0] ?? range.getBoundingClientRect(); if (selectionRect.width <= 0 || selectionRect.height <= 0) return null; const readerRect = reader.getBoundingClientRect(); const pointX = selectionRect.left + selectionRect.width / 2 - readerRect.left; const pointY = selectionRect.top + selectionRect.height / 2 - readerRect.top; const startElement = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement; const blockElement = startElement?.closest("[data-block-id]"); const blockType = blockElement?.dataset.blockId ? findPlanBlockById( bundle?.plan.content?.blocks ?? [], blockElement.dataset.blockId, )?.type : undefined; const quoteContext = textQuoteContextForBlock({ block: blockElement, quote: textQuote, }); const snippet = textQuote.slice(0, 220); const anchor = { ...buildNativeAnchorFromElement({ reader, target: startElement instanceof HTMLElement ? startElement : reader, pointX, pointY, planTitle: bundle?.plan.title, }), snippet, textQuote: snippet, anchorKind: "text", tagName: "selection", blockType, ...quoteContext, } satisfies PlanAnnotationAnchor; const toolbarWidth = 132; const toolbarLeft = clamp( pointX - toolbarWidth / 2, 12, Math.max(12, readerRect.width - toolbarWidth - 12), ); const toolbarTop = clamp( selectionRect.top - readerRect.top - 48, 12, Math.max(12, readerRect.height - 48), ); return { anchor, toolbarLeft, toolbarTop, position: getPositionFromAnchor(anchor) ?? resolveInlineCommentPosition({ pointX, pointY, viewportWidth: readerRect.width, viewportHeight: readerRect.height, }), }; }, [ bundle?.plan.content?.blocks, bundle?.plan.title, getPositionFromAnchor, ]); const openNativeSelectionComment = useCallback( (selectionComment: NativeSelectionComment) => { documentStateRef.current = readNativeDocumentState(); setCanvasMarkupMode("none"); setActiveAnnotation(null); setAnnotationsOpen(false); setCommentVisibility("open"); setAnnotateMode(true); setPendingAnnotation(selectionComment.anchor); setInlineCommentPosition(selectionComment.position); setNativeSelectionComment(null); window.getSelection()?.removeAllRanges(); }, [readNativeDocumentState], ); const beginNativeSelectionComment = () => { if (!nativeSelectionComment) return; openNativeSelectionComment(nativeSelectionComment); }; useEffect(() => { if (!bundle) return; const handleCommentShortcut = (event: KeyboardEvent) => { if (!shouldHandlePlanCommentShortcut(event)) return; event.preventDefault(); preservePlanReaderScroll(() => { const selectionComment = readNativeSelectionComment(); if (selectionComment) { openNativeSelectionComment(selectionComment); return; } startCommenting(); }); }; window.addEventListener("keydown", handleCommentShortcut); return () => window.removeEventListener("keydown", handleCommentShortcut); }, [ bundle, openNativeSelectionComment, preservePlanReaderScroll, readNativeSelectionComment, startCommenting, ]); const handleNativeReaderPointerDown = ( event: PointerEvent, ) => { if (event.button !== 0) return; const target = event.target as HTMLElement; if (target.closest("[data-plan-interactive]")) return; // Clear any previous selection comment tooltip on pointer-down so it // doesn't linger while a new selection gesture starts. setNativeSelectionComment(null); if (!annotateMode) return; nativeCommentPointerRef.current = { clientX: event.clientX, clientY: event.clientY, }; }; const handleNativeReaderPointerUp = (event: PointerEvent) => { if (event.button !== 0) return; const target = event.target as HTMLElement; if (target.closest("[data-plan-interactive]")) return; const reader = nativeReaderRef.current; if (!reader) return; // Text-selection "Comment" affordance: show the floating button whenever // the user finishes a selection inside the reader, even outside annotate // mode. Click-to-place annotations (the else branch) remain annotate-mode // only because they don't have a visible target without the full review UI. const selectionComment = readNativeSelectionComment(); if (selectionComment) { event.preventDefault(); setActiveAnnotation(null); setAnnotationsOpen(false); setNativeSelectionComment(selectionComment); return; } if (!annotateMode) return; const start = nativeCommentPointerRef.current; nativeCommentPointerRef.current = null; if ( start && Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > 8 ) { return; } const rect = reader.getBoundingClientRect(); const pointX = event.clientX - rect.left; const pointY = event.clientY - rect.top; const anchor = buildNativeAnchorFromElement({ reader, target, pointX, pointY, planTitle: bundle?.plan.title, }); documentStateRef.current = readNativeDocumentState(); const fallbackPosition = resolveInlineCommentPosition({ pointX, pointY, viewportWidth: rect.width, viewportHeight: rect.height, }); setActiveAnnotation(null); setPendingAnnotation(anchor); setInlineCommentPosition(getPositionFromAnchor(anchor) ?? fallbackPosition); }; const updateStructuredContent = async (content: PlanContent) => { if (!bundle) return; if (localPlanMode) { if (!localPlanSlug || localPlanBridgeUrl) return; await updateLocalPlan.mutateAsync({ slug: localPlanSlug, ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), content, note: "Updated local structured visual plan content.", }); return; } await updatePlan.mutateAsync({ planId: bundle.plan.id, expectedUpdatedAt: bundle.plan.updatedAt, content, note: "Updated structured visual plan content.", }); }; const patchStructuredContent = async (patch: PlanContentPatch) => { if (!bundle) return; // For background autosave (replace-blocks) ops, suppress the global // onError toast so the autosave loop's backoff+pill handles error state // instead of spamming toast.error on every retry. const silentError = patch.op === "replace-blocks"; try { if (localPlanMode) { if (!localPlanSlug || localPlanBridgeUrl) return; await updateLocalPlan.mutateAsync( { slug: localPlanSlug, ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), contentPatches: [patch], note: patch.op === "update-rich-text" ? `Edited local markdown block ${patch.blockId}.` : "Patched local structured visual plan content.", }, silentError ? { onError: () => {} } : undefined, ); return; } await updatePlan.mutateAsync( { planId: bundle.plan.id, ...(patch.op === "replace-blocks" ? { expectedUpdatedAt: bundle.plan.updatedAt } : {}), contentPatches: [patch], note: patch.op === "update-rich-text" ? `Edited markdown block ${patch.blockId}.` : "Patched structured visual plan content.", }, silentError ? { onError: () => {} } : undefined, ); } catch (error) { // Re-throw so the autosave backoff loop in PlanContentRenderer can handle // retries. The global onError toast was already suppressed above. throw error; } }; const updatePlanMetadata = async (patch: { title?: string; brief?: string; }) => { if (!bundle) return; if (localPlanMode) { if (!localPlanSlug || localPlanBridgeUrl) return; await updateLocalPlan.mutateAsync({ slug: localPlanSlug, ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.brief !== undefined ? { brief: patch.brief } : {}), contentPatches: [{ op: "set-metadata", ...patch }], note: "Updated local plan title and brief.", }); return; } await updatePlan.mutateAsync({ planId: bundle.plan.id, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.brief !== undefined ? { brief: patch.brief } : {}), contentPatches: [{ op: "set-metadata", ...patch }], note: "Updated plan title and brief.", }); }; const appendCanvasMarkup = async ( annotation: Omit, context: CanvasMarkupCreateContext, ) => { if (!bundle?.plan.content?.canvas) return; const nextAnnotation: PlanAnnotation = { id: newCanvasMarkupId(), ...annotation, }; const anchor: PlanAnnotationAnchor = { ...context.anchor, planAnnotationId: nextAnnotation.id, markupType: context.anchor.markupType, resolutionTarget: "agent", targetKind: "canvas", targetSelector: "[data-plan-canvas-world]", targetX: context.anchor.x, targetY: context.anchor.y, targetLabel: nextAnnotation.type === "callout" ? "Canvas callout" : "Canvas note", targetText: nextAnnotation.text, visualContext: nextAnnotation.type === "callout" ? "Reviewer drew an arrow callout on the canvas." : "Reviewer placed a text note on the canvas.", }; await updatePlan.mutateAsync({ planId: bundle.plan.id, contentPatches: [ { op: "append-canvas-annotation", annotation: nextAnnotation, }, ], comments: [ { kind: "annotation", status: "open", message: buildCanvasMarkupFeedbackMessage(nextAnnotation), anchor: JSON.stringify(anchor), createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, }, ], note: "Human added canvas review markup.", }); toast.success( nextAnnotation.type === "callout" ? "Callout added" : "Note added", ); }; const togglePlansAgent = () => { preservePlanReaderScroll(() => { if (!bundle) return; if (!agentSidebarOpen) { setAgentChatContextItem({ key: `visual-plan:${bundle.plan.id}`, title: bundle.plan.title, context: planAgentContext, openSidebar: false, }); window.dispatchEvent( new CustomEvent("agent-panel:set-mode", { detail: { mode: "chat" }, }), ); } window.dispatchEvent(new Event("agent-panel:toggle")); }); }; const captureFocusedFeedbackImages = async (threads: CommentThread[]) => { const reader = nativeReaderRef.current; const surface = reader?.parentElement; if (!reader || !surface) { return { images: [] as string[], note: "" }; } const allEligible = threads .filter((thread) => shouldCaptureAnchor(thread.anchor)) .sort( (a, b) => feedbackScreenshotPriority(b) - feedbackScreenshotPriority(a), ); const eligible = allEligible.slice(0, MAX_FEEDBACK_SCREENSHOTS); const overflow = allEligible.slice(MAX_FEEDBACK_SCREENSHOTS); if (eligible.length === 0) { return { images: [] as string[], note: "" }; } const restore = readNativeDocumentState(); const images: string[] = []; const labels: string[] = []; try { for (const [index, thread] of eligible.entries()) { const anchor = thread.anchor; if (!anchor) continue; const target = resolveNativeAnchorTarget(anchor, reader); if (!target && prototypeScreenIdForAnchor(anchor)) continue; const readerRectBeforeScroll = reader.getBoundingClientRect(); const targetRect = target?.getBoundingClientRect(); const pointInReader = targetRect ? { left: targetRect.left - readerRectBeforeScroll.left + reader.scrollLeft + ((anchor.targetX ?? anchor.visualX ?? 50) / 100) * targetRect.width, top: targetRect.top - readerRectBeforeScroll.top + reader.scrollTop + ((anchor.targetY ?? anchor.visualY ?? 50) / 100) * targetRect.height, } : { left: (anchor.x / 100) * reader.scrollWidth, top: (anchor.y / 100) * reader.scrollHeight, }; reader.scrollTo({ left: clamp( pointInReader.left - reader.clientWidth / 2, 0, Math.max(0, reader.scrollWidth - reader.clientWidth), ), top: clamp( pointInReader.top - reader.clientHeight / 2, 0, Math.max(0, reader.scrollHeight - reader.clientHeight), ), behavior: "auto", }); await nextFrame(); await nextFrame(); setNativeMarkerVersion((version) => version + 1); const point = nativePointForAnchor(anchor, reader); const surfaceRect = surface.getBoundingClientRect(); const readerRect = reader.getBoundingClientRect(); const pointX = readerRect.left - surfaceRect.left + point.left; const pointY = readerRect.top - surfaceRect.top + point.top; const { default: html2canvas } = await import("html2canvas"); const canvas = await html2canvas(surface, { backgroundColor: null, logging: false, scale: Math.min(2, window.devicePixelRatio || 1), useCORS: true, width: surface.clientWidth, height: surface.clientHeight, onclone: (clonedDocument) => { const clonedReader = clonedDocument.querySelector("[data-plan-reader]"); const clonedSurface = clonedReader?.parentElement; if (!(clonedSurface instanceof HTMLElement)) return; clonedSurface.setAttribute("data-plan-feedback-capture", "true"); const style = clonedDocument.createElement("style"); style.textContent = ` [data-plan-feedback-capture] { --background: #ffffff !important; --foreground: #18181b !important; --muted: #f4f4f5 !important; --muted-foreground: #71717a !important; --border: #d4d4d8 !important; --ring: #71717a !important; --plan-document: #ffffff !important; --plan-text: #18181b !important; --plan-muted: #71717a !important; --plan-line: #d4d4d8 !important; --plan-chrome: #ffffff !important; --plan-canvas: #f4f4f5 !important; --plan-grid-line: #e4e4e7 !important; } [data-plan-feedback-capture], [data-plan-feedback-capture] * { color: #18181b !important; border-color: #d4d4d8 !important; outline-color: #71717a !important; text-decoration-color: #18181b !important; caret-color: #18181b !important; background: transparent !important; background-color: transparent !important; background-image: none !important; box-shadow: none !important; text-shadow: none !important; } [data-plan-feedback-capture] { background: #ffffff !important; } [data-plan-feedback-capture] svg, [data-plan-feedback-capture] svg * { fill: currentColor !important; stroke: currentColor !important; } `; clonedDocument.head.appendChild(style); }, }); const label = `Comment ${index + 1}: ${thread.id}`; const dataUrl = cropFeedbackScreenshot({ canvas, surfaceWidth: surface.clientWidth, surfaceHeight: surface.clientHeight, pointX, pointY, label, }); if (dataUrl) { images.push(dataUrl); labels.push(`${label} — ${formatAnchorForAgent(anchor)}`); } } } finally { restoreNativeDocumentScroll(restore); schedulePlanDocumentRestore(restore); } const overflowLabels = overflow .slice(0, 12) .map( (thread) => `- ${thread.id}: ${formatAnchorForAgent(thread.anchor)} (${planCommentAnchorDetails( thread.anchor, ).join("; ")})`, ); const note = [ images.length ? `Attached ${images.length} focused screenshot crop(s):` : "", ...labels.map((label) => `- ${label}`), overflow.length > 0 ? localPlanMode ? `- ${overflow.length} additional visual comment(s) exceeded the screenshot budget. Use comments.json anchor details/coordinates for these overflow comments:` : `- ${overflow.length} additional visual comment(s) exceeded the screenshot budget. Use get-plan-feedback anchorDetails/coordinates for these overflow comments:` : "", ...overflowLabels, ] .filter(Boolean) .join("\n"); return { images, note }; }; const sendPlanFeedbackToInlineAgent = async () => { if (!bundle) return; const openCommentCount = bundle.summary.openCommentCount; if (openCommentCount === 0) { startCommenting(); return; } setSendingFeedback(true); try { const openThreads = commentThreads.filter( (thread) => commentThreadStatus(thread) === "open", ); const capture = await captureFocusedFeedbackImages(openThreads); const recapBase = bundle.plan.kind === "recap" ? "recaps" : "plans"; const reviewPath = `/${recapBase}/${selectedId ?? bundle.plan.id}`; const context = buildPlanAgentContext({ bundle, documentHtml, url: typeof window === "undefined" ? appPath(reviewPath) : `${window.location.origin}${appPath(reviewPath)}`, screenshotNote: capture.note, }); sendToAgentChat({ type: "content", submit: true, chatTarget: "local", openSidebar: true, context, images: capture.images, message: buildApplyFeedbackMessage(openCommentCount, { local: localPlanMode, }), }); toast.success( capture.images.length > 0 ? t("plansPage.reader.sentCommentsWithScreenshots") : t("plansPage.reader.sentComments"), ); } catch (error) { console.error( "[PlansPage] Failed to send plan feedback to inline agent:", error, ); toast.error(t("plansPage.comments.sendFailed")); } finally { setSendingFeedback(false); } }; const copyPlanFeedbackForAgent = async () => { if (!bundle) return; const openCommentCount = bundle.summary.openCommentCount; if (openCommentCount === 0) { startCommenting(); return; } await navigator.clipboard.writeText( [ buildApplyFeedbackMessage(openCommentCount, { local: localPlanMode }), "", planAgentContext, ].join("\n"), ); toast.success(t("plansPage.reader.feedbackCopied")); }; // Route comment writes to the DB (hosted) or comments.json (local); both // return the same bundle shape. const writeComments = async ( comments: PlanCommentInput[], note: string, ): Promise => { if (localPlanMode && localPlanBridgeUrl) { setLocalBridgeCommentPending(true); try { return await updateLocalPlanBridgeComments( localPlanBridgeUrl, localPlanSlug ?? "", { comments }, ); } finally { setLocalBridgeCommentPending(false); } } if (localPlanMode) { return updateLocalCommentMutation.mutateAsync({ slug: localPlanSlug ?? "", ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), comments, }) as Promise; } if (!bundle) return Promise.reject(new Error("No plan loaded.")); return updateCommentMutation.mutateAsync({ planId: bundle.plan.id, comments, note, }) as Promise; }; const removeCommentById = async (commentId: string): Promise => { if (localPlanMode && localPlanBridgeUrl) { setLocalBridgeCommentPending(true); try { const updated = await updateLocalPlanBridgeComments( localPlanBridgeUrl, localPlanSlug ?? "", { deletedCommentIds: [commentId] }, ); if (selectedPlanQueryKey) { queryClient.setQueryData(selectedPlanQueryKey, updated); } } finally { setLocalBridgeCommentPending(false); } return; } if (localPlanMode) { await updateLocalCommentMutation.mutateAsync({ slug: localPlanSlug ?? "", ...(localPlanRepoPath ? { path: localPlanRepoPath } : {}), deletedCommentIds: [commentId], }); return; } if (!bundle) return; await deleteCommentMutation.mutateAsync({ planId: bundle.plan.id, commentId, }); }; const commentWritePending = updateCommentMutation.isPending || updateLocalCommentMutation.isPending || localBridgeCommentPending; const commentDeletePending = deleteCommentMutation.isPending || updateLocalCommentMutation.isPending || localBridgeCommentPending; const submitInlineComment = async (draft: CommentDraft) => { if (!bundle || !pendingAnnotation || !selectedPlanQueryKey) return; // Capture the current position before clearing (used to restore on failure). const capturedPosition = inlineCommentPosition; const anchor: PlanAnnotationAnchor = { ...pendingAnnotation, resolutionTarget: draft.resolutionTarget, mentions: draft.mentions, }; const sectionId = anchor.sectionId && bundle.sections.some((section) => section.id === anchor.sectionId) ? anchor.sectionId : undefined; const anchorJson = JSON.stringify(anchor); const commentId = newCommentId(); const now = new Date().toISOString(); const commentInput: PlanCommentInput = { id: commentId, kind: "annotation", status: "open", message: draft.message, sectionId, anchor: anchorJson, createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, resolutionTarget: draft.resolutionTarget, mentions: draft.mentions, }; const optimisticComment: PlanCommentItem = { id: commentId, planId: bundle.plan.id, parentCommentId: null, sectionId: sectionId ?? null, kind: "annotation", status: "open", anchor: anchorJson, message: draft.message, createdBy: "human", authorEmail: collabUser?.email ?? null, authorName: collabUser?.name ?? null, resolutionTarget: draft.resolutionTarget, mentions: draft.mentions, mentionsJson: draft.mentions.length > 0 ? JSON.stringify(draft.mentions) : null, resolvedBy: null, resolvedAt: null, consumedAt: null, createdAt: now, updatedAt: now, }; clearPendingDocumentRestore(); pendingDocumentRestoreRef.current = documentStateRef.current; // Await the cancel so an in-flight sync refresh can't resolve *after* our // optimistic write and revert it (the "comment lagged / didn't stick" // symptom). cancelQueries reverts outstanding fetches before we patch. await queryClient.cancelQueries({ queryKey: selectedPlanQueryKey }); queryClient.setQueryData( selectedPlanQueryKey, (current: PlanBundleWithHtml | undefined) => current ? addPlanCommentToBundle(current, optimisticComment) : current, ); setFailedCommentDraft(null); clearInlineCommentDraft(); setAnnotateMode(true); void writeComments( [commentInput], "Human added inline visual plan feedback.", ) .then((updated) => { queryClient.setQueryData(selectedPlanQueryKey, updated); expirePendingDocumentRestore(); }) .catch(() => { queryClient.setQueryData( selectedPlanQueryKey, (current: PlanBundleWithHtml | undefined) => current ? removePlanCommentFromBundle(current, optimisticComment.id) : current, ); clearPendingDocumentRestore(); // Restore the draft so the reviewer doesn't lose their typed text. // Re-open the composer at the same anchor with the original draft // pre-filled (Issue 2a). setFailedCommentDraft(draft); setPendingAnnotation(anchor); setInlineCommentPosition( getPositionFromAnchor(anchor) ?? capturedPosition, ); }); }; const updateAnnotationComment = ( annotation: RuntimeAnnotation, message: string, ) => { if (!bundle) return; const anchor: PlanAnnotationAnchor = { ...annotation.anchor, mentions: extractCommentMentions(message), resolutionTarget: normalizePlanCommentResolutionTarget( annotation.anchor.resolutionTarget, ), }; void writeComments( [ { id: annotation.id, kind: annotation.kind as PlanBundle["comments"][number]["kind"], status: annotation.status as PlanBundle["comments"][number]["status"], message, sectionId: annotation.sectionId ?? anchor.sectionId, anchor: JSON.stringify(anchor), createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, }, ], "Human edited visual plan feedback.", ) .then(() => { setActiveAnnotation(null); toast.success(t("plansPage.comments.commentUpdated")); }) .catch(() => { // The mutation hook surfaces the failure toast; just clear pending. }); }; const replyToCommentThread = async ( threadRootId: string, message: string, ) => { if (!bundle || !selectedPlanQueryKey) return; const thread = commentThreads.find((item) => item.id === threadRootId); if (!thread) { throw new Error("Comment thread is no longer available."); } // Optimistic reply: insert into cache immediately so the UI updates // before the server round-trip completes (Issue 3). const replyId = newCommentId(); const now = new Date().toISOString(); const optimisticReply: PlanCommentItem = { id: replyId, planId: bundle.plan.id, parentCommentId: thread.root.id, sectionId: thread.root.sectionId ?? null, kind: thread.root.kind, status: "open", anchor: thread.root.anchor ?? null, message, createdBy: "human", authorEmail: collabUser?.email ?? null, authorName: collabUser?.name ?? null, resolutionTarget: thread.root.resolutionTarget ?? null, mentions: [], mentionsJson: null, resolvedBy: null, resolvedAt: null, consumedAt: null, createdAt: now, updatedAt: now, }; // Await the cancel so an in-flight sync refresh can't resolve *after* our // optimistic write and revert it (the "comment lagged / didn't stick" // symptom). cancelQueries reverts outstanding fetches before we patch. await queryClient.cancelQueries({ queryKey: selectedPlanQueryKey }); queryClient.setQueryData( selectedPlanQueryKey, (current: PlanBundleWithHtml | undefined) => current ? addPlanCommentToBundle(current, optimisticReply) : current, ); try { const updated = await writeComments( [ { parentCommentId: thread.root.id, kind: thread.root.kind, status: "open", message, sectionId: thread.root.sectionId ?? undefined, anchor: thread.root.anchor ?? undefined, createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, }, ], "Human replied to visual plan feedback.", ); // Replace optimistic entry with the authoritative server response. if (selectedPlanQueryKey) { queryClient.setQueryData(selectedPlanQueryKey, updated); } toast.success(t("plansPage.comments.replyAdded")); } catch { // Roll back the optimistic reply on error. queryClient.setQueryData( selectedPlanQueryKey, (current: PlanBundleWithHtml | undefined) => current ? removePlanCommentFromBundle(current, replyId) : current, ); throw new Error("Could not send reply. Try again."); } }; const setCommentThreadStatus = async ( threadRootId: string, status: PlanBundle["comments"][number]["status"], fallbackAnchor?: PlanAnnotationAnchor | null, ) => { if (!bundle || !canResolveCommentThreads || !selectedPlanQueryKey) return; const thread = commentThreads.find((item) => item.id === threadRootId); if (!thread) return; const fallbackAnchorJson = fallbackAnchor ? JSON.stringify(fallbackAnchor) : undefined; // Optimistic status flip: update the cache immediately so the marker and // popover update without waiting for the server round-trip (Issue 3). const prevBundle = queryClient.getQueryData(selectedPlanQueryKey); // Await the cancel so an in-flight sync refresh can't resolve *after* our // optimistic write and revert it (the "comment lagged / didn't stick" // symptom). cancelQueries reverts outstanding fetches before we patch. await queryClient.cancelQueries({ queryKey: selectedPlanQueryKey }); queryClient.setQueryData( selectedPlanQueryKey, (current: PlanBundleWithHtml | undefined) => { if (!current) return current; return withPlanComments( current, current.comments.map((comment) => thread.comments.some((tc) => tc.id === comment.id) ? { ...comment, status } : comment, ), ); }, ); setActiveAnnotation(null); void writeComments( thread.comments.map((comment) => ({ id: comment.id, kind: comment.kind, status, message: comment.message, sectionId: comment.sectionId ?? undefined, anchor: comment.anchor ?? fallbackAnchorJson, createdBy: "human", authorEmail: collabUser?.email, authorName: collabUser?.name, })), status === "resolved" ? "Human resolved visual plan feedback." : "Human reopened visual plan feedback.", ) .then(() => { toast.success( status === "resolved" ? t("plansPage.comments.commentResolved") : t("plansPage.comments.commentReopened"), ); }) .catch(() => { // Roll back the optimistic status change. if (prevBundle !== undefined) { queryClient.setQueryData(selectedPlanQueryKey, prevBundle); } }); }; const requestDeleteComment = (thread: CommentThread, commentId: string) => { const target = thread.comments.find((comment) => comment.id === commentId); if (!target || !canDeletePlanComment(target)) return; setDeleteCommentRequest({ commentId, replyCount: commentDescendantCount(thread.comments, commentId), }); }; const requestDeleteCommentThread = (thread: CommentThread) => { requestDeleteComment(thread, thread.root.id); }; const confirmDeleteCommentThread = async () => { if (!bundle || !selectedPlanQueryKey || !deleteCommentRequest) return; const request = deleteCommentRequest; const prevBundle = queryClient.getQueryData(selectedPlanQueryKey); const commentId = request.commentId; // Await the cancel so an in-flight sync refresh can't resolve *after* our // optimistic write and revert it (the "comment lagged / didn't stick" // symptom). cancelQueries reverts outstanding fetches before we patch. await queryClient.cancelQueries({ queryKey: selectedPlanQueryKey }); queryClient.setQueryData( selectedPlanQueryKey, (current: PlanBundleWithHtml | undefined) => current ? removePlanCommentThreadFromBundle(current, commentId) : current, ); setActiveAnnotation((current) => current?.annotation.id === commentId ? null : current, ); setDeleteCommentRequest(null); try { await removeCommentById(commentId); toast.success(t("plansPage.comments.commentDeleted")); } catch (error) { if (prevBundle !== undefined) { queryClient.setQueryData(selectedPlanQueryKey, prevBundle); } setDeleteCommentRequest(request); } }; const nativeMarkerPosition = (anchor: PlanAnnotationAnchor) => { void nativeMarkerVersion; const reader = nativeReaderRef.current; if (!reader) return null; const placement = nativeMarkerPlacementForAnchor(anchor, reader); if (!placement) return null; const { left, top } = placement.clip ? { left: placement.clip.left + placement.marker.left, top: placement.clip.top + placement.marker.top, } : placement.marker; if ( left < -40 || top < -40 || left > reader.clientWidth + 40 || top > reader.clientHeight + 40 ) { return null; } return placement; }; const pendingMarkerPlacement = pendingAnnotation && inlineCommentPosition ? nativeMarkerPosition(pendingAnnotation) : null; const pendingVisualSurfaceMode = visualSurfaceModeForAnchor(pendingAnnotation); const pendingCommentPin = pendingAnnotation && inlineCommentPosition ? ( pendingMarkerPlacement || !pendingVisualSurfaceMode ? (
) : null ) : null; return (
{!selectedId ? ( void plansQuery.refetch()} viewerEmail={session?.email ?? null} onCreate={requestCreatePlan} canCreate={Boolean(session)} onArchive={handleArchivePlan} onDelete={requestDeletePlan} onRestore={(plan) => void confirmDeletePlan( { planId: plan.id, mode: "restore" }, plan, ) } onSignIn={() => openSignIn()} /> ) : showLocalPlanConnection ? ( setLocalBridgeConnectionRequested(true)} onRetry={() => void checkLocalNetworkPermission()} /> ) : showLocalPlanLoadError ? ( void refetchLocalPlan()} /> ) : showPlanLoadError ? ( void planQuery.refetch()} onSignIn={() => openSignIn()} onGoogleSignIn={startGoogleSignIn} onRequestAccess={requestPlanAccess} requestAccessPending={requestPlanAccessMutation.isPending} accessRequestSent={accessRequestSentPlanId === selectedId} viewerEmail={session?.email ?? null} /> ) : showInitialPlanSkeleton ? ( ) : (
{immersiveReader && !recapScreenshotMode && !showingPrototypeSurface && (
{t("plansPage.reader.backToPlans")}
)} {reviewMode !== "none" && !recapScreenshotMode && (
{reviewMode === "comment" ? t("plansPage.reader.clickToComment", { noun: isRecap ? t("plansPage.nouns.recap") : t("plansPage.nouns.plan"), }) : reviewMode === "text" ? t("plansPage.reader.clickCanvasNote") : t("plansPage.reader.dragCanvasCallout")}
)} {bundle.plan.content ? (
preservePlanReaderScroll(startCommenting) } planId={bundle.plan.id} collabUser={collabUser} prototypeOnly={prototypeOnly} isRecap={isRecap} hideChangedFiles={recapScreenshotMode} hideRecapChrome={recapScreenshotMode} showCodeAnnotationOverlays={recapScreenshotMode} recapScreenshotTheme={recapScreenshotTheme} sourceUrl={bundle.plan.sourceUrl} visualSurfaceMode={visualSurfaceMode} onVisualSurfaceModeChange={setVisualSurfaceMode} onVisualQuestionsSubmit={(summary) => { sendToAgentChat({ type: "content", submit: true, context: planAgentContext, message: buildQuestionFormRevisionMessage(summary), }); persistQuestionFormAnswers(summary, bundle.plan.id); toast.success(t("plansPage.reader.sentAnswers")); }} />
{nativeSelectionComment && (
)} {commentMarkersVisible && (
{runtimeCommentThreads.map((annotation) => { const placement = nativeMarkerPosition( annotation.anchor, ); if (!placement) return null; const participants = annotation.participants.map( runtimeParticipantPresentation, ); const marker = ( { const popoverPosition = getPositionFromAnchor( annotation.anchor, ); if (!popoverPosition) return; closeInlineComment(); setAnnotationsOpen(false); setActiveAnnotation({ annotation, position: popoverPosition, }); }} /> ); if (!placement.clip) { return
{marker}
; } return (
{marker}
); })}
)}
) : (