Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
778da28
cmux-next iOS: failing tests for TUI-mode drag scrolling (T-SCROLL-12)
azooz2003-bit Oct 22, 2026
4feb6cc
cmux-next iOS: failing tests for workspace group collapse racing a re…
azooz2003-bit Oct 22, 2026
1a194b4
cmux-next iOS: a drag in a TUI scrolls the app; group collapse surviv…
azooz2003-bit Oct 22, 2026
9254580
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
azooz2003-bit Oct 22, 2026
4ff7f62
cmux-next iOS: TUI scroll test reads the step before #require
azooz2003-bit Oct 22, 2026
4d1d4ba
cmux-next iOS: review fixes; a fling into the bottom keeps its bottom…
azooz2003-bit Oct 22, 2026
1078673
cmux-next iOS: group edit re-review nits
azooz2003-bit Oct 22, 2026
caf50f6
cmux-next iOS: DisclosureChoice builds on macOS too (its test runs in…
azooz2003-bit Oct 22, 2026
97d5ba6
cmux-next iOS: failing test, less on the alternate screen scrolls bel…
azooz2003-bit Oct 22, 2026
7d72cac
cmux-next iOS: the alternate screen's scroll margins are the Mac's ro…
azooz2003-bit Oct 22, 2026
f00e536
cmux-next iOS: every alternate-screen margin form stays within the Ma…
azooz2003-bit Oct 22, 2026
b9069f6
cmux-next iOS: failing test, the composer types a one-line draft into…
azooz2003-bit Oct 22, 2026
f156b28
cmux-next iOS: failing test, a tap in a mouse-reporting app is a clic…
azooz2003-bit Oct 22, 2026
e03153a
cmux-next iOS: TUI taps click in mouse-mode apps; composer types into…
azooz2003-bit Oct 22, 2026
a6d6f76
cmux-next iOS: typed composer Return waits past an agent's paste-burs…
azooz2003-bit Oct 22, 2026
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
@@ -0,0 +1,14 @@
/// A row that opens on its own (a live thought shows its last lines) until
/// the person taps it: from then on their choice holds, so a tap always
/// closes what is open. A live thought used to keep its text on screen
/// after a tap with the chevron turned to closed.
struct DisclosureChoice: Equatable {
/// The person's choice; nil follows `automatic`.
private(set) var chosen: Bool?

func isOpen(automatic: Bool) -> Bool { chosen ?? automatic }

mutating func toggle(automatic: Bool) {
chosen = !isOpen(automatic: automatic)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ struct ToolGroupView: View {
}
.buttonStyle(.plain)
.padding(.vertical, -6)
// The header's 44 pt target overlaps the first call's: the header
// wins, so a tap on it always closes the group.
.zIndex(1)
if expanded {
VStack(alignment: .leading, spacing: 0) {
ForEach(calls) { call in
Expand All @@ -163,6 +166,9 @@ struct ToolGroupView: View {
.transition(.opacity)
} else if let live = calls.last(where: \.isRunning) {
ToolRowView(call: live)
// Each running call is its own row: one call's expansion
// does not carry over to the next.
.id(live.id)
.padding(.leading, 12)
.transition(.opacity)
}
Expand All @@ -176,16 +182,19 @@ struct ToolGroupView: View {
}
}

/// Collapsed "Thought" row; expands to the reasoning text in label2.
/// Collapsed "Thought" row; expands to the reasoning text in label2. While
/// the agent thinks it shows the last three lines until the person closes it.
struct ThoughtRow: View {
let text: String
let isLive: Bool
@State private var expanded = false
@State private var choice = DisclosureChoice()

private var expanded: Bool { choice.isOpen(automatic: isLive) }

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.cmuxMobileSmooth) { expanded.toggle() }
withAnimation(.cmuxMobileSmooth) { choice.toggle(automatic: isLive) }
} label: {
HStack(spacing: 8) {
Image(systemName: "brain").font(.system(size: 14)).foregroundStyle(.secondary).frame(width: 18)
Expand All @@ -206,11 +215,11 @@ struct ThoughtRow: View {
}
.buttonStyle(.plain)
.padding(.vertical, -6)
if expanded || isLive {
if expanded {
Text(text)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(expanded ? nil : 3)
.lineLimit(choice.chosen == true ? nil : 3)
.padding(.leading, 26)
.transition(.opacity)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ public final class LiveCoordinator {
feedStore.actions = MultiMacFeedActions(coordinator: self)
feedStore.syncState = .connecting
store.workspaces = []
store.resetGroupEdits()
store.tracksMacData = true
store.macData = [:]
store.macConnection = [:]
Expand Down
12 changes: 12 additions & 0 deletions cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileModel/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ public final class AppStore {
/// Optimistic workspaces still being created on their Mac. A tree
/// refresh keeps them until the creation settles.
public private(set) var pendingWorkspaceIDs: Set<String> = []
/// Group edits (collapse, rename, color, delete, move) the Mac's list
/// has not shown back yet: a tree refresh keeps them (`PendingGroupEdits`).
@ObservationIgnored var groupEdits = PendingGroupEdits()
/// The clock group edits are confirmed against (tests set it).
@ObservationIgnored public var now: () -> Date = Date.init

/// Forgets the group edits not yet shown back (sign-out, another account).
public func resetGroupEdits() {
groupEdits.removeAll()
}
/// Placeholder id → the real workspace id once its creation succeeds, so
/// a screen opened on the placeholder follows the real workspace.
public private(set) var workspaceAliases: [String: String] = [:]
Expand Down Expand Up @@ -140,6 +150,8 @@ public final class AppStore {

/// Replaces one Mac's workspaces, keeping the others.
public func replaceWorkspaces(macID: String, with next: [Workspace]) {
var next = next
groupEdits.apply(to: &next, now: now())
let merged = Self.reconciled(workspaces, macID: macID, with: next, keeping: pendingWorkspaceIDs)
if merged != workspaces { workspaces = merged }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ public final class WorkspaceCreator {
@ObservationIgnored private let recents: RecentDirectories
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var tasks: [String: Task<Void, Never>] = [:]
/// The last group request of each Mac: group requests go to a Mac one
/// after another, so the Mac applies quick taps in the order they came.
@ObservationIgnored var groupRequestTail: [String: Task<Void, Never>] = [:]
private static let preferredMacKey = "cmuxMobile.workspaces.newMac"

public init(store: AppStore, backend: any WorkspaceCreationBackend, defaults: UserDefaults = .standard) {
Expand Down Expand Up @@ -496,12 +499,15 @@ public final class WorkspaceCreator {

public func canDuplicate(_ workspace: Workspace) -> Bool { canManage(workspace) && !workspace.isHome }

func track(_ id: String, _ work: @escaping @MainActor () async -> Void) {
@discardableResult
func track(_ id: String, _ work: @escaping @MainActor () async -> Void) -> Task<Void, Never> {
let key = "manage-\(id)-\(UUID().uuidString)"
tasks[key] = Task { [weak self] in
let task = Task { [weak self] in
await work()
self?.tasks[key] = nil
}
tasks[key] = task
return task
}

static func message(_ error: any Error) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,118 @@ extension SidebarArrangement {
}
}

/// Group edits this phone made that the Mac's list has not shown back yet.
///
/// The phone shows a group change at once and sends it to the Mac. Until the
/// Mac's list carries it, a tree refresh (a terminal title, an agent event, a
/// notification) can publish the group as it was: the list's arrangement is
/// cached and only re-read after the Mac answers. Without this a collapsed
/// group opened again by itself, and a second tap seemed to be needed
/// ("disclosure groups not being properly closable"). An edit holds its
/// members' group over refreshes while it is in flight, and after the Mac's
/// yes until a refresh shows it or `confirmWindow` passes; then the Mac's
/// state wins again (another device may change it). Only the latest edit of
/// a workspace counts: a refused earlier tap does not undo a later one.
struct PendingGroupEdits: Sendable {
/// How long an answered edit outlasts refreshes that do not show it.
static let confirmWindow: TimeInterval = 10
/// An edit the Mac never answered gives way after this long.
static let unansweredWindow: TimeInterval = 30

private struct Edit: Sendable {
var group: MacWorkspaceGroup?
var generation: Int
/// When its request left for the Mac (nil: queued behind earlier ones).
var sentAt: Date?
var answeredAt: Date?
/// False: the edit only marks the latest one (a new group, which
/// the Mac names with its own id); refreshes show the Mac's group.
var holds: Bool
}

private var edits: [String: Edit] = [:]
private var generation = 0

var isEmpty: Bool { edits.isEmpty }

/// The workspaces now show `group` (nil: no group); returns the edit's
/// generation. It replaces any earlier edit of those workspaces.
mutating func begin(_ workspaceIDs: [String], group: MacWorkspaceGroup?, holds: Bool = true) -> Int {
generation += 1
for id in workspaceIDs { edits[id] = Edit(group: group, generation: generation, holds: holds) }
return generation
}

/// The edit's request left for the Mac (after the Mac's earlier group
/// requests): the unanswered window counts from here.
mutating func sent(_ workspaceIDs: [String], generation: Int, at date: Date) {
for id in workspaceIDs where edits[id]?.generation == generation { edits[id]?.sentAt = date }
}

/// The Mac accepted the edit: refreshes keep it until one shows it (a
/// non-holding edit is done).
mutating func answered(_ workspaceIDs: [String], generation: Int, at date: Date) {
for id in workspaceIDs where edits[id]?.generation == generation {
if edits[id]?.holds == false { edits[id] = nil } else { edits[id]?.answeredAt = date }
}
}

/// Forgets every edit (sign-in); generations keep counting, so a request
/// from before never matches a later edit.
mutating func removeAll() {
edits.removeAll()
}

/// The Mac refused the edit: the workspaces it was still the latest edit
/// of (those to roll back).
mutating func refused(_ workspaceIDs: [String], generation: Int) -> Set<String> {
var latest: Set<String> = []
for id in workspaceIDs where edits[id]?.generation == generation {
edits[id] = nil
latest.insert(id)
}
return latest
}

/// One Mac's refreshed rows, with the edits they do not show yet.
mutating func apply(to rows: inout [Workspace], now: Date) {
guard !edits.isEmpty else { return }
for index in rows.indices {
let id = rows[index].id
guard let edit = edits[id] else { continue }
let incoming = rows[index].group
guard edit.holds else { continue }
let expired = edit.answeredAt.map { now.timeIntervalSince($0) > Self.confirmWindow }
?? edit.sentAt.map { now.timeIntervalSince($0) > Self.unansweredWindow } ?? false
if expired || (edit.answeredAt != nil && Self.shows(incoming, edit.group)) {
edits[id] = nil
continue
}
rows[index].group = Self.merged(incoming, edit.group)
}
}

/// The Mac's group carries the edit (its place among groups may differ).
static func shows(_ incoming: MacWorkspaceGroup?, _ expected: MacWorkspaceGroup?) -> Bool {
guard let incoming, let expected else { return incoming == nil && expected == nil }
return incoming.id == expected.id && incoming.name == expected.name && incoming.color == expected.color
&& incoming.isCollapsed == expected.isCollapsed
}

/// The edit over the Mac's group: the Mac's place, the edit's fields.
static func merged(_ incoming: MacWorkspaceGroup?, _ expected: MacWorkspaceGroup?) -> MacWorkspaceGroup? {
guard var expected else { return nil }
guard var incoming, incoming.id == expected.id else {
expected.index = incoming?.index ?? expected.index
return expected
}
incoming.name = expected.name
incoming.color = expected.color
incoming.isCollapsed = expected.isCollapsed
return incoming
}
}

/// The Mac's workspace group actions, for a backend that can send them
/// (cmux-next-ios/SECURITY.md "Workspace groups").
@MainActor
Expand Down Expand Up @@ -187,8 +299,9 @@ extension WorkspaceCreator {
public func place(_ workspace: Workspace, in group: MacWorkspaceGroup?) {
guard canGroup(workspace), workspace.group?.id != group?.id, let grouper else { return }
let old = store.updateWorkspace(workspace.id) { $0.group = group }
let generation = store.groupEdits.begin([workspace.id], group: group)
let failure = group == nil ? "Couldn't remove \(workspace.title) from its group" : "Couldn't move \(workspace.title) to \(group?.displayName ?? "")"
regroup([old].compactMap { $0 }, workspace: workspace, failure: failure) {
regroup([old].compactMap { $0 }, generation: generation, workspace: workspace, failure: failure) {
try await grouper.place(workspace, in: group)
}
}
Expand All @@ -203,7 +316,10 @@ extension WorkspaceCreator {
let color = MacWorkspaceGroup.nextColor(used: used)
let pending = MacWorkspaceGroup(id: "pending-\(UUID().uuidString)", name: name, color: color.rawValue)
let old = store.updateWorkspace(workspace.id) { $0.group = pending }
regroup([old].compactMap { $0 }, workspace: workspace, failure: "Couldn't make a group for \(workspace.title)") {
// Not held over refreshes (the Mac names the new group with its own
// id), but it is the workspace's latest edit: earlier ones stop.
let generation = store.groupEdits.begin([workspace.id], group: pending, holds: false)
regroup([old].compactMap { $0 }, generation: generation, workspace: workspace, failure: "Couldn't make a group for \(workspace.title)") {
try await grouper.moveToNewGroup(workspace, name: name, color: color)
}
}
Expand Down Expand Up @@ -250,18 +366,33 @@ extension WorkspaceCreator {
var changed = group
change(&changed)
let old = members.compactMap { ws in store.updateWorkspace(ws.id) { $0.group = removes ? nil : changed } }
regroup(old, workspace: first, failure: failure) { try await work(grouper) }
let generation = store.groupEdits.begin(old.map(\.id), group: removes ? nil : changed)
regroup(old, generation: generation, workspace: first, failure: failure) { try await work(grouper) }
}

private func regroup(_ old: [Workspace], workspace: Workspace, failure: String, _ work: @escaping @MainActor () async throws -> Void) {
track(workspace.id) { [weak self] in
/// Sends a group edit already shown, after the Mac's earlier group
/// requests. `generation` (`PendingGroupEdits`) holds it over refreshes;
/// a refusal rolls back only the workspaces whose latest edit it still is.
private func regroup(_ old: [Workspace], generation: Int, workspace: Workspace, failure: String,
_ work: @escaping @MainActor () async throws -> Void) {
let ids = old.map(\.id)
let macID = workspace.macID
let previous = groupRequestTail[macID]
let task = track(workspace.id) { [weak self] in
await previous?.value
self?.store.groupEdits.sent(ids, generation: generation, at: self?.store.now() ?? Date())
do {
try await work()
guard let self else { return }
store.groupEdits.answered(ids, generation: generation, at: store.now())
} catch {
for ws in old { self?.store.updateWorkspace(ws.id) { $0.group = ws.group } }
self?.onManageFailure?(workspace, failure, Self.message(error))
guard let self else { return }
let latest = store.groupEdits.refused(ids, generation: generation)
for ws in old where latest.contains(ws.id) { store.updateWorkspace(ws.id) { $0.group = ws.group } }
onManageFailure?(workspace, failure, Self.message(error))
}
}
groupRequestTail[macID] = task
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,40 @@ public struct ComposerSubmit: Equatable, Sendable {
public enum Step: Equatable, Sendable {
/// The whole draft, one paste (several lines stay one paste).
case paste(String)
/// The draft as typed keys (a one-line draft for a full-screen app).
case type(String)
/// Return, as a separate write once the paste is out.
case enter
}

/// Time between the paste and its Return: the paste leaves in its own
/// write and a program reading in bursts sees Return after it, not in it.
public static let enterDelay: Duration = .milliseconds(60)
/// Time between typed text and its Return: past the window in which an
/// agent's paste-burst detection (Codex: about 120 ms after a burst of
/// unbracketed characters) takes Return for a newline (Codex QA F1),
/// should one run on the alternate screen.
public static let typedEnterDelay: Duration = .milliseconds(250)

/// The wait before Return: after a paste, after typed text, or none.
public var enterWait: Duration? {
switch steps.first {
case .paste: Self.enterDelay
case .type: Self.typedEnterDelay
case .enter, nil: nil
}
}

public let steps: [Step]

public init(text: String, pressReturn: Bool) {
/// `fullScreenApp`: the program is on the alternate screen (vim, less,
/// htop). A one-line draft is typed there: vim takes a bracketed paste
/// as text even in normal mode, so `:q` was inserted, not run.
public init(text: String, pressReturn: Bool, fullScreenApp: Bool = false) {
var steps: [Step] = []
if !text.isEmpty { steps.append(.paste(text)) }
if !text.isEmpty {
steps.append(fullScreenApp && !text.contains(where: \.isNewline) ? .type(text) : .paste(text))
}
if pressReturn { steps.append(.enter) }
self.steps = steps
}
Expand All @@ -44,8 +65,6 @@ public struct ComposerSubmit: Equatable, Sendable {
return "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
}

/// Whether Return waits for `enterDelay` (it follows a paste).
public var delaysEnter: Bool {
steps.first.map { if case .paste = $0 { true } else { false } } ?? false
}
/// Whether Return waits (it follows a paste or typed text: `enterWait`).
public var delaysEnter: Bool { enterWait != nil }
}
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ struct TerminalDemoDriver {
model.keyBar(.control)
await wait(0.5)
mark(":q")
model.controller?.surface.sendText(":q", pressReturn: true)
model.controller?.surface.sendText(":q", pressReturn: true, fullScreenApp: model.isTUI)
await wait(1.2)
model.controller?.hideKeyboard()
await wait(1.5)
Expand Down
Loading