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 @@ -59,8 +59,6 @@ public nonisolated struct ActionContext: OptionSet, Sendable, Hashable {
/// A markdown page (with ``markdownFocused``) edits its file: bare keys
/// are typing there.
public static let markdownEditorFocused = ActionContext(rawValue: 1 << 22)
/// Debug Settings' Side sessions (/btw) is on; Ask a Side Question… is offered only then.
public static let sideSessions = ActionContext(rawValue: 1 << 23)

/// Contexts whose page owns bare keys (no Command, Control or Option):
/// only while one holds, and no text field has the keyboard, may a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/// Ask a Side Question… (`/btw`): the focused chat's composer starts a side question, which opens
/// as a side session in the chat's one side pane. No default key (default hotkeys need a call).
/// Offered only while Debug Settings' Side sessions (/btw) is on (``ActionContext/sideSessions``).
nonisolated enum AgentSideQuestionActionCatalog: ActionCatalogGroup {
static func descriptors() -> [ActionDescriptor] {
[
Expand All @@ -9,7 +8,7 @@ nonisolated enum AgentSideQuestionActionCatalog: ActionCatalogGroup {
title: String(localized: "action.agentPane.sideQuestion", defaultValue: "Ask a Side Question…", bundle: .module),
keywords: ["agent", "chat", "btw", "side", "question", "aside", "ask", "session"],
category: .agents, symbol: "questionmark.bubble", surfaces: [.palette],
requires: [.agentPaneFocused, .sideSessions], targets: [.pane],
requires: [.agentPaneFocused], targets: [.pane],
surfacePlan: ActionSurfacePlan(cli: .exempt(.guiOnly), contextMenuExemption: .guiOnly)
),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ extension ActionContext {
(.omnibarFocused, "omnibarFocused"), (.codeEditorFocused, "codeEditorFocused"),
(.homeChannelsEnabled, "homeChannelsEnabled"), (.webViewFocused, "webViewFocused"),
(.markdownReadOnlyFocused, "markdownReadOnlyFocused"), (.markdownEditorFocused, "markdownEditorFocused"),
(.sideSessions, "sideSessions"),
]

/// The bits a window's focus decides; the rest are app-wide facts.
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ extension ActionRegistry {

/// A header state with every value given (no Debug Settings read), only to ask which commands
/// are toggles the App can read (``AgentPageCommand/isOn(in:)`` not nil).
private static let blankHeaderState = AgentPaneHeaderState(sideSessions: false, sessionInfo: false, browserOpensPreview: false)
private static let blankHeaderState = AgentPaneHeaderState(sessionInfo: false, browserOpensPreview: false)

/// Binds Session Info for the invocation's pane, focused when there is no target. With
/// `agent.sessionInfo` off the page has no Session Info, so the action refuses instead of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,31 @@ public struct AgentPaneHeaderState: Equatable, Sendable {
public var pullRequest: AgentPanePullRequest?
/// The "..." menu offers Move to Floating Chat (Start Agent's floating chat is on, cx-b9qi).
public var floatingChat: Bool
/// Debug Settings' Side sessions (/btw) is on (``AgentPaneTunables/sideSessions``): the composer
/// offers `/btw`. The init reads the toggle unless given, so a watch that builds the state observes it.
public var sideSessions: Bool
/// The header offers Session Info (Debug Settings `agent.sessionInfo`, cx-ncc.101), read like
/// `sideSessions`. The page reads it as `inspector` (``json``), a wire name kept so a page and an
/// The header offers Session Info (Debug Settings `agent.sessionInfo`, cx-ncc.101). The init
/// reads the toggle unless given, so a watch that builds the state observes it. The page reads it as `inspector` (``json``), a wire name kept so a page and an
/// app from either side of the rename still agree.
public var sessionInfo: Bool
/// The header's Browser opens the chat's page and Preview is no button of its own (Debug Settings
/// `agentChat.browserOpensPreview`, on by default), read like `sideSessions`.
/// `agentChat.browserOpensPreview`, on by default), read like `sessionInfo`.
public var browserOpensPreview: Bool
/// Rises with every change the App sees: a `pane.tabState` reply that reaches the page after a
/// newer push (two channels) is older, and the page keeps the newer state.
public var version = 0

public init(pinned: Bool = false, sideTabs: Bool = false, lit: [String] = [], floatingChat: Bool = false,
sideSessions: Bool? = nil, sessionInfo: Bool? = nil, browserOpensPreview: Bool? = nil) {
sessionInfo: Bool? = nil, browserOpensPreview: Bool? = nil) {
self.pinned = pinned
self.sideTabs = sideTabs
self.lit = lit
self.floatingChat = floatingChat
self.sideSessions = sideSessions ?? AgentPaneTunables.sideSessions.value
self.sessionInfo = sessionInfo ?? AgentPaneTunables.sessionInfo.value
self.browserOpensPreview = browserOpensPreview ?? AgentPaneTunables.browserOpensPreview.value
}

/// Whether the header shows the same thing, whatever the versions.
public func shows(_ other: AgentPaneHeaderState) -> Bool {
pinned == other.pinned && sideTabs == other.sideTabs && lit == other.lit && floatingChat == other.floatingChat
&& pullRequest == other.pullRequest && sideSessions == other.sideSessions && sessionInfo == other.sessionInfo
&& pullRequest == other.pullRequest && sessionInfo == other.sessionInfo
&& browserOpensPreview == other.browserOpensPreview
}

Expand All @@ -50,7 +46,7 @@ public struct AgentPaneHeaderState: Equatable, Sendable {
/// `pullRequest` is always there, `null` when there is none, so the page drops a chip it kept.
var json: JSONValue {
["pinned": .bool(pinned), "sideTabs": .bool(sideTabs), "lit": .array(lit.map(JSONValue.string)), "floatingChat": .bool(floatingChat),
"sideSessions": .bool(sideSessions), "inspector": .bool(sessionInfo), "browserOpensPreview": .bool(browserOpensPreview), "version": JSONValue(version),
"inspector": .bool(sessionInfo), "browserOpensPreview": .bool(browserOpensPreview), "version": JSONValue(version),
"pullRequest": pullRequest?.json ?? .null]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,27 +228,16 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
}
}

/// Connection `owner` ended. `byDaemon`: the daemon's side closed it, and its page was told,
/// so the prompts that page sent are the page's again (Retry); the rest are left over. The
/// change is made at once (no file read on the caller's thread) and written off it.
func connectionEnded(owner: UUID, byDaemon: Bool) {
let snapshot = state.withLock { state -> (Int, [Entry], [PendingTrust])? in
/// Connection `owner` ended: the prompts it held are left over, for the next connection to
/// send again. Nothing on disk changes.
func connectionEnded(owner: UUID) {
state.withLock { state in
state.ended.insert(owner)
guard state.loaded else { return nil }
var changed = false
guard state.loaded else { return }
for (key, holder) in state.holders where holder.owner == owner {
state.holders[key] = nil
if byDaemon, case .page = holder {
state.entries.removeAll { Self.key($0.daemon, $0.promptId) == key }
changed = true
}
}
guard changed else { return nil }
state.generation += 1
return (state.generation, state.entries, state.pendingTrust)
}
guard let (generation, entries, pendingTrust) = snapshot else { return }
queue.async { [self] in write(entries, pendingTrust: pendingTrust, generation: generation) }
}

/// The prompts kept now (tests).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ nonisolated struct AgentPanePromptRelay: Sendable {
{ [journal, daemon] promptId in journal.forget(daemon: daemon, promptId: promptId) }
}

/// The connection ended: `byDaemon` when the daemon's side closed it and the page was told
/// (its prompts are the page's again, with Retry); otherwise its prompts are left over for the
/// next connection.
func ended(byDaemon: Bool) {
journal.connectionEnded(owner: owner, byDaemon: byDaemon)
/// The connection ended: its prompts are left over for the next connection.
func ended() {
journal.connectionEnded(owner: owner)
}

/// Records the decided prompt in `box` on disk before it goes (a prompt with no promptId gets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ import CmuxNextCompat
decisions.reset()
onQuestionStateChanged?()
localAppToken = nil
promptRelay?.ended(byDaemon: false)
promptRelay?.ended()
}

func offerQuestionKeyCredit(target: PageQuestionKeyTarget, intent: AgentPaneGestureIntent,
Expand Down Expand Up @@ -484,9 +484,8 @@ import CmuxNextCompat
gestures.clearTickets(connection: current)
localAppToken = nil
// A connection the daemon closed: its prompts go again on the next connection, where the
// page waits for them, unless Debug Settings agentPane.debug.lostPromptReplay is off (then
// they are the page's again).
promptRelay?.ended(byDaemon: deliver != nil && !AgentPaneTunables.lostPromptReplay.value)
// page waits for them.
promptRelay?.ended()
// Kept in the log (notice, not info): the page's "connection interrupted" needs its reason.
Self.logger.notice("agent pane transport closed connection=\(self.current, privacy: .public) code=\(batch.closed?.code ?? 0, privacy: .public) error=\(batch.closed?.error?.rawValue ?? "-", privacy: .public)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,6 @@ public nonisolated struct AgentPaneTunables {
help: "Agent chats show the live plan card and the recap card.",
default: false, code: "AgentPaneTunables.sessionStatus")

/// Side sessions: the composer's `/btw <question>` opens a read-only side session about the chat
/// beside it, and the palette offers Ask a Side Question… when the gate is on.
/// On by default; the page reads it from the header state (``AgentPaneHeaderState/sideSessions``).
public static let sideSessions = Tunable<Bool>.toggle(
"agentPane.debug.sideSessions", PageTunables.section, "Side sessions (/btw)",
help: "The composer's /btw opens a read-only side session beside the chat, and the palette offers Ask a Side Question….",
default: true, code: "AgentPaneTunables.sideSessions")

/// Which done thoughts fold to one line that opens to their text (webviews
/// conversation/ThoughtRow.tsx): every one by default (Leo 2026-10-20), or, as before, only a
/// tall one. A long thought streams in a fixed seven-line viewport with its newest line in view,
Expand Down Expand Up @@ -417,17 +409,6 @@ public nonisolated struct AgentPaneTunables {
help: "Visual treatment for the session sidebar's latest-reply hover preview: card, quiet, or outline.",
default: .card, code: "AgentPaneTunables.sidebarPreviewVariant")

/// Whether a prompt the daemon dropped with its connection before acpmux took it (an acpmux
/// restart, a reset socket) goes again on the pane's next connection, under its promptId, so
/// acpmux runs it at most once (``AgentPanePromptJournal``). On by default: the page waits for
/// the prompt to show on that connection instead of saying "Not sent" (webviews lostPrompt.ts;
/// Leo, 2026-10-21: "why does it say it's not responding?"). Off, the page says "Not sent" once
/// its wait for the prompt runs out, and gives it back.
public static let lostPromptReplay = Tunable<Bool>.toggle(
"agentPane.debug.lostPromptReplay", PageTunables.section, "Send again a prompt a dropped connection lost",
help: "A prompt acpmux never took because its connection dropped goes again on the next connection.",
default: true, code: "AgentPaneTunables.lostPromptReplay")

public static var all: [TunableDescriptor] {
[
agentPairGuard.descriptor,
Expand All @@ -440,7 +421,6 @@ public nonisolated struct AgentPaneTunables {
escapeStopDoublePress.descriptor,
harnessSwitchInStartedChat.descriptor,
liveLineBelow.descriptor,
lostPromptReplay.descriptor,
modelMenu.descriptor,
harnessPlacement.descriptor,
needsYou.descriptor,
Expand All @@ -457,7 +437,6 @@ public nonisolated struct AgentPaneTunables {
nativeSessionInfo.descriptor,
sidebarPreviewVariant.descriptor,
scrollerFade.descriptor,
sideSessions.descriptor,
subagentTray.descriptor,
suggestedTasks.descriptor,
systemDictation.descriptor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,6 @@ enum AgentHandlers {
run(view)
})
}
registry.publishSideSessions()
registry.bind("agentPane.createCheckpoint", run: { invocation in
guard invocation.allowsViewChange else {
return context.refuse(MiscHandlerStrings.checkpointNeedsFocus)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,5 @@ public final class RegistryControlBridge: ControlActionExecutor {
(.cloudWorkspace, "cloudWorkspace"),
(.homeChannelsEnabled, "homeChannelsEnabled"),
(.webViewFocused, "webViewFocused"),
(.sideSessions, "sideSessions"),
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ struct AgentPageCommandTests {
let registry = ActionRegistry.standard()
registry.context = ActionContext(rawValue: .max)
let model = AgentPaneModel(host: NoHost(), sessionId: "session-a")
var header = AgentPaneHeaderState(sideSessions: false, sessionInfo: false, browserOpensPreview: false)
var header = AgentPaneHeaderState(sessionInfo: false, browserOpensPreview: false)
var hooks = AgentPaneHeaderHooks(run: { _, _ in }, tabState: { header })
hooks.shown = { header }
model.header = hooks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ struct AgentPaneSendPickedFolderPromptTests {
let owner = UUID()
let recorded = await Task.detached { [journal] in journal.record(entry, owner: owner) }.value
#expect(recorded)
journal.connectionEnded(owner: owner, byDaemon: false)
journal.connectionEnded(owner: owner)
transport.promptJournal = journal
transport.webModes = { _, _, _ in nil }
#expect(await transport.pick(pane.folder) == pane.folder)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import CmuxNextActions
import Testing
@testable import CmuxNextAgentPane

/// `/btw` side sessions are always on: the palette's Ask a Side Question… needs only a focused
/// agent chat.
@Suite
@MainActor
struct AgentPaneSideQuestionAvailabilityTests {
private static let sideQuestion: ActionID = "agentPane.sideQuestion"

@Test func aFocusedChatOffersAskASideQuestion() {
let registry = ActionRegistry.standard()
registry.context = [.agentPaneFocused]
#expect(registry.isAvailable(Self.sideQuestion))
#expect(!registry.isAvailable(Self.sideQuestion, in: []))
}
}

This file was deleted.

Loading
Loading