Skip to content

Diff viewer: untracked files in unstaged, persisted viewer prefs, soft refresh, hunk/file navigation - #6010

Merged
lawrencecchen merged 5 commits into
mainfrom
feat-diff-viewer-round1
Sep 30, 2026
Merged

lawrencecchen merged 5 commits into
mainfrom
feat-diff-viewer-round1

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Round 1 of diff viewer improvements from issue triage (#5284, #2526, #5246, #609).

Replayed on current main (2026-09-29). The original branch was 13,506 commits behind and the packages had moved, so every change was re-implemented on top of main instead of merged. Behavior that changed versus the original PR is listed at the end.

Untracked files in the unstaged source. The live viewer now renders the Rust sidecar's typed session, so the sidecar's unstaged patch (and the CLI's legacy unstaged source) appends an added-file patch per untracked, non-ignored path, bounded at 512 paths, using the same git diff --no-index -- /dev/null <path> form the last-turn source already uses. An untracked-only working tree is no longer the empty state.

Viewer preferences persist globally (fixes #5284). Layout and the options-menu toggles previously lived in page-local localStorage, which the cmux-diff-viewer:// origin does not persist (the new app test shows jsdom refusing storage on that origin). A new DiffViewerPreferencesStore persists them to ~/Library/Application Support/cmux/diff-viewer/preferences.json; the webview saves through new viewerPrefs.get/viewerPrefs.set methods on the existing cmuxDiffComments bridge and re-syncs at boot, and the CLI reads the same file (CMUX_DIFF_VIEWER_PREFS_PATH overrides it) so new diff panels open with the last-used layout and a sanitized viewerOptions payload seeds the toggles at first paint. Explicit --layout still wins. localStorage remains a fallback for pages opened outside cmux, with the legacy layout-only key still read.

Refresh preserves viewer state (the bug in #5284). The options-menu Refresh was window.location.reload(), resetting layout and options. It now re-opens the typed session in place (a render generation the render effect depends on), which also regenerates the diff through the sidecar, keeping layout and all toggles. Pages with nothing to re-stream (baked status pages, pending replacements without a typed session) keep the full reload.

Keyboard navigation between hunks (from #2526). n / p jump to the next / previous hunk (diffViewerNextHunk / diffViewerPreviousHunk), routed through the native viewer navigation key router like the file jumps main already has (] f / [ f). Wired through KeyboardShortcutSettings, the CmuxSettings ShortcutAction enum, the CLI payload, the cmux.json schema (embedded copy regenerated), shortcut docs data, the settings action list, and Settings; all rebindable. Labels localized for all nine macOS locales.

Deferred empty-state fallback no longer dead-ends (remaining #5246 path). In the legacy deferred pipeline, a fallback candidate that failed for a non-empty reason (last-turn without workspace/surface context) rendered the raw error. Unusable candidates are now skipped so the friendly empty state renders. The typed sidecar path did not have this bug, so no new test covers it.

Commit 1 adds the regression tests only (bun test red on 7 tests), commit 2 the implementation. New bun tests cover the prefs sanitizer and bridge/localStorage fallback, hunk anchor helpers, bridge-synced options at boot, persisted option changes, soft refresh, and hunk actions; new XCTests (CMUXOpenCommandDiffViewerRound1Tests) cover untracked-in-unstaged through the bundled sidecar, persisted-prefs payload seeding, and the hunk shortcut payload; a sidecar unit test covers the untracked append.

Changed versus the original PR

  • File navigation (] f / [ f, diffViewerNextFile / diffViewerPreviousFile) already landed on main as chords, so this PR adds only hunk navigation and names the actions diffViewerNextHunk / diffViewerPreviousHunk to match.
  • Shortcuts are dispatched natively (ViewerNavigationKeyRouter to __cmuxPerformDiffViewerNavigationAction), not by an in-page keydown listener.
  • The untracked append lives in the Rust sidecar too, and both sides cap it at 512 untracked paths (later paths are left out) so an unignored build tree cannot spawn thousands of git processes.
  • viewerPrefs.get shares the cmuxDiffComments handler, so the existing typed-session test now filters comments.list requests.
  • cmux_open.swift and KeyboardShortcutSettings.swift are over the Swift file length budget, so layout/preference resolution moved to CLI/CMUXCLI+DiffViewerPreferences.swift and the diff viewer navigation labels/defaults to Sources/KeyboardShortcutSettings+DiffViewerNavigation.swift (the Simulator pattern); the old .github/swift-file-length-budget.tsv no longer exists.
  • Localization: labels carry translated entries for all nine macOS locales, as localization_catalog.py check now requires.

Changelog

  • Added: cmux diff --unstaged includes untracked files.
  • Added: diff viewer n / p shortcuts jump between hunks.
  • Changed: diff viewer layout and display options persist globally, and Refresh keeps them.
  • Fixed: a clean repo outside a cmux terminal shows the friendly empty diff state.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches git patch assembly and diff-source fallback logic in the CLI plus global shortcut routing; behavior changes are user-visible but localized to the diff viewer path.

Overview
Unstaged diffs now include untracked (non-ignored) files by appending per-file added patches alongside plain git diff, so agent-created files show up in the default unstaged view.

Viewer preferences are read from persisted diff-viewer/preferences.json (with optional CMUX_DIFF_VIEWER_PREFS_PATH): default layout follows the user’s last in-viewer choice before settings defaults, and generated pages seed viewerOptions (wrap, line numbers, etc.) for first paint.

Empty / fallback sources: when the selected diff source is empty, all failed fallback candidates are skipped—not only explicitly empty ones—so a clean repo or missing last-turn context still gets the friendly empty state instead of a raw error.

Keyboard navigation adds rebindable diff-viewer actions: n/p next/previous hunk, ]/[ next/previous file, wired through cmux_open shortcut payloads, CmuxSettings ShortcutAction, defaults, tests, and localized labels.

Reviewed by Cursor Bugbot for commit fce83b0. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Improves the diff viewer: untracked files now appear in Unstaged, layout and display options persist across sessions, Refresh stops resetting them, and n/p jump between hunks. Also restores the friendly diff empty state for clean repos.

  • New Features

    • Untracked, non-ignored files show up in cmux diff --unstaged as added-file patches, capping at 512 paths; untracked files that are unreadable, oversized (over 8 MiB), or over budget are skipped best-effort so the tracked diff still renders.
    • Viewer preferences persist globally to ~/Library/Application Support/cmux/diff-viewer/preferences.json (fixes Cmux Diff resets layout and layout preference is not persisted #5284); the CLI seeds new panels from it (--layout still wins, CMUX_DIFF_VIEWER_PREFS_PATH overrides the path), and localStorage remains the fallback outside cmux.
    • Refresh re-streams the patch in place, preserving layout and all toggles; status-only pages still hard-reload.
    • n/p jump to the next/previous hunk, rebindable via Settings with schema and docs updated.
  • Bug Fixes

Written for commit eff2376. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added keyboard shortcuts for diff viewer navigation: next hunk (n), previous hunk (p), next file (]), previous file ([).
    • Diff viewer layout and display settings now persist across sessions.
    • Unstaged diffs now include untracked files.

@vercel

vercel Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 12, 2026 10:34pm
cmux-staging Building Building Preview, Comment Jun 12, 2026 10:34pm

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds keyboard navigation shortcuts for diff viewer hunks and files, introduces persistent viewer preferences (layout and display toggles) saved to the app's Application Support directory, includes untracked files in unstaged diffs, and makes diff streaming generation-aware to preserve UI state across refresh operations.

Changes

Diff Viewer Navigation, Preferences, and Rendering

Layer / File(s) Summary
Shortcut action definitions and metadata
Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift, Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift, Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift, Sources/KeyboardShortcutSettings.swift, Sources/KeyboardShortcutContext.swift, Resources/Localizable.xcstrings
Four new enum cases (diffViewerNextHunk, diffViewerPrevHunk, diffViewerNextFile, diffViewerPrevFile) are defined and wired through all metadata paths: group assignment, display name labels with localization keys, default keybindings (n, p, ], [), bare-first-stroke support, browser context mapping, and test allowlist updates.
Native preferences store and bridge integration
Sources/DiffViewerPreferencesStore.swift, Sources/Panels/DiffCommentsBridge.swift, cmux.xcodeproj/project.pbxproj
New DiffViewerPreferencesStore singleton provides thread-safe JSON file persistence under Application Support with sanitization (valid layouts, diff indicators, and boolean toggles). Constructor parameter added to DiffCommentsBridge to integrate the store, exposing viewerPrefs.get and viewerPrefs.set methods via the native webkit bridge for web-side access.
Layout resolution and unstaged diff generation
CLI/cmux_open.swift, cmuxTests/CMUXOpenCommandTests.swift
parseDiffViewerLayout now consults persisted preferences when --layout is not explicit. Unstaged diff generation appends patches for untracked files to git output. HTML payload generation injects persisted display toggles (excluding layout) into initial viewer options. Tests verify untracked file inclusion and preference seeding with layout override behavior.
Deferred diff viewer fallback handling
CLI/cmux_open.swift
Fallback candidate loop now skips on render failure instead of treating certain errors as actionable, with updated comments for invalid "last-turn" fallback cases.
Web-side preference persistence and loading
webviews/src/viewer-prefs.ts, webviews/test/viewer-prefs.test.ts
New module introduces ViewerPrefs type and dual-path persistence: native bridge (viewerPrefs.get/set) when available, falling back to localStorage under cmux.diffViewer.options. Sanitization pipeline retains only valid layout/indicator values and known boolean keys. Bridge failures are swallowed gracefully.
Hunk and file navigation utilities
webviews/src/viewer-nav.ts, webviews/test/viewer-nav.test.ts
New module provides hunk anchor construction (buildHunkAnchors), directional hunk index computation (nextHunkIndex with reseeding and bounds clamping), and file-level adjacent-item resolution (adjacentItemId). Comprehensive test coverage includes edge cases and behavior validation.
App state, render generation, and viewer option persistence
webviews/src/App.tsx, webviews/src/types.ts
renderGeneration added to app state for generation-aware streaming. New apply-persisted-options and refresh reducer actions. Stream callbacks made generation-safe via isCurrent() guard. Viewer options seeded from payload.viewerOptions on init. Global setOption callback persists option changes (excluding collapsed). useViewerPrefsBootstrap loads prefs on mount.
Keyboard shortcuts for hunk/file navigation
webviews/src/App.tsx
Keyboard shortcut handlers for next/prev hunk and next/prev file integrated via useKeyboardShortcuts. Navigation ref passed for state sharing. Handlers call navigation functions and prevent default. Effect dependencies updated.
Toolbar and options menu callback wiring
webviews/src/App.tsx
Toolbar component accepts onReload, onSetLayout, and onSetOption callbacks. Option-toggling logic (including diff-indicator style) updated to dispatch onSetOption instead of direct actions. Callbacks wired through to OptionsMenu.
Web shortcut definitions and schema
web/data/cmux-shortcuts.ts, web/data/cmux.schema.json
Four new diff viewer navigation shortcuts added with localized descriptions. JSON schema updated to recognize new action ids in shortcuts.bindings and define their binding schemas.
Initial shortcut assertion in diff command test
cmuxTests/CMUXOpenCommandTests.swift
Test updated to verify four new navigation shortcuts are present in generated HTML with correct keybindings.
Tests for preference persistence and layout overrides
webviews/test/app.test.tsx
New tests verify persisted options appear in payload (invalid keys filtered), --layout overrides persisted layout, viewerOptions seeds display toggles while layout remains controlled by explicit sources, and legacy localStorage layout key is honored.
CLI shortcut action registration
CLI/cmux_open.swift
Four new shortcut actions registered with default keybindings in cmux_open.

Sequence Diagram(s)

The PR involves multiple independent flows rather than a single coordinated sequence. Stream rendering safety, preference persistence, and navigation are three distinct patterns that benefit from independent review but do not interact sequentially in a meaningful way for visualization. A high-level sequence of preference loading at app start would be too simple (three steps: init → bridge/localStorage fetch → apply to state), and navigation or streaming flows lack multi-component interaction that would clarify the diagram beyond the code. Diagrams are omitted as the architectural changes are clearer from code inspection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The changes span multiple areas (native preferences, web rendering safety, keyboard navigation, payload structure) with moderate logic density in preference sanitization and stream generation tracking. Heterogeneous file spread and new module definitions add review scope, but patterns are consistent and test coverage is comprehensive.

Possibly related PRs

  • manaflow-ai/cmux#5016: Both modify CLI/cmux_open.swift deferred diff viewer fallback behavior, with this PR skipping problematic candidates while the earlier PR routes errors to friendly UI.
  • manaflow-ai/cmux#5768: Both extend the DiffCommentsBridge and native webkit bridge, with this PR adding viewerPrefs.get/set methods alongside comment persistence already in that PR.
  • manaflow-ai/cmux#4451: Both modify diff viewer layout and HTML payload generation in CLI/cmux_open.swift, with this PR extending layout resolution and option injection where the earlier PR introduced --layout handling.

Important

Pre-merge checks failed

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

❌ Failed checks (7 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error Sources/DiffViewerPreferencesStore.swift defines final class DiffViewerPreferencesStore: @unchecked Sendable with mutable cached guarded only by NSLock, but no documented safety rationale/c... Remove @unchecked Sendable (or convert to an actor) and/or add a clear /// Safety: explanation that NSLock fully guards all mutable shared state/file writes per Swift 6 sendability rules.
Cmux Swift Blocking Runtime ❌ Error PR adds DiffViewerPreferencesStore with production NSLock (lock.lock()/defer lock.unlock()) to guard cached preferences; the swift-blocking-runtime rules flag manual locks as failures. Refactor the store to use actor/@mainactor ownership instead of NSLock. If you must keep a lock, document why actors can’t be used and justify it as a minimal, non-synchronization bridge.
Cmux Cache Substitution Correctness ❌ Error DiffViewerPreferencesStore caches preferences in-memory and returns cached without re-reading preferences.json; merge sets cached before persistLocked, which ignores write errors—so stale/incorrect... In DiffViewerPreferencesStore, only serve cached after successful disk load+write, and revalidate/refresh cached values (e.g., track file mtime or clear cache when persistence fails/external changes occur).
Cmux Algorithmic Complexity ❌ Error FAIL: CLI/cmux_open.swift (1496-1505) runs git diff --no-index per untracked path; webviews/src/App.tsx (286) rebuilds hunk anchors on every n/p keypress. Cap/batch untracked patch generation (hard/benchmarked limit) instead of per-path git diff; memoize precomputed hunk anchors in App.tsx (useMemo) so n/p doesn’t rescan items each keypress.
Cmux Swift File And Package Boundaries ❌ Error PR adds Sources/DiffViewerPreferencesStore.swift (116 lines) implementing JSON persistence + sanitization in app-root Sources; CLI duplicates persistedDiffViewerPreferences logic (no DiffViewerPref... Extract the diff-viewer preference persistence/sanitization into a small SwiftPM package target with a shared API, then update both the app bridge and CLI to use it (avoiding duplicated parsing).
Cmux Full Internationalization ❌ Error FAIL: Resources/Localizable.xcstrings new diffViewerNext/Prev hunk+file labels have many non-en/ja locales identical to English, and web/data/cmux-shortcuts.ts adds new shortcut descriptions only f... Translate the four new Swift catalog strings for every locale in Resources/Localizable.xcstrings (no copied-English placeholders), and add matching next-intl entries in web/messages for diffViewerNext/Prev hunk/file (or wire the UI to th...
Cmux Architecture Rethink ❌ Error PR adds new DiffViewerPreferencesStore singleton with NSLock and in-memory cached prefs, introducing a new cached lock/state owner forbidden by swift-architectural-rethink rules. Refactor to avoid singleton+cached lock ownership: make an actor/main-thread source of truth (or re-read file per request) and centralize persistence updates in the existing persistence layer with explicit invariants.
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description gives a detailed summary, changelog, test coverage, localization notes, and implementation context. However, it does not follow the required template structure: it lacks the required S… Reformat the description using the repository template. Add Summary, Testing, Demo Video, and Checklist sections. Move test commands and verification results into Testing, provide a demo video or screenshots for the UI changes, state the lo…
✅ Passed checks (12 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes in the changeset: untracked files handling, viewer preference persistence, soft refresh, and keyboard navigation.
Linked Issues check ✅ Passed All code changes comprehensively address the linked issue #5284 requirements: viewer preferences are persisted globally via DiffViewerPreferencesStore, refresh preserves viewer state through soft refr…
Out of Scope Changes check ✅ Passed All changes are directly aligned with the PR objectives: untracked file handling in unstaged diffs, persisted viewer preferences, soft refresh mechanism, keyboard navigation shortcuts, and fallback ha…
Cmux Expensive Synchronous Load ✅ Passed PR diff adds no RestorableAgentSessionIndex.load in production—only 3 deletions in cmuxTests/RestorableAgentSessionIndexTests.swift; SharedLiveAgentIndex.shared has 0 diff occurrences.
Cmux No Hacky Sleeps ✅ Passed Checked runtime-no-hacky-sleeps scope: added/changed TS in webviews adds no setTimeout/sleep usage; viewer-nav/prefs contain no timers. Existing timers unchanged (diff-stream unchanged).
Cmux Swift Concurrency ✅ Passed PR Swift code in cmux-owned files adds no Combine/DispatchGroup/completion-handler APIs; only a minimal hotkey Task { @MainActor } hop is present. No Task.detached.
Cmux Swift @Concurrent ✅ Passed Swift files associated with this PR contain no nonisolated async declarations and no @concurrent attributes; any nonisolated usage found is for synchronous helpers (e.g., defaultFileURL/comme...
Cmux Swift Logging ✅ Passed No banned Swift logging (print/debugPrint/dump/NSLog or file-scoped Logger constants) found in production app code touched by PR; only CLI user-facing print output in CLI/cmux_open.swift (allowed).
Cmux User-Facing Error Privacy ✅ Passed PR user-facing error/copy changes appear limited to diff viewer behavior; no evidence of new messages exposing upstream/vendor/provider names, raw upstream errors, tokens/headers/credentials, or en...
Cmux Swiftui State Layout ✅ Passed SwiftUI-importing file diffs (main..HEAD) add no ObservableObject/@Published/@StateObject/@EnvironmentObject/GeometryReader/LazyVStack/List/ForEach in added lines; only new NSView bridge in Content...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Checked PR-listed Swift files (incl. CLI/cmux_open.swift, DiffViewerPreferencesStore, keyboard/context, DiffCommentsBridge): no NSWindow/NSPanel/WindowGroup code or cmux window-id assignments added...
Cmux Source Artifacts ✅ Passed PR’s changed paths (from provided summary) contain no source-control artifact indicators per .github/review-bot-rules/source-control-artifacts.md (no DerivedData/tmp/artifacts/etc.).
Full details: Description check

Explanation

The description gives a detailed summary, changelog, test coverage, localization notes, and implementation context. However, it does not follow the required template structure: it lacks the required Summary, Testing, Demo Video, and Checklist sections. It also provides no demo video or screenshot attachment and does not explicitly state localization audit or review/checklist status.

Resolution

Reformat the description using the repository template. Add Summary, Testing, Demo Video, and Checklist sections. Move test commands and verification results into Testing, provide a demo video or screenshots for the UI changes, state the localization audit result, and complete the applicable checklist items. Remove or retain generated footer content only if it does not replace the required sections.

✨ 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-diff-viewer-round1

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

Comment thread CLI/cmux_open.swift Outdated
@greptile-apps

greptile-apps Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds four improvements to the diff viewer: untracked files now appear in --unstaged diffs, viewer layout and display options persist globally via a new DiffViewerPreferencesStore and viewerPrefs bridge, Refresh soft-restreams the patch in place instead of reloading the page, and n/p/]/[ keyboard shortcuts navigate between hunks and files.

  • Unstaged + untracked: gitUntrackedPaths (git ls-files --others --exclude-standard) runs after git diff and its results are appended as added-file patches; --layout overrides and .gitignore exclusions are both respected.
  • Persisted prefs: DiffViewerPreferencesStore writes ~/Library/Application Support/cmux/diff-viewer/preferences.json; the webview reads via the viewerPrefs.get bridge at boot and writes via viewerPrefs.set on every options change; the CLI seeds layout and viewerOptions from the same file at generation time.
  • Empty-state fallback fix: the completeDeferredDiffViewerSource fallback loop now catches all errors (not only EmptyDiffSourceError) from fallback candidates and skips them, fixing the dead-end when a last-turn source throws a context error in a clean repo.

Confidence Score: 4/5

Safe to merge with one targeted fix in the fallback loop error handling

The fallback loop's catch was widened from catch is EmptyDiffSourceError to a bare catch, which silences any git or write error thrown by a fallback candidate. A user with staged changes whose git index errors during fallback evaluation would see a friendly empty state rather than the git error, hiding real diff content. The rest of the PR — prefs persistence, soft refresh, keyboard nav, and untracked-file inclusion — is well-structured and thoroughly tested.

CLI/cmux_open.swift around the fallback candidate error catch in completeDeferredDiffViewerSource

Important Files Changed

Filename Overview
CLI/cmux_open.swift Adds untracked-file inclusion in --unstaged, persisted prefs seeding, and navigation shortcuts; the broad catch replacing the typed catch in the fallback loop silently swallows git and write failures from fallback candidates
Sources/DiffViewerPreferencesStore.swift New file; @unchecked Sendable + NSLock with synchronous file I/O called from @mainactor bridge (already flagged in prior thread); sanitize() logic is correct and tested
Sources/Panels/DiffCommentsBridge.swift Adds viewerPrefs.get/set bridge methods ahead of the repoRoot guard; logic is correct and straightforward
webviews/src/App.tsx Adds soft-refresh via renderGeneration, hunk/file keyboard navigation, and viewerPrefs bootstrap; isCurrent() guards prevent stale-stream clobber; logic looks correct
webviews/src/viewer-prefs.ts New module for global prefs persistence via native bridge with localStorage fallback and legacy-key migration; well-tested and correctly sanitized
webviews/src/viewer-nav.ts New hunk/file navigation helpers; edge cases (empty anchors, out-of-bounds, re-seed on file jump) are well-handled and fully tested
Resources/Localizable.xcstrings Adds 4 new shortcut label strings across all 20 locales; en and ja are translated, others fall back to English per established convention
Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift Adds 4 new diff viewer shortcut actions with correct localized labels, defaults, and context registrations

Sequence Diagram

sequenceDiagram
    participant CLI as cmux CLI
    participant FS as preferences.json
    participant WV as Webview (App.tsx)
    participant Bridge as DiffCommentsBridge
    participant Store as DiffViewerPreferencesStore

    CLI->>FS: read (persistedDiffViewerPreferences)
    FS-->>CLI: layout + toggles
    CLI->>WV: generate page (payload.layout, payload.viewerOptions)
    WV->>WV: initialAppState (seeded from payload)
    WV->>Bridge: viewerPrefs.get
    Bridge->>Store: preferences()
    Store->>FS: read (cached)
    Store-->>Bridge: "{layout, wordWrap, ...}"
    Bridge-->>WV: "{ok, value.preferences}"
    WV->>WV: apply-persisted-options (override initial state)
    WV->>Bridge: viewerPrefs.set (on user change)
    Bridge->>Store: merge(updates)
    Store->>FS: write atomically
Loading

Reviews (2): Last reviewed commit: "Retrigger CI (no run scheduled for previ..." | Re-trigger Greptile

Comment on lines +54 to +65
func merge(_ updates: [String: Any]) -> [String: Any] {
let sanitizedUpdates = Self.sanitize(updates)
lock.lock()
defer { lock.unlock() }
var merged = loadLocked()
for (key, value) in sanitizedUpdates {
merged[key] = value
}
cached = merged
persistLocked(merged)
return merged
}

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 Synchronous file I/O under NSLock from the main actor

merge() acquires NSLock and calls persistLocked, which executes FileManager.createDirectory, JSONSerialization.data, and data.write(to:options:.atomic) while holding the lock — all synchronously on the @MainActor bridge thread. DiffCommentsBridge is @MainActor, so every viewerPrefs.set message from the webview runs this I/O on the main thread. The preferences file is small so latency is negligible today, but the @unchecked Sendable + NSLock pattern bypasses actor isolation — the Swift concurrency rule for this codebase flags this where actor isolation or an async off-main path should own coordination.

Comment thread CLI/cmux_open.swift Outdated
Comment on lines +1295 to +1329
private func persistedDiffViewerPreferences() -> [String: Any] {
let fileURL: URL
if let override = ProcessInfo.processInfo.environment["CMUX_DIFF_VIEWER_PREFS_PATH"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
fileURL = URL(fileURLWithPath: override, isDirectory: false)
} else if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first {
fileURL = appSupport
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("diff-viewer", isDirectory: true)
.appendingPathComponent("preferences.json", isDirectory: false)
} else {
return [:]
}
guard let data = try? Data(contentsOf: fileURL),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
var sanitized: [String: Any] = [:]
if let layout = object["layout"] as? String, layout == "split" || layout == "unified" {
sanitized["layout"] = layout
}
if let indicators = object["diffIndicators"] as? String,
indicators == "bars" || indicators == "classic" || indicators == "none" {
sanitized["diffIndicators"] = indicators
}
for key in ["wordWrap", "wordDiffs", "lineNumbers", "showBackgrounds", "expandUnchanged"] {
if let value = object[key] as? Bool {
sanitized[key] = value
}
}
return sanitized
}

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 Sanitization logic duplicated from DiffViewerPreferencesStore.sanitize()

persistedDiffViewerPreferences() re-implements the same validation (layout enum, diffIndicators enum, five boolean keys) that DiffViewerPreferencesStore.sanitize() already encodes. The CLI cannot import the app target so the duplication is unavoidable at the binary level, but the two tables will drift if a new preference key is added to one side and missed on the other. A shared comment or schema reference would make it obvious when they need to be kept in sync.

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!

Comment thread CLI/cmux_open.swift Outdated
Comment on lines +1500 to +1505
patch = try joinedGitDiffPatches(
[gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)]
+ gitUntrackedPaths(in: repoRoot).map { path in
try gitAddedUntrackedPatch(path: path, in: repoRoot)
}
)

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.

P1 gitUntrackedPaths failure aborts the entire --unstaged diff

gitUntrackedPaths is now throws. If git ls-files --others --exclude-standard fails for any reason, the thrown error propagates out and the caller receives no diff at all — not even the regular git diff output already computed in [gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)]. Previously --unstaged never called ls-files, so this is a new failure mode. A non-throwing helper that returns [] on error would degrade gracefully: users see the staged/modified changes even when ls-files has a problem.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9fa2556. Configure here.

Comment thread CLI/cmux_open.swift Outdated
+ gitUntrackedPaths(in: repoRoot).map { path in
try gitAddedUntrackedPatch(path: path, in: repoRoot)
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One bad untracked fails unstaged

Medium Severity

Building the unstaged patch runs git diff and then generates a patch for every untracked path in one throwing chain. If any single untracked path cannot be diffed, the whole unstaged read fails, so tracked unstaged edits and other untracked files never appear in the viewer.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9fa2556. Configure here.

@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

Caution

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

⚠️ Outside diff range comments (1)
webviews/src/App.tsx (1)

1257-1329: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Soft refresh starts overlapping diff streams without cancellation.

The generation guard prevents stale callbacks from mutating state, but previous streamPatch runs still continue after refresh. Repeated refreshes can stack concurrent parse/fetch work and degrade responsiveness. Add effect cleanup cancellation (AbortSignal or explicit stream cancel handle) so superseded generations stop work, not just ignore callbacks.

🤖 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 `@CLI/cmux_open.swift`:
- Around line 1497-1505: The current unstaged-patch generation code iterates
gitUntrackedPaths and calls gitAddedUntrackedPatch for each path, spawning an
unbounded subprocess per file (see gitUntrackedPaths, gitAddedUntrackedPatch,
joinedGitDiffPatches, gitStdout, gitDiffPatchArguments); change this to a
bounded strategy by introducing a configurable batch size constant (e.g.
UNTRACKED_BATCH_SIZE) and produce patches in batches (group N paths, call
gitAddedUntrackedPatch or a batched variant for that group) and if there are
more files than the cap append a single synthetic "TRUNCATED_UNTRACKED_FILES"
patch or metadata entry to signal truncation so callers know results are partial
and avoid per-file process explosion.
- Around line 1282-1288: Update the CLI help/usage text that describes the
--layout flag to state that when --layout is omitted the app will prefer the
user's persisted viewer preference (persistedDiffViewerPreferences()["layout"])
over the settings-file default (diffViewerDefaultLayoutSetting()), rather than
always falling back to the settings default; locate the string that documents
the --layout option in the CLI usage/help output and change its wording to
reflect this new precedence (persisted viewer prefs win, then settings default,
then "unified" fallback).

In `@Sources/DiffViewerPreferencesStore.swift`:
- Around line 21-23: preferences() currently trusts the in-memory cached
dictionary and never re-reads preferences.json, and merge() updates cached
before the file write succeeds; change behavior so the on-disk file is
authoritative: acquire lock, read and decode fileURL (preferences.json) on each
call to preferences() to refresh stale/cold state before returning; in merge(),
merge into a temporary copy, write the updated JSON to disk first (handling
write errors), and only after a successful write update the in-memory cached
property; ensure all file read/write and cached accesses are protected by the
existing lock (NSLock) and keep fileURL, cached, preferences(), and merge() as
the key symbols to modify.

In `@webviews/src/App.tsx`:
- Around line 284-287: navigateHunk is rebuilding
buildHunkAnchors(current.items) on every keystroke causing repeated O(n) scans;
instead compute and cache the anchors when the source collection changes (e.g.,
when latestState.current.items updates) and have navigateHunk read from that
cached value (store in a ref or on latestState) so nextHunkIndex and
hunkNavIndex.current use the precomputed anchors; apply the same caching for the
other hot-path call sites noted (around the 321-326 block) to avoid repeated
full scans.
🪄 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: df35aa03-24e5-436a-a8c9-b47f236afa37

📥 Commits

Reviewing files that changed from the base of the PR and between aeb8847 and 87a764b.

📒 Files selected for processing (21)
  • CLI/cmux_open.swift
  • Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift
  • Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
  • Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift
  • Resources/Localizable.xcstrings
  • Resources/markdown-viewer/webviews-app/chunks/diffSurface.mjs
  • Sources/DiffViewerPreferencesStore.swift
  • Sources/KeyboardShortcutContext.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/Panels/DiffCommentsBridge.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/CMUXOpenCommandTests.swift
  • web/data/cmux-shortcuts.ts
  • web/data/cmux.schema.json
  • webviews/src/App.tsx
  • webviews/src/types.ts
  • webviews/src/viewer-nav.ts
  • webviews/src/viewer-prefs.ts
  • webviews/test/app.test.tsx
  • webviews/test/viewer-nav.test.ts
  • webviews/test/viewer-prefs.test.ts

Comment thread CLI/cmux_open.swift Outdated
Comment on lines 1282 to 1288
// The user's last in-viewer layout choice (persisted by the app's
// viewerPrefs bridge) wins over the settings-file default, so new diff
// panels open the way the user last left one (#5284).
if let persisted = persistedDiffViewerPreferences()["layout"] as? String {
return (persisted, "default")
}
return (diffViewerDefaultLayoutSetting() ?? "unified", "default")

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

Update CLI usage text for the new layout precedence.

Layout now prefers persisted viewer prefs when --layout is omitted, but Line 6191 still says the default is unified/settings-based. That help output is now misleading.

Suggested help-text update
-          --layout <split|unified>     Diff layout (default: unified; configurable via diffViewer.defaultLayout in cmux.json)
+          --layout <split|unified>     Diff layout (default: last persisted viewer layout; otherwise diffViewer.defaultLayout or unified)
🤖 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 `@CLI/cmux_open.swift` around lines 1282 - 1288, Update the CLI help/usage text
that describes the --layout flag to state that when --layout is omitted the app
will prefer the user's persisted viewer preference
(persistedDiffViewerPreferences()["layout"]) over the settings-file default
(diffViewerDefaultLayoutSetting()), rather than always falling back to the
settings default; locate the string that documents the --layout option in the
CLI usage/help output and change its wording to reflect this new precedence
(persisted viewer prefs win, then settings default, then "unified" fallback).

Comment thread CLI/cmux_open.swift Outdated
Comment on lines +1497 to +1505
// Untracked files are part of the unstaged working-tree state but
// plain `git diff` omits them, which silently hides files agents
// just created. Append an added-file patch per untracked path.
patch = try joinedGitDiffPatches(
[gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)]
+ gitUntrackedPaths(in: repoRoot).map { path in
try gitAddedUntrackedPatch(path: path, in: repoRoot)
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound untracked patch fan-out in --unstaged generation.

Line 1502 spawns one git diff --no-index subprocess per untracked path. This is an unbounded per-target batch scan and can become very slow in large repos with many untracked files. Add a bounded/benchmarked strategy (e.g., explicit cap + truncation signaling, or a batched generation path) before merge.

As per coding guidelines, production scalable paths must flag per-target rescans and unbenchmarked algorithm choices.

🤖 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 `@CLI/cmux_open.swift` around lines 1497 - 1505, The current unstaged-patch
generation code iterates gitUntrackedPaths and calls gitAddedUntrackedPatch for
each path, spawning an unbounded subprocess per file (see gitUntrackedPaths,
gitAddedUntrackedPatch, joinedGitDiffPatches, gitStdout, gitDiffPatchArguments);
change this to a bounded strategy by introducing a configurable batch size
constant (e.g. UNTRACKED_BATCH_SIZE) and produce patches in batches (group N
paths, call gitAddedUntrackedPatch or a batched variant for that group) and if
there are more files than the cap append a single synthetic
"TRUNCATED_UNTRACKED_FILES" patch or metadata entry to signal truncation so
callers know results are partial and avoid per-file process explosion.

Source: Coding guidelines

Comment on lines +21 to +23
private let lock = NSLock()
private let fileURL: URL?
private var cached: [String: Any]?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the file authoritative over the in-memory cache.

After the first load, preferences() never revalidates preferences.json, and merge() updates cached before the write succeeds. Any external update or local write failure can leave the bridge serving a different preference set than the persisted file, which breaks this persistence path’s source-of-truth contract.

As per coding guidelines, cached substitutions in persistence paths must handle cold and stale caches explicitly.

Also applies to: 54-64, 83-113

🧰 Tools
🪛 SwiftLint (0.63.3)

[Warning] 23-23: Prefer empty collection over optional collection

(discouraged_optional_collection)

🤖 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/DiffViewerPreferencesStore.swift` around lines 21 - 23, preferences()
currently trusts the in-memory cached dictionary and never re-reads
preferences.json, and merge() updates cached before the file write succeeds;
change behavior so the on-disk file is authoritative: acquire lock, read and
decode fileURL (preferences.json) on each call to preferences() to refresh
stale/cold state before returning; in merge(), merge into a temporary copy,
write the updated JSON to disk first (handling write errors), and only after a
successful write update the in-memory cached property; ensure all file
read/write and cached accesses are protected by the existing lock (NSLock) and
keep fileURL, cached, preferences(), and merge() as the key symbols to modify.

Source: Coding guidelines

Comment thread webviews/src/App.tsx Outdated
Comment on lines +284 to +287
const navigateHunk = (direction: 1 | -1) => {
const current = latestState.current;
const anchors = buildHunkAnchors(current.items);
const index = nextHunkIndex(anchors, hunkNavIndex.current, current.activeItemId, direction);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid rebuilding all hunk anchors on every next/prev-hunk keystroke.

navigateHunk currently calls buildHunkAnchors(current.items) per keypress, which does a full scan each time. On large diffs, this creates avoidable repeated O(n) work on an interaction hot path.

♻️ Proposed fix
-import { useCallback, useEffect, useReducer, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
@@
-  const hunkNavIndex = useRef(-1);
+  const hunkNavIndex = useRef(-1);
+  const hunkAnchors = useMemo(() => buildHunkAnchors(state.items), [state.items]);
@@
-    const anchors = buildHunkAnchors(current.items);
+    const anchors = hunkAnchors;

As per coding guidelines, production code over scalable user data should flag repeated full-collection scans in hot paths.

Also applies to: 321-326

🤖 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 `@webviews/src/App.tsx` around lines 284 - 287, navigateHunk is rebuilding
buildHunkAnchors(current.items) on every keystroke causing repeated O(n) scans;
instead compute and cache the anchors when the source collection changes (e.g.,
when latestState.current.items updates) and have navigateHunk read from that
cached value (store in a ref or on latestState) so nextHunkIndex and
hunkNavIndex.current use the precomputed anchors; apply the same caching for the
other hot-path call sites noted (around the 321-326 block) to avoid repeated
full scans.

Source: Coding guidelines

Comment thread CLI/cmux_open.swift
Comment on lines +3957 to 3964
} catch {
// A fallback candidate that cannot be read (empty, or e.g.
// last-turn without a workspace/surface context) is skipped;
// only the originally selected source's own failure may
// surface. Otherwise `cmux diff --unstaged` in a clean repo
// outside a cmux terminal dead-ends on a raw last-turn
// context error instead of the friendly empty state (#5246).
continue

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.

P1 Broad catch silences git failures during fallback evaluation

The outer catch block (changed from catch is EmptyDiffSourceError) now swallows every error thrown by writeDeferredDiffViewerSource for every fallback candidate — including git failures (e.g., corrupted index, git binary missing) and disk-write errors that indicate real problems.

Concrete failure: the user's --unstaged view is empty, the staged fallback is tried, git returns an error while reading the staged index (corrupted pack or misconfigured git config). The old code would surface that error; the new code silently falls through to the friendly empty state. The user now sees "no changes" when there are actually staged diffs that failed to load.

The intended fix was specifically for the case where last-turn context throws a non-EmptyDiffSourceError because there is no workspace/surface context. Catching that specific error class (or matching on it) would avoid swallowing genuine git or write failures from other fallback sources.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

cmux-reconcile: partly-useful

Usefulness verdict: Separate the remaining diff-viewer improvements and drop already-covered navigation behavior.

The diff bundles untracked unstaged files, global preferences, soft refresh, and hunk/file navigation. Current shortcut registry already includes next/previous-file actions; diff source implementation already has untracked baseline handling for last-turn, which must not be mistaken for unstaged-source coverage. Audit each behavior separately and retain only missing unstaged/refresh/preferences/hunk functionality. Do not describe this as adding the whole review viewer from #609.

Reviewed patch head: fce83b0eed08d67dd38c690d804926e35f055d96. Source/diff triage on September 18, 2026; no new build or runtime validation. No issue state, label, or merge decision changed.

Older issue/PR tracking index — remaining scope and competing implementations are recorded there.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Mac fleet instructions for head fce83b0eed08d67dd38c690d804926e35f055d96. Planned tag: pr-6010-fce83b0e; this is not yet a published build.

JOB_JSON=$(~/.local/bin/cmux-ci submit --kind cmux --command 'CMUX_FLEET_BUILD_TAG=pr-6010-fce83b0e /Users/Shared/cmux-build-fleet/recipes/cmux.sh https://github.com/manaflow-ai/cmux.git fce83b0eed08d67dd38c690d804926e35f055d96' --artifact artifacts/cmux.app.zip --workspace https://github.com/manaflow-ai/cmux/pull/6010 --source-digest fce83b0eed08d67dd38c690d804926e35f055d96 --cache-key cmux:pr-6010 --min-free-bytes 268435456000 --label cmux --label ram48)
JOB_ID=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$JOB_JSON")
~/.local/bin/cmux-ci wait "$JOB_ID" --receipt artifacts/fleet/$JOB_ID.json
~/.local/bin/cmux-ci publish-hq "$JOB_ID"

Use an existing campaign job ID if one is already posted; do not submit a duplicate. A wait timeout leaves the remote job running. Published results will include an exact-head artifact link and timing/disk receipt. This recipe validates the macOS app only, not iOS or tests. Never use maclease or put credentials in a PR comment.

…s, hunk navigation

Regression tests for diff viewer round 1, replayed on current main:

- Rust sidecar and CLI: `cmux diff --unstaged` must include untracked
  (non-ignored) files as added-file patches, and an untracked-only working
  tree is not an empty diff.
- CLI: persisted viewer preferences seed the page's `layout`
  (`layoutSource: default`) and a sanitized `viewerOptions` payload;
  `--layout` still wins.
- CLI: the shortcut payload carries `diffViewerNextHunk` (`n`) and
  `diffViewerPreviousHunk` (`p`).
- webviews: viewer-prefs sanitizer and bridge/localStorage fallback,
  hunk anchor navigation helpers, bridge-synced options at boot, option
  changes persisting through `viewerPrefs.set`, soft refresh re-opening the
  typed session without a page reload, and hunk actions handled by the app.

Red: `bun test test/app.test.tsx test/viewer-prefs.test.ts
test/viewer-hunks.test.ts` fails 7 tests (2 missing modules, 5 behavioral).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lawrencecchen
lawrencecchen force-pushed the feat-diff-viewer-round1 branch from fce83b0 to 1d49383 Compare September 29, 2026 06:35
@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

CI fast guards passes on eff2376060 (https://github.com/manaflow-ai/cmux/actions/runs/36659502557).

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

CI failure attribution

CI passes on eff2376060 (run 36659502841 attempt 2).

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's.

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Dogfood tours of eff23760

sidebar-and-chrome-tour at eff23760: not run

skipped: CI built this head on a runner pool whose products the UI test Macs cannot load, and media never compiles one; gh workflow run pr-media.yml -f pr=&lt;n&gt; -f allow_compile=true does

Tours are picked by the paths globs in dogfood/scenarios/*.json; a Dogfood-tours: a, b line in the description picks them instead (none turns this off). Look at every frame before merging: a green tour only means no step failed.

…efresh, hunk navigation

Replay of the round-1 diff viewer work on current main.

Untracked files in the unstaged source. The Rust sidecar's unstaged session
patch and the CLI's legacy unstaged source now append one `git diff
--no-index -- /dev/null <path>` added-file patch per untracked, non-ignored
path (bounded at 512 paths), so files an agent just created show up in the
default review view and an untracked-only tree is no longer the empty state.

Viewer preferences persist globally (#5284). `DiffViewerPreferencesStore`
keeps layout and the options-menu toggles in
`~/Library/Application Support/cmux/diff-viewer/preferences.json`; the
webview saves through new `viewerPrefs.get`/`viewerPrefs.set` methods on the
`cmuxDiffComments` bridge and re-syncs at boot. The CLI reads the same file
(`CMUX_DIFF_VIEWER_PREFS_PATH` overrides it) so new diff panels open with the
last-used layout and a sanitized `viewerOptions` payload seeds the toggles at
first paint; `--layout` still wins. `localStorage` remains the fallback for
pages opened outside cmux, with the legacy layout-only key still read.

Refresh preserves viewer state. The options-menu Refresh re-opens the typed
session in place (bumping a render generation the render effect depends on)
instead of `window.location.reload()`, so layout and toggles survive; pages
with nothing to re-stream keep the full reload.

Hunk navigation. `n` / `p` jump to the next / previous hunk
(`diffViewerNextHunk` / `diffViewerPreviousHunk`), routed through the native
viewer navigation key router like the existing `] f` / `[ f` file jumps.
Wired through `KeyboardShortcutSettings`, the `CmuxSettings` `ShortcutAction`
enum, the CLI shortcut payload, the `cmux.json` schema (regenerated embedded
copy), the shortcut docs data, the settings action list and Settings; all
rebindable. Labels are localized for all nine macOS locales.

Deferred fallback chain skips unusable candidates. In the legacy deferred
empty-state path a fallback source that fails for a non-empty reason (last
turn without workspace context) no longer surfaces its raw error.

Layout resolution and the preference reader moved from `cmux_open.swift` to
`CMUXCLI+DiffViewerPreferences.swift`; the diff viewer navigation labels and
defaults moved to `KeyboardShortcutSettings+DiffViewerNavigation.swift`
following the Simulator pattern, keeping both over-budget files from growing.

Green: `bun test` (263 pass), `bun run typecheck`, `bun run lint:ci`,
`build-webviews-app.sh --check`, `check-webviews-react-compiler.mjs`,
`swift_file_length_budget.py`, `wire-app-sources.py --check`,
`localization_catalog.py check`, `verify-local.py --only swift-syntax`,
`tests.test_cmux_settings_supported_paths`, rustfmt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lawrencecchen
lawrencecchen force-pushed the feat-diff-viewer-round1 branch from 1d49383 to 55806f6 Compare September 29, 2026 06:57
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
lawrencecchen and others added 3 commits September 29, 2026 18:59
# Conflicts:
#	Resources/markdown-viewer/webviews-app/chunks/diffSurface.mjs
#	webviews/src/App.tsx
…il the unstaged diff

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Untracked files are appended best effort in both the sidecar and the CLI:
a listing failure, a file git cannot diff, or an added-file patch over the
remaining budget (or over 8 MiB) is left out instead of failing the session
and hiding the tracked git diff. Update the --layout help to the new
default order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Sep 30, 2026
github-actions Bot added a commit that referenced this pull request Sep 30, 2026
github-actions Bot added a commit that referenced this pull request Sep 30, 2026
github-actions Bot added a commit that referenced this pull request Sep 30, 2026
github-actions Bot added a commit that referenced this pull request Sep 30, 2026
github-actions Bot added a commit that referenced this pull request Sep 30, 2026
@lawrencecchen
lawrencecchen merged commit ddc7ae0 into main Sep 30, 2026
133 of 134 checks passed
@lawrencecchen
lawrencecchen deleted the feat-diff-viewer-round1 branch September 30, 2026 03:03
@github-actions

Copy link
Copy Markdown
Contributor

Merge receipt for eff2376060: every check was green at merge (37 verified; 22 skipped by policy). Full suite runs on main after merge.

rustybret pushed a commit to rustybret/bmux that referenced this pull request Sep 30, 2026
ddc7ae0 Diff viewer: untracked files in unstaged, persisted viewer prefs, soft refresh, hunk/file navigation (manaflow-ai#6010)
02b6f05 Keep split-divider-color on the split divider only (manaflow-ai#15093)
teamleaderleo added a commit to teamleaderleo/cmux that referenced this pull request Sep 30, 2026
* List shared sizing actions in the dock tab switch

Main's dock tab context handler predates the sizing actions, so the merge
left its switch non-exhaustive and raised a new Swift warning.

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

* Link CmuxTerminalSizing in the Cloud command fixture

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

* Count the shared sizing commands and event in SDK coverage tests

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

* Replace namespace-only iOS sizing helpers with values

TerminalGridFit.mode becomes TerminalGridFitMode.init, requestedPixelSize
moves onto TerminalNaturalGridMeasurement, and the chrome gate, band clip
and viewport parameters hold their stable inputs as instances.

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

* Bump bonsplit: side-by-side presence avatars

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

* docs: explain native notification banner lifetime (#15763)

* ci: register the nightly owned-Mac producer on the fork default branch

GitHub only dispatches workflows that exist on the default branch; the
content that runs comes from the dispatched ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: explain native notification banner lifetime

* Revert "ci: register the nightly owned-Mac producer on the fork default branch"

This reverts commit aa0ba600658c0788f348a7464aa9fa0c3cfa3a13.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve remote command when persistent session is missing (#15764)

* Pass the cell height in the keyboard pin tests

renderRect now takes the cell height to decide top or bottom pinning.
These tests render a full-height natural grid, which stays bottom-pinned.

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

* test: re-enable six app-host regressions after fixture repairs (#15765)

* test: re-enable app-host coverage after fixture repairs

* test: exercise browser drag exit callback

* test: use registered pane transfer for browser drag lifecycle

* Test sizing host lifetime, fixed-size limit and phone reconnect ordering

Red: a closed terminal keeps its local sizing host, the socket accepts a
70000-column fixed grid, and the phone drops a new host's generation 1.

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

* Bound sizing host lifetime, fixed sizes and phone state ordering

- cleanupSurfaceState removes a closed terminal's local sizing host,
  controller and store snapshot; a moved surface keeps its host.
- Socket and phone policy entrypoints reject a fixed grid above the
  size panel's 500 x 200 (TerminalSizingPolicy.maximumFixedSize).
- The phone forgets published size states when its Mac connection
  ends, so a relaunched host's generation 1 is accepted.
- A font-size change re-reports the Mac pane's natural grid, not only
  a pixel-size change.

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

* Add a sizing fixture for a zero viewport on attach

Rust clamps an attached viewport to 2 x 1; the Swift twin kept 0 x 0 for
a decoded participant.

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

* Clamp an attached viewport in the Swift sizing reducer

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

* Install updates automatically at a quiet moment and resume agents (#15296)

* Install updates automatically at a quiet moment; explicit installs relaunch right away

An explicit Install and Relaunch, Restart Now or Install Now relaunches as soon
as the app has captured its sessions (#15084). With the new Install Updates
Automatically setting (on by default for nightly), Sparkle downloads updates in
the background and the relaunch waits for no busy agent, no running command and
a minute without input. Every update relaunch first takes a fresh process scan,
so agents started since the last scan are saved as running and resume.

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

* Classify agents by resume safety for update relaunches

Safe and care agents resume after the relaunch, so only risky agents (a
foreground command, an unanswered prompt) and other running commands hold it.
An install the user asks for relaunches right away unless something is risky;
then the popover lists every agent with a safety chip and offers Wait, Update
When These Finish and Update Anyway. Remote cmux ssh agents keep running on
their host and count as safe.

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

* Localize update relaunch safety strings

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

* Nudge agents cut off mid-task to continue after an update relaunch

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

* Test that a menu install asks first while risky agents hold an automatic update

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

* Ask before a menu install stops risky work; expire unused continuation nudges

A menu install while risky agents hold an automatic update switches the
popover to asking instead of stopping them. A continuation nudge the
restore never used expires after ten minutes, and a failed relaunch stops
marking panels after a minute.

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

* Read mutating index captures outside the test macros

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

* Dogfood updates from CMUX_UPDATE_DOGFOOD_FEED_URL

A CMUX_UI_TEST_* variable marks the process as a test host, which never
starts the updater, so the DEV-build dogfood opt-in needs its own feed
variable.

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

* Test that the launch check downloads when installs are automatic

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

* Download at launch when updates install automatically

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

* test: cover disabling a pending automatic update

* fix: defer pending relaunch when automatic updates are disabled

* fix: guard update relaunch preparation and nudge identity

* test: pass checkpoint IDs to continuation nudge prompts

* Harden update relaunch policy and continuation state

* Add update relaunch regression coverage

* Make relaunch marking test time independent

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix Feature Flags window trapping Cmd-` and ignoring Cmd-W (#15565)

* test: Feature Flags window owns Cmd-W and releases on close

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

* fix: make Feature Flags a released NSWindow that owns Cmd-W

The Feature Flags inspector was a never-released NSPanel with no
identifier. A panel hides on app deactivation; after a deactivate and
reactivate it can stay ordered out in the WindowServer while AppKit still
counts it visible, so Cmd-` cycled focus into an invisible window. Without
an identifier, Cmd-W on it fell through to closing the focused terminal
panel in the main window.

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

* test: prove Feature Flags close releases the window by reopening it

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Settle viewport reports by ID in iOS geometry tests

The spacing harness confirmed echoes with the no-argument call, which
never ends the report handshake, so the sizing chrome gate kept the
letterbox border hidden. Production settles by report ID first; the
harness now does the same. A grid larger than the phone now renders
exactly and scaled to fit, so the verified replay test expects the
exact grid before the viewport grows.

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

* fix(cloud): make display creation self-starting (#15744)

* fix(cloud): make display creation self-starting

* fix(cloud): sanitize display creation failures

* fix(cloud): keep display creation button clickable

* fix(cloud): preserve display capability state

* test: cover pending display creation and capability discovery

* fix: coalesce display creation state access

* Revert "test: cover pending display creation and capability discovery"

This reverts commit dab9ebd018f362458535e89e93742a3f04d00d65.

* test(ios): cover browser update hint during reconnect

* Fix typed diff session patch loading (#14538)

* test: reproduce typed diff session patch refresh

* fix: refresh typed diff session manifests on patch fetch

* docs: clarify diff manifest refresh lifecycle

* fix(browser): keep diff toolbar controls clickable (#14525)

* test: keep browser content clickable in titlebar band

* fix: keep browser titlebar content clickable

* Revert "Fix live terminal surfaces that never present a frame (#15520)" (#15788)

This reverts commit 1bc6e61f6d0bcd7bb37ca2ee0c66450630c35ab8.

* Load diff viewer grammars lazily and drop dead vendored Pierre bundles (#15576)

* ci: budget the diff viewer's eagerly evaluated JS

`scripts/check-webviews-diff-budget.mjs` walks the committed webviews bundle
from `main.mjs` and `chunks/diffSurface.mjs` through static imports, sums the
bytes the diff viewer evaluates on every open, and fails above 1.5 MB or when a
shiki grammar, theme or WASM chunk is reachable statically. Wired into the
react-apps-check job. On main the diff surface evaluates 10.74 MB because
`chunks/diff-vendor.mjs` inlines every TextMate grammar and theme, so this
check is red until the next commit.

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

* Load diff viewer grammars lazily and drop the dead vendored Pierre bundles

The diff surface statically imports `@pierre/diffs`, whose `diff-vendor`
chunk collapsed every shiki grammar, theme and the Oniguruma WASM blob into
one 10.28 MB module evaluated on every `cmux diff` open. Each of those is
already a dynamic import inside shiki, so the Vite config now keeps them as
stably named lazy chunks (`chunks/shiki-lang-<name>.mjs`,
`chunks/shiki-theme-<name>.mjs`, `chunks/shiki-wasm.mjs`,
`chunks/pierre-theme-<name>.mjs`). The main thread fetches only the grammars
for the languages in the diff and posts them to the worker pool as before.
Eager JS for the diff surface drops from 10,740,316 to 1,241,872 bytes;
`diff-vendor.mjs` is 777 KB.

`Resources/markdown-viewer/diff-viewer` also shipped `diffs.mjs`, `trees.mjs`,
`worker-pool.mjs`, `worker-portable.mjs` and two 350-file grammar chunk
directories (22 MB) that nothing loaded: the webviews app bundles its own
`@pierre/diffs` and `@pierre/trees`, and the worker resolves grammars on the
main thread. Only `worker-portable.js` and its WASM file remain, guarded by a
test that they match the installed `@pierre/diffs` build. The CLI no longer
requires or advertises the removed entry modules; `config.assets` carries
`workerModuleURL` only. The per-token allowlist cap is 4096 files, so the
~330 registered files stay well inside it.

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

* test: expect the diff highlight worker to be a chunk of the webviews bundle

The worker must be emitted as `chunks/diff-worker.mjs` by the same Rollup
graph as `main.mjs`, statically import only `shiki-core` (never React, the
main-thread renderer, a grammar, a theme or the WASM chunk) and be spawned
from the diff surface with `new URL("./diff-worker.mjs", import.meta.url)`.
The vendored `Resources/markdown-viewer/diff-viewer` copy must be gone, the
CLI must stop advertising `assets.workerModuleURL`, and the budget script
also caps the worker's eager bytes. Red until the next commit.

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

* Build the diff highlight worker from @pierre/diffs with Vite and drop the vendored copy

The highlight worker pool loaded a vendored prebuilt
`Resources/markdown-viewer/diff-viewer/worker-pool/worker-portable.js`
(505 KB) that carried its own copy of shiki, so shiki core was parsed once on
the main thread and again in each of the 3 pool workers, and a drift test had
to keep the vendored file equal to the installed package.

`src/diff-worker.ts` is now a second Rollup entry of the webviews bundle,
importing `@pierre/diffs/worker/worker.js`. Because both entries share one
graph, shiki core, the Oniguruma engines, `diff` and the transformers land
in one `chunks/shiki-core.mjs` (210 KB) imported by the page and the worker,
and the WASM blob is one lazy `chunks/shiki-wasm.mjs` file for both. The
worker entry itself is 27 KB (Pierre's inlined worker code) and statically
imports only `shiki-core` and Vite's preload helper, which now sits in its
own 1.3 KB chunk instead of the React `vendor` chunk so the worker never
evaluates React or the main-thread renderer. A tiny plugin marks the package
worker file as side-effectful because `@pierre/diffs` declares
`sideEffects: false`, which tree-shook the entry to an empty chunk.

The diff surface spawns `new URL("./diff-worker.mjs", import.meta.url)`
(a sibling of `chunks/diffSurface.mjs`), so the CLI no longer copies a
second asset directory or advertises `assets.workerModuleURL`; asset
discovery looks for `markdown-viewer/webviews-app` directly and the
`diff-viewer-app` legacy candidate is gone with the vendored directory.
`worker-pool.ts` mirrors pool counters (entry URL, workers created,
messages, errors) onto `<html data-cmux-diff-worker-*>` so a debug-socket
eval, which runs in an isolated world, can prove the workers run in a hidden
web view where Pierre never paints tokens.

Eager JS: page 1,241,919 bytes (unchanged), worker 238,131 bytes per worker
(was 505,204 vendored, plus a 622 KB WASM loader no longer duplicated on
disk). `scripts/check-webviews-diff-budget.mjs` now also caps the worker
closure at 400 KB and forbids `diff-vendor`/`vendor` in it.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ios): hide browser update hint while reconnecting

* Register a main window context in the sizing host tests

Socket targets resolve a surface through the main window contexts, so
the tests could not create a host without one.

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

* test: use a reserved host in team origin tests

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

* test: cover team review findings before fixing them

Team nav active state for encoded ids and prefixes, invite role restore
on a failed send, resend ordering, and the client/server link use cap.

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

* teams: fix review findings on invites and team nav

- A failed re-invite restores the previous stored role (or deletes a new
  one), since the older invitation stays valid.
- Resend invites first and revokes the old code after, so a failed send
  never strands the recipient.
- Team nav matches the encoded href with a path boundary.
- One shared invite limits module; the form's max-uses check and message
  now use the server cap of 1000.

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

* Keep split-divider-color on the split divider only (#15093)

* test: split-divider-color must not recolor pane borders

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

* Keep split-divider-color on the split divider only

#12066 put Ghostty's split-divider-color into Bonsplit's borderHex, which
also colors every tab-bar underline and pane border. Pass it through the
new divider-only dividerHex instead, so the other borders keep the chrome
separator. An explicit pane border color still colors every border.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Diff viewer: untracked files in unstaged, persisted viewer prefs, soft refresh, hunk/file navigation (#6010)

* Add failing tests: untracked files in unstaged, persisted viewer prefs, hunk navigation

Regression tests for diff viewer round 1, replayed on current main:

- Rust sidecar and CLI: `cmux diff --unstaged` must include untracked
  (non-ignored) files as added-file patches, and an untracked-only working
  tree is not an empty diff.
- CLI: persisted viewer preferences seed the page's `layout`
  (`layoutSource: default`) and a sanitized `viewerOptions` payload;
  `--layout` still wins.
- CLI: the shortcut payload carries `diffViewerNextHunk` (`n`) and
  `diffViewerPreviousHunk` (`p`).
- webviews: viewer-prefs sanitizer and bridge/localStorage fallback,
  hunk anchor navigation helpers, bridge-synced options at boot, option
  changes persisting through `viewerPrefs.set`, soft refresh re-opening the
  typed session without a page reload, and hunk actions handled by the app.

Red: `bun test test/app.test.tsx test/viewer-prefs.test.ts
test/viewer-hunks.test.ts` fails 7 tests (2 missing modules, 5 behavioral).

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

* Diff viewer: untracked files in unstaged, global viewer prefs, soft refresh, hunk navigation

Replay of the round-1 diff viewer work on current main.

Untracked files in the unstaged source. The Rust sidecar's unstaged session
patch and the CLI's legacy unstaged source now append one `git diff
--no-index -- /dev/null <path>` added-file patch per untracked, non-ignored
path (bounded at 512 paths), so files an agent just created show up in the
default review view and an untracked-only tree is no longer the empty state.

Viewer preferences persist globally (#5284). `DiffViewerPreferencesStore`
keeps layout and the options-menu toggles in
`~/Library/Application Support/cmux/diff-viewer/preferences.json`; the
webview saves through new `viewerPrefs.get`/`viewerPrefs.set` methods on the
`cmuxDiffComments` bridge and re-syncs at boot. The CLI reads the same file
(`CMUX_DIFF_VIEWER_PREFS_PATH` overrides it) so new diff panels open with the
last-used layout and a sanitized `viewerOptions` payload seeds the toggles at
first paint; `--layout` still wins. `localStorage` remains the fallback for
pages opened outside cmux, with the legacy layout-only key still read.

Refresh preserves viewer state. The options-menu Refresh re-opens the typed
session in place (bumping a render generation the render effect depends on)
instead of `window.location.reload()`, so layout and toggles survive; pages
with nothing to re-stream keep the full reload.

Hunk navigation. `n` / `p` jump to the next / previous hunk
(`diffViewerNextHunk` / `diffViewerPreviousHunk`), routed through the native
viewer navigation key router like the existing `] f` / `[ f` file jumps.
Wired through `KeyboardShortcutSettings`, the `CmuxSettings` `ShortcutAction`
enum, the CLI shortcut payload, the `cmux.json` schema (regenerated embedded
copy), the shortcut docs data, the settings action list and Settings; all
rebindable. Labels are localized for all nine macOS locales.

Deferred fallback chain skips unusable candidates. In the legacy deferred
empty-state path a fallback source that fails for a non-empty reason (last
turn without workspace context) no longer surfaces its raw error.

Layout resolution and the preference reader moved from `cmux_open.swift` to
`CMUXCLI+DiffViewerPreferences.swift`; the diff viewer navigation labels and
defaults moved to `KeyboardShortcutSettings+DiffViewerNavigation.swift`
following the Simulator pattern, keeping both over-budget files from growing.

Green: `bun test` (263 pass), `bun run typecheck`, `bun run lint:ci`,
`build-webviews-app.sh --check`, `check-webviews-react-compiler.mjs`,
`swift_file_length_budget.py`, `wire-app-sources.py --check`,
`localization_catalog.py check`, `verify-local.py --only swift-syntax`,
`tests.test_cmux_settings_supported_paths`, rustfmt.

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

* Add failing test: unreadable or oversized untracked files must not fail the unstaged diff

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

* Keep the unstaged diff when an untracked file cannot be included

Untracked files are appended best effort in both the sidecar and the CLI:
a listing failure, a file git cannot diff, or an added-file patch over the
remaining budget (or over 8 MiB) is left out instead of failing the session
and hiding the tracked git diff. Update the --layout help to the new
default order.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: add CodeRouter documentation in all locales (#15506)

Three pages under /docs/coderouter: an overview (supported accounts,
routing and failover, Mac quickstart, teams and sharing, usage, and
credential handling), agents and models (Codex, OpenCode, Pi, and
Claude Code on a Mac and on Cloud machines, API keys, model selection,
Bedrock mapping), and a CLI reference with troubleshooting. Wired into
docs nav, sitemap, agent page index, docs search aliases, and the
audited SEO matrix, and linked from the Cloud workspaces and Cloud CLI
pages. Copy is checked against the cr CLI, the cmux coderouter verbs,
the guest CLI, and the coderouter data-plane error messages; translated
into all 20 site locales.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* dashboard: record the oRPC data layer and loading contract

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

* dashboard: serve every dashboard read and write as a typed oRPC procedure

Adds /api/dashboard/rpc with one procedure per dashboard call (session,
billing, TestFlight, teams, invites and links, team billing, coderouter,
Cloud access grants, Vault). Every procedure declares input and output
zod schemas and one typed error map (status class plus the route's reason);
team procedures narrow the reason to the team error vocabulary.

Auth: requireDashboardOrigin on every browser call, requireDashboardUser
for session reads, and teamUser + teamAccess + teamRateLimit for team
procedures with the same options as each /api/teams route. Routes whose
logic lives in the handler (coderouter, subrouter, Vault, VM access grants)
run in process through callRoute with the caller's headers, so native
clients and the dashboard share one implementation.

The SPA-only REST reads (/api/dashboard/session|billing|coderouter,
/api/teams/[teamId]/billing, GET /api/testflight) are removed; their tests
now call the procedures.

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

* dashboard: read and write only through the typed oRPC client

The SPA's queries and mutations now come from @orpc/tanstack-query utils
over the dashboard router, so input, output, and error types are the
server's. dashboardFetch, teamRequest, TeamApiError, and every client-side
response schema are gone; refusals are read through lib/refusal.ts (typed
status class plus the route's reason). The team switch, catalog, Cloud
device actions, Vault approval, and coderouter writes call procedures.

An ESLint rule bans fetch() and casts of parsed JSON in dashboard-app,
except the RPC link itself, the transcript byte stream, and the iroh
presence worker client.

The typed catalog showed the team list read a memberCount the server
never sends; that dead line and its message are removed.

Tests serve fake procedures through a real RPCHandler, and the coderouter
mutation tests run the real procedures with only the REST handler faked.

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

* Test that the SwiftPM scratch key covers the vendored bonsplit commit

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

* Key the owned SwiftPM scratch on the vendored bonsplit commit

swift-package-tests linked CmuxPanes test objects compiled against main's
bonsplit into a branch whose bonsplit changed Tab.init, and failed on an
undefined symbol. SwiftPM's mtime check cannot see a submodule that moved to
sources older than the kept build, so the scratch directory is now per
bonsplit commit.

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

* settings: prefetchable typed reads for every account settings page

Adds dashboard.settings.{overview,notifications,sessions,apiKeys,
oauthProviders}, read from the Stack user of the request's own session.
Route loaders prefetch each page's reads, so a page renders from the cache
instead of waiting on SDK hooks.

Writes stay on the Hexclave client SDK with the user's own session, where
Hexclave applies its user-level rules (password verification, passkey and
OAuth ceremonies, email ownership). Each write looks up the SDK object by
id and then invalidates the settings queries. Server-key writes would skip
those rules, so they are deliberately not used here.

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

* dashboard: server prefetch, layout-true pending states, kept data on refetch

The dashboard page now serves a static skeleton shell and, behind Suspense,
checks the session and prefetches the first route's reads in process
(createRouterClient over the dashboard router). A signed-out visitor is
redirected before any SPA code loads; the SPA hydrates the cache before
its router exists, so a full load renders real data with no request.
dashboard-app/server-prefetch.ts maps each dashboard path to the same
query options its route loader uses.

The router keeps the previous page for loads under 300 ms and shows a
skeleton for at least 400 ms otherwise. List routes use row skeletons,
nested routes (settings sections, team billing) keep their layout and show
only a section skeleton, and a Vault search keeps its rows until the new
page arrives.

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

* e2e: cover server-prefetched loads, settings preload, and server sign-in redirect

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

* dashboard: make team queries callable from the server prefetch

queries/teams.ts carried "use client", so on the server teamDetailQuery
was a client reference and a full load of a team page failed. The module
holds query options and hooks, not a component boundary.

The session e2e now refuses the teams.catalog procedure the SPA calls,
and the sessions check reads the table heading.

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

* e2e: assert the settings hover preload, not a single sessions fetch

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

* e2e: walk to the settings Account page, which every Stack project shows

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

* iOS onboarding: align the final primary action (#15791)

* test: cover final onboarding button alignment

* fix: align final onboarding primary action

* docs: document onboarding button alignment rule

* test: isolate onboarding alignment fixture

* test: account for hidden onboarding slot

* test: remove brittle hidden-slot assertion

* test: record every onboarding alignment frame

* test: API keys page must not wait on retried server errors

A project without user API keys made settings.apiKeys a 500 that the
client retried three times, so the page sat on its skeleton ~20 s.

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

* settings: answer disabled API keys at once and stop retrying refusals

- settings.apiKeys returns a declared FORBIDDEN (api_keys_disabled) when
  the project has no user API keys, instead of a Stack 500.
- The route loader and the server prefetch load the key list only when
  the project allows it.
- Queries retry only transient failures; a declared 4xx refusal is final.

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

* test: team API keys read through a typed procedure

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

* teams: read team API keys through oRPC and explain a disabled page

The team API keys page read straight from the Hexclave SDK, and a direct
visit on a project without team keys said "Team not found" under the
team's own header. Now teams.apiKeys (manageApiKeys permission) returns
the keys or enabled: false, the route and the server render prefetch it,
and the page says the feature is off or the permission is missing.
Create and revoke stay on the SDK under the viewer's session.

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

* test: invalid invite links and 402/501 refusals must stay declared

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

* test: leaving a team must not refetch it

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

* dashboard rpc: declare 402, 410, and 501 refusals

Team links and invitations answer 410 when they are revoked, expired,
full, or used, and VM, coderouter, and billing routes answer 402 and 501.
Those statuses were missing from the error map, so they reached the
client as an undeclared 500: a revoked invite link offered "Join team"
and the join then failed with a generic error.

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

* teams: leaving a team no longer refetches it

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

* test: legacy team billing without teamId must require team admin

A plain member who selects a paid team could cancel its subscription,
open its Stripe portal, or start its checkout through the older forms
that name no team.

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

* billing: require team admin for legacy team billing without teamId

The subscription, portal, and checkout routes resolve the selected team
when an older client names no team. That path skipped the admin check the
explicit teamId path has. Now both paths run resolveTeamBillingAccess
with requireAdmin, and the legacy portal reuses the explicit-team portal.
A new team that legacy checkout creates still grants its creator admin.

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

* test: a removed member must not rejoin through a used invite link

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

* teams: forget a departing member's invite link redemptions

claimLink treats an existing redemption as already redeemed before it
checks capacity, so a removed member could reopen a single-use link they
had used and be added again for free. Removing a member or leaving now
deletes that user's redemptions of the team's links first; the spent uses
stay counted, so a rejoin claims a new use and a full link refuses it.

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

* test: a stored admin role must not apply to an invitation cmux did not send

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

* teams: bind a stored invite role to the Stack invitation cmux sent

Stack gives members $invite_members, so a member could send their own
invitation to an address that still had an admin row (for example after
a cmux admin invitation expired), and accepting it granted admin because
the stored role was keyed by email alone.

The role row now records the Stack invitation it was sent with
(migration 20260929120000_team_invite_role_invitation_id adds a nullable
stack_invitation_id). Sending binds it once Stack lists the invitation, a
re-invite clears it until the new one is bound, a failed re-invite
restores the old binding, and resend moves it to the replacement only
when the resent invitation carried the role. Accept identifies the
consumed invitation by id and grants admin only for the bound one; the
pending list shows a role the same way. Rows without an id apply as
member.

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

* test: use a hex token hash in the redemption database test

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

* test: member removal must finish on a one-connection pool

Production and staging run CMUX_DB_POOL_MAX=1. removeMember now deletes
link redemptions inside the per-team admin lock through a second pool
connection, which never comes: every leave and removal hangs.

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

* teams: run lock-held writes on the lock's transaction

withTeamAdminLock now hands its transaction to the operation, and
removeMember deletes link redemptions on it. With CMUX_DB_POOL_MAX=1 (the
production and staging setting) the delete asked the pool for a second
connection while the lock held the only one, so every leave and removal
hung. On the shared transaction a failed Stack removal also rolls the
delete back.

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

* Update the watch target runtime test for #15311 (unblocks the merge queue) (#15814)

* Wait for the background focus, not the scan signal, in the watch target runtime test

The scan callback fires before the activity is processed, and a scan can
defer the activity while the launched target's process metadata is not
ready. The test then asserted two terminal focuses one scan too early and
failed on loaded CI Macs. Wait for the second focus event instead; the
test's one-minute time limit still bounds a real failure.

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

* Expect no terminal focus from background activity in the watch target test

#15311 stopped Computer Use activity from focusing the calling terminal,
but this test still expected a second focus after the background scan.
PR CI runs only changed suites, so the stale expectation first failed in
the merge queue's full run. The previous commit's wait never completed
for the same reason; replace it with the new expected count.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: pin pane-flash routing defaults in direct-interaction tests (#15810)

testTerminalMouseDown/KeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder
failed on main run 36661608916 with flashCount 0 while the unread was
dismissed. That run executed on cmux13s-Mac-mini, whose persisted
com.cmuxterm.app.debug domain holds tmuxOverlayExperimentEnabled = 1 and
tmuxOverlayExperimentTarget = bonsplitPane. The app host reads that domain,
so TerminalPanel.triggerFlash took the bonsplitPane branch and flashed the
workspace pane overlay instead of GhosttySurfaceScrollView, which is the
only place flashCount records. The same tests pass on the same commits on
cmux7/9/10/12 and austin-mini-1, whose domains lack those keys.

The product path is unchanged: the direct-interaction dismissal still
requests the dismiss flash. Pin the tmux overlay experiment off and the
pane flash on for each test in the class and restore the prior values in
tearDown, so the tests stop depending on runner state.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(input): consume keys while terminal runtime is unavailable (#15738)

* test: cover unavailable terminal escape fallback

* fix: consume input while terminal runtime is unavailable

* Fix the cross-surface ordered-input test hang (#15811)

* test: check ordered-input buckets before waiting on a second surface

orderedInputOnAnotherSurfaceIsNotBlocked holds input-1 and waits for
input-2 to start. If both requests share one ordering bucket, input-2
queues behind input-1 and the wait never ends, so CI reports only a
300 s time limit. Require distinct ordering keys first so the test fails
at once with the real reason. With the non-UUID surface ids the test
still sends, this commit fails: both keys are empty.

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

* test: name real terminals in the cross-surface ordered-input test

#15432 keys the ordered-input bucket on the canonical terminal UUID
(phoneNamedTerminalID), so the test's "surface-1"/"surface-2" ids no
longer parse and both requests share the empty bucket. input-2 then
waits behind the held input-1 and the test hangs to its 300 s limit.
Use UUID surface ids, which is what a phone sends. The product change is
intended and already covered by
testOrderedInputKeyIsTheSameForEverySpellingOfOneTerminal.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep zsh prompts intact when a terminal resizes after a partial line (#15809)

* Keep zsh prompts intact when a terminal resizes after a partial line

Bump Ghostty to 5e5f8e12e (manaflow-ai/ghostty#245). zsh PROMPT_SP pads a
partial output line past the right edge, which soft-wraps into the prompt
row. Reflow joined the two rows on every resize, so zsh redrew its prompt
at the wrong cells and left fragments such as "lalalawlawrence in ~ λ".
An OSC 133;A prompt at column 0 of a wrapped row now starts its own line.

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

* Rebase the Ghostty prompt fix onto the fixed styled blank row test

Ghostty e1b8bf5f4 carries 9d8d40319, which corrects the styled blank row
test that failed at 9961d09be and stopped build-ghosttykit.yml.

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

* Pin the GhosttyKit archive for Ghostty e1b8bf5f4

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Add a Themes settings page listing every terminal theme; TextBox leaves beta (#15112)

* test: Themes settings page lists every terminal theme

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

* Add a Themes settings page with every terminal theme; drop TextBox beta label

Themes collects app appearance, accent color, browser theme, adaptive
default theme and the terminal theme gallery. The gallery now lists every
theme Ghostty ships, grouped by the edited slot's appearance, and search
has no result cap. TextBox loses its beta label, warning note and docs
callout.

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

* test: sidebar matches the terminal background by default

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

* Match the terminal background in the sidebar by default

A light terminal theme under a dark app appearance left the sidebar and
titlebar dark beside a white terminal. sidebarAppearance.matchTerminalBackground
now defaults to true. Every reader takes the catalog default; the AppKit
resolver, which cannot import CmuxSettings, mirrors it. An explicit false
in Settings or cmux.json still opts out.

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

* test: secondary chrome color holds a contrast floor

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

* Hold sidebar secondary text at 3.5:1 over terminal-matched backdrops

A sweep of all 463 built-in themes with the sidebar matching the terminal
found secondary text (workspace path, metadata, footer help icon, Upgrade
badge) as low as 2.6:1 on saturated mid-tone themes such as Hot Dog Stand
and Grass. The macOS secondary label keeps a fixed opacity tuned for
neutral backgrounds.

WindowChromeColorResolver.contrastFloored raises only the opacity until a
color reaches a minimum WCAG ratio over a known opaque backdrop. The window
appearance snapshot exposes that backdrop when the sidebar shares the
terminal background, the sidebar passes it through the environment, and the
row palette, footer icons and plain Pro badge use the floored color. The
floor is 3.5:1: the system secondary label on white is 3.9:1, so only six
saturated themes change.

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

* Pick titlebar and tab bar text by WCAG contrast

The workspace titlebar and the Bonsplit tab bar used a 0.5 gamma-space
brightness cutoff while the sidebar used WCAG contrast. On saturated
mid-tones such as Hot Dog Stand (#E44330) the sidebar drew black text and
the titlebar and tabs white text at 3.2:1. Both now use the WCAG choice.
Bumps vendor/bonsplit to manaflow-ai/bonsplit#260.

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

* Keep titlebar symbols at full color when the window is inactive

Hosted SF Symbols drew their pre-tinted bitmap through NSImageView, which
dims its image to about 45% in a titlebar whenever the window is not key.
The drawn sidebar-toggle glyph beside them did not dim, so the bell, new
workspace and history buttons changed color on focus loss (median 13:1 to
3.2:1 across 463 themes) and disabled arrows dropped to 1.1:1.

The bitmap already carries every intended opacity (tint, hover, disabled),
so CmuxResolvedIconImageView now draws it with a plain view that keeps
NSImageView's scale-down, centered layout. Reproduced and verified with a
standalone titlebar-accessory probe: NSImageView 116, drawn glyph 224,
custom-drawn bitmap 225 (8-bit white channel, inactive dark window).

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

* test: a theme pick applies to the appearance in use

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

* Theme gallery: one theme by default, cheaper cards, App Icon under Themes

A pick on the Light tab while the app was dark saved a theme the terminal
never showed, so a click looked like it did nothing. The gallery now owns
one mode: by default a pick sets both appearances and always applies.
Separate Light and Dark Themes (on when the config already holds two
themes) shows the tabs and says when the edited side is not in use;
turning it off keeps the theme the terminal shows now.

Scrolling realized about 25 views per card, including 16 swatch shapes and
an AppKit tooltip. The background and swatches are now one Canvas, colors
are resolved once at load, and cards are Equatable. Hosting 200 cards drops
from about 255 ms to 78 ms.

App Icon also appears under Themes, bound to the same key as the App row.

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

* test: the gallery highlights and writes one theme

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

* Theme gallery owns one theme: drop the light/dark checkbox and tabs

With separate light and dark themes, the gallery could highlight one side
while the terminal showed the other (Light: Front End Delight, Dark:
Iceberg Light, macOS dark), so picks looked desynced. The gallery now
holds exactly one theme: every pick writes it to both appearances, and the
highlighted card is the theme the terminal shows. Pairs remain available
through cmux themes set --light/--dark; picking in the gallery replaces
one. Removes the checkbox, slot tabs, not-in-use caption and badges.

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

* Fix the light-theme sidebar top wash and unify Cmd-hold hint colors

The sidebar list's top fade mask reached 20 pt below the first row's
resting position, so the first row was partly transparent at rest; over a
light terminal-matched backdrop that washed the selected row's top. The
fade now ends where the first row rests, so rows fade only while scrolled
under the titlebar.

Cmd-hold hint pills drew translucent material with system label colors:
the material resolved against the window while the chrome picked its
scheme from the terminal, so a light theme in a dark window gave dark text
on a dark pill. ShortcutHintPalette is an opaque palette (10.4:1 dark,
15.1:1 light) chosen by the chrome's scheme: the sidebar row scheme for
AppKit pills, the titlebar icon scheme for titlebar hints, and the view's
scheme elsewhere. Bumps vendor/bonsplit (manaflow-ai/bonsplit#260) for the
same palette on pane tab hints.

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

* Fix titlebarControlAppearance: explicit return, single @MainActor

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

* test: Cmd-hold hints fade in unless Reduce Motion

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

* Fade Cmd-hold hints in as well as out

Hints appeared in one frame and only faded out. The whole hint layer shows
at once across the window, so popping every pill in the same frame read as
a flash. SwiftUI hints now use an opacity transition with the 0.12 s
ease-out in both directions (none under Reduce Motion), and the AppKit
sidebar pill runs a matching opacity fade-in. Pane tab hints in Bonsplit
already animated both ways with the same curve.

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

* Stop the Themes page shifting sideways as it opens

The gallery loaded after the page appeared, the page grew past the window,
and with legacy scrollers a vertical scroller appeared and narrowed every
card mid-view. The Settings detail scroll view now always reserves the
scroller gutter, which also removes the same shift between short and long
pages, and loaded themes are cached for the app's lifetime so reopening
Themes renders the full gallery without a background load.

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

* test: gallery follows appearance changes and picks up added themes

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

* Address review: live appearance, refreshed theme cache, card tooltip

- The gallery follows app appearance changes while Settings is open, so
  the highlighted card and group order track the theme on screen when the
  config holds a light/dark pair.
- The theme cache shows the last parse at once, then re-reads the
  directories so added theme files appear; an empty parse is not cached.
- BitmapView invalidates its intrinsic size when the image size changes.
- Theme cards show the full name as a tooltip again (names truncate).
- TextBox search aliases drop the beta word in every language.

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

* Keep TextBox docs descriptions within the 110-160 SEO bounds

Removing the beta word shortened the French and Khmer meta descriptions
below 110, so the audited selector fell back to a 109-character intro.

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

* ci: rerun checks with the no-full-ci label

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

* test: expect pane exit to hide the drop preview at once

PR #15550 made leaving a pane hide its drag preview immediately and covers
that in PaneDropTargetIdentityTests. The older #15171 assertion still
expected a fade-out, so this test failed on main.

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

* test(hermes): let the hang guard outlast the installer budget

The default installer timeout equaled the 5 s hang guard, so on a busy
runner a slow installer and the guard expired together and the wrapper
was killed before it launched Hermes. Give the installer its own 10 s
budget and the guard 15 s more.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ci): keep media artifact commits out of PR timelines (#15826)

* test: a resent invitation replaces the old one in the cache at once

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

* teams: swap in the resent invitation right away

A resend replaces the Stack invitation, so its id changes. The cache kept
the old id until the refetch landed, so a quick Revoke answered 404 and the
rollback showed the invitation again.

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

* teams: fix types for the resend cache swap and its test

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

* Label SSH and Cloud workspaces by host in window titles and Task Manager (#15270)

* Label SSH and Cloud workspaces by host in window titles and Task Manager

Add WorkspaceHostLabel (CmuxFoundation), which derives a workspace's host
from its SSH destination or Cloud machine instead of the typed title. The
window title bar and NSWindow.title now read "title · host" for remote
workspaces, system.top workspace nodes carry a host object, and Task
Manager workspace rows show the host.

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

* Address review: IPv6-only brackets, Port= option, title refresh order

Bracket only IPv6 hosts in detail and grouping keys, read a port set
through --ssh-option Port=, keep {activeWorkspace} host-free, refresh the
title after a Cloud binding change clears directories, return the trimmed
title when it already names the host, and document {defaultTitle}.

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

* Keep host label code in existing files to avoid project churn

Move Workspace.hostLabel next to cloudVMID and the window-title tests into TabManagerTitleUpdateTests, so the PR no longer edits project.pbxproj.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ci): update the production drift issue in place (#15838)

* test(ci): reproduce repeated drift comment noise

* fix(ci): update the production drift issue in place

* Resume Cloud terminal replays inside escape sequences (#15533)

* test: byte mirrors must match the terminal when joining or resizing mid-sequence

Add failing tests for the Cloud rendering races where a byte viewer attaches,
or the geometry owner resizes, while the daemon parser is inside an escape
sequence or UTF-8 code point. Today the attach replay drops the partial
sequence (its tail then prints as text) and a mid-sequence resize disconnects
every viewer.

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

* fix: replay the parser's incomplete sequence so byte mirrors resume mid-stream

A byte-mode replay reproduced the screen but not an escape sequence or UTF-8
code point the parser was inside. A viewer that attached during streaming
output printed the rest of the sequence as text, and a resize at such a byte
disconnected every viewer.

The VT boundary tracker now keeps the bytes fed since the last safe point
(up to 1 MiB) and every replay ends with them, so a fresh parser enters the
same incomplete state and the live stream completes it. Attach, resize, and
terminal-host snapshots now resync only inside a control string past that
budget; oversized direct Kitty uploads keep using their own tracker.

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

* fix: send the incomplete sequence as a separate replay field

A replay that ended inside a sequence broke every consumer that writes its
own bytes after the replay: the macOS pane, the daemon's hosted mirror, the
remote cmux-tui client, and iOS all append color-override OSCs there, which
would land inside the incomplete sequence.

VtReplay.bytes now always ends at a parser boundary, and the incomplete
sequence (including a partial Kitty command) travels as pending_sequence.
Consumers write it last, after their colors and immediately before the live
stream. The attach events vt-state and resized carry it as an optional
base64 `pending` field, sent only when non-empty, so older clients see no
change. The terminal-host protocol is unchanged: hosts still snapshot and
resize only at a boundary, and single-field wires (host frames, the vt-state
command, journal checkpoints, resource reads) use the self-contained bytes.

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

* test: the Cloud pane must write a replay's pending sequence after its colors

End-to-end through CloudTuiManualMirrorSession and a real manual-I/O Ghostty
surface: a vt-state or resized event whose daemon parser was inside an SGR
carries the incomplete bytes as `pending`; the live output that completes
it must render styled text, not print the sequence tail.

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

* feat: gate the separate pending field on terminal-pending-sequence-v1

Additive attach-event fields reach only clients that advertise them, because
the SDK decoders are strict. Attachments that advertise
terminal-pending-sequence-v1 receive the replay's incomplete sequence as
`pending`; others get it appended to the replay, which is what a raw
consumer such as chatmux-relay needs, so its special case is reverted. The
cmux-tui remote client advertises the capability and gains a test for its
replay, colors, pending ordering. Documented in the events, commands and
transports specs.

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

* fix: write a Cloud replay's pending sequence after the pane's colors

The macOS pane and the iOS cmux-tui client now advertise
terminal-pending-sequence-v1. The macOS frame decoder carries `pending` on
snapshot and resized frames, and CloudTuiManualMirrorSession appends it after
the replay's color OSCs, so the next output completes the sequence the
daemon's parser was inside. iOS emits it as output after the replay and its
colors.

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

* test: pending sequences must not replay bytes the parser already acted on

A string terminator's ESC dispatches the string, a new introducer abandons
the sequence before it, C0 controls inside a sequence execute at once, and
strictly invalid UTF-8 prints U+FFFD at once; replaying any of those bytes
acts on them twice. A resize inside a sequence must also keep disconnecting
viewers that did not advertise terminal-pending-sequence-v1.

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

* fix: never replay bytes the parser already acted on

Review findings on the pending sequence:
- ESC, or a C1 introducer outside a UTF-8 code point, ends the sequence in
  progress (Ghostty dispatches OSC/DCS/APC strings there), so pending
  restarts at it; replaying the whole string ran it twice (OSC 52, OSC 9,
  Kitty transmits, DECRQSS).
- C0 controls inside an escape, CSI or control string execute or are
  ignored on arrival, so they are not recorded except in DCS passthrough.
- The tracker uses Ghostty's strict UTF-8 DFA ranges, so a code point
  Ghostty already replaced with U+FFFD is not pending.
- Byte viewers that did not advertise terminal-pending-sequence-v1 are
  disconnected by a resize replay with pending bytes, as before this
  change, instead of writing their color sequences into the open sequence.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Add Cloud to the menu bar extra and a main-menu Cloud menu (#15822)

* Add Cloud to the menu bar: status item section and top-level Cloud menu

One shared entry tree (CloudMenuContent) renders in the status item
(AppKit) and a new main-menu Cloud menu (SwiftUI). Machine verbs come
from CloudMachineMenuVerbs, which the Cloud sidebar context menu now
uses too. CloudMenuModel reads the fleet when a menu opens, reuses a
read younger than 20s, and drops it on team switch or sign-out.

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

* Cloud menu: discard open() result, drop macro attribute on static

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

* Cloud menu: import CmuxFoundation for the menu font

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

* Status item: close the Cloud section with its own separator

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

* Cloud menu: resolve the shared model inside the main actor

Default arguments evaluate in a nonisolated context, so `.shared` there
warned under Swift 6 checking and exceeded the CI warning budget.

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

* Cloud menu tests: bound the readiness wait by a deadline, not an iteration count

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

* Cloud menu: regression test for a read that lands after a scope change

Adds an injectable scope seam (defaults to the pin store's scope) and a
test that confirms the team while the first read is in flight. Today the
result is dropped without clearing the in-flight task, so the menu stays
at Loading and never reads again.

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

* Cloud menu: never strand a read after a scope change; keep open submenus

A read whose team scope changed in flight was dropped without clearing
the in-flight task, so the menu stayed at Loading and every later open
returned early. Clear the task first, then read again for the current
scope. The status item now rebuilds its Cloud rows only when something
visible changed, so a landing read no longer collapses an open machine
submenu.

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

* Size the stale-selection split test's window before splitting

createMainWindow copies a 320-point window left by earlier app-host
tests, so split admission (#15392) refuses the second side-by-side
split and the test fails on main for every PR that runs this suite
(seen on #15469, #15475, #15107). Same fix #15434 applied to two other
split tests.

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

* Wait for the scheduled focus in the background Computer Use runtime test

The scan reports the background session before the presentation
controller's scheduled focus effect runs, so asserting two focused
terminals right after the scan signal fails whenever the test task
resumes first. It fails in main's CI and in every merge-queue run.
Poll for the focus with a deadline instead.

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

* Expect one focus in the background Computer Use runtime test

#15311 stopped Computer Use activity from re-focusing the calling
terminal and updated ComputerUseUXTests, but this test still expected
the old second focus after a scan. It has failed in main's CI and in
every merge-queue run since. The only focus is now the one Continue in
Background makes. Replaces the previous commit's wait, which was wrong.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* cmux-tui: fix the replay row scroll and stale hook fence tests breaking the full gate (#15240)

* Stop Cloud VT replays one row early so they do not scroll the mirror

Ghostty's formatter preserved trailing blank rows for VT replays with one
row break too many: the break ending the final row. Every replay that
ended on the last screen row scrolled its target by one row, pushing the
top row into scrollback. Bump ghostty to the fix.

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

* cmux-tui: check hook projector output in hook fence tests; rustfmt the title suffix

#13299 moved list_agents onto the journal-folded agent roster. Seven hook
fence tests drive apply_agent_hook_record directly with hand-picked
sequences, which never reaches the roster fold, so list_agents came back
empty and they failed. Assert on the projector's own live record instead.

Also apply rustfmt to the OSC title suffix from #15163.

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

* cmux-tui: update two core tests that main's journal changes left stale

#13299 narrowed the forced resource patch failure trigger to journal rows
that carry a resource revision, so a failed topology close now commits its
failure outcome instead of going indeterminate. Expect operation.failed on
the first attempt and the same error on replay.

The raw socket and hook report race test assumed the socket report always
commits first. When the hook wins, the hook-owned record retains the later
socket report without a new revision. Check one batch per committed
revision with the hook's commit last.

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

* cmux-tui: expect the generic plugin validation field in the sidebar CLI test

#13299 generalized the plugin manager and reports validation errors on the
plugin field for every plugin kind.

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

* Pin ghostty to main's replay row fix plus the hex escape commits

manaflow-ai/ghostty#241 landed on ghostty main as 3429f20. Pin a commit
that adds the two hex escape commits from manaflow-ai/ghostty#239 on top,
so startup input keeps its UTF-8 bytes.

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

* Pin the GhosttyKit checksum for fd8e62daa

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

* cmux-tui: receipted input to an exited keep-on-exit terminal is a no-op

Receipted API input (95a184a) rejected writes to an exited hosted
terminal, while keep-on-exit terminals document typing on the final
screen as a harmless no-op and the unreceipted path already drops those
bytes. terminal.input.write on a kept terminal failed with
terminal_input_delivery_failed. Treat it as a successful no-op on both
paths.

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

* cmux-tui: run the plugin projection repair test against a live surface

startup_repairs_a_plugin_projection_lost_after_journal_commit restarted
the daemon and expected its local PTY terminal back. Startup adopts only
host-owned terminals and detaches the rest, so the terminal had no
surface after restart and the repair had nothing to project onto. Run
the startup reconciliation on the live surface, check a repeat is a
no-op, and check the restart restores the roster entry.

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

* cmux-tui: run the live OSC 7 cwd tests on a real PTY

Mux::new_for_test builds PTY-free surfaces that never spawn the command,
so the shell never printed its OSC 7 report and both tests timed out since
they were added in #12978.

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

* ci: isolate wrapper deadline regressions

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin Cloud panes to the daemon's terminal grid (#15792)

* test: byte mirrors must match the terminal when joining or resizing mid-sequence

Add failing tests for the Cloud rendering races where a byte viewer attaches,
or the geometry owner resizes, while the daemon parser is inside an escape
sequence or UTF-8 code point. Today the attach replay drops the partial
sequence (its tail then prints as text) and a mid-sequence resize disconnects
every viewer.

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

* fix: replay the parser's incomplete sequence so byte mirrors resume mid-stream

A byte-mode replay reproduced the screen but not an escape sequence or UTF-8
code point the parser was inside. A viewer that attached during streaming
output printed the rest of the sequence as text, and a resize at such a byte
disconnected every viewer.

The VT boundary tracker now keeps the bytes fed since the last safe point
(up to 1 MiB) and every replay ends with them, so a fresh parser enters the
same incomplete state and the live stream completes it. Attach, resize, and
terminal-host snapshots now resync only inside a control string past that
budget; oversized direct Kitty uploads keep using their own tracker.

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

* fix: send the incomplete sequence as a separate replay field

A replay that ended inside a sequence broke every consumer that writes its
own bytes after the replay: the macOS pane, the daemon's hosted mirror, the
remote cmux-tui client, and iOS all append color-override OSCs there, which
would land inside the incomplete sequence.

VtReplay.bytes now always ends at a parser boundary, and the incomplete
sequence (including a partial Kitty command) travels as pending_sequence.
Consumers write it last, after their colors and immediately before the live
stream. The attach events vt-state and resized carry it as an optional
base64 `pending` field, sent only when non-empty, so older clients see no
change. The terminal-host protocol is unchanged: hosts still snapshot and
resize only at a boundary, and single-field wires (host frames, the vt-state
command, journal checkpoints, resource reads) use the self-contained bytes.

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

* test: the Cloud pane must write a replay's pending sequence after its colors

End-to-end through CloudTuiManualMirrorSession and a real manual-I/O Ghostty
surface: a vt-state or resized event whose daemon parser was inside an SGR
carries the incomplete bytes as `pending`; the live output that completes
it must render styled text, not pri…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants