DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-mobile-companion-surface-v0_1.md

Change Request — Mobile Companion surface — v0.1

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.


Plain-language summary

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.


What this changes, in one line

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.


Grounding — the real code this is built against

Read before drafting; verified against the actual files at 8bd73f2.

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.


Decisions settled (from the scoping note, confirmed at drafting)

  1. Swipe order + default landing — Companion first, and the default surface on entering an engagement. MobileSurface opens at surface-index 0 (the Companion); swipe right into the rooms.
  2. Nav row + dots extend to five; the Companion's label reads "Companion" (the user-facing name, per vocabulary discipline — not a room name).
  3. The Companion surface reuses the desktop transcript logic (bubbles, load-earlier, timestamps, ordering), adapted to the single-column mobile width — not a bespoke mobile conversation view.

Decisions made at drafting (recorded, with reasons)


The build — steps

Per-step commits, suite green at each step, halt-before-push. Frontend only; no migration, no engine.

Step 1 — Extract the shared transcript component

Create src/app/operator/engagement/[engagement_address]/ConversationTranscript.tsx.

Move into it, verbatim from ConversationPane.tsx, the transcript-rendering pieces:

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.)

Step 2 — Re-point 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.

Step 3 — Thread the two new props to 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.

Step 4 — Add the Companion surface slot to 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:

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).

Step 5 — Strings

Add to the SURFACE object in src/lib/strings.ts (in the conversation/mic block, near movedTo/roomPrevAria):

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.


Acceptance


What this does NOT do (scope boundary)


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.