diff --git a/_claude-integration/.gitignore b/_claude-integration/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/_claude-integration/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/_claude-integration/README.md b/_claude-integration/README.md new file mode 100644 index 0000000..1108456 --- /dev/null +++ b/_claude-integration/README.md @@ -0,0 +1,89 @@ +# @agentation/claude + +Claude Code integration for [Agentation](https://agentation.dev) - sync page feedback directly to Claude Code via MCP. + +## Setup + +### 1. Add the MCP server to Claude Code + +```bash +claude mcp add agentation -- npx @agentation/claude +``` + +That's it! The server will auto-start when you launch Claude Code. + +### 2. Configure Agentation in your app + +```tsx +import { Agentation } from 'agentation'; + +function App() { + return ( + <> + + {process.env.NODE_ENV === 'development' && ( + + )} + + ); +} +``` + +## Usage + +1. **Start Claude Code** - the MCP server starts automatically +2. **Browse your app** and annotate issues with Agentation +3. **Tell Claude** to check your feedback: + - "Check my page feedback" + - "What UI issues do I have?" + - "Fix the issues on /dashboard" + +Claude will query the MCP server and see your structured annotations. + +## How it works + +``` +Browser (your app) Your Machine Claude Code + │ │ │ + │ POST localhost:4242 │ │ + └──────────────────────────►│ HTTP Server │ + │ │ │ + │ ▼ │ + │ In-Memory Store │ + │ │ │ + │ MCP Server ◄────────────┘ + │ (stdio) MCP Protocol +``` + +- **HTTP Server** (port 4242): Receives annotations from browser +- **MCP Server** (stdio): Exposes tools to Claude Code +- **Shared Store**: In-memory, cleared when server restarts + +## MCP Tools + +| Tool | Description | +|------|-------------| +| `agentation_get_feedback` | Get all annotations, optionally filtered by pathname | +| `agentation_list_pages` | List pages with pending feedback | +| `agentation_clear_feedback` | Clear feedback after processing | + +## CLI Options + +```bash +npx @agentation/claude [options] + +Options: + --port HTTP server port (default: 4242) + --verbose, -v Enable verbose logging +``` + +## Custom port + +```bash +claude mcp add agentation -- npx @agentation/claude --port 5000 +``` + +Then configure your app: +```tsx + +``` diff --git a/_claude-integration/package.json b/_claude-integration/package.json new file mode 100644 index 0000000..8111db9 --- /dev/null +++ b/_claude-integration/package.json @@ -0,0 +1,36 @@ +{ + "name": "@agentation/claude", + "version": "0.0.1", + "description": "Claude Code integration for Agentation - MCP server for syncing page feedback", + "license": "PolyForm-Shield-1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "agentation-claude": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "watch": "tsup --watch", + "dev": "tsx src/cli.ts", + "start": "node dist/cli.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1" + }, + "devDependencies": { + "tsup": "^8.0.0", + "tsx": "^4.7.0", + "typescript": "^5.0.0", + "@types/node": "^20.0.0" + } +} diff --git a/_claude-integration/src/cli.ts b/_claude-integration/src/cli.ts new file mode 100644 index 0000000..f38c10f --- /dev/null +++ b/_claude-integration/src/cli.ts @@ -0,0 +1,20 @@ +import { startServer } from "./server.js"; + +const args = process.argv.slice(2); + +// Parse CLI arguments +const portIndex = args.indexOf("--port"); +const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 4242; +const verbose = args.includes("--verbose") || args.includes("-v"); + +console.error(` +Agentation Claude Integration +============================== +HTTP server: http://localhost:${port} +MCP server: stdio (connected to Claude Code) +`); + +startServer({ httpPort: port, verbose }).catch((err) => { + console.error("[agentation] Fatal error:", err); + process.exit(1); +}); diff --git a/_claude-integration/src/index.ts b/_claude-integration/src/index.ts new file mode 100644 index 0000000..77efe47 --- /dev/null +++ b/_claude-integration/src/index.ts @@ -0,0 +1,21 @@ +/** + * @agentation/claude + * + * Claude Code integration for Agentation - MCP server for syncing page feedback + * + * Usage as CLI (recommended): + * claude mcp add agentation -- npx @agentation/claude + * + * Usage programmatically: + * import { startServer } from '@agentation/claude'; + * await startServer({ httpPort: 4242 }); + */ + +export { startServer } from "./server.js"; +export { store } from "./store.js"; +export type { + Annotation, + PageFeedback, + SyncPayload, + ServerConfig, +} from "./types.js"; diff --git a/_claude-integration/src/server.ts b/_claude-integration/src/server.ts new file mode 100644 index 0000000..e77c12d --- /dev/null +++ b/_claude-integration/src/server.ts @@ -0,0 +1,340 @@ +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { store } from "./store.js"; +import type { SyncPayload, ServerConfig, PageFeedback } from "./types.js"; + +const DEFAULT_HTTP_PORT = 4242; + +// SSE clients for broadcasting events to connected browsers +const sseClients = new Set(); + +/** + * Broadcast an event to all connected SSE clients + */ +function broadcastEvent(event: string, data: Record) { + const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const client of sseClients) { + client.write(message); + } +} + +/** + * Format annotations as markdown for Claude + */ +function formatFeedbackMarkdown(pages: PageFeedback[]): string { + if (pages.length === 0) { + return "No pending page feedback."; + } + + const lines: string[] = ["# Page Feedback\n"]; + + for (const page of pages) { + if (page.annotations.length === 0) continue; + + lines.push(`## ${page.pathname}`); + lines.push(`**URL:** ${page.url}`); + if (page.viewport) { + lines.push(`**Viewport:** ${page.viewport.width}x${page.viewport.height}`); + } + lines.push(""); + + for (let i = 0; i < page.annotations.length; i++) { + const ann = page.annotations[i]; + lines.push(`### ${i + 1}. ${ann.element}`); + lines.push(`**Location:** ${ann.elementPath}`); + if (ann.cssClasses) { + lines.push(`**Classes:** ${ann.cssClasses}`); + } + if (ann.selectedText) { + lines.push(`**Selected text:** "${ann.selectedText}"`); + } + if (ann.fullPath) { + lines.push(`**Full path:** ${ann.fullPath}`); + } + lines.push(`**Feedback:** ${ann.comment}`); + lines.push(""); + } + } + + return lines.join("\n"); +} + +/** + * Create and start the HTTP server for receiving browser annotations + */ +function createHttpServer(port: number, verbose: boolean): Promise { + return new Promise((resolve, reject) => { + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + // CORS headers for browser requests + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + // Handle preflight + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url || "/", `http://localhost:${port}`); + + // Health check + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", annotations: store.getTotalCount() })); + return; + } + + // SSE endpoint for real-time events + if (req.method === "GET" && url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + // Send initial connection message + res.write(`event: connected\ndata: ${JSON.stringify({ status: "ok" })}\n\n`); + + // Add to clients + sseClients.add(res); + + // Remove on close + req.on("close", () => { + sseClients.delete(res); + }); + + return; + } + + // Receive annotations from browser + if (req.method === "POST" && url.pathname === "/sync") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const payload: SyncPayload = JSON.parse(body); + + store.set(payload.pathname, { + url: payload.url, + pathname: payload.pathname, + viewport: payload.viewport, + userAgent: payload.userAgent, + annotations: payload.annotations, + }); + + if (verbose) { + console.error( + `[agentation] Synced ${payload.annotations.length} annotations for ${payload.pathname}` + ); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + } catch (err) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + return; + } + + // Get current feedback (for debugging) + if (req.method === "GET" && url.pathname === "/feedback") { + const pathname = url.searchParams.get("pathname"); + const data = pathname ? store.get(pathname) : store.getAll(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); + return; + } + + // Clear feedback + if (req.method === "DELETE" && url.pathname === "/feedback") { + const pathname = url.searchParams.get("pathname"); + if (pathname) { + store.clear(pathname); + broadcastEvent("clear", { pathname }); + } else { + store.clearAll(); + broadcastEvent("clear", { all: true }); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + return; + } + + // 404 + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.error(`[agentation] Port ${port} is already in use. Server may already be running.`); + // Don't reject - allow MCP to still work even if HTTP server can't start + resolve(); + } else { + reject(err); + } + }); + + server.listen(port, () => { + console.error(`[agentation] HTTP server listening on http://localhost:${port}`); + resolve(); + }); + }); +} + +/** + * Create the MCP server with tools for Claude Code + */ +function createMcpServer(verbose: boolean): Server { + const server = new Server( + { + name: "agentation", + version: "0.0.1", + }, + { + capabilities: { + tools: {}, + }, + } + ); + + // List available tools + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "agentation_get_feedback", + description: + "Get page feedback annotations from Agentation. Returns structured feedback about UI elements that need attention. Use this when the user mentions page feedback, UI issues, or wants you to check their annotations.", + inputSchema: { + type: "object" as const, + properties: { + pathname: { + type: "string", + description: + "Optional: specific page pathname to get feedback for (e.g., '/dashboard'). If not provided, returns feedback for all pages.", + }, + }, + }, + }, + { + name: "agentation_list_pages", + description: + "List all pages that have pending feedback annotations. Use this to see which pages have issues to address.", + inputSchema: { + type: "object" as const, + properties: {}, + }, + }, + { + name: "agentation_clear_feedback", + description: + "Clear feedback after processing. Use this after you've addressed the feedback to remove it from the queue.", + inputSchema: { + type: "object" as const, + properties: { + pathname: { + type: "string", + description: + "Optional: specific page pathname to clear. If not provided, clears all feedback.", + }, + }, + }, + }, + ], + })); + + // Handle tool calls + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const toolArgs = args as Record | undefined; + + switch (name) { + case "agentation_get_feedback": { + const pathname = typeof toolArgs?.pathname === "string" ? toolArgs.pathname : undefined; + const pages = pathname + ? [store.get(pathname)].filter((p): p is PageFeedback => p !== undefined) + : store.getAll(); + + const markdown = formatFeedbackMarkdown(pages); + + if (verbose) { + console.error(`[agentation] get_feedback: returning ${pages.length} pages`); + } + + return { + content: [{ type: "text", text: markdown }], + }; + } + + case "agentation_list_pages": { + const pages = store.getPagesWithFeedback(); + + if (pages.length === 0) { + return { + content: [{ type: "text", text: "No pages with pending feedback." }], + }; + } + + const list = pages + .map((p) => `- ${p.pathname} (${p.count} annotations)`) + .join("\n"); + + return { + content: [{ type: "text", text: `Pages with pending feedback:\n${list}` }], + }; + } + + case "agentation_clear_feedback": { + const pathname = typeof toolArgs?.pathname === "string" ? toolArgs.pathname : undefined; + + if (pathname) { + store.clear(pathname); + broadcastEvent("clear", { pathname }); + return { + content: [{ type: "text", text: `Cleared feedback for ${pathname}` }], + }; + } + + store.clearAll(); + broadcastEvent("clear", { all: true }); + return { + content: [{ type: "text", text: "Cleared all feedback" }], + }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } + }); + + return server; +} + +/** + * Start the combined HTTP + MCP server + */ +export async function startServer(config: Partial = {}): Promise { + const { httpPort = DEFAULT_HTTP_PORT, verbose = false } = config; + + // Start HTTP server for browser communication + await createHttpServer(httpPort, verbose); + + // Start MCP server for Claude Code communication + const mcpServer = createMcpServer(verbose); + const transport = new StdioServerTransport(); + + await mcpServer.connect(transport); + + console.error("[agentation] MCP server connected via stdio"); + console.error("[agentation] Ready to receive annotations from browser and queries from Claude Code"); +} diff --git a/_claude-integration/src/store.ts b/_claude-integration/src/store.ts new file mode 100644 index 0000000..d92531f --- /dev/null +++ b/_claude-integration/src/store.ts @@ -0,0 +1,80 @@ +import type { PageFeedback } from "./types.js"; + +/** + * In-memory store for page feedback + * Keyed by pathname for easy lookup + */ +class FeedbackStore { + private pages: Map = new Map(); + + /** + * Update feedback for a page (full replacement) + */ + set(pathname: string, feedback: Omit): void { + this.pages.set(pathname, { + ...feedback, + timestamp: Date.now(), + }); + } + + /** + * Get feedback for a specific page + */ + get(pathname: string): PageFeedback | undefined { + return this.pages.get(pathname); + } + + /** + * Get all pages with feedback + */ + getAll(): PageFeedback[] { + return Array.from(this.pages.values()); + } + + /** + * Get all pages that have at least one annotation + */ + getPagesWithFeedback(): { pathname: string; url: string; count: number }[] { + const result: { pathname: string; url: string; count: number }[] = []; + + for (const [pathname, feedback] of this.pages) { + if (feedback.annotations.length > 0) { + result.push({ + pathname, + url: feedback.url, + count: feedback.annotations.length, + }); + } + } + + return result; + } + + /** + * Clear feedback for a specific page + */ + clear(pathname: string): boolean { + return this.pages.delete(pathname); + } + + /** + * Clear all feedback + */ + clearAll(): void { + this.pages.clear(); + } + + /** + * Get total annotation count across all pages + */ + getTotalCount(): number { + let count = 0; + for (const page of this.pages.values()) { + count += page.annotations.length; + } + return count; + } +} + +// Singleton instance +export const store = new FeedbackStore(); diff --git a/_claude-integration/src/types.ts b/_claude-integration/src/types.ts new file mode 100644 index 0000000..06954ed --- /dev/null +++ b/_claude-integration/src/types.ts @@ -0,0 +1,53 @@ +/** + * Annotation type - matches the core agentation package + */ +export interface Annotation { + id: string; + x: number; + y: number; + comment: string; + element: string; + elementPath: string; + timestamp: number; + selectedText?: string; + boundingBox?: { x: number; y: number; width: number; height: number }; + nearbyText?: string; + cssClasses?: string; + nearbyElements?: string; + computedStyles?: string; + fullPath?: string; + accessibility?: string; + isMultiSelect?: boolean; + isFixed?: boolean; +} + +/** + * Page feedback - annotations grouped by URL + */ +export interface PageFeedback { + url: string; + pathname: string; + viewport?: { width: number; height: number }; + userAgent?: string; + timestamp: number; + annotations: Annotation[]; +} + +/** + * Payload sent from browser to server + */ +export interface SyncPayload { + url: string; + pathname: string; + viewport?: { width: number; height: number }; + userAgent?: string; + annotations: Annotation[]; +} + +/** + * Server configuration + */ +export interface ServerConfig { + httpPort: number; + verbose: boolean; +} diff --git a/_claude-integration/tsconfig.json b/_claude-integration/tsconfig.json new file mode 100644 index 0000000..467dd9e --- /dev/null +++ b/_claude-integration/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/_claude-integration/tsup.config.ts b/_claude-integration/tsup.config.ts new file mode 100644 index 0000000..57f5c88 --- /dev/null +++ b/_claude-integration/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + { + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + }, + { + entry: ["src/cli.ts"], + format: ["esm"], + dts: true, + banner: { + js: "#!/usr/bin/env node", + }, + }, +]); diff --git a/_package-export/example/src/app/ToolbarProvider.tsx b/_package-export/example/src/app/ToolbarProvider.tsx index b363a79..0331bc3 100644 --- a/_package-export/example/src/app/ToolbarProvider.tsx +++ b/_package-export/example/src/app/ToolbarProvider.tsx @@ -38,6 +38,8 @@ export function ToolbarProvider() { demoAnnotations={demoAnnotations} demoDelay={1500} enableDemoMode + // Enable Claude Code sync when running locally + serverUrl={process.env.NODE_ENV === "development" ? "http://localhost:4242" : undefined} /> ); } diff --git a/_package-export/example/src/app/install/page.tsx b/_package-export/example/src/app/install/page.tsx index 8d34d38..c58d04f 100644 --- a/_package-export/example/src/app/install/page.tsx +++ b/_package-export/example/src/app/install/page.tsx @@ -222,6 +222,28 @@ function App() { /> +
+

