From 4ce433cd4a84ece69fadcd63f9e6e4404faac129 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sat, 17 Oct 2026 09:03:59 -0500 Subject: [PATCH 1/4] overview: show lane scroll bars Co-Authored-By: Claude Opus 5.5 --- Sources/Flock/Views/MissionControl/MissionControlView.swift | 1 - Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Sources/Flock/Views/MissionControl/MissionControlView.swift b/Sources/Flock/Views/MissionControl/MissionControlView.swift index 7797c573..4954d2d8 100644 --- a/Sources/Flock/Views/MissionControl/MissionControlView.swift +++ b/Sources/Flock/Views/MissionControl/MissionControlView.swift @@ -165,7 +165,6 @@ struct MissionControlView: View { .padding(M.laneScrollInset) .frame(maxWidth: .infinity, alignment: .leading) } - .scrollIndicators(.never) .scrollBounceBehavior(.basedOnSize, axes: .vertical) // Clipped top and bottom only: a card moving to another lane // is drawn by the lane it lands in, and has to stay visible diff --git a/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift b/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift index 643225fb..ce5d6033 100644 --- a/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift +++ b/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift @@ -157,7 +157,6 @@ private struct SubgroupScroll: View { .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, offset in memory.offsets[kind] = offset } - .scrollIndicators(.never) .scrollBounceBehavior(.basedOnSize, axes: .vertical) // Clipped top and bottom only: a card moving to another lane is // drawn by the subgroup it lands in, and has to stay visible as From 3b48a57504b5aee0e04ae493ac3dc635eaa8f9f7 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sat, 17 Oct 2026 09:08:47 -0500 Subject: [PATCH 2/4] overview: lane scroll bars run beside the cards, drawn for the theme Co-Authored-By: Claude Opus 5.5 --- Sources/Flock/Theme/ChromeMetrics.swift | 4 ++++ .../Views/MissionControl/MissionControlView.swift | 2 ++ .../MissionControl/MissionSplitLaneView.swift | 14 +++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/Flock/Theme/ChromeMetrics.swift b/Sources/Flock/Theme/ChromeMetrics.swift index f585dded..fb6bcb8b 100644 --- a/Sources/Flock/Theme/ChromeMetrics.swift +++ b/Sources/Flock/Theme/ChromeMetrics.swift @@ -951,6 +951,10 @@ enum ChromeMetrics { /// and its heading and labels are pulled in to match, so their /// leading edges line up with the groups'. static let laneScrollInset: CGFloat = 3 + /// A lane's scroll view reaches this far into the lane's trailing + /// padding, and its content gives it back, so the scroller runs in + /// the padding instead of over the cards. + static let laneScrollerGutter: CGFloat = lanePadding - laneScrollInset static let coolingOpacity: Double = 0.75 static let timelineWidth: CGFloat = 180 /// A narrow window's lanes give the timeline up before `repo @ diff --git a/Sources/Flock/Views/MissionControl/MissionControlView.swift b/Sources/Flock/Views/MissionControl/MissionControlView.swift index 4954d2d8..f385409f 100644 --- a/Sources/Flock/Views/MissionControl/MissionControlView.swift +++ b/Sources/Flock/Views/MissionControl/MissionControlView.swift @@ -163,6 +163,7 @@ struct MissionControlView: View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: M.cardGap) { cards } .padding(M.laneScrollInset) + .padding(.trailing, M.laneScrollerGutter) .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize, axes: .vertical) @@ -171,6 +172,7 @@ struct MissionControlView: View { // as it crosses from its old lane. .scrollClipDisabled() .mask { Rectangle().padding(.horizontal, -M.crossLaneReach) } + .laneScroller(theme: theme) .onAppear { if let selected = mode.missionSelection { reader.scrollTo(selected) } } .onChange(of: mode.missionSelection) { _, selected in if let selected { withAnimation { reader.scrollTo(selected) } } diff --git a/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift b/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift index ce5d6033..1d783456 100644 --- a/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift +++ b/Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift @@ -106,7 +106,7 @@ struct SplitLaneBody: View { memory.record($0, in: \.labelHeight, for: slot.kind) } .accessibilityIdentifier("flock.mission.subgroup.\(slot.kind.rawValue)") - SubgroupScroll(memory: memory, kind: slot.kind, cards: slot.cards, selection: selection) { content(slot.kind) } + SubgroupScroll(theme: theme, memory: memory, kind: slot.kind, cards: slot.cards, selection: selection) { content(slot.kind) } .frame(height: scrollHeight) } } @@ -132,6 +132,7 @@ struct SplitLaneBody: View { /// One subgroup's scroll area. Its content is measured inside the scroll /// view, where its height does not depend on the frame the split gives it. private struct SubgroupScroll: View { + let theme: Theme let memory: MissionLaneMemory let kind: MissionSubgroup let cards: [PaneID] @@ -147,6 +148,7 @@ private struct SubgroupScroll: View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: M.cardGap) { content() } .padding(M.laneScrollInset) + .padding(.trailing, M.laneScrollerGutter) .frame(maxWidth: .infinity, alignment: .leading) .coordinateSpace(kind.space) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { @@ -163,6 +165,7 @@ private struct SubgroupScroll: View { // it crosses from its old lane. .scrollClipDisabled() .mask { Rectangle().padding(.horizontal, -M.crossLaneReach) } + .laneScroller(theme: theme) .onAppear { if let offset = memory.offsets[kind] { position.scrollTo(y: offset) } guard let selection, cards.contains(selection) else { return } @@ -178,6 +181,15 @@ private struct SubgroupScroll: View { } extension View { + /// A lane's scroll view, out through the lane's trailing padding, with + /// its scroller drawn for the theme's ground: the window's appearance + /// follows the system, so a dark theme otherwise gets a dark scroller. + /// Its content pads `laneScrollerGutter` back on the trailing side. + func laneScroller(theme: Theme) -> some View { + padding(.trailing, -ChromeMetrics.MissionControl.laneScrollerGutter) + .environment(\.colorScheme, ChromeRoles.isLight(panelBg: theme.palette.panelBg) ? .light : .dark) + } + /// Reports a subgroup's first card's bottom, which with its label is the /// least of the subgroup a split may show. func missionSubgroupFloor(_ subgroup: MissionSubgroup?, in memory: MissionLaneMemory) -> some View { From 65dfddf91578dd2f2489e42c99bc0cf3f379e1d9 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sat, 17 Oct 2026 09:26:05 -0500 Subject: [PATCH 3/4] =?UTF-8?q?overview:=20Show=20in=20Workspaces=20(?= =?UTF-8?q?=E2=8C=98O)=20opens=20the=20focused=20pane's=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Sources/Flock/FlockApp.swift | 5 +++++ Sources/Flock/Menus/JumpNavigator.swift | 7 +++++++ Sources/Flock/Menus/ViewCommand.swift | 9 ++++++--- Sources/Flock/Palette/PaletteCatalog.swift | 2 +- Sources/Flock/Palette/PaletteRunner.swift | 2 ++ .../FlockChromeRender/ChromeRenderTests.swift | 19 +++++++++++++++++++ .../PaletteShortcutTests.swift | 1 + 7 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Sources/Flock/FlockApp.swift b/Sources/Flock/FlockApp.swift index 94da7bc6..267ba0c2 100644 --- a/Sources/Flock/FlockApp.swift +++ b/Sources/Flock/FlockApp.swift @@ -446,6 +446,11 @@ struct FlockApp: App { focusedPaneButton(command) } Divider() + Button(ViewCommand.showInWorkspaces.title) { navigator.showInWorkspaces() } + .keyboardShortcut(ViewCommand.showInWorkspaces.shortcut) + .disabled(!navigator.canShowInWorkspaces) + .accessibilityIdentifier(ViewCommand.showInWorkspaces.accessibilityIdentifier) + Divider() // Move and swap compile the plan the equivalent drag would, // through the same planner; focus is a click on the neighbor. ForEach(PaneDirectionCommand.families, id: \.title) { family in diff --git a/Sources/Flock/Menus/JumpNavigator.swift b/Sources/Flock/Menus/JumpNavigator.swift index 183102be..bfd87fbd 100644 --- a/Sources/Flock/Menus/JumpNavigator.swift +++ b/Sources/Flock/Menus/JumpNavigator.swift @@ -72,6 +72,13 @@ struct JumpNavigator { drag.unfocusGridPane() } + var canShowInWorkspaces: Bool { isFocusedInOverview } + + func showInWorkspaces() { + guard isFocusedInOverview, let pane = drag.gridFocusedPane else { return } + jump(to: pane) + } + private func focus(_ pane: PaneID) { guard viewModel.focusInOverview(pane: pane) else { return } mode.missionSelection = pane diff --git a/Sources/Flock/Menus/ViewCommand.swift b/Sources/Flock/Menus/ViewCommand.swift index 1159a86a..912720e3 100644 --- a/Sources/Flock/Menus/ViewCommand.swift +++ b/Sources/Flock/Menus/ViewCommand.swift @@ -7,7 +7,7 @@ enum ViewCommand: String, CaseIterable { case newTab, newWorkspace, closeTab, closeWorkspace case showWorkspaces, showOverview, showArrange case rearrangeMode, allWorkspaces, openOldestNotification, clearNotifications, commandPalette - case backToOverview, openNextCard + case backToOverview, openNextCard, showInWorkspaces var title: String { switch self { @@ -21,6 +21,7 @@ enum ViewCommand: String, CaseIterable { case .openOldestNotification: "Open Oldest Notification" case .backToOverview: "Back to Overview" case .openNextCard: "Open Next Card" + case .showInWorkspaces: "Show in Workspaces" case .clearNotifications: "Clear Notifications" case .commandPalette: "Command Palette…" } @@ -37,6 +38,7 @@ enum ViewCommand: String, CaseIterable { case .openOldestNotification: "j" case .backToOverview: "[" case .openNextCard: "]" + case .showInWorkspaces: "o" case .clearNotifications: "u" case .commandPalette: "k" } @@ -44,7 +46,7 @@ enum ViewCommand: String, CaseIterable { var modifiers: EventModifiers { switch self { - case .newTab, .openOldestNotification, .commandPalette, .backToOverview, .openNextCard: .command + case .newTab, .openOldestNotification, .commandPalette, .backToOverview, .openNextCard, .showInWorkspaces: .command case .newWorkspace, .closeTab, .clearNotifications: [.command, .shift] case .closeWorkspace: [.command, .option, .shift] case .showWorkspaces, .showOverview, .showArrange: .command @@ -61,7 +63,7 @@ enum ViewCommand: String, CaseIterable { PaletteSurface.everywhere.subtracting(PaletteSurface.surfaces(of: viewTab ?? .workspaces)) case .newTab, .newWorkspace, .closeTab, .closeWorkspace, .rearrangeMode, .allWorkspaces: [.workspaces] case .openOldestNotification, .clearNotifications: PaletteSurface.everywhere - case .backToOverview, .openNextCard: [.overviewPane] + case .backToOverview, .openNextCard, .showInWorkspaces: [.overviewPane] case .commandPalette: [] } } @@ -99,6 +101,7 @@ enum ViewCommand: String, CaseIterable { case .openOldestNotification: "flock.view.openOldestNotification" case .backToOverview: "flock.view.backToOverview" case .openNextCard: "flock.view.openNextCard" + case .showInWorkspaces: "flock.pane.showInWorkspaces" case .clearNotifications: "flock.view.clearNotifications" case .commandPalette: "flock.view.commandPalette" } diff --git a/Sources/Flock/Palette/PaletteCatalog.swift b/Sources/Flock/Palette/PaletteCatalog.swift index 24d517ae..99adc2c8 100644 --- a/Sources/Flock/Palette/PaletteCatalog.swift +++ b/Sources/Flock/Palette/PaletteCatalog.swift @@ -144,7 +144,7 @@ enum PaletteCatalog { var commands: [(PaletteNamespace, ViewCommand)] = ViewTab.allCases.map { (.view, ViewCommand.show($0)) } commands += [(.view, .rearrangeMode), (.view, .allWorkspaces)] if context.hasNotifications { commands += [(.view, .openOldestNotification), (.view, .clearNotifications)] } - commands.append((.view, .backToOverview)) + commands += [(.view, .backToOverview), (.view, .showInWorkspaces)] if context.hasNextCard { commands.append((.view, .openNextCard)) } if !context.topBarOverlayUp { if context.hasSelectedWorkspace { commands += [(.tab, .newTab), (.tab, .closeTab)] } diff --git a/Sources/Flock/Palette/PaletteRunner.swift b/Sources/Flock/Palette/PaletteRunner.swift index 214d5389..7951908e 100644 --- a/Sources/Flock/Palette/PaletteRunner.swift +++ b/Sources/Flock/Palette/PaletteRunner.swift @@ -94,6 +94,8 @@ struct PaletteRunner { JumpNavigator(viewModel: viewModel, drag: dragCoordinator, mode: modeStore).backToOverview() case .openNextCard: JumpNavigator(viewModel: viewModel, drag: dragCoordinator, mode: modeStore).openNext() + case .showInWorkspaces: + JumpNavigator(viewModel: viewModel, drag: dragCoordinator, mode: modeStore).showInWorkspaces() case .clearNotifications: viewModel.clearAttentionToasts() case .commandPalette: break } diff --git a/Tests/FlockChromeRender/ChromeRenderTests.swift b/Tests/FlockChromeRender/ChromeRenderTests.swift index de41d588..67888697 100644 --- a/Tests/FlockChromeRender/ChromeRenderTests.swift +++ b/Tests/FlockChromeRender/ChromeRenderTests.swift @@ -2218,6 +2218,25 @@ final class ChromeRenderTests: XCTestCase { window.close() } + /// Show in Workspaces leaves Overview for the focused pane's own tab, + /// with that pane focused. + func testShowInWorkspacesOpensTheFocusedPanesTab() async throws { + let client = MethodRecordingClient() + let (harness, window) = try await focusedOverview(theme: .tokyoNight, client: client) + let navigator = JumpNavigator(viewModel: harness.viewModel, drag: harness.drag, mode: harness.modeStore) + navigator.open(pane: GridFixture.buildPane) + await settle(window) + XCTAssertTrue(navigator.canShowInWorkspaces) + navigator.showInWorkspaces() + await settle(window) + XCTAssertFalse(harness.drag.isGridShown) + XCTAssertNil(harness.drag.gridFocusedPane) + let methods = await client.methods + XCTAssertTrue(methods.contains("tab.focus") && methods.contains("pane.focus"), "herdr was not moved to the pane: \(methods)") + XCTAssertFalse(navigator.canShowInWorkspaces, "only the focused view has a pane to show") + window.close() + } + /// The jump key in the focused view swaps in the next oldest card, and /// with none left it leaves the view as it is. func testJInTheFocusedViewOpensTheNextCard() async throws { diff --git a/Tests/FlockChromeRender/PaletteShortcutTests.swift b/Tests/FlockChromeRender/PaletteShortcutTests.swift index 9b241b1a..44e23af3 100644 --- a/Tests/FlockChromeRender/PaletteShortcutTests.swift +++ b/Tests/FlockChromeRender/PaletteShortcutTests.swift @@ -21,6 +21,7 @@ final class PaletteShortcutTests: XCTestCase { XCTAssertFalse(ViewCommand.allCases.contains { $0.key == "j" && $0.modifiers == [.command, .shift] }, "Jump Back is retired") XCTAssertEqual(ShortcutLabel.text(key: ViewCommand.backToOverview.key, modifiers: ViewCommand.backToOverview.modifiers), "⌘[") XCTAssertEqual(ShortcutLabel.text(key: ViewCommand.openNextCard.key, modifiers: ViewCommand.openNextCard.modifiers), "⌘]") + XCTAssertEqual(ShortcutLabel.text(key: ViewCommand.showInWorkspaces.key, modifiers: ViewCommand.showInWorkspaces.modifiers), "⌘O") XCTAssertEqual(ViewCommand.clearNotifications.key, "u") XCTAssertEqual(ViewCommand.clearNotifications.modifiers, [.command, .shift]) } From 7c0be9b29855e7e492358c7a3cae1e5d4509d3aa Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sat, 17 Oct 2026 09:54:14 -0500 Subject: [PATCH 4/4] panes: a park that lands while the pane is on screen leaves it drawing Show in Workspaces hands a pane from Overview's focused view to the canvas, and the old host's park arrived after the new host's attach, leaving the pane occluded: keys reached it but nothing redrew. Co-Authored-By: Claude Opus 5.5 --- .../GhosttyControlSurfaceFactory.swift | 2 ++ .../Ghostty/GhosttyPaneAttaching.swift | 5 ++++ .../ViewModels/SessionViewModel.swift | 2 +- .../ArrangeRenderTests.swift | 1 + .../FlockChromeRender/ChromeRenderTests.swift | 1 + .../RtModalChromeRenderTests.swift | 1 + .../FlockChromeRender/TopBarRenderTests.swift | 1 + .../WorkspaceClickLatencyTests.swift | 1 + .../SessionViewModelTests.swift | 23 +++++++++++++++++++ 9 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Sources/Flock/Ghostty/GhosttyControlSurfaceFactory.swift b/Sources/Flock/Ghostty/GhosttyControlSurfaceFactory.swift index 0f11aed8..bbb3c15c 100644 --- a/Sources/Flock/Ghostty/GhosttyControlSurfaceFactory.swift +++ b/Sources/Flock/Ghostty/GhosttyControlSurfaceFactory.swift @@ -193,6 +193,8 @@ final class GhosttySessionSurfaceHandle: GhosttyPaneSurface, @unchecked Sendable session.setParked(false) } + var isOnScreen: Bool { session.view?.window != nil } + func releaseHerdrHold() { session.sendHold(.release) } diff --git a/Sources/FlockCore/Ghostty/GhosttyPaneAttaching.swift b/Sources/FlockCore/Ghostty/GhosttyPaneAttaching.swift index 10a20b27..941729d5 100644 --- a/Sources/FlockCore/Ghostty/GhosttyPaneAttaching.swift +++ b/Sources/FlockCore/Ghostty/GhosttyPaneAttaching.swift @@ -97,6 +97,11 @@ public protocol GhosttyPaneSurface: AnyObject, Sendable { /// parked is a harmless no-op. func unpark() + /// Whether the surface's view is in a window right now. A pane handed + /// from one host to another gets the old host's park after the new + /// host's attach, and only this tells that park apart from a real one. + var isOnScreen: Bool { get } + /// Drops flock's herdr control client for this pane, which drops the /// pane's `direct_attach_resize_lock` with it, so herdr sizes the pane for /// its own shell clients again. The surface, its PTY, its scrollback, the diff --git a/Sources/FlockCore/ViewModels/SessionViewModel.swift b/Sources/FlockCore/ViewModels/SessionViewModel.swift index 38a1ec18..1133be0c 100644 --- a/Sources/FlockCore/ViewModels/SessionViewModel.swift +++ b/Sources/FlockCore/ViewModels/SessionViewModel.swift @@ -1356,7 +1356,7 @@ public final class SessionViewModel { /// difference between this and `performTeardown`. Evicts the warm cap's /// overflow afterward, oldest park first. private func performPark(pane: PaneID) async { - guard let surface = ghosttySurfaces[pane] else { return } + guard let surface = ghosttySurfaces[pane], !surface.isOnScreen else { return } surface.park() paneScrollSubscriber?.unsubscribe(pane: pane) parkedPanes.removeAll { $0 == pane } diff --git a/Tests/FlockChromeRender/ArrangeRenderTests.swift b/Tests/FlockChromeRender/ArrangeRenderTests.swift index 2433ff67..50148d60 100644 --- a/Tests/FlockChromeRender/ArrangeRenderTests.swift +++ b/Tests/FlockChromeRender/ArrangeRenderTests.swift @@ -341,6 +341,7 @@ private final class ArrangeGroundSurface: GhosttyPaneSurface { func detach() async {} func park() {} func unpark() {} + var isOnScreen: Bool { false } func releaseHerdrHold() {} func takeHerdrHold() {} var cellHeight: CGFloat? { 18 } diff --git a/Tests/FlockChromeRender/ChromeRenderTests.swift b/Tests/FlockChromeRender/ChromeRenderTests.swift index 67888697..bd5f9366 100644 --- a/Tests/FlockChromeRender/ChromeRenderTests.swift +++ b/Tests/FlockChromeRender/ChromeRenderTests.swift @@ -4979,6 +4979,7 @@ private final class GroundSurface: GhosttyPaneSurface { func detach() async {} func park() {} func unpark() {} + var isOnScreen: Bool { false } func releaseHerdrHold() {} func takeHerdrHold() {} var cellHeight: CGFloat? { 18 } diff --git a/Tests/FlockChromeRender/RtModalChromeRenderTests.swift b/Tests/FlockChromeRender/RtModalChromeRenderTests.swift index 898e91bc..8f109770 100644 --- a/Tests/FlockChromeRender/RtModalChromeRenderTests.swift +++ b/Tests/FlockChromeRender/RtModalChromeRenderTests.swift @@ -926,6 +926,7 @@ private final class GroundSurface: GhosttyPaneSurface { func detach() async {} func park() {} func unpark() {} + var isOnScreen: Bool { false } func releaseHerdrHold() {} func takeHerdrHold() {} var cellHeight: CGFloat? { 18 } diff --git a/Tests/FlockChromeRender/TopBarRenderTests.swift b/Tests/FlockChromeRender/TopBarRenderTests.swift index cd93af15..cc59c8da 100644 --- a/Tests/FlockChromeRender/TopBarRenderTests.swift +++ b/Tests/FlockChromeRender/TopBarRenderTests.swift @@ -484,6 +484,7 @@ private final class OverlaySurface: GhosttyPaneSurface { func detach() async {} func park() {} func unpark() {} + var isOnScreen: Bool { false } func releaseHerdrHold() {} func takeHerdrHold() {} var cellHeight: CGFloat? { nil } diff --git a/Tests/FlockChromeRender/WorkspaceClickLatencyTests.swift b/Tests/FlockChromeRender/WorkspaceClickLatencyTests.swift index 4431ca8c..fda8746c 100644 --- a/Tests/FlockChromeRender/WorkspaceClickLatencyTests.swift +++ b/Tests/FlockChromeRender/WorkspaceClickLatencyTests.swift @@ -299,6 +299,7 @@ private final class GroundSurface: GhosttyPaneSurface { func detach() async {} func park() {} func unpark() {} + var isOnScreen: Bool { false } func releaseHerdrHold() {} func takeHerdrHold() {} var cellHeight: CGFloat? { 18 } diff --git a/Tests/FlockCoreTests/SessionViewModelTests.swift b/Tests/FlockCoreTests/SessionViewModelTests.swift index b1d5bdcd..f7db32b6 100644 --- a/Tests/FlockCoreTests/SessionViewModelTests.swift +++ b/Tests/FlockCoreTests/SessionViewModelTests.swift @@ -248,6 +248,7 @@ private final class FakeGhosttyPaneSurface: GhosttyPaneSurface, @unchecked Senda var hasFirstFrame = false var hasClaimedMouse = false var programHasMouse = false + var isOnScreen = false init(pane: PaneID) { self.pane = pane @@ -1856,6 +1857,28 @@ final class SessionViewModelTests: XCTestCase { XCTAssertNotNil(viewModel.ghosttySurface(for: pane), "a parked surface must still be reachable -- it is what makes a warm reattach possible") } + /// A pane handed from one host to another (Overview's focused view to the + /// Workspaces canvas) gets the old host's park after the new host's + /// attach. The surface is on screen by then, and a park would stop it + /// drawing while it still takes keys. + @MainActor + func testAParkThatLandsWhileThePaneIsOnScreenLeavesItDrawing() async throws { + let factory = FakeGhosttyPaneFactory() + let viewModel = SessionViewModel(client: RecordingCommandClient(), ghosttyFactory: factory) + let pane = PaneID(rawValue: "w1:p1") + _ = await viewModel.attachPane(pane) + let surface = try XCTUnwrap(factory.surfaces[pane]) + surface.isOnScreen = true + + await viewModel.detachPane(pane) + + XCTAssertEqual(surface.parkCallCount, 0) + let otherPanes = (2...13).map { PaneID(rawValue: "w1:p\($0)") } + for other in otherPanes { _ = await viewModel.attachPane(other) } + for other in otherPanes { await viewModel.detachPane(other) } + XCTAssertEqual(surface.detachCallCount, 0, "a pane on screen is never in the warm queue") + } + /// Reattaching a PARKED pane returns the SAME surface, with no second /// `makeSurface` call -- the whole point of the pool: a tab switch back /// re-hosts warm content instead of spawning a fresh bridge/PTY.