From 30288a4784c5107e33c1ffc8ca03d0ca07ff3086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 22:15:30 -0300 Subject: [PATCH 1/7] iOS: open side chats and subagent transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions with side chats get a toolbar button that lists them in a sheet; picking one opens it on top of its parent. Subagent spawn cards in the transcript are now links to a read-only transcript: finished subagents read their frozen blob, running ones dial the doc's chat2 room on the parent's host. Session rows show a "● N" pill while subagents run. Co-authored-by: Claude Opus 5.5 --- apps/ios/Zeron/App/AppModel.swift | 33 +++- .../ios/Zeron/Core/Generated/zeron_core.swift | 61 ++++++- .../ios/Zeron/Session/CoreSessionSource.swift | 1 + .../Zeron/Session/SessionViewController.swift | 51 +++++- .../Session/SideChatsViewController.swift | 43 +++++ .../Session/SubagentViewController.swift | 155 ++++++++++++++++++ apps/ios/Zeron/Shell/MainTabController.swift | 8 +- apps/ios/Zeron/Threads/SessionCell.swift | 25 ++- .../Zeron/Threads/SessionListController.swift | 11 +- apps/ios/Zeron/Transcript/RowView.swift | 14 ++ .../Zeron/Transcript/TranscriptListView.swift | 7 + crates/client/src/client.rs | 117 +++++++++++-- crates/client/src/demo/fixtures.rs | 3 +- crates/client/src/demo/transcripts.rs | 70 ++++++++ crates/client/src/lib.rs | 4 +- crates/client/src/session/mod.rs | 46 ++++-- crates/client/src/workspace/view.rs | 5 + crates/client/tests/demo.rs | 43 +++++ crates/mobile/src/client_ffi/mod.rs | 12 ++ crates/mobile/src/client_ffi/types.rs | 3 + crates/mobile/src/layout/display.rs | 3 + crates/mobile/src/layout/tools.rs | 24 +++ 22 files changed, 700 insertions(+), 39 deletions(-) create mode 100644 apps/ios/Zeron/Session/SideChatsViewController.swift create mode 100644 apps/ios/Zeron/Session/SubagentViewController.swift diff --git a/apps/ios/Zeron/App/AppModel.swift b/apps/ios/Zeron/App/AppModel.swift index 0f4c97864b..1819fc5ae1 100644 --- a/apps/ios/Zeron/App/AppModel.swift +++ b/apps/ios/Zeron/App/AppModel.swift @@ -50,6 +50,7 @@ final class AppModel { private var workspaceRevision: UInt64 = 0 private var observers: [UUID: () -> Void] = [:] private var sessionObservers: [String: [UUID: () -> Void]] = [:] + private var workspaceObservers: [UUID: () -> Void] = [:] private lazy var bridge = ListenerBridge(app: self) private var refreshScheduled = false private var clock: Timer? @@ -326,6 +327,14 @@ final class AppModel { return Token { [weak self] in self?.observers[id] = nil } } + /// Every workspace revision, including changes no list on the front page + /// shows (a session's side chats): `observe` fires only when it changes. + func observeWorkspace(_ handler: @escaping () -> Void) -> AnyObject { + let id = UUID() + workspaceObservers[id] = handler + return Token { [weak self] in self?.workspaceObservers[id] = nil } + } + func observeSession(_ chatId: String, _ handler: @escaping () -> Void) -> AnyObject { let id = UUID() sessionObservers[chatId, default: [:]][id] = handler @@ -367,6 +376,7 @@ final class AppModel { private func refreshWorkspace() { guard let client else { return } let ws = client.workspace() + let revised = ws.revision != workspaceRevision workspaceRevision = ws.revision var all: [String: SessionRow] = [:] func vm(_ r: SessionRow) -> SessionRowVM { @@ -407,6 +417,7 @@ final class AppModel { frontPage = page archived = archivedVMs if changed { observers.values.forEach { $0() } } + if revised { workspaceObservers.values.forEach { $0() } } } /// Like the desktop's project grouping: checkouts of one repository @@ -469,7 +480,8 @@ final class AppModel { timeLabel: r.timeLabel, unseen: r.unseen, pinned: r.pinned, - sendFailed: r.sendState == .failed + sendFailed: r.sendState == .failed, + runningSubagents: Int(r.runningSubagents) ) } @@ -481,6 +493,12 @@ final class AppModel { rows[id] ?? client?.sessionRow(chatId: id) } + /// Side chats and agent-spawned chats hanging off `chatId` (archived + /// ones left out), recency order. + func children(of chatId: String) -> [SessionRowVM] { + (client?.childSessions(parentId: chatId) ?? []).filter { !$0.archived }.map(Self.vm) + } + func sessions(inFolder id: String) -> [SessionRowVM] { id == "archived" ? archived : frontPage.sectionSessions[id] ?? [] } @@ -531,6 +549,19 @@ final class AppModel { } } + /// A subagent's transcript (a spawn card's doc), read-only. Finished + /// subagents read their frozen copy, so this can wait on the network. + @MainActor + func openSubagent(docId: String, frozen: Bool) async -> SessionHandle? { + guard let client else { return nil } + do { + return try await client.openSubagent(docId: docId, frozen: frozen) + } catch { + NSLog("open subagent failed: \(error)") + return nil + } + } + // MARK: New session /// Who's signed in, by name (never the WorkOS user / org ids). diff --git a/apps/ios/Zeron/Core/Generated/zeron_core.swift b/apps/ios/Zeron/Core/Generated/zeron_core.swift index 55e89c2ef4..99d13894b7 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -1123,6 +1123,12 @@ public protocol CoreClientProtocol: AnyObject, Sendable { */ func openSession(chatId: String) throws -> SessionHandle + /** + * Open a subagent's transcript read-only (a spawn chip's doc id). A + * `frozen` (finished) subagent reads its uploaded blob first. + */ + func openSubagent(docId: String, frozen: Bool) async throws -> SessionHandle + func orgId() -> String func pinSession(chatId: String) throws @@ -1655,6 +1661,26 @@ open func openSession(chatId: String)throws -> SessionHandle { }) } + /** + * Open a subagent's transcript read-only (a spawn chip's doc id). A + * `frozen` (finished) subagent reads its uploaded blob first. + */ +open func openSubagent(docId: String, frozen: Bool)async throws -> SessionHandle { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_zeron_mobile_fn_method_coreclient_open_subagent( + self.uniffiCloneHandle(),FfiConverterString.lower(docId),FfiConverterBool.lower(frozen) + ) + }, + pollFunc: ffi_zeron_mobile_rust_future_poll_u64, + completeFunc: ffi_zeron_mobile_rust_future_complete_u64, + freeFunc: ffi_zeron_mobile_rust_future_free_u64, + liftFunc: FfiConverterTypeSessionHandle_lift, + errorHandler: FfiConverterTypeCoreError_lift + ) +} + open func orgId() -> String { return try! FfiConverterString.lift(try! rustCall() { uniffiCallStatus in @@ -8491,6 +8517,10 @@ public struct SessionRow: Equatable, Hashable { * Run start of the live turn while Working/AwaitingInput. */ public var workingSinceMs: Int64? + /** + * Subagents of this chat streaming right now. + */ + public var runningSubagents: UInt32 public var lastActivityMs: Int64 /** * `now` / `34m` / `4h` / `2d` at derive time. @@ -8538,7 +8568,10 @@ public struct SessionRow: Equatable, Hashable { */hostIndicator: ChatIndicator, /** * Run start of the live turn while Working/AwaitingInput. - */workingSinceMs: Int64?, lastActivityMs: Int64, + */workingSinceMs: Int64?, + /** + * Subagents of this chat streaming right now. + */runningSubagents: UInt32, lastActivityMs: Int64, /** * `now` / `34m` / `4h` / `2d` at derive time. */timeLabel: String, createdAtMs: Int64, unseen: Bool, archived: Bool, pinned: Bool, sectionId: String?, pullRequest: PullRequest?, @@ -8567,6 +8600,7 @@ public struct SessionRow: Equatable, Hashable { self.indicator = indicator self.hostIndicator = hostIndicator self.workingSinceMs = workingSinceMs + self.runningSubagents = runningSubagents self.lastActivityMs = lastActivityMs self.timeLabel = timeLabel self.createdAtMs = createdAtMs @@ -8615,6 +8649,7 @@ public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { indicator: FfiConverterTypeChatIndicator.read(from: &buf), hostIndicator: FfiConverterTypeChatIndicator.read(from: &buf), workingSinceMs: FfiConverterOptionInt64.read(from: &buf), + runningSubagents: FfiConverterUInt32.read(from: &buf), lastActivityMs: FfiConverterInt64.read(from: &buf), timeLabel: FfiConverterString.read(from: &buf), createdAtMs: FfiConverterInt64.read(from: &buf), @@ -8649,6 +8684,7 @@ public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { FfiConverterTypeChatIndicator.write(value.indicator, into: &buf) FfiConverterTypeChatIndicator.write(value.hostIndicator, into: &buf) FfiConverterOptionInt64.write(value.workingSinceMs, into: &buf) + FfiConverterUInt32.write(value.runningSubagents, into: &buf) FfiConverterInt64.write(value.lastActivityMs, into: &buf) FfiConverterString.write(value.timeLabel, into: &buf) FfiConverterInt64.write(value.createdAtMs, into: &buf) @@ -13188,6 +13224,12 @@ public enum WidgetKind: Equatable, Hashable { */ case toolToggle(detail: UInt64, `open`: Bool ) + /** + * Tap target over a subagent spawn card: opens the subagent's + * transcript (`doc_id`, read-only). `frozen` = it finished (done/failed). + */ + case subagentLink(docId: String, title: String, frozen: Bool + ) /** * Shimmer sweep over the active group's title (text drawn by the canvas * underneath; the painter re-draws those runs brighter through a moving @@ -13246,7 +13288,10 @@ public struct FfiConverterTypeWidgetKind: FfiConverterRustBuffer { case 11: return .toolToggle(detail: try FfiConverterUInt64.read(from: &buf), open: try FfiConverterBool.read(from: &buf) ) - case 12: return .shimmer + case 12: return .subagentLink(docId: try FfiConverterString.read(from: &buf), title: try FfiConverterString.read(from: &buf), frozen: try FfiConverterBool.read(from: &buf) + ) + + case 13: return .shimmer default: throw UniffiInternalError.unexpectedEnumCase } @@ -13318,8 +13363,15 @@ public struct FfiConverterTypeWidgetKind: FfiConverterRustBuffer { FfiConverterBool.write(`open`, into: &buf) - case .shimmer: + case let .subagentLink(docId,title,frozen): writeInt(&buf, Int32(12)) + FfiConverterString.write(docId, into: &buf) + FfiConverterString.write(title, into: &buf) + FfiConverterBool.write(frozen, into: &buf) + + + case .shimmer: + writeInt(&buf, Int32(13)) } } @@ -15355,6 +15407,9 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_coreclient_open_session() != 32134) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_coreclient_open_subagent() != 36931) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_coreclient_org_id() != 50248) { return InitializationResult.apiChecksumMismatch } diff --git a/apps/ios/Zeron/Session/CoreSessionSource.swift b/apps/ios/Zeron/Session/CoreSessionSource.swift index 3e96d3c5c9..1036de2b66 100644 --- a/apps/ios/Zeron/Session/CoreSessionSource.swift +++ b/apps/ios/Zeron/Session/CoreSessionSource.swift @@ -44,6 +44,7 @@ final class CoreSessionSource: SessionSource { next.title = c.title let project = row?.project?.name ?? "No project" next.subtitle = c.host.name.map { "\(project) @ \($0)" } ?? project + if row?.parentChatId != nil { next.subtitle = "Side chat · " + next.subtitle } next.running = c.live.turnRunning next.canSteer = c.host.capabilities.midTurnSteering ?? false next.placeholder = "Message \(row?.harnessLabel ?? "the agent")" diff --git a/apps/ios/Zeron/Session/SessionViewController.swift b/apps/ios/Zeron/Session/SessionViewController.swift index 2958dd82b6..84f5174e15 100644 --- a/apps/ios/Zeron/Session/SessionViewController.swift +++ b/apps/ios/Zeron/Session/SessionViewController.swift @@ -46,22 +46,37 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate let voiceBar = VoiceBarItem(app: app, host: self) self.voiceBar = voiceBar let menu = navigationItem.rightBarButtonItem! - let syncVoice = { [weak self, weak voiceBar] in + // Side chats and agent-spawned chats of this session, once it has any. + let sideChats = UIBarButtonItem(image: UIImage(systemName: "bubble.left.and.bubble.right"), primaryAction: UIAction { [weak self] _ in + self?.showSideChats() + }) + sideChats.accessibilityLabel = "Side chats" + sideChats.accessibilityIdentifier = "side-chats" + let syncBar = { [weak self, weak voiceBar] in guard let self, let voiceBar else { return } - let items = voiceBar.visible ? [menu, voiceBar.item] : [menu] - if self.navigationItem.rightBarButtonItems?.count != items.count { + let children = self.app.children(of: self.chatId).count + sideChats.badge = children > 0 ? .count(children) : nil + var items = [menu] + if voiceBar.visible { items.append(voiceBar.item) } + if children > 0 { items.append(sideChats) } + if self.navigationItem.rightBarButtonItems?.map(ObjectIdentifier.init) != items.map(ObjectIdentifier.init) { self.navigationItem.setRightBarButtonItems(items, animated: self.view.window != nil) } } - voiceToken = app.voice.observe(syncVoice) - hostsToken = app.observe(syncVoice) - syncVoice() + voiceToken = app.voice.observe(syncBar) + hostsToken = app.observe(syncBar) + childrenToken = app.observeWorkspace(syncBar) + syncBar() list.frame = view.bounds list.autoresizingMask = [.flexibleWidth, .flexibleHeight] list.accessibilityIdentifier = "transcript" list.imageLoader = { [weak self] ref, iv in self?.source.loadImage(ref, into: iv) } list.imageFetcher = { [weak self] ref in await self?.source.image(ref) } + list.onOpenSubagent = { [weak self] docId, title, frozen in + guard let self else { return } + self.navigationController?.pushViewController(SubagentViewController(app: self.app, docId: docId, title: title, frozen: frozen), animated: true) + } // Hidden while following (the runway glide and tail spring travel). list.onDistanceFromBottom = { [weak self] d in guard let self else { return } @@ -183,6 +198,7 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate private var voiceBar: VoiceBarItem? private var voiceToken: AnyObject? private var hostsToken: AnyObject? + private var childrenToken: AnyObject? deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } @@ -297,7 +313,10 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate // Leaving mid-edit: give the queued row back and the draft its place. if editingQueueId != nil { endEdit(commit: nil) } saveDraft() - if isMovingFromParent { setAccessory(visible: true) } + // Back to the list — not to the session this one hangs off. + if isMovingFromParent, !MainTabController.hidesAccessory(navigationController?.topViewController) { + setAccessory(visible: true) + } } /// Tear down only once the pop has really happened: an interactive @@ -520,6 +539,24 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate } } + /// This session's side chats in a sheet; picking one opens it on top. + private func showSideChats() { + let list = SideChatsViewController(app: app, parentId: chatId) { [weak self] childId in + guard let self else { return } + self.dismiss(animated: true) + guard let child = SessionViewController(app: self.app, chatId: childId) else { + return Toast.show("Couldn't open session. Try again.", in: self.view.window) + } + self.navigationController?.pushViewController(child, animated: true) + } + let sheet = UINavigationController(rootViewController: list) + if let s = sheet.sheetPresentationController { + s.detents = [.medium(), .large()] + s.prefersGrabberVisible = true + } + present(sheet, animated: true) + } + private func sessionMenu() -> UIMenu { UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in guard let self else { return done([]) } diff --git a/apps/ios/Zeron/Session/SideChatsViewController.swift b/apps/ios/Zeron/Session/SideChatsViewController.swift new file mode 100644 index 0000000000..e2391287d1 --- /dev/null +++ b/apps/ios/Zeron/Session/SideChatsViewController.swift @@ -0,0 +1,43 @@ +import UIKit + +/// One session's side chats — forked from it, or spawned by its agent through +/// the Zeron MCP server — newest first. Shown as a sheet over the session; +/// picking one opens it on top of the session it hangs off. +final class SideChatsViewController: SessionListController { + private let parentId: String + private let onOpen: (String) -> Void + private var childrenToken: AnyObject? + + init(app: AppModel, parentId: String, onOpen: @escaping (String) -> Void) { + self.parentId = parentId + self.onOpen = onOpen + super.init(app: app) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + sideRows = true + super.viewDidLoad() + title = "Side Chats" + navigationItem.largeTitleDisplayMode = .never + navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in + self?.dismiss(animated: true) + }) + collectionView.accessibilityIdentifier = "side-chats-list" + // Children aren't on the front page: follow every workspace revision. + childrenToken = app.observeWorkspace { [weak self] in + guard let self else { return } + self.reload(animated: true) + if self.sessions.isEmpty { self.dismiss(animated: true) } + } + } + + override func buildSections() -> [(id: String, header: String?, folders: [FolderRowVM], sessions: [SessionRowVM])] { + [("children", nil, [], app.children(of: parentId))] + } + + override func openSession(_ id: String) { + onOpen(id) + } +} diff --git a/apps/ios/Zeron/Session/SubagentViewController.swift b/apps/ios/Zeron/Session/SubagentViewController.swift new file mode 100644 index 0000000000..f1a35790f4 --- /dev/null +++ b/apps/ios/Zeron/Session/SubagentViewController.swift @@ -0,0 +1,155 @@ +import UIKit + +/// A subagent's transcript, read-only: the doc a spawn card links to. A +/// running subagent streams live from its parent's host; a finished one +/// reads its frozen copy. No composer — the parent agent drives it. +final class SubagentViewController: UIViewController { + private let app: AppModel + let docId: String + private let subagentTitle: String + private let frozen: Bool + /// The chat whose run spawned this subagent (nested spawns included). + private let parentChatId: String + private var handle: SessionHandle? + private lazy var relay = FrameRelay { [weak self] in self?.applyFrame() } + private lazy var engine = TranscriptView(text: TextEngine.shared, listener: relay) + private lazy var list = TranscriptListView(engine: engine) + private let titleView = SessionTitleView() + private let loader = StatusGlyph(.spinner) + private let empty = UILabel() + private var revealed = false + private var closed = false + + init(app: AppModel, docId: String, title: String, frozen: Bool) { + self.app = app + self.docId = docId + self.subagentTitle = title + self.frozen = frozen + self.parentChatId = docId.components(separatedBy: "--sub--").first ?? docId + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Palette.background + navigationItem.largeTitleDisplayMode = .never + navigationItem.titleView = titleView + let parent = app.row(parentChatId)?.title + titleView.set(title: subagentTitle, subtitle: parent.map { "Subagent · \($0)" } ?? "Subagent") + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: UIMenu(children: [ + UIAction(title: "Copy Transcript", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in + guard let self else { return } + UIPasteboard.general.string = self.engine.frame().plainText() + }, + ])) + + list.frame = view.bounds + list.autoresizingMask = [.flexibleWidth, .flexibleHeight] + list.accessibilityIdentifier = "subagent-transcript" + list.alpha = 0 + let host = app.row(parentChatId)?.deviceId ?? "" + let client = app.client + list.imageLoader = { ref, iv in + iv.accessibilityIdentifier = ref + Task { @MainActor in + guard let data = try? await client?.readAttachment(deviceId: host, path: ref), + let image = await UIImage(data: data)?.byPreparingForDisplay(), + iv.accessibilityIdentifier == ref + else { return } + iv.image = image + } + } + list.imageFetcher = { ref in + guard let data = try? await client?.readAttachment(deviceId: host, path: ref) else { return nil } + return await UIImage(data: data)?.byPreparingForDisplay() + } + // A subagent can spawn its own: they push on top, as from the chat. + list.onOpenSubagent = { [weak self] docId, title, frozen in + guard let self else { return } + self.navigationController?.pushViewController(SubagentViewController(app: self.app, docId: docId, title: title, frozen: frozen), animated: true) + } + view.addSubview(list) + setContentScrollView(list, for: .top) + list.topEdgeEffect.style = .soft + + loader.translatesAutoresizingMaskIntoConstraints = false + loader.transform = CGAffineTransform(scaleX: 1.75, y: 1.75) + loader.accessibilityIdentifier = "subagent-loading" + view.addSubview(loader) + empty.text = "Nothing from this subagent yet." + empty.font = Fonts.ui(.sans, 15) + empty.textColor = Palette.secondary + empty.alpha = 0 + empty.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(empty) + NSLayoutConstraint.activate([ + loader.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loader.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -40), + loader.widthAnchor.constraint(equalToConstant: 12), + loader.heightAnchor.constraint(equalToConstant: 12), + empty.centerXAnchor.constraint(equalTo: view.centerXAnchor), + empty.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -40), + ]) + + Task { @MainActor [weak self] in + guard let self else { return } + guard let handle = await self.app.openSubagent(docId: self.docId, frozen: self.frozen) else { + return self.showEmpty("Couldn't open this subagent.") + } + // Popped while the frozen copy was on its way. + guard !self.closed, let client = self.app.client else { return } + self.handle = handle + handle.setViewAttached(attached: true) + _ = self.engine.attach(client: client, chatId: self.docId) + // Nothing laid out (an empty or unreachable doc): say so eventually + // rather than spin forever. + DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in + guard let self, !self.revealed else { return } + self.showEmpty("Nothing from this subagent yet.") + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + (tabBarController as? MainTabController)?.setAccessoryVisible(false, animated: false) + if closed { + closed = false + handle?.setViewAttached(attached: true) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + guard isMovingFromParent || navigationController == nil else { return } + closed = true + engine.close() + if handle != nil { app.client?.closeSession(chatId: docId) } + } + + private func applyFrame() { + let frame = engine.frame() + list.apply(frame) + guard !revealed, frame.rowCount() > 0 else { return } + revealed = true + list.settleEdgeEffect() + let reveal = { + self.list.alpha = 1 + self.loader.alpha = 0 + self.empty.alpha = 0 + } + if UIAccessibility.isReduceMotionEnabled { return reveal() } + UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseOut, .allowUserInteraction], animations: reveal) + } + + private func showEmpty(_ text: String) { + empty.text = text + UIView.animate(withDuration: 0.18) { + self.loader.alpha = 0 + self.empty.alpha = 1 + } + } +} diff --git a/apps/ios/Zeron/Shell/MainTabController.swift b/apps/ios/Zeron/Shell/MainTabController.swift index eda05c2c03..c77ddce341 100644 --- a/apps/ios/Zeron/Shell/MainTabController.swift +++ b/apps/ios/Zeron/Shell/MainTabController.swift @@ -88,7 +88,13 @@ final class MainTabController: UITabBarController, UITabBarControllerDelegate, A /// its own composer). func syncAccessory(animated: Bool = false) { let top = (selectedTab?.viewController as? UINavigationController)?.topViewController - setAccessoryVisible(!(top is SessionViewController), animated: animated) + setAccessoryVisible(!Self.hidesAccessory(top), animated: animated) + } + + /// Sessions carry their own composer, subagent transcripts sit above one: + /// the accessory steps aside for both. + static func hidesAccessory(_ top: UIViewController?) -> Bool { + top is SessionViewController || top is SubagentViewController } /// Pushed sessions carry their own composer; the accessory steps aside. diff --git a/apps/ios/Zeron/Threads/SessionCell.swift b/apps/ios/Zeron/Threads/SessionCell.swift index 6fd27b2eae..b4808e5969 100644 --- a/apps/ios/Zeron/Threads/SessionCell.swift +++ b/apps/ios/Zeron/Threads/SessionCell.swift @@ -29,6 +29,8 @@ struct SessionRowVM: Hashable { var unseen: Bool var pinned: Bool var sendFailed: Bool + /// Subagents of this session streaming right now (a "● N" pill). + var runningSubagents: Int } /// Folder row ("Pinned 2 ›", "P0 3 ›"). @@ -54,6 +56,9 @@ final class SessionCell: UICollectionViewListCell { private let time = UILabel() private let status = StatusGlyph() private let prBadge = PRBadgeView() + /// "● N": subagents running under this session (the desktop sidebar's + /// running pill). Leads the status, never replaces it. + private let agents = UILabel() private var vm: SessionRowVM? /// The session open beside the sidebar (iPad): a quiet fill. var isCurrent = false { didSet { if isCurrent != oldValue { setNeedsUpdateConfiguration() } } } @@ -64,7 +69,12 @@ final class SessionCell: UICollectionViewListCell { time.textAlignment = .right harness.contentMode = .scaleAspectFit harness.tintColor = Palette.text - for v in [harness, title, meta, time, status, prBadge] as [UIView] { contentView.addSubview(v) } + agents.textAlignment = .center + agents.textColor = StatusTone.working + agents.backgroundColor = Palette.controlFill + agents.layer.cornerCurve = .continuous + agents.clipsToBounds = true + for v in [harness, title, meta, time, status, prBadge, agents] as [UIView] { contentView.addSubview(v) } var bg = UIBackgroundConfiguration.listCell() bg.backgroundColor = .clear backgroundConfiguration = bg @@ -149,7 +159,11 @@ final class SessionCell: UICollectionViewListCell { status.kind = .none status.isHidden = true } - accessibilityLabel = [vm.title, vm.projectName, corner?.word, prBadge.isHidden ? nil : prBadge.accessibilityLabel].compactMap { $0 }.joined(separator: ", ") + agents.isHidden = vm.runningSubagents == 0 + agents.font = Fonts.ui(.sansMedium, TypeScale.size(11.5)) + agents.text = "● \(vm.runningSubagents)" + let agentsLabel = vm.runningSubagents == 0 ? nil : vm.runningSubagents == 1 ? "1 subagent running" : "\(vm.runningSubagents) subagents running" + accessibilityLabel = [vm.title, vm.projectName, corner?.word, agentsLabel, prBadge.isHidden ? nil : prBadge.accessibilityLabel].compactMap { $0 }.joined(separator: ", ") accessibilityIdentifier = "session-\(vm.id)" setNeedsLayout() } @@ -179,6 +193,13 @@ final class SessionCell: UICollectionViewListCell { status.frame = CGRect(x: time.frame.minX - 5 - 12, y: titleY + titleH / 2 - 6, width: 12, height: 12) trailing = status.frame.minX - 10 } + if !agents.isHidden { + let h = (18 * k).rounded() + let w = ceil(agents.sizeThatFits(CGSize(width: 80, height: h)).width) + 12 + agents.frame = CGRect(x: trailing + 4 - w, y: titleY + (titleH - h) / 2, width: w, height: h) + agents.layer.cornerRadius = h / 2 + trailing = agents.frame.minX - 8 + } title.frame = CGRect(x: textX, y: titleY, width: max(0, trailing - textX), height: titleH) let x = textX var metaRight = b.width - right diff --git a/apps/ios/Zeron/Threads/SessionListController.swift b/apps/ios/Zeron/Threads/SessionListController.swift index d950c39943..b928756713 100644 --- a/apps/ios/Zeron/Threads/SessionListController.swift +++ b/apps/ios/Zeron/Threads/SessionListController.swift @@ -21,6 +21,8 @@ class SessionListController: UIViewController, UICollectionViewDelegate { var reorderable = false /// The Archived list: rows offer Unarchive instead of Archive / Pin / Move. var archivedRows = false + /// Another session's side chats: they live under it, so no Pin / Move. + var sideRows = false /// The session open beside this list (iPad sidebar): drawn as current. var currentChatId: String? { didSet { @@ -226,7 +228,7 @@ class SessionListController: UIViewController, UICollectionViewDelegate { } private func leadingSwipe(_ path: IndexPath) -> UISwipeActionsConfiguration? { - guard !archivedRows, let id = sessionId(path), let vm = sessions[id] else { return nil } + guard !archivedRows, !sideRows, let id = sessionId(path), let vm = sessions[id] else { return nil } let pin = UIContextualAction(style: .normal, title: vm.pinned ? "Unpin" : "Pin") { [weak self] _, _, done in self?.app.setPinned(id, !vm.pinned) done(true) @@ -253,6 +255,7 @@ class SessionListController: UIViewController, UICollectionViewDelegate { } archive.image = UIImage(systemName: "archivebox.fill") archive.backgroundColor = Palette.secondary + if sideRows { return UISwipeActionsConfiguration(actions: [archive]) } let move = UIContextualAction(style: .normal, title: "Move") { [weak self] _, view, done in self?.presentMoveMenu(id, from: view) done(true) @@ -275,6 +278,12 @@ class SessionListController: UIViewController, UICollectionViewDelegate { UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { _ in self.rename(id) }, ]) } + if self.sideRows { + return UIMenu(children: [ + UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { _ in self.rename(id) }, + UIAction(title: "Archive", image: UIImage(systemName: "archivebox"), attributes: .destructive) { _ in self.app.archive(id) }, + ]) + } return UIMenu(children: [ UIAction(title: vm.pinned ? "Unpin" : "Pin", image: UIImage(systemName: vm.pinned ? "pin.slash" : "pin")) { _ in self.app.setPinned(id, !vm.pinned) }, self.moveMenu(id), diff --git a/apps/ios/Zeron/Transcript/RowView.swift b/apps/ios/Zeron/Transcript/RowView.swift index 8d4e7b4560..26ab518b26 100644 --- a/apps/ios/Zeron/Transcript/RowView.swift +++ b/apps/ios/Zeron/Transcript/RowView.swift @@ -36,6 +36,7 @@ protocol RowViewDelegate: AnyObject { func rowView(_ view: RowView, toggleDetail detail: UInt64, open: Bool) func rowView(_ view: RowView, open url: URL) func rowView(_ view: RowView, imageFor reference: String, into imageView: UIImageView) + func rowView(_ view: RowView, openSubagent docId: String, title: String, frozen: Bool) } /// A reusable transcript row: canvas + horizontal scrollers + native widgets. @@ -277,6 +278,19 @@ final class RowView: UIView { view = b case .shimmer: view = ShimmerView(model: model!, rect: rect) + case let .subagentLink(docId, title, frozen): + let b = ToolToggleControl() + b.layer.cornerRadius = 10 + b.layer.cornerCurve = .continuous + b.accessibilityTraits = .button + b.accessibilityLabel = "Open subagent: \(title)" + b.accessibilityIdentifier = "subagent-link" + b.addAction(UIAction { [weak self] _ in + guard let self else { return } + UISelectionFeedbackGenerator().selectionChanged() + self.delegate?.rowView(self, openSubagent: docId, title: title, frozen: frozen) + }, for: .touchUpInside) + view = b } view.frame = rect host.addSubview(view) diff --git a/apps/ios/Zeron/Transcript/TranscriptListView.swift b/apps/ios/Zeron/Transcript/TranscriptListView.swift index 514b7d8244..a1e45a0657 100644 --- a/apps/ios/Zeron/Transcript/TranscriptListView.swift +++ b/apps/ios/Zeron/Transcript/TranscriptListView.swift @@ -40,6 +40,9 @@ final class TranscriptListView: UIScrollView, RowViewDelegate, UIScrollViewDeleg var imageLoader: ((String, UIImageView) -> Void)? /// An attachment's picture, for an image chip opened full size. var imageFetcher: ((String) async -> UIImage?)? + /// A subagent spawn card was tapped: open its transcript (doc id, title, + /// finished). + var onOpenSubagent: ((String, String, Bool) -> Void)? // MARK: Runway (desktop transcript.rs `OwnTurnAnchor`) // @@ -597,6 +600,10 @@ final class TranscriptListView: UIScrollView, RowViewDelegate, UIScrollViewDeleg imageLoader?(reference, imageView) } + func rowView(_ view: RowView, openSubagent docId: String, title: String, frozen: Bool) { + onOpenSubagent?(docId, title, frozen) + } + fileprivate func row(at point: CGPoint) -> RowView? { visible.values.first { !$0.isHidden && $0.frame.contains(point) } } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 7da26d0368..9c772fcd51 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -7,7 +7,7 @@ use std::time::Duration; use chrono::{TimeZone, Utc}; use tokio_util::sync::CancellationToken; -use zeron_doc::RegistryDoc; +use zeron_doc::{RegistryDoc, SessionDoc}; use zeron_proto::{Chat, ChatConfig, SidebarPinChange, SidebarSectionChange}; use crate::attachments::{self, AttachmentCache}; @@ -22,7 +22,9 @@ use crate::error::{ClientError, Result}; use crate::events::{ClientEvent, ClientListener, EventPump}; use crate::live::LiveBackend; use crate::rpc::{FolderListing, ProgressFn, QUEUED_ATTACHMENTS_MIN, RepoRef, capability}; -use crate::session::{HostCapabilities, OutgoingAttachment, RoomState, SessionCore, SessionHandle}; +use crate::session::{ + DocSource, HostCapabilities, OutgoingAttachment, RoomState, SessionCore, SessionHandle, +}; use crate::workspace::{SearchHit, WorkspaceSnapshot, WorkspaceStore}; use crate::{lock, now_ms, read, write}; @@ -37,6 +39,16 @@ pub const WARM_SESSION_CAP: usize = 6; /// Sessions `preload_sessions` warms (front page order). pub const PRELOAD_CAP: usize = 4; +/// The chat a subagent doc (`{chatId}--sub--{id}`) belongs to — the +/// top-level chat even for nested spawns (the engine names every subagent +/// doc of a run off the run's own chat). +pub fn subagent_parent(doc_id: &str) -> Option<&str> { + doc_id + .split_once("--sub--") + .map(|(parent, _)| parent) + .filter(|parent| !parent.is_empty()) +} + /// Where a new session runs. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionTarget { @@ -119,6 +131,15 @@ impl ClientInner { lock(&self.sessions).values().cloned().collect() } + /// Register a freshly built core, or return the one a racing open + /// registered first. + fn adopt_core(&self, chat_id: &str, core: Arc) -> Arc { + lock(&self.sessions) + .entry(chat_id.to_owned()) + .or_insert(core) + .clone() + } + pub(crate) fn network_online(&self) -> bool { self.path_online.load(Ordering::Acquire) } @@ -1056,14 +1077,8 @@ impl Client { (local.doc, local.cursor, local.had_content) } }; - let core = SessionCore::new(chat_id, &self.inner, doc, cursor); - let core = { - let mut sessions = lock(&self.inner.sessions); - sessions - .entry(chat_id.to_owned()) - .or_insert_with(|| core.clone()) - .clone() - }; + let core = SessionCore::new(chat_id, &self.inner, doc, cursor, DocSource::Chat); + let core = self.inner.adopt_core(chat_id, core); if hydrated { core.set_hydrated(); } @@ -1089,6 +1104,88 @@ impl Client { Ok(SessionHandle { core }) } + /// Open a subagent's transcript read-only: the doc a spawn chip's + /// `subagent_ref` names (`{chatId}--sub--{id}`). It has no registry row; + /// the parent chat's host serves its chat2 room. A `frozen` (done/failed) + /// subagent reads its uploaded blob first and never wakes the room; any + /// blob failure falls back to the live room. + pub async fn open_subagent(&self, doc_id: &str, frozen: bool) -> Result { + let parent = subagent_parent(doc_id) + .ok_or_else(|| ClientError::InvalidArgument(format!("not a subagent doc: {doc_id}")))?; + if let Some(core) = self.inner.session_core(doc_id) { + return Ok(SessionHandle { core }); + } + let host = self + .inner + .workspace + .chat(parent) + .map(|chat| chat.device_id) + .ok_or_else(|| ClientError::NotFound(parent.to_owned()))?; + let (doc, cursor, source) = match self.inner.backend() { + Backend::Demo(demo) => (demo.session_doc(doc_id)?, 0, DocSource::Frozen), + Backend::Live(live) => { + let blob = if frozen { + self.fetch_subagent_blob(parent, doc_id).await + } else { + None + }; + match blob { + Some(doc) => (doc, 0, DocSource::Frozen), + None => { + let local = crate::live::room::load_local(&live.store, doc_id); + (local.doc, local.cursor, DocSource::Subagent { host }) + } + } + } + }; + let blob_served = source == DocSource::Frozen; + let core = SessionCore::new(doc_id, &self.inner, doc, cursor, source); + let core = self.inner.adopt_core(doc_id, core); + if blob_served { + core.set_hydrated(); + } + core.ensure_room(&self.inner); + core.touch(); + core.refresh(); + self.inner.evict_sessions(); + Ok(SessionHandle { core }) + } + + /// The frozen transcript blob (`blob/{chatId}/{docId}`: a JSON array of + /// entries) as a local doc. `None` on any failure — the upload is + /// best-effort host-side. + async fn fetch_subagent_blob(&self, chat_id: &str, doc_id: &str) -> Option { + let live = self.inner.live()?; + let url = format!( + "{}/blob/{}/{}", + live.edge.trim_end_matches('/'), + crate::live::urls::encode(chat_id), + crate::live::urls::encode(doc_id) + ); + let token = crate::live::LiveBackend::bearer(&self.inner) + .get() + .await + .ok()?; + let response = crate::auth::http() + .get(&url) + .bearer_auth(&token) + .timeout(std::time::Duration::from_secs(20)) + .send() + .await + .ok()?; + if !response.status().is_success() { + tracing::debug!(doc = %doc_id, status = %response.status(), "subagent blob unavailable"); + return None; + } + let entries: Vec = + serde_json::from_slice(&response.bytes().await.ok()?).ok()?; + let doc = SessionDoc::from_doc(loro::LoroDoc::new()); + for entry in &entries { + doc.push_message(entry).ok()?; + } + Some(doc) + } + /// An already-open session, if any. pub fn session(&self, chat_id: &str) -> Option { self.inner diff --git a/crates/client/src/demo/fixtures.rs b/crates/client/src/demo/fixtures.rs index 2a7b9fb750..2921b276fd 100644 --- a/crates/client/src/demo/fixtures.rs +++ b/crates/client/src/demo/fixtures.rs @@ -435,7 +435,8 @@ pub(crate) fn seed( if let Some(status) = demo.status { doc.upsert_session(&Session { last_completed_turn: None, - running_subagents: 0, + // The working chat shows the sidebar's subagent badge. + running_subagents: u32::from(status == SessionStatus::Working) * 2, chat_id: demo.id.into(), device_id: demo.device.into(), status, diff --git a/crates/client/src/demo/transcripts.rs b/crates/client/src/demo/transcripts.rs index e99691811d..37737df219 100644 --- a/crates/client/src/demo/transcripts.rs +++ b/crates/client/src/demo/transcripts.rs @@ -4,6 +4,7 @@ //! groups, questions, errors, attachments), the synthetic big transcripts //! for benchmarks, and the scripted streaming reply. +use zeron_doc::parts::SubagentStatus; use zeron_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry, ToolDiffStat}; use zeron_proto::{TodoItem, TodoStatus, ToolCall, UserInputQuestion}; @@ -62,6 +63,34 @@ fn edit(id: &str, path: &str, additions: u64, deletions: u64) -> MessagePart { part } +/// The finished subagent `chat-tabs` spawned (its chip links here). +pub(crate) const TABS_SUBAGENT: &str = "chat-tabs--sub--toolu_demo_audit"; + +/// A finished subagent spawn chip linking to `doc`. +fn spawn(id: &str, description: &str, doc: &str, tail: &str) -> MessagePart { + let mut part = tool( + id, + ToolCall::Unknown { + name: format!("Agent: {description}"), + input: Some(serde_json::json!({ "subagent_type": "Explore" })), + }, + false, + None, + ); + if let MessagePart::Tool { + subagent_ref, + subagent_status, + subagent_tail, + .. + } = &mut part + { + *subagent_ref = Some(doc.into()); + *subagent_status = Some(SubagentStatus::Done); + *subagent_tail = Some(tail.into()); + } + part +} + fn exec(command: &str) -> ToolCall { ToolCall::Exec { command: command.into(), @@ -408,6 +437,12 @@ fn tabs(host: &str, now: i64) -> Vec { host, now - 950_000, vec![ + spawn( + "k0", + "Find every tool group header color", + TABS_SUBAGENT, + "3 call sites; only the header path picks up danger.", + ), tool( "k1", ToolCall::Search { @@ -439,6 +474,40 @@ fn tabs(host: &str, now: i64) -> Vec { ] } +/// The transcript `chat-tabs`' subagent wrote into its own doc. +fn tabs_subagent(host: &str, now: i64) -> Vec { + vec![ + user( + "s1", + now - 990_000, + "Find every place a tool group header picks its color, and whether a failed child changes it.", + ), + assistant( + "s2", + host, + now - 980_000, + vec![ + tool( + "q1", + ToolCall::Search { + pattern: "group_header_color".into(), + path: Some("crates/ui/src".into()), + }, + false, + Some( + "crates/ui/src/shell/transcript.rs:812\ncrates/ui/src/shell/transcript.rs:1490\ncrates/ui/src/tool_group.rs:64", + ), + ), + tool("q2", read("crates/ui/src/tool_group.rs"), false, None), + text( + "t0", + "Three call sites. Only `tool_group.rs:64` reacts to a failed child: it swaps `text_muted` for `danger` when `any_failed` is set. The other two read the theme's muted role unconditionally.", + ), + ], + ), + ] +} + fn errored(host: &str, now: i64) -> Vec { let mut failed = assistant( "m2", @@ -615,6 +684,7 @@ pub(crate) fn fixture(chat_id: &str, host: &str, last_activity: i64) -> Vec veil(host, crate::now_ms()), "chat-picker" => picker(host, now), "chat-tabs" => tabs(host, now), + TABS_SUBAGENT => tabs_subagent(host, now), "chat-errored" => errored(host, now), "chat-ios-scroll" => scroll(host, now), "chat-home" => home(host, now), diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index e315e554dd..94cbc3f1c3 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -39,7 +39,9 @@ pub mod runtime; pub mod session; pub mod workspace; -pub use client::{Client, NewSession, PRELOAD_CAP, PushPrefs, SessionTarget, WARM_SESSION_CAP}; +pub use client::{ + Client, NewSession, PRELOAD_CAP, PushPrefs, SessionTarget, WARM_SESSION_CAP, subagent_parent, +}; pub use config::{ AuthTokens, ClientConfig, Credentials, DemoFixture, DemoOptions, StreamSpeed, TranscriptScale, }; diff --git a/crates/client/src/session/mod.rs b/crates/client/src/session/mod.rs index f876a3bc1b..e896eaaa7b 100644 --- a/crates/client/src/session/mod.rs +++ b/crates/client/src/session/mod.rs @@ -158,6 +158,18 @@ pub enum QueueEditFinish { Unavailable, } +/// Where a session's doc comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DocSource { + /// A registry chat: its row names the room generation and the host. + Chat, + /// A running subagent's transcript doc (`{chatId}--sub--{id}`): no + /// registry row, always a chat2 room, served by the parent's `host`. + Subagent { host: String }, + /// A finished subagent read from its frozen blob: local only, never dials. + Frozen, +} + /// Mutable per-session state behind one lock. struct CoreState { tracker: Tracker, @@ -180,6 +192,7 @@ struct CoreState { pub(crate) struct SessionCore { pub(crate) chat_id: String, + source: DocSource, client: Weak, /// Live chat2 room (declared before `doc`: it drops — and flushes — first). room: Mutex>>, @@ -208,6 +221,7 @@ impl SessionCore { client: &Arc, doc: SessionDoc, cursor: u64, + source: DocSource, ) -> Arc { let doc = Arc::new(doc); let dirty = Arc::new(Mutex::new(Dirty::all())); @@ -224,6 +238,7 @@ impl SessionCore { let tasks = client.cancel.child_token(); let core = Arc::new(Self { chat_id: chat_id.to_owned(), + source, client: Arc::downgrade(client), room: Mutex::new(None), initial_cursor: cursor, @@ -295,18 +310,25 @@ impl SessionCore { if lock(&self.room).is_some() { return; } - let Some(chat) = client.workspace.chat(&self.chat_id) else { - return; - }; - if chat.room_gen.unwrap_or(1) < 2 { - // A retired legacy (s2) room: nothing to dial. Show what's local - // instead of an endless loader. - let newly = !std::mem::replace(&mut lock(&self.state).hydrated, true); - if newly { - self.schedule_refresh(); + let host_device_id = match &self.source { + DocSource::Frozen => return, + DocSource::Subagent { host } => host.clone(), + DocSource::Chat => { + let Some(chat) = client.workspace.chat(&self.chat_id) else { + return; + }; + if chat.room_gen.unwrap_or(1) < 2 { + // A retired legacy (s2) room: nothing to dial. Show what's + // local instead of an endless loader. + let newly = !std::mem::replace(&mut lock(&self.state).hydrated, true); + if newly { + self.schedule_refresh(); + } + return; + } + chat.device_id } - return; - } + }; let core = Arc::downgrade(self); let on_applied: Arc = Arc::new(move || { if let Some(core) = core.upgrade() { @@ -343,7 +365,7 @@ impl SessionCore { bearer: crate::live::LiveBackend::bearer(client), edge: live.edge.clone(), device_id: client.config.device_id.clone(), - host_device_id: chat.device_id.clone(), + host_device_id, store: live.store.clone(), on_applied, on_status, diff --git a/crates/client/src/workspace/view.rs b/crates/client/src/workspace/view.rs index ccc490b302..cdc5de3c4e 100644 --- a/crates/client/src/workspace/view.rs +++ b/crates/client/src/workspace/view.rs @@ -85,6 +85,9 @@ pub struct SessionRow { pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option, + /// Subagents of this chat streaming right now (staleness-gated like + /// `host_indicator`). + pub running_subagents: u32, /// `last_message_at`, falling back to `created_at` (the sort key). pub last_activity_ms: i64, /// [`relative_time_label`] of `last_activity_ms` at derive time (the 1 Hz @@ -397,6 +400,7 @@ fn hash_row(row: &SessionRow) -> u64 { attention_rank(row.indicator).hash(&mut h); attention_rank(row.host_indicator).hash(&mut h); row.working_since_ms.hash(&mut h); + row.running_subagents.hash(&mut h); row.last_activity_ms.hash(&mut h); row.time_label.hash(&mut h); row.created_at_ms.hash(&mut h); @@ -497,6 +501,7 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc subagent_ref.clone(), + _ => None, + }) + .expect("chat-tabs carries a linked spawn chip"); + let runtime = zeron_client::runtime::shared(); + let sub = runtime + .block_on(client.open_subagent(&doc_id, true)) + .unwrap(); + let entries = sub.snapshot().transcript_messages(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].role, MessageRole::User); + // Opening again reuses the open doc; non-subagent ids are refused. + let again = runtime + .block_on(client.open_subagent(&doc_id, true)) + .unwrap(); + assert_eq!(again.chat_id(), doc_id); + assert!( + runtime + .block_on(client.open_subagent("chat-tabs", true)) + .is_err() + ); +} + #[test] fn sidebar_writes_reorder_the_front_page() { let (client, _dir) = demo(fast()); diff --git a/crates/mobile/src/client_ffi/mod.rs b/crates/mobile/src/client_ffi/mod.rs index 80d8213f11..4000872f98 100644 --- a/crates/mobile/src/client_ffi/mod.rs +++ b/crates/mobile/src/client_ffi/mod.rs @@ -317,6 +317,18 @@ impl CoreClient { Ok(SessionHandle::new(self.client.open_session(&chat_id)?)) } + /// Open a subagent's transcript read-only (a spawn chip's doc id). A + /// `frozen` (finished) subagent reads its uploaded blob first. + pub async fn open_subagent( + &self, + doc_id: String, + frozen: bool, + ) -> CoreResult> { + let client = self.client.clone(); + let handle = on_runtime(async move { client.open_subagent(&doc_id, frozen).await }).await?; + Ok(SessionHandle::new(handle)) + } + /// An already-open session. pub fn session(&self, chat_id: String) -> Option> { self.client.session(&chat_id).map(SessionHandle::new) diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index c62862edae..46b0562524 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -493,6 +493,8 @@ pub struct SessionRow { pub host_indicator: ChatIndicator, /// Run start of the live turn while Working/AwaitingInput. pub working_since_ms: Option, + /// Subagents of this chat streaming right now. + pub running_subagents: u32, pub last_activity_ms: i64, /// `now` / `34m` / `4h` / `2d` at derive time. pub time_label: String, @@ -535,6 +537,7 @@ impl From<&zc::SessionRow> for SessionRow { indicator: r.indicator.into(), host_indicator: r.host_indicator.into(), working_since_ms: r.working_since_ms, + running_subagents: r.running_subagents, last_activity_ms: r.last_activity_ms, time_label: r.time_label.clone(), created_at_ms: r.created_at_ms, diff --git a/crates/mobile/src/layout/display.rs b/crates/mobile/src/layout/display.rs index 89665d0506..820ce02c6e 100644 --- a/crates/mobile/src/layout/display.rs +++ b/crates/mobile/src/layout/display.rs @@ -189,6 +189,9 @@ pub enum WidgetKind { ToolRail { trunk_x: f32, bend: f32, branch_end: f32, row_mid: f32, tops: Vec, heights: Vec }, /// Tap target toggling one tool row's inline detail. ToolToggle { detail: u64, open: bool }, + /// Tap target over a subagent spawn card: opens the subagent's + /// transcript (`doc_id`, read-only). `frozen` = it finished (done/failed). + SubagentLink { doc_id: String, title: String, frozen: bool }, /// Shimmer sweep over the active group's title (text drawn by the canvas /// underneath; the painter re-draws those runs brighter through a moving /// gradient). diff --git a/crates/mobile/src/layout/tools.rs b/crates/mobile/src/layout/tools.rs index 8a3bd8458e..51a57d6064 100644 --- a/crates/mobile/src/layout/tools.rs +++ b/crates/mobile/src/layout/tools.rs @@ -54,6 +54,14 @@ pub(crate) struct ToolLine { pub key: u64, pub open: bool, pub body: Vec, + /// Spawn cards whose subagent doc exists: tapping opens it. + pub link: Option, +} + +pub(crate) struct SubagentLink { + pub doc_id: String, + pub title: String, + pub frozen: bool, } pub(crate) struct ToolGroup { @@ -812,6 +820,13 @@ impl RowBuilder { body.extend(lines_block(ctx, &st, &call_text(call), Some(CALL_WRAP_COLS))); body.extend(result_block(ctx, &st, part)); } + // Spawn cards are links once the engine stamped the + // subagent's doc (desktop `is_spawn_link`). + let link = subagent_ref.as_ref().filter(|_| agents).map(|doc_id| SubagentLink { + doc_id: doc_id.clone(), + title: zeron_proto::view::tool_chip_content(call).1.lines().next().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("Subagent").to_owned(), + frozen: matches!(subagent_status, Some(SubagentStatus::Done | SubagentStatus::Failed)), + }); lines.push(ToolLine { icon: tool_icon(call).to_owned(), label: prepare_plain(ctx, label, if agents { medium } else { st.label }, st.lh, color, WhiteSpace::Pre), @@ -822,6 +837,7 @@ impl RowBuilder { key: dkey, open, body, + link, }); } MessagePart::Reasoning { text, .. } => { @@ -849,6 +865,7 @@ impl RowBuilder { key: dkey, open, body, + link: None, }); } _ => {} @@ -1108,6 +1125,13 @@ fn place_agents(t: &ToolGroup, px: Px, x: f32, y: f32, cw: f32, mut out: Option< let s = d(px, 14.0); o.widget(WidgetKind::Spinner, (x + cw - d(px, 8.0) - s, card_y + (card_h - s) / 2.0, s, s), None); } + if let Some(link) = &line.link { + o.widget( + WidgetKind::SubagentLink { doc_id: link.doc_id.clone(), title: link.title.clone(), frozen: link.frozen }, + (x, card_y, cw, card_h), + None, + ); + } } h } From bf9f01a258dc708b2d43b3fb788f4188c21d1a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 22:17:52 -0300 Subject: [PATCH 2/7] Demo: give the side-chat session a subagent too chat-veil already has a side chat; its CJK turn now spawns a finished subagent (with its own transcript), so one demo session exercises both. Co-authored-by: Claude Opus 5.5 --- crates/client/src/demo/transcripts.rs | 39 +++++++++++++++++++++++++++ crates/client/tests/demo.rs | 34 +++++++++++++++-------- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/crates/client/src/demo/transcripts.rs b/crates/client/src/demo/transcripts.rs index 37737df219..3446fd54c0 100644 --- a/crates/client/src/demo/transcripts.rs +++ b/crates/client/src/demo/transcripts.rs @@ -65,6 +65,9 @@ fn edit(id: &str, path: &str, additions: u64, deletions: u64) -> MessagePart { /// The finished subagent `chat-tabs` spawned (its chip links here). pub(crate) const TABS_SUBAGENT: &str = "chat-tabs--sub--toolu_demo_audit"; +/// The finished subagent `chat-veil` spawned — the chat that also has a +/// side chat. +pub(crate) const VEIL_SUBAGENT: &str = "chat-veil--sub--toolu_demo_graphemes"; /// A finished subagent spawn chip linking to `doc`. fn spawn(id: &str, description: &str, doc: &str, tail: &str) -> MessagePart { @@ -313,6 +316,12 @@ fn veil(host: &str, now: i64) -> Vec { host, now - 1_700_000, vec![ + spawn( + "k0", + "Audit grapheme handling in the veil", + VEIL_SUBAGENT, + "Chunk splits cut ZWJ sequences; snap them to clusters.", + ), tool( "k1", ToolCall::Glob { @@ -474,6 +483,35 @@ fn tabs(host: &str, now: i64) -> Vec { ] } +/// The transcript `chat-veil`'s subagent wrote into its own doc. +fn veil_subagent(host: &str, now: i64) -> Vec { + vec![ + user( + "s1", + now - 1_690_000, + "Check whether veil.rs splits chunks on grapheme cluster boundaries. Report any case that cuts a cluster.", + ), + assistant( + "s2", + host, + now - 1_680_000, + vec![ + tool("q1", read("crates/ui/src/markdown/veil.rs"), false, None), + tool( + "q2", + exec("cargo test -p zeron-ui veil_graphemes"), + true, + Some("assertion failed: split 👩‍💻 at byte 4 (inside a ZWJ sequence)"), + ), + text( + "t0", + "Chunk splits use `char_indices`, so a ZWJ sequence like 👩‍💻 can be cut in half — the failing test above. CJK is fine (one char per cluster). Snap each split with `unicode-segmentation`'s `grapheme_indices` and the test passes.", + ), + ], + ), + ] +} + /// The transcript `chat-tabs`' subagent wrote into its own doc. fn tabs_subagent(host: &str, now: i64) -> Vec { vec![ @@ -685,6 +723,7 @@ pub(crate) fn fixture(chat_id: &str, host: &str, last_activity: i64) -> Vec picker(host, now), "chat-tabs" => tabs(host, now), TABS_SUBAGENT => tabs_subagent(host, now), + VEIL_SUBAGENT => veil_subagent(host, now), "chat-errored" => errored(host, now), "chat-ios-scroll" => scroll(host, now), "chat-home" => home(host, now), diff --git a/crates/client/tests/demo.rs b/crates/client/tests/demo.rs index d97b4676db..7819c971d8 100644 --- a/crates/client/tests/demo.rs +++ b/crates/client/tests/demo.rs @@ -126,18 +126,30 @@ fn spawn_chips_open_their_subagent_transcript() { assert_eq!(ws.session("chat-veil").unwrap().running_subagents, 2); assert_eq!(ws.session("chat-tabs").unwrap().running_subagents, 0); - let parent = client.open_session("chat-tabs").unwrap(); - let doc_id = parent - .snapshot() - .transcript_messages() - .iter() - .flat_map(|entry| entry.parts.iter()) - .find_map(|part| match part { - zeron_client::MessagePart::Tool { subagent_ref, .. } => subagent_ref.clone(), - _ => None, - }) - .expect("chat-tabs carries a linked spawn chip"); + let spawned = |chat_id: &str| { + client + .open_session(chat_id) + .unwrap() + .snapshot() + .transcript_messages() + .iter() + .flat_map(|entry| entry.parts.iter()) + .find_map(|part| match part { + zeron_client::MessagePart::Tool { subagent_ref, .. } => subagent_ref.clone(), + _ => None, + }) + }; + let doc_id = spawned("chat-tabs").expect("chat-tabs carries a linked spawn chip"); + // One chat shows both: a side chat and a subagent. + assert_eq!(ids(ws.children("chat-veil")), ["chat-side"]); + let veil_sub = spawned("chat-veil").expect("chat-veil carries a linked spawn chip"); let runtime = zeron_client::runtime::shared(); + let veil_entries = runtime + .block_on(client.open_subagent(&veil_sub, true)) + .unwrap() + .snapshot() + .transcript_messages(); + assert_eq!(veil_entries.len(), 2); let sub = runtime .block_on(client.open_subagent(&doc_id, true)) .unwrap(); From 8629085ba256d0662b8ef5a8ebc0d062797fef3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 22:32:28 -0300 Subject: [PATCH 3/7] =?UTF-8?q?iOS:=20start=20side=20chats=20=E2=80=94=20e?= =?UTF-8?q?mpty=20or=20forked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session menu and the side chats sheet offer "New Side Chat" and "Fork into Side Chat", as on the desktop. A new side chat is a composer pushed over the session: it runs in the parent's checkout and host (sandbox included) but may pick any harness and model until its first send mints the row, then hands over to the chat in place. A fork asks the source's host to copy its history through the last completed reply. From a side chat, both make a sibling. Co-authored-by: Claude Opus 5.5 --- apps/ios/Zeron/App/AppModel.swift | 37 ++++- .../ios/Zeron/Core/Generated/zeron_core.swift | 47 ++++++- .../Session/NewSessionViewController.swift | 71 ++++++++-- .../Zeron/Session/SessionViewController.swift | 73 +++++++++- .../Session/SideChatsViewController.swift | 10 ++ apps/ios/Zeron/Shell/MainTabController.swift | 8 +- crates/client/src/client.rs | 97 +++++++++++++ crates/client/src/demo/mod.rs | 79 +++++++++++ crates/client/tests/demo.rs | 128 +++++++++++++++++- crates/client/tests/live.rs | 2 + crates/mobile/src/client_ffi/mod.rs | 8 ++ crates/mobile/src/client_ffi/types.rs | 4 + 12 files changed, 537 insertions(+), 27 deletions(-) diff --git a/apps/ios/Zeron/App/AppModel.swift b/apps/ios/Zeron/App/AppModel.swift index 1819fc5ae1..910c7ab98b 100644 --- a/apps/ios/Zeron/App/AppModel.swift +++ b/apps/ios/Zeron/App/AppModel.swift @@ -562,6 +562,27 @@ final class AppModel { } } + /// Fork `sourceId` through its last completed reply into a side chat on + /// its host. Nil (and a toast with the host's reason) when it can't. + @MainActor + func forkSideChat(of sourceId: String) async -> String? { + guard let client else { return nil } + do { + let id = try await client.forkSideChat(sourceId: sourceId) + refreshWorkspace() + return id + } catch { + let message: String = switch error as? CoreError { + case let .HostError(message)?: message + case let .HostUnavailable(message)?: "The host is unreachable (\(message))." + default: "Couldn't fork this session." + } + let window = UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first + Toast.show(message, in: window) + return nil + } + } + // MARK: New session /// Who's signed in, by name (never the WorkOS user / org ids). @@ -646,11 +667,17 @@ final class AppModel { return refs.sorted { $0.current && !$1.current }.map(\.name) } - /// Create the chat, open it, and send the first message. - func createSession(draft: NewSessionDraft, text: String, images: [StagedImage]) -> String? { + /// Create the chat, open it, and send the first message. `sideOf` makes + /// it a side chat of that session (its checkout and host; the draft only + /// picks the model). + func createSession(draft: NewSessionDraft, text: String, images: [StagedImage], sideOf parentId: String? = nil) -> String? { guard let client else { return nil } let target: SessionTarget - if let p = draft.projectId { + if let parentId { + // Ignored by the core for a side chat; the parent's host fills it. + guard let parent = row(parentId) else { return nil } + target = .projectless(deviceId: parent.deviceId) + } else if let p = draft.projectId { target = .project(spaceId: p) } else if let h = draft.hostId { target = .projectless(deviceId: h) @@ -659,10 +686,10 @@ final class AppModel { } let config = ChatConfig(harness: draft.harness, model: draft.model, reasoning: draft.effort, modelOptions: [:], sandbox: .workspaceWrite) do { - let chatId = try client.createSession(newSession: NewSession(target: target, config: config, branch: draft.worktree ? nil : draft.branch, cwd: nil, title: nil)) + let chatId = try client.createSession(newSession: NewSession(target: target, config: config, branch: draft.worktree ? nil : draft.branch, cwd: nil, title: nil, parentChatId: parentId)) let handle = try client.openSession(chatId: chatId) let project = rawProjects.first { $0.id == draft.projectId } - let worktree = draft.worktree ? project.map { WorktreeSpec(repoPath: $0.path, base: draft.branch ?? "HEAD", spaceId: $0.id) } : nil + let worktree = draft.worktree && parentId == nil ? project.map { WorktreeSpec(repoPath: $0.path, base: draft.branch ?? "HEAD", spaceId: $0.id) } : nil _ = try handle.send(request: SendRequest(text: text, attachments: images.map(\.outgoing), worktree: worktree, busy: .queue)) noteDeliveryActivity() refreshWorkspace() diff --git a/apps/ios/Zeron/Core/Generated/zeron_core.swift b/apps/ios/Zeron/Core/Generated/zeron_core.swift index 99d13894b7..199436ca23 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -1076,6 +1076,13 @@ public protocol CoreClientProtocol: AnyObject, Sendable { */ func executionDevices() -> [DeviceView] + /** + * Fork `source_id` through its latest completed response into a side + * chat (a side chat's fork is its sibling). Returns the new chat id; the + * error carries the host's reason (e.g. nothing completed yet). + */ + func forkSideChat(sourceId: String) async throws -> String + /** * Threads page: pinned, sections, recent. */ @@ -1506,6 +1513,27 @@ open func executionDevices() -> [DeviceView] { }) } + /** + * Fork `source_id` through its latest completed response into a side + * chat (a side chat's fork is its sibling). Returns the new chat id; the + * error carries the host's reason (e.g. nothing completed yet). + */ +open func forkSideChat(sourceId: String)async throws -> String { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_zeron_mobile_fn_method_coreclient_fork_side_chat( + self.uniffiCloneHandle(),FfiConverterString.lower(sourceId) + ) + }, + pollFunc: ffi_zeron_mobile_rust_future_poll_rust_buffer, + completeFunc: ffi_zeron_mobile_rust_future_complete_rust_buffer, + freeFunc: ffi_zeron_mobile_rust_future_free_rust_buffer, + liftFunc: FfiConverterString.lift, + errorHandler: FfiConverterTypeCoreError_lift + ) +} + /** * Threads page: pinned, sections, recent. */ @@ -6986,6 +7014,11 @@ public struct NewSession: Equatable, Hashable { */ public var cwd: String? public var title: String? + /** + * A side chat of this chat: runs in its parent's checkout and host + * (`target`, `cwd`, `branch` ignored). + */ + public var parentChatId: String? // Default memberwise initializers are never public by default, so we // declare one manually. @@ -6995,12 +7028,17 @@ public struct NewSession: Equatable, Hashable { */branch: String?, /** * Checkout override (an existing worktree's path). - */cwd: String?, title: String?) { + */cwd: String?, title: String?, + /** + * A side chat of this chat: runs in its parent's checkout and host + * (`target`, `cwd`, `branch` ignored). + */parentChatId: String?) { self.target = target self.config = config self.branch = branch self.cwd = cwd self.title = title + self.parentChatId = parentChatId } @@ -7023,7 +7061,8 @@ public struct FfiConverterTypeNewSession: FfiConverterRustBuffer { config: FfiConverterOptionTypeChatConfig.read(from: &buf), branch: FfiConverterOptionString.read(from: &buf), cwd: FfiConverterOptionString.read(from: &buf), - title: FfiConverterOptionString.read(from: &buf) + title: FfiConverterOptionString.read(from: &buf), + parentChatId: FfiConverterOptionString.read(from: &buf) ) } @@ -7033,6 +7072,7 @@ public struct FfiConverterTypeNewSession: FfiConverterRustBuffer { FfiConverterOptionString.write(value.branch, into: &buf) FfiConverterOptionString.write(value.cwd, into: &buf) FfiConverterOptionString.write(value.title, into: &buf) + FfiConverterOptionString.write(value.parentChatId, into: &buf) } } @@ -15374,6 +15414,9 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_coreclient_execution_devices() != 15578) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_coreclient_fork_side_chat() != 45854) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_coreclient_front_page() != 3792) { return InitializationResult.apiChecksumMismatch } diff --git a/apps/ios/Zeron/Session/NewSessionViewController.swift b/apps/ios/Zeron/Session/NewSessionViewController.swift index 809575e882..7f94892820 100644 --- a/apps/ios/Zeron/Session/NewSessionViewController.swift +++ b/apps/ios/Zeron/Session/NewSessionViewController.swift @@ -58,6 +58,10 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg /// Embedded in the iPad split's main column (no sheet chrome). private let embedded: Bool + /// A side chat of this chat: pushed over it, run in its checkout on its + /// host, so only the model and effort are picked (any harness, until + /// the first send mints the row). + private let sideOf: String? private var backgroundObserver: NSObjectProtocol? deinit { @@ -73,24 +77,51 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg self.focusOnAppear = !embedded self.onCreated = onCreated self.draft = app.lastDraft + self.sideOf = nil super.init(nibName: nil, bundle: nil) // Pick up where the page was left (closed without sending). composer.text = prompt ?? app.newSessionText composer.images = app.newSessionImages } + /// A new, empty side chat of `parentId`, starting on the parent's model. + /// Nothing is written until the first send; leaving first leaves nothing. + init(app: AppModel, sideOf parentId: String, onCreated: @escaping (String, DraftHandoff?) -> Void) { + self.app = app + self.embedded = true + self.focusOnAppear = true + self.onCreated = onCreated + self.sideOf = parentId + let parent = app.row(parentId) + var draft = NewSessionDraft() + draft.harness = parent?.harness ?? draft.harness + draft.model = parent?.model + draft.effort = parent?.reasoning + self.draft = draft + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + } + + private var parentRow: SessionRow? { sideOf.flatMap { app.row($0) } } /// A session was created from this page: nothing to keep. private var created = false /// Closed (swipe down, ✕, or replaced in the iPad column) without /// sending: keep what was typed and picked for next time. private func rememberDraft() { - guard !created else { return } + // A side chat's draft is its own: it never seeds the next new session. + guard !created, sideOf == nil else { return } app.lastDraft = draft app.newSessionText = composer.text app.newSessionImages = composer.images } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // Pushed over a session: the tab accessory stays away. + if sideOf != nil { (tabBarController as? MainTabController)?.setAccessoryVisible(false, animated: false) } + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) rememberDraft() @@ -110,7 +141,13 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg dismissTap.delegate = self view.addGestureRecognizer(dismissTap) view.backgroundColor = Palette.background - title = "New Session" + title = sideOf == nil ? "New Session" : "New Side Chat" + if sideOf != nil { + let titleView = SessionTitleView() + titleView.set(title: "New Side Chat", subtitle: parentRow.map { "Side chat · \($0.title)" } ?? "Side chat") + navigationItem.titleView = titleView + navigationItem.largeTitleDisplayMode = .never + } if !embedded { navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) @@ -120,7 +157,7 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg mark.image = BrandMarks.image(for: draft.harness, side: 34) mark.tintColor = Palette.text mark.contentMode = .center - hero.text = "What are we building?" + hero.text = sideOf == nil ? "What are we building?" : "Ask on the side" hero.numberOfLines = 2 hero.font = Fonts.ui(.sansSemibold, 22) hero.textColor = Palette.text @@ -142,7 +179,12 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg } composer.onChipTap = { _, _ in } composer.mentionSearch = { [weak self] q in - guard let self, let p = self.project else { return [] } + guard let self else { return [] } + if self.sideOf != nil { + guard let parent = self.parentRow, let space = parent.project?.id else { return [] } + return await self.app.searchFiles(deviceId: parent.deviceId, spaceId: space, query: q) + } + guard let p = self.project else { return [] } return await self.app.searchFiles(deviceId: p.device, spaceId: p.id, query: q) } composer.onSend = { [weak self] text, images, _ in @@ -174,8 +216,8 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg fill, composer.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -8), ]) - if draft.projectId == nil, draft.hostId == nil { draft.projectId = app.projectOptions.first?.id } - if draft.projectId == nil, draft.hostId == nil { + if sideOf == nil, draft.projectId == nil, draft.hostId == nil { draft.projectId = app.projectOptions.first?.id } + if sideOf == nil, draft.projectId == nil, draft.hostId == nil { // No projects yet: run on the first reachable host. draft.hostId = app.hostOptions.first(where: \.online)?.id ?? app.hostOptions.first?.id } @@ -234,7 +276,7 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg } private var project: ProjectOption? { app.projectOptions.first { $0.id == draft.projectId } } - private var deviceId: String { project?.device ?? draft.hostId ?? "" } + private var deviceId: String { sideOf != nil ? parentRow?.deviceId ?? "" : project?.device ?? draft.hostId ?? "" } /// Checkouts of the picked project, one per row of the host menu. private var checkouts: [ProjectOption] { @@ -246,7 +288,9 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg private func refreshChips() { var chips: [ComposerChip] = [] - if let p = project { + if sideOf != nil { + // The parent's checkout and host: nothing to pick there. + } else if let p = project { // The project, then the host it runs on. chips.append(ComposerChip(id: "project", title: p.groupName, symbol: nil, icon: ProjectTile.image(name: p.groupName, colorIndex: p.colorIndex))) chips.append(ComposerChip(id: "host", title: p.deviceName, symbol: "desktopcomputer")) @@ -405,6 +449,17 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg /// False when the session couldn't be created (the prompt stays put). private func create(text: String, images: [StagedImage]) -> Bool { + if let parentId = sideOf { + guard let chatId = app.createSession(draft: draft, text: text, images: images, sideOf: parentId) else { + let alert = UIAlertController(title: "Couldn't start the side chat", message: "Its session is no longer available.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + return false + } + created = true + onCreated(chatId, DraftHandoff.capture(from: self, composer: composer, text: text)) + return true + } app.lastDraft = draft guard let chatId = app.createSession(draft: draft, text: text, images: images) else { let alert = UIAlertController(title: "Couldn't start the session", message: "Choose a project or a host that can run it.", preferredStyle: .alert) diff --git a/apps/ios/Zeron/Session/SessionViewController.swift b/apps/ios/Zeron/Session/SessionViewController.swift index 84f5174e15..a60b94cd17 100644 --- a/apps/ios/Zeron/Session/SessionViewController.swift +++ b/apps/ios/Zeron/Session/SessionViewController.swift @@ -516,12 +516,14 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate self.queue.isHidden = c.queue.isEmpty || asking self.queue.configure(c.queue) let editing = self.editingQueueId != nil - self.pill.superview?.isHidden = c.banner == .none && !editing - self.pill.banner = editing ? .editing : c.banner + let banner: SessionChrome.Banner = editing ? .editing : self.forking ? .working(since: nil, word: "Forking into a side chat") : c.banner + self.pill.superview?.isHidden = banner == .none + self.pill.banner = banner self.bottom.layoutIfNeeded() self.view.layoutIfNeeded() } - let structural = (old.questions == nil) != (c.questions == nil) || old.queue.count != c.queue.count || (old.banner == .none) != (c.banner == .none) + let structural = (old.questions == nil) != (c.questions == nil) || old.queue.count != c.queue.count || (old.banner == .none) != (c.banner == .none) || forking != shownForking + shownForking = forking // Panels materialize when their content appears; a first render (the // session reopened with a queue or an open question) shows them at // once — they used to stay dematerialized, so the queue "vanished" @@ -544,11 +546,10 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate let list = SideChatsViewController(app: app, parentId: chatId) { [weak self] childId in guard let self else { return } self.dismiss(animated: true) - guard let child = SessionViewController(app: self.app, chatId: childId) else { - return Toast.show("Couldn't open session. Try again.", in: self.view.window) - } - self.navigationController?.pushViewController(child, animated: true) + self.openSideChat(childId) } + list.onNew = { [weak self] in self?.dismiss(animated: true) { self?.newSideChat() } } + list.onFork = { [weak self] in self?.dismiss(animated: true) { self?.forkSideChat() } } let sheet = UINavigationController(rootViewController: list) if let s = sheet.sheetPresentationController { s.detents = [.medium(), .large()] @@ -557,6 +558,60 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate present(sheet, animated: true) } + private func openSideChat(_ childId: String) { + guard let child = SessionViewController(app: app, chatId: childId) else { + return Toast.show("Couldn't open session. Try again.", in: view.window) + } + navigationController?.pushViewController(child, animated: true) + } + + /// An empty side chat: a composer pushed on top that mints the chat on + /// its first send, then hands over to it in place (as a new session does). + /// From a side chat, it's a sibling. + private func newSideChat() { + let draft = NewSessionViewController(app: app, sideOf: chatId) { [weak self] childId, handoff in + self?.arrive(at: childId, handoff: handoff) + } + navigationController?.pushViewController(draft, animated: true) + } + + /// The draft on top became `childId`: swap the chat in under it and play + /// the handoff. + private func arrive(at childId: String, handoff: DraftHandoff?) { + guard let nav = navigationController, let draft = nav.topViewController as? NewSessionViewController else { return } + guard let child = SessionViewController(app: app, chatId: childId) else { + nav.popViewController(animated: true) + return Toast.show("Couldn't open session. Try again.", in: view.window) + } + UIView.performWithoutAnimation { + nav.setViewControllers(nav.viewControllers.map { $0 === draft ? child : $0 }, animated: false) + nav.view.layoutIfNeeded() + child.prepareArrival() + } + guard let handoff, let window = view.window else { return child.finishArrival() } + DraftHandoffAnimator.run(handoff, into: child, window: window) + } + + /// A fork in flight (one at a time, like the desktop's). + private var forking = false { + didSet { if forking != oldValue { render(animated: true) } } + } + private var shownForking = false + + /// Copy this session's history (through its last completed reply) into a + /// side chat on its host, then open it. From a side chat, a sibling. + private func forkSideChat() { + guard !forking else { return } + forking = true + Task { @MainActor [weak self] in + guard let self else { return } + let forkId = await self.app.forkSideChat(of: self.chatId) + self.forking = false + guard let forkId, self.view.window != nil else { return } + self.openSideChat(forkId) + } + } + private func sessionMenu() -> UIMenu { UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in guard let self else { return done([]) } @@ -567,6 +622,10 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate UIAction(title: "Copy Transcript", image: UIImage(systemName: "doc.on.doc")) { _ in UIPasteboard.general.string = self.engine.frame().plainText() }, + UIMenu(options: .displayInline, children: [ + UIAction(title: "New Side Chat", image: UIImage(systemName: "plus.bubble")) { _ in self.newSideChat() }, + UIAction(title: "Fork into Side Chat", image: UIImage(systemName: "arrow.triangle.branch"), attributes: self.forking ? .disabled : []) { _ in self.forkSideChat() }, + ]), UIAction(title: "Archive", image: UIImage(systemName: "archivebox"), attributes: .destructive) { _ in self.app.archive(self.chatId) // Beside the iPad sidebar there's nothing to pop back to. diff --git a/apps/ios/Zeron/Session/SideChatsViewController.swift b/apps/ios/Zeron/Session/SideChatsViewController.swift index e2391287d1..aeb1b550e7 100644 --- a/apps/ios/Zeron/Session/SideChatsViewController.swift +++ b/apps/ios/Zeron/Session/SideChatsViewController.swift @@ -7,6 +7,9 @@ final class SideChatsViewController: SessionListController { private let parentId: String private let onOpen: (String) -> Void private var childrenToken: AnyObject? + /// "+": a new empty side chat, or a fork of the session. + var onNew: (() -> Void)? + var onFork: (() -> Void)? init(app: AppModel, parentId: String, onOpen: @escaping (String) -> Void) { self.parentId = parentId @@ -24,6 +27,13 @@ final class SideChatsViewController: SessionListController { navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) + let add = UIBarButtonItem(image: UIImage(systemName: "plus"), menu: UIMenu(children: [ + UIAction(title: "New Side Chat", image: UIImage(systemName: "plus.bubble")) { [weak self] _ in self?.onNew?() }, + UIAction(title: "Fork into Side Chat", image: UIImage(systemName: "arrow.triangle.branch")) { [weak self] _ in self?.onFork?() }, + ])) + add.accessibilityLabel = "Add side chat" + add.accessibilityIdentifier = "side-chats-add" + navigationItem.leftBarButtonItem = add collectionView.accessibilityIdentifier = "side-chats-list" // Children aren't on the front page: follow every workspace revision. childrenToken = app.observeWorkspace { [weak self] in diff --git a/apps/ios/Zeron/Shell/MainTabController.swift b/apps/ios/Zeron/Shell/MainTabController.swift index c77ddce341..4f1a141b59 100644 --- a/apps/ios/Zeron/Shell/MainTabController.swift +++ b/apps/ios/Zeron/Shell/MainTabController.swift @@ -91,10 +91,12 @@ final class MainTabController: UITabBarController, UITabBarControllerDelegate, A setAccessoryVisible(!Self.hidesAccessory(top), animated: animated) } - /// Sessions carry their own composer, subagent transcripts sit above one: - /// the accessory steps aside for both. + /// Sessions and a side chat's draft carry their own composer, subagent + /// transcripts sit above one: the accessory steps aside for all of them. + /// (The new-session page is a sheet here; on a tab's stack it's a side + /// chat's draft.) static func hidesAccessory(_ top: UIViewController?) -> Bool { - top is SessionViewController || top is SubagentViewController + top is SessionViewController || top is SubagentViewController || top is NewSessionViewController } /// Pushed sessions carry their own composer; the accessory steps aside. diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 9c772fcd51..83ffafe622 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -68,6 +68,10 @@ pub struct NewSession { /// Checkout override (an existing worktree's path). pub cwd: Option, pub title: Option, + /// A side chat of this chat: `target`, `cwd` and `branch` are ignored — + /// it runs in its parent's checkout on its parent's host. A side chat's + /// own side chats hang off its parent instead (siblings). + pub parent_chat_id: Option, } pub(crate) enum Backend { @@ -728,6 +732,9 @@ impl Client { /// Mint a chat row (born on chat2: `roomGen: 2`). The row is written /// before any session doc opens, so the host never reads it as legacy. pub fn create_session(&self, new: NewSession) -> Result { + if let Some(parent_id) = &new.parent_chat_id { + return self.create_side_chat(parent_id, new.config, new.title); + } let (state, _) = self.inner.workspace.state(); let id = crate::new_id(); let now = Utc::now(); @@ -794,6 +801,96 @@ impl Client { Ok(id) } + /// The chat side chats of `chat_id` hang off: itself, or its parent when + /// it is a side chat (a dangling parent leaves it as the root). + fn side_chat_root(&self, chat_id: &str) -> Result { + let chat = self + .inner + .workspace + .chat(chat_id) + .ok_or_else(|| ClientError::NotFound(chat_id.to_owned()))?; + Ok(chat + .parent_chat_id + .as_deref() + .and_then(|parent| self.inner.workspace.chat(parent)) + .unwrap_or(chat)) + } + + /// An empty side chat, minted the way the desktop's "New side chat" + /// does: the parent's row (host, project, checkout) with a fresh + /// identity. The run config is the caller's pick, but the sandbox stays + /// the parent's. + fn create_side_chat( + &self, + parent_id: &str, + config: Option, + title: Option, + ) -> Result { + let parent = self.side_chat_root(parent_id)?; + let mut chat = parent.clone(); + chat.id = crate::new_id(); + chat.parent_chat_id = Some(parent.id.clone()); + chat.title = title.filter(|t| !t.trim().is_empty()); + chat.archived = false; + chat.created_at = Utc::now(); + chat.last_message_at = None; + chat.last_message_preview = None; + chat.last_seen_at = None; + chat.harness_session_id = None; + chat.harness_session_cwd = None; + chat.room_gen = Some(2); + if let Some(mut config) = config { + if let Some(sandbox) = parent.config.as_ref().map(|c| c.sandbox) { + config.sandbox = sandbox; + } + chat.config = Some(config); + } + let id = chat.id.clone(); + self.inner.registry_write(|doc| doc.upsert_chat(&chat))?; + Ok(id) + } + + /// Fork `source_id` through its latest completed response into a side + /// chat, on the source's host (which copies the history). A side chat's + /// fork lists as its sibling. Fails with the host's reason when there is + /// no completed response yet. + pub async fn fork_side_chat(&self, source_id: &str) -> Result { + let source = self + .inner + .workspace + .chat(source_id) + .ok_or_else(|| ClientError::NotFound(source_id.to_owned()))?; + let parent = self.side_chat_root(source_id)?; + let params = serde_json::json!({ + "chatId": crate::new_id(), + "sourceChatId": source.id, + "parentChatId": parent.id, + }); + let method = zeron_rpc::methods::FORK_SIDE_CHAT; + let reply = self + .inner + .host_rpc(&source.device_id, method, params) + .await + .map_err(|err| match err { + // The host's own words, without the method prefix. + ClientError::HostError(message) => ClientError::HostError( + message + .strip_prefix(&format!("{method}: ")) + .unwrap_or(&message) + .to_owned(), + ), + err => err, + })?; + let chat: Chat = serde_json::from_value(reply) + .map_err(|err| ClientError::HostError(format!("unexpected fork reply: {err}")))?; + // The host published the row; this replica may not have it yet, and + // the caller opens the fork right away. + if self.inner.workspace.chat(&chat.id).is_none() { + self.inner.registry_write(|doc| doc.upsert_chat(&chat))?; + } + Ok(chat.id) + } + fn chat_write( &self, chat_id: &str, diff --git a/crates/client/src/demo/mod.rs b/crates/client/src/demo/mod.rs index 528fb66ad2..782faf46bd 100644 --- a/crates/client/src/demo/mod.rs +++ b/crates/client/src/demo/mod.rs @@ -77,6 +77,9 @@ pub(crate) struct DemoHost { processed: Mutex>, leases: Mutex>, uploads: Mutex>>>, + /// History copied into forked side chats, by chat id (their docs are + /// built from it, like the fixtures). + forks: Mutex>>, rng: Mutex, cancel: Mutex>, } @@ -97,6 +100,7 @@ impl DemoHost { processed: Mutex::new(HashSet::new()), leases: Mutex::new(HashMap::new()), uploads: Mutex::new(HashMap::new()), + forks: Mutex::new(HashMap::new()), rng: Mutex::new(Rng(0x9e37_79b9_7f4a_7c15)), cancel: Mutex::new(None), }) @@ -212,6 +216,12 @@ impl DemoHost { .as_ref() .and_then(|c| c.last_message_at) .map_or_else(now_ms, |t| t.timestamp_millis()); + if let Some(forked) = lock(&self.forks).get(chat_id) { + for entry in forked { + doc.push_message(entry).map_err(doc_err)?; + } + return Ok(doc); + } let entries = match (chat_id, self.options.transcript_scale.turns()) { ("chat-veil", Some(turns)) => { transcripts::synthetic(turns, &host, now_ms() - turns as i64 * 60_000) @@ -764,6 +774,9 @@ impl DemoHost { return Err(ClientError::HostUnavailable(device_id.to_owned())); } tokio::time::sleep(Duration::from_millis(80)).await; + if method == m::FORK_SIDE_CHAT { + return self.fork_side_chat(&client, ¶ms); + } let chat_id = params["chatId"].as_str().unwrap_or_default().to_owned(); let id = params["id"].as_str().unwrap_or_default().to_owned(); let core = client @@ -899,6 +912,72 @@ impl DemoHost { Ok(reply) } + /// The engine's `ForkSideChat`: copy the source's history through its + /// latest completed response (plus the fork seam) into a new child row. + fn fork_side_chat( + &self, + client: &Arc, + params: &serde_json::Value, + ) -> Result { + let field = |key: &str| params[key].as_str().unwrap_or_default().to_owned(); + let (chat_id, source_id) = (field("chatId"), field("sourceChatId")); + let parent_id = Some(field("parentChatId")) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| source_id.clone()); + let source = client + .workspace + .chat(&source_id) + .ok_or_else(|| ClientError::HostError("Source chat no longer exists".into()))?; + let entries = match client.session_core(&source_id) { + Some(core) => core.doc().read_entries().map_err(doc_err)?, + None => self + .session_doc(&source_id)? + .read_entries() + .map_err(doc_err)?, + }; + let boundary = entries + .iter() + .rposition(|e| { + e.role == MessageRole::Assistant && e.status == Some(MessageStatus::Complete) + }) + .ok_or_else(|| { + ClientError::HostError( + "Wait for a completed response before starting a side chat".into(), + ) + })?; + let mut copied = entries[..=boundary].to_vec(); + let marker = format!("fork:{chat_id}"); + copied.push(zeron_doc::SessionMessageEntry { + id: marker.clone(), + role: MessageRole::System, + parts: vec![MessagePart::Fork { + id: marker, + source_chat_id: source.id.clone(), + source_title: source.title.clone().unwrap_or_else(|| "New session".into()), + }], + created_at: now_ms(), + device_id: source.device_id.clone(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }); + lock(&self.forks).insert(chat_id.clone(), copied); + let mut chat = source.clone(); + chat.id = chat_id; + chat.parent_chat_id = Some(parent_id); + chat.title = None; + chat.archived = false; + chat.created_at = Utc::now(); + chat.last_message_at = None; + chat.last_message_preview = None; + chat.last_seen_at = None; + chat.harness_session_id = None; + chat.harness_session_cwd = None; + chat.room_gen = Some(2); + client.registry_write(|doc| doc.upsert_chat(&chat))?; + serde_json::to_value(&chat).map_err(|e| ClientError::Internal(e.to_string())) + } + pub(crate) async fn list_harnesses(&self, _device_id: &str) -> Vec { tokio::time::sleep(Duration::from_millis(100)).await; let mut list = catalog::fallback_harnesses(); diff --git a/crates/client/tests/demo.rs b/crates/client/tests/demo.rs index 7819c971d8..85a449e5b8 100644 --- a/crates/client/tests/demo.rs +++ b/crates/client/tests/demo.rs @@ -7,9 +7,10 @@ use std::time::{Duration, Instant}; use zeron_client::events::NullListener; use zeron_client::{ - ChatIndicator, Client, ClientConfig, Credentials, DemoFixture, DemoOptions, MessageRole, - MessageStatus, SendOutcome, SendRequest, StreamSpeed, TranscriptScale, + ChatConfig, ChatIndicator, Client, ClientConfig, Credentials, DemoFixture, DemoOptions, + MessageRole, MessageStatus, SendOutcome, SendRequest, StreamSpeed, TranscriptScale, }; +use zeron_proto::{HarnessId, SandboxLevel}; fn demo(options: DemoOptions) -> (Client, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); @@ -168,6 +169,128 @@ fn spawn_chips_open_their_subagent_transcript() { ); } +#[test] +fn new_side_chats_inherit_the_checkout_but_pick_their_own_harness() { + let (client, _dir) = demo(fast()); + let config = |harness| ChatConfig { + harness, + model: Some("gpt-5.6".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::ReadOnly, + }; + let side = |parent: &str, harness| { + client + .create_session(zeron_client::NewSession { + // Ignored: a side chat runs where its parent does. + target: zeron_client::SessionTarget::Projectless { + device_id: "dev-vps".into(), + }, + config: Some(config(harness)), + branch: Some("elsewhere".into()), + cwd: Some("/tmp/elsewhere".into()), + title: None, + parent_chat_id: Some(parent.into()), + }) + .unwrap() + }; + let id = side("chat-veil", HarnessId::Codex); + let parent = client.workspace().session("chat-veil").unwrap().clone(); + let row = client.workspace().session(&id).unwrap().clone(); + assert_eq!(row.parent_chat_id.as_deref(), Some("chat-veil")); + assert_eq!(row.device_id, parent.device_id); + assert_eq!(row.cwd, parent.cwd); + assert_eq!(row.branch, parent.branch); + assert_eq!( + row.project.as_ref().map(|p| &p.id), + parent.project.as_ref().map(|p| &p.id) + ); + assert_eq!(row.room_gen, 2); + let picked = client.session_config(&id).unwrap(); + assert_eq!(picked.harness, HarnessId::Codex); + assert_ne!( + Some(picked.harness), + client.session_config("chat-veil").map(|c| c.harness) + ); + // The sandbox is the parent's, not the pick's. + assert_eq!( + picked.sandbox, + client.session_config("chat-veil").unwrap().sandbox + ); + assert!(ids(client.workspace().children("chat-veil")).contains(&id.as_str())); + // A side chat's side chat is its sibling. + let sibling = side(&id, HarnessId::ClaudeCode); + assert_eq!( + client + .workspace() + .session(&sibling) + .unwrap() + .parent_chat_id + .as_deref(), + Some("chat-veil") + ); +} + +#[test] +fn forks_copy_history_through_the_last_completed_reply() { + let (client, _dir) = demo(fast()); + let runtime = zeron_client::runtime::shared(); + // chat-veil is mid-reply: the fork stops at its last completed answer. + let fork = runtime + .block_on(client.fork_side_chat("chat-veil")) + .unwrap(); + let row = client.workspace().session(&fork).unwrap().clone(); + assert_eq!(row.parent_chat_id.as_deref(), Some("chat-veil")); + let entries = client + .open_session(&fork) + .unwrap() + .snapshot() + .transcript_messages(); + let last = entries.last().unwrap(); + assert_eq!(last.role, MessageRole::System); + assert!(matches!( + last.parts[0], + zeron_client::MessagePart::Fork { .. } + )); + assert!( + entries + .iter() + .all(|e| e.status != Some(MessageStatus::Streaming)) + ); + assert_eq!(entries[entries.len() - 2].role, MessageRole::Assistant); + // Forking a side chat makes a sibling. + let sibling = runtime + .block_on(client.fork_side_chat("chat-side")) + .unwrap(); + assert_eq!( + client + .workspace() + .session(&sibling) + .unwrap() + .parent_chat_id + .as_deref(), + Some("chat-veil") + ); + // Nothing completed yet: the host's reason, without the method name. + let empty = client + .create_session(zeron_client::NewSession { + target: zeron_client::SessionTarget::Project { + space_id: "space-edge".into(), + }, + config: None, + branch: None, + cwd: None, + title: None, + parent_chat_id: None, + }) + .unwrap(); + let err = runtime.block_on(client.fork_side_chat(&empty)).unwrap_err(); + assert_eq!( + err.to_string(), + "host error: Wait for a completed response before starting a side chat" + ); +} + #[test] fn sidebar_writes_reorder_the_front_page() { let (client, _dir) = demo(fast()); @@ -405,6 +528,7 @@ fn new_sessions_are_born_on_chat2() { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .unwrap(); let row = client.workspace().session(&id).unwrap().clone(); diff --git a/crates/client/tests/live.rs b/crates/client/tests/live.rs index 9a6cfbfb28..3f51ce70e4 100644 --- a/crates/client/tests/live.rs +++ b/crates/client/tests/live.rs @@ -816,6 +816,7 @@ async fn phone_born_sessions_reach_the_host_before_their_first_command() { branch: None, cwd: None, title: None, + parent_chat_id: None, }) .unwrap(); let session = client.open_session(&chat_id).unwrap(); @@ -982,6 +983,7 @@ async fn immediate_exit_after_first_send_recovers_new_chat_on_each_host_platform branch: None, cwd: None, title: None, + parent_chat_id: None, }) .unwrap(); let session = client.open_session(&chat).unwrap(); diff --git a/crates/mobile/src/client_ffi/mod.rs b/crates/mobile/src/client_ffi/mod.rs index 4000872f98..9a78cb51b9 100644 --- a/crates/mobile/src/client_ffi/mod.rs +++ b/crates/mobile/src/client_ffi/mod.rs @@ -219,6 +219,14 @@ impl CoreClient { Ok(self.client.create_session(new_session.try_into()?)?) } + /// Fork `source_id` through its latest completed response into a side + /// chat (a side chat's fork is its sibling). Returns the new chat id; the + /// error carries the host's reason (e.g. nothing completed yet). + pub async fn fork_side_chat(&self, source_id: String) -> CoreResult { + let client = self.client.clone(); + on_runtime(async move { client.fork_side_chat(&source_id).await }).await + } + pub fn archive_session(&self, chat_id: String) -> CoreResult<()> { Ok(self.client.archive_session(&chat_id)?) } diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index 46b0562524..84dd4d954a 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -859,6 +859,9 @@ pub struct NewSession { /// Checkout override (an existing worktree's path). pub cwd: Option, pub title: Option, + /// A side chat of this chat: runs in its parent's checkout and host + /// (`target`, `cwd`, `branch` ignored). + pub parent_chat_id: Option, } impl TryFrom for zc::NewSession { @@ -876,6 +879,7 @@ impl TryFrom for zc::NewSession { branch: n.branch, cwd: n.cwd, title: n.title, + parent_chat_id: n.parent_chat_id, }) } } From de6a1f680cd3f8d9a8978fd3aae07f33817c3f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 23:00:19 -0300 Subject: [PATCH 4/7] iOS: list subagents beside side chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side chats sheet becomes "Side Chats & Agents": subagents the session's agent is running (status glyph, live tail, model), its side chats, and the subagents that finished. The toolbar badge shows "● N" while subagents run. SessionSnapshot::subagents() reads them off the transcript's spawn chips. The demo's chat-veil gets four of each. Co-authored-by: Claude Opus 5.5 --- apps/ios/Zeron/App/AppModel.swift | 6 + .../ios/Zeron/Core/Generated/zeron_core.swift | 211 ++++++++++++++++++ .../Zeron/Session/SessionViewController.swift | 33 ++- .../Session/SideChatsViewController.swift | 169 ++++++++++++-- .../Zeron/Threads/SessionListController.swift | 11 +- apps/ios/Zeron/Threads/SubagentCell.swift | 98 ++++++++ crates/client/src/demo/fixtures.rs | 43 +++- crates/client/src/demo/transcripts.rs | 171 +++++++++++++- crates/client/src/lib.rs | 3 +- crates/client/src/session/mod.rs | 2 +- crates/client/src/session/snapshot.rs | 78 +++++++ crates/client/tests/demo.rs | 29 ++- crates/mobile/src/client_ffi/session.rs | 49 ++++ 13 files changed, 861 insertions(+), 42 deletions(-) create mode 100644 apps/ios/Zeron/Threads/SubagentCell.swift diff --git a/apps/ios/Zeron/App/AppModel.swift b/apps/ios/Zeron/App/AppModel.swift index 910c7ab98b..45ca89a0b1 100644 --- a/apps/ios/Zeron/App/AppModel.swift +++ b/apps/ios/Zeron/App/AppModel.swift @@ -499,6 +499,12 @@ final class AppModel { (client?.childSessions(parentId: chatId) ?? []).filter { !$0.archived }.map(Self.vm) } + /// Subagents `chatId`'s agent spawned, in spawn order (its open + /// transcript's spawn chips; empty while the session isn't open). + func subagents(of chatId: String) -> [SubagentInfo] { + client?.session(chatId: chatId)?.subagents() ?? [] + } + func sessions(inFolder id: String) -> [SessionRowVM] { id == "archived" ? archived : frontPage.sectionSessions[id] ?? [] } diff --git a/apps/ios/Zeron/Core/Generated/zeron_core.swift b/apps/ios/Zeron/Core/Generated/zeron_core.swift index 199436ca23..f53f62c570 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -3210,6 +3210,12 @@ public protocol SessionHandleProtocol: AnyObject, Sendable { */ func setViewAttached(attached: Bool) + /** + * Subagents spawned in this transcript, in spawn order (pull after + * `SessionChanged`). + */ + func subagents() -> [SubagentInfo] + func transcriptStatus() -> TranscriptStatus } @@ -3523,6 +3529,19 @@ open func setViewAttached(attached: Bool) {try! rustCall() { FfiConverterBool.lower(attached),uniffiCallStatus ) } +} + + /** + * Subagents spawned in this transcript, in spawn order (pull after + * `SessionChanged`). + */ +open func subagents() -> [SubagentInfo] { + return try! FfiConverterSequenceTypeSubagentInfo.lift(try! rustCall() { + uniffiCallStatus in + uniffi_zeron_mobile_fn_method_sessionhandle_subagents( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) } open func transcriptStatus() -> TranscriptStatus { @@ -8820,6 +8839,97 @@ public func FfiConverterTypeStyleDesc_lower(_ value: StyleDesc) -> RustBuffer { } +/** + * A subagent the session's agent spawned (one linked spawn chip). + */ +public struct SubagentInfo: Equatable, Hashable { + /** + * Its transcript doc — `CoreClient.open_subagent`. + */ + public var docId: String + public var title: String + public var state: SubagentState + /** + * One-line live tail of its output. + */ + public var tail: String? + /** + * The model the spawn picked, when it named one. + */ + public var model: String? + public var spawnedAtMs: Int64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Its transcript doc — `CoreClient.open_subagent`. + */docId: String, title: String, state: SubagentState, + /** + * One-line live tail of its output. + */tail: String?, + /** + * The model the spawn picked, when it named one. + */model: String?, spawnedAtMs: Int64) { + self.docId = docId + self.title = title + self.state = state + self.tail = tail + self.model = model + self.spawnedAtMs = spawnedAtMs + } + + + + +} + +#if compiler(>=6) +extension SubagentInfo: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSubagentInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SubagentInfo { + return + try SubagentInfo( + docId: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + state: FfiConverterTypeSubagentState.read(from: &buf), + tail: FfiConverterOptionString.read(from: &buf), + model: FfiConverterOptionString.read(from: &buf), + spawnedAtMs: FfiConverterInt64.read(from: &buf) + ) + } + + public static func write(_ value: SubagentInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.docId, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterTypeSubagentState.write(value.state, into: &buf) + FfiConverterOptionString.write(value.tail, into: &buf) + FfiConverterOptionString.write(value.model, into: &buf) + FfiConverterInt64.write(value.spawnedAtMs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSubagentInfo_lift(_ buf: RustBuffer) throws -> SubagentInfo { + return try FfiConverterTypeSubagentInfo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSubagentInfo_lower(_ value: SubagentInfo) -> RustBuffer { + return FfiConverterTypeSubagentInfo.lower(value) +} + + /** * One positioned piece of text: `text[start..start+len]` (UTF-16) drawn with * `style` so its origin sits at (`x`, `baseline`). @@ -12414,6 +12524,79 @@ public func FfiConverterTypeStreamSpeed_lower(_ value: StreamSpeed) -> RustBuffe +public enum SubagentState: Equatable, Hashable { + + case running + case done + case failed + + + + + +} + +#if compiler(>=6) +extension SubagentState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSubagentState: FfiConverterRustBuffer { + typealias SwiftType = SubagentState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SubagentState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .running + + case 2: return .done + + case 3: return .failed + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: SubagentState, into buf: inout [UInt8]) { + switch value { + + + case .running: + writeInt(&buf, Int32(1)) + + + case .done: + writeInt(&buf, Int32(2)) + + + case .failed: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSubagentState_lift(_ buf: RustBuffer) throws -> SubagentState { + return try FfiConverterTypeSubagentState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSubagentState_lower(_ value: SubagentState) -> RustBuffer { + return FfiConverterTypeSubagentState.lower(value) +} + + + + public enum TranscriptScale: Equatable, Hashable { case normal @@ -14757,6 +14940,31 @@ fileprivate struct FfiConverterSequenceTypeStyleDesc: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeSubagentInfo: FfiConverterRustBuffer { + typealias SwiftType = [SubagentInfo] + + public static func write(_ value: [SubagentInfo], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeSubagentInfo.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [SubagentInfo] { + let len: Int32 = try readInt(&buf) + var seq = [SubagentInfo]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeSubagentInfo.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -15609,6 +15817,9 @@ private let initializationResult: InitializationResult = { if (uniffi_zeron_mobile_checksum_method_sessionhandle_set_view_attached() != 11843) { return InitializationResult.apiChecksumMismatch } + if (uniffi_zeron_mobile_checksum_method_sessionhandle_subagents() != 6011) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_zeron_mobile_checksum_method_sessionhandle_transcript_status() != 12910) { return InitializationResult.apiChecksumMismatch } diff --git a/apps/ios/Zeron/Session/SessionViewController.swift b/apps/ios/Zeron/Session/SessionViewController.swift index a60b94cd17..ab038d8819 100644 --- a/apps/ios/Zeron/Session/SessionViewController.swift +++ b/apps/ios/Zeron/Session/SessionViewController.swift @@ -46,19 +46,34 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate let voiceBar = VoiceBarItem(app: app, host: self) self.voiceBar = voiceBar let menu = navigationItem.rightBarButtonItem! - // Side chats and agent-spawned chats of this session, once it has any. + // This session's side chats and subagents, once it has any. While + // subagents run, the badge is the sidebar's "● N"; otherwise a quiet + // count (the default red reads as unread). let sideChats = UIBarButtonItem(image: UIImage(systemName: "bubble.left.and.bubble.right"), primaryAction: UIAction { [weak self] _ in self?.showSideChats() }) - sideChats.accessibilityLabel = "Side chats" + sideChats.accessibilityLabel = "Side chats and agents" sideChats.accessibilityIdentifier = "side-chats" let syncBar = { [weak self, weak voiceBar] in guard let self, let voiceBar else { return } - let children = self.app.children(of: self.chatId).count - sideChats.badge = children > 0 ? .count(children) : nil + let agents = self.app.subagents(of: self.chatId) + let running = agents.filter { $0.state == .running }.count + let total = self.app.children(of: self.chatId).count + agents.count + var badge: UIBarButtonItem.Badge? + if running > 0 { + badge = .string("● \(running)") + badge?.backgroundColor = Palette.accent + badge?.foregroundColor = .white + } else if total > 0 { + badge = .count(total) + badge?.backgroundColor = Palette.secondary + badge?.foregroundColor = Palette.background + } + if sideChats.badge != badge { sideChats.badge = badge } + sideChats.accessibilityValue = running > 0 ? "\(running) running" : nil var items = [menu] if voiceBar.visible { items.append(voiceBar.item) } - if children > 0 { items.append(sideChats) } + if total > 0 { items.append(sideChats) } if self.navigationItem.rightBarButtonItems?.map(ObjectIdentifier.init) != items.map(ObjectIdentifier.init) { self.navigationItem.setRightBarButtonItems(items, animated: self.view.window != nil) } @@ -66,6 +81,8 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate voiceToken = app.voice.observe(syncBar) hostsToken = app.observe(syncBar) childrenToken = app.observeWorkspace(syncBar) + // Spawn chips land and settle with the transcript. + agentsToken = app.observeSession(chatId, syncBar) syncBar() list.frame = view.bounds @@ -199,6 +216,7 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate private var voiceToken: AnyObject? private var hostsToken: AnyObject? private var childrenToken: AnyObject? + private var agentsToken: AnyObject? deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } @@ -550,6 +568,11 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate } list.onNew = { [weak self] in self?.dismiss(animated: true) { self?.newSideChat() } } list.onFork = { [weak self] in self?.dismiss(animated: true) { self?.forkSideChat() } } + list.onOpenAgent = { [weak self] agent in + guard let self else { return } + self.dismiss(animated: true) + self.navigationController?.pushViewController(SubagentViewController(app: self.app, docId: agent.docId, title: agent.title, frozen: agent.state != .running), animated: true) + } let sheet = UINavigationController(rootViewController: list) if let s = sheet.sheetPresentationController { s.detents = [.medium(), .large()] diff --git a/apps/ios/Zeron/Session/SideChatsViewController.swift b/apps/ios/Zeron/Session/SideChatsViewController.swift index aeb1b550e7..54ebc01d89 100644 --- a/apps/ios/Zeron/Session/SideChatsViewController.swift +++ b/apps/ios/Zeron/Session/SideChatsViewController.swift @@ -1,28 +1,49 @@ import UIKit -/// One session's side chats — forked from it, or spawned by its agent through -/// the Zeron MCP server — newest first. Shown as a sheet over the session; -/// picking one opens it on top of the session it hangs off. -final class SideChatsViewController: SessionListController { +/// Everything hanging off one session, in a sheet over it: the subagents +/// its agent is running (live tail), its side chats (forked, started by hand, +/// or spawned through the Zeron MCP server), and the subagents that finished. +/// Picking a row opens it on top of the session. +final class SideChatsViewController: UIViewController, UICollectionViewDelegate { + private enum Section: String { + case running, chats, finished + } + + private enum Item: Hashable { + case agent(String) + case chat(String) + } + + private let app: AppModel private let parentId: String private let onOpen: (String) -> Void - private var childrenToken: AnyObject? + /// A subagent row: its transcript (read-only). + var onOpenAgent: ((SubagentInfo) -> Void)? /// "+": a new empty side chat, or a fork of the session. var onNew: (() -> Void)? var onFork: (() -> Void)? + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var chats: [String: SessionRowVM] = [:] + private var agents: [String: SubagentInfo] = [:] + private var counts: [Section: Int] = [:] + private var workspaceToken: AnyObject? + private var sessionToken: AnyObject? + init(app: AppModel, parentId: String, onOpen: @escaping (String) -> Void) { + self.app = app self.parentId = parentId self.onOpen = onOpen - super.init(app: app) + super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError() } override func viewDidLoad() { - sideRows = true super.viewDidLoad() - title = "Side Chats" + view.backgroundColor = Palette.background + title = "Side Chats & Agents" navigationItem.largeTitleDisplayMode = .never navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) @@ -34,20 +55,134 @@ final class SideChatsViewController: SessionListController { add.accessibilityLabel = "Add side chat" add.accessibilityIdentifier = "side-chats-add" navigationItem.leftBarButtonItem = add + + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + config.showsSeparators = false + config.headerMode = .supplementary + config.trailingSwipeActionsConfigurationProvider = { [weak self] path in self?.trailingSwipe(path) } + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UICollectionViewCompositionalLayout.list(using: config)) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = .clear + collectionView.delegate = self collectionView.accessibilityIdentifier = "side-chats-list" - // Children aren't on the front page: follow every workspace revision. - childrenToken = app.observeWorkspace { [weak self] in - guard let self else { return } - self.reload(animated: true) - if self.sessions.isEmpty { self.dismiss(animated: true) } + view.addSubview(collectionView) + + let chatReg = UICollectionView.CellRegistration { [weak self] cell, _, id in + guard let vm = self?.chats[id] else { return } + cell.configure(vm) + } + let agentReg = UICollectionView.CellRegistration { [weak self] cell, _, id in + guard let agent = self?.agents[id] else { return } + cell.configure(agent) + } + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { cv, path, item in + switch item { + case let .chat(id): cv.dequeueConfiguredReusableCell(using: chatReg, for: path, item: id) + case let .agent(id): cv.dequeueConfiguredReusableCell(using: agentReg, for: path, item: id) + } + } + let headerReg = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] cell, _, path in + guard let self, let section = self.dataSource.sectionIdentifier(for: path.section) else { return } + var c = UIListContentConfiguration.plainHeader() + let name = switch section { + case .running: "Running" + case .chats: "Side chats" + case .finished: "Finished agents" + } + c.text = "\(name) · \(self.counts[section] ?? 0)" + c.textProperties.font = Fonts.ui(.sansSemibold, 13) + c.textProperties.color = Palette.secondary + c.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 14, leading: 20, bottom: 4, trailing: 20) + cell.contentConfiguration = c + cell.backgroundConfiguration = .clear() + } + dataSource.supplementaryViewProvider = { cv, _, path in + cv.dequeueConfiguredReusableSupplementary(using: headerReg, for: path) + } + + reload(animated: false) + // Side chats come with workspace revisions; subagents (and their live + // tails) with the session's transcript. + workspaceToken = app.observeWorkspace { [weak self] in self?.reload(animated: true) } + sessionToken = app.observeSession(parentId) { [weak self] in self?.reload(animated: true) } + } + + private func reload(animated: Bool) { + let chatRows = app.children(of: parentId) + let agentRows = app.subagents(of: parentId) + if chatRows.isEmpty, agentRows.isEmpty, view.window != nil { + return dismiss(animated: true) + } + // Running first, newest spawn on top; finished ones newest first too. + let running = agentRows.filter { $0.state == .running }.reversed() + let finished = agentRows.filter { $0.state != .running }.reversed() + var changed: [Item] = [] + var nextChats: [String: SessionRowVM] = [:] + var nextAgents: [String: SubagentInfo] = [:] + for r in chatRows { + if let old = chats[r.id], old != r { changed.append(.chat(r.id)) } + nextChats[r.id] = r + } + for a in agentRows { + if let old = agents[a.docId], old != a { changed.append(.agent(a.docId)) } + nextAgents[a.docId] = a } + let nextCounts: [Section: Int] = [.running: running.count, .chats: chatRows.count, .finished: finished.count] + var snapshot = NSDiffableDataSourceSnapshot() + for (section, items) in [(Section.running, running.map { Item.agent($0.docId) }), (.chats, chatRows.map { Item.chat($0.id) }), (.finished, finished.map { Item.agent($0.docId) })] where !items.isEmpty { + snapshot.appendSections([section]) + snapshot.appendItems(items, toSection: section) + } + let present = Set(snapshot.itemIdentifiers) + snapshot.reconfigureItems(changed.filter { present.contains($0) }) + let recounted = nextCounts != counts + chats = nextChats + agents = nextAgents + counts = nextCounts + if recounted { snapshot.reloadSections(snapshot.sectionIdentifiers) } + dataSource.apply(snapshot, animatingDifferences: animated && view.window != nil) } - override func buildSections() -> [(id: String, header: String?, folders: [FolderRowVM], sessions: [SessionRowVM])] { - [("children", nil, [], app.children(of: parentId))] + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + switch dataSource.itemIdentifier(for: indexPath) { + case let .chat(id)?: onOpen(id) + case let .agent(id)?: if let agent = agents[id] { onOpenAgent?(agent) } + case nil: break + } } - override func openSession(_ id: String) { - onOpen(id) + /// Side chats archive away; subagents belong to the transcript. + private func trailingSwipe(_ path: IndexPath) -> UISwipeActionsConfiguration? { + guard case let .chat(id)? = dataSource.itemIdentifier(for: path) else { return nil } + let archive = UIContextualAction(style: .destructive, title: "Archive") { [weak self] _, _, done in + self?.app.archive(id) + done(true) + } + archive.image = UIImage(systemName: "archivebox.fill") + archive.backgroundColor = Palette.secondary + return UISwipeActionsConfiguration(actions: [archive]) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { + guard let path = indexPaths.first, case let .chat(id)? = dataSource.itemIdentifier(for: path) else { return nil } + return UIContextMenuConfiguration(identifier: id as NSString, previewProvider: nil) { [weak self] _ in + UIMenu(children: [ + UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { _ in self?.rename(id) }, + UIAction(title: "Archive", image: UIImage(systemName: "archivebox"), attributes: .destructive) { _ in self?.app.archive(id) }, + ]) + } + } + + private func rename(_ id: String) { + let alert = UIAlertController(title: "Rename Session", message: nil, preferredStyle: .alert) + alert.addTextField { [weak self] in $0.text = self?.chats[id]?.title } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Rename", style: .default) { [weak self] _ in + let title = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" + if !title.isEmpty { self?.app.rename(id, title) } + }) + present(alert, animated: true) } } diff --git a/apps/ios/Zeron/Threads/SessionListController.swift b/apps/ios/Zeron/Threads/SessionListController.swift index b928756713..d950c39943 100644 --- a/apps/ios/Zeron/Threads/SessionListController.swift +++ b/apps/ios/Zeron/Threads/SessionListController.swift @@ -21,8 +21,6 @@ class SessionListController: UIViewController, UICollectionViewDelegate { var reorderable = false /// The Archived list: rows offer Unarchive instead of Archive / Pin / Move. var archivedRows = false - /// Another session's side chats: they live under it, so no Pin / Move. - var sideRows = false /// The session open beside this list (iPad sidebar): drawn as current. var currentChatId: String? { didSet { @@ -228,7 +226,7 @@ class SessionListController: UIViewController, UICollectionViewDelegate { } private func leadingSwipe(_ path: IndexPath) -> UISwipeActionsConfiguration? { - guard !archivedRows, !sideRows, let id = sessionId(path), let vm = sessions[id] else { return nil } + guard !archivedRows, let id = sessionId(path), let vm = sessions[id] else { return nil } let pin = UIContextualAction(style: .normal, title: vm.pinned ? "Unpin" : "Pin") { [weak self] _, _, done in self?.app.setPinned(id, !vm.pinned) done(true) @@ -255,7 +253,6 @@ class SessionListController: UIViewController, UICollectionViewDelegate { } archive.image = UIImage(systemName: "archivebox.fill") archive.backgroundColor = Palette.secondary - if sideRows { return UISwipeActionsConfiguration(actions: [archive]) } let move = UIContextualAction(style: .normal, title: "Move") { [weak self] _, view, done in self?.presentMoveMenu(id, from: view) done(true) @@ -278,12 +275,6 @@ class SessionListController: UIViewController, UICollectionViewDelegate { UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { _ in self.rename(id) }, ]) } - if self.sideRows { - return UIMenu(children: [ - UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { _ in self.rename(id) }, - UIAction(title: "Archive", image: UIImage(systemName: "archivebox"), attributes: .destructive) { _ in self.app.archive(id) }, - ]) - } return UIMenu(children: [ UIAction(title: vm.pinned ? "Unpin" : "Pin", image: UIImage(systemName: vm.pinned ? "pin.slash" : "pin")) { _ in self.app.setPinned(id, !vm.pinned) }, self.moveMenu(id), diff --git a/apps/ios/Zeron/Threads/SubagentCell.swift b/apps/ios/Zeron/Threads/SubagentCell.swift new file mode 100644 index 0000000000..99e847e3e2 --- /dev/null +++ b/apps/ios/Zeron/Threads/SubagentCell.swift @@ -0,0 +1,98 @@ +import UIKit + +/// A subagent row in a session's side chats sheet. Its status glyph stands +/// where a side chat shows its harness mark, so the two read apart at a +/// glance: +/// +/// ⠿ Audit the reduced-motion fallback ··········· Haiku +/// Reading RowView.swift… (running: live tail) +/// ✓ Check CJK line breaking ······················ 12m +final class SubagentCell: UICollectionViewListCell { + static func height(running: Bool) -> CGFloat { ((running ? 62 : 46) * TypeScale.factor).rounded() } + + private let glyph = StatusGlyph() + private let title = FadingLabel() + private let tail = FadingLabel() + private let trailing = UILabel() + private var running = false + + override init(frame: CGRect) { + super.init(frame: frame) + title.textColor = Palette.text + tail.textColor = Palette.secondary + trailing.textAlignment = .right + for v in [glyph, title, tail, trailing] as [UIView] { contentView.addSubview(v) } + var bg = UIBackgroundConfiguration.listCell() + bg.backgroundColor = .clear + backgroundConfiguration = bg + } + + required init?(coder: NSCoder) { fatalError() } + + override func updateConfiguration(using state: UICellConfigurationState) { + var bg = UIBackgroundConfiguration.listCell().updated(for: state) + bg.backgroundColor = state.isHighlighted || state.isSelected ? Palette.controlFill : .clear + bg.cornerRadius = 16 + bg.backgroundInsets = NSDirectionalEdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8) + backgroundConfiguration = bg + } + + override func preferredLayoutAttributesFitting(_ attrs: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + attrs.size.height = Self.height(running: running) + return attrs + } + + func configure(_ agent: SubagentInfo) { + running = agent.state == .running + let word: String + switch agent.state { + case .running: + glyph.kind = .spinner + word = "Running" + case .done: + glyph.kind = .check(StatusTone.done) + word = "Done" + case .failed: + glyph.kind = .dot(StatusTone.failed) + word = "Failed" + } + title.text = agent.title + title.font = Fonts.ui(.sansMedium, TypeScale.size(running ? 16.5 : 15.5)) + title.textColor = running ? Palette.text : Palette.text.withAlphaComponent(0.88) + tail.font = Fonts.ui(.sans, TypeScale.size(13.5)) + tail.text = agent.tail + tail.isHidden = !running || agent.tail == nil + // Running: which model it runs on. Finished: when it was spawned. + let model = agent.model.map { $0.prefix(1).uppercased() + $0.dropFirst() } + trailing.font = Fonts.ui(.sansMedium, TypeScale.size(13)) + if running { + trailing.text = model + trailing.textColor = Palette.secondary + } else { + trailing.text = relativeTimeLabel(atMs: agent.spawnedAtMs, nowMs: Int64(Date().timeIntervalSince1970 * 1000)) + trailing.textColor = agent.state == .failed ? StatusTone.failed : StatusTone.time + } + accessibilityLabel = ["Subagent", agent.title, word, model, running ? agent.tail : nil].compactMap { $0 }.joined(separator: ", ") + accessibilityTraits = .button + accessibilityIdentifier = "subagent-\(agent.docId)" + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + let b = contentView.bounds + let k = TypeScale.factor + let left: CGFloat = 20 + let right: CGFloat = 20 + let titleH = (22 * k).rounded() + let titleY = running ? (10 * k).rounded() : (b.height - titleH) / 2 + let mark = SessionCell.markSide + glyph.frame = CGRect(x: left + (mark - 12) / 2, y: titleY + titleH / 2 - 6, width: 12, height: 12) + let textX = left + mark + 14 + let tw = trailing.text == nil ? 0 : ceil(trailing.sizeThatFits(CGSize(width: 120, height: 40)).width) + trailing.frame = CGRect(x: b.width - right - tw, y: titleY, width: tw, height: titleH) + let titleRight = tw > 0 ? trailing.frame.minX - 10 : b.width - right + title.frame = CGRect(x: textX, y: titleY, width: max(0, titleRight - textX), height: titleH) + tail.frame = CGRect(x: textX, y: (35 * k).rounded(), width: max(0, b.width - right - textX), height: (18 * k).rounded()) + } +} diff --git a/crates/client/src/demo/fixtures.rs b/crates/client/src/demo/fixtures.rs index 2921b276fd..937d61b0e8 100644 --- a/crates/client/src/demo/fixtures.rs +++ b/crates/client/src/demo/fixtures.rs @@ -206,6 +206,45 @@ pub(crate) fn chats() -> Vec { "α = 0.2 over inter-append gaps", ) }, + // chat-veil's other side chats: it shows four of each (side chats, + // running and finished subagents). + DemoChat { + parent: Some("chat-veil"), + harness: Codex, + model: "gpt-6-astra", + last_ago_ms: 4 * MIN, + ..chat( + "chat-side-2", + Some("space-zeron"), + MAC, + "Veil under Reduce Motion", + "Rows render settled at once", + ) + }, + DemoChat { + parent: Some("chat-veil"), + last_ago_ms: 12 * MIN, + ..chat( + "chat-side-3", + Some("space-zeron"), + MAC, + "Fade curve vs. desktop", + "Same ease-out, 220 ms on both", + ) + }, + DemoChat { + parent: Some("chat-veil"), + harness: Codex, + model: "gpt-6-astra", + last_ago_ms: 2 * HOUR, + ..chat( + "chat-side-4", + Some("space-zeron"), + MAC, + "Changelog line for the veil", + "Streaming replies now fade in", + ) + }, DemoChat { archived: true, last_ago_ms: 3 * DAY, @@ -435,8 +474,8 @@ pub(crate) fn seed( if let Some(status) = demo.status { doc.upsert_session(&Session { last_completed_turn: None, - // The working chat shows the sidebar's subagent badge. - running_subagents: u32::from(status == SessionStatus::Working) * 2, + // chat-veil's live turn runs four subagents (its spawn chips). + running_subagents: if demo.id == "chat-veil" { 4 } else { 0 }, chat_id: demo.id.into(), device_id: demo.device.into(), status, diff --git a/crates/client/src/demo/transcripts.rs b/crates/client/src/demo/transcripts.rs index 3446fd54c0..f45ad2bb2d 100644 --- a/crates/client/src/demo/transcripts.rs +++ b/crates/client/src/demo/transcripts.rs @@ -71,13 +71,30 @@ pub(crate) const VEIL_SUBAGENT: &str = "chat-veil--sub--toolu_demo_graphemes"; /// A finished subagent spawn chip linking to `doc`. fn spawn(id: &str, description: &str, doc: &str, tail: &str) -> MessagePart { + spawn_as(id, description, doc, tail, SubagentStatus::Done, None) +} + +/// A subagent spawn chip linking to `doc`, in `status`, on `model` when the +/// spawn named one. +fn spawn_as( + id: &str, + description: &str, + doc: &str, + tail: &str, + status: SubagentStatus, + model: Option<&str>, +) -> MessagePart { + let mut input = serde_json::json!({ "subagent_type": "Explore" }); + if let Some(model) = model { + input["model"] = model.into(); + } let mut part = tool( id, ToolCall::Unknown { name: format!("Agent: {description}"), - input: Some(serde_json::json!({ "subagent_type": "Explore" })), + input: Some(input), }, - false, + status == SubagentStatus::Failed, None, ); if let MessagePart::Tool { @@ -88,12 +105,132 @@ fn spawn(id: &str, description: &str, doc: &str, tail: &str) -> MessagePart { } = &mut part { *subagent_ref = Some(doc.into()); - *subagent_status = Some(SubagentStatus::Done); + *subagent_status = Some(status); *subagent_tail = Some(tail.into()); } + if status == SubagentStatus::Running + && let MessagePart::Tool { resolved, .. } = &mut part + { + *resolved = false; + } part } +/// `chat-veil`'s other subagents (beside [`VEIL_SUBAGENT`]): four still +/// running in its live turn, three finished in earlier ones. +struct DemoAgent { + /// Doc id suffix after `chat-veil--sub--toolu_demo_`. + key: &'static str, + task: &'static str, + status: SubagentStatus, + model: Option<&'static str>, + /// Live tail (running) / last line (finished). + tail: &'static str, + /// Its full answer (finished) — running ones stream their tail. + reply: &'static str, +} + +const VEIL_AGENTS: [DemoAgent; 7] = [ + DemoAgent { + key: "chunks", + task: "Map how the veil splits chunks", + status: SubagentStatus::Done, + model: Some("haiku"), + tail: "Splits on append boundaries; spans keep their own start time.", + reply: "Each append becomes one chunk span with its own start time; the veil fades spans independently, so a slow token never holds back the text before it.", + }, + DemoAgent { + key: "cost", + task: "Measure the veil's paint cost", + status: SubagentStatus::Failed, + model: None, + tail: "xctrace: no device found — run it on a simulator.", + reply: "Couldn't record: `xctrace record` found no device to attach to. Run the Time Profiler template against a booted simulator instead.", + }, + DemoAgent { + key: "cjk", + task: "Check CJK line breaking", + status: SubagentStatus::Done, + model: Some("haiku"), + tail: "Breaks fall between ideographs; no cluster is split.", + reply: "Line breaks fall between ideographs, and kinsoku rules keep closing punctuation off line starts. No grapheme cluster is split across a break.", + }, + DemoAgent { + key: "tests", + task: "Run the veil test matrix", + status: SubagentStatus::Running, + model: None, + tail: "cargo test -p zeron-ui veil — 9 of 14 passed so far", + reply: "", + }, + DemoAgent { + key: "screens", + task: "Screenshot the veil on iPhone and iPad", + status: SubagentStatus::Running, + model: Some("haiku"), + tail: "xcodebuild test — capturing iPad Pro 13-inch", + reply: "", + }, + DemoAgent { + key: "pr", + task: "Draft the PR description", + status: SubagentStatus::Running, + model: Some("sonnet"), + tail: "Summarizing the 6 commits on veil-fade…", + reply: "", + }, + DemoAgent { + key: "motion", + task: "Audit the reduced-motion fallback", + status: SubagentStatus::Running, + model: None, + tail: "Reading apps/ios/Zeron/Transcript/RowView.swift", + reply: "", + }, +]; + +fn veil_agent_doc(key: &str) -> String { + format!("chat-veil--sub--toolu_demo_{key}") +} + +/// The spawn chip for `chat-veil`'s subagent `key`. +fn veil_spawn(id: &str, key: &str) -> MessagePart { + let agent = VEIL_AGENTS + .iter() + .find(|a| a.key == key) + .expect("demo agent"); + spawn_as( + id, + agent.task, + &veil_agent_doc(key), + agent.tail, + agent.status, + agent.model, + ) +} + +/// The transcript a [`VEIL_AGENTS`] subagent wrote into its own doc. +fn veil_agent_transcript(doc_id: &str, host: &str, now: i64) -> Option> { + let agent = VEIL_AGENTS + .iter() + .find(|a| veil_agent_doc(a.key) == doc_id)?; + let running = agent.status == SubagentStatus::Running; + let mut reply = assistant( + "s2", + host, + now - 50_000, + vec![ + tool("q1", read("crates/ui/src/markdown/veil.rs"), false, None), + text("t0", if running { agent.tail } else { agent.reply }), + ], + ); + if running { + reply.status = Some(MessageStatus::Streaming); + reply.duration_ms = None; + } + Some(vec![user("s1", now - 55_000, agent.task), reply]) +} + fn exec(command: &str) -> ToolCall { ToolCall::Exec { command: command.into(), @@ -261,6 +398,10 @@ fn veil(host: &str, now: i64) -> Vec { now - 60_000, vec![ text("t0", "Opening the PR now. Running the checks first:"), + veil_spawn("a1", "tests"), + veil_spawn("a2", "screens"), + veil_spawn("a3", "pr"), + veil_spawn("a4", "motion"), tool( "k1", exec("cargo test -p zeron-ui veil -- --nocapture"), @@ -284,6 +425,8 @@ fn veil(host: &str, now: i64) -> Vec { "The veil must be paint-only. Check how veil.rs splits chunk spans and where the fade duration comes from before touching the Swift side.", ), text("t0", VEIL_PLAN), + veil_spawn("a1", "chunks"), + veil_spawn("a2", "cost"), tool("k1", read("crates/ui/src/markdown/veil.rs"), false, None), tool( "k2", @@ -322,6 +465,7 @@ fn veil(host: &str, now: i64) -> Vec { VEIL_SUBAGENT, "Chunk splits cut ZWJ sequences; snap them to clusters.", ), + veil_spawn("a1", "cjk"), tool( "k1", ToolCall::Glob { @@ -724,6 +868,27 @@ pub(crate) fn fixture(chat_id: &str, host: &str, last_activity: i64) -> Vec tabs(host, now), TABS_SUBAGENT => tabs_subagent(host, now), VEIL_SUBAGENT => veil_subagent(host, now), + "chat-side-2" => short( + host, + now, + "Does the veil run under Reduce Motion?", + "No — with Reduce Motion on, rows render settled at once; the fade is skipped entirely.", + ), + "chat-side-3" => short( + host, + now, + "Compare our fade curve with the desktop's.", + "Same ease-out, 220 ms on both; the desktop clamps the minimum to 120 ms for tiny chunks.", + ), + "chat-side-4" => short( + host, + now, + "Write a changelog line for the veil.", + "Streaming replies now fade in chunk by chunk instead of popping in, on iPhone and iPad.", + ), + id if id.starts_with("chat-veil--sub--") => { + veil_agent_transcript(id, host, now).unwrap_or_default() + } "chat-errored" => errored(host, now), "chat-ios-scroll" => scroll(host, now), "chat-home" => home(host, now), diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 94cbc3f1c3..3de5b2602e 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -52,7 +52,8 @@ pub use session::{ AppendHint, BusyPolicy, ComposerState, Entry, HostCapabilities, HostInfo, InputRequest, LiveStatus, LocalEcho, OutgoingAttachment, PendingKind, PendingSend, QueueEditAction, QueueEditFinish, QueueEditLease, QueueEditStart, QueueGate, QueueItem, RoomState, SendOutcome, - SendRequest, SessionHandle, SessionSnapshot, SnapshotDelta, SnapshotWatch, + SendRequest, SessionHandle, SessionSnapshot, SnapshotDelta, SnapshotWatch, SubagentRow, + SubagentState, }; pub use workspace::{ DeviceView, FrontPage, ProjectRef, ProjectView, PullRequestGroups, SearchField, SearchHit, diff --git a/crates/client/src/session/mod.rs b/crates/client/src/session/mod.rs index e896eaaa7b..66a6e3f10f 100644 --- a/crates/client/src/session/mod.rs +++ b/crates/client/src/session/mod.rs @@ -29,7 +29,7 @@ use zeron_proto::{ pub use snapshot::{ AppendHint, ComposerState, Entry, HostCapabilities, HostInfo, InputRequest, LiveStatus, LocalEcho, PendingKind, PendingSend, QueueGate, QueueItem, RoomState, SessionSnapshot, - SnapshotDelta, + SnapshotDelta, SubagentRow, SubagentState, }; use transcript::{Dirty, Tracker}; diff --git a/crates/client/src/session/snapshot.rs b/crates/client/src/session/snapshot.rs index c52a10b17c..822dd7fd66 100644 --- a/crates/client/src/session/snapshot.rs +++ b/crates/client/src/session/snapshot.rs @@ -21,6 +21,30 @@ pub struct LocalEcho { pub sent_at_ms: i64, } +/// Where a spawned subagent stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubagentState { + Running, + Done, + Failed, +} + +/// A subagent this session's agent spawned: one linked spawn chip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubagentRow { + /// Its transcript doc (`{chatId}--sub--{id}`). + pub doc_id: String, + /// What the spawn asked for ("Audit grapheme handling"). + pub title: String, + pub state: SubagentState, + /// The engine's one-line live tail of its output. + pub tail: Option, + /// The model the spawn picked, when it named one. + pub model: Option, + /// When the spawning reply started. + pub spawned_at_ms: i64, +} + /// "Only text grew" hint for streaming. Consumer rule: if you hold a version /// of this entry with `rev >= base_rev`, every part is unchanged except /// `parts[part_index]`, whose text (text or reasoning body) only grew — the @@ -126,6 +150,60 @@ impl SessionSnapshot { .collect() } + /// Subagents spawned in this transcript, in spawn order: every spawn chip + /// the engine linked to a subagent doc (direct children only — nested + /// spawns live in the subagent's own transcript). + pub fn subagents(&self) -> Vec { + self.transcript() + .iter() + .flat_map(|entry| { + let at = entry.message.created_at; + entry + .message + .parts + .iter() + .filter_map(move |part| match part { + MessagePart::Tool { + call, + is_error, + resolved, + subagent_ref: Some(doc_id), + subagent_status, + subagent_tail, + .. + } if call.is_subagent_spawn() => { + use zeron_doc::parts::SubagentStatus as S; + let state = match subagent_status { + Some(S::Running) => SubagentState::Running, + Some(S::Failed) => SubagentState::Failed, + _ if *is_error => SubagentState::Failed, + Some(S::Done) => SubagentState::Done, + None if *resolved => SubagentState::Done, + None => SubagentState::Running, + }; + let detail = zeron_proto::view::tool_chip_content(call).1; + Some(SubagentRow { + doc_id: doc_id.clone(), + title: Some(detail.trim()) + .filter(|t| !t.is_empty()) + .unwrap_or("Subagent") + .to_owned(), + state, + tail: subagent_tail + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned), + model: call.subagent_model().map(str::to_owned), + spawned_at_ms: at, + }) + } + _ => None, + }) + }) + .collect() + } + pub fn entry(&self, id: &str) -> Option<&Arc> { self.index.get(id).and_then(|&ix| self.entries.get(ix)) } diff --git a/crates/client/tests/demo.rs b/crates/client/tests/demo.rs index 85a449e5b8..3ad509312b 100644 --- a/crates/client/tests/demo.rs +++ b/crates/client/tests/demo.rs @@ -65,7 +65,10 @@ fn front_page_mirrors_the_desktop_sidebar() { ["chat-cjk", "chat-home", "chat-deploy", "chat-blog"] ); assert_eq!(ids(&ws.archived), ["chat-oklch", "chat-presence"]); - assert_eq!(ids(ws.children("chat-veil")), ["chat-side"]); + assert_eq!( + ids(ws.children("chat-veil")), + ["chat-side-2", "chat-side-3", "chat-side", "chat-side-4"] + ); let veil = ws.session("chat-veil").unwrap(); assert_eq!(veil.indicator, ChatIndicator::Working); @@ -124,7 +127,7 @@ fn spawn_chips_open_their_subagent_transcript() { assert_eq!(zeron_client::subagent_parent("chat-tabs"), None); // The working chat badges its running subagents; idle ones don't. let ws = client.workspace(); - assert_eq!(ws.session("chat-veil").unwrap().running_subagents, 2); + assert_eq!(ws.session("chat-veil").unwrap().running_subagents, 4); assert_eq!(ws.session("chat-tabs").unwrap().running_subagents, 0); let spawned = |chat_id: &str| { @@ -142,8 +145,28 @@ fn spawn_chips_open_their_subagent_transcript() { }; let doc_id = spawned("chat-tabs").expect("chat-tabs carries a linked spawn chip"); // One chat shows both: a side chat and a subagent. - assert_eq!(ids(ws.children("chat-veil")), ["chat-side"]); + assert_eq!( + ids(ws.children("chat-veil")), + ["chat-side-2", "chat-side-3", "chat-side", "chat-side-4"] + ); let veil_sub = spawned("chat-veil").expect("chat-veil carries a linked spawn chip"); + // Its spawned subagents, in spawn order: four finished, four running. + let agents = client + .open_session("chat-veil") + .unwrap() + .snapshot() + .subagents(); + let count = |state| agents.iter().filter(|a| a.state == state).count(); + assert_eq!(count(zeron_client::SubagentState::Running), 4); + assert_eq!(count(zeron_client::SubagentState::Done), 3); + assert_eq!(count(zeron_client::SubagentState::Failed), 1); + assert_eq!(agents[0].title, "Map how the veil splits chunks"); + assert_eq!(agents[0].model.as_deref(), Some("haiku")); + let running = agents + .iter() + .find(|a| a.state == zeron_client::SubagentState::Running) + .unwrap(); + assert!(running.tail.is_some()); let runtime = zeron_client::runtime::shared(); let veil_entries = runtime .block_on(client.open_subagent(&veil_sub, true)) diff --git a/crates/mobile/src/client_ffi/session.rs b/crates/mobile/src/client_ffi/session.rs index f5db2db920..e07661d9ae 100644 --- a/crates/mobile/src/client_ffi/session.rs +++ b/crates/mobile/src/client_ffi/session.rs @@ -437,6 +437,44 @@ pub enum QueueEditFinish { Unavailable, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum SubagentState { + Running, + Done, + Failed, +} + +/// A subagent the session's agent spawned (one linked spawn chip). +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SubagentInfo { + /// Its transcript doc — `CoreClient.open_subagent`. + pub doc_id: String, + pub title: String, + pub state: SubagentState, + /// One-line live tail of its output. + pub tail: Option, + /// The model the spawn picked, when it named one. + pub model: Option, + pub spawned_at_ms: i64, +} + +impl From for SubagentInfo { + fn from(r: zc::SubagentRow) -> Self { + Self { + doc_id: r.doc_id, + title: r.title, + state: match r.state { + zc::SubagentState::Running => SubagentState::Running, + zc::SubagentState::Done => SubagentState::Done, + zc::SubagentState::Failed => SubagentState::Failed, + }, + tail: r.tail, + model: r.model, + spawned_at_ms: r.spawned_at_ms, + } + } +} + /// One open chat. Obtain via `CoreClient.open_session`. #[derive(uniffi::Object)] pub struct SessionHandle { @@ -490,6 +528,17 @@ impl SessionHandle { } } + /// Subagents spawned in this transcript, in spawn order (pull after + /// `SessionChanged`). + pub fn subagents(&self) -> Vec { + self.inner + .snapshot() + .subagents() + .into_iter() + .map(Into::into) + .collect() + } + /// Plain text of one entry's text parts (copy / share). pub fn message_text(&self, entry_id: String) -> Option { let snap = self.inner.snapshot(); From 7109560cd3e05c3345630d04ad9ab77110790a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 23:04:35 -0300 Subject: [PATCH 5/7] iOS: fold the side chats sheet's sections Running, Side chats and Finished agents fold under the session list's section headers (count, chevron, a spinner on Running while folded). The fold state persists on this device. Co-authored-by: Claude Opus 5.5 --- .../Session/SideChatsViewController.swift | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/apps/ios/Zeron/Session/SideChatsViewController.swift b/apps/ios/Zeron/Session/SideChatsViewController.swift index 54ebc01d89..9487683a6c 100644 --- a/apps/ios/Zeron/Session/SideChatsViewController.swift +++ b/apps/ios/Zeron/Session/SideChatsViewController.swift @@ -3,13 +3,15 @@ import UIKit /// Everything hanging off one session, in a sheet over it: the subagents /// its agent is running (live tail), its side chats (forked, started by hand, /// or spawned through the Zeron MCP server), and the subagents that finished. -/// Picking a row opens it on top of the session. +/// Picking a row opens it on top of the session. Each section folds under +/// its header (remembered across sheets). final class SideChatsViewController: UIViewController, UICollectionViewDelegate { private enum Section: String { case running, chats, finished } private enum Item: Hashable { + case header(Section) case agent(String) case chat(String) } @@ -27,7 +29,9 @@ final class SideChatsViewController: UIViewController, UICollectionViewDelegate private var dataSource: UICollectionViewDiffableDataSource! private var chats: [String: SessionRowVM] = [:] private var agents: [String: SubagentInfo] = [:] - private var counts: [Section: Int] = [:] + private var headers: [Section: SectionHeaderCell.State] = [:] + private static let collapsedKey = "collapsedSideChatSections" + private var collapsed = Set((UserDefaults.standard.stringArray(forKey: collapsedKey) ?? []).compactMap(Section.init(rawValue:))) private var workspaceToken: AnyObject? private var sessionToken: AnyObject? @@ -59,7 +63,6 @@ final class SideChatsViewController: UIViewController, UICollectionViewDelegate var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .clear config.showsSeparators = false - config.headerMode = .supplementary config.trailingSwipeActionsConfigurationProvider = { [weak self] path in self?.trailingSwipe(path) } collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UICollectionViewCompositionalLayout.list(using: config)) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -76,30 +79,17 @@ final class SideChatsViewController: UIViewController, UICollectionViewDelegate guard let agent = self?.agents[id] else { return } cell.configure(agent) } + let headerReg = UICollectionView.CellRegistration { [weak self] cell, _, section in + guard let state = self?.headers[section] else { return } + cell.configure(state) + } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { cv, path, item in switch item { + case let .header(section): cv.dequeueConfiguredReusableCell(using: headerReg, for: path, item: section) case let .chat(id): cv.dequeueConfiguredReusableCell(using: chatReg, for: path, item: id) case let .agent(id): cv.dequeueConfiguredReusableCell(using: agentReg, for: path, item: id) } } - let headerReg = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] cell, _, path in - guard let self, let section = self.dataSource.sectionIdentifier(for: path.section) else { return } - var c = UIListContentConfiguration.plainHeader() - let name = switch section { - case .running: "Running" - case .chats: "Side chats" - case .finished: "Finished agents" - } - c.text = "\(name) · \(self.counts[section] ?? 0)" - c.textProperties.font = Fonts.ui(.sansSemibold, 13) - c.textProperties.color = Palette.secondary - c.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 14, leading: 20, bottom: 4, trailing: 20) - cell.contentConfiguration = c - cell.backgroundConfiguration = .clear() - } - dataSource.supplementaryViewProvider = { cv, _, path in - cv.dequeueConfiguredReusableSupplementary(using: headerReg, for: path) - } reload(animated: false) // Side chats come with workspace revisions; subagents (and their live @@ -128,31 +118,44 @@ final class SideChatsViewController: UIViewController, UICollectionViewDelegate if let old = agents[a.docId], old != a { changed.append(.agent(a.docId)) } nextAgents[a.docId] = a } - let nextCounts: [Section: Int] = [.running: running.count, .chats: chatRows.count, .finished: finished.count] var snapshot = NSDiffableDataSourceSnapshot() for (section, items) in [(Section.running, running.map { Item.agent($0.docId) }), (.chats, chatRows.map { Item.chat($0.id) }), (.finished, finished.map { Item.agent($0.docId) })] where !items.isEmpty { + let title = switch section { + case .running: "Running" + case .chats: "Side chats" + case .finished: "Finished agents" + } + // Folded, Running still shows its spinner: something is live inside. + let header = SectionHeaderCell.State(id: section.rawValue, title: title, count: items.count, collapsed: collapsed.contains(section), live: section == .running ? .spinner : nil) + if let old = headers[section], old != header { changed.append(.header(section)) } + headers[section] = header snapshot.appendSections([section]) - snapshot.appendItems(items, toSection: section) + snapshot.appendItems([.header(section)] + (header.collapsed ? [] : items), toSection: section) } let present = Set(snapshot.itemIdentifiers) snapshot.reconfigureItems(changed.filter { present.contains($0) }) - let recounted = nextCounts != counts chats = nextChats agents = nextAgents - counts = nextCounts - if recounted { snapshot.reloadSections(snapshot.sectionIdentifiers) } dataSource.apply(snapshot, animatingDifferences: animated && view.window != nil) } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: true) switch dataSource.itemIdentifier(for: indexPath) { + case let .header(section)?: toggle(section) case let .chat(id)?: onOpen(id) case let .agent(id)?: if let agent = agents[id] { onOpenAgent?(agent) } case nil: break } } + private func toggle(_ section: Section) { + if collapsed.remove(section) == nil { collapsed.insert(section) } + UserDefaults.standard.set(collapsed.map(\.rawValue), forKey: Self.collapsedKey) + UISelectionFeedbackGenerator().selectionChanged() + reload(animated: true) + } + /// Side chats archive away; subagents belong to the transcript. private func trailingSwipe(_ path: IndexPath) -> UISwipeActionsConfiguration? { guard case let .chat(id)? = dataSource.itemIdentifier(for: path) else { return nil } From 632138b154eb731ced6f9474f0131c5fc9db503a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Wed, 14 Oct 2026 13:17:27 -0300 Subject: [PATCH 6/7] Follow a resumed subagent instead of its cached frozen copy A subagent opened finished keeps a frozen core in the warm cache, and that core never dials. Reopening it live (it resumed) returned the stale copy with no room. A live open now replaces a cached frozen core with the live doc. The mock edge serves /blob/ so a live test covers it. Co-authored-by: Claude Opus 5.5 --- crates/client/src/client.rs | 17 ++++++- crates/client/src/session/mod.rs | 5 ++ crates/client/tests/live.rs | 60 ++++++++++++++++++++++++ crates/client/tests/support/mock_edge.rs | 46 +++++++++++++++++- 4 files changed, 125 insertions(+), 3 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 83ffafe622..2d6e2a850b 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -135,6 +135,15 @@ impl ClientInner { lock(&self.sessions).values().cloned().collect() } + /// Forget `core` (if it is still the one registered for `chat_id`): the + /// next open builds a fresh one. Views holding it keep their copy. + fn release_core(&self, chat_id: &str, core: &Arc) { + let mut sessions = lock(&self.sessions); + if sessions.get(chat_id).is_some_and(|c| Arc::ptr_eq(c, core)) { + sessions.remove(chat_id); + } + } + /// Register a freshly built core, or return the one a racing open /// registered first. fn adopt_core(&self, chat_id: &str, core: Arc) -> Arc { @@ -1210,7 +1219,13 @@ impl Client { let parent = subagent_parent(doc_id) .ok_or_else(|| ClientError::InvalidArgument(format!("not a subagent doc: {doc_id}")))?; if let Some(core) = self.inner.session_core(doc_id) { - return Ok(SessionHandle { core }); + // A frozen copy never dials, so it can't follow a subagent that + // runs (again — a resumed one keeps its doc): a live open + // replaces it with the live doc. + if frozen || !core.is_frozen() { + return Ok(SessionHandle { core }); + } + self.inner.release_core(doc_id, &core); } let host = self .inner diff --git a/crates/client/src/session/mod.rs b/crates/client/src/session/mod.rs index 66a6e3f10f..f0a0fb7aa6 100644 --- a/crates/client/src/session/mod.rs +++ b/crates/client/src/session/mod.rs @@ -297,6 +297,11 @@ impl SessionCore { lock(&self.room).clone() } + /// Served from a finished subagent's frozen blob: it never dials. + pub(crate) fn is_frozen(&self) -> bool { + self.source == DocSource::Frozen + } + /// Drop the room (deleted chat): its tasks stop and it flushes once. pub(crate) fn close_room(&self) { let room = lock(&self.room).take(); diff --git a/crates/client/tests/live.rs b/crates/client/tests/live.rs index 3f51ce70e4..6aba4312e0 100644 --- a/crates/client/tests/live.rs +++ b/crates/client/tests/live.rs @@ -942,6 +942,66 @@ async fn a_refused_host_wake_settles_instead_of_retrying_forever() { client.shutdown(); } +/// A subagent first read finished (its frozen blob) and then resumed must +/// follow the live doc on the next live open, not the cached frozen copy. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resumed_subagent_leaves_its_frozen_copy_for_the_live_doc() { + let edge = MockEdge::start().await; + let _host = HostRegistry::start(&edge).await; + let dir = tempfile::tempdir().unwrap(); + let client = phone(&edge, dir.path()); + await_chat(&client, CHAT).await; + let doc_id = format!("{CHAT}--sub--toolu_resume"); + let reply = |id: &str, text: &str| SessionMessageEntry { + id: id.into(), + role: MessageRole::Assistant, + parts: vec![MessagePart::Text { + id: "t0".into(), + text: text.into(), + }], + created_at: zeron_client_now(), + device_id: HOST.into(), + status: Some(zeron_doc::MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }; + + // Finished once: read from its frozen copy, no room. + let first = vec![reply("r1", "First pass.")]; + edge.put_blob(CHAT, &doc_id, serde_json::to_vec(&first).unwrap()); + let frozen = client.open_subagent(&doc_id, true).await.unwrap(); + assert_eq!(frozen.snapshot().transcript_messages().len(), 1); + assert_eq!(edge.chat_sockets(&doc_id), 0); + client.close_session(&doc_id); + + // Resumed: the host writes a second reply into the live doc. + let host_doc = LoroDoc::new(); + let session = SessionDoc::from_doc(host_doc.clone()); + session.push_message(&first[0]).unwrap(); + session.push_message(&reply("r2", "Second pass.")).unwrap(); + host_doc.commit(); + edge.inject( + &doc_id, + HOST, + host_doc.export(ExportMode::all_updates()).unwrap(), + ); + + let live = client.open_subagent(&doc_id, false).await.unwrap(); + live.set_view_attached(true); + tokio::task::spawn_blocking({ + let edge_sockets = move || edge.chat_sockets(&doc_id); + move || { + wait_for( + "the live doc, both replies", + Duration::from_secs(10), + || edge_sockets() > 0 && live.snapshot().transcript_messages().len() == 2, + ) + } + }) + .await + .unwrap(); +} + async fn await_chat(client: &Client, chat: &str) { let start = Instant::now(); while client.workspace().session(chat).is_none() { diff --git a/crates/client/tests/support/mock_edge.rs b/crates/client/tests/support/mock_edge.rs index 43c73bae89..193ebc6dc5 100644 --- a/crates/client/tests/support/mock_edge.rs +++ b/crates/client/tests/support/mock_edge.rs @@ -57,6 +57,8 @@ struct Shared { nudge_status_by_chat: Mutex>, nudges: Mutex>, host_hints: Mutex>, + /// Tool-output sidecars (`/blob/{chat}/{part}`), by `chat/part`. + blobs: Mutex>>, } enum Out { @@ -94,6 +96,7 @@ impl MockEdge { nudge_status_by_chat: Mutex::new(HashMap::new()), nudges: Mutex::new(Vec::new()), host_hints: Mutex::new(HashMap::new()), + blobs: Mutex::new(HashMap::new()), }); let accept_shared = shared.clone(); let task = tokio::spawn(async move { @@ -104,8 +107,12 @@ impl MockEdge { let shared = accept_shared.clone(); let registry_url = registry_url.clone(); tokio::spawn(async move { - let mut first = [0u8; 4]; - if stream.peek(&mut first).await.is_ok() && first[0] == b'P' { + let mut first = [0u8; 10]; + let peeked = stream.peek(&mut first).await.unwrap_or(0); + if first[..peeked].starts_with(b"GET /blob/") { + return serve_blob(stream, &shared).await; + } + if peeked > 0 && first[0] == b'P' { let mut request = Vec::new(); let mut buf = [0u8; 4096]; loop { @@ -220,6 +227,15 @@ impl MockEdge { } } + /// Serve `body` at `GET /blob/{chat}/{part}` (a frozen subagent copy). + pub fn put_blob(&self, chat: &str, part: &str, body: Vec) { + self.shared + .blobs + .lock() + .unwrap() + .insert(format!("{chat}/{part}"), body); + } + pub fn nudge_status(&self, status: u16) { *self.shared.nudge_status.lock().unwrap() = status; } @@ -359,6 +375,32 @@ fn row_frame(row: &Row) -> Vec { ) } +/// `GET /blob/{chat}/{part}`: the stored body, else 404. +async fn serve_blob(mut stream: tokio::net::TcpStream, shared: &Shared) { + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + while !request.windows(4).any(|b| b == b"\r\n\r\n") { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => request.extend_from_slice(&buf[..n]), + } + } + let head = String::from_utf8_lossy(&request); + let path = head.split_whitespace().nth(1).unwrap_or_default(); + let key = path.trim_start_matches("/blob/").to_owned(); + let body = shared.blobs.lock().unwrap().get(&key).cloned(); + let (status, body) = match body { + Some(body) => ("200 OK", body), + None => ("404 Not Found", b"{}".to_vec()), + }; + let head = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; +} + async fn serve_chat(ws: Ws, chat: String, shared: Arc) { *shared .sockets From b0da721bd3346f820d3f19b1caf65fb83cec81fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Wed, 14 Oct 2026 13:31:22 -0300 Subject: [PATCH 7/7] Demo: keep the running-subagents pill past the parent's turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_status rewrote running_subagents to 0 on every turn transition, so chat-veil's "● 4" vanished as soon as its live turn finished. It now keeps the row's count, and the heartbeat also refreshes rows with running subagents so the pill doesn't go stale. Co-authored-by: Claude Opus 5.5 --- crates/client/src/demo/mod.rs | 13 +++++++++---- crates/client/tests/demo.rs | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/client/src/demo/mod.rs b/crates/client/src/demo/mod.rs index 782faf46bd..fc162e987d 100644 --- a/crates/client/src/demo/mod.rs +++ b/crates/client/src/demo/mod.rs @@ -156,11 +156,13 @@ impl DemoHost { let live: Vec = state .sessions .iter() + // Subagents outlive their parent's turn: a row counting + // them stays fresh too, or its pill would go stale. .filter(|s| { matches!( s.status, SessionStatus::Working | SessionStatus::AwaitingInput - ) + ) || s.running_subagents > 0 }) .cloned() .collect(); @@ -446,18 +448,21 @@ impl DemoHost { ) { let host = self.host_of(client, chat_id); let now = Utc::now(); - let started = client + let previous = client .workspace .state() .0 .sessions .iter() .find(|s| s.chat_id == chat_id) - .and_then(|s| s.started_at); + .cloned(); + let started = previous.as_ref().and_then(|s| s.started_at); + // The turn's status changes; the subagents it spawned keep running. + let running_subagents = previous.map_or(0, |s| s.running_subagents); let _ = client.registry_write(|doc| { doc.upsert_session(&Session { last_completed_turn: completed.map(str::to_owned), - running_subagents: 0, + running_subagents, chat_id: chat_id.to_owned(), device_id: host, status, diff --git a/crates/client/tests/demo.rs b/crates/client/tests/demo.rs index 3ad509312b..f96e15cc67 100644 --- a/crates/client/tests/demo.rs +++ b/crates/client/tests/demo.rs @@ -503,6 +503,29 @@ fn interrupt_aborts_the_live_turn() { }); } +#[test] +fn subagents_keep_their_pill_after_the_parent_turn_ends() { + let (client, _dir) = demo(fast()); + let _veil = client.open_session("chat-veil").unwrap(); + // Opening chat-veil finishes its live turn; its four subagents run on. + wait_for("chat-veil's turn ends", Duration::from_secs(10), || { + client + .workspace() + .session("chat-veil") + .unwrap() + .host_indicator + != ChatIndicator::Working + }); + assert_eq!( + client + .workspace() + .session("chat-veil") + .unwrap() + .running_subagents, + 4 + ); +} + #[test] fn opening_the_live_chat_finishes_its_stream() { let (client, _dir) = demo(fast());