Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5789,6 +5789,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
backing: .buffered,
defer: false
)
// Disable macOS native window state restoration. cmux manages its own
// session persistence via SessionPersistenceStore; letting macOS also
// restore windows causes duplicate windows on relaunch.
window.isRestorable = false
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
Expand Down
4 changes: 4 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2801,6 +2801,10 @@ struct ContentView: View {

view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
// Disable macOS native window state restoration. cmux manages its own
// session persistence; letting macOS also restore windows causes
// duplicate windows on relaunch.
window.isRestorable = false
window.titlebarAppearsTransparent = true
// Do not make the entire background draggable; it interferes with drag gestures
// like sidebar tab reordering in multi-window mode.
Expand Down
47 changes: 40 additions & 7 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ extension Workspace {
setCustomColor(snapshot.customColor)
isPinned = snapshot.isPinned

// Preserve the restored title so new shell process titles don't
// immediately overwrite it (see updatePanelTitle guard).
if snapshot.customTitle == nil, !snapshot.processTitle.isEmpty {
restoredSessionProcessTitle = snapshot.processTitle
}
Comment on lines +243 to +247

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 Guard set for multi-panel workspaces but never cleared via normal path

restoredSessionProcessTitle is assigned unconditionally here for any workspace whose snapshot has customTitle == nil and a non-empty processTitle, regardless of how many panels the workspace had. However, in updatePanelTitle the guard is only examined when panels.count == 1. For a workspace restored with multiple panels, the guard is set but will never be evaluated or cleared through the normal updatePanelTitle path. It will persist until the workspace is deallocated or the user reduces panels to 1, at which point it could unexpectedly suppress an unrelated title update.

Consider gating the assignment on single-panel workspaces:

if snapshot.customTitle == nil, !snapshot.processTitle.isEmpty, snapshot.panels.count == 1 {
    restoredSessionProcessTitle = snapshot.processTitle
}


// Status entries and agent PIDs are ephemeral runtime state tied to running
// processes (e.g. claude_code "Running"). Don't restore them across app
// restarts because the processes that set them are gone.
Expand Down Expand Up @@ -5104,6 +5110,13 @@ final class Workspace: Identifiable, ObservableObject {

private var processTitle: String

/// When non-nil, the workspace title was restored from a session snapshot.
/// The first shell process title update is suppressed to prevent the new
/// shell's generic title (e.g. "zsh") from overwriting the meaningful
/// restored title. Cleared once a *different* process title arrives,
/// indicating the user has started a new command.
private var restoredSessionProcessTitle: String?

private enum SurfaceKind {
static let terminal = "terminal"
static let browser = "browser"
Expand Down Expand Up @@ -5808,6 +5821,7 @@ final class Workspace: Identifiable, ObservableObject {

func setCustomTitle(_ title: String?) {
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
restoredSessionProcessTitle = nil
if trimmed.isEmpty {
customTitle = nil
self.title = processTitle
Expand Down Expand Up @@ -6018,14 +6032,33 @@ final class Workspace: Identifiable, ObservableObject {
)
}

// If this is the only panel and no custom title, update workspace title
// If this is the only panel and no custom title, update workspace title.
// After session restore, suppress the first title update (the new shell
// reporting a generic title like "zsh") to preserve the restored title.
// Once a genuinely different title arrives, clear the guard and resume
// normal auto-updating.
if panels.count == 1, customTitle == nil {
if self.title != trimmed {
self.title = trimmed
didMutate = true
}
if processTitle != trimmed {
processTitle = trimmed
if let restoredTitle = restoredSessionProcessTitle {
// The new shell's initial title matches or is less informative
// than the restored title — keep the restored one.
if trimmed != restoredTitle {

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Restore-title guard only blocks exact matches; first differing startup titles still overwrite restored workspace titles.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 6044:

<comment>Restore-title guard only blocks exact matches; first differing startup titles still overwrite restored workspace titles.</comment>

<file context>
@@ -6018,14 +6032,33 @@ final class Workspace: Identifiable, ObservableObject {
+            if let restoredTitle = restoredSessionProcessTitle {
+                // The new shell's initial title matches or is less informative
+                // than the restored title — keep the restored one.
+                if trimmed != restoredTitle {
+                    restoredSessionProcessTitle = nil
+                    if self.title != trimmed {
</file context>
Fix with Cubic

restoredSessionProcessTitle = nil
if self.title != trimmed {
self.title = trimmed
didMutate = true
}
if processTitle != trimmed {
processTitle = trimmed
}
}
Comment on lines +6041 to +6053

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 Guard only suppresses when titles match, not the initial shell title

The guard clears (and immediately allows the new title through) when trimmed != restoredTitle. This means the fix only preserves the restored title when the shell's very first reported title happens to exactly equal the restored title — the "directory as title" case. For workspaces where the restored title was a command name (e.g. "vim" or "git log"), the shell's first title report ("zsh" or the current directory) will be different from the restored title, so trimmed != restoredTitle evaluates to true, the guard is immediately cleared, and the restored title is still overwritten.

Per the PR description the intent is to suppress the initial generic shell title (e.g. "zsh") unconditionally, not only when it happens to match. A more robust approach would be to consume exactly one initial title event regardless of its value:

if let _ = restoredSessionProcessTitle {
    // Consume the initial shell title event unconditionally; the restored
    // title is already reflected in self.title / processTitle.
    restoredSessionProcessTitle = nil
} else {
    if self.title != trimmed {
        self.title = trimmed
        didMutate = true
    }
    if processTitle != trimmed {
        processTitle = trimmed
    }
}

This would cover all restored-title shapes (directory-based, command-based, etc.) while still allowing subsequent title updates through normally.

} else {
if self.title != trimmed {
self.title = trimmed
didMutate = true
}
if processTitle != trimmed {
processTitle = trimmed
}
Comment on lines +6035 to +6061

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

trimmed != restoredTitle releases the guard too early.

Line 6044 treats any title that differs from the snapshot as a real post-restore update. If the snapshot was taken while the workspace title was a running command like npm test, the first prompt title from the relaunched shell (zsh) will clear the guard and overwrite the restored title immediately. That misses the "preserve restored title until a real new command appears" behavior this change is aiming for. Please key the release off a stronger signal than simple inequality, such as shell-activity state or another explicit "new command started" transition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 6035 - 6061, The guard cleared by
comparing trimmed != restoredSessionProcessTitle is too weak and will drop the
restored title when the relaunched shell reports a generic initial title (e.g.,
"zsh"); update the logic in the panels.count == 1 && customTitle == nil branch
to only clear restoredSessionProcessTitle when there is a stronger signal of new
shell activity: instead of releasing the guard on simple inequality, require
either (a) an explicit shell-activity flag or transition (e.g., a
newCommandStarted or lastCommandTimestamp change you add/observe), or (b) a
heuristic check that the incoming trimmed title is meaningfully more informative
than the snapshot (for example not matching known shell names/prompts and
containing whitespace or command characters, or being longer than
restoredSessionProcessTitle and not a short shell identifier). Apply this change
where restoredSessionProcessTitle, trimmed, title and processTitle are handled
so the guard only clears when the new title indicates real new command activity.

}
}

Expand Down