Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ extension AgentTabStore {
func rekey(_ key: String, to real: String) {
guard key != real else { return }
aliases[key] = real
OutboundTab.rekeyed(key, to: real) // a tab on its way out of its pane stays out under its new id
if remoteCreations[key] != nil || remoteSeeds[key] != nil { remoteAliases[key] = real }
if let view = views.removeValue(forKey: key) {
// A page made under the store's id meanwhile (another window) gives way to the one shown.
Expand All @@ -216,7 +215,8 @@ extension AgentTabStore {
adoptions = adoptions.mapValues { $0 == key ? real : $0 }
if let store = tabStores.removeValue(forKey: key) { tabStores[real] = store }
if checkpointFocusTab == key { checkpointFocusTab = real }
// The provisional alias goes once the tree settles: live callbacks then name the store tab (conversion, project reads).
// The provisional alias is removed once the tree settles. Live callbacks
// must then name the store tab directly, including conversion and project reads.
if let view = views[real] { wire(view.model, key: real) }
seenLive.remove(key)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,8 @@ enum AgentBesidePlacement {
let lone = controller.flatMap { ChatColumnPlacement.resolve(from: $0, services: services) == .dockChat ? $0 : nil }
let chat = lone?.pane.tabs.first { ChatColumnPlacement.isChat($0, services: services) }
let chatHadFocus = lone.map { $0.workspace?.focusedPane === $0 } ?? false
// The chat's strip never lists the tab on its way out: it first shows on the right (OutboundTab).
let outbound = controller.flatMap { $0.pane === anchor ? OutboundTab.begin(in: $0) : nil }
if let chat { outbound?.hidden(whileHolding: chat.id) }
let (surfaces, sink) = AsyncStream<SurfaceID>.makeStream(bufferingPolicy: .bufferingNewest(1))
services.registry.track(Task { @MainActor in
var moved = false
defer { outbound?.finish(moved: moved) }
// The tab's surface, then the store listing it; a bounded wait, so a tab
// that never opens cannot hold the run.
let listed = Task { @MainActor () -> SurfaceID? in
Expand Down Expand Up @@ -100,7 +95,6 @@ enum AgentBesidePlacement {
content.layoutModel.cancelContentHandoff()
return "beside-caller: the chat did not dock (see the app log)"
}
moved = true
// Keyboard focus stays in the chat, now in its dock; a person's click focuses the tab.
// Not when the person acted since the run began: their focus stays where they put it.
if (chatHadFocus || content.focusedPane?.pane === anchor) && !personActed() {
Expand Down Expand Up @@ -140,13 +134,12 @@ enum AgentBesidePlacement {
// the dock (`pinned-dock-alone-v1`), so there is nothing to move.
let away = services.paneController(for: anchor)?.paneKey
if services.locateTab(tab.id).map({ $0.1 !== anchor }) == true {
moved = true
if let clicked { focus(surface, returningTo: from, awayFrom: away, in: clicked, generation: intent, services: services) }
await revealMoved(tab.id, from: anchor, services: services)
return nil
}
let after = newColumn(beside: anchor, in: services.daemon(for: anchor).store)
moved = await withCheckedContinuation { (done: CheckedContinuation<Bool, Never>) in
let moved = await withCheckedContinuation { (done: CheckedContinuation<Bool, Never>) in
TabMoves.toNewColumn(tab, anchor: anchor, afterColumn: after, services: services) { done.resume(returning: $0) }
}
guard moved else { return "beside-caller: the move into a new column failed (see the app log)" }
Expand All @@ -155,7 +148,6 @@ enum AgentBesidePlacement {
return nil
})
return { surface in
outbound?.made(surface)
then?(surface)
sink.yield(surface)
sink.finish()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ extension MainPaneLayout {
/// else no focus moves to it, and focus follows the chat into its dock when the chat had it
/// (an agent's open stays in the background). A failed dock selects the tab where it is, so it
/// never stays hidden. `then` hears whether the chat docked, once the store lists it in its
/// dock, so a later open sees the column on its right. `outbound`: the pane's strip leaves the tab
/// out until the chat has left, so it first shows in the pane on the right (``OutboundTab``).
/// dock, so a later open sees the column on its right.
@MainActor static func dockChat(of controller: PaneController, keeping surface: SurfaceID, focus: Bool, services: AppServices,
outbound: OutboundTab? = nil, then: (@MainActor (Bool) -> Void)? = nil) {
then: (@MainActor (Bool) -> Void)? = nil) {
let store = controller.daemon.store, anchor = controller.pane
let chatHadFocus = controller.workspace?.focusedPane === controller
let reveal: @MainActor (PaneController, TabModel) -> Void = { pane, tab in
Expand All @@ -55,41 +54,34 @@ extension MainPaneLayout {
let found = await listed.value
bound.cancel()
guard let tab = found, let controller else {
outbound?.finish(moved: false)
then?(false)
return "main pane: the new tab did not open"
}
guard let chat = anchor.tabs.first(where: { $0.id != tab.id && ChatColumnPlacement.isChat($0, services: services) }) else {
outbound?.finish(moved: false)
reveal(controller, tab)
then?(false)
return nil
}
outbound?.hidden(whileHolding: chat.id)
// The chat stays where it shows; only the tab's pane comes in, from the right (as
// AgentBesidePlacement docks a lone chat).
let layout = controller.workspace?.layoutModel
layout?.handOffContent(of: controller.layoutPaneID)
let docked = await withCheckedContinuation { (done: CheckedContinuation<Bool, Never>) in
TabMoves.toNewDockColumn(chat, anchor: anchor, edge: .left, role: .agentChat, services: services) { done.resume(returning: $0) }
}
reveal(controller, tab)
guard docked else {
layout?.cancelContentHandoff()
outbound?.finish(moved: false)
reveal(controller, tab)
if focus { controller.focusContent() }
then?(false)
return "main pane: the chat did not dock (see the app log)"
}
// Selected once the store has the chat in its dock: until then the strip leaves the tab out.
let dock = await movedPane(of: chat.id, from: anchor, services: services)
outbound?.finish(moved: dock != nil)
reveal(controller, tab)
// A hidden tab bar was the chat's choice: the pane it left shows its tab bar again.
if services.paneTabBars.choice(for: controller.paneKey) == false {
services.paneTabBars.set(nil, for: controller.paneKey)
controller.apply(controller.snapshot())
}
let dock = await movedPane(of: chat.id, from: anchor, services: services)
if let content = controller.workspace {
if focus {
PaneHandlers.focus(controller.layoutPaneID, in: content)
Expand All @@ -109,44 +101,36 @@ extension MainPaneLayout {
/// Opens an agent tab (a subagent's or a side chat's pane) that chat `controller` opens beside
/// itself, where main pane layout's `side` puts it: through `open` as a tab in the strip pane
/// right of the chat dock, or in the lone chat's pane, which then docks the chat left
/// (``dockChat(of:keeping:focus:services:outbound:then:)``). `created` hears the new tab's key once the
/// (``dockChat(of:keeping:focus:services:)``). `created` hears the new tab's key once the
/// tree lists it, `finished` whether it landed beside the chat. With `focus` the tab takes
/// keyboard focus; else it is only selected there. False when nothing opened.
@MainActor static func openAgentTab(from controller: PaneController, side: ChatColumnPlacement, focus: Bool, services: AppServices,
open: (DaemonPaneID, DaemonService) throws -> AgentTabPending,
created: (@MainActor (String) -> Void)?, finished: (@MainActor (Bool) -> Void)?) -> Bool {
let target: PaneController, outbound: OutboundTab?
let target: PaneController
switch side {
case .tab(let id):
guard let strip = controller.workspace?.panes[id] else { return false }
(target, outbound) = (strip, nil)
target = strip
case .dockChat:
// Made in the chat's pane, it stays out of the chat's strip until the chat has docked.
(target, outbound) = (controller, OutboundTab.begin(in: controller))
target = controller
case .here, .besideDock:
return false
}
guard let pending = try? open(target.pane.handle, target.daemon) else {
outbound?.finish(moved: false)
return false
}
// The stand-in tab shows at once: by its id (and the store's tab that replaces it), not as any new tab.
outbound?.made(key: pending.key)
guard let pending = try? open(target.pane.handle, target.daemon) else { return false }
let locate: TabMirror.Locate = { key in
services.locateTab(key).map { (pane: $0.1.handle, daemon: services.machines.daemon(forTab: $0.0)) }
}
// task-owner: one tab creation, then one selection (or the chat's dock) for it
Task { @MainActor [weak target] in
guard let tab = try? await pending.value(), await TabMirror.reaches(tab.key, in: locate), let target else {
outbound?.finish(moved: false)
finished?(false)
return
}
created?(tab.key)
if target === controller {
outbound?.made(tab.surface)
// `finished` once the chat is in its dock: a queued open then sees the column on its right.
dockChat(of: target, keeping: tab.surface, focus: focus, services: services, outbound: outbound) { _ in finished?(true) }
dockChat(of: target, keeping: tab.surface, focus: focus, services: services) { _ in finished?(true) }
return
}
if focus, let model = services.locateTab(tab.key)?.0 {
Expand Down
55 changes: 22 additions & 33 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CmuxNextWakeups
/// A new tab for the pane on the right opens there, selected and focused. A new split or column is
/// made by opening the tab unselected in the source pane and moving it there in one daemon move
/// (`TabMoves`), the way an agent's tab reaches the column beside its chat (``AgentBesidePlacement``):
/// the source keeps showing what it showed, and its strip never lists the tab (``OutboundTab``).
/// the source keeps showing what it showed.
@MainActor
final class LinkOpener {
/// Opens the item in `pane`. `placed` is nil for a tab that shows in `pane` now (selected and
Expand Down Expand Up @@ -103,27 +103,23 @@ final class LinkOpener {
case .pane(let id):
return make(id == source.layoutPaneID ? source : source.workspace?.panes[id] ?? source, nil)
case .splitRight:
let outbound = OutboundTab.begin(in: source)
return outbound.started(make(source) { [weak self, weak source] surface in
guard let self, let source else { return outbound.finish(moved: false) }
self.move(surface, from: source, to: .split(.right), focus: !background, outbound: outbound)
})
return make(source) { [weak self, weak source] surface in
guard let self, let source else { return }
self.move(surface, from: source, to: .split(.right), focus: !background)
}
case .newColumn:
let outbound = OutboundTab.begin(in: source)
return outbound.started(make(source) { [weak self, weak source] surface in
guard let self, let source else { return outbound.finish(moved: false) }
self.move(surface, from: source, to: .besideColumn, focus: !background, outbound: outbound)
})
return make(source) { [weak self, weak source] surface in
guard let self, let source else { return }
self.move(surface, from: source, to: .besideColumn, focus: !background)
}
case .besideDock:
// The daemon makes a tab aimed at a lone pinned dock in a new column right of it.
return make(source, nil)
case .dockChat:
let outbound = OutboundTab.begin(in: source)
return outbound.started(make(source) { [weak source] surface in
guard let source else { return outbound.finish(moved: false) }
outbound.made(surface)
MainPaneLayout.dockChat(of: source, keeping: surface, focus: !background, services: source.services, outbound: outbound)
})
return make(source) { [weak source] surface in
guard let source else { return }
MainPaneLayout.dockChat(of: source, keeping: surface, focus: !background, services: source.services)
}
}
}

Expand Down Expand Up @@ -204,7 +200,7 @@ final class LinkOpener {
}
}

/// Where ``move(_:from:to:focus:outbound:)`` takes a new tab.
/// Where ``move(_:from:to:focus:)`` takes a new tab.
enum Destination {
/// A new split of the tab's pane on this side.
case split(PaneEdge)
Expand Down Expand Up @@ -244,19 +240,16 @@ final class LinkOpener {
}
}
guard let destination else { return make(pane, nil) }
let outbound = OutboundTab.begin(in: pane)
return outbound.started(make(pane) { [weak self, weak pane] surface in
guard let self, let pane else { return outbound.finish(moved: false) }
self.move(surface, from: pane, to: destination, focus: true, outbound: outbound)
})
return make(pane) { [weak self, weak pane] surface in
guard let self, let pane else { return }
self.move(surface, from: pane, to: destination, focus: true)
}
}

/// Moves the new tab on `surface` out of `source` to `destination` (a new split, column or dock)
/// once the store lists it, then focuses it unless not `focus`. Until then `source`'s strip leaves it
/// out (`outbound`). A failed move selects it where it is, so it never stays hidden.
private func move(_ surface: SurfaceID, from source: PaneController, to destination: Destination, focus: Bool,
outbound: OutboundTab) {
outbound.made(surface)
/// once the store lists it, then focuses it unless not `focus`. A failed move selects it where
/// it is, so it never stays hidden.
private func move(_ surface: SurfaceID, from source: PaneController, to destination: Destination, focus: Bool) {
let services = source.services, store = source.daemon.store
let intent = focus ? source.workspace?.beginFocusIntent() : nil
services.registry.track(Task { @MainActor [weak source] () -> ActionWorkFailure? in
Expand All @@ -272,10 +265,7 @@ final class LinkOpener {
bound.schedule(after: Self.listLimit) { @MainActor in listed.cancel() }
let found = await listed.value
bound.cancel()
guard let tab = found, let source, let anchor = store.pane(holding: tab) else {
outbound.finish(moved: false)
return "link open: the new tab did not open"
}
guard let tab = found, let source, let anchor = store.pane(holding: tab) else { return "link open: the new tab did not open" }
let moved = await withCheckedContinuation { (done: CheckedContinuation<Bool, Never>) in
switch destination {
case .besideColumn:
Expand All @@ -290,7 +280,6 @@ final class LinkOpener {
}
}
let column = if case .besideColumn = destination { true } else { false }
outbound.finish(moved: moved)
if moved {
if let intent { source.workspace?.expectFocus(on: surface, awayFrom: source.paneKey, generation: intent) }
if column { await AgentBesidePlacement.revealMoved(tab.id, from: anchor, services: services) }
Expand Down
Loading