Skip to content

Stream agent prose to the iOS chat as it generates (default-off) - #6731

Merged
azooz2003-bit merged 7 commits into
mainfrom
feat-streaming-agent-updates
Jun 28, 2026
Merged

azooz2003-bit merged 7 commits into
mainfrom
feat-streaming-agent-updates

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

The iOS chat showed an agent's answer only once its JSONL transcript line was written. Both installed agents write prose to that file per content block at block/turn completion, never per token, so a single long final answer appeared all at once instead of building as it generated.

This adds a live streaming preview: while a turn is in flight, the host scrapes Ghostty's emulated screen grid for the agent surface, extracts the in-progress prose, and pushes it to chat clients as a new streamingProse wire event. The preview is reconciled to the authoritative JSONL line the instant it lands, so it never duplicates a committed message.

Behind a default-off flag (CMUXAgentChatProseStreaming) pending iOS dogfood.

Why this path (investigation)

Documented in docs/streaming-agent-updates.md. Confirmed against the installed versions (Claude Code 2.1.191, Codex 0.142.0):

  • No partial prose in either JSONL. Watched a live turn's file grow: a long answer went absent then complete in one tick. Codex persists no agent_message_delta. So this is not a parser change.
  • The raw pty byte tee is not linearly parseable. Claude paints the answer as a synchronized-output frame (ESC[?2026h/l) with absolute-column cursor moves, repainting a bottom live region per frame. Recovering text means running a terminal emulator, which Ghostty already is.
  • stream-json deltas exist only in non-interactive print mode, incompatible with the interactive TUI the user runs.

So the one viable source is Ghostty's emulated screen text, reconciled to JSONL.

How

  • ChatSessionEvent.streamingProse(ChatMessage?): whole-value preview replace; nil clears. Lives outside the message window.
  • ChatConversationStore: renders the preview as a trailing agent bubble; clears it on authoritative agent prose append or reset.
  • AgentChatProseScreenExtractor: pure, spinner-anchored screen to prose. Anchors on the agent's working line, takes the contiguous answer block above it up to the previous committed block, strips TUI chrome. Returns nil unless a turn is actively streaming.
  • AgentChatProseStreamer + AgentChatTranscriptService: polls the surface render-grid on the turn lifecycle (UserPromptSubmit to Stop), gated by the default-off flag and live chat subscribers, off the keystroke hot path. Settles on the authoritative prose append.

Verified against a real live turn

The extractor was hardened against raw read-screen captures of a live claude turn (Claude Code 2.1.191), driven over the debug socket. Replaying the verbatim frames exposed three things the original synthetic fixtures had wrong, each of which made the extractor return chrome or leak the prompt:

  • the in-progress answer is itself prefixed with Claude's ⏺ bullet, and the persistent bottom mode bar carries esc to interrupt below the input box (the old anchor picked that bar and folded a divider row into the preview);
  • during the first seconds the spinner line is a bare gerund (✻ Nebulizing… ) with no timer, so collection walked up into the wrapped user prompt and leaked ...three sentences. as a fake answer;
  • the running stop hooks… 0/3 · 3s · ↓ 56 tokens status drops the paren around the timer.

Fixed via a three-tier anchor (timer line, bare gerund line, interrupt hint on the working line only, never the footer), requiring Claude's ⏺ answer-top (so the thinking phase previews nothing), and stripping the 2-space hanging indent. The test suite now replays the live thinking→partial→full→settled frames and asserts: nil until ⏺ +words appear, the growing partial, the clean full answer, nil once settled.

Principled vs hacky

The wire/store/lifecycle plumbing is principled and reuses existing seams (the render-grid snapshot, the surface→session registry, the .appended/.updated reconciliation discipline). The screen extractor is inherently heuristic — it pattern-matches per-agent TUI chrome, which drifts with CLI versions and locale. That risk is bounded: the preview is always superseded by the JSONL line at turn end, so any mis-extraction is transient and self-corrects within one turn, and the feature is default-off.

Known limitations (v1)

  • Viewport-only: an answer taller than the visible screen previews its growing tail; the full text still arrives via JSONL at settle.
  • Streams the common single-final-answer turn; intermediate prose blocks in a multi-prose turn fall back to per-block JSONL rendering.
  • Claude chrome is the primary, real-frame-verified target; Codex markers are best-effort until dogfooded.

Tests

swift test --package-path Packages/Shared/CmuxAgentChat: 120 pass (0 failures), including the real-frame AgentChatProseScreenExtractor fixtures and store reconciliation (streaming prose renders/clears, authoritative agent prose supersedes the preview without a duplicate). CmuxMobileSupport: 63 pass. CmuxMobileShellUI builds for the iOS simulator.

Dogfood

Default-off. To try: defaults write <bundle-id> CMUXAgentChatProseStreaming -bool YES, pair an iPhone, open an agent's chat, send a prompt that yields a multi-sentence answer, and watch the bubble build as it generates, then match the committed message exactly at turn end.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a live, in-progress agent prose preview that updates during generation.
    • Introduced a runtime feature flag to enable agent-prose streaming preview.
    • Added a SwiftUI iOS DEBUG-only automated chat preview for simulator verification.
  • Bug Fixes
    • Made the preview transient: it clears on reset/session end and is replaced by the authoritative finalized message to prevent duplicates.
  • Documentation
    • Documented the approach and rationale for incremental prose streaming.
  • Tests
    • Expanded unit/integration coverage for screen extraction, preview rendering, and streaming event handling.

The iOS chat showed an agent answer only when its JSONL line was written,
which both Claude Code 2.1 and Codex do per content block at block/turn
completion, never per token. A single long final answer therefore appeared
all at once. Confirmed by watching the on-disk file grow during a live turn
(a 647-char answer went absent->complete in one 100ms tick) and by capturing
Claude's interactive pty, which paints the answer as a synchronized-output
frame with absolute-column cursor moves rather than a linear text append.

Since the only token-grained source for an interactive turn is the rendered
screen, add a live preview path that scrapes Ghostty's emulated screen grid
while a turn is in flight, extracts the in-progress prose, and pushes it as a
new streamingProse wire event. The preview lives outside the message window
and is superseded the instant the authoritative JSONL line lands, so it never
duplicates a committed message.

- ChatSessionEvent.streamingProse(ChatMessage?): whole-value preview, nil clears.
- ChatConversationStore: render the preview as a trailing agent bubble; clear
  it on authoritative agent prose or reset (no duplicate).
- AgentChatProseScreenExtractor: pure, spinner-anchored screen->prose; returns
  nil unless a turn is actively streaming. Unit tested over Claude/Codex
  fixtures.
- AgentChatProseStreamer + AgentChatTranscriptService: poll the surface grid
  on the turn lifecycle (UserPromptSubmit..Stop), gated by a default-off flag
  (CMUXAgentChatProseStreaming) and chat subscribers, off the keystroke path.

Default-off pending iOS dogfood. The extractor is heuristic by nature; the
JSONL reconciliation guarantees the committed state is always correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 28, 2026 1:57am
cmux-staging Building Building Preview, Comment Jun 28, 2026 1:57am

@coderabbitai

coderabbitai Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 889c975e-ddd0-4661-b5a4-adf900370daf

📥 Commits

Reviewing files that changed from the base of the PR and between fb5ca2e and 36d9066.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (3)
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift
  • Sources/Mobile/AgentChat/AgentChatTranscriptService.swift
  • cmux.xcodeproj/project.pbxproj

📝 Walkthrough

Walkthrough

Adds live agent prose previewing from terminal snapshots through transient wire events, store projection, and an iOS debug preview surface.

Changes

Agent Prose Streaming Preview

Layer / File(s) Summary
streamingProse wire event and feature flag
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift, Sources/Mobile/AgentChat/AgentChatProseStreamingFlag.swift, Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/FixtureChatEventSource.swift, Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift
Adds streamingProse(ChatMessage?) to ChatSessionEvent with Codable support, adds a UserDefaults-backed runtime flag for enabling prose streaming, keeps fixture emissions transient, and treats the new event as non-list-mutating.
AgentChatProseScreenExtractor: heuristic prose isolation
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/AgentChatProseScreenExtractor.swift, Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentChatProseScreenExtractorTests.swift
Adds the screen extractor that finds a status anchor, gathers in-progress prose, cleans it, and returns nil when no active preview is found. Tests cover Claude/Codex layouts, paragraph handling, timer detection, and length capping.
AgentChatProseStreamer polling orchestration
Sources/Mobile/AgentChat/AgentChatProseStreamer.swift, Sources/Mobile/AgentChat/AgentChatTranscriptService.swift, cmux.xcodeproj/project.pbxproj
Adds the turn-scoped polling component that snapshots terminal rows, extracts prose, emits streaming frames, settles or cancels when committed prose or turn-end events arrive, and registers the new mobile sources in the project.
ChatConversationStore provisional preview rendering
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift, Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
Adds a separate streaming preview slot in the conversation store, routes .streamingProse through store projection, clears it on authoritative prose and reset, and tests render/clear plus authoritative supersession.
iOS streaming preview view
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift, Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift, docs/streaming-agent-updates.md
Adds the debug-only streaming chat preview view, root-view gating, the UI-test environment flag that enables the preview, and the design document for the incremental prose flow.

Sequence Diagram(s)

sequenceDiagram
  participant TranscriptService as AgentChatTranscriptService
  participant Streamer as AgentChatProseStreamer
  participant Extractor as AgentChatProseScreenExtractor
  participant Store as ChatConversationStore
  participant MobileView as StreamingChatPreviewView

  TranscriptService->>Streamer: turnStarted(sessionID, agentKind, surfaceID)
  loop active turn polling
    Streamer->>Extractor: extract(lines:agentKind:)
    Extractor-->>Streamer: prose preview or nil
    Streamer->>Store: .streamingProse(preview)
  end
  TranscriptService->>Streamer: authoritativeProseArrived(sessionID)
  Streamer->>Store: .streamingProse(nil)
  TranscriptService->>Store: .appended(committed prose)
  Store-->>MobileView: rendered preview bubble updates
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#6216: Changes AgentChatTranscriptService lifecycle and initialization, which this PR extends with prose-streaming orchestration.

Poem

🐰 I sniff the spinner, line by line,
Pulling prose from terminal shine.
A preview hops, then fades away,
When committed words arrive to stay.
No duplicate carrots in the view—
Just tidy bubbles, fresh and new.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (3 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error New production AgentChatProseStreamer adds a 150ms Task.sleep polling loop; the rule flags polling/sleep-based synchronization in shipped Swift code. Replace the polling Task.sleep loop with a real signal/callback from the terminal render update path (or an async sequence/notification) so the preview reacts to state changes instead of timing.
Cmux Architecture Rethink ❌ Error AgentChatProseStreamer adds a 150ms Task.sleep polling loop to scrape terminal state, the exact timing/side-channel pattern the rule forbids. Move preview derivation to an event-driven source of truth and remove the separate polling streamer; keep one explicit owner for live-turn state.
Cmux No Ambient Global State ❌ Error AgentChatProseStreamingFlag is a new caseless namespace reading UserDefaults.standard, introducing app-wide ambient state instead of injection. Move flag lookup into an injected config/feature-flag service (or UserDefaults dependency) owned by AgentChatTranscriptService; remove the new static namespace.
Description check ⚠️ Warning The description covers the change, rationale, and tests, but omits required template sections like Demo Video, Review Trigger, and Checklist. Add the missing Demo Video link, paste the review-trigger block, and complete the checklist items; keep the summary and testing details.
Docstring Coverage ⚠️ Warning Docstring coverage is 52.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: default-off streaming of agent prose into iOS chat.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New chat streaming types are actor-isolated or Sendable value models; no new background access or implicit MainActor debt was introduced.
Cmux Browser Automation Off-Main ✅ Passed PASS: The PR only adds agent-chat streaming files; it does not touch browser.* socket routing, WebKit waits, or the worker/main-actor browser automation paths.
Cmux Expensive Synchronous Load ✅ Passed PASS: the new startup call wraps seedFromHookStores() in a Task, and that method offloads the whole-file hook-store read via Task.detached; preview code only snapshots screen rows.
Cmux Cache Substitution Correctness ✅ Passed Preview is transient UI-only; it never enters history/persistence, and authoritative appended/reset events clear or replace it.
Cmux No Hacky Sleeps ✅ Passed Changed files are Swift/docs/pbxproj only; no TS/JS/shell/runtime-script deltas, so the no-hacky-sleeps rule is not implicated.
Cmux Algorithmic Complexity ✅ Passed Only linear scans over bounded collections were added (200-row screen cap, 600-message window); no nested rescans or hot-path sort/filter churn.
Cmux Swift Concurrency ✅ Passed New async work is Task/actor-based; the streaming loop is stored and cancelled per session, and no new Combine/DispatchQueue/completion-handler APIs were added.
Cmux Swift @Concurrent ✅ Passed PASS: Changed Swift code stays on @MainActor or explicitly hops off-main with Task.detached; no new nonisolated async or @concurrent misuse found.
Cmux Swift File And Package Boundaries ✅ Passed PASS: New app-target code is a 144-line host-side streamer glue layer; the reusable extractor lives in CmuxAgentChat, and touched large files only got small additions.
Cmux Swiftpm Lockfiles ✅ Passed PASS: The PR only adds Swift source entries in cmux.xcodeproj/project.pbxproj; no Package.swift, .gitignore, or Package.resolved changes and no package-reference edits.
Cmux Swift Logging ✅ Passed No new runtime logging was added: the diff contains no print/debugPrint/NSLog/Logger additions, no new file-scoped Logger constants, and no sensitive-data logs.
Cmux User-Facing Error Privacy ✅ Passed PASS: The diff only adds streaming-preview plumbing, docs/tests, and debug preview UI; no user-facing error/alert/copy changes exposing vendor or internal details.
Cmux Full Internationalization ✅ Passed All new visible copy is confined to DEBUG-only preview code; production Swift changes are logic-only, and no xcstrings/plist localization assets were touched.
Cmux Swiftui State Layout ✅ Passed New streaming preview uses @State and .task only; no new ObservableObject/@published, GeometryReader, or lazy-row store boundary was introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR adds only a debug SwiftUI view inside the existing mobile root scene; no new NSWindow/WindowGroup/NSWindowController or cmux.* identifier changes were introduced.
Cmux Source Artifacts ✅ Passed No artifact paths were added; the TSV is tracked CI config and vendor/bonsplit is only a submodule gitlink update, not a checked-in checkout.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Added code is feature-gated preview/flag plumbing, not a test-observability accessor; no new debug*/ForTesting/TestHook seam appears in production Sources.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-streaming-agent-updates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a default-off live streaming preview for agent prose on iOS chat: while a turn is in-flight the Mac host polls Ghostty's emulated screen grid at 150 ms, extracts the in-progress answer via a spinner-anchored heuristic extractor, and pushes it to iOS clients as a new streamingProse wire event that is atomically superseded by the authoritative JSONL line when the turn settles.

  • AgentChatProseScreenExtractor — pure, hardened against real Claude Code 2.1.191 viewport frames; three-tier anchor (timer line → gerund spinner → interrupt hint) avoids the persistent footer bar and correctly returns nil during the thinking phase before the ⏺ bullet appears.
  • AgentChatProseStreamer — @MainActor poll driver with per-session turn bookkeeping; isEnabled/hasSubscribers gates keep idle sessions off the snapshot path, and dedup via lastEmitted prevents no-op re-emits.
  • ChatConversationStore — streamingMessage is held outside the committed window and cleared on .reset, on authoritative .appended agent prose, or on explicit streamingProse(nil), so the preview never duplicates a committed message.

Confidence Score: 4/5

Safe to merge behind the default-off flag; the streaming preview is always superseded by the authoritative JSONL message, so any mis-extraction is transient and self-correcting within one turn.

The wire/store/lifecycle plumbing is correct and well-tested. Two open items from the prior review thread remain unaddressed in the diff: the AgentChatProseStreamingFlag caseless-enum namespace and the proseStreamer implicitly-unwrapped optional in AgentChatTranscriptService. Neither affects correctness at runtime, but they represent unresolved architectural debt that reviewers have already asked about.

Sources/Mobile/AgentChat/AgentChatProseStreamingFlag.swift and Sources/Mobile/AgentChat/AgentChatTranscriptService.swift carry the still-open items from the prior review thread.

Important Files Changed

Filename Overview
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/AgentChatProseScreenExtractor.swift New pure-value extractor that anchors on the agent's spinner/timer working line to isolate in-progress prose from TUI chrome. Three-tier anchoring logic is well-documented and hardened against real Claude Code 2.1.191 screen frames. Minor: strippingLeadingBullet accepts agentKind but never reads it.
Sources/Mobile/AgentChat/AgentChatProseStreamer.swift New @MainActor poll driver. Turn lifecycle (start/authoritativeProseArrived/end) is clearly modelled; dedup via lastEmitted, stable per-session preview message ID, and task reuse across multi-prose turns all look correct. Snapshot and emit closures are fully injectable for tests.
Sources/Mobile/AgentChat/AgentChatProseStreamingFlag.swift Default-off UserDefaults flag wrapped in a caseless enum namespace. The flag-read in AgentChatTranscriptService.noteHookEvent is noted as an existing open comment on the PR.
Sources/Mobile/AgentChat/AgentChatTranscriptService.swift Wires AgentChatProseStreamer into the turn lifecycle. proseStreamer IUO and the direct AgentChatProseStreamingFlag.isEnabled read in noteHookEvent are already noted as prior-thread open items. Event ordering (streamingProse nil before appended) is correct; double-call to turnEnded on state == .ended is a no-op.
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift Adds streamingMessage held outside the committed window; cleared on .reset, cleared on authoritative agent prose arriving via .appended, and reconciled by reproject. Logic is correct — contains(where:) guard in reproject runs only while streamingMessage != nil (streaming-active path only).
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift Adds streamingProse(ChatMessage?) case with correct Codable round-trip using decodeIfPresent/encodeIfPresent for the nullable payload and a new message coding key.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift DEBUG-only self-playing simulator harness fully wrapped in #if canImport(UIKit) && DEBUG. Follows the existing TerminalLayoutPreviewView / WorkspaceListLayoutPreviewView pattern in the same package. Animation sleeps in drive() are pure pacing, not synchronization.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Hook as AgentChatTranscriptService
    participant Streamer as AgentChatProseStreamer
    participant Ghostty as Ghostty Screen Grid
    participant Wire as ChatSessionEvent Wire
    participant Store as ChatConversationStore (iOS)

    Hook->>Streamer: turnStarted(sessionID, surfaceID, agentKind)
    loop every 150 ms while turn active
        Streamer->>Ghostty: snapshot(surfaceID) → [String]
        Ghostty-->>Streamer: screen rows
        Streamer->>Streamer: AgentChatProseScreenExtractor.extract()
        alt prose changed
            Streamer->>Wire: .streamingProse(ChatMessage)
            Wire-->>Store: "streamingMessage = preview"
            Store->>Store: reproject() → trailing bubble
        end
    end
    Hook->>Streamer: authoritativeProseArrived(sessionID)
    Streamer->>Wire: .streamingProse(nil) [clear]
    Wire-->>Store: "streamingMessage = nil"
    Hook->>Wire: .appended([committedMessages])
    Wire-->>Store: committed message takes over
    Hook->>Streamer: turnEnded(sessionID)
    Streamer->>Streamer: cancel loop task
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Hook as AgentChatTranscriptService
    participant Streamer as AgentChatProseStreamer
    participant Ghostty as Ghostty Screen Grid
    participant Wire as ChatSessionEvent Wire
    participant Store as ChatConversationStore (iOS)

    Hook->>Streamer: turnStarted(sessionID, surfaceID, agentKind)
    loop every 150 ms while turn active
        Streamer->>Ghostty: snapshot(surfaceID) → [String]
        Ghostty-->>Streamer: screen rows
        Streamer->>Streamer: AgentChatProseScreenExtractor.extract()
        alt prose changed
            Streamer->>Wire: .streamingProse(ChatMessage)
            Wire-->>Store: "streamingMessage = preview"
            Store->>Store: reproject() → trailing bubble
        end
    end
    Hook->>Streamer: authoritativeProseArrived(sessionID)
    Streamer->>Wire: .streamingProse(nil) [clear]
    Wire-->>Store: "streamingMessage = nil"
    Hook->>Wire: .appended([committedMessages])
    Wire-->>Store: committed message takes over
    Hook->>Streamer: turnEnded(sessionID)
    Streamer->>Streamer: cancel loop task
Loading

Reviews (6): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +15 to +22
enum AgentChatProseStreamingFlag {
static let defaultsKey = "CMUXAgentChatProseStreaming"

/// Whether the live streaming preview is enabled. Defaults to `false`.
static var isEnabled: Bool {
UserDefaults.standard.bool(forKey: defaultsKey)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Caseless enum static namespace violates cmux-no-ambient-global-state

enum AgentChatProseStreamingFlag has no cases and exists solely to hold static let defaultsKey and static var isEnabled, which is exactly the pattern the rule flags. AgentChatProseStreamer.init already accepts isEnabled as an injectable closure (correct), but AgentChatTranscriptService.noteHookEvent reads AgentChatProseStreamingFlag.isEnabled directly from the global instead of using the same injected seam. Preferred shape: a fileprivate or private constant for the defaults key, read directly at the call site, or a small injectable struct if shared re-use is needed.

Rule Used: Flag new ambient global state in production Swift:... (source)

Comment on lines +18 to +19
/// Drives the live agent-prose streaming preview (default-off feature flag).
private var proseStreamer: AgentChatProseStreamer!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 proseStreamer IUO (!) is avoidable — proseStreamer is always assigned in every designated init path, so the implicitly-unwrapped optional exists only to satisfy the two-phase init ordering. Declaring it private let and moving the AgentChatProseStreamer(...) construction before registry.onRecordChanged (which already captures self weakly) removes the crash-at-nil risk of the ! and makes the always-initialized invariant visible at the declaration site.

Suggested change
/// Drives the live agent-prose streaming preview (default-off feature flag).
private var proseStreamer: AgentChatProseStreamer!
/// Drives the live agent-prose streaming preview (default-off feature flag).
private let proseStreamer: AgentChatProseStreamer

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`:
- Around line 517-523: In the `.streamingProse` case within
ChatConversationStore, the message validation on line 521 only checks if the
message is prose using `Self.isProse($0)` but does not verify it is specifically
`.agent` prose. Modify the flatMap condition in the `let next = message.flatMap
{ ... }` line to additionally validate that the message kind is `.agent` in
addition to passing the isProse check, so that only valid agent prose is
accepted and malformed non-agent frames are ignored.

In
`@Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentChatProseScreenExtractorTests.swift`:
- Around line 129-131: The test assertion for the extracted line count is too
weak because the nil coalescing operator converts a nil result to 0, which still
passes the `<= 200` check. This allows broken extraction to pass silently.
Remove the nil coalescing operator and instead assert that the extraction result
from extractor.extract is non-nil, then assert the exact expected line count for
this fixture to properly catch any regressions in the extraction logic.

In `@Sources/Mobile/AgentChat/AgentChatProseStreamer.swift`:
- Line 61: Remove Task.sleep-driven polling from the production runtime path in
AgentChatProseStreamer by eliminating the sleep parameter default that uses
Task.sleep(for:) on line 61 and refactoring the polling loop at lines 112-116
that relies on Task.sleep cadencing. Replace the timing-based polling mechanism
with an alternative implementation that does not depend on Task.sleep or
sleep-driven polling primitives in the production code path.
- Around line 119-127: The emitPreviewIfChanged method returns early when
snapshot or extraction fails (lines 122-123) without emitting a nil prose event
to clear stale UI state. Instead of returning when snapshot is nil or extraction
fails, emit a ChatSessionEventFrame with .streamingProse(nil) to clear any
previously emitted preview and ensure the UI shows an empty/disabled state when
the signal is unavailable, following the fail-closed pattern described in the
path instructions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e405f061-aa9e-4459-aff4-ef71230f4153

📥 Commits

Reviewing files that changed from the base of the PR and between ddcd95d and cbc7815.

📒 Files selected for processing (12)
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/AgentChatProseScreenExtractor.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/FixtureChatEventSource.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Wire/ChatSessionEvent.swift
  • Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentChatProseScreenExtractorTests.swift
  • Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
  • Sources/Mobile/AgentChat/AgentChatProseStreamer.swift
  • Sources/Mobile/AgentChat/AgentChatProseStreamingFlag.swift
  • Sources/Mobile/AgentChat/AgentChatTranscriptService.swift
  • cmux.xcodeproj/project.pbxproj
  • docs/streaming-agent-updates.md

Comment on lines +517 to +523
case .streamingProse(let message):
// The preview is a whole-value replace; an agent session only. A
// terminal session has no agent prose, so ignore it there.
guard descriptor.kind != .terminal else { break }
let next = message.flatMap { Self.isProse($0) ? $0 : nil }
guard next != streamingMessage else { break }
streamingMessage = next

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fail closed on malformed .streamingProse payloads.

Line 521 only checks message kind (.prose) and will accept non-agent prose. Restrict this path to .agent prose so invalid frames are ignored instead of rendered.

As per path instructions, “Correctness-critical state must come from a single authoritative source … fail closed (disable/empty state) rather than guessing.”

🔧 Suggested fix
-            let next = message.flatMap { Self.isProse($0) ? $0 : nil }
+            let next = message.flatMap { ($0.role == .agent && Self.isProse($0)) ? $0 : nil }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`
around lines 517 - 523, In the `.streamingProse` case within
ChatConversationStore, the message validation on line 521 only checks if the
message is prose using `Self.isProse($0)` but does not verify it is specifically
`.agent` prose. Modify the flatMap condition in the `let next = message.flatMap
{ ... }` line to additionally validate that the message kind is `.agent` in
addition to passing the isProse check, so that only valid agent prose is
accepted and malformed non-agent frames are ignored.

Source: Path instructions

Comment on lines +129 to +131
let result = extractor.extract(lines: rows, agentKind: .claude)
let lineCount = result?.split(separator: "\n", omittingEmptySubsequences: false).count ?? 0
#expect(lineCount <= 200)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen the cap test so extractor regressions cannot pass silently.

Line 130 converts nil to 0, so a broken extraction path still passes <= 200. Require non-nil extraction (and ideally assert the exact capped line count for this fixture).

🔧 Suggested fix
         let result = extractor.extract(lines: rows, agentKind: .claude)
-        let lineCount = result?.split(separator: "\n", omittingEmptySubsequences: false).count ?? 0
-        `#expect`(lineCount <= 200)
+        guard let result else {
+            Issue.record("expected capped prose extraction, got nil")
+            return
+        }
+        let lineCount = result.split(separator: "\n", omittingEmptySubsequences: false).count
+        `#expect`(lineCount == 200)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentChatProseScreenExtractorTests.swift`
around lines 129 - 131, The test assertion for the extracted line count is too
weak because the nil coalescing operator converts a nil result to 0, which still
passes the `<= 200` check. This allows broken extraction to pass silently.
Remove the nil coalescing operator and instead assert that the extraction result
from extractor.extract is non-nil, then assert the exact expected line count for
this fixture to properly catch any regressions in the extraction logic.

hasSubscribers: @escaping @MainActor () -> Bool,
now: @escaping @MainActor () -> Date = { Date() },
pollInterval: Duration = .milliseconds(150),
sleep: @escaping @Sendable (Duration) async -> Void = { try? await Task.sleep(for: $0) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Remove Task.sleep-driven polling from this production runtime path.

Line 61 and Lines 112-116 introduce a Task.sleep cadence loop in shipped Swift runtime code. This conflicts with the repo rule set that treats timing/polling primitives in production paths as failures by default.

As per coding guidelines, "Task.sleep/timing-based polling in production Swift code should be treated as failures by default."

Also applies to: 112-116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatProseStreamer.swift` at line 61, Remove
Task.sleep-driven polling from the production runtime path in
AgentChatProseStreamer by eliminating the sleep parameter default that uses
Task.sleep(for:) on line 61 and refactoring the polling loop at lines 112-116
that relies on Task.sleep cadencing. Replace the timing-based polling mechanism
with an alternative implementation that does not depend on Task.sleep or
sleep-driven polling primitives in the production code path.

Source: Coding guidelines

Comment on lines +119 to +127
private func emitPreviewIfChanged(sessionID: String) {
guard let turn = turns[sessionID], !turn.settled else { return }
guard isEnabled(), hasSubscribers() else { return }
guard let lines = snapshot(turn.surfaceID) else { return }
guard let prose = extractor.extract(lines: lines, agentKind: turn.agentKind) else { return }
guard prose != turn.lastEmitted else { return }
turns[sessionID]?.lastEmitted = prose
emit(ChatSessionEventFrame(sessionID: sessionID, event: .streamingProse(previewMessage(sessionID: sessionID, text: prose))))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear stale preview when extraction/snapshot signal disappears.

On Line 123, a failed extraction path returns early without emitting .streamingProse(nil). If a preview was already emitted, the UI can keep showing stale prose until turnEnded or authoritative append arrives, instead of failing closed.

💡 Proposed fix
 private func emitPreviewIfChanged(sessionID: String) {
     guard let turn = turns[sessionID], !turn.settled else { return }
-    guard isEnabled(), hasSubscribers() else { return }
-    guard let lines = snapshot(turn.surfaceID) else { return }
-    guard let prose = extractor.extract(lines: lines, agentKind: turn.agentKind) else { return }
+    guard isEnabled(), hasSubscribers(),
+          let lines = snapshot(turn.surfaceID),
+          let prose = extractor.extract(lines: lines, agentKind: turn.agentKind)
+    else {
+        clearPreviewIfNeeded(sessionID: sessionID)
+        return
+    }
     guard prose != turn.lastEmitted else { return }
     turns[sessionID]?.lastEmitted = prose
     emit(ChatSessionEventFrame(sessionID: sessionID, event: .streamingProse(previewMessage(sessionID: sessionID, text: prose))))
 }
+
+private func clearPreviewIfNeeded(sessionID: String) {
+    guard turns[sessionID]?.lastEmitted != nil else { return }
+    turns[sessionID]?.lastEmitted = nil
+    clearPreview(sessionID: sessionID)
+}

As per path instructions, "If a reliable signal is missing, fail closed (disable/empty state) rather than guessing."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatProseStreamer.swift` around lines 119 -
127, The emitPreviewIfChanged method returns early when snapshot or extraction
fails (lines 122-123) without emitting a nil prose event to clear stale UI
state. Instead of returning when snapshot is nil or extraction fails, emit a
ChatSessionEventFrame with .streamingProse(nil) to clear any previously emitted
preview and ensure the UI shows an empty/disabled state when the signal is
unavailable, following the fail-closed pattern described in the path
instructions.

Source: Path instructions

A self-playing agent chat mounted at the root when
CMUX_UITEST_STREAMING_CHAT_PREVIEW=1, mirroring the existing terminal/
workspace layout previews. It drives the real ChatConversationStore +
ChatScreen with live streamingProse events that grow word by word then
clear, so the incremental streaming preview can be recorded and verified
on a simulator with no sign-in or Mac pairing.

Used to capture frame-by-frame proof that the agent bubble builds
incrementally and is superseded cleanly with no duplicate. DEBUG-only;
Release compiles the branch to EmptyView.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift`:
- Around line 32-58: The preview title and scripted chat copy in
StreamingChatPreviewView are still hardcoded English user-facing strings. Update
the ChatSessionDescriptor title, the ChatMessage prose text, and the drive()
narration text to use String(localized:defaultValue:) with stable keys, and add
matching entries to Resources/Localizable.xcstrings so the preview renders
localized text instead of literals.
- Around line 52-73: The preview loop in StreamingChatPreviewView.drive only
exercises incremental streaming updates and clearing, but it never simulates the
authoritative committed message that should reconcile the trailing preview
bubble. Update the FixtureChatEventSource sequence in drive so it emits the
final committed agent message event before calling streamingProse(nil), using
the existing previewMessage helper and the same event source flow, so ChatScreen
can verify settlement/reconciliation without duplication.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 03a96197-10cd-47cd-886b-3bfaebb1927e

📥 Commits

Reviewing files that changed from the base of the PR and between cbc7815 and 75c2ab6.

📒 Files selected for processing (3)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift

Comment on lines +32 to +58
let descriptor = ChatSessionDescriptor(
id: "streaming-preview",
agentKind: .claude,
title: "Streaming preview"
)
let prompt = ChatMessage(
id: "preview-user-0",
seq: 0,
role: .user,
timestamp: Date(),
kind: .prose(ChatProse(text: "Reply with three short sentences about the color blue."))
)
let source = FixtureChatEventSource(backlog: [prompt])
let store = ChatConversationStore(descriptor: descriptor, source: source)
let model = Model(store: store, source: source)
model.runTask = Task { await store.run() }
model.driveTask = Task { await Self.drive(source: source) }
self.model = model
}

/// Loops the streaming lifecycle so any recording window captures a full
/// build: grow the answer word by word via `streamingProse`, hold, then
/// clear with `streamingProse(nil)`, and repeat.
private static func drive(source: FixtureChatEventSource) async {
let full = "The sky looks blue because air scatters short blue wavelengths most. "
+ "Blue is widely tied to calm, depth, and quiet focus. "
+ "From sapphires to the open ocean, it runs through the natural world."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Localize the preview title and scripted chat text.

Line 35, Line 42, and Lines 56-58 introduce bare user-facing strings that will render directly in the simulator preview. These need String(localized:defaultValue:) keys plus matching Resources/Localizable.xcstrings entries instead of raw English literals. As per coding guidelines, "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text")" and per path instructions, "Swift text must use localized APIs with matching translated string-catalog entries."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift`
around lines 32 - 58, The preview title and scripted chat copy in
StreamingChatPreviewView are still hardcoded English user-facing strings. Update
the ChatSessionDescriptor title, the ChatMessage prose text, and the drive()
narration text to use String(localized:defaultValue:) with stable keys, and add
matching entries to Resources/Localizable.xcstrings so the preview renders
localized text instead of literals.

Sources: Coding guidelines, Path instructions

Comment on lines +52 to +73
/// Loops the streaming lifecycle so any recording window captures a full
/// build: grow the answer word by word via `streamingProse`, hold, then
/// clear with `streamingProse(nil)`, and repeat.
private static func drive(source: FixtureChatEventSource) async {
let full = "The sky looks blue because air scatters short blue wavelengths most. "
+ "Blue is widely tied to calm, depth, and quiet focus. "
+ "From sapphires to the open ocean, it runs through the natural world."
let words = full.split(separator: " ").map(String.init)
// Let ChatScreen subscribe and load the seeded prompt first.
try? await Task.sleep(for: .milliseconds(1200))
while !Task.isCancelled {
var accumulated = ""
for word in words {
if Task.isCancelled { return }
accumulated += accumulated.isEmpty ? word : " " + word
await source.emit(.streamingProse(previewMessage(text: accumulated)))
try? await Task.sleep(for: .milliseconds(130))
}
try? await Task.sleep(for: .milliseconds(1500))
await source.emit(.streamingProse(nil))
try? await Task.sleep(for: .milliseconds(800))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This preview never exercises the settlement/reconciliation path.

The loop only emits incremental .streamingProse(...) updates and then .streamingProse(nil). That verifies preview growth/clear, but it does not simulate the authoritative committed prose event that is supposed to replace the preview without duplication, so the main behavior called out in this PR is still untested here. Emit the final committed agent message before clearing the preview so this surface actually covers reconciliation. Based on PR objectives, the preview should validate that the trailing bubble is reconciled when the authoritative JSONL message arrives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift`
around lines 52 - 73, The preview loop in StreamingChatPreviewView.drive only
exercises incremental streaming updates and clearing, but it never simulates the
authoritative committed message that should reconcile the trailing preview
bubble. Update the FixtureChatEventSource sequence in drive so it emits the
final committed agent message event before calling streamingProse(nil), using
the existing previewMessage helper and the same event source flow, so ChatScreen
can verify settlement/reconciliation without duplication.

@blacksmith-sh

This comment has been minimized.

Captured a live `claude` turn over the debug socket and replayed the raw
read-screen frames through the extractor. The synthetic fixtures had been
unrealistic in three ways the real TUI exposed, each of which made the
extractor return garbage or leak the prompt instead of the answer:

- The in-progress answer is itself prefixed with Claude's "⏺ " bullet, and
  the persistent bottom mode bar carries "esc to interrupt" *below* the input
  box. The old anchor picked that bottom bar and folded the input-box chrome
  (a divider row) into the preview.
- During the first seconds the spinner line is a bare gerund ("✻ Nebulizing… ")
  with no timer, so it was missed entirely and collection walked up into the
  wrapped user prompt, leaking "...three sentences." as a fake answer.
- The "running stop hooks… 0/3 · 3s · ↓ 56 tokens" status drops the paren
  around the elapsed timer.

Fixes:
- Three-tier anchor: timer line, then bare gerund line (leading animated
  spinner glyph + "…", which excludes the post-turn "Brewed for 3s" summary),
  then an interrupt hint *on the working line only* (never the footer mode bar).
- Treat the answer's own "⏺ " as an inclusive top, and for Claude require it:
  if collection reaches a prompt boundary without it, there is no answer yet so
  return nil (the thinking phase previews nothing).
- Strip the 2-space hanging indent so wrapped answer lines read as one paragraph.
- Relaxed timer scanner (bare "· 3s") plus a strict parenthesized variant used
  to tell a live "Forming… (9s)" from the bare "Brewed for 3s" summary.

Tests now replay the verbatim live frames (thinking→partial→full→settled) and
assert nil until "⏺ "+words appear, the growing partial, the clean full answer,
and nil once settled.
…updates

# Conflicts:
#	Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift
The required `workflow-guard-tests` "Validate Swift file length budget" step
failed: the streaming feature legitimately grew four tracked files past their
budgets. Surgically bump only those entries (rather than a full --write-budget
rewrite, which churns unrelated recounts):

- ChatConversationStoreTests.swift 877 → 948 (streaming reconciliation tests)
- ChatConversationStore.swift 722 → 764 (streamingMessage projection)
- CMUXMobileRootView.swift 505 → 518 (streaming preview branch)
- AgentChatTranscriptService.swift: newly tracked at 524 (prose streamer wiring)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift (1)

103-129: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Mirror the launch-argument fallback on the new preview flags.

workspaceListLayoutPreviewEnabled now accepts both environment and launch-argument forms, but these new preview toggles still read environment only. Any UI-test/dev launcher that passes CMUX_UITEST_*_PREVIEW=1 as an argument will never activate the streaming or agent-chat previews, so the new harnesses won't come up under the same invocation style.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift`
around lines 103 - 129, The new preview toggles in UITestConfig currently read
only from ProcessInfo.processInfo.environment, so they miss the launch-argument
fallback used by workspaceListLayoutPreviewEnabled. Update
streamingChatPreviewEnabled, agentChatPreviewEnabled, and
agentChatInlinePreviewEnabled to check both environment and
ProcessInfo.processInfo.arguments for their CMUX_UITEST_*_PREVIEW flags, using
the same pattern as the existing preview flag logic in UITestEnvironmentConfig
so UI-test/dev launchers can activate them consistently.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift (1)

199-208: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep the streaming preview out of the production root routing path.

This branch adds a new debug/UI-test-only surface to rootContent in a production Sources/ file. The repo rules treat new test/debug seams in production Swift source as failures; mount this from a dedicated debug-only composition point instead of the shipping root view. As per path instructions, **/Sources/**/*.swift: "Do not add test-only or debug-only seams in production Swift source files" and "isolate genuinely debug-only facilities in a dedicated debug file or folder."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift`
around lines 199 - 208, The root routing in CMUXMobileRootView/rootContent is
exposing a debug-only streamingChatPreview from production Sources code. Remove
that branch from the shipping root view and move the streaming preview selection
into a dedicated debug-only composition point or debug file/folder, keeping only
production navigation paths in rootContent. Ensure any references to
shouldShowStreamingChatPreview and streamingChatPreview are isolated outside the
production root routing path.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift`:
- Around line 199-208: The root routing in CMUXMobileRootView/rootContent is
exposing a debug-only streamingChatPreview from production Sources code. Remove
that branch from the shipping root view and move the streaming preview selection
into a dedicated debug-only composition point or debug file/folder, keeping only
production navigation paths in rootContent. Ensure any references to
shouldShowStreamingChatPreview and streamingChatPreview are isolated outside the
production root routing path.

In `@Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift`:
- Around line 103-129: The new preview toggles in UITestConfig currently read
only from ProcessInfo.processInfo.environment, so they miss the launch-argument
fallback used by workspaceListLayoutPreviewEnabled. Update
streamingChatPreviewEnabled, agentChatPreviewEnabled, and
agentChatInlinePreviewEnabled to check both environment and
ProcessInfo.processInfo.arguments for their CMUX_UITEST_*_PREVIEW flags, using
the same pattern as the existing preview flag logic in UITestEnvironmentConfig
so UI-test/dev launchers can activate them consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0987b679-e885-4cc9-9e2c-285b3b9c210b

📥 Commits

Reviewing files that changed from the base of the PR and between b22a8af and fb5ca2e.

📒 Files selected for processing (3)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift
  • cmux.xcodeproj/project.pbxproj
💤 Files with no reviewable changes (1)
  • cmux.xcodeproj/project.pbxproj

…updates

# Conflicts:
#	.github/swift-file-length-budget.tsv
…updates

# Conflicts:
#	.github/swift-file-length-budget.tsv
#	Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift
#	Sources/Mobile/AgentChat/AgentChatTranscriptService.swift
@azooz2003-bit
azooz2003-bit merged commit 05c03cf into main Jun 28, 2026
33 of 36 checks passed

This branch was successfully deployed

1 active deployment
Preview – cmux — 36d9066e Deployed Jun 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant