From 4ea1cf3a02e75d08a982adb1f729a5971c2d48ae Mon Sep 17 00:00:00 2001 From: Ali Gasimzade Date: Mon, 22 Jun 2026 15:05:48 +0400 Subject: [PATCH 1/4] feat: implement lazy session restore feature with UI toggle --- .../xcshareddata/xcschemes/Deckard.xcscheme | 6 +- Sources/Window/DeckardWindowController.swift | 193 ++++++++++-------- Sources/Window/SettingsWindow.swift | 18 ++ Sources/Window/SidebarController.swift | 8 +- Sources/Window/SidebarViews.swift | 49 +++-- 5 files changed, 169 insertions(+), 105 deletions(-) diff --git a/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme b/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme index faf89b1..2d44fb6 100644 --- a/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme +++ b/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme @@ -15,7 +15,7 @@ @@ -56,7 +56,7 @@ @@ -73,7 +73,7 @@ diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 5ab1a9f..11f2069 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -22,6 +22,16 @@ class TabItem { var badgeState: BadgeState = .none /// Set during restore — suppresses completedUnseen until hook.session-start fires. var suppressUnseen: Bool = false + /// Deferred shell-start parameters. Set when the tab is created lazily + /// the process only spawns when the tab is first shown. + var pendingStart: PendingStart? + + struct PendingStart { + let workingDirectory: String + let envVars: [String: String] + let initialInput: String? + let tmuxSession: String? + } var isClaude: Bool { kind == .claude } var isCodex: Bool { kind == .codex } @@ -577,10 +587,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { workspace.name = snapshot.name workspace.defaultArgs = snapshot.defaultArgs workspace.defaultCodexArgs = snapshot.defaultCodexArgs + // When lazy restore is on, only the selected tab's process starts + // otherwise all tabs start eagerly. + let lazy = UserDefaults.standard.bool(forKey: "lazySessionRestore") for ts in snapshot.tabs { createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, - tmuxSessionToResume: ts.tmuxSessionName) + tmuxSessionToResume: ts.tmuxSessionName, + deferStart: lazy) } workspace.selectedTabIndex = min(snapshot.selectedTabIndex, workspace.tabs.count - 1) } @@ -754,7 +768,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { createTabInWorkspace(workspace, kind: isClaude ? .claude : .terminal, name: name, sessionIdToResume: sessionIdToResume, forkSession: forkSession, tmuxSessionToResume: tmuxSessionToResume, extraArgs: extraArgs) } - func createTabInWorkspace(_ workspace: WorkspaceItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { + func createTabInWorkspace(_ workspace: WorkspaceItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil, deferStart: Bool = false) { let surface = TerminalSurface() let tabName: String if let name = name { @@ -823,14 +837,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { initialInput = nil } - DiagnosticLog.shared.log("surface", "createTab: \(kind.rawValue) surfaceId=\(surface.surfaceId)") - - surface.startShell( - workingDirectory: workspace.path, - envVars: envVars, - initialInput: initialInput, - tmuxSession: tmuxSessionToResume - ) + DiagnosticLog.shared.log("surface", "createTab: \(kind.rawValue) surfaceId=\(surface.surfaceId) deferred=\(deferStart)") surface.onProcessExit = { [weak self] exitedSurface in DispatchQueue.main.async { @@ -838,11 +845,74 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } + if deferStart { + tab.pendingStart = TabItem.PendingStart( + workingDirectory: workspace.path, + envVars: envVars, + initialInput: initialInput, + tmuxSession: tmuxSessionToResume + ) + + if kind == .terminal { + surface.tmuxSessionName = tmuxSessionToResume + } + } else { + surface.startShell( + workingDirectory: workspace.path, + envVars: envVars, + initialInput: initialInput, + tmuxSession: tmuxSessionToResume + ) + if kind == .codex && (tab.sessionId == nil || forkSession) { + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: workspace.path) + } + } + workspace.tabs.append(tab) tabCreationOrder.append(tab.id) + } + + /// Start the shell for a lazily-created tab the first time it is shown. + /// - Parameter refreshSidebar: when true, schedules a sidebar rebuild so the + /// tab's dot fills in. The eager bulk-start path passes + /// false and rebuilds once at the end instead. + func startPendingShellIfNeeded(_ tab: TabItem, refreshSidebar: Bool = true) { + guard let pending = tab.pendingStart else { return } + tab.pendingStart = nil + + DiagnosticLog.shared.log("surface", + "lazy start: \(tab.kind.rawValue) surfaceId=\(tab.surface.surfaceId) cwd=\(pending.workingDirectory)") - if kind == .codex && (tab.sessionId == nil || forkSession) { - scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: workspace.path) + tab.surface.startShell( + workingDirectory: pending.workingDirectory, + envVars: pending.envVars, + initialInput: pending.initialInput, + tmuxSession: pending.tmuxSession + ) + + if tab.kind == .codex && tab.sessionId == nil { + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: pending.workingDirectory) + } + + // The tab is no longer pending — refresh the sidebar so its dot fills in + // (hollow → solid). Async to avoid re-entrancy when called mid-rebuild. + if refreshSidebar { + DispatchQueue.main.async { [weak self] in + self?.rebuildSidebar() + } + } + } + + /// Eager-restore helper: start every still-pending tab one at a time with a + /// small delay, so a full session restore doesn't spawn N processes at once. + private func startPendingTabsProgressively(_ remaining: [TabItem]) { + guard let tab = remaining.first else { + rebuildSidebar() + return + } + startPendingShellIfNeeded(tab, refreshSidebar: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.startPendingTabsProgressively(Array(remaining.dropFirst())) } } @@ -1068,6 +1138,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func showTab(_ tab: TabItem) { hideEmptyState() + + startPendingShellIfNeeded(tab) + let view = tab.surface.view // Remove the previous surface view from the hierarchy. @@ -1694,40 +1767,29 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return nil } - // Phase 1: Create the active workspace's active tab immediately so the user - // sees a working terminal right away. Collect remaining tabs for Phase 2. - var pending: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] - for (i, ps) in workspaceStates.enumerated() { + for ps in workspaceStates { let workspace = WorkspaceItem(path: ps.path) workspace.name = ps.name workspace.defaultArgs = ps.defaultArgs workspace.defaultCodexArgs = ps.defaultCodexArgs - let selTab = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) - - for (t, ts) in ps.tabs.enumerated() { + for ts in ps.tabs { var restoredTab = ts if restoredTab.kind == .codex, restoredTab.sessionId == nil { restoredTab.sessionId = recoverCodexSessionId(for: ps.path, tabName: restoredTab.name) } - - if i == selectedIdx && t == selTab { - // Create the active tab's surface synchronously - createTabInWorkspace(workspace, kind: restoredTab.kind, name: restoredTab.name, - sessionIdToResume: restoredTab.kind.isAgent ? restoredTab.sessionId : nil, - tmuxSessionToResume: restoredTab.tmuxSessionName) - } else { - pending.append((workspace: workspace, tab: restoredTab, originalIndex: t)) - } + createTabInWorkspace(workspace, kind: restoredTab.kind, name: restoredTab.name, + sessionIdToResume: restoredTab.kind.isAgent ? restoredTab.sessionId : nil, + tmuxSessionToResume: restoredTab.tmuxSessionName, + deferStart: true) } - workspace.selectedTabIndex = selTab + workspace.selectedTabIndex = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) workspaces.append(workspace) } - // Keep isRestoring = true until Phase 2 finishes, so selectWorkspace - // won't clamp selectedTabIndex before all tabs are inserted. + isRestoring = false // Restore sidebar groups restoreSidebarGroups(from: state) @@ -1736,9 +1798,23 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if selectedIdx >= 0 && selectedIdx < workspaces.count { selectWorkspace(at: selectedIdx) } + rebuildTabBar() + saveState() + + // Start autosave now that all tabs are restored. + SessionManager.shared.startAutosave { [weak self] in + self?.captureState() ?? DeckardState() + } - // Phase 2: Create remaining surfaces progressively with small delays for UX. - createTabsProgressively(pending) + let lazy = UserDefaults.standard.bool(forKey: "lazySessionRestore") + DiagnosticLog.shared.log("restore", + "restored \(workspaces.count) workspaces, \(workspaces.reduce(0) { $0 + $1.tabs.count }) tabs (lazy=\(lazy))") + + + if !lazy { + let pendingTabs = workspaces.flatMap { $0.tabs.filter { $0.pendingStart != nil } } + startPendingTabsProgressively(pendingTabs) + } } private func restoreSidebarGroups(from state: DeckardState) { @@ -1788,57 +1864,6 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // If no saved order, ensureSidebarOrder() will build one from workspaces } - private func createTabsProgressively(_ remaining: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)]) { - guard let first = remaining.first else { - // All tabs created — rebuild UI to reflect the full state - isRestoring = false - rebuildSidebar() - rebuildTabBar() - saveState() - - // Start autosave now that restore is complete — autosaving - // during progressive restore would lose tabs on crash. - SessionManager.shared.startAutosave { [weak self] in - self?.captureState() ?? DeckardState() - } - - // Dump tab creation order -> PID mapping for diagnostics - let mapping = tabCreationOrder.enumerated().map { (i, id) -> String in - var label = "?" - for workspace in workspaces { - if let tab = workspace.tabs.first(where: { $0.id == id }) { - label = "\(tab.kind.rawValue.prefix(1).uppercased()):\(tab.name)@\(workspace.name)" - break - } - } - return " [\(i)] \(label)" - }.joined(separator: "\n") - DiagnosticLog.shared.log("processmon", "tabCreationOrder after restore (\(tabCreationOrder.count) tabs):\n\(mapping)") - - return - } - - let ts = first.tab - let workspace = first.workspace - let insertAt = first.originalIndex - - // Create the tab (appends to workspace.tabs) - createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, - sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, - tmuxSessionToResume: ts.tmuxSessionName) - - // Move it from the end to its original position - if insertAt < workspace.tabs.count - 1 { - let tab = workspace.tabs.removeLast() - workspace.tabs.insert(tab, at: min(insertAt, workspace.tabs.count)) - } - - // Small delay between tab creations for smoother UX during restore. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [self] in - self.createTabsProgressively(Array(remaining.dropFirst())) - } - } - // MARK: - Theme @objc private func vibrancyDidChange() { diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index 580faf7..432eb8d 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -156,6 +156,20 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie tabConfigHelp.textColor = .secondaryLabelColor grid.addRow(with: [NSGridCell.emptyContentView, tabConfigHelp]) + let lazyLabel = NSTextField(labelWithString: "Startup:") + lazyLabel.alignment = .right + + let lazyCheck = NSButton(checkboxWithTitle: "Restore sessions lazily", + target: self, action: #selector(lazyRestoreToggled(_:))) + lazyCheck.state = UserDefaults.standard.bool(forKey: "lazySessionRestore") ? .on : .off + grid.addRow(with: [lazyLabel, lazyCheck]) + + let lazyHelp = NSTextField(wrappingLabelWithString: + "Don't launch a session until its tab is first opened. Not-yet-loaded sessions show a hollow dot in the sidebar.") + lazyHelp.font = .systemFont(ofSize: 11) + lazyHelp.textColor = .secondaryLabelColor + grid.addRow(with: [NSGridCell.emptyContentView, lazyHelp]) + pane.addSubview(grid) NSLayoutConstraint.activate([ grid.topAnchor.constraint(equalTo: pane.topAnchor, constant: 20), @@ -295,6 +309,10 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie UserDefaults.standard.set(sender.state == .on, forKey: "promptForSessionArgs") } + @objc private func lazyRestoreToggled(_ sender: NSButton) { + UserDefaults.standard.set(sender.state == .on, forKey: "lazySessionRestore") + } + @objc private func codexPerSessionArgsToggled(_ sender: NSButton) { UserDefaults.standard.set(sender.state == .on, forKey: "promptForCodexSessionArgs") } diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index f7498c4..d872858 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -86,7 +86,7 @@ extension DeckardWindowController { target: self, action: #selector(workspaceRowClicked(_:))) row.shortcutBadge = shortcutForWorkspaceIndex[pi] row.badgeInfos = workspace.tabs.filter { $0.badgeState != .none }.map { tab in - (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) + (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id], pending: tab.pendingStart != nil) } row.onRename = { [weak self] newName in guard let self = self else { return } @@ -126,11 +126,11 @@ extension DeckardWindowController { } // Aggregate badge infos from all workspaces in the group - var aggregatedBadges: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] + var aggregatedBadges: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?, pending: Bool)] = [] for pid in group.workspaceIds { if let workspace = workspaceById(pid) { for tab in workspace.tabs where tab.badgeState != .none { - aggregatedBadges.append((state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id])) + aggregatedBadges.append((state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id], pending: tab.pendingStart != nil)) } } } @@ -161,7 +161,7 @@ extension DeckardWindowController { row.indent = 16 row.shortcutBadge = shortcutForWorkspaceIndex[pi] row.badgeInfos = workspace.tabs.filter { $0.badgeState != .none }.map { tab in - (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) + (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id], pending: tab.pendingStart != nil) } row.onRename = { [weak self] newName in guard let self = self else { return } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 586387b..611fa0d 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -10,7 +10,8 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { didSet { needsDisplay = true } } /// Badge info for each Claude tab in this workspace, shown as right-aligned dots. - var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { + /// `pending marks a not-yet-loaded (lazy) tab — drawn as a hollow outline dot. + var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?, pending: Bool)] = [] { didSet { updateBadgeDots() } } var onRename: ((String) -> Void)? @@ -112,10 +113,13 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { for info in badgeInfos where info.state != .none { let dot = BadgeShapeView( shape: Self.shapeForBadge(info.state), - color: Self.colorForBadge(info.state) + color: Self.colorForBadge(info.state), + filled: !info.pending ) - dot.toolTip = "\(info.name): \(Self.tooltipForBadge(info.state, activity: info.activity))" - if SettingsWindowController.isBadgeAnimated(info.state) { + let suffix = info.pending ? " (not loaded)" : "" + dot.toolTip = "\(info.name): \(Self.tooltipForBadge(info.state, activity: info.activity))\(suffix)" + // Pending tabs aren't actually running, so never pulse them. + if !info.pending && SettingsWindowController.isBadgeAnimated(info.state) { Self.addPulseAnimation(to: dot) } badgeContainer.addArrangedSubview(dot) @@ -308,7 +312,8 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { } /// Badge info aggregated from all workspaces in the group. - var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { + /// `pending` marks a not-yet-loaded (lazy) tab — drawn as a hollow outline dot. + var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?, pending: Bool)] = [] { didSet { updateBadgeDots() } } @@ -502,10 +507,12 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { for info in badgeInfos where info.state != .none { let dot = BadgeShapeView( shape: VerticalTabRowView.shapeForBadge(info.state), - color: VerticalTabRowView.colorForBadge(info.state) + color: VerticalTabRowView.colorForBadge(info.state), + filled: !info.pending ) - dot.toolTip = "\(info.name): \(VerticalTabRowView.tooltipForBadge(info.state, activity: info.activity))" - if SettingsWindowController.isBadgeAnimated(info.state) { + let suffix = info.pending ? " (not loaded)" : "" + dot.toolTip = "\(info.name): \(VerticalTabRowView.tooltipForBadge(info.state, activity: info.activity))\(suffix)" + if !info.pending && SettingsWindowController.isBadgeAnimated(info.state) { VerticalTabRowView.addPulseAnimation(to: dot) } badgeContainer.addArrangedSubview(dot) @@ -890,20 +897,23 @@ class BadgeShapeView: NSView { private var shape: TabItem.BadgeShape private var color: NSColor + /// When false, the dot is drawn as a stroked outline instead of filled, used to indicate a not-yet-loaded (lazy) session. + private var filled: Bool private var isPulseAnimationEnabled = false private var pulseTimer: Timer? private var pulseStartTime: TimeInterval = 0 - init(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { + init(shape: TabItem.BadgeShape, color: NSColor, filled: Bool = true, size: CGFloat = 7) { self.shape = shape self.color = color + self.filled = filled super.init(frame: NSRect(x: 0, y: 0, width: size, height: size)) translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: size), heightAnchor.constraint(equalToConstant: size), ]) - updateAppearance(shape: shape, color: color, size: size) + updateAppearance(shape: shape, color: color, filled: filled, size: size) } required init?(coder: NSCoder) { fatalError() } @@ -932,18 +942,29 @@ class BadgeShapeView: NSView { } } - func updateAppearance(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { + func updateAppearance(shape: TabItem.BadgeShape, color: NSColor, filled: Bool = true, size: CGFloat = 7) { self.shape = shape self.color = color + self.filled = filled needsDisplay = true } override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() - context.addPath(Self.path(for: shape, in: bounds)) - context.setFillColor(color.cgColor) - context.fillPath() + if filled { + context.addPath(Self.path(for: shape, in: bounds)) + context.setFillColor(color.cgColor) + context.fillPath() + } else { + // Hollow outline inset by half the line width so the stroke isn't clipped. + let lineWidth: CGFloat = 1.25 + let inset = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2) + context.addPath(Self.path(for: shape, in: inset)) + context.setStrokeColor(color.cgColor) + context.setLineWidth(lineWidth) + context.strokePath() + } context.restoreGState() } From 4db3e9c0a5fcb4f499502b7b3339ac6d15672683 Mon Sep 17 00:00:00 2001 From: Ali Gasimzade <38853700+quarterpound@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:28:17 +0400 Subject: [PATCH 2/4] Rename buildable target from 'Deckard Dev.app' to 'Deckard.app' --- Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme b/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme index 2d44fb6..e086af0 100644 --- a/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme +++ b/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme @@ -15,7 +15,7 @@ @@ -73,7 +73,7 @@ From 82e65b079f1aefa48f851de3202b8aa553258eb6 Mon Sep 17 00:00:00 2001 From: Ali Gasimzade <38853700+quarterpound@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:28:39 +0400 Subject: [PATCH 3/4] Rename buildable app from 'Deckard Dev.app' to 'Deckard.app' --- Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme b/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme index e086af0..faf89b1 100644 --- a/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme +++ b/Deckard.xcodeproj/xcshareddata/xcschemes/Deckard.xcscheme @@ -56,7 +56,7 @@ From 149bc976c1984cea921c27267801f7f6469e123a Mon Sep 17 00:00:00 2001 From: Ali Gasimzade Date: Thu, 23 Jul 2026 17:32:04 +0300 Subject: [PATCH 4/4] fix: harden deferred-tab restore against orphaned processes and stale badges Addresses review feedback on lazy session restore (#93): - startPendingShellIfNeeded bails (and clears pendingStart) when the tab is no longer in any workspace, so the progressive-restore walk can't spawn an orphaned process for a tab closed before its deferred start ran; handleSurfaceClosedById also clears pendingStart on removal. - Tab bar renders deferred tabs' badge dots hollow and non-pulsing (filled: !pending, gated animation, "(not loaded)" tooltip), matching the sidebar. - Exclude deferred codex tabs from codex badge polling so a stale on-disk .jsonl can't drive a never-started tab to .codexThinking. - Capture forkSession in PendingStart and align the lazy codex-discovery guard with the eager path to prevent divergence. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Window/DeckardWindowController.swift | 24 +++++++++++++++++--- Sources/Window/TabBarController.swift | 1 + Sources/Window/TabBarViews.swift | 9 +++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 11f2069..154ae4d 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -31,6 +31,7 @@ class TabItem { let envVars: [String: String] let initialInput: String? let tmuxSession: String? + let forkSession: Bool } var isClaude: Bool { kind == .claude } @@ -850,7 +851,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { workingDirectory: workspace.path, envVars: envVars, initialInput: initialInput, - tmuxSession: tmuxSessionToResume + tmuxSession: tmuxSessionToResume, + forkSession: forkSession ) if kind == .terminal { @@ -878,6 +880,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { /// false and rebuilds once at the end instead. func startPendingShellIfNeeded(_ tab: TabItem, refreshSidebar: Bool = true) { guard let pending = tab.pendingStart else { return } + // A tab closed before its deferred start ran must never spawn a process. + // The progressive-restore walk (and any queued caller) can still hold a + // reference after closeTabById/closeWorkspace removed it from its + // workspace, so bail if it's no longer present anywhere. + guard workspaces.contains(where: { $0.tabs.contains(where: { $0.id == tab.id }) }) else { + tab.pendingStart = nil + return + } tab.pendingStart = nil DiagnosticLog.shared.log("surface", @@ -890,7 +900,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tmuxSession: pending.tmuxSession ) - if tab.kind == .codex && tab.sessionId == nil { + if tab.kind == .codex && (tab.sessionId == nil || pending.forkSession) { scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: pending.workingDirectory) } @@ -1320,7 +1330,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tabInfos.append(ProcessMonitor.TabInfo( surfaceId: tab.id, kind: tab.kind, name: tab.name, workspacePath: workspace.path)) - if tab.kind == .codex { + // Skip deferred codex tabs: their process was never spawned, + // so a stale on-disk .jsonl must not drive the badge to + // .codexThinking on a tab that isn't actually running. + if tab.kind == .codex, tab.pendingStart == nil { codexTargets.append(CodexBadgePollTarget( surfaceId: tab.id, workspacePath: workspace.path, @@ -1466,6 +1479,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if let ti = workspace.tabs.firstIndex(where: { $0.id == surfaceId }) { let tab = workspace.tabs[ti] + // A deferred tab closed before it ever started must not leave a + // pending start queued — the progressive-restore walk could + // otherwise reach it and spawn an orphaned process. + tab.pendingStart = nil + // Terminal tabs: restart shell instead of removing the tab. // Reconnects to the tmux session if it still exists, otherwise // starts a fresh shell. Rate-limited to prevent crash loops. diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index 41bd2c4..ea086f6 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -43,6 +43,7 @@ extension DeckardWindowController { kind: tab.kind, badgeState: tab.badgeState, activity: terminalActivity[tab.id], + pending: tab.pendingStart != nil, isSelected: isSelected, index: i, target: self, diff --git a/Sources/Window/TabBarViews.swift b/Sources/Window/TabBarViews.swift index c1e894b..cfdfb78 100644 --- a/Sources/Window/TabBarViews.swift +++ b/Sources/Window/TabBarViews.swift @@ -29,6 +29,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { init(displayTitle: String, editableName: String, kind: TabKind = .terminal, badgeState: TabItem.BadgeState = .none, activity: ProcessMonitor.ActivityInfo? = nil, + pending: Bool = false, isSelected: Bool, index: Int, target: AnyObject, clickAction: Selector) { self.index = index @@ -54,14 +55,16 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { if badgeState != .none { let dot = BadgeShapeView( shape: VerticalTabRowView.shapeForBadge(badgeState), - color: VerticalTabRowView.colorForBadge(badgeState) + color: VerticalTabRowView.colorForBadge(badgeState), + filled: !pending ) - dot.toolTip = VerticalTabRowView.tooltipForBadge(badgeState, activity: activity) + let suffix = pending ? " (not loaded)" : "" + dot.toolTip = VerticalTabRowView.tooltipForBadge(badgeState, activity: activity) + suffix addSubview(dot) NSLayoutConstraint.activate([ dot.centerYAnchor.constraint(equalTo: centerYAnchor), ]) - if SettingsWindowController.isBadgeAnimated(badgeState) { + if !pending && SettingsWindowController.isBadgeAnimated(badgeState) { VerticalTabRowView.addPulseAnimation(to: dot) } badgeDot = dot