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 @@ -5,18 +5,18 @@ import Foundation
extension AgentPageProvider {
/// `chat.scrollAnchor` (`sessionId`, `rowId` or null, `offset`) and `chat.scrollAnchor.get`
/// (`sessionId`): a chat's kept place in ``AgentPaneScrollAnchors/shared`` (cx-6bgzj). It is
/// the chat's, not the pane's, so the model never sees these.
static func scrollAnchor(_ method: String, params: JSONValue, op: String) throws -> JSONValue {
/// kept per tab (`tab`, the pane's ``AgentPaneModel/tabKey``), so the model never sees these.
static func scrollAnchor(_ method: String, params: JSONValue, op: String, tab: String? = nil) throws -> JSONValue {
let body = params.foundationObject as? [String: Any]
guard let session = body?["sessionId"] as? String, !session.isEmpty else { throw PageError.invalidParams(op) }
guard let anchors = AgentPaneScrollAnchors.shared else { return .null }
if method == "chat.scrollAnchor.get" {
guard let anchor = anchors.anchor(for: session) else { return .null }
guard let anchor = anchors.anchor(for: session, tab: tab) else { return .null }
return .object(["rowId": .string(anchor.rowId), "offset": .number(anchor.offset)])
}
let row = body?["rowId"] as? String
let offset = (body?["offset"] as? NSNumber)?.doubleValue ?? 0
anchors.remember(row.map { AgentPaneScrollAnchor(rowId: $0, offset: offset) }, for: session)
anchors.remember(row.map { AgentPaneScrollAnchor(rowId: $0, offset: offset) }, for: session, tab: tab)
return .null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ public final class AgentPageProvider: PageProvider {
public var pendingAsks = AgentPanePendingAsks()
/// The pane host, available before its handshake or transport socket.
public var tailHost: (any AgentPaneHostProviding)?
/// The pane's tab (``AgentPaneModel/tabKey``), which a chat's place is kept by. ``AgentPaneView`` sets it.
public var tabKey: @MainActor () -> String? = { nil }
/// The debug UI pointer report from an app-owned page. The native picker
/// owns the crop and composer delivery; the page only supplies metadata.
public var onUiPointerReport: @MainActor (JSONValue) -> Void = { _ in }
Expand Down Expand Up @@ -369,7 +371,7 @@ public final class AgentPageProvider: PageProvider {
}
// Named, not a prefix: `scrollAnchor` treats anything but `.get` as a write, so a later
// `chat.scrollAnchor.forget` would have erased a chat's place instead of being refused.
if AgentPaneScrollAnchors.handles(method: method) { return try Self.scrollAnchor(method, params: params, op: op) }
if AgentPaneScrollAnchors.handles(method: method) { return try Self.scrollAnchor(method, params: params, op: op, tab: tabKey()) }
if AgentPaneTailCache.handles(method: method) {
return try await Self.tail(method, params: params, op: op, host: tailHost, pageGeneration: tailGeneration)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply {
// Where a chat rests belongs to the chat, so it is answered here from the one store both
// hosts share and never reaches the model (AgentPaneScrollAnchors+Bridge). Not logged: it
// names a row of the person's transcript.
if let reply = Self.chatPlaceReply(for: message.body) {
if let reply = Self.chatPlaceReply(for: message.body, tab: view?.model.tabKey()) {
return replyHandler(reply, nil)
}
// What a page last read of a chat, for the next tab that opens it (``AgentPaneTailCache``):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public nonisolated struct AgentPaneHandshake: Codable, Sendable, Equatable {
private enum CodingKeys: String, CodingKey {
case protocolVersion, transport, sessionId, newSession, newTab, cwd, draft, prompt, promptAttachments, promptTicket, harness, model, peer, adopt, surface,
linkScheme, sessionMustExist, revealTurn, chooseFolder, startKind, folderNeeded, pastTurns, machineName, localMachineName, githubRepository, harnesses, wake,
workspaceContext, sandboxState, cloudMachineID, cloudAccount, cloudCredentialRecoveryAvailable, cloudTeamMachine, cloudHandoffs, cloudRepositoryAvailable
workspaceContext, sandboxState, tab, cloudMachineID, cloudAccount, cloudCredentialRecoveryAvailable, cloudTeamMachine, cloudHandoffs, cloudRepositoryAvailable
}

public var protocolVersion: Int
Expand Down Expand Up @@ -140,6 +140,10 @@ public nonisolated struct AgentPaneHandshake: Codable, Sendable, Equatable {
/// This app answers `sandbox.state` (``AgentPaneSandboxRuns``, hq#3367): the page reads the tab's
/// Sandboxed send only when it is set. Pages that predate it ignore it.
public var sandboxState: Bool? = true
/// The tab the page is (``AgentPaneModel/tabKey``): the page keeps its Session Info by it, so two
/// tabs of one chat keep their own. A page a tab adopts later hears it then (`acpmux-pane-tab`).
/// Pages that predate it ignore it and keep it by chat.
public var tab: String?

public init(transport: Transport, connection: AcpmuxConnection? = nil, sessionId: String? = nil, newSession: Bool? = nil) {
protocolVersion = Self.currentVersion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ enum AgentPaneHandshakeReply {
}
if model.sessionId == nil, let folderNeeded = model.seed?.folderNeeded { handshake.folderNeeded = AgentPaneHandshake.FolderNeeded(reason: folderNeeded.reason) }
handshake.linkScheme = model.linkScheme
handshake.tab = model.tabKey()
// A chat on another machine names that machine (its host set it), never this Mac.
let localName = await AgentPaneModel.localMachineName?.value
if handshake.machineName == nil { handshake.machineName = localName }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public final class AgentPaneModel {
/// opening an existing chat only while this holds (`NewTabSparePool.takeConnectedSpare`).
@ObservationIgnored public internal(set) var clientReady = false

/// The tab this pane shows (the store's id), asked each time: what a pane keeps per view, not per
/// chat, is kept by it (its transcript's place, ``AgentPaneScrollAnchors``; its Session Info, the
/// page's `sessionInfoState.ts`), since one chat can be open in two tabs at once. Nil for a page
/// that is no tab's (a prewarmed spare until a tab adopts it, the quick panel): those keep by chat.
@ObservationIgnored public var tabKey: @MainActor () -> String? = { nil } { didSet { onTabKeyChange?() } }
/// The view tells a page that already had its handshake (a spare a tab adopted) its tab.
@ObservationIgnored var onTabKeyChange: (() -> Void)?
/// Called when the page switches to or creates a session, so the App can keep it with the tab.
@ObservationIgnored public var onSessionChange: ((String) -> Void)?
@ObservationIgnored var onQuestionKeySessionChange: (() -> Void)?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ extension AgentPaneBridge {
/// the same `message.body`. The handler is then one call, which
/// `ChatScrollRestoreDefaultPathTests.theHandlerStillCallsThatSeam` holds in the source,
/// because no test can send a message through the handler itself.
static func chatPlaceReply(for body: Any) -> [String: Any]? {
/// `tab` is the pane's tab (``AgentPaneModel/tabKey``): the host names it, never the page.
static func chatPlaceReply(for body: Any, tab: String? = nil) -> [String: Any]? {
guard let object = body as? [String: Any], let method = object["method"] as? String,
AgentPaneScrollAnchors.handles(method: method) else { return nil }
return AgentPaneScrollAnchors.reply(to: method, params: object["params"] as? [String: Any])
return AgentPaneScrollAnchors.reply(to: method, params: object["params"] as? [String: Any], tab: tab)
}
}

Expand All @@ -34,7 +35,7 @@ extension AgentPaneScrollAnchors {
/// A chat's place is the chat's, not the pane's, so ``AgentPaneModel`` never sees these. The
/// handler answers them before it decodes an `AgentPaneRequest` at all, the way it already
/// answers the `popup.` ops above this branch.
static func reply(to method: String, params: [String: Any]?) -> [String: Any] {
static func reply(to method: String, params: [String: Any]?, tab: String? = nil) -> [String: Any] {
// An op this seam does not serve, before anything else, so that a refusal never depends on
// whether a store happens to exist — as it would if this came after the `shared` guard two
// below. The one caller in the app, `chatPlaceReply`, already filters by `handles`, so
Expand All @@ -49,13 +50,13 @@ extension AgentPaneScrollAnchors {
// No store (a test host, the gallery): nothing is kept, and nothing is answered.
guard let anchors = shared else { return AgentPaneReply.success() }
if method == "chat.scrollAnchor.get" {
guard let anchor = anchors.anchor(for: session) else { return AgentPaneReply.success() }
guard let anchor = anchors.anchor(for: session, tab: tab) else { return AgentPaneReply.success() }
return AgentPaneReply.success(["rowId": anchor.rowId, "offset": anchor.offset])
}
// At the latest message the page sends `rowId: null`, which forgets the place.
let row = params?["rowId"] as? String
let offset = (params?["offset"] as? NSNumber)?.doubleValue ?? 0
anchors.remember(row.map { AgentPaneScrollAnchor(rowId: $0, offset: offset) }, for: session)
anchors.remember(row.map { AgentPaneScrollAnchor(rowId: $0, offset: offset) }, for: session, tab: tab)
return AgentPaneReply.success()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ public nonisolated struct AgentPaneScrollAnchor: Codable, Sendable, Equatable {
}
}

/// Where each agent chat rested, by session, in a JSON file beside the launch snapshot, so a
/// Where each agent chat rested, by session and tab, in a JSON file beside the launch snapshot, so a
/// relaunch or a crash reopens a chat where the reader left it instead of at its latest message.
/// The page reads its chat's place before it attaches (`chat.scrollAnchor.get`) and writes it once
/// a scroll settles (`chat.scrollAnchor`); the most recent ``limit`` chats are kept.
/// a scroll settles (`chat.scrollAnchor`); the most recent ``limit`` places are kept.
///
/// One chat can be open in two tabs at once (Leo, 2026-11-03: "right split is fine on click by
/// default (extra view doesn't really affect anything)"), so a place is kept per tab: a tab's reload
/// reopens where that tab rested, never where the other one did. A tab with no place of its own (a
/// tab opened on the chat later, or one saved before places were per tab) takes the chat's most
/// recent place, whichever tab left it.
@MainActor
public final class AgentPaneScrollAnchors {
public static let fileName = "agent-scroll-anchors.json"
Expand All @@ -25,7 +31,12 @@ public final class AgentPaneScrollAnchors {

private nonisolated struct Entry: Codable, Equatable, Sendable {
var sessionId: String
var anchor: AgentPaneScrollAnchor
/// The tab that rested here; nil for a place saved before places were per tab, or by a
/// page that is no tab's (it stays the chat's place for a tab with none of its own).
var tab: String?
/// Nil: the tab rested at the chat's latest message. Kept, so that tab's reload does not
/// take the other tab's place instead.
var anchor: AgentPaneScrollAnchor?
}

let file: URL?
Expand All @@ -40,14 +51,19 @@ public final class AgentPaneScrollAnchors {
store = file.map(files.at)
}

public func anchor(for sessionId: String) -> AgentPaneScrollAnchor? {
loaded().last { $0.sessionId == sessionId }?.anchor
/// Where chat `sessionId` rests in tab `tab`: that tab's own place, else the chat's most recent
/// one in any tab (an entry from before places were per tab included).
public func anchor(for sessionId: String, tab: String? = nil) -> AgentPaneScrollAnchor? {
let entries = loaded()
if let tab, let own = entries.last(where: { $0.sessionId == sessionId && $0.tab == tab }) { return own.anchor }
return entries.last { $0.sessionId == sessionId }?.anchor
}

/// Keeps chat `sessionId`'s place; nil (at the latest message) forgets it.
public func remember(_ anchor: AgentPaneScrollAnchor?, for sessionId: String) {
var kept = loaded().filter { $0.sessionId != sessionId }
if let anchor { kept.append(Entry(sessionId: sessionId, anchor: anchor)) }
/// Keeps chat `sessionId`'s place in tab `tab`; nil is its latest message. No tab (a page that
/// is no tab's): nil forgets the chat's tabless place.
public func remember(_ anchor: AgentPaneScrollAnchor?, for sessionId: String, tab: String? = nil) {
var kept = loaded().filter { $0.sessionId != sessionId || $0.tab != tab }
if tab != nil || anchor != nil { kept.append(Entry(sessionId: sessionId, tab: tab, anchor: anchor)) }
kept = Array(kept.suffix(Self.limit))
guard kept != entries else { return }
entries = kept
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ public nonisolated struct AgentPaneSessionInfoModel: Codable, Equatable, Sendabl
/// Over the chat's column, how far above the pane's foot it stops (the composer's stack), CSS
/// pixels; nil runs it to the foot less the float margin.
public var foot: Double?
/// It lies over the chat's column, a short pane's popover too (which sends no ``foot``): it gets
/// the overlay glass under it, so the transcript never shows through it. Pages that predate it
/// send only ``foot``.
public var overChat: Bool?
/// The panel's accessible name ("Session Info", in the page's language).
public var label: String
/// A subagent's own panel names the subagent at its top.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import AppKit
import CmuxNextDesign

Expand All @@ -7,7 +7,7 @@
/// as its content, up to the pane's foot (then its list scrolls), so no empty panel hangs under its
/// last section (Leo, 2026-11-02: "IT IS TAKING THIS STUPID FUCKING SPACE FOR NO REASON"). It floats:
/// it never takes a column and never moves the chat's layout; the page supplies the current push or
/// overlay geometry. Over the chat's column (the page sends a `foot`) the transcript runs under it,
/// overlay geometry. Over the chat's column (the page sends `overChat`, or a `foot`) the transcript runs under it,
/// so there, and only there, it lies on the composer's glass (``backing``, the shared overlay glass,
/// solid under Reduce Transparency) so its text never reads through the chat's, as the web panel's
/// `.ui-glass` does over the chat. No close button and no band at its top: its header toggle and Esc
Expand Down Expand Up @@ -54,9 +54,17 @@
/// The content's height at `width` points: the panel ends after its last section.
func fittingHeight(forWidth width: CGFloat) -> CGFloat { content.fittingHeight(forWidth: width) }

/// Over the chat (`foot` set) the content lies on the overlay glass; over the gutter, on nothing.
/// Whether `info` lies over the chat's column: it says so (`overChat`), or it stops above the
/// composer (`foot`, all a page that predates `overChat` sends). A short pane's popover lies over
/// the transcript with no `foot`; with only `foot` read it had no glass, and the transcript and
/// its sections showed through each other.
static func liesOverChat(_ info: AgentPaneSessionInfoModel?) -> Bool {
info?.overChat == true || info?.foot != nil
}

/// Over the chat the content lies on the overlay glass; over the gutter, on nothing.
private func updateBacking() {
let overChat = store.info?.foot != nil
let overChat = Self.liesOverChat(store.info)
if overChat, backing == nil {
let glass = Glass.makeOverlayPanel()
glass.translatesAutoresizingMaskIntoConstraints = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ extension AgentPaneView {
return true
}

/// Tells the page the tab it now is (``AgentPaneModel/tabKey``, on each change), for a page a tab
/// adopted after its handshake (a prewarmed spare): it keeps its Session Info by that tab from here
/// (`acpmux-pane-tab`, sessionInfoState.ts). A page with no handshake yet gets it with its handshake.
func announceTab() {
guard model.hasHandshake, let tab = model.tabKey(),
let data = try? JSONSerialization.data(withJSONObject: ["tab": tab], options: [.withoutEscapingSlashes]),
let json = String(data: data, encoding: .utf8) else { return }
evaluateScript("window.dispatchEvent(new CustomEvent('acpmux-pane-tab', {detail: \(json)}))")
}

/// Lays the page outside the window now, before the pane shows this view, and back in place in
/// the turn it reports the chat drawn (or the loading state's give-up marks it painted).
private func holdPageAway(moved: (@MainActor () -> Void)?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ public final class AgentPaneView: NSView {
// Needs you reads the pane's own acpmux (a Chief chat's is the Chief home's), never another.
provider.pendingAsksSocket = { [weak model] in model?.pendingAsksSocket }
provider.tailHost = model.host
provider.tabKey = { [weak model] in model?.tabKey() }
guard let page = AgentPanePageHost.makePage(root: index.deletingLastPathComponent(), testRoot: testRoot, provider: provider, renderRate: renderRate,
chromium: chromium, panelProvider: panelProvider)
else { return nil }
Expand Down Expand Up @@ -298,6 +299,7 @@ public final class AgentPaneView: NSView {
return await AgentPaneFilePicker(openPanel: openPanel).pickAttachments(window: window)
}
model.onFramePacing = { [weak self] _ in self?.framePacingSettings() ?? [:] }
model.onTabKeyChange = { [weak self] in self?.announceTab() }
model.onRenderRate = { [weak self] full in
guard let self, self.renderRate == .adaptive else { return }
self.rendersAtFullRate = full
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ public final class AgentUnsentDrafts {
}

/// Keeps the typed text of `model`'s chat for the tab `key` names when it runs, until the chat
/// has a session. Call it after the tab's own `onSessionChange` is set.
/// has a session. Call it after the tab's own `onSessionChange` is set. It also hands the model
/// its tab (``AgentPaneModel/tabKey``), which what a pane keeps per view is kept by: every tab's
/// model is wired here, a spare's when a tab adopts it.
public func wire(_ model: AgentPaneModel, key: @escaping () -> String) {
model.tabKey = { key() }
model.onUnsentDraft = { [weak self] text in self?.typed(text, for: key()) }
model.onNewChatLeftover = { [weak self] text in
guard let self, let window = windowOfTab(key()) else { return }
Expand Down
Loading
Loading