Skip to content

Render diff viewer empty state instead of raw error (issue #5246)#5252

Merged
austinywang merged 4 commits into
mainfrom
issue-5246-diff-viewer-empty-state
Jun 3, 2026
Merged

Render diff viewer empty state instead of raw error (issue #5246)#5252
austinywang merged 4 commits into
mainfrom
issue-5246-diff-viewer-empty-state

Conversation

@austinywang

@austinywang austinywang commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Problem (issue #5246)

When the diff viewer has nothing to show — most notably a last-turn diff with no recorded baseline (e.g. a clean checkout before any agent turn ran cmux hooks) — two things go wrong:

  1. Bad empty-state UI. The panel renders a raw, developer-facing CLI error verbatim: "No last-turn diff baseline recorded for this workspace and surface yet. Run another agent turn with cmux hooks active, or use --unstaged, --staged, or --branch." It reads like an error and gives no friendly guidance.
  2. Click beep. Opening that empty panel produces the system "unable to click" beep.

Root cause (one cause, both symptoms)

"Nothing to diff" was modeled as a thrown error (CLIError) rather than a legitimate empty state:

  • latestAgentTurnDiffBaseline threw raw jargon when no baseline existed.
  • EmptyDiffSourceError for last turn (and the all-sources-empty case) was re-thrown as CLIError.

The thrown error (1) was rendered verbatim via the isError: true status page, and (2) made the diff-viewer CLI exit non-zero, which tripped AppDelegate.launchDiffViewerProcess's termination handler NSSound.beep() — that's the beep.

Fix (empty diff = empty state, not error)

  • latestAgentTurnDiffBaseline returns nil for a missing baseline instead of throwing; readGitDiffInput emits an empty patch, folding it into the same EmptyDiffSourceError path as "no changes".
  • Route EmptyDiffSourceError to a friendly, non-error empty-state page (isError: false) with the Unstaged/Staged/Branch switcher visible, in both the synchronous selected-source path and the deferred-source path — instead of throw CLIError / isError: true. Last turn keeps its no-silent-fallback behavior but now shows its own empty state.
  • The CLI now exits 0 for an empty diff, so the launcher's NSSound.beep() never fires — this is what eliminates the "unable to click" beep.
  • Empty-state status-page writes propagate failures (no try? on the returned-completion paths), so the deferred pipeline never reports success while a stale loading page remains.

Visual polish (empty state)

The empty state is now a proper centered "nothing to show" layout instead of a small left-aligned status strip:

  • Vertically + horizontally centered in the panel.
  • A themed icon badge (a currentColor-masked document glyph that adapts to light/dark) above the message.
  • The message rendered centered, medium-weight, as the title.
  • The icon shows only for the friendly empty state — not for errors (red) or while loading (spinner).

Implemented in the diff-viewer/ web app (App.tsx, viewer-controller.ts, styles.css); rebuilt assets are committed under Resources/markdown-viewer/diff-viewer-app/ (CI staleness check passes).

Tests (two-commit red/green)

  • Commit 1 adds/updates regression tests in CMUXOpenCommandTests that fail on current code: the diff viewer must open (exit 0), render a friendly empty state (statusIsError == false) with the source switcher present (last turn still selected — no silent fallback), and never show the raw baseline string. Covers the no-baseline path, the empty-last-turn path, the wrong-surface (deferred) path, and the all-sources-empty path.
  • Commit 2 is the fix; the tests go green.

The beep itself is downstream of the CLI exit code: exiting 0 for the empty case removes the NSSound.beep() trip, and that exit-code behavior is covered by the CLI tests above. The remaining responder/visual behavior is verified by manual dogfood.

Localization audit

No new user-facing strings; reuses existing localized diffViewer.empty.* and diffViewer.source.* keys (en + ja in Resources/Localizable.xcstrings). The empty-state polish is presentational (icon + layout), reusing the same localized message. Nothing in diff-viewer/ copy changed.

austinywang and others added 2 commits June 2, 2026 15:11
A last-turn diff with no recorded baseline (and any all-empty git diff
source) currently throws a developer-facing CLIError that renders the raw
"No last-turn diff baseline recorded..." string in the diff panel and
exits non-zero, which trips the diff-viewer launcher's NSSound.beep().

These tests assert the intended behavior: the diff viewer opens (exit 0),
renders a friendly non-error empty state (statusIsError == false) with the
source switcher still present, and never shows the raw baseline error.
They fail on the current code (which errors instead of opening) and pass
once the empty-state fix lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When there is nothing to diff — no recorded last-turn baseline, or every
git source is empty — the diff viewer threw a CLIError. That raw,
developer-facing string ("No last-turn diff baseline recorded for this
workspace and surface yet...") was rendered verbatim as the panel's empty
state, and the non-zero CLI exit tripped the diff-viewer launcher's
NSSound.beep() (AppDelegate.launchDiffViewerProcess termination handler) —
the "unable to click" beep the reporter heard.

Root cause: "nothing to diff" was modeled as an error rather than a
legitimate empty state. Fix it at the source of truth:

- latestAgentTurnDiffBaseline returns nil for a missing baseline instead
  of throwing; readGitDiffInput emits an empty patch for that case, so it
  flows through the same EmptyDiffSourceError path as "no changes".
- Route EmptyDiffSourceError to a friendly, non-error empty-state page
  (isError: false) with the Unstaged/Staged/Branch switcher visible, in
  both the synchronous selected-source path and the deferred-source path,
  instead of throwing CLIError / rendering isError: true. Last turn keeps
  its no-silent-fallback behavior but now shows its own empty state.
- The CLI now exits 0 for an empty diff, so the launcher no longer beeps.

Reuses the existing localized diffViewer.empty.* strings (no new copy; the
localized source switcher is the actionable guidance).

Defensive: add an empty doCommand(by:) override to CmuxWebView (mirrors
GhosttyTerminalView's documented NSBeep suppression) so any unhandled
command in a non-interactive status-only page is a harmless no-op.

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

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 2, 2026 11:48pm
cmux-staging Building Building Preview, Comment Jun 2, 2026 11:48pm

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The diff transforms cmux diff --last-turn to render friendly empty states instead of throwing errors when baselines are missing or no changes exist. The baseline lookup helper returns an optional record, the diff input path emits empty patches on missing baselines, and the viewer HTML generation preserves .lastTurn source selection while recording friendly empty messages. Tests are updated to assert successful exit codes and non-error UI states.

Changes

Empty Diff Friendly State

Layer / File(s) Summary
Optional baseline and lastTurn empty patches
CLI/cmux_open.swift
latestAgentTurnDiffBaseline now returns CMUXAgentTurnDiffBaselineRecord? instead of throwing when missing. The .lastTurn diff input path emits an empty patch when baseline lookup yields nil.
Selected source empty state preservation
CLI/cmux_open.swift
writeCompleteGitDiffViewerHTMLSet records selectedEmptyMessage and avoids auto-switching away from .lastTurn on EmptyDiffSourceError, preserving explicit source selection as a non-error state.
Empty state rendering and deferred completion
CLI/cmux_open.swift
Renders selected empty input as friendly non-error pages via updated status HTML logic; introduces writeDiffViewerEmptyStatePage and deferredDiffViewerEmptyStateCompletion helpers for deferred completion of empty result sets.
Viewer UI structure and styles
diff-viewer/src/App.tsx, diff-viewer/src/viewer-controller.ts, diff-viewer/src/styles.css
Separates #status-icon and #status-text, updates showStatusMessage to set the #status-text content only, and adds centered empty-state styling plus an icon badge for non-pending/non-error states.
Test validation for friendly empty state
cmuxTests/CMUXOpenCommandTests.swift
Updated assertions to expect exit status 0 and statusIsError == false for empty diffs; renamed tests to reflect friendly-state semantics; added assertFriendlyLastTurnEmptyState(html:) helper to validate non-error render, source selection, and absence of raw error strings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • manaflow-ai/cmux#5246: Addresses the same codepath in CLI/cmux_open.swift and diff viewer empty-state handling to convert thrown CLIError into friendly empty-state render.

Possibly related PRs

  • manaflow-ai/cmux#5016: Both PRs modify CLI/cmux_open.swift's diff viewer EmptyDiffSourceError handling; this PR special-cases .lastTurn missing baseline to render friendly empty state, while #5016 extends deferred rendering with per-page source fallback/retry.

Poem

🐰 A baseline runs dry, yet the diff viewer stands,
No errors thrown—just empty, friendly hands.
Last-turn preserved, no switch in sight,
Empty is okay when rendered just right.
hops away contentedly 🌿


Caution

Pre-merge checks failed

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

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error Public file-level Codable structs implicitly @MainActor but should be marked nonisolated per actor isolation rules. Mark CMUXAgentTurnDiffBaselineRecord and CMUXAgentTurnDiffBaselineStore as nonisolated struct ... : Codable.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rendering a friendly empty state UI for the diff viewer instead of displaying a raw developer-facing error message.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Blocking Runtime ✅ Passed No new blocking synchronization primitives introduced in production Swift code. Existing semaphores/locks predate this PR; only empty-state UI rendering logic modified.
Cmux No Hacky Sleeps ✅ Passed PR does not introduce new timing code. All setTimeout/yieldToNextFrame patterns in diff-viewer are pre-existing from commit 1a7ebde; this PR only refactors DOM structure and styling.
Cmux Algorithmic Complexity ✅ Passed PR introduces only UI and error-handling changes; no new algorithmic complexity violations. All new loops operate on fixed-size collections.
Cmux Swift Concurrency ✅ Passed All changes in cmux_open.swift are synchronous. No Dispatch queues, Combine, completion handlers, or fire-and-forget Tasks introduced by the PR changes to empty state handling.
Cmux Swift @Concurrent ✅ Passed Swift files in PR use traditional GCD (DispatchQueue) API, not Swift concurrency. No async/await functions, @concurrent annotations, or nonisolated async work found.
Cmux Swift File And Package Boundaries ✅ Passed PR adds +132 lines to existing 5831-line CLI/cmux_open.swift (under 250-line threshold for oversized files) with focused empty-state bug fix; falls within allowed cases for existing oversized files.
Cmux Swift Logging ✅ Passed No logging violations found. Existing print() statements are CLI output (allowed), console logging is existing diagnostics (allowed), and tests are exempt.
Cmux User-Facing Error Privacy ✅ Passed PR's user-facing empty-state messages use localized strings with no sensitive data, and error messages expose only user-provided values or generic POSIX errors, complying with privacy rules.
Cmux Full Internationalization ✅ Passed All user-facing strings use CMUXDiffViewerLocalization.string() with localized keys backed by Localizable.xcstrings with full locale translations. No new hardcoded user strings introduced.
Cmux Swiftui State Layout ✅ Passed PR contains no SwiftUI state changes; modifications are Swift CLI code, tests, and web diff viewer (TypeScript/React/CSS) with no SwiftUI decorators or state patterns.
Cmux Architecture Rethink ✅ Passed PR makes empty diffs a valid non-error state with clear ownership at the diff command level. No banned patterns: no sleeps, dispatch timing, polling, locks, or observer side channels introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR changes do not add or materially modify Swift windows. Changes are diff viewer CLI logic, tests, and web UI. "window" references are CLI arguments only (--window flag).
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem, root cause, fix, testing approach, and implementation details.
✨ 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 issue-5246-diff-viewer-empty-state

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

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two symptoms caused by treating "nothing to diff" as a thrown error rather than an empty state: a raw developer-facing CLI error shown in the diff viewer panel, and a system beep when opening an empty-diff panel. The root fix is that latestAgentTurnDiffBaseline now returns nil instead of throwing, and EmptyDiffSourceError is now routed to a non-error (isError: false) status page with the source switcher visible in both the synchronous and deferred source paths.

  • CLI/cmux_open.swift: latestAgentTurnDiffBaseline returns Optional instead of throwing; EmptyDiffSourceError is caught and written as a friendly empty state (with isError: false) rather than re-thrown as CLIError; two new helpers extract the deferred empty-state pattern; CLI exits 0 for all empty-diff cases.
  • diff-viewer/src/App.tsx + styles.css + viewer-controller.ts: The status element is restructured to have a dedicated #status-text child so that showStatusMessage no longer clobbers the decorative #status-icon element; CSS adds a polished centered icon badge (visible only for non-error, non-pending status-only states).
  • Tests: Regression tests converted from asserting non-zero exit + raw error string to asserting exit 0 + statusIsError == false + last-turn selected in source switcher; a shared assertFriendlyLastTurnEmptyState helper covers the no-baseline, empty-last-turn, and wrong-surface paths.

Confidence Score: 5/5

Safe to merge. The change narrows a thrown-error path to an in-process empty state; no new mutable shared state, no new concurrency primitives, and no data is written unless it was already being written.

The fix correctly models 'nothing to diff' as a first-class empty state: latestAgentTurnDiffBaseline returns Optional, EmptyDiffSourceError is handled before it can escape the command, and the CLI exits 0 in all empty-diff cases. Empty messages are routed through CMUXDiffViewerLocalization.string() for all DiffSource cases. The React/CSS restructuring is straightforward and regression tests cover all four key empty-state scenarios.

No files require special attention.

Important Files Changed

Filename Overview
CLI/cmux_open.swift Core logic change: latestAgentTurnDiffBaseline returns Optional instead of throwing; EmptyDiffSourceError handled as friendly empty state (isError:false) in both synchronous and deferred source paths; two new helpers extract the deferred empty-state pattern cleanly.
diff-viewer/src/viewer-controller.ts Added statusText element reference; showStatusMessage now writes to #status-text child instead of #status parent, so the new #status-icon child is preserved across status updates.
diff-viewer/src/App.tsx Restructured LoadingLayer status div to separate #status-icon (aria-hidden decorative) and #status-text children, enabling showStatusMessage to update text without overwriting the icon node.
diff-viewer/src/styles.css Redesigned data-status-only empty-state layout from a thin horizontal strip to a centered card with icon badge; icon is only shown for non-error, non-pending states via CSS selector.
cmuxTests/CMUXOpenCommandTests.swift Tests updated from expecting non-zero exit + raw error string to expecting exit 0 + statusIsError:false + last-turn selected in source options; shared assertFriendlyLastTurnEmptyState helper covers four distinct empty-state scenarios.
Resources/markdown-viewer/diff-viewer-app/main.mjs Rebuilt minified bundle reflecting viewer-controller.ts and App.tsx source changes; only variable-name renaming from re-minification, no logic changes beyond the source changes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["cmux diff --last-turn"] --> B{latestAgentTurnDiffBaseline}
    B -- "record found" --> C["Build patch from git diff + untracked"]
    B -- "nil (no baseline yet)" --> D["patch = empty string"]
    C --> E["nonEmptyGitDiffInput"]
    D --> E
    E -- "patch is non-empty" --> F["DiffInput with content"]
    E -- "patch is empty" --> G["throw EmptyDiffSourceError"]
    F --> H["Write diff HTML + exit 0"]
    G --> I{selectedSource == .lastTurn?}
    I -- yes --> J["selectedEmptyMessage = error.message\nselectedInput = nil"]
    I -- no, try fallback --> K{Any fallback non-empty?}
    K -- yes --> L["Render fallback; mark original as empty state"]
    K -- no --> J
    J --> M["writeDiffViewerStatusHTML\nisError: false\nwith source switcher"]
    M --> N["Exit 0 — friendly empty state shown\nNo NSSound.beep"]
    L --> N
Loading

Reviews (3): Last reviewed commit: "Polish diff viewer empty state into a ce..." | Re-trigger Greptile

Comment thread Sources/Panels/CmuxWebView.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 2, 2026

@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: 1

🤖 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 3745-3777: The function writeDeferredDiffViewerEmptyState
currently swallows failures by using try? when calling writeDiffViewerStatusHTML
and always returns a successful DiffViewerDeferredCompletion; change
writeDeferredDiffViewerEmptyState to propagate or handle write errors instead of
ignoring them: either make writeDeferredDiffViewerEmptyState a throwing function
and call try writeDiffViewerStatusHTML so failures bubble up, or check the
Result/error from writeDiffViewerStatusHTML and return an
error/completed-failure path (or nil) instead of the success
DiffViewerDeferredCompletion; update callers to handle the thrown error or
optional/failure return accordingly so a failed write does not report a
completed deferred page.
🪄 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: 5051338b-1c91-43dc-afa4-f38de2ce0d17

📥 Commits

Reviewing files that changed from the base of the PR and between 67014e3 and ee037bc.

📒 Files selected for processing (3)
  • CLI/cmux_open.swift
  • Sources/Panels/CmuxWebView.swift
  • cmuxTests/CMUXOpenCommandTests.swift

Comment thread CLI/cmux_open.swift Outdated

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Sources/Panels/CmuxWebView.swift Outdated
…te write errors (#5246)

- Remove the empty doCommand(by:) override on CmuxWebView. It was placed on
  the shared base class and silently swallowed every unhandled command for
  ALL web pages (not just empty diff pages), bypassing the default
  NSResponder forward to nextResponder (greptile P2, cubic P1). The
  "unable to click" beep is the diff-viewer launcher's NSSound.beep() on a
  non-zero CLI exit, which the empty-state-exits-0 change already fixes, so
  the broad override is unnecessary.
- Split writeDeferredDiffViewerEmptyState into a throwing page writer
  (writeDiffViewerEmptyStatePage) plus a pure completion builder. The
  returned-completion paths now use `try` so a failed status-page write
  propagates instead of reporting success with a stale loading page
  (coderabbit major). The secondary fallback page render stays best-effort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@austinywang austinywang dismissed coderabbitai[bot]’s stale review June 2, 2026 22:56

CodeRabbit confirmed the fix in its follow-up ("The split looks correct…") and resolved its thread; dismissing the stale changes-requested review. The try? swallow was fixed in 3b81bb2.

The empty diff state rendered as a small left-aligned status strip floating
in the panel. Restyle it as a proper centered empty state (icon badge above
a centered message), closer to a polished "nothing to show" page.

- App.tsx: split #status into a persistent #status-icon badge and a
  #status-text node (so updating the message text no longer wipes the icon).
- viewer-controller: write the message into #status-text.
- styles.css: center the status-only layer, stack icon + message, and show a
  themed (currentColor-masked) document glyph only for the friendly empty
  state — not for errors or while loading.

Reuses the existing localized message; no new user-facing strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 2, 2026

@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: 1

🤖 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 `@diff-viewer/src/styles.css`:
- Line 623: Replace the invalid keyword-case usage in the CSS declaration that
sets background-color: currentColor; — locate the rule using the
background-color property (the declaration shown as background-color:
currentColor;) and change the value to lowercase "currentcolor" so it complies
with the Stylelint value-keyword-case rule and avoids CI failures.
🪄 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: 415e7da9-15a5-493c-a204-f69672a36450

📥 Commits

Reviewing files that changed from the base of the PR and between 3b81bb2 and e138696.

📒 Files selected for processing (4)
  • Resources/markdown-viewer/diff-viewer-app/main.mjs
  • diff-viewer/src/App.tsx
  • diff-viewer/src/styles.css
  • diff-viewer/src/viewer-controller.ts

Comment thread diff-viewer/src/styles.css
@austinywang austinywang dismissed coderabbitai[bot]’s stale review June 3, 2026 00:15

The single flagged item (currentColor casing) is a false positive: this repo has no Stylelint, the file already uses currentColor in 5 places, and the keyword is valid CSS. All other findings were fixed. Dismissing the stale changes-requested review.

@austinywang austinywang merged commit ee4fa66 into main Jun 3, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant