Document: cr-mobile-companion-surface-v0_1
Version: v0.1
Date: 2026-06-28
Type: Phase change request — frontend only (Operator Layer loomworks repo). No engine change.
Consumer: Claude Code (executor).
Branch base: feat-companion-surface @ 8bd73f2 (the current OL HEAD; carries the Phase-5 held-card work, the voice-panel fix, and the per-bubble timestamps the transcript depends on).
Governing scoping note: loomworks-mobile-companion-surface-scoping-note-v0_1.
Resolves: the open gap named in loomworks-companion-build-queue-v0_3 — "Mobile in-engagement transcript — per mockup 2 there is no conversation transcript; replies surface transiently in the hint line. Confirm intended end state." This CR is that confirmation: the Companion becomes a readable, persistent surface in the mobile swipe plane.
On a phone, there is today no way to read the Companion conversation. The Companion's replies only flash for five seconds in the hint line and are then gone. This change adds the Companion as a surface you can swipe to — a scrollable, persistent transcript of the back-and-forth — sitting alongside the four rooms in the same left-right swipe you already use. You land on the Companion when you open an engagement (it is how you operate Loomworks), and you swipe right into Memory, Manifestation, Shaping, Rendering. The five-second hint flash stays exactly as it is; the two do different jobs — the flash tells you a reply arrived no matter which surface you're on, the Companion surface is where you go to read.
The work reuses what already exists. The conversation data, the paging ("load earlier"), and the timestamps are already built and already flow into the mobile surface — the mobile surface simply doesn't render them today. The transcript bubbles (Operator turns, Companion turns, the voice-origin microphone mark, the per-turn timestamps, the suggested-action chips) are extracted from the desktop pane into one shared piece so both surfaces render turns identically and provenance is shown the same way on both.
MobileSurface's swipe plane goes from four surfaces (the rooms) to five (Companion, then the four rooms). The Companion surface renders the same transcript the desktop pane renders. Frontend only.
Read before drafting; verified against the actual files at 8bd73f2.
InEngagementSurface.tsx — the composition root. ResolvedInEngagement owns conversation (useConversation), companionName, messageOrder, voice/onMicClick, and activeRoom/setActiveRoom (RoomKey-typed, useState(ROOMS[0].key)). The width split (useIsWide(900)) renders ConversationPane + WorkspacePane when wide, MobileSurface when narrow. activeRoom/setActiveRoom are passed to both WorkspacePane and MobileSurface — so the room state is shared, and the Companion (not a room) must NOT be folded into it.MobileSurface.tsx — derives idx from ROOMS.findIndex(activeRoom); move(±1) cycles ROOMS with wraparound; renders the active room via <RoomView room projectId compact />; has its own bottom bar (hint line + attach + composer + 48px mic on the right) and the transient-reply effect (REPLY_SHOW_MS = 5000). Receives conversation but renders no transcript. Does not currently receive messageOrder or companionName.ConversationPane.tsx — already a single column (flex flex-col): scroll-area child 1, fixed input-zone child 2 (its own 38px-mic-left bar). Renders the transcript via OperatorBubble / CompanionBubble / BubbleTime, the LoadMoreControl in prepend mode (showCount={false}), the order/scroll discipline, the empty state, the thinking indicator. The transcript-rendering logic lives here and is what mobile needs.useConversation.ts — surface-agnostic; one instance created in ResolvedInEngagement, passed to both panes. Owns turns, loadEarlier, hasMore, loadingEarlier, loadedCount, totalCount, send, sendError, sending, and the upload primitives. Mobile already holds this — it just doesn't render the transcript portion.usePagedList.ts / pagination.ts — the shared keyset spine; useConversation drives it in prepend mode. Nothing to change here; the transcript inherits paging for free.room-labels.ts — ROOMS is exactly the four rooms; RoomKey is their union. The Companion is not a RoomKey and must not become one (the vocabulary wall and the room-typed parent state both depend on this).
The structural decision (recorded): the Companion is added as a surface-index layer local to MobileSurface, wrapping the room model — not as a fifth ROOMS entry and not by widening the parent's RoomKey state. The alternative (widen activeRoom to RoomKey | "companion" in InEngagementSurface) was set aside: it pushes a non-room value into a room-typed contract across every consumer of activeRoom, contradicts the scoping note's "the Companion is not a room," and has a larger blast radius. The local-index approach keeps the parent untouched except for two new downward props.
MobileSurface opens at surface-index 0 (the Companion); swipe right into the rooms.heldCount is NOT introduced to the mobile transcript. ConversationPane accepts heldCount (defaulted 0) but the parent does not thread it on desktop either — both surfaces render suggestion chips with heldCount = 0 today. The build queue lists "no shared held-count source on the conversation pane" as an open gap. Mobile matches desktop's current behavior; when a held-count source lands, both surfaces get it together. No half-built, mobile-only affordance.OperatorBubble / CompanionBubble / BubbleTime are non-trivial and provenance-bearing (voice-origin mic glyph, Companion source label, suggested-action chips, upload-result cards). Reimplementing them for mobile would duplicate provenance display and invite drift. Extraction guarantees mobile inherits provenance display identically — the conformance requirement build-queue-v0.3 names for every surface.MobileSurface's existing bottom bar (the 48px-mic-right, hint-line arrangement from mockup 2). It does NOT adopt ConversationPane's input zone (which would give mobile two bottom bars). The Companion slot renders only the scroll-and-bubbles region; the existing mobile bottom bar serves it, the same bar that serves the rooms.REPLY_SHOW_MS transient-reply effect stays exactly as written. It fires on every surface including the Companion surface — on the Companion surface it is redundant-but-harmless (the reply is also in the transcript below), which matches the scoping note ("you glance at the hint as replies arrive; you swipe to the Companion when you want to read").Per-step commits, suite green at each step, halt-before-push. Frontend only; no migration, no engine.
Create src/app/operator/engagement/[engagement_address]/ConversationTranscript.tsx.
Move into it, verbatim from ConversationPane.tsx, the transcript-rendering pieces:
BubbleTime, OperatorBubble, CompanionBubble (and their imports: formatTurnTimestamp, IconMicrophone, UploadResultCard, SURFACE, DisplayTurn).ConversationPane): the scrollRef div, the LoadMoreControl, the empty state, the thinking indicator, the ordered turns.map(...), the sendError line.useEffect and the ordered computation.The component signature:
ConversationTranscript({
conversation, // UseConversationResult
engagementId, // string
companionName, // string
messageOrder, // MessageOrder
})
It renders only the scroll area (child 1) — NOT the input zone (child 2). It owns its own scrollRef. The onAction for suggested-action chips and the upload-result onClarificationPick route through conversation.send (already available on the passed conversation).
ConversationTranscript does not own the composer, the mic, or the attach button — those stay with each surface's own bottom bar.
Suite expectation: new file, no consumer yet → suite unchanged green. (If the existing ConversationPane test file imports the moved bubble symbols directly, this step does not break them yet — ConversationPane still re-exports nothing changed until Step 2. Verify: the bubbles were module-private in ConversationPane, so no external import exists to break.)
ConversationPane onto the shared transcript
In ConversationPane.tsx, replace the inlined scroll-area JSX (child 1) with <ConversationTranscript conversation={conversation} engagementId={engagementId} companionName={companionName} messageOrder={messageOrder} />. Keep child 2 (the input zone) exactly as is. Delete the now-moved BubbleTime/OperatorBubble/CompanionBubble definitions and the order/scroll useEffect from ConversationPane (they live in ConversationTranscript now).
ConversationPane keeps owning: the draft state, the upload hook, the composer/mic/attach/suggestion-chip bottom bar, and the props it already takes.
Suite expectation: desktop transcript renders identically (same bubbles, same load-earlier, same timestamps). The existing ConversationPane tests pass unchanged — the rendered output is byte-identical, only its internal composition moved. Verify in the browser that the desktop pane is visually and behaviorally unchanged before continuing.
MobileSurface
In InEngagementSurface.tsx, in the narrow branch, pass messageOrder and companionName to MobileSurface:
<MobileSurface
projectId={projectId}
activeRoom={activeRoom}
onRoomChange={setActiveRoom}
conversation={conversation}
companionName={companionName} // NEW
messageOrder={messageOrder} // NEW
voiceState={voice.state}
onMicClick={onMicClick}
/>
Add the two props to MobileSurface's prop type. They are unused until Step 4 — TypeScript will accept them as declared-and-unused only if referenced; if the linter flags unused, Step 4 lands in the same commit as Step 3 to avoid a transient unused-prop. Recommended: fold Step 3 and Step 4 into one commit so MobileSurface never commits in a half-wired state.
MobileSurface
This is the core change. In MobileSurface.tsx:
4a — Local surface index. Introduce a five-slot surface model local to MobileSurface. Slot 0 = the Companion; slots 1–4 = ROOMS[0..3]. Add local state const [surfaceIndex, setSurfaceIndex] = useState(0) (0 = Companion = default landing, decision 1).
Keep the parent's activeRoom/onRoomChange in sync for the room slots only: when surfaceIndex lands on a room slot (1–4), call onRoomChange(ROOMS[surfaceIndex - 1].key). When it lands on slot 0 (Companion), the parent room state is left as-is (the Companion is not a room). Conversely, if the parent's activeRoom changes from outside (it does not today, but keep the contract honest), reflect it only when on a room slot.
The simplest correct shape: surfaceIndex is the single source of truth for which mobile surface shows; a useEffect syncs onRoomChange whenever surfaceIndex is a room slot. Do NOT derive surfaceIndex from activeRoom (that would lose slot 0).
4b — move(delta) cycles five slots. Change move to cycle 0..4 with wraparound over 5, not over ROOMS.length. The hint on move: for a room slot, SURFACE.movedTo(ROOMS[index-1].label) as today; for slot 0, SURFACE.movedTo("Companion") (add a SURFACE.companionSurfaceLabel string = "Companion" rather than inlining — see Step 5).
4c — The nav row renders five dots and the active label. The label is room.label for room slots and the Companion label for slot 0. The dot row maps over five slots, not ROOMS. The active-dot styling rule is unchanged (i === surfaceIndex ? wide-bar : dot).
4d — The stack renders the Companion transcript at slot 0, the room at slots 1–4. In the mobile-stack div, conditionally render:
surfaceIndex === 0 → <ConversationTranscript conversation={conversation} engagementId={projectId} companionName={companionName} messageOrder={messageOrder} /><RoomView room={ROOMS[surfaceIndex - 1]} projectId={projectId} compact />
The swipe (onTouchStart/onTouchEnd) and vertical-scroll coexistence are unchanged — the same mobile-stack div hosts whichever surface is active. Note: the Companion transcript has its own internal scroll (ConversationTranscript owns a scrollRef with overflow-y-auto); confirm the horizontal-swipe detection still fires on the transcript (it should — the touch handlers are on the outer mobile-stack, and the transcript's vertical scroll is the inner element, exactly as a room's vertical scroll works today).
4e — The bottom bar is unchanged and serves all five surfaces. Composer, attach, mic, hint line stay exactly as written. The Companion surface uses the same bar. The REPLY_SHOW_MS transient effect stays.
Suite expectation: existing MobileSurface tests (room nav, swipe, hint) must still pass — adjusted only where they assert "four dots" or wraparound-over-4 (now five / over-5). Add new tests: default landing on the Companion (slot 0), swipe Companion→Memory→…→Rendering→Companion wraparound, the Companion transcript renders turns, the nav label reads "Companion" on slot 0. Provenance conformance: assert a voice-origin Operator turn renders the mic glyph and a Companion turn renders the Companion source label on the mobile transcript (the same assertions the desktop transcript carries — they pass for free if the extraction is clean, and they prove it).
Add to the SURFACE object in src/lib/strings.ts (in the conversation/mic block, near movedTo/roomPrevAria):
companionSurfaceLabel: "Companion" — the slot-0 nav label and the movedTo argument for slot 0.
No other new strings: the transcript reuses the existing companionLabel, loadEarlier, conversationEmpty, thinking, inputPlaceholder, etc. (all already referenced by ConversationPane, now rendered through ConversationTranscript). The Dismiss strings from Phase 5 are not touched.
Confirm the vocabulary wall accepts "Companion" here — it is the user-facing product name, already used in companionLabel, so it is wall-safe.
ConversationPane renders the transcript identically post-extraction (visual + behavioral parity confirmed in the browser, not only the suite).messageOrder), and the "load earlier" control pages history (the same loadEarlier the desktop pane uses).main — this lands on feat-companion-surface; merge is a separate authorization.
DUNIN7 — Done In Seven LLC — Miami, Florida
Change Request — Mobile Companion surface — v0.1 — 2026-06-28
Adds the Companion as a fifth swipe surface (slot 0, default landing) in MobileSurface, rendering the same transcript the desktop pane renders via a shared ConversationTranscript extraction. Frontend only. Hint flash unchanged. Provenance carries to mobile for free.