Skip to content

Fix blank terminal when creating a new workspace - #2835

Closed
austinywang wants to merge 3 commits into
mainfrom
issue-2555-blank-terminal-new-workspace
Closed

austinywang wants to merge 3 commits into
mainfrom
issue-2555-blank-terminal-new-workspace

Conversation

@austinywang

@austinywang austinywang commented Apr 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a UI regression test that creates 10 workspaces over the automation socket and waits for each selected terminal to present its first frame
  • schedule a post-insertion terminal portal refresh after TabManager.addWorkspace() selects the new workspace
  • mirror the re-present path by reattaching, synchronizing portal geometry, forcing a surface refresh, and nudging the selected surface layer to display

Closes #2555


Note

Medium Risk
Touches terminal rendering/portal synchronization and CoreAnimation display triggers during workspace insertion, which could introduce visual regressions or extra redraw work. Scope is limited to post-selection refresh paths and adds a UI regression test to catch failures.

Overview
Prevents newly created workspaces from showing a blank terminal by scheduling an immediate post-selection render/geometry sync when TabManager.addWorkspace() selects the new workspace.

Adds Workspace.scheduleInitialSelectedTerminalRenderRefresh() to reattach the focused panel, reconcile portal geometry, force/start the surface, and request a layer display pass via the new GhosttyTerminalView.requestVisibleSurfaceDisplayIfNeeded() (including deferred execution until the view has a window).

Adds an automation-socket UI regression test that rapidly creates 10 workspaces and asserts each selected terminal presents its first IOSurface-backed frame using new render_stats polling helpers and a lightweight UNIX-domain socket client.

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


Summary by cubic

Fixes #2555: new workspaces now show the selected terminal’s first frame immediately, preventing a blank terminal. Adds a regression UI test that creates 10 workspaces and confirms first-frame rendering without switching tabs.

  • Bug Fixes
    • After TabManager.addWorkspace(), schedule an initial render refresh on the newly selected terminal: reattach the view, reconcile geometry and portal sync (per-window when possible), start the surface if needed, force a refresh, and request a visible-surface display with a window-attach fallback; run this pass twice (now and ~30ms later) to cover layout/composition races.
    • UI regression test (automation socket) creates 10 workspaces and asserts the selected terminal presents its first IOSurface-backed frame using render_stats; bypasses onboarding with -cmuxWelcomeShown YES.

Written for commit 0561f15. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • More reliable initial rendering and display updates when workspaces or tabs are created/selected, ensuring terminal content appears promptly even when windows attach or views are not yet ready.
  • Tests

    • Added UI tests that validate rapid workspace creation still presents the first frame for each new workspace and focused terminal panel.

@vercel

vercel Bot commented Apr 12, 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 Apr 12, 2026 7:14am

@coderabbitai

coderabbitai Bot commented Apr 12, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds an initial render-refresh flow for newly selected workspaces: defers surface display until window attach, exposes a surface display request on the Ghostty surface view, schedules multi-pass geometry/refresh from Workspace when a workspace is selected, and adds a UI test exercising rapid workspace creation.

Changes

Cohort / File(s) Summary
Surface display control
Sources/GhosttyTerminalView.swift
Added requestVisibleSurfaceDisplayIfNeeded() and pendingVisibleSurfaceDisplayOnWindowAttach to defer display when window == nil, force layout, and call setNeedsDisplay() on the hosted CAMetalLayer (falls back to view layer). Also re-dispatches the request on viewDidMoveToWindow().
Workspace render orchestration
Sources/Workspace.swift
Added scheduleInitialSelectedTerminalRenderRefresh(reason:) which validates selection, reattaches focused TerminalPanel view, schedules geometry reconciles and PortalRegistry syncs, runs two main-queue refresh passes (0 and 0.03s) that reconcile geometry, start/refresh Ghostty surfaces, and call the surface display request.
TabManager trigger
Sources/TabManager.swift
Calls newWorkspace.scheduleInitialSelectedTerminalRenderRefresh(reason: "tabManager.addWorkspace") immediately after posting focus notification and selecting the new tab.
UI tests & socket helpers
cmuxUITests/AutomationSocketUITests.swift
Added testRapidWorkspaceCreationPresentsFirstFrameWithoutWorkspaceSwitch, ControlSocketClient, socket utilities, polling helpers (waitForCondition, waitForSocketPong, waitForSelectedWorkspaceFirstFrame, currentRenderStats), and adjusted configuredApp(mode:) launch args.

Sequence Diagram(s)

sequenceDiagram
    participant TabMgr as TabManager
    participant WS as Workspace
    participant Panel as TerminalPanel
    participant PortalReg as TerminalWindowPortalRegistry
    participant Surface as GhosttySurfaceScrollView

    TabMgr->>WS: scheduleInitialSelectedTerminalRenderRefresh(reason)
    WS->>Panel: reattachView() / scheduleGeometryReconcile()

    Note over WS: First refresh pass (delay 0)
    WS->>WS: validate selected workspace & focused panel
    WS->>PortalReg: synchronizeExternalGeometry(window-aware)
    WS->>Panel: reconcileGeometry()
    alt Ghostty surface exists
        WS->>Panel: forceRefresh(reason)
    else No surface yet
        WS->>Panel: startBackgroundSurface()
    end
    WS->>Surface: requestVisibleSurfaceDisplayIfNeeded()
    Surface->>Surface: if window==nil set deferral flag and return
    Surface->>Surface: else layoutSubtreeIfNeeded() + setNeedsDisplay()

    Note over WS: Second refresh pass (delay 0.03)
    WS->>WS: validate still selected & focused
    WS->>PortalReg: synchronizeExternalGeometry()
    WS->>Panel: reconcileGeometry()
    WS->>Surface: requestVisibleSurfaceDisplayIfNeeded()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇
I nudged the metal surface awake at last,
No ghostly terminals fading in the past.
Two tiny passes, a gentle hop and call,
Now first frames bloom — no blankness at all. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title directly summarizes the main change: preventing blank terminals in newly created workspaces, which matches the core issue being fixed.
Linked Issues check ✅ Passed All code changes align with #2555 requirements: scheduling post-selection terminal refresh to present first frame immediately, synchronizing geometry, and adding regression UI test to prevent recurrence.
Out of Scope Changes check ✅ Passed All file changes directly address the blank terminal issue: GhosttyTerminalView adds deferred surface display, TabManager triggers refresh scheduling, Workspace implements the scheduled refresh, and tests validate the fix.
Description check ✅ Passed The PR description comprehensively covers all required template sections including summary of changes (what and why), testing approach via automation test, and a detailed checklist completion status.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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-2555-blank-terminal-new-workspace

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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 Apr 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a blank terminal screen on new workspace creation by scheduling a post-insertion render refresh that mirrors the re-present path: reattaching the portal, synchronizing geometry, forcing a surface refresh, and nudging the selected surface layer to display. A UI regression test validates that each of 10 rapidly-created workspaces presents its first terminal frame before the next is created.

Confidence Score: 5/5

Safe to merge — fix follows established patterns, guards against stale state, and is covered by a new regression test.

All findings are P2 style suggestions (async-redispatch guard vs. assertion, code duplication with scheduleMovedTerminalRefresh). No logic errors, data integrity issues, or threading violations were found.

No files require special attention.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds requestVisibleSurfaceDisplayIfNeeded() to GhosttySurfaceScrollView; nudges the Metal/CA layer to composite after portal sync. Main-thread guard uses async-redispatch rather than assertion (P2 style note).
Sources/Workspace.swift Adds scheduleInitialSelectedTerminalRenderRefresh mirroring scheduleMovedTerminalRefresh with extra portal geometry sync and display nudge; code duplication between the two methods is a minor style concern (P2).
Sources/TabManager.swift Calls scheduleInitialSelectedTerminalRenderRefresh after selectedTabId and notification are set; gated behind the existing select guard — placement looks correct.
cmuxUITests/AutomationSocketUITests.swift Adds a regression UI test that creates 10 workspaces sequentially and polls render_stats to verify each terminal presents its first frame; also introduces a self-contained ControlSocketClient for raw Unix socket communication in tests.

Sequence Diagram

sequenceDiagram
    participant Socket as Socket Command
    participant TM as TabManager.addWorkspace
    participant WS as Workspace
    participant Portal as TerminalWindowPortalRegistry
    participant SV as GhosttySurfaceScrollView

    Socket->>TM: new_workspace (select: true)
    TM->>TM: selectedTabId = newWorkspace.id
    TM->>TM: post .ghosttyDidFocusTab
    TM->>WS: scheduleInitialSelectedTerminalRenderRefresh()
    WS->>WS: terminalPanel.requestViewReattach()
    WS->>WS: scheduleTerminalGeometryReconcile()

    Note over WS: Pass 1 — asyncAfter(delay: 0)
    WS->>Portal: scheduleExternalGeometrySynchronize(for: window)
    WS->>SV: reconcileGeometryNow()
    WS->>SV: surface.forceRefresh(reason:)
    WS->>SV: requestVisibleSurfaceDisplayIfNeeded()

    Note over WS: Pass 2 — asyncAfter(delay: 0.03 s)
    WS->>Portal: scheduleExternalGeometrySynchronize(for: window)
    WS->>SV: reconcileGeometryNow()
    WS->>SV: surface.forceRefresh(reason:)
    WS->>SV: requestVisibleSurfaceDisplayIfNeeded()
Loading

Reviews (1): Last reviewed commit: "Refresh terminal portal after workspace ..." | Re-trigger Greptile

