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..4276fd76d1 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -25,6 +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 { + EntityTimelineCommentRow, + SelectedCommentTarget, +} from '@electric-ax/agents-server-ui/src/lib/comments' const HEADER_HEIGHT = 44 @@ -34,8 +38,11 @@ type SessionDomEmbedProps = { theme: `light` | `dark` scrollToBottomSignal?: number inlineQueuedMessages?: Array + inlineComments?: 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 +88,27 @@ function SessionRouteInner({ const [inlineQueuedMessages, setInlineQueuedMessages] = useState< Array >([]) + const [inlineComments, setInlineComments] = 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 +116,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 +164,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 +195,10 @@ function SessionRouteInner({ )} - {view === `chat` ? ( + {view !== `state-explorer` ? ( setChatLogScrollSignal(Date.now())} onInlineQueuedMessagesChange={setInlineQueuedMessages} + onInlineCommentsChange={setInlineComments} 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 @@ -94,12 +109,15 @@ export function ChatSessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + onInlineCommentsChange?: (comments: Array) => void onShare?: () => void + commentTarget?: SelectedCommentTarget | null + onClearCommentTarget?: () => void }): React.ReactElement { return ( ) } @@ -153,7 +174,10 @@ export function SessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onInlineCommentsChange, onShare, + commentTarget = null, + onClearCommentTarget, }: { entityUrl: string view: EmbedViewId @@ -166,8 +190,18 @@ 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 + onClearCommentTarget?: () => void }): React.ReactElement { + const commentOnly = view === `comments` const { entitiesCollection, serverUrl, signalEntity } = useAgents() const tokens = useTokens() const styles = useMemo(() => createStyles(tokens), [tokens]) @@ -194,8 +228,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( () => @@ -308,6 +344,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]) @@ -392,7 +447,7 @@ export function SessionScreen({ /> - {view === `chat` && ( + {view !== `state-explorer` && ( void stopImmediately()} @@ -465,6 +525,10 @@ function NativeMessageComposer({ onStop, writeDisabled, stopDisabled, + commentsEnabled, + commentOnly, + commentTarget, + onClearCommentTarget, disabled, placeholder, }: { @@ -484,20 +548,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 +638,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 +661,21 @@ 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({ + db, + baseUrl: serverUrl, + entityUrl, + from: principal, + }) + }, [db, commentsEnabled, serverUrl, entityUrl, principal]) const updateAction = useMemo(() => { if (!db) return null return createUpdateInboxMessageAction({ db, baseUrl: serverUrl, entityUrl }) @@ -575,6 +689,7 @@ function NativeMessageComposer({ return createSteerInboxMessageAction({ db, baseUrl: serverUrl, entityUrl }) }, [db, serverUrl, entityUrl]) const showStop = + !commentMode && generationActive && text.length === 0 && !hasDraftAttachments && @@ -589,7 +704,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 +734,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, @@ -672,6 +811,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`, @@ -684,7 +826,7 @@ function NativeMessageComposer({ setPendingSelection(null) slash.reset() }, - [disabled, slash.reset, updateAction, writeDisabled] + [commentOnly, disabled, slash.reset, updateAction, writeDisabled] ) const cancelEditing = useCallback((): void => { @@ -767,7 +909,63 @@ function NativeMessageComposer({ ]} > {error && {error}} - {entity && ( + {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 + + + + )} + {commentMode && commentTarget && ( + + + + {formatReplyBannerLabel(commentTarget)} + + {commentTarget.snapshot.text ? ( + + {commentTarget.snapshot.text} + + ) : null} + + onClearCommentTarget?.()} + accessibilityRole="button" + accessibilityLabel="Cancel reply" + hitSlop={8} + > + + + + )} + {entity && !commentOnly && ( )} { slash.onSelectionChange(event) @@ -820,7 +1019,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 +1042,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 +1881,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-end`, + marginBottom: spacing.xs, + padding: 2, + borderRadius: radii.pill, + backgroundColor: tokens.surface, + borderWidth: 1, + borderColor: tokens.border1, + gap: 2, + }, + modeButton: { + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: radii.pill, + }, + 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/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/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index b6b2509332..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' @@ -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, @@ -32,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. * @@ -76,15 +83,48 @@ 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 + /** + * 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 { + 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. + // The embed router stubs out navigation, so we track the focus target locally. + const [focusTarget, setFocusTarget] = useState(null) const processedInboxKeys = useMemo( () => new Set( @@ -109,16 +149,101 @@ export function ChatLogView({ pendingInboxByKey, processedInboxKeys, ]) + // 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>(() => { + 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 + ) + }, [inlineComments, latchedComments, syncedCommentKeys]) + 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) { @@ -133,19 +258,29 @@ export function ChatLogView({ canFork, }) + const commentsTimeline = useMemo( + () => (commentsOnly ? buildCommentsTimeline(visibleRows) : null), + [commentsOnly, visibleRows] + ) + 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..0fc8364e27 100644 --- a/packages/agents-server-ui/src/embed/EmbedApp.tsx +++ b/packages/agents-server-ui/src/embed/EmbedApp.tsx @@ -26,18 +26,27 @@ 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' 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 +68,7 @@ export function EmbedSessionRoot({ @@ -85,7 +95,11 @@ 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 // 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 +110,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 +138,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 +172,13 @@ function EmbeddedRouter({ return router }, [onNavigatePathname]) + const runtime = useMemo( + () => ({ ...state, onReplyToComment }), + [state, onReplyToComment] + ) + return ( - + ) @@ -162,7 +188,7 @@ function EmbedRouteSurface(): ReactElement { return } -function EmbedSurface({ state }: { state: EmbedState }): ReactElement { +function EmbedSurface({ state }: { state: EmbedRuntime }): ReactElement { const { entitiesCollection } = useElectricAgents() if (!state.entityUrl) { @@ -180,7 +206,10 @@ function EmbedSurface({ state }: { state: EmbedState }): ReactElement { serverUrl={state.serverUrl} scrollToBottomSignal={state.scrollToBottomSignal} inlineQueuedMessages={state.inlineQueuedMessages} + inlineComments={state.inlineComments} bottomInset={state.bottomInset} + commentsOnly={state.commentsOnly} + onReplyToComment={state.onReplyToComment} /> ) } @@ -191,14 +220,20 @@ function EntityHost({ serverUrl, scrollToBottomSignal, inlineQueuedMessages, + inlineComments, bottomInset, + commentsOnly, + onReplyToComment, }: { entityUrl: string view: EmbedView serverUrl: string scrollToBottomSignal?: number inlineQueuedMessages?: Array + inlineComments?: Array bottomInset?: number + commentsOnly?: boolean + onReplyToComment?: ReplyToCommentFn }): ReactElement { const { entitiesCollection } = useElectricAgents() const { data: matches = [], isLoading } = useLiveQuery( @@ -259,6 +294,9 @@ function EntityHost({ {...props} scrollToBottomSignal={scrollToBottomSignal} inlineQueuedMessages={inlineQueuedMessages} + inlineComments={inlineComments} + commentsOnly={commentsOnly} + onReplyToComment={onReplyToComment} /> ) 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