From b2ebf74a5dcaabf98ced64b0c48f5d0dc0c45738 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Mon, 15 Jun 2026 23:08:54 +0100 Subject: [PATCH 01/12] docs: design for native app comments (desktop parity) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-native-app-comments-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-native-app-comments-design.md diff --git a/docs/superpowers/specs/2026-06-15-native-app-comments-design.md b/docs/superpowers/specs/2026-06-15-native-app-comments-design.md new file mode 100644 index 0000000000..9c6dc081dd --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-native-app-comments-design.md @@ -0,0 +1,152 @@ +# Native app comments — design + +## Goal + +Bring the desktop comments feature (shipped in #4551, "generic externally-writable +custom collections, comments as first consumer") to the native app +(`packages/agents-mobile`) at full desktop parity: + +1. **Read** comment bubbles inline in the session timeline. +2. **Post** top-level comments from the composer. +3. **Reply** to a specific timeline row or comment (with a target snapshot). +4. A dedicated **comments-only view**. + +The runtime and server are already generic and done; this is a client-only change +in `agents-mobile` plus small additive threading in the shared `agents-server-ui` +embed/components. No backend changes. + +## Background: how the mobile session screen is built + +Unlike desktop, the mobile session screen is a **split**: + +- The **timeline** is rendered by a shared web component running inside an Expo-DOM + WebView: `app/session.tsx` mounts `SessionChatLogDomEmbed` + (`agents-server-ui/src/embed/SessionChatLogDomEmbed`) → + `EmbedChatLogRoot` → `EntityHost` (view `chat-log`) → `ChatLogView` + (`agents-server-ui/src/components/views/ChatView.tsx`) → `EntityTimeline`. +- The **composer** is native: `SessionScreen.tsx`'s `NativeMessageComposer`. + +The Expo-DOM bridge is simple and one-directional per channel: + +- **Native → WebView**: props (`serverUrl`, `entityUrl`, `scrollToBottomSignal`, + `inlineQueuedMessages`, `bottomInset`, `serverHeaders`, …). +- **WebView → native**: marshalled async callback props. Today only + `onRequestOpenEntity(entityUrl)` exists. + +Because `ChatLogView` already calls `useEntityTimeline(baseUrl, connectUrl)` with no +opts, and `useEntityTimeline` defaults `comments` on when `commentsEnabled`, **comment +bubbles already render in the mobile chat log today**. What is missing on mobile is the +*write* path, the *reply* affordance, and the *comments-only* view. + +## Reused, unchanged + +- Runtime/server: generic externally-writable collections, `commentsCollection`, + `/collections/comments` endpoint, `_principal` virtual column. No changes. +- `agents-server-ui/src/lib/comments.ts`: `createSendCommentAction`, + `createCommentsTimelineSource`, `buildCommentsTimeline`, target encode/decode, + `EntityTimelineCommentRow`, `SelectedCommentTarget`. Imported by mobile as-is. +- `agents-server-ui/src/lib/comments-capability.ts`: `supportsComments` / + contract gating. Surfaced to mobile via `useEntityTimeline().commentsEnabled`. +- `agents-server-ui/src/lib/principals.ts`, `useCurrentPrincipal` (mobile already + has it) for the `from` author. +- `EntityTimeline` + `CommentBubble` rendering and their CSS — bubbles inherit the + shared embed styles, no native restyle. + +## Changes + +### 1. Capability gating (mobile) + +`SessionScreen` already destructures from `useEntityTimeline`. Also read +`commentsEnabled`. Gate all comment UI on `commentsEnabled && canWrite` +(`canWrite` already computed from `useEntityPermissions` against `SESSION_PERMISSIONS`, +which includes `write`). No new collection registration — `entity-connection` +registers `db.collections.comments` from entity metadata for both the embed and the +native hook. + +### 2. Reply affordance: WebView → native bridge + +Add one new optional callback prop, threaded through the shared embed: + +``` +SessionChatLogDomEmbed (new prop: onRequestReplyToComment?) + → EmbedChatLogRoot / EmbedSurfaceProps + → EntityHost / ChatLogView (new optional prop: onReplyToRow passthrough) + → EntityTimeline.onReplyToRow / onCommentTargetClick +``` + +- The callback signature carries the reply target and snapshot: + `onRequestReplyToComment(target: CommentTarget, snapshot: CommentSnapshot)`. + Both are plain JSON and marshal across the Expo-DOM boundary cleanly. +- `ChatLogView` gains an **optional** `onReplyToRow` (and `onCommentTargetClick`) + prop. When absent (desktop tile usage) behaviour is unchanged; when present (mobile + embed) it enables the reply button on comment/timeline rows and forwards the target. +- Desktop callers of `ChatLogView` are unaffected (new props are optional and unset). + +### 3. Native composer comment mode + +Extend `NativeMessageComposer` (in `SessionScreen.tsx`) with a +`'prompt' | 'comment'` mode, mirroring desktop `MessageInput`: + +- Mode toggle rendered only when `commentsEnabled && canWrite` and not editing a + queued message. Hidden otherwise (status quo for non-comment entities). +- In comment mode: placeholder "Add a comment…", send posts a comment instead of a + composer input; image attachments / slash autocomplete are disabled (comments are + plain text, matching desktop). +- Wire `createSendCommentAction({ db, baseUrl: serverUrl, entityUrl, from })` where + `from` comes from `useCurrentPrincipal`. The optimistic insert + `POST + /collections/comments` is reused verbatim; on send, bump the existing + `onSendMessage` scroll signal. +- **Reply target state**: a `selectedCommentTarget: SelectedCommentTarget | null` + lives in the native session screen. When `onRequestReplyToComment` fires from the + bridge: set the target, switch the composer to comment mode, focus the input, and + render a native **reply banner** (snapshot label + truncated text + clear button), + mirroring desktop. Clearing the target drops back to a top-level comment. The target + + snapshot are passed into `createSendCommentAction`'s call as `replyTo` / + `targetSnapshot`. + +### 4. Comments-only view (parity) + +- Add `'comments'` to `EmbedViewId` (`src/lib/embedView.ts`). +- **Embed side**: add a `commentsOnly?: boolean` prop on the chat-log embed + (`SessionChatLogDomEmbed` → `EmbedChatLogRoot` → `ChatLogView`). When set, + `ChatLogView` renders `buildCommentsTimeline(timelineRows)` (filtered rows + + adjacency) instead of the full timeline. This reuses the existing component/embed + path rather than introducing a separate embed module. +- **Native side**: in `app/session.tsx`, when `view === 'comments'`, pass + `commentsOnly` to the embed and render the native composer in **comment-only** mode + (always comment, no prompt toggle), mirroring desktop `CommentsView`. +- **Entry point**: `SessionMenu`'s view switcher (currently chat / state-explorer) + gains a "Comments" entry, shown only when the entity `commentsEnabled`. + +### 5. Tests + +Following the repo's tight-test style: + +- Native comment-send path: composing in comment mode invokes + `createSendCommentAction` with the expected `{ body, replyTo?, targetSnapshot? }`, + and clears/keeps the reply target correctly. +- Bridge serialization: a reply target round-trips through the + `onRequestReplyToComment` callback shape (target + snapshot are JSON-safe). +- Capability gating: comment toggle / comments view hidden when `commentsEnabled` is + false or `canWrite` is false. + +## Out of scope + +- No backend / runtime / server changes. +- No rich-text in comments (desktop comments are plain text too). +- No new comment data shape — reuse `commentsCollection` / `comments/v1` contract. + +## Files touched (anticipated) + +- `packages/agents-mobile/src/screens/SessionScreen.tsx` — composer comment mode, + reply banner, reply-target state, `commentsEnabled` gating. +- `packages/agents-mobile/app/session.tsx` — `'comments'` view wiring, + `onRequestReplyToComment` callback, `commentsOnly` prop pass-through. +- `packages/agents-mobile/src/lib/embedView.ts` — add `'comments'`. +- `packages/agents-mobile/src/components/SessionMenu.tsx` — comments view entry. +- `packages/agents-server-ui/src/embed/SessionChatLogDomEmbed.tsx` & + `embed/EmbedApp.tsx` — new optional `onRequestReplyToComment` / `commentsOnly` + props threaded through. +- `packages/agents-server-ui/src/components/views/ChatView.tsx` — optional + `onReplyToRow` / `onCommentTargetClick` / `commentsOnly` on `ChatLogView`. +- Tests alongside the above. From 377b254e81c564b7a16fe8616798107f12069e53 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Mon, 15 Jun 2026 23:26:41 +0100 Subject: [PATCH 02/12] feat(agents-mobile): session comments (desktop parity) Add comments to the native app: a prompt/comment mode in the native composer, tap-a-row-to-reply forwarded from the embed timeline over a new Expo-DOM callback, and a dedicated comments-only view reachable from the session menu. Reading comment bubbles already worked via the shared chat-log embed; this adds the write + reply + dedicated-view surface. No server/runtime changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/agents-mobile-comments.md | 6 + packages/agents-mobile/app/session.tsx | 29 ++- .../agents-mobile/src/components/Icon.tsx | 3 + .../src/components/SessionMenu.tsx | 18 ++ packages/agents-mobile/src/lib/embedView.ts | 2 +- .../src/screens/SessionScreen.tsx | 239 +++++++++++++++++- .../src/components/views/ChatView.tsx | 28 +- .../agents-server-ui/src/embed/EmbedApp.tsx | 37 ++- 8 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 .changeset/agents-mobile-comments.md diff --git a/.changeset/agents-mobile-comments.md b/.changeset/agents-mobile-comments.md new file mode 100644 index 0000000000..ce83fbb38e --- /dev/null +++ b/.changeset/agents-mobile-comments.md @@ -0,0 +1,6 @@ +--- +"@electric-ax/agents-mobile": patch +"@electric-ax/agents-server-ui": patch +--- + +Bring session comments to mobile (desktop parity): a comment mode in the native composer, tap-a-row-to-reply forwarded from the embed timeline, and a dedicated comments-only view reachable from the session menu. No server API changes. diff --git a/packages/agents-mobile/app/session.tsx b/packages/agents-mobile/app/session.tsx index f2cc587217..bf9bb46a39 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -25,6 +25,7 @@ import SessionChatLogDomEmbedModule from '@electric-ax/agents-server-ui/src/embe import SessionStateInspectorDomEmbedModule from '@electric-ax/agents-server-ui/src/embed/SessionStateInspectorDomEmbed' import { getActiveServerHeadersSnapshot } from '@electric-ax/agents-server-ui/src/lib/auth-fetch' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' +import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' const HEADER_HEIGHT = 44 @@ -35,7 +36,9 @@ type SessionDomEmbedProps = { scrollToBottomSignal?: number inlineQueuedMessages?: Array bottomInset?: number + commentsOnly?: boolean onRequestOpenEntity: (entityUrl: string) => Promise + onRequestReplyToComment?: (target: SelectedCommentTarget) => void style?: StyleProp matchContents?: boolean serverHeaders?: { url: string; headers: Record } | null @@ -81,11 +84,24 @@ function SessionRouteInner({ const [inlineQueuedMessages, setInlineQueuedMessages] = useState< Array >([]) + const [replyTarget, setReplyTarget] = useState( + null + ) const entityUrl = Array.isArray(params.entityUrl) ? params.entityUrl[0] : (params.entityUrl ?? ``) - const view = params.view === `state-explorer` ? `state-explorer` : `chat` + const view: EmbedViewId = + params.view === `state-explorer` + ? `state-explorer` + : params.view === `comments` + ? `comments` + : `chat` + + // Drop any pending reply target when the session or view changes. + useEffect(() => { + setReplyTarget(null) + }, [entityUrl, view]) // Read once per render — the DOM embed receives this as a prop and // re-registers it on its side of the JS-context boundary. @@ -93,7 +109,7 @@ function SessionRouteInner({ const embedTop = insets.top + HEADER_HEIGHT const composerInset = - view === `chat` + view !== `state-explorer` ? Math.max(0, chatComposerHeight + keyboardInset - CHAT_COMPOSER_OVERLAP) : 0 const embedFrame = useMemo( @@ -141,7 +157,7 @@ function SessionRouteInner({ { backgroundColor: tokens.bg }, ]} > - {view === `chat` ? ( + {view !== `state-explorer` ? ( openSession(target)} + onRequestReplyToComment={(target) => setReplyTarget(target)} dom={domOptions(styles, embedSize, tokens.bg)} /> ) : ( @@ -169,9 +187,10 @@ function SessionRouteInner({ )} - {view === `chat` ? ( + {view !== `state-explorer` ? ( setChatLogScrollSignal(Date.now())} onInlineQueuedMessagesChange={setInlineQueuedMessages} onShare={openShare} + commentTarget={replyTarget} + onClearCommentTarget={() => setReplyTarget(null)} /> ) : ( = { info: `M12 8v.01M11 12h1v4h1M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18Z`, swap: `M7 4l-3 3 3 3M4 7h13M17 14l3 3-3 3M20 17H7`, chat: `M4 4h16v12H8l-4 4Z`, + // Lucide `message-square` — distinct from the simpler `chat` bubble. + comment: `M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`, database: `M5 5c0-1.1 3.1-2 7-2s7 .9 7 2v14c0 1.1-3.1 2-7 2s-7-.9-7-2V5ZM5 12c0 1.1 3.1 2 7 2s7-.9 7-2`, radio: `M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M10.6 13.4a2 2 0 0 1 0-2.8M14 12h.01M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2`, 'arrow-up': `M12 19V5M5 12l7-7 7 7`, diff --git a/packages/agents-mobile/src/components/SessionMenu.tsx b/packages/agents-mobile/src/components/SessionMenu.tsx index 201dd55f0b..cb23182160 100644 --- a/packages/agents-mobile/src/components/SessionMenu.tsx +++ b/packages/agents-mobile/src/components/SessionMenu.tsx @@ -118,6 +118,7 @@ export function SessionMenu({ entity, view, onSetView, + commentsEnabled = false, signalError, onSignal, onStopImmediately, @@ -129,6 +130,8 @@ export function SessionMenu({ entity: ElectricEntity | null view: EmbedViewId onSetView: (view: EmbedViewId) => void + /** Show the Comments view entry — entity type declares the comments collection. */ + commentsEnabled?: boolean signalError?: string | null onSignal?: (signal: EntitySignal) => void onStopImmediately?: () => void @@ -341,6 +344,21 @@ export function SessionMenu({ active={view === `chat`} onPress={() => handlePick(`chat`)} /> + {commentsEnabled && ( + + } + active={view === `comments`} + onPress={() => handlePick(`comments`)} + /> + )} void onSetView: (view: EmbedViewId) => void onOpenEntity: (entityUrl: string) => void @@ -95,11 +103,13 @@ export function ChatSessionScreen({ messages: Array ) => void onShare?: () => void + commentTarget?: SelectedCommentTarget | null + onClearCommentTarget?: () => void }): React.ReactElement { return ( ) } @@ -154,6 +166,8 @@ export function SessionScreen({ onSendMessage, onInlineQueuedMessagesChange, onShare, + commentTarget = null, + onClearCommentTarget, }: { entityUrl: string view: EmbedViewId @@ -167,7 +181,11 @@ export function SessionScreen({ messages: Array ) => void onShare?: () => void + /** Reply target forwarded from the embed timeline (chat + comments views). */ + commentTarget?: SelectedCommentTarget | null + onClearCommentTarget?: () => void }): React.ReactElement { + const commentOnly = view === `comments` const { entitiesCollection, serverUrl, signalEntity } = useAgents() const tokens = useTokens() const styles = useMemo(() => createStyles(tokens), [tokens]) @@ -194,8 +212,10 @@ export function SessionScreen({ const canWrite = permissions.write const canSignal = permissions.signal const streamEntityUrl = - view === `chat` && entity?.status !== `spawning` ? entityUrl : null - const { timelineRows, pendingInbox, db, generationActive } = + view !== `state-explorer` && entity?.status !== `spawning` + ? entityUrl + : null + const { timelineRows, pendingInbox, db, generationActive, commentsEnabled } = useEntityTimeline(serverUrl, streamEntityUrl) const manifests = useMemo( () => @@ -392,7 +412,7 @@ export function SessionScreen({ /> - {view === `chat` && ( + {view !== `state-explorer` && ( void stopImmediately()} @@ -465,6 +490,10 @@ function NativeMessageComposer({ onStop, writeDisabled, stopDisabled, + commentsEnabled, + commentOnly, + commentTarget, + onClearCommentTarget, disabled, placeholder, }: { @@ -484,20 +513,50 @@ function NativeMessageComposer({ onStop: () => void writeDisabled: boolean stopDisabled: boolean + /** Entity type declares the comments collection — enables the comment toggle. */ + commentsEnabled: boolean + /** Comments-only view: lock the composer to comment mode, hide the toggle. */ + commentOnly: boolean + /** Reply target forwarded from the embed timeline. */ + commentTarget: SelectedCommentTarget | null + onClearCommentTarget?: () => void disabled: boolean placeholder: string }): React.ReactElement { const { serverUrl, entityTypesCollection } = useAgents() + const { principal } = useCurrentPrincipal() const tokens = useTokens() const insets = useSafeAreaInsets() const styles = useMemo(() => createComposerStyles(tokens), [tokens]) const { keyboardVisible, keyboardTranslateY } = useKeyboardAttachment() + const inputRef = useRef(null) const [value, setValue] = useState(``) const [sending, setSending] = useState(false) const [error, setError] = useState(null) + const [mode, setMode] = useState<`prompt` | `comment`>( + commentOnly ? `comment` : `prompt` + ) const [editingMessage, setEditingMessage] = useState<{ key: string } | null>(null) + // A reply target (from tapping a timeline row) or the comments-only view + // forces comment mode; focus the input so the user can type immediately. + useEffect(() => { + if (commentTarget) { + setMode(`comment`) + inputRef.current?.focus() + } + }, [commentTarget]) + useEffect(() => { + if (commentOnly) setMode(`comment`) + }, [commentOnly]) + // If the entity loses comment support (or write access), fall back to prompt. + useEffect(() => { + if (!commentOnly && (!commentsEnabled || writeDisabled)) setMode(`prompt`) + }, [commentOnly, commentsEnabled, writeDisabled]) + const commentMode = mode === `comment` + const showCommentToggle = commentsEnabled && !commentOnly && !editingMessage + const attachmentsAllowed = !commentMode const text = value.trim() const bottomPadding = keyboardVisible ? 4 : Math.max(insets.bottom, 8) // The per-entity slashCommands collection only carries dynamically-registered @@ -544,11 +603,16 @@ function NativeMessageComposer({ ), [matchingTypes, entity?.spawn_args] ) - const showAttach = imageInputSupported && attach.supported && !editingMessage + const showAttach = + imageInputSupported && + attach.supported && + !editingMessage && + attachmentsAllowed useEffect(() => { - if (!imageInputSupported) attach.clear() - }, [imageInputSupported, attach.clear]) - const hasDraftAttachments = attach.drafts.length > 0 && !editingMessage + if (!imageInputSupported || commentMode) attach.clear() + }, [imageInputSupported, commentMode, attach.clear]) + const hasDraftAttachments = + attach.drafts.length > 0 && !editingMessage && attachmentsAllowed const sendAction = useMemo(() => { if (!db) return null return createSendComposerInputAction({ @@ -562,6 +626,15 @@ function NativeMessageComposer({ }, }) }, [db, entityUrl, inlineQueuedSubmits, onOptimisticQueuedMessage, serverUrl]) + const sendCommentAction = useMemo(() => { + if (!db || !commentsEnabled) return null + return createSendCommentAction({ + db, + baseUrl: serverUrl, + entityUrl, + from: principal, + }) + }, [db, commentsEnabled, serverUrl, entityUrl, principal]) const updateAction = useMemo(() => { if (!db) return null return createUpdateInboxMessageAction({ db, baseUrl: serverUrl, entityUrl }) @@ -589,7 +662,7 @@ function NativeMessageComposer({ const inputDisabled = disabled || writeDisabled || sending const composerDisabled = disabled || (writeDisabled && !showStop) const slash = useSlashAutocomplete(value, slashCommands, { - enabled: !inputDisabled, + enabled: !inputDisabled && !commentMode, }) // Controls the caret for one render after a programmatic command insert, then // releases back to uncontrolled so normal typing isn't fought. @@ -619,6 +692,30 @@ function NativeMessageComposer({ setSending(true) setError(null) + if (commentMode) { + const tx = sendCommentAction?.({ + body: text, + ...(commentTarget + ? { + replyTo: commentTarget.target, + targetSnapshot: commentTarget.snapshot, + } + : {}), + }) + if (!tx) { + setSending(false) + return + } + + setValue(``) + setPendingSelection(null) + slash.reset() + onClearCommentTarget?.() + onSendMessage?.() + finishPersistedAction(tx.isPersisted.promise) + return + } + if (editingMessage) { const tx = updateAction?.({ key: editingMessage.key, @@ -767,7 +864,7 @@ function NativeMessageComposer({ ]} > {error && {error}} - {entity && ( + {entity && !commentOnly && ( )} + {commentMode && commentTarget && ( + + + + Replying to {commentTarget.snapshot.label} + + {commentTarget.snapshot.text ? ( + + {commentTarget.snapshot.text} + + ) : null} + + onClearCommentTarget?.()} + accessibilityRole="button" + accessibilityLabel="Cancel reply" + hitSlop={8} + > + + + + )} + {showCommentToggle && ( + + setMode(`prompt`)} + accessibilityRole="button" + accessibilityState={{ selected: !commentMode }} + style={[styles.modeButton, !commentMode && styles.modeButtonActive]} + > + + Message + + + setMode(`comment`)} + accessibilityRole="button" + accessibilityState={{ selected: commentMode }} + style={[styles.modeButton, commentMode && styles.modeButtonActive]} + > + + Comment + + + + )} {slash.open && ( )} @@ -812,6 +965,7 @@ function NativeMessageComposer({ /> )} { slash.onSelectionChange(event) @@ -820,7 +974,9 @@ function NativeMessageComposer({ selection={pendingSelection ?? undefined} editable={!inputDisabled} multiline - placeholder={placeholder} + placeholder={ + commentMode && !inputDisabled ? `Add a comment...` : placeholder + } placeholderTextColor={tokens.text4} // Size to content intrinsically (within the style's min/maxHeight) // rather than via onContentSizeChange — that callback never fires when @@ -841,7 +997,13 @@ function NativeMessageComposer({ disabled={showStop ? stopPending : !canSend} hitSlop={8} accessibilityRole="button" - accessibilityLabel={showStop ? `Stop generating` : `Send message`} + accessibilityLabel={ + showStop + ? `Stop generating` + : commentMode + ? `Post comment` + : `Send message` + } style={({ pressed }) => [ styles.sendButton, canSend || canStop ? styles.sendButtonActive : null, @@ -1674,6 +1836,59 @@ function createComposerStyles(tokens: Tokens) { lineHeight: lineHeight.xs, fontWeight: `600`, }, + replyBanner: { + marginBottom: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.lg, + backgroundColor: tokens.accentA2, + flexDirection: `row`, + alignItems: `center`, + justifyContent: `space-between`, + gap: spacing.sm, + }, + replyBannerBody: { + flex: 1, + }, + replyLabel: { + color: tokens.text2, + fontSize: fontSize.xs, + lineHeight: lineHeight.xs, + fontWeight: `600`, + }, + replyText: { + color: tokens.text3, + fontSize: fontSize.xs, + lineHeight: lineHeight.xs, + }, + modeToggle: { + flexDirection: `row`, + alignSelf: `flex-start`, + marginBottom: spacing.xs, + padding: 2, + borderRadius: radii.lg, + backgroundColor: tokens.surface, + borderWidth: 1, + borderColor: tokens.border1, + gap: 2, + }, + modeButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.md, + }, + modeButtonActive: { + backgroundColor: tokens.accentA3, + }, + modeButtonText: { + color: tokens.text3, + fontSize: fontSize.xs, + lineHeight: lineHeight.xs, + fontWeight: `600`, + }, + modeButtonTextActive: { + color: tokens.accent11, + }, }) } diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index b6b2509332..939186d67f 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -76,15 +76,27 @@ export function ChatLogView({ tileId, scrollToBottomSignal, inlineQueuedMessages = [], + commentsOnly = false, + onReplyToComment, }: ViewProps & { scrollToBottomSignal?: number inlineQueuedMessages?: Array + /** Render only the comment rows (with surrounding context), mirroring CommentsView. */ + commentsOnly?: boolean + /** + * Forwarded across the Expo-DOM boundary when the user taps "reply" on a + * timeline row — the native composer owns the reply target on mobile. + */ + onReplyToComment?: (target: SelectedCommentTarget) => void }): React.ReactElement { const connectUrl = isSpawning ? null : entityUrl const { timelineRows, pendingInbox, entities, db, loading, error } = useEntityTimeline(baseUrl || null, connectUrl) const canFork = useEntityPermission(entity, `fork`) const navigate = useNavigate() + // `onCommentTargetClick` jumps from a reply's snapshot to the original row. + // The embed router stubs out navigation, so we track the focus target locally. + const [focusTarget, setFocusTarget] = useState(null) const processedInboxKeys = useMemo( () => new Set( @@ -133,19 +145,29 @@ export function ChatLogView({ canFork, }) + const commentsTimeline = useMemo( + () => (commentsOnly ? buildCommentsTimeline(timelineRows) : null), + [commentsOnly, timelineRows] + ) + return ( setFocusTarget(null)} /> ) } diff --git a/packages/agents-server-ui/src/embed/EmbedApp.tsx b/packages/agents-server-ui/src/embed/EmbedApp.tsx index 22dd8621cb..1ad7179b3e 100644 --- a/packages/agents-server-ui/src/embed/EmbedApp.tsx +++ b/packages/agents-server-ui/src/embed/EmbedApp.tsx @@ -26,18 +26,24 @@ import { StateExplorerView } from '../components/views/StateExplorerView' import { registerActiveServerHeaders } from '../lib/auth-fetch' import styles from './EmbedApp.module.css' import type { OptimisticInboxMessage } from '../lib/sendMessage' +import type { SelectedCommentTarget } from '../lib/comments' const TILE_ID = `mobile-embed` type EmbedView = `chat` | `chat-log` | `state-explorer` type EmbedTheme = `light` | `dark` +/** Forwarded across the Expo-DOM boundary when a timeline row's "reply" is tapped. */ +type ReplyToCommentFn = (target: SelectedCommentTarget) => void + export type EmbedSessionProps = EmbedState & { onNavigatePathname?: (pathname: string) => void | Promise + onRequestReplyToComment?: ReplyToCommentFn } export function EmbedSessionRoot({ onNavigatePathname, + onRequestReplyToComment, ...state }: EmbedSessionProps): ReactElement { // Register the Cloud auth headers in THIS context's auth-fetch @@ -59,6 +65,7 @@ export function EmbedSessionRoot({ @@ -86,6 +93,8 @@ type EmbedState = { scrollToBottomSignal?: number inlineQueuedMessages?: Array bottomInset?: number + /** Render only the comment rows — the native shell's "comments" view. */ + commentsOnly?: boolean // Forwarded across the Expo-DOM boundary so the embed's auth-fetch // module instance (separate from the native side) can inject the // Cloud auth headers on every outbound request. `null` means no @@ -96,9 +105,14 @@ type EmbedState = { } | null } -const EmbedStateContext = createContext(null) +/** Context value: the serializable embed state plus the marshalled reply callback. */ +type EmbedRuntime = EmbedState & { + onReplyToComment?: ReplyToCommentFn +} + +const EmbedStateContext = createContext(null) -function useCurrentEmbedState(): EmbedState { +function useCurrentEmbedState(): EmbedRuntime { const state = useContext(EmbedStateContext) if (!state) { throw new Error(`useCurrentEmbedState must be used inside EmbeddedRouter`) @@ -119,9 +133,11 @@ function useCurrentEmbedState(): EmbedState { function EmbeddedRouter({ state, onNavigatePathname, + onReplyToComment, }: { state: EmbedState onNavigatePathname?: (pathname: string) => void | Promise + onReplyToComment?: ReplyToCommentFn }): ReactElement { const router = useMemo(() => { const rootRoute = createRootRoute({ @@ -151,8 +167,13 @@ function EmbeddedRouter({ return router }, [onNavigatePathname]) + const runtime = useMemo( + () => ({ ...state, onReplyToComment }), + [state, onReplyToComment] + ) + return ( - + ) @@ -162,7 +183,7 @@ function EmbedRouteSurface(): ReactElement { return } -function EmbedSurface({ state }: { state: EmbedState }): ReactElement { +function EmbedSurface({ state }: { state: EmbedRuntime }): ReactElement { const { entitiesCollection } = useElectricAgents() if (!state.entityUrl) { @@ -181,6 +202,8 @@ function EmbedSurface({ state }: { state: EmbedState }): ReactElement { scrollToBottomSignal={state.scrollToBottomSignal} inlineQueuedMessages={state.inlineQueuedMessages} bottomInset={state.bottomInset} + commentsOnly={state.commentsOnly} + onReplyToComment={state.onReplyToComment} /> ) } @@ -192,6 +215,8 @@ function EntityHost({ scrollToBottomSignal, inlineQueuedMessages, bottomInset, + commentsOnly, + onReplyToComment, }: { entityUrl: string view: EmbedView @@ -199,6 +224,8 @@ function EntityHost({ scrollToBottomSignal?: number inlineQueuedMessages?: Array bottomInset?: number + commentsOnly?: boolean + onReplyToComment?: ReplyToCommentFn }): ReactElement { const { entitiesCollection } = useElectricAgents() const { data: matches = [], isLoading } = useLiveQuery( @@ -259,6 +286,8 @@ function EntityHost({ {...props} scrollToBottomSignal={scrollToBottomSignal} inlineQueuedMessages={inlineQueuedMessages} + commentsOnly={commentsOnly} + onReplyToComment={onReplyToComment} /> ) From 8498c6755dcaaf83238c2fa2ae7d06bea374873d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 12:20:10 +0100 Subject: [PATCH 03/12] fix(agents-mobile): comment composer polish + edit-mode bug - Compact, right-aligned Message/Comment toggle pill (was a full-width orphaned row). - Reset to prompt mode when editing a queued message so the comment branch can't hijack an edit (mirrors desktop startEditing). - Match desktop reply-banner wording ("Reply to ..."). - Document the native/embed cross-context gap: optimistic comments render after stream sync, not instantly (follow-up: comment bridge). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/screens/SessionScreen.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index ecce10d377..963e76a6a4 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -473,6 +473,13 @@ export function SessionScreen({ ) } +// Mirrors agents-server-ui MessageInput's reply-banner label. +function formatReplyBannerLabel(target: SelectedCommentTarget): string { + const label = target.snapshot.label.trim() + if (!label) return `Reply` + return `Reply to ${label.charAt(0).toLowerCase()}${label.slice(1)}` +} + function NativeMessageComposer({ entityUrl, entity, @@ -626,6 +633,12 @@ function NativeMessageComposer({ }, }) }, [db, entityUrl, inlineQueuedSubmits, onOptimisticQueuedMessage, serverUrl]) + // NOTE: unlike desktop, the composer (native) and the timeline (WebView + // embed) run in separate JS contexts with separate stream `db`s, so the + // optimistic row this inserts into the native `db.collections.comments` + // isn't rendered — the comment appears once the embed's stream syncs it + // from the server. Queued messages bridge this via `inlineQueuedMessages`; + // an equivalent comment bridge is a follow-up. The POST + sync are correct. const sendCommentAction = useMemo(() => { if (!db || !commentsEnabled) return null return createSendCommentAction({ @@ -769,6 +782,9 @@ function NativeMessageComposer({ if (disabled || writeDisabled) return const queuedText = readTextPayload(message.payload) setError(null) + // Editing is a prompt-mode action; leave comment mode so `send()`'s + // comment branch can't hijack the edit (mirrors desktop `startEditing`). + if (!commentOnly) setMode(`prompt`) updateAction?.({ key: message.key, mode: `paused`, @@ -781,7 +797,7 @@ function NativeMessageComposer({ setPendingSelection(null) slash.reset() }, - [disabled, slash.reset, updateAction, writeDisabled] + [commentOnly, disabled, slash.reset, updateAction, writeDisabled] ) const cancelEditing = useCallback((): void => { @@ -896,7 +912,7 @@ function NativeMessageComposer({ - Replying to {commentTarget.snapshot.label} + {formatReplyBannerLabel(commentTarget)} {commentTarget.snapshot.text ? ( @@ -1863,19 +1879,19 @@ function createComposerStyles(tokens: Tokens) { }, modeToggle: { flexDirection: `row`, - alignSelf: `flex-start`, + alignSelf: `flex-end`, marginBottom: spacing.xs, padding: 2, - borderRadius: radii.lg, + borderRadius: radii.pill, backgroundColor: tokens.surface, borderWidth: 1, borderColor: tokens.border1, gap: 2, }, modeButton: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - borderRadius: radii.md, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: radii.pill, }, modeButtonActive: { backgroundColor: tokens.accentA3, From f598c781e68521b9bb2dd18de8db598d5c1bd720 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 12:54:55 +0100 Subject: [PATCH 04/12] fix(agents-mobile): optimistic comment bridge + switch above reply banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bridge optimistic comments from the native composer's db into the embed timeline (mirrors the inlineQueuedMessages bridge): forward still-pending comments and project them, deduped by key against synced comments. A posted comment now renders immediately and reconciles in place instead of popping in only after stream sync — desktop parity (shared ordering untouched). - Render the Message/Comment switch above the reply banner. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-mobile/app/session.tsx | 11 ++- .../src/screens/SessionScreen.tsx | 78 +++++++++++++------ .../src/components/views/ChatView.tsx | 54 ++++++++++--- .../agents-server-ui/src/embed/EmbedApp.tsx | 11 ++- 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/packages/agents-mobile/app/session.tsx b/packages/agents-mobile/app/session.tsx index bf9bb46a39..4276fd76d1 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -25,7 +25,10 @@ import SessionChatLogDomEmbedModule from '@electric-ax/agents-server-ui/src/embe import SessionStateInspectorDomEmbedModule from '@electric-ax/agents-server-ui/src/embed/SessionStateInspectorDomEmbed' import { getActiveServerHeadersSnapshot } from '@electric-ax/agents-server-ui/src/lib/auth-fetch' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' -import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '@electric-ax/agents-server-ui/src/lib/comments' const HEADER_HEIGHT = 44 @@ -35,6 +38,7 @@ type SessionDomEmbedProps = { theme: `light` | `dark` scrollToBottomSignal?: number inlineQueuedMessages?: Array + inlineComments?: Array bottomInset?: number commentsOnly?: boolean onRequestOpenEntity: (entityUrl: string) => Promise @@ -84,6 +88,9 @@ function SessionRouteInner({ const [inlineQueuedMessages, setInlineQueuedMessages] = useState< Array >([]) + const [inlineComments, setInlineComments] = useState< + Array + >([]) const [replyTarget, setReplyTarget] = useState( null ) @@ -166,6 +173,7 @@ function SessionRouteInner({ theme={scheme} scrollToBottomSignal={chatLogScrollSignal} inlineQueuedMessages={inlineQueuedMessages} + inlineComments={inlineComments} bottomInset={composerInset} commentsOnly={view === `comments`} serverHeaders={serverHeaders} @@ -198,6 +206,7 @@ function SessionRouteInner({ onComposerHeightChange={setChatComposerHeight} onSendMessage={() => setChatLogScrollSignal(Date.now())} onInlineQueuedMessagesChange={setInlineQueuedMessages} + onInlineCommentsChange={setInlineComments} onShare={openShare} commentTarget={replyTarget} onClearCommentTarget={() => setReplyTarget(null)} diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index 963e76a6a4..92cd5b4c60 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -27,7 +27,10 @@ import { } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import { createSendCommentAction } from '@electric-ax/agents-server-ui/src/lib/comments' -import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '@electric-ax/agents-server-ui/src/lib/comments' import { serializeComposerInput } from '@electric-ax/agents-runtime/client' import type { EntityTimelineQueryRow, @@ -86,6 +89,7 @@ export function ChatSessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onInlineCommentsChange, onShare, commentTarget, onClearCommentTarget, @@ -102,6 +106,7 @@ export function ChatSessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + onInlineCommentsChange?: (comments: Array) => void onShare?: () => void commentTarget?: SelectedCommentTarget | null onClearCommentTarget?: () => void @@ -117,6 +122,7 @@ export function ChatSessionScreen({ onComposerHeightChange={onComposerHeightChange} onSendMessage={onSendMessage} onInlineQueuedMessagesChange={onInlineQueuedMessagesChange} + onInlineCommentsChange={onInlineCommentsChange} onShare={onShare} commentTarget={commentTarget} onClearCommentTarget={onClearCommentTarget} @@ -165,6 +171,7 @@ export function SessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onInlineCommentsChange, onShare, commentTarget = null, onClearCommentTarget, @@ -180,6 +187,12 @@ export function SessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + /** + * Optimistic comments (pending, not yet synced) forwarded to the embed so a + * posted comment renders immediately — the composer's `db` is a separate JS + * context from the embed's, so its optimistic insert isn't otherwise visible. + */ + onInlineCommentsChange?: (comments: Array) => void onShare?: () => void /** Reply target forwarded from the embed timeline (chat + comments views). */ commentTarget?: SelectedCommentTarget | null @@ -328,6 +341,25 @@ export function SessionScreen({ onInlineQueuedMessagesChange?.(Array.from(inlineQueuedMessages.values())) }, [inlineQueuedMessages, onInlineQueuedMessagesChange]) + // Optimistic comments the composer inserted into our (native) db but that + // the embed timeline — a separate JS context — can't see yet. Forward the + // still-pending ones so the embed projects them until its own stream syncs + // the authoritative row. `~pending` is the optimistic timeline-order prefix. + const inlineComments = useMemo( + () => + timelineRows + .filter((row) => row.comment?.order?.startsWith(`~pending`)) + .map((row) => row.comment!), + [timelineRows] + ) + const lastInlineCommentSigRef = useRef(``) + useEffect(() => { + const signature = inlineComments.map((comment) => comment.key).join(`\0`) + if (signature === lastInlineCommentSigRef.current) return + lastInlineCommentSigRef.current = signature + onInlineCommentsChange?.(inlineComments) + }, [inlineComments, onInlineCommentsChange]) + useEffect(() => { if (!generationActive) setStopPending(false) }, [generationActive]) @@ -908,28 +940,6 @@ function NativeMessageComposer({ )} - {commentMode && commentTarget && ( - - - - {formatReplyBannerLabel(commentTarget)} - - {commentTarget.snapshot.text ? ( - - {commentTarget.snapshot.text} - - ) : null} - - onClearCommentTarget?.()} - accessibilityRole="button" - accessibilityLabel="Cancel reply" - hitSlop={8} - > - - - - )} {showCommentToggle && ( )} + {commentMode && commentTarget && ( + + + + {formatReplyBannerLabel(commentTarget)} + + {commentTarget.snapshot.text ? ( + + {commentTarget.snapshot.text} + + ) : null} + + onClearCommentTarget?.()} + accessibilityRole="button" + accessibilityLabel="Cancel reply" + hitSlop={8} + > + + + + )} {slash.open && ( )} diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 939186d67f..10a85c0988 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -16,7 +16,11 @@ import { commentFocusViewParams, decodeCommentTargetParam, } from '../../lib/comments' -import type { SelectedCommentTarget, TimelineRow } from '../../lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, + TimelineRow, +} from '../../lib/comments' import { useEntityPermission, useEntityPermissions, @@ -76,11 +80,20 @@ export function ChatLogView({ tileId, scrollToBottomSignal, inlineQueuedMessages = [], + inlineComments = [], commentsOnly = false, onReplyToComment, }: ViewProps & { scrollToBottomSignal?: number inlineQueuedMessages?: Array + /** + * Optimistic comments from the native composer, forwarded across the + * Expo-DOM boundary because the composer's `db` is a separate JS context + * from this embed's. Projected into the timeline (deduped by key against + * synced comments) so a posted comment shows immediately, mirroring desktop + * where composer and timeline share one `db`. + */ + inlineComments?: Array /** Render only the comment rows (with surrounding context), mirroring CommentsView. */ commentsOnly?: boolean /** @@ -121,16 +134,33 @@ export function ChatLogView({ pendingInboxByKey, processedInboxKeys, ]) + // Project optimistic comments that haven't synced yet (deduped by key against + // comments already in the timeline) so they render before the stream catches up. + const projectedComments = useMemo>(() => { + if (inlineComments.length === 0) return [] + const syncedKeys = new Set( + timelineRows.filter((row) => row.comment).map((row) => row.comment!.key) + ) + return inlineComments + .filter((comment) => !syncedKeys.has(comment.key)) + .map( + (comment) => + ({ $key: `pending-comment:${comment.key}`, comment }) as TimelineRow + ) + }, [inlineComments, timelineRows]) + const visibleRows = useMemo>(() => { - if (!projectedPendingMessage) return timelineRows - return [ - ...timelineRows, - { - $key: `pending-inbox:${projectedPendingMessage.key}`, - inbox: projectedPendingMessage, - } as TimelineRow, - ] - }, [projectedPendingMessage, timelineRows]) + const base = projectedPendingMessage + ? [ + ...timelineRows, + { + $key: `pending-inbox:${projectedPendingMessage.key}`, + inbox: projectedPendingMessage, + } as TimelineRow, + ] + : timelineRows + return projectedComments.length > 0 ? [...base, ...projectedComments] : base + }, [projectedPendingMessage, timelineRows, projectedComments]) useEffect(() => { if (error && !isSpawning) { @@ -146,8 +176,8 @@ export function ChatLogView({ }) const commentsTimeline = useMemo( - () => (commentsOnly ? buildCommentsTimeline(timelineRows) : null), - [commentsOnly, timelineRows] + () => (commentsOnly ? buildCommentsTimeline(visibleRows) : null), + [commentsOnly, visibleRows] ) return ( diff --git a/packages/agents-server-ui/src/embed/EmbedApp.tsx b/packages/agents-server-ui/src/embed/EmbedApp.tsx index 1ad7179b3e..0fc8364e27 100644 --- a/packages/agents-server-ui/src/embed/EmbedApp.tsx +++ b/packages/agents-server-ui/src/embed/EmbedApp.tsx @@ -26,7 +26,10 @@ import { StateExplorerView } from '../components/views/StateExplorerView' import { registerActiveServerHeaders } from '../lib/auth-fetch' import styles from './EmbedApp.module.css' import type { OptimisticInboxMessage } from '../lib/sendMessage' -import type { SelectedCommentTarget } from '../lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '../lib/comments' const TILE_ID = `mobile-embed` @@ -92,6 +95,8 @@ type EmbedState = { theme: EmbedTheme scrollToBottomSignal?: number inlineQueuedMessages?: Array + /** Optimistic comments from the native composer (separate JS context). */ + inlineComments?: Array bottomInset?: number /** Render only the comment rows — the native shell's "comments" view. */ commentsOnly?: boolean @@ -201,6 +206,7 @@ function EmbedSurface({ state }: { state: EmbedRuntime }): ReactElement { serverUrl={state.serverUrl} scrollToBottomSignal={state.scrollToBottomSignal} inlineQueuedMessages={state.inlineQueuedMessages} + inlineComments={state.inlineComments} bottomInset={state.bottomInset} commentsOnly={state.commentsOnly} onReplyToComment={state.onReplyToComment} @@ -214,6 +220,7 @@ function EntityHost({ serverUrl, scrollToBottomSignal, inlineQueuedMessages, + inlineComments, bottomInset, commentsOnly, onReplyToComment, @@ -223,6 +230,7 @@ function EntityHost({ serverUrl: string scrollToBottomSignal?: number inlineQueuedMessages?: Array + inlineComments?: Array bottomInset?: number commentsOnly?: boolean onReplyToComment?: ReplyToCommentFn @@ -286,6 +294,7 @@ function EntityHost({ {...props} scrollToBottomSignal={scrollToBottomSignal} inlineQueuedMessages={inlineQueuedMessages} + inlineComments={inlineComments} commentsOnly={commentsOnly} onReplyToComment={onReplyToComment} /> From 47a4bcff7139b9b2db1043142263af08e2658a24 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 13:47:42 +0100 Subject: [PATCH 05/12] fix(agents-mobile): revert optimistic comment bridge for ordering parity; move switch atop composer - Revert the optimistic-comment bridge: it appended projected comments rather than letting the shared createEntityTimelineQuery sort them, diverging from desktop ordering. Mobile now uses the identical shared timeline path, so message/comment ordering matches the desktop app. (Trade-off, accepted: on a device a posted comment appears on stream sync rather than optimistically.) - Move the Message/Comment switch to the top of the composer card so it no longer sits in the seam between the queued drawer and the input. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-mobile/app/session.tsx | 11 +-- .../src/screens/SessionScreen.tsx | 90 ++++++------------- .../src/components/views/ChatView.tsx | 54 +++-------- .../agents-server-ui/src/embed/EmbedApp.tsx | 11 +-- 4 files changed, 43 insertions(+), 123 deletions(-) diff --git a/packages/agents-mobile/app/session.tsx b/packages/agents-mobile/app/session.tsx index 4276fd76d1..bf9bb46a39 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -25,10 +25,7 @@ import SessionChatLogDomEmbedModule from '@electric-ax/agents-server-ui/src/embe import SessionStateInspectorDomEmbedModule from '@electric-ax/agents-server-ui/src/embed/SessionStateInspectorDomEmbed' import { getActiveServerHeadersSnapshot } from '@electric-ax/agents-server-ui/src/lib/auth-fetch' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' -import type { - EntityTimelineCommentRow, - SelectedCommentTarget, -} from '@electric-ax/agents-server-ui/src/lib/comments' +import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' const HEADER_HEIGHT = 44 @@ -38,7 +35,6 @@ type SessionDomEmbedProps = { theme: `light` | `dark` scrollToBottomSignal?: number inlineQueuedMessages?: Array - inlineComments?: Array bottomInset?: number commentsOnly?: boolean onRequestOpenEntity: (entityUrl: string) => Promise @@ -88,9 +84,6 @@ function SessionRouteInner({ const [inlineQueuedMessages, setInlineQueuedMessages] = useState< Array >([]) - const [inlineComments, setInlineComments] = useState< - Array - >([]) const [replyTarget, setReplyTarget] = useState( null ) @@ -173,7 +166,6 @@ function SessionRouteInner({ theme={scheme} scrollToBottomSignal={chatLogScrollSignal} inlineQueuedMessages={inlineQueuedMessages} - inlineComments={inlineComments} bottomInset={composerInset} commentsOnly={view === `comments`} serverHeaders={serverHeaders} @@ -206,7 +198,6 @@ function SessionRouteInner({ onComposerHeightChange={setChatComposerHeight} onSendMessage={() => setChatLogScrollSignal(Date.now())} onInlineQueuedMessagesChange={setInlineQueuedMessages} - onInlineCommentsChange={setInlineComments} onShare={openShare} commentTarget={replyTarget} onClearCommentTarget={() => setReplyTarget(null)} diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index 92cd5b4c60..f62cb320b1 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -27,10 +27,7 @@ import { } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import { createSendCommentAction } from '@electric-ax/agents-server-ui/src/lib/comments' -import type { - EntityTimelineCommentRow, - SelectedCommentTarget, -} from '@electric-ax/agents-server-ui/src/lib/comments' +import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' import { serializeComposerInput } from '@electric-ax/agents-runtime/client' import type { EntityTimelineQueryRow, @@ -89,7 +86,6 @@ export function ChatSessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, - onInlineCommentsChange, onShare, commentTarget, onClearCommentTarget, @@ -106,7 +102,6 @@ export function ChatSessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void - onInlineCommentsChange?: (comments: Array) => void onShare?: () => void commentTarget?: SelectedCommentTarget | null onClearCommentTarget?: () => void @@ -122,7 +117,6 @@ export function ChatSessionScreen({ onComposerHeightChange={onComposerHeightChange} onSendMessage={onSendMessage} onInlineQueuedMessagesChange={onInlineQueuedMessagesChange} - onInlineCommentsChange={onInlineCommentsChange} onShare={onShare} commentTarget={commentTarget} onClearCommentTarget={onClearCommentTarget} @@ -171,7 +165,6 @@ export function SessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, - onInlineCommentsChange, onShare, commentTarget = null, onClearCommentTarget, @@ -187,12 +180,6 @@ export function SessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void - /** - * Optimistic comments (pending, not yet synced) forwarded to the embed so a - * posted comment renders immediately — the composer's `db` is a separate JS - * context from the embed's, so its optimistic insert isn't otherwise visible. - */ - onInlineCommentsChange?: (comments: Array) => void onShare?: () => void /** Reply target forwarded from the embed timeline (chat + comments views). */ commentTarget?: SelectedCommentTarget | null @@ -341,25 +328,6 @@ export function SessionScreen({ onInlineQueuedMessagesChange?.(Array.from(inlineQueuedMessages.values())) }, [inlineQueuedMessages, onInlineQueuedMessagesChange]) - // Optimistic comments the composer inserted into our (native) db but that - // the embed timeline — a separate JS context — can't see yet. Forward the - // still-pending ones so the embed projects them until its own stream syncs - // the authoritative row. `~pending` is the optimistic timeline-order prefix. - const inlineComments = useMemo( - () => - timelineRows - .filter((row) => row.comment?.order?.startsWith(`~pending`)) - .map((row) => row.comment!), - [timelineRows] - ) - const lastInlineCommentSigRef = useRef(``) - useEffect(() => { - const signature = inlineComments.map((comment) => comment.key).join(`\0`) - if (signature === lastInlineCommentSigRef.current) return - lastInlineCommentSigRef.current = signature - onInlineCommentsChange?.(inlineComments) - }, [inlineComments, onInlineCommentsChange]) - useEffect(() => { if (!generationActive) setStopPending(false) }, [generationActive]) @@ -912,34 +880,6 @@ function NativeMessageComposer({ ]} > {error && {error}} - {entity && !commentOnly && ( - - )} - {editingMessage && ( - - Editing queued message - - Cancel - - - )} {showCommentToggle && ( )} + {entity && !commentOnly && ( + + )} + {editingMessage && ( + + Editing queued message + + Cancel + + + )} {commentMode && commentTarget && ( diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 10a85c0988..939186d67f 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -16,11 +16,7 @@ import { commentFocusViewParams, decodeCommentTargetParam, } from '../../lib/comments' -import type { - EntityTimelineCommentRow, - SelectedCommentTarget, - TimelineRow, -} from '../../lib/comments' +import type { SelectedCommentTarget, TimelineRow } from '../../lib/comments' import { useEntityPermission, useEntityPermissions, @@ -80,20 +76,11 @@ export function ChatLogView({ tileId, scrollToBottomSignal, inlineQueuedMessages = [], - inlineComments = [], commentsOnly = false, onReplyToComment, }: ViewProps & { scrollToBottomSignal?: number inlineQueuedMessages?: Array - /** - * Optimistic comments from the native composer, forwarded across the - * Expo-DOM boundary because the composer's `db` is a separate JS context - * from this embed's. Projected into the timeline (deduped by key against - * synced comments) so a posted comment shows immediately, mirroring desktop - * where composer and timeline share one `db`. - */ - inlineComments?: Array /** Render only the comment rows (with surrounding context), mirroring CommentsView. */ commentsOnly?: boolean /** @@ -134,33 +121,16 @@ export function ChatLogView({ pendingInboxByKey, processedInboxKeys, ]) - // Project optimistic comments that haven't synced yet (deduped by key against - // comments already in the timeline) so they render before the stream catches up. - const projectedComments = useMemo>(() => { - if (inlineComments.length === 0) return [] - const syncedKeys = new Set( - timelineRows.filter((row) => row.comment).map((row) => row.comment!.key) - ) - return inlineComments - .filter((comment) => !syncedKeys.has(comment.key)) - .map( - (comment) => - ({ $key: `pending-comment:${comment.key}`, comment }) as TimelineRow - ) - }, [inlineComments, timelineRows]) - const visibleRows = useMemo>(() => { - const base = projectedPendingMessage - ? [ - ...timelineRows, - { - $key: `pending-inbox:${projectedPendingMessage.key}`, - inbox: projectedPendingMessage, - } as TimelineRow, - ] - : timelineRows - return projectedComments.length > 0 ? [...base, ...projectedComments] : base - }, [projectedPendingMessage, timelineRows, projectedComments]) + if (!projectedPendingMessage) return timelineRows + return [ + ...timelineRows, + { + $key: `pending-inbox:${projectedPendingMessage.key}`, + inbox: projectedPendingMessage, + } as TimelineRow, + ] + }, [projectedPendingMessage, timelineRows]) useEffect(() => { if (error && !isSpawning) { @@ -176,8 +146,8 @@ export function ChatLogView({ }) const commentsTimeline = useMemo( - () => (commentsOnly ? buildCommentsTimeline(visibleRows) : null), - [commentsOnly, visibleRows] + () => (commentsOnly ? buildCommentsTimeline(timelineRows) : null), + [commentsOnly, timelineRows] ) return ( diff --git a/packages/agents-server-ui/src/embed/EmbedApp.tsx b/packages/agents-server-ui/src/embed/EmbedApp.tsx index 0fc8364e27..1ad7179b3e 100644 --- a/packages/agents-server-ui/src/embed/EmbedApp.tsx +++ b/packages/agents-server-ui/src/embed/EmbedApp.tsx @@ -26,10 +26,7 @@ import { StateExplorerView } from '../components/views/StateExplorerView' import { registerActiveServerHeaders } from '../lib/auth-fetch' import styles from './EmbedApp.module.css' import type { OptimisticInboxMessage } from '../lib/sendMessage' -import type { - EntityTimelineCommentRow, - SelectedCommentTarget, -} from '../lib/comments' +import type { SelectedCommentTarget } from '../lib/comments' const TILE_ID = `mobile-embed` @@ -95,8 +92,6 @@ type EmbedState = { theme: EmbedTheme scrollToBottomSignal?: number inlineQueuedMessages?: Array - /** Optimistic comments from the native composer (separate JS context). */ - inlineComments?: Array bottomInset?: number /** Render only the comment rows — the native shell's "comments" view. */ commentsOnly?: boolean @@ -206,7 +201,6 @@ function EmbedSurface({ state }: { state: EmbedRuntime }): ReactElement { serverUrl={state.serverUrl} scrollToBottomSignal={state.scrollToBottomSignal} inlineQueuedMessages={state.inlineQueuedMessages} - inlineComments={state.inlineComments} bottomInset={state.bottomInset} commentsOnly={state.commentsOnly} onReplyToComment={state.onReplyToComment} @@ -220,7 +214,6 @@ function EntityHost({ serverUrl, scrollToBottomSignal, inlineQueuedMessages, - inlineComments, bottomInset, commentsOnly, onReplyToComment, @@ -230,7 +223,6 @@ function EntityHost({ serverUrl: string scrollToBottomSignal?: number inlineQueuedMessages?: Array - inlineComments?: Array bottomInset?: number commentsOnly?: boolean onReplyToComment?: ReplyToCommentFn @@ -294,7 +286,6 @@ function EntityHost({ {...props} scrollToBottomSignal={scrollToBottomSignal} inlineQueuedMessages={inlineQueuedMessages} - inlineComments={inlineComments} commentsOnly={commentsOnly} onReplyToComment={onReplyToComment} /> From 31207a99f9bb92a3a8cad8c1e33715e98fce0d9a Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 14:00:21 +0100 Subject: [PATCH 06/12] fix(agents-mobile): move reply banner above queued drawer (out of seam) The reply banner sat between the queued drawer's open bottom and the input, breaking that connection. Render it above the drawer so the drawer connects directly to the input again: toggle, reply banner, drawer, input. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/screens/SessionScreen.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index f62cb320b1..e6cda2c7e0 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -914,6 +914,28 @@ function NativeMessageComposer({ )} + {commentMode && commentTarget && ( + + + + {formatReplyBannerLabel(commentTarget)} + + {commentTarget.snapshot.text ? ( + + {commentTarget.snapshot.text} + + ) : null} + + onClearCommentTarget?.()} + accessibilityRole="button" + accessibilityLabel="Cancel reply" + hitSlop={8} + > + + + + )} {entity && !commentOnly && ( )} - {commentMode && commentTarget && ( - - - - {formatReplyBannerLabel(commentTarget)} - - {commentTarget.snapshot.text ? ( - - {commentTarget.snapshot.text} - - ) : null} - - onClearCommentTarget?.()} - accessibilityRole="button" - accessibilityLabel="Cancel reply" - hitSlop={8} - > - - - - )} {slash.open && ( )} From fa1bf87bd4d6fc66701e00f2ce94c518d2972eee Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 14:06:43 +0100 Subject: [PATCH 07/12] chore: prettier-format native-app-comments design doc Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-15-native-app-comments-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-06-15-native-app-comments-design.md b/docs/superpowers/specs/2026-06-15-native-app-comments-design.md index 9c6dc081dd..32ce535abe 100644 --- a/docs/superpowers/specs/2026-06-15-native-app-comments-design.md +++ b/docs/superpowers/specs/2026-06-15-native-app-comments-design.md @@ -36,7 +36,7 @@ The Expo-DOM bridge is simple and one-directional per channel: Because `ChatLogView` already calls `useEntityTimeline(baseUrl, connectUrl)` with no opts, and `useEntityTimeline` defaults `comments` on when `commentsEnabled`, **comment bubbles already render in the mobile chat log today**. What is missing on mobile is the -*write* path, the *reply* affordance, and the *comments-only* view. +_write_ path, the _reply_ affordance, and the _comments-only_ view. ## Reused, unchanged @@ -94,15 +94,15 @@ Extend `NativeMessageComposer` (in `SessionScreen.tsx`) with a plain text, matching desktop). - Wire `createSendCommentAction({ db, baseUrl: serverUrl, entityUrl, from })` where `from` comes from `useCurrentPrincipal`. The optimistic insert + `POST - /collections/comments` is reused verbatim; on send, bump the existing +/collections/comments` is reused verbatim; on send, bump the existing `onSendMessage` scroll signal. - **Reply target state**: a `selectedCommentTarget: SelectedCommentTarget | null` lives in the native session screen. When `onRequestReplyToComment` fires from the bridge: set the target, switch the composer to comment mode, focus the input, and render a native **reply banner** (snapshot label + truncated text + clear button), mirroring desktop. Clearing the target drops back to a top-level comment. The target - + snapshot are passed into `createSendCommentAction`'s call as `replyTo` / - `targetSnapshot`. + - snapshot are passed into `createSendCommentAction`'s call as `replyTo` / + `targetSnapshot`. ### 4. Comments-only view (parity) From 6416e5d7fde23b946c923cba030800588c5440dc Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 14:10:03 +0100 Subject: [PATCH 08/12] chore: remove native-app-comments design doc Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-native-app-comments-design.md | 152 ------------------ 1 file changed, 152 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-15-native-app-comments-design.md diff --git a/docs/superpowers/specs/2026-06-15-native-app-comments-design.md b/docs/superpowers/specs/2026-06-15-native-app-comments-design.md deleted file mode 100644 index 32ce535abe..0000000000 --- a/docs/superpowers/specs/2026-06-15-native-app-comments-design.md +++ /dev/null @@ -1,152 +0,0 @@ -# Native app comments — design - -## Goal - -Bring the desktop comments feature (shipped in #4551, "generic externally-writable -custom collections, comments as first consumer") to the native app -(`packages/agents-mobile`) at full desktop parity: - -1. **Read** comment bubbles inline in the session timeline. -2. **Post** top-level comments from the composer. -3. **Reply** to a specific timeline row or comment (with a target snapshot). -4. A dedicated **comments-only view**. - -The runtime and server are already generic and done; this is a client-only change -in `agents-mobile` plus small additive threading in the shared `agents-server-ui` -embed/components. No backend changes. - -## Background: how the mobile session screen is built - -Unlike desktop, the mobile session screen is a **split**: - -- The **timeline** is rendered by a shared web component running inside an Expo-DOM - WebView: `app/session.tsx` mounts `SessionChatLogDomEmbed` - (`agents-server-ui/src/embed/SessionChatLogDomEmbed`) → - `EmbedChatLogRoot` → `EntityHost` (view `chat-log`) → `ChatLogView` - (`agents-server-ui/src/components/views/ChatView.tsx`) → `EntityTimeline`. -- The **composer** is native: `SessionScreen.tsx`'s `NativeMessageComposer`. - -The Expo-DOM bridge is simple and one-directional per channel: - -- **Native → WebView**: props (`serverUrl`, `entityUrl`, `scrollToBottomSignal`, - `inlineQueuedMessages`, `bottomInset`, `serverHeaders`, …). -- **WebView → native**: marshalled async callback props. Today only - `onRequestOpenEntity(entityUrl)` exists. - -Because `ChatLogView` already calls `useEntityTimeline(baseUrl, connectUrl)` with no -opts, and `useEntityTimeline` defaults `comments` on when `commentsEnabled`, **comment -bubbles already render in the mobile chat log today**. What is missing on mobile is the -_write_ path, the _reply_ affordance, and the _comments-only_ view. - -## Reused, unchanged - -- Runtime/server: generic externally-writable collections, `commentsCollection`, - `/collections/comments` endpoint, `_principal` virtual column. No changes. -- `agents-server-ui/src/lib/comments.ts`: `createSendCommentAction`, - `createCommentsTimelineSource`, `buildCommentsTimeline`, target encode/decode, - `EntityTimelineCommentRow`, `SelectedCommentTarget`. Imported by mobile as-is. -- `agents-server-ui/src/lib/comments-capability.ts`: `supportsComments` / - contract gating. Surfaced to mobile via `useEntityTimeline().commentsEnabled`. -- `agents-server-ui/src/lib/principals.ts`, `useCurrentPrincipal` (mobile already - has it) for the `from` author. -- `EntityTimeline` + `CommentBubble` rendering and their CSS — bubbles inherit the - shared embed styles, no native restyle. - -## Changes - -### 1. Capability gating (mobile) - -`SessionScreen` already destructures from `useEntityTimeline`. Also read -`commentsEnabled`. Gate all comment UI on `commentsEnabled && canWrite` -(`canWrite` already computed from `useEntityPermissions` against `SESSION_PERMISSIONS`, -which includes `write`). No new collection registration — `entity-connection` -registers `db.collections.comments` from entity metadata for both the embed and the -native hook. - -### 2. Reply affordance: WebView → native bridge - -Add one new optional callback prop, threaded through the shared embed: - -``` -SessionChatLogDomEmbed (new prop: onRequestReplyToComment?) - → EmbedChatLogRoot / EmbedSurfaceProps - → EntityHost / ChatLogView (new optional prop: onReplyToRow passthrough) - → EntityTimeline.onReplyToRow / onCommentTargetClick -``` - -- The callback signature carries the reply target and snapshot: - `onRequestReplyToComment(target: CommentTarget, snapshot: CommentSnapshot)`. - Both are plain JSON and marshal across the Expo-DOM boundary cleanly. -- `ChatLogView` gains an **optional** `onReplyToRow` (and `onCommentTargetClick`) - prop. When absent (desktop tile usage) behaviour is unchanged; when present (mobile - embed) it enables the reply button on comment/timeline rows and forwards the target. -- Desktop callers of `ChatLogView` are unaffected (new props are optional and unset). - -### 3. Native composer comment mode - -Extend `NativeMessageComposer` (in `SessionScreen.tsx`) with a -`'prompt' | 'comment'` mode, mirroring desktop `MessageInput`: - -- Mode toggle rendered only when `commentsEnabled && canWrite` and not editing a - queued message. Hidden otherwise (status quo for non-comment entities). -- In comment mode: placeholder "Add a comment…", send posts a comment instead of a - composer input; image attachments / slash autocomplete are disabled (comments are - plain text, matching desktop). -- Wire `createSendCommentAction({ db, baseUrl: serverUrl, entityUrl, from })` where - `from` comes from `useCurrentPrincipal`. The optimistic insert + `POST -/collections/comments` is reused verbatim; on send, bump the existing - `onSendMessage` scroll signal. -- **Reply target state**: a `selectedCommentTarget: SelectedCommentTarget | null` - lives in the native session screen. When `onRequestReplyToComment` fires from the - bridge: set the target, switch the composer to comment mode, focus the input, and - render a native **reply banner** (snapshot label + truncated text + clear button), - mirroring desktop. Clearing the target drops back to a top-level comment. The target - - snapshot are passed into `createSendCommentAction`'s call as `replyTo` / - `targetSnapshot`. - -### 4. Comments-only view (parity) - -- Add `'comments'` to `EmbedViewId` (`src/lib/embedView.ts`). -- **Embed side**: add a `commentsOnly?: boolean` prop on the chat-log embed - (`SessionChatLogDomEmbed` → `EmbedChatLogRoot` → `ChatLogView`). When set, - `ChatLogView` renders `buildCommentsTimeline(timelineRows)` (filtered rows + - adjacency) instead of the full timeline. This reuses the existing component/embed - path rather than introducing a separate embed module. -- **Native side**: in `app/session.tsx`, when `view === 'comments'`, pass - `commentsOnly` to the embed and render the native composer in **comment-only** mode - (always comment, no prompt toggle), mirroring desktop `CommentsView`. -- **Entry point**: `SessionMenu`'s view switcher (currently chat / state-explorer) - gains a "Comments" entry, shown only when the entity `commentsEnabled`. - -### 5. Tests - -Following the repo's tight-test style: - -- Native comment-send path: composing in comment mode invokes - `createSendCommentAction` with the expected `{ body, replyTo?, targetSnapshot? }`, - and clears/keeps the reply target correctly. -- Bridge serialization: a reply target round-trips through the - `onRequestReplyToComment` callback shape (target + snapshot are JSON-safe). -- Capability gating: comment toggle / comments view hidden when `commentsEnabled` is - false or `canWrite` is false. - -## Out of scope - -- No backend / runtime / server changes. -- No rich-text in comments (desktop comments are plain text too). -- No new comment data shape — reuse `commentsCollection` / `comments/v1` contract. - -## Files touched (anticipated) - -- `packages/agents-mobile/src/screens/SessionScreen.tsx` — composer comment mode, - reply banner, reply-target state, `commentsEnabled` gating. -- `packages/agents-mobile/app/session.tsx` — `'comments'` view wiring, - `onRequestReplyToComment` callback, `commentsOnly` prop pass-through. -- `packages/agents-mobile/src/lib/embedView.ts` — add `'comments'`. -- `packages/agents-mobile/src/components/SessionMenu.tsx` — comments view entry. -- `packages/agents-server-ui/src/embed/SessionChatLogDomEmbed.tsx` & - `embed/EmbedApp.tsx` — new optional `onRequestReplyToComment` / `commentsOnly` - props threaded through. -- `packages/agents-server-ui/src/components/views/ChatView.tsx` — optional - `onReplyToRow` / `onCommentTargetClick` / `commentsOnly` on `ChatLogView`. -- Tests alongside the above. From 473a4e8ab93f6a42584c70c4d22c13d10ff50599 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 14:17:40 +0100 Subject: [PATCH 09/12] feat(agents-mobile): optimistic comment bridge Restore the optimistic-comment bridge so a posted comment renders immediately instead of waiting for the embed's stream to sync. The native composer's optimistic insert lands in its own (native) db; the still-pending comments are forwarded across the Expo-DOM boundary and projected into the embed timeline (deduped by key against synced rows), mirroring the existing inlineQueuedMessages bridge. The `~pending` orders keep them in the same bottom band the shared query uses, so ordering still matches desktop. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-mobile/app/session.tsx | 11 +++- .../src/screens/SessionScreen.tsx | 34 ++++++++++- .../src/components/views/ChatView.tsx | 57 +++++++++++++++---- .../agents-server-ui/src/embed/EmbedApp.tsx | 11 +++- 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/packages/agents-mobile/app/session.tsx b/packages/agents-mobile/app/session.tsx index bf9bb46a39..4276fd76d1 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -25,7 +25,10 @@ import SessionChatLogDomEmbedModule from '@electric-ax/agents-server-ui/src/embe import SessionStateInspectorDomEmbedModule from '@electric-ax/agents-server-ui/src/embed/SessionStateInspectorDomEmbed' import { getActiveServerHeadersSnapshot } from '@electric-ax/agents-server-ui/src/lib/auth-fetch' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' -import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '@electric-ax/agents-server-ui/src/lib/comments' const HEADER_HEIGHT = 44 @@ -35,6 +38,7 @@ type SessionDomEmbedProps = { theme: `light` | `dark` scrollToBottomSignal?: number inlineQueuedMessages?: Array + inlineComments?: Array bottomInset?: number commentsOnly?: boolean onRequestOpenEntity: (entityUrl: string) => Promise @@ -84,6 +88,9 @@ function SessionRouteInner({ const [inlineQueuedMessages, setInlineQueuedMessages] = useState< Array >([]) + const [inlineComments, setInlineComments] = useState< + Array + >([]) const [replyTarget, setReplyTarget] = useState( null ) @@ -166,6 +173,7 @@ function SessionRouteInner({ theme={scheme} scrollToBottomSignal={chatLogScrollSignal} inlineQueuedMessages={inlineQueuedMessages} + inlineComments={inlineComments} bottomInset={composerInset} commentsOnly={view === `comments`} serverHeaders={serverHeaders} @@ -198,6 +206,7 @@ function SessionRouteInner({ onComposerHeightChange={setChatComposerHeight} onSendMessage={() => setChatLogScrollSignal(Date.now())} onInlineQueuedMessagesChange={setInlineQueuedMessages} + onInlineCommentsChange={setInlineComments} onShare={openShare} commentTarget={replyTarget} onClearCommentTarget={() => setReplyTarget(null)} diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index e6cda2c7e0..22d9a0aaf8 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -27,7 +27,10 @@ import { } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import { createSendCommentAction } from '@electric-ax/agents-server-ui/src/lib/comments' -import type { SelectedCommentTarget } from '@electric-ax/agents-server-ui/src/lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '@electric-ax/agents-server-ui/src/lib/comments' import { serializeComposerInput } from '@electric-ax/agents-runtime/client' import type { EntityTimelineQueryRow, @@ -86,6 +89,7 @@ export function ChatSessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onInlineCommentsChange, onShare, commentTarget, onClearCommentTarget, @@ -102,6 +106,7 @@ export function ChatSessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + onInlineCommentsChange?: (comments: Array) => void onShare?: () => void commentTarget?: SelectedCommentTarget | null onClearCommentTarget?: () => void @@ -117,6 +122,7 @@ export function ChatSessionScreen({ onComposerHeightChange={onComposerHeightChange} onSendMessage={onSendMessage} onInlineQueuedMessagesChange={onInlineQueuedMessagesChange} + onInlineCommentsChange={onInlineCommentsChange} onShare={onShare} commentTarget={commentTarget} onClearCommentTarget={onClearCommentTarget} @@ -165,6 +171,7 @@ export function SessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onInlineCommentsChange, onShare, commentTarget = null, onClearCommentTarget, @@ -180,6 +187,12 @@ export function SessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + /** + * Optimistic comments (pending, not yet synced) forwarded to the embed so a + * posted comment renders immediately — the composer's `db` is a separate JS + * context from the embed's, so its optimistic insert isn't otherwise visible. + */ + onInlineCommentsChange?: (comments: Array) => void onShare?: () => void /** Reply target forwarded from the embed timeline (chat + comments views). */ commentTarget?: SelectedCommentTarget | null @@ -328,6 +341,25 @@ export function SessionScreen({ onInlineQueuedMessagesChange?.(Array.from(inlineQueuedMessages.values())) }, [inlineQueuedMessages, onInlineQueuedMessagesChange]) + // Optimistic comments the composer inserted into our (native) db but that + // the embed timeline — a separate JS context — can't see yet. Forward the + // still-pending ones so the embed projects them until its own stream syncs + // the authoritative row. `~pending` is the optimistic timeline-order prefix. + const inlineComments = useMemo( + () => + timelineRows + .filter((row) => row.comment?.order?.startsWith(`~pending`)) + .map((row) => row.comment!), + [timelineRows] + ) + const lastInlineCommentSigRef = useRef(``) + useEffect(() => { + const signature = inlineComments.map((comment) => comment.key).join(`\0`) + if (signature === lastInlineCommentSigRef.current) return + lastInlineCommentSigRef.current = signature + onInlineCommentsChange?.(inlineComments) + }, [inlineComments, onInlineCommentsChange]) + useEffect(() => { if (!generationActive) setStopPending(false) }, [generationActive]) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 939186d67f..b85e0c0f0a 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -16,7 +16,11 @@ import { commentFocusViewParams, decodeCommentTargetParam, } from '../../lib/comments' -import type { SelectedCommentTarget, TimelineRow } from '../../lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, + TimelineRow, +} from '../../lib/comments' import { useEntityPermission, useEntityPermissions, @@ -76,11 +80,21 @@ export function ChatLogView({ tileId, scrollToBottomSignal, inlineQueuedMessages = [], + inlineComments = [], commentsOnly = false, onReplyToComment, }: ViewProps & { scrollToBottomSignal?: number inlineQueuedMessages?: Array + /** + * Optimistic comments from the native composer, forwarded across the + * Expo-DOM boundary because the composer's `db` is a separate JS context + * from this embed's. Projected into the timeline so a posted comment shows + * immediately, mirroring desktop where composer and timeline share one `db`. + * They carry `~pending` orders, so they sort into the same bottom band the + * shared query would place them in — deduped by key once the row syncs. + */ + inlineComments?: Array /** Render only the comment rows (with surrounding context), mirroring CommentsView. */ commentsOnly?: boolean /** @@ -121,16 +135,35 @@ export function ChatLogView({ pendingInboxByKey, processedInboxKeys, ]) + // Optimistic comments not yet present in the timeline (deduped by key against + // comments already synced), so a posted comment renders before the stream + // catches up. `~pending` orders keep them in the same bottom band the shared + // query uses, so ordering still matches desktop. + const projectedComments = useMemo>(() => { + if (inlineComments.length === 0) return [] + const syncedKeys = new Set( + timelineRows.filter((row) => row.comment).map((row) => row.comment!.key) + ) + return inlineComments + .filter((comment) => !syncedKeys.has(comment.key)) + .map( + (comment) => + ({ $key: `pending-comment:${comment.key}`, comment }) as TimelineRow + ) + }, [inlineComments, timelineRows]) + const visibleRows = useMemo>(() => { - if (!projectedPendingMessage) return timelineRows - return [ - ...timelineRows, - { - $key: `pending-inbox:${projectedPendingMessage.key}`, - inbox: projectedPendingMessage, - } as TimelineRow, - ] - }, [projectedPendingMessage, timelineRows]) + const base = projectedPendingMessage + ? [ + ...timelineRows, + { + $key: `pending-inbox:${projectedPendingMessage.key}`, + inbox: projectedPendingMessage, + } as TimelineRow, + ] + : timelineRows + return projectedComments.length > 0 ? [...base, ...projectedComments] : base + }, [projectedPendingMessage, timelineRows, projectedComments]) useEffect(() => { if (error && !isSpawning) { @@ -146,8 +179,8 @@ export function ChatLogView({ }) const commentsTimeline = useMemo( - () => (commentsOnly ? buildCommentsTimeline(timelineRows) : null), - [commentsOnly, timelineRows] + () => (commentsOnly ? buildCommentsTimeline(visibleRows) : null), + [commentsOnly, visibleRows] ) return ( diff --git a/packages/agents-server-ui/src/embed/EmbedApp.tsx b/packages/agents-server-ui/src/embed/EmbedApp.tsx index 1ad7179b3e..0fc8364e27 100644 --- a/packages/agents-server-ui/src/embed/EmbedApp.tsx +++ b/packages/agents-server-ui/src/embed/EmbedApp.tsx @@ -26,7 +26,10 @@ import { StateExplorerView } from '../components/views/StateExplorerView' import { registerActiveServerHeaders } from '../lib/auth-fetch' import styles from './EmbedApp.module.css' import type { OptimisticInboxMessage } from '../lib/sendMessage' -import type { SelectedCommentTarget } from '../lib/comments' +import type { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '../lib/comments' const TILE_ID = `mobile-embed` @@ -92,6 +95,8 @@ type EmbedState = { theme: EmbedTheme scrollToBottomSignal?: number inlineQueuedMessages?: Array + /** Optimistic comments from the native composer (separate JS context). */ + inlineComments?: Array bottomInset?: number /** Render only the comment rows — the native shell's "comments" view. */ commentsOnly?: boolean @@ -201,6 +206,7 @@ function EmbedSurface({ state }: { state: EmbedRuntime }): ReactElement { serverUrl={state.serverUrl} scrollToBottomSignal={state.scrollToBottomSignal} inlineQueuedMessages={state.inlineQueuedMessages} + inlineComments={state.inlineComments} bottomInset={state.bottomInset} commentsOnly={state.commentsOnly} onReplyToComment={state.onReplyToComment} @@ -214,6 +220,7 @@ function EntityHost({ serverUrl, scrollToBottomSignal, inlineQueuedMessages, + inlineComments, bottomInset, commentsOnly, onReplyToComment, @@ -223,6 +230,7 @@ function EntityHost({ serverUrl: string scrollToBottomSignal?: number inlineQueuedMessages?: Array + inlineComments?: Array bottomInset?: number commentsOnly?: boolean onReplyToComment?: ReplyToCommentFn @@ -286,6 +294,7 @@ function EntityHost({ {...props} scrollToBottomSignal={scrollToBottomSignal} inlineQueuedMessages={inlineQueuedMessages} + inlineComments={inlineComments} commentsOnly={commentsOnly} onReplyToComment={onReplyToComment} /> From 7f02888d4a0911a8c34a2ac24c67d4f75747b5d4 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 14:24:08 +0100 Subject: [PATCH 10/12] fix(agents-mobile): exclude comment mode from showStop; dedupe reply-banner label Address review feedback: - showStop now excludes comment mode (mirrors desktop MessageInput), so the Post button on the comments surface never becomes a Stop-generating control while the agent is running. - Move formatReplyBannerLabel into shared lib/comments.ts and import it on both desktop and mobile instead of duplicating; add a unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/screens/SessionScreen.tsx | 13 ++++------ .../src/components/MessageInput.tsx | 7 +----- .../agents-server-ui/src/lib/comments.test.ts | 25 ++++++++++++++++++- packages/agents-server-ui/src/lib/comments.ts | 9 +++++++ 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index 22d9a0aaf8..9d1b9ac59b 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -26,7 +26,10 @@ import { readTextPayload, } from '@electric-ax/agents-server-ui/src/lib/sendMessage' import type { OptimisticInboxMessage } from '@electric-ax/agents-server-ui/src/lib/sendMessage' -import { createSendCommentAction } from '@electric-ax/agents-server-ui/src/lib/comments' +import { + createSendCommentAction, + formatReplyBannerLabel, +} from '@electric-ax/agents-server-ui/src/lib/comments' import type { EntityTimelineCommentRow, SelectedCommentTarget, @@ -505,13 +508,6 @@ export function SessionScreen({ ) } -// Mirrors agents-server-ui MessageInput's reply-banner label. -function formatReplyBannerLabel(target: SelectedCommentTarget): string { - const label = target.snapshot.label.trim() - if (!label) return `Reply` - return `Reply to ${label.charAt(0).toLowerCase()}${label.slice(1)}` -} - function NativeMessageComposer({ entityUrl, entity, @@ -693,6 +689,7 @@ function NativeMessageComposer({ return createSteerInboxMessageAction({ db, baseUrl: serverUrl, entityUrl }) }, [db, serverUrl, entityUrl]) const showStop = + !commentMode && generationActive && text.length === 0 && !hasDraftAttachments && diff --git a/packages/agents-server-ui/src/components/MessageInput.tsx b/packages/agents-server-ui/src/components/MessageInput.tsx index 906e4e890b..8ebe546134 100644 --- a/packages/agents-server-ui/src/components/MessageInput.tsx +++ b/packages/agents-server-ui/src/components/MessageInput.tsx @@ -11,6 +11,7 @@ import { } from '../lib/sendMessage' import { createSendCommentAction, + formatReplyBannerLabel, type SelectedCommentTarget, } from '../lib/comments' import { @@ -571,9 +572,3 @@ export function MessageInput({ ) } - -function formatReplyBannerLabel(target: SelectedCommentTarget | null): string { - const label = target?.snapshot.label.trim() - if (!label) return `Reply` - return `Reply to ${label.charAt(0).toLowerCase()}${label.slice(1)}` -} diff --git a/packages/agents-server-ui/src/lib/comments.test.ts b/packages/agents-server-ui/src/lib/comments.test.ts index d81ef03409..082fd8fc7b 100644 --- a/packages/agents-server-ui/src/lib/comments.test.ts +++ b/packages/agents-server-ui/src/lib/comments.test.ts @@ -9,13 +9,18 @@ import { createCommentsTimelineSource, createSendCommentAction, decodeCommentTargetParam, + formatReplyBannerLabel, } from './comments' import type { CommentSnapshot, CommentTarget, EntityStreamDBWithActions, } from '@electric-ax/agents-runtime/client' -import type { OptimisticComment, TimelineRow } from './comments' +import type { + OptimisticComment, + SelectedCommentTarget, + TimelineRow, +} from './comments' function createCommentsDb() { const comments = createCollection( @@ -305,3 +310,21 @@ describe(`comment focus view params`, () => { expect(decodeCommentTargetParam(encoded)).toBeNull() }) }) + +describe(`formatReplyBannerLabel`, () => { + const target = (label: string): SelectedCommentTarget => ({ + target: { kind: `comment`, key: `c1` }, + snapshot: { label }, + }) + + it(`lowercases the first character of the snapshot label`, () => { + expect(formatReplyBannerLabel(target(`User message`))).toBe( + `Reply to user message` + ) + }) + + it(`falls back to "Reply" when null or label is blank`, () => { + expect(formatReplyBannerLabel(null)).toBe(`Reply`) + expect(formatReplyBannerLabel(target(` `))).toBe(`Reply`) + }) +}) diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index 056addc82f..524754dc65 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -82,6 +82,15 @@ export type SelectedCommentTarget = { snapshot: CommentSnapshot } +/** Label for the composer's reply banner; `Reply` when there's no snapshot label. */ +export function formatReplyBannerLabel( + target: SelectedCommentTarget | null +): string { + const label = target?.snapshot.label.trim() + if (!label) return `Reply` + return `Reply to ${label.charAt(0).toLowerCase()}${label.slice(1)}` +} + type SendCommentInput = { key: string body: string From 264183552d2d52e38ab5edf4c156c83e2470ef54 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 15:26:37 +0100 Subject: [PATCH 11/12] fix(agents-mobile): gate reply affordance on commentsEnabled The native shell always passes the reply callback to the chat-log embed, so the timeline showed a reply button even for entity types that don't declare the comments contract. Gate onReplyToRow/onCommentTargetClick on commentsEnabled in ChatLogView, mirroring desktop GenericChatBody. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/views/ChatView.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index b85e0c0f0a..190d2b171a 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -104,8 +104,19 @@ export function ChatLogView({ onReplyToComment?: (target: SelectedCommentTarget) => void }): React.ReactElement { const connectUrl = isSpawning ? null : entityUrl - const { timelineRows, pendingInbox, entities, db, loading, error } = - useEntityTimeline(baseUrl || null, connectUrl) + const { + timelineRows, + pendingInbox, + entities, + db, + loading, + error, + commentsEnabled, + } = useEntityTimeline(baseUrl || null, connectUrl) + // Only expose the reply affordance when the entity type declares comments — + // the native shell always passes the callback, so gate it here (mirrors + // desktop GenericChatBody's `onReplyToRow={showComments ? ... : undefined}`). + const replyToComment = commentsEnabled ? onReplyToComment : undefined const canFork = useEntityPermission(entity, `fork`) const navigate = useNavigate() // `onCommentTargetClick` jumps from a reply's snapshot to the original row. @@ -197,8 +208,8 @@ export function ChatLogView({ entities={entities} scrollToBottomSignal={scrollToBottomSignal} forkFromHereByRunKey={commentsOnly ? undefined : forkFromHereByRunKey} - onReplyToRow={onReplyToComment} - onCommentTargetClick={onReplyToComment ? setFocusTarget : undefined} + onReplyToRow={replyToComment} + onCommentTargetClick={replyToComment ? setFocusTarget : undefined} focusTarget={focusTarget} onFocusTargetHandled={() => setFocusTarget(null)} /> From e7d23479d697a7e2f74862d73f7a139ff6f7d5ba Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Tue, 16 Jun 2026 15:36:57 +0100 Subject: [PATCH 12/12] fix(agents-mobile): latch optimistic comments to stop posting flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native composer stops forwarding an optimistic comment as soon as its own stream syncs it, but the embed renders off its own (separate) stream — so if native synced first the projected row blinked out before the embed's authoritative row arrived. ChatLogView now latches optimistic comments and only drops one once THIS embed's stream delivers it (with a timeout safety net for a failed POST). Render = (incoming union latched) minus synced, so a posted comment shows on the first frame and survives the hand-off without flicker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/views/ChatView.tsx | 99 ++++++++++++++++--- 1 file changed, 84 insertions(+), 15 deletions(-) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 190d2b171a..8035420bcb 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { eq, useLiveQuery } from '@tanstack/react-db' import { useEntityTimeline } from '../../hooks/useEntityTimeline' @@ -36,6 +36,9 @@ const CHAT_VIEW_PERMISSIONS: ReadonlyArray = [ `signal`, `fork`, ] +// Safety net: evict a latched optimistic comment that never syncs into this +// embed's stream (e.g. the POST failed and the native side rolled back). +const OPTIMISTIC_COMMENT_LATCH_MS = 15_000 /** * The default view: chat / timeline + message composer. * @@ -146,22 +149,88 @@ export function ChatLogView({ pendingInboxByKey, processedInboxKeys, ]) - // Optimistic comments not yet present in the timeline (deduped by key against - // comments already synced), so a posted comment renders before the stream - // catches up. `~pending` orders keep them in the same bottom band the shared - // query uses, so ordering still matches desktop. + // Comment keys this embed's own stream has already delivered. + const syncedCommentKeys = useMemo( + () => + new Set( + timelineRows.filter((row) => row.comment).map((row) => row.comment!.key) + ), + [timelineRows] + ) + // Latch optimistic comments until THIS embed's stream delivers them. The + // native side stops forwarding a comment once its own (separate) stream + // syncs it; without latching it would blink out in the gap before this + // embed's stream catches up. Render = (incoming ∪ latched) − synced, so a + // posted comment shows on the first frame and survives the hand-off. + const [latchedComments, setLatchedComments] = useState< + Map + >(() => new Map()) + const latchTimersRef = useRef( + new Map>() + ) + useEffect(() => { + for (const comment of inlineComments) { + if (syncedCommentKeys.has(comment.key)) continue + setLatchedComments((prev) => { + if (prev.has(comment.key)) return prev + const next = new Map(prev) + next.set(comment.key, comment) + return next + }) + if (!latchTimersRef.current.has(comment.key)) { + const key = comment.key + latchTimersRef.current.set( + key, + setTimeout(() => { + latchTimersRef.current.delete(key) + setLatchedComments((prev) => { + if (!prev.has(key)) return prev + const next = new Map(prev) + next.delete(key) + return next + }) + }, OPTIMISTIC_COMMENT_LATCH_MS) + ) + } + } + }, [inlineComments, syncedCommentKeys]) + // Unlatch the instant this embed's stream delivers the authoritative row. + useEffect(() => { + setLatchedComments((prev) => { + let next: Map | null = null + for (const key of prev.keys()) { + if (!syncedCommentKeys.has(key)) continue + next ??= new Map(prev) + next.delete(key) + const timer = latchTimersRef.current.get(key) + if (timer) { + clearTimeout(timer) + latchTimersRef.current.delete(key) + } + } + return next ?? prev + }) + }, [syncedCommentKeys]) + useEffect(() => { + const timers = latchTimersRef.current + return () => { + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + } + }, []) const projectedComments = useMemo>(() => { - if (inlineComments.length === 0) return [] - const syncedKeys = new Set( - timelineRows.filter((row) => row.comment).map((row) => row.comment!.key) + const byKey = new Map() + for (const comment of latchedComments.values()) { + if (!syncedCommentKeys.has(comment.key)) byKey.set(comment.key, comment) + } + for (const comment of inlineComments) { + if (!syncedCommentKeys.has(comment.key)) byKey.set(comment.key, comment) + } + return Array.from(byKey.values()).map( + (comment) => + ({ $key: `pending-comment:${comment.key}`, comment }) as TimelineRow ) - return inlineComments - .filter((comment) => !syncedKeys.has(comment.key)) - .map( - (comment) => - ({ $key: `pending-comment:${comment.key}`, comment }) as TimelineRow - ) - }, [inlineComments, timelineRows]) + }, [inlineComments, latchedComments, syncedCommentKeys]) const visibleRows = useMemo>(() => { const base = projectedPendingMessage