From b30d2c91902c808385775df268587a69a511cfa9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 27 May 2026 08:49:58 +0200 Subject: [PATCH] feat(agents-server-ui): show "Fork from here" button on the mobile chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces: 1. `ChatLogView` (the view the mobile DOM embed mounts under `view='chat-log'`) now computes the same `forkFromHereByInboxKey` map that `ChatView` has on the web. Without this the embed's `EntityTimeline` received no per-row callbacks and `UserMessage` stayed without the button regardless of any CSS. 2. `UserMessage.module.css` gains a scoped reveal — `:global(html[data-electric-mobile-dom='true']) .forkButton { opacity: 1 }` — so the button is visible by default on touch (the web's `.bubble:hover` rule never fires there). Placing the rule inside the CSS module (rather than in a sibling stylesheet that targets the hashed class by substring) means whichever bundler is in play — Vite for the web/desktop bundles, Metro for the Expo DOM embed — hashes `.forkButton` consistently with the JSX. The mobile package itself doesn't change. The embed already mounts `ChatView` → `EntityTimeline` → `UserMessage`, the fork POST already routes through `serverFetch()` in the embed's JS context, and post-fork navigation already routes through `onRequestOpenEntity` → `openSession(target)`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fork-from-here-mobile.md | 5 +++ .../src/components/UserMessage.module.css | 4 +++ .../src/components/views/ChatView.tsx | 32 ++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .changeset/fork-from-here-mobile.md diff --git a/.changeset/fork-from-here-mobile.md b/.changeset/fork-from-here-mobile.md new file mode 100644 index 0000000000..b343620ab6 --- /dev/null +++ b/.changeset/fork-from-here-mobile.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-server-ui': patch +--- + +Make the "Fork from here" affordance work in the mobile Expo DOM embed. Two pieces: (1) wire the fork-anchor map in `ChatLogView` (the view the mobile embed mounts) so `EntityTimeline` actually receives the per-row callbacks; (2) add a `:global(html[data-electric-mobile-dom='true']) .forkButton { opacity: 1 }` rule in `UserMessage.module.css` so the button is visible without a hover/tap (touch devices don't fire `:hover`). The fork POST and post-fork navigation already route through the existing `serverFetch` + `onRequestOpenEntity` callback, so no changes to the mobile package itself. diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index ea5a537796..b02cbda392 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -56,6 +56,10 @@ opacity: 1; } +:global(html[data-electric-mobile-dom='true']) .forkButton { + opacity: 1; +} + .forkButton:hover { background: var(--ds-gray-a4); } diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 71ab749d03..900edf7cab 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -59,8 +59,9 @@ export function ChatLogView({ inlineQueuedMessages?: Array }): React.ReactElement { const connectUrl = isSpawning ? null : entityUrl - const { timelineRows, pendingInbox, entities, loading, error } = + const { timelineRows, pendingInbox, entities, db, loading, error } = useEntityTimeline(baseUrl || null, connectUrl) + const { forkEntity } = useElectricAgents() const navigate = useNavigate() const processedInboxKeys = useMemo( () => @@ -103,6 +104,34 @@ export function ChatLogView({ } }, [error, navigate, isSpawning]) + const forkFromHereByInboxKey = useMemo(() => { + if (!forkEntity || !connectUrl || !db) return undefined + const runOffsets = db.collections.runs.__electricRowOffsets + if (!runOffsets) return undefined + const map = new Map void>() + let anchor: EventPointer | null = null + for (const row of visibleRows) { + if (row.run && row.run.status === `completed`) { + const pointer = runOffsets.get(row.run.key) + if (pointer) anchor = pointer + } + if (row.inbox && anchor) { + const capturedAnchor = anchor + map.set(row.$key, () => { + void forkEntity(connectUrl, { pointer: capturedAnchor }) + .then((res) => + navigate({ + to: `/entity/$`, + params: { _splat: res.url.replace(/^\//, ``) }, + }) + ) + .catch(() => {}) + }) + } + } + return map + }, [visibleRows, db, forkEntity, connectUrl, navigate]) + return ( ) }