From 1b36200c912988b36d55c1e7bbd7920f3d5cb3b6 Mon Sep 17 00:00:00 2001 From: Alex Gorbatchev Date: Tue, 10 Mar 2026 12:03:38 -0700 Subject: [PATCH 1/3] feat: add annotation thread/reply UI Close the loop on thread conversations in the browser. The MCP server already supports agentation_reply, but the UI never displayed thread messages or allowed human replies. Server-side: fix thread.message SSE event to emit the full Annotation instead of just the ThreadMessage so the browser can match by id. Browser-side: - Add postThreadReply() sync utility - Annotation popup switches to thread view when messages exist (read-only original comment, chronological messages, reply input) - SSE listener for thread.message updates live state - Purple badge on markers showing thread message count Co-Authored-By: Claude Opus 4.6 --- mcp/src/server/sqlite.ts | 2 +- mcp/src/server/store.ts | 2 +- .../components/annotation-popup-css/index.tsx | 199 ++++++++++++++---- .../annotation-popup-css/styles.module.scss | 112 ++++++++++ .../src/components/page-toolbar-css/index.tsx | 63 ++++++ .../page-toolbar-css/styles.module.scss | 20 ++ package/src/utils/sync.ts | 21 ++ 7 files changed, 372 insertions(+), 47 deletions(-) diff --git a/mcp/src/server/sqlite.ts b/mcp/src/server/sqlite.ts index c15ae5f1..2bda3a5d 100644 --- a/mcp/src/server/sqlite.ts +++ b/mcp/src/server/sqlite.ts @@ -495,7 +495,7 @@ export function createSQLiteStore(dbPath?: string): AFSStore { const updated = this.updateAnnotation(annotationId, { thread }); if (updated && existing.sessionId) { - const event = eventBus.emit("thread.message", existing.sessionId, message); + const event = eventBus.emit("thread.message", existing.sessionId, updated); persistEvent(event); } diff --git a/mcp/src/server/store.ts b/mcp/src/server/store.ts index a53990b4..116265e3 100644 --- a/mcp/src/server/store.ts +++ b/mcp/src/server/store.ts @@ -216,7 +216,7 @@ function createMemoryStore(): AFSStore { annotation.updatedAt = new Date().toISOString(); if (annotation.sessionId) { - const event = eventBus.emit("thread.message", annotation.sessionId, message); + const event = eventBus.emit("thread.message", annotation.sessionId, annotation); events.push(event); } diff --git a/package/src/components/annotation-popup-css/index.tsx b/package/src/components/annotation-popup-css/index.tsx index f595260e..b54b2ca5 100644 --- a/package/src/components/annotation-popup-css/index.tsx +++ b/package/src/components/annotation-popup-css/index.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHand import styles from "./styles.module.scss"; import { IconTrash } from "../icons"; import { originalSetTimeout } from "../../utils/freeze-animations"; +import type { ThreadMessage } from "../../types"; // ============================================================================= // Helpers @@ -57,6 +58,12 @@ export interface AnnotationPopupCSSProps { lightMode?: boolean; /** Computed styles for the selected element */ computedStyles?: Record; + /** Thread messages for this annotation */ + thread?: ThreadMessage[]; + /** Called when user sends a reply in the thread */ + onReply?: (content: string) => void; + /** Whether a reply is currently being sent */ + isReplySending?: boolean; } export interface AnnotationPopupCSSHandle { @@ -85,18 +92,25 @@ export const AnnotationPopupCSS = forwardRef 0; + const [text, setText] = useState(hasThread ? "" : initialValue); const [isShaking, setIsShaking] = useState(false); const [animState, setAnimState] = useState<"initial" | "enter" | "entered" | "exit">("initial"); const [isFocused, setIsFocused] = useState(false); const [isStylesExpanded, setIsStylesExpanded] = useState(false); // Computed styles accordion state const textareaRef = useRef(null); + const replyTextareaRef = useRef(null); + const threadMessagesRef = useRef(null); const popupRef = useRef(null); const cancelTimerRef = useRef | null>(null); const shakeTimerRef = useRef | null>(null); + const [replyText, setReplyText] = useState(""); // Sync with parent exit state useEffect(() => { @@ -116,11 +130,11 @@ export const AnnotationPopupCSS = forwardRef { - const textarea = textareaRef.current; - if (textarea) { - focusBypassingTraps(textarea); - textarea.selectionStart = textarea.selectionEnd = textarea.value.length; - textarea.scrollTop = textarea.scrollHeight; + const target = hasThread ? replyTextareaRef.current : textareaRef.current; + if (target) { + focusBypassingTraps(target); + target.selectionStart = target.selectionEnd = target.value.length; + target.scrollTop = target.scrollHeight; } }, 50); return () => { @@ -129,8 +143,16 @@ export const AnnotationPopupCSS = forwardRef { + if (threadMessagesRef.current) { + threadMessagesRef.current.scrollTop = threadMessagesRef.current.scrollHeight; + } + }, [thread?.length]); + // Shake animation const shake = useCallback(() => { if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); @@ -160,6 +182,13 @@ export const AnnotationPopupCSS = forwardRef { + if (!replyText.trim() || !onReply) return; + onReply(replyText.trim()); + setReplyText(""); + }, [replyText, onReply]); + // Handle keyboard const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -176,6 +205,22 @@ export const AnnotationPopupCSS = forwardRef) => { + e.stopPropagation(); + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleReply(); + } + if (e.key === "Escape") { + handleCancel(); + } + }, + [handleReply, handleCancel] + ); + const popupClassName = [ styles.popup, lightMode ? styles.light : "", @@ -249,49 +294,113 @@ export const AnnotationPopupCSS = forwardRef )} - {selectedText && ( -
- “{selectedText.slice(0, 80)} - {selectedText.length > 80 ? "..." : ""}” -
- )} + {hasThread ? ( + <> + {/* Thread view: original comment read-only, messages, reply input */} +
{initialValue}
+ +
+ {thread.map((msg) => ( +
+
+ {msg.role === "agent" ? "Agent" : "You"} +
+
{msg.content}
+
+ ))} +
+ + {onReply && ( +
+