Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
8030559
cmux-next-ios: list scroll rig for every vertical list (DEBUG only)
azooz2003-bit Oct 26, 2026
6fd292a
cmux-next-ios list rig: transcripts scroll from the top, open windows…
azooz2003-bit Oct 27, 2026
cec0c80
cmux-next-ios list rig: lane brief
azooz2003-bit Oct 27, 2026
c347a53
cmux-next-ios Feed store: no per-write scans or main-thread file writes
azooz2003-bit Oct 27, 2026
e4f05da
cmux-next-ios workspaces: red tests for stable section ids, one inbox…
azooz2003-bit Oct 27, 2026
f35ad34
cmux-next-ios home: red test for one Home list build per change and a…
azooz2003-bit Oct 27, 2026
b5a07c3
cmux-next-ios list rig: diff pan, wrap and split modes, artifacts gri…
azooz2003-bit Oct 27, 2026
40e42e7
CmuxTabsKit: build only the grid cards near the screen, keep the hidd…
azooz2003-bit Oct 27, 2026
5a2d196
cmux-next iOS tabs: stored kit tabs, per-tab observation, per-card lo…
azooz2003-bit Oct 27, 2026
8da08d6
cmux-next-ios lists: Workspaces and Home rows skip unchanged rows; me…
azooz2003-bit Oct 27, 2026
a6bf705
cmux-next-ios list rig: FAMILY and PRODUCTS for an iPad build (diff S…
azooz2003-bit Oct 27, 2026
5256d8e
cmux-next-ios diff and artifacts: O(visible) diff list, prepared off …
azooz2003-bit Oct 27, 2026
cecf2c8
cmux-next-ios workspaces: a tree update re-hosts no toolbar menus and…
azooz2003-bit Oct 27, 2026
c1df4cb
cmux-next-ios live: red test, a repeated terminal title is not activity
azooz2003-bit Oct 28, 2026
b95af25
cmux-next-ios list rig: shot waits for the run's hold line, ARGS for …
azooz2003-bit Oct 28, 2026
7bf8fe2
cmux-next-ios chat: transcript as a collection view of reused rows; s…
azooz2003-bit Oct 28, 2026
b81d289
CmuxTabsKit: card residency instead of a scroll band; overviewIdleCha…
azooz2003-bit Oct 28, 2026
e58acc9
cmux-next iOS tabs: agent cards as pictures drawn while the grid rests
azooz2003-bit Oct 28, 2026
0700e2d
cmux-next-ios: a repeated terminal title is not activity; list-row tr…
azooz2003-bit Oct 28, 2026
a6b1e4c
cmux-next-ios list rig: the open window waits one beat so a stalled o…
azooz2003-bit Oct 28, 2026
e518f67
CmuxTabsKit: the grid is idle only after its offset is still for 6 fr…
azooz2003-bit Oct 28, 2026
134307d
CmuxTabsKit: observed residency busy state, idle reported from the fi…
azooz2003-bit Oct 28, 2026
d3e7ae8
cmux-next iOS tabs: review fixes (frozen titles, workspace identity, …
azooz2003-bit Oct 28, 2026
9c5580c
Merge branch 'feat-cmux-next-ios-smooth-chat' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
91f1d0f
Merge branch 'feat-cmux-next-ios-smooth-diff' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
c36a4ee
Merge branch 'feat-cmux-next-ios-smooth-workspaces' into feat-cmux-ne…
azooz2003-bit Oct 28, 2026
2298850
cmux-next-ios workspaces: red tests for the UIKit list's apply plan a…
azooz2003-bit Oct 28, 2026
81df30c
CmuxTabsKit: idle computed inside CardResidency, not observed by the …
azooz2003-bit Oct 28, 2026
1bb017f
cmux-next iOS tabs: hold title rebuilds while the grid moves; draw ca…
azooz2003-bit Oct 28, 2026
d196b5c
cmux-next-ios workspaces: the list is a UIKit collection with a diffa…
azooz2003-bit Oct 28, 2026
c3dc474
Merge branch 'feat-cmux-next-ios-smooth-tabs' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
ed2f216
cmux-next-ios list rig: tabs-push window around opening the workspace
azooz2003-bit Oct 28, 2026
eb19aa3
cmux-next-ios chat: native transcript rows laid out off main; SwiftUI…
azooz2003-bit Oct 28, 2026
0e4bed5
cmux-next-ios chat: native row labels draw asynchronously at commit, …
azooz2003-bit Oct 28, 2026
104c48e
cmux-next-ios workspaces: O(changed rows) live applies on the UIKit list
azooz2003-bit Oct 28, 2026
789f96a
cmux-next-ios chat: native table rows and prompt bubbles at SwiftUI's…
azooz2003-bit Oct 28, 2026
930a3b8
cmux-next iOS tabs: still agent cards, held grid transcript, lazy bro…
azooz2003-bit Oct 28, 2026
8f38752
Merge branch 'feat-cmux-next-ios-smooth-workspaces' into feat-cmux-ne…
azooz2003-bit Oct 28, 2026
c8e8d3a
cmux-next-ios chat: reload, not reconfigure, a row whose reuse pool c…
azooz2003-bit Oct 28, 2026
2d25837
Merge branch 'feat-cmux-next-ios-smooth-tabs' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
52b4543
Merge branch 'feat-cmux-next-ios-smooth-chat' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
f58db9e
cmux-next-ios workspaces: separator ends where the List's did; a UI t…
azooz2003-bit Oct 28, 2026
46df1a7
Merge branch 'feat-cmux-next-ios-smooth-lists' into feat-cmux-next-io…
azooz2003-bit Oct 28, 2026
667d255
cmux-next-ios: review fixes for Feed read marks, artifact thumbnails,…
azooz2003-bit Oct 28, 2026
c80182f
CmuxTabsKit: unbuilt grid cards stay in the accessibility tree
azooz2003-bit Oct 28, 2026
098c191
cmux-next iOS tabs: an older card picture never hides a loading image
azooz2003-bit Oct 28, 2026
e5cab78
cmux-next-ios chat: review round 3 fixes (accessibility, AX sizes, sh…
azooz2003-bit Oct 28, 2026
d565fb3
Merge branch 'feat-cmux-next-ios-smooth-chat' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
ab32437
Merge branch 'feat-cmux-next-ios-smooth-tabs' into feat-cmux-next-ios…
azooz2003-bit Oct 28, 2026
a5090dd
Merge branch 'feat-cmux-next-ios-smooth-workspaces' into feat-cmux-ne…
azooz2003-bit Oct 28, 2026
b4ac838
cmux-next-ios list rig: final before/after script
azooz2003-bit Oct 28, 2026
ba194e2
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
azooz2003-bit Oct 28, 2026
e8e9d63
cmux-next-ios list rig: sync the specs the package tests read (schema…
azooz2003-bit Oct 28, 2026
258a877
cmux-next-ios artifacts grid: no web snapshot under a moving grid; on…
azooz2003-bit Oct 28, 2026
19cbbcf
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
azooz2003-bit Oct 28, 2026
e7e3c9c
cmux-next-ios chat: warm each native row kind's first draw after the …
azooz2003-bit Oct 28, 2026
b2e7a1a
cmux-next-ios tabs: a store change reaches the stored tab list in the…
azooz2003-bit Oct 30, 2026
905c536
Merge origin/feat-cmux-next (048d21118f9d) into feat-cmux-next-ios-sm…
azooz2003-bit Oct 31, 2026
a0ac961
cmux-next-ios: MERGE-905c536ff590.md, where each upstream change of t…
azooz2003-bit Oct 31, 2026
d992282
Merge origin/feat-cmux-next (dfaa79eb0f4c) into feat-cmux-next-ios-sm…
azooz2003-bit Oct 31, 2026
447291d
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
azooz2003-bit Oct 31, 2026
4c2a65c
Merge origin/feat-cmux-next (9a0fe9db6237) into feat-cmux-next-ios-sm…
azooz2003-bit Oct 31, 2026
5bd1ede
Merge origin/feat-cmux-next (8c2303516f67) into feat-cmux-next-ios-sm…
azooz2003-bit Oct 31, 2026
3cc58c6
Merge origin/feat-cmux-next (4dc7567b3b3e) into feat-cmux-next-ios-sm…
azooz2003-bit Oct 31, 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
7 changes: 6 additions & 1 deletion Packages/iOS/CmuxTabsKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ transport or models.
putting that frame in its card; `resumeLiveContent(_:)` restarts it when
the grid is left, so the zoom still starts from the same pixels.
`overviewVisibilityChanged(_:)` and `overviewScrollingChanged(_:)` tell
you when to stop or hold card updates. Return false from `isContentReady` while a
you when to stop or hold card updates, and `overviewIdleChanged(_:)` when
the grid rests (settled, not scrolling): the time to draw a costly card
into a picture. The grid builds only the cards within a few screens of
the visible rect (one per frame, nearest first) and releases far ones, so
`card(for:)` runs for a card when it nears the screen, not for every tab
at once; keep it a cheap view of what you already have. Return false from `isContentReady` while a
tab's content would still be blank (a terminal attaching) and its card
covers it.
- **Close buttons and titles.** They arrive and leave as in Safari (measured
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ public protocol TabsDataSource: AnyObject, Observable {
/// The grid started (true) or stopped (false) scrolling: hold card
/// updates until it stops, so none lands in a scroll frame.
func overviewScrollingChanged(_ scrolling: Bool)
/// The grid settled and is not scrolling (true), or a zoom, a scroll or
/// the page took over (false): deferred card work (rendering a card's
/// snapshot) runs only while this is true. Default: nothing.
func overviewIdleChanged(_ idle: Bool)
/// The grid settled with `id` active. Return true after making the
/// tab's card show the live content's current frame (`thumbnail` or
/// `card`) and pausing the live content (it keeps showing that frame):
Expand Down Expand Up @@ -152,6 +156,7 @@ extension TabsDataSource {
public func overviewWillAppear() {}
public func overviewVisibilityChanged(_ visible: Bool) {}
public func overviewScrollingChanged(_ scrolling: Bool) {}
public func overviewIdleChanged(_ idle: Bool) {}
public func showStaticCard(for id: TabsKitTab.ID) -> Bool { false }
public func resumeLiveContent(_ id: TabsKitTab.ID) {}
public func goBack(_ id: TabsKitTab.ID) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,49 @@ struct OverviewLayout: Equatable, Sendable {
}
}


/// Which grid cards are built (in the view tree), as a collection view keeps
/// only the cells near the screen: a card costs a full page of content laid
/// out and scaled, so a grid of 80 tabs never builds 80.
///
/// Bands are in screens around the visible rect: cards on screen are needed
/// at once, cards within `keep` screens are built ahead one per frame (the
/// nearest first), and a built card is released only past `release`
/// screens, so scrolling back and forth does not build a card again.
struct CardResidencyPlan: Equatable, Sendable {
var keep: CGFloat = 3
var release: CGFloat = 4

/// What to change for the grid at scroll `offset` with `built` cards.
struct Step: Equatable, Sendable {
/// Unbuilt cards on screen (build now when the grid must show them).
var visible: [Int] = []
/// The nearest unbuilt card within the keep band (build next).
var next: Int?
/// Built cards past the release band.
var release: [Int] = []
}

func step(layout: OverviewLayout, offset: CGFloat, isBuilt: (Int) -> Bool) -> Step {
let h = layout.size.height
let top = max(0, offset)
let screen = (min: top, max: top + h)
var step = Step()
var best: (index: Int, distance: CGFloat)?
for i in 0..<layout.count {
let r = layout.card(i)
let minY = r.minY, maxY = r.maxY + OverviewLayout.titleRow
let distance = maxY < screen.min ? screen.min - maxY : minY > screen.max ? minY - screen.max : 0
let built = isBuilt(i)
if built {
if distance > release * h { step.release.append(i) }
} else if distance == 0 {
step.visible.append(i)
} else if distance <= keep * h, distance < (best?.distance ?? .infinity) {
best = (i, distance)
}
}
step.next = step.visible.first ?? best?.index
return step
}
}
221 changes: 221 additions & 0 deletions Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/CardResidency.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#if os(iOS)
import Observation
import QuartzCore
import SwiftUI

/// One card's residency: whether its view is in the tree. Each card observes
/// only its own slot, so building or releasing a card re-runs that card,
/// never the grid and the other cards.
@MainActor
@Observable
final class CardSlot {
var built = false
}

/// Builds grid cards near the screen and releases far ones
/// (`CardResidencyPlan`). Cards on screen are built at once while the grid
/// shows (the zoom lands on real cards, a scroll never shows a blank one);
/// the rest are built one per frame, nearest first, only while the grid
/// rests; a hidden grid builds only its on-screen cards, one per frame.
/// Scrolling never re-runs the grid's body: the offset comes in through
/// `scrolled(to:)` and changes only the slots that flip.
@MainActor
final class CardResidency {
private var slots: [String: CardSlot] = [:]
private var ids: [String] = []
private var layout: OverviewLayout?
private var offset: CGFloat = 0
private var visible = false
private var settled = true
private let plan = CardResidencyPlan()
private let clock = FrameClock()
private var ticking = false
/// Cards built so far (tests and the rig's log).
private(set) var builds = 0
private var building = false { didSet { publishBusy() } }
/// The offset changed within the last `restFrames` frames: any scroll,
/// a finger's, a deceleration's or one set from code (which the scroll
/// phase does not report).
private var moving = false { didSet { publishBusy() } }
/// Moving or building ahead.
private(set) var busy = false
/// Scrolled by a finger or a deceleration (the scroll phase).
private var scrolling = false
/// Whether the grid rests: settled, not scrolling, not moving, nothing
/// left to build. Sent to `idleSink` on change; never observed by a
/// view, so a scroll that starts or stops re-runs no body.
private(set) var idle = false
/// Receives idle changes (the data source's `overviewIdleChanged`). Set
/// to a closure that holds the source weakly.
var idleSink: (Bool) -> Void = { _ in } {
// A sink installed after the first update hears the current state.
didSet { if idle { idleSink(true) } }
}
private let motionClock = FrameClock()
private var stillFrames = 0
/// Frames without an offset change before the grid counts as at rest.
static let restFrames = 6

private func publishBusy() {
let now = building || moving
guard now != busy else { return }
busy = now
publishIdle()
}

private func publishIdle() {
let now = settled && visible && !scrolling && !busy
guard now != idle else { return }
idle = now
idleSink(now)
}

func setScrolling(_ scrolling: Bool) {
self.scrolling = scrolling
publishIdle()
}

func slot(_ id: String) -> CardSlot {
if let slot = slots[id] { return slot }
let slot = CardSlot()
slots[id] = slot
return slot
}

func update(ids: [String], layout: OverviewLayout) {
self.ids = ids
self.layout = layout
let listed = Set(ids)
for id in slots.keys where !listed.contains(id) { slots[id] = nil }
reconcile(urgent: visible)
}

func scrolled(to offset: CGFloat) {
guard abs(offset - self.offset) > 0.5 else { return }
self.offset = offset
stillFrames = 0
if !moving {
moving = true
motionClock.start { [weak self] _ in
guard let self else { return false }
self.stillFrames += 1
guard self.stillFrames >= Self.restFrames else { return true }
self.moving = false
return false
}
}
// A card on screen is never shown blank: built in this frame (the
// grid scrolled to reveal the zooming card, or a fling outran the
// building ahead).
reconcile(urgent: visible)
}

/// The grid is about to show (or was covered): visible cards are built
/// now, so the first frame of the zoom shows them; the rest follow.
func setVisible(_ visible: Bool) {
self.visible = visible
reconcile(urgent: visible)
publishIdle()
}

func setSettled(_ settled: Bool) {
self.settled = settled
tick()
publishIdle()
}

private func isBuilt(_ index: Int) -> Bool {
index < ids.count && slots[ids[index]]?.built == true
}

private func reconcile(urgent: Bool) {
guard let layout else { return }
let step = plan.step(layout: layout, offset: offset, isBuilt: isBuilt)
for i in step.release where i < ids.count { slots[ids[i]]?.built = false }
if urgent {
for i in step.visible where i < ids.count { build(ids[i]) }
}
tick()
}

private func build(_ id: String) {
let slot = slot(id)
guard !slot.built else { return }
slot.built = true
builds += 1
}

/// One card per frame while there is one to build: the keep band while
/// the grid is at rest, only the on-screen cards while it is hidden or
/// zooming (they are ready when it opens; nothing else is built then).
private func tick() {
guard !ticking, nextToBuild() != nil else { return }
ticking = true
building = true
clock.start { [weak self] _ in
guard let self, let id = self.nextToBuild() else {
self?.ticking = false
self?.building = false
return false
}
self.build(id)
return true
}
}

private func nextToBuild() -> String? {
guard let layout else { return nil }
let step = plan.step(layout: layout, offset: offset, isBuilt: isBuilt)
let next = visible && settled ? step.next : step.visible.first
return next.flatMap { $0 < ids.count ? ids[$0] : nil }
}
}

/// A grid card, in the tree only while its slot is built (or a gesture or
/// the zoom holds it). The card's view is made in this body, so an unbuilt
/// card costs the grid's body nothing (a built value per card was made for
/// all 80 cards on every zoom frame). An unbuilt card is still in the
/// accessibility tree: a bare element in its slot, named after its tab, that
/// opens it, so swiping through the cards with VoiceOver reaches every tab
/// (scrolling to one builds it).
struct ResidentCard<Content: View>: View {
let slot: CardSlot
let held: Bool
let standIn: CardStandIn
let content: () -> Content

init(slot: CardSlot, held: Bool, standIn: CardStandIn, @ViewBuilder content: @escaping () -> Content) {
self.slot = slot
self.held = held
self.standIn = standIn
self.content = content
}

var body: some View {
if slot.built || held {
content()
} else {
standIn
}
}
}

/// An unbuilt card's accessibility element: no pixels, no touches.
struct CardStandIn: View {
var label: String
var rect: CGRect
var open: () -> Void

var body: some View {
Color.clear
.frame(width: rect.width, height: rect.height + OverviewLayout.titleRow)
.offset(x: rect.minX, y: rect.minY)
.allowsHitTesting(false)
.accessibilityElement()
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
.accessibilityAction(named: "Open", open)
.accessibilityAction(.default, open)
}
}
#endif
Loading
Loading