Comment on lines +9207 to +9212
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.requestVisibleSurfaceDisplayIfNeeded()
}
return
}

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 Main-thread guard silently re-dispatches instead of asserting

The if !Thread.isMainThread branch dispatches asynchronously back to main, but since every call site is already dispatched via DispatchQueue.main.asyncAfter, this path is never exercised. A silent async redispatch here would mask a threading mistake at the call site rather than surface it. An assert or precondition would be more appropriate for a method that is explicitly scoped to structural transitions.

Suggested change
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.requestVisibleSurfaceDisplayIfNeeded()
}
return
}
assert(Thread.isMainThread, "requestVisibleSurfaceDisplayIfNeeded must be called on the main thread")

Comment thread Sources/Workspace.swift
Comment on lines +10916 to +10950
func scheduleInitialSelectedTerminalRenderRefresh(reason: String = "workspace.create.initialSelection") {
guard let terminalPanel = focusedTerminalPanel else { return }
let panelId = terminalPanel.id

// New-workspace insertion can saturate the current layout turn before the freshly
// selected terminal gets the same portal sync/reveal path that a tab re-present does.
terminalPanel.requestViewReattach()
scheduleTerminalGeometryReconcile()

let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self,
self.owningTabManager?.selectedTabId == self.id,
self.focusedPanelId == panelId,
let panel = self.terminalPanel(for: panelId) else { return }

if let window = panel.hostedView.window {
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
} else {
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
}

panel.hostedView.reconcileGeometryNow()
if panel.surface.surface != nil {
panel.surface.forceRefresh(reason: reason)
} else {
panel.surface.requestBackgroundSurfaceStartIfNeeded()
}
panel.hostedView.requestVisibleSurfaceDisplayIfNeeded()
}
}

runRefreshPass(0)
runRefreshPass(0.03)
}

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 Duplicated runRefreshPass pattern with scheduleMovedTerminalRefresh

scheduleInitialSelectedTerminalRenderRefresh and the private scheduleMovedTerminalRefresh (lines 10890–10913) share the same double-pass asyncAfter structure with near-identical closure bodies. The new method adds portal geometry sync and the requestVisibleSurfaceDisplayIfNeeded() nudge on top, but the overall skeleton is repeated verbatim. Extracting the shared structure into a private helper (e.g. accepting the extra steps as closures or a flags enum) would make the two call sites easier to maintain together when the refresh logic evolves.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Sources/Workspace.swift (1)

10932-10935: Avoid the all-windows geometry sync fallback here.

When panel.hostedView.window is still nil, calling scheduleExternalGeometrySynchronizeForAllWindows() adds cross-window portal work on the exact path this bug report says is already main-thread saturated, and it still does not give this panel a concrete window to synchronize against. Sources/GhosttyTerminalView.swift:11952-11981 already treats “no window yet” as a defer/retry case. Prefer a panel-local retry once the hosted view is attached instead of fanning out to every window.

♻️ Suggested change
-                if let window = panel.hostedView.window {
-                    TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
-                } else {
-                    TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
-                }
+                guard let window = panel.hostedView.window else {
+                    panel.requestViewReattach()
+                    self.scheduleTerminalGeometryReconcile()
+                    return
+                }
+                TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 10932 - 10935, The current fallback
calling
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
when panel.hostedView.window is nil should be removed; instead, defer and retry
for this specific panel by observing when panel.hostedView gets attached to a
window and then call
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window).
Concretely: stop invoking scheduleExternalGeometrySynchronizeForAllWindows(),
add a panel-local retry mechanism (e.g., attach a one-shot observer/callback on
panel.hostedView to detect when hostedView.window becomes non-nil or reuse the
same defer/retry approach used in GhosttyTerminalView’s handling), and once you
detect a window, call
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
and remove the observer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 10932-10935: The current fallback calling
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
when panel.hostedView.window is nil should be removed; instead, defer and retry
for this specific panel by observing when panel.hostedView gets attached to a
window and then call
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window).
Concretely: stop invoking scheduleExternalGeometrySynchronizeForAllWindows(),
add a panel-local retry mechanism (e.g., attach a one-shot observer/callback on
panel.hostedView to detect when hostedView.window becomes non-nil or reuse the
same defer/retry approach used in GhosttyTerminalView’s handling), and once you
detect a window, call
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
and remove the observer.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98e7f74a-e192-4e73-b8e1-865a01f74391

📥 Commits

Reviewing files that changed from the base of the PR and between d289f66 and f2426fb.

📒 Files selected for processing (4)
  • Sources/GhosttyTerminalView.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxUITests/AutomationSocketUITests.swift

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

No issues found across 4 files

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 0561f156 Deployed Apr 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

new workspace terminal doesn't render until tab switch — first metal frame deferred by main thread congestion

3 participants