Claude Code Integration (Optional)

+

+ Sync annotations directly to Claude Code instead of copy/paste. + Annotations flow automatically to Claude via MCP. +

+

1. Add the MCP server

+ + +

2. Enable sync in your app

+ `} + language="tsx" + /> + +

3. Use naturally

+

+ When you annotate elements, they sync to Claude automatically. + Just tell Claude “check my page feedback” or “fix the issues on /dashboard”. +

+
+

Requirements

    diff --git a/_package-export/src/components/icons.tsx b/_package-export/src/components/icons.tsx index 973875e..2fc615f 100644 --- a/_package-export/src/components/icons.tsx +++ b/_package-export/src/components/icons.tsx @@ -823,3 +823,85 @@ export const AnimatedBunny = ({ ); + +// Clawd icon - the Claude Code mascot for sync indicator +export const IconClaudeSync = ({ + size = 24, + status = "idle", +}: { + size?: number; + status?: "idle" | "syncing" | "synced" | "error"; +}) => { + // Clawd colors + const bodyColor = "#C27C5C"; + const feetColor = "#8B5A42"; + const eyeColor = "#1a1a1a"; + + // Status indicator colors + const getStatusColor = () => { + switch (status) { + case "syncing": + return "#ffd60a"; + case "synced": + return "#34c759"; + case "error": + return "#ff3b30"; + default: + return null; + } + }; + + const statusColor = getStatusColor(); + const opacity = status === "idle" ? 0.5 : 1; + + return ( + + + + + {/* Body - main rectangle */} + + + {/* Arms */} + + + + {/* Eyes - tall vertical rectangles */} + + + + {/* Legs - 2 pairs */} + + + + + + + {/* Status indicator dot */} + {statusColor && ( + + )} + + ); +}; diff --git a/_package-export/src/components/page-toolbar-css/index.tsx b/_package-export/src/components/page-toolbar-css/index.tsx index 3c090dc..6f665b7 100644 --- a/_package-export/src/components/page-toolbar-css/index.tsx +++ b/_package-export/src/components/page-toolbar-css/index.tsx @@ -34,6 +34,7 @@ import { IconSun, IconMoon, IconXmarkLarge, + IconClaudeSync, } from "../icons"; import { identifyElement, @@ -243,10 +244,16 @@ export type DemoAnnotation = { selectedText?: string; }; +type SyncStatus = "idle" | "syncing" | "synced" | "error"; + type PageFeedbackToolbarCSSProps = { demoAnnotations?: DemoAnnotation[]; demoDelay?: number; enableDemoMode?: boolean; + /** URL of the agentation-claude server for syncing annotations (e.g., "http://localhost:4242") */ + serverUrl?: string; + /** Callback fired whenever annotations change */ + onAnnotationChange?: (annotations: Annotation[]) => void; }; // ============================================================================= @@ -257,9 +264,12 @@ export function PageFeedbackToolbarCSS({ demoAnnotations, demoDelay = 1000, enableDemoMode = false, + serverUrl, + onAnnotationChange, }: PageFeedbackToolbarCSSProps = {}) { const [isActive, setIsActive] = useState(false); const [annotations, setAnnotations] = useState([]); + const [syncStatus, setSyncStatus] = useState("idle"); const [showMarkers, setShowMarkers] = useState(true); // Unified marker visibility state - controls both toolbar and eye toggle @@ -519,14 +529,63 @@ export function PageFeedbackToolbarCSS({ }; }, []); - // Save annotations + // Save annotations to localStorage and sync to server useEffect(() => { - if (mounted && annotations.length > 0) { + if (!mounted) return; + + // Save to localStorage + if (annotations.length > 0) { saveAnnotations(pathname, annotations); - } else if (mounted && annotations.length === 0) { + } else { localStorage.removeItem(getStorageKey(pathname)); } - }, [annotations, pathname, mounted]); + + // Call change callback if provided + onAnnotationChange?.(annotations); + + // Sync to server if serverUrl is configured + if (serverUrl) { + const syncToServer = async () => { + setSyncStatus("syncing"); + try { + const payload = { + url: typeof window !== "undefined" ? window.location.href : "", + pathname, + viewport: + typeof window !== "undefined" + ? { width: window.innerWidth, height: window.innerHeight } + : undefined, + userAgent: + typeof navigator !== "undefined" ? navigator.userAgent : undefined, + annotations, + }; + + const response = await fetch(`${serverUrl}/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + setSyncStatus("synced"); + // Reset to idle after a brief moment + setTimeout(() => setSyncStatus("idle"), 2000); + } else { + setSyncStatus("error"); + } + } catch { + // Server not available - fail silently, this is optional functionality + setSyncStatus("error"); + // Reset to idle after showing error briefly + setTimeout(() => setSyncStatus("idle"), 3000); + } + }; + + // Debounce sync to avoid hammering server during rapid changes + const timeoutId = setTimeout(syncToServer, 300); + return () => clearTimeout(timeoutId); + } + }, [annotations, pathname, mounted, serverUrl, onAnnotationChange]); // Freeze animations const freezeAnimations = useCallback(() => { @@ -1335,6 +1394,42 @@ export function PageFeedbackToolbarCSS({ setTimeout(() => setCleared(false), 1500); }, [pathname, annotations.length]); + // Listen for server-sent events (clear commands from MCP) + useEffect(() => { + if (!serverUrl || !mounted) return; + + let eventSource: EventSource | null = null; + + const connect = () => { + eventSource = new EventSource(`${serverUrl}/events`); + + eventSource.addEventListener("clear", (event) => { + try { + const data = JSON.parse(event.data); + // Clear if it's for all pages or matches our pathname + if (data.all || data.pathname === pathname) { + clearAll(); + } + } catch { + // Ignore parse errors + } + }); + + eventSource.onerror = () => { + // Silently handle errors - server may not be running + eventSource?.close(); + // Attempt to reconnect after a delay + setTimeout(connect, 5000); + }; + }; + + connect(); + + return () => { + eventSource?.close(); + }; + }, [serverUrl, mounted, pathname, clearAll]); + // Copy output const copyOutput = useCallback(async () => { const output = generateOutput(annotations, pathname, settings.outputDetail); @@ -1685,6 +1780,24 @@ export function PageFeedbackToolbarCSS({ + {/* Sync status indicator - only shown when serverUrl is configured */} + {serverUrl && ( +
    + +
    + )} +