diff --git a/apps/ios/Zeron/App/AppModel.swift b/apps/ios/Zeron/App/AppModel.swift index 6b14f87afa..b640281bd1 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,18 @@ 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) + } + + /// 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] ?? [] } @@ -539,6 +563,40 @@ 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 + } + } + + /// 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). @@ -623,11 +681,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) @@ -636,10 +700,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 8c5f9e9b47..a9151d59e7 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -1092,6 +1092,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. */ @@ -1139,6 +1146,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 @@ -1527,6 +1540,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. */ @@ -1682,6 +1716,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 @@ -3213,6 +3267,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 } @@ -3526,6 +3586,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 { @@ -7713,6 +7786,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. @@ -7722,12 +7800,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 } @@ -7750,7 +7833,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) ) } @@ -7760,6 +7844,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) } } @@ -9313,6 +9398,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. @@ -9360,7 +9449,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?, @@ -9389,6 +9481,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 @@ -9437,6 +9530,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), @@ -9471,6 +9565,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) @@ -9566,6 +9661,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) +} + + /** * A surface's latest picture: an image (scenes, shaders) under vector * paint (canvas drawings, charts, scene labels), in surface coordinates. @@ -13947,6 +14133,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) +} + + + /** * How a surface takes touches. */ @@ -14982,6 +15241,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 @@ -15045,9 +15310,12 @@ 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 - case 13: return .visual(id: try FfiConverterString.read(from: &buf) + case 14: return .visual(id: try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -15120,12 +15388,19 @@ 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)) case let .visual(id): - writeInt(&buf, Int32(13)) + writeInt(&buf, Int32(14)) FfiConverterString.write(id, into: &buf) } @@ -16593,6 +16868,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 @@ -17367,6 +17667,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 } @@ -17400,6 +17703,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 } @@ -17562,6 +17868,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/CoreSessionSource.swift b/apps/ios/Zeron/Session/CoreSessionSource.swift index adde56aa26..1fe5c25921 100644 --- a/apps/ios/Zeron/Session/CoreSessionSource.swift +++ b/apps/ios/Zeron/Session/CoreSessionSource.swift @@ -55,6 +55,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/NewSessionViewController.swift b/apps/ios/Zeron/Session/NewSessionViewController.swift index 04e192b77a..db20c3bece 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 composerRest: NSLayoutConstraint? private var backgroundObserver: NSObjectProtocol? @@ -74,24 +78,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() @@ -111,7 +142,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) @@ -121,7 +158,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 @@ -143,7 +180,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 @@ -177,8 +219,8 @@ final class NewSessionViewController: UIViewController, UIGestureRecognizerDeleg fill, ]) composerRest = pinAboveKeyboard(composer) - 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 } @@ -242,7 +284,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] { @@ -254,7 +296,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")) @@ -413,6 +457,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 3069a207ac..cddab1c2a4 100644 --- a/apps/ios/Zeron/Session/SessionViewController.swift +++ b/apps/ios/Zeron/Session/SessionViewController.swift @@ -47,22 +47,54 @@ 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 + // 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 and agents" + 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 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 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) } } - voiceToken = app.voice.observe(syncVoice) - hostsToken = app.observe(syncVoice) - syncVoice() + 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 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 } @@ -184,6 +216,8 @@ final class SessionViewController: UIViewController, UIGestureRecognizerDelegate private var voiceBar: VoiceBarItem? private var voiceToken: AnyObject? private var hostsToken: AnyObject? + private var childrenToken: AnyObject? + private var agentsToken: AnyObject? deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } @@ -298,7 +332,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 @@ -503,12 +540,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" @@ -526,6 +565,82 @@ 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) + 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() } } + 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()] + s.prefersGrabberVisible = true + } + 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([]) } @@ -536,6 +651,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 new file mode 100644 index 0000000000..9487683a6c --- /dev/null +++ b/apps/ios/Zeron/Session/SideChatsViewController.swift @@ -0,0 +1,191 @@ +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. 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) + } + + private let app: AppModel + private let parentId: String + private let onOpen: (String) -> Void + /// 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 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? + + init(app: AppModel, parentId: String, onOpen: @escaping (String) -> Void) { + self.app = app + self.parentId = parentId + self.onOpen = onOpen + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + 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) + }) + 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 + + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + config.showsSeparators = false + 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" + 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) + } + 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) + } + } + + 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 + } + 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([.header(section)] + (header.collapsed ? [] : items), toSection: section) + } + let present = Set(snapshot.itemIdentifiers) + snapshot.reconfigureItems(changed.filter { present.contains($0) }) + chats = nextChats + agents = nextAgents + 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 } + 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/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..4f1a141b59 100644 --- a/apps/ios/Zeron/Shell/MainTabController.swift +++ b/apps/ios/Zeron/Shell/MainTabController.swift @@ -88,7 +88,15 @@ 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 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 NewSessionViewController } /// 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/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/apps/ios/Zeron/Transcript/RowView.swift b/apps/ios/Zeron/Transcript/RowView.swift index d41c43de62..f36c3dc775 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) /// The live view for an agent visual (kept across row reuse). func rowView(_ view: RowView, visualFor id: String) -> UIView? } @@ -295,6 +296,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 case let .visual(id): view = delegate?.rowView(self, visualFor: id) ?? UIView() (view as? VisualView)?.active = !isHidden diff --git a/apps/ios/Zeron/Transcript/TranscriptListView.swift b/apps/ios/Zeron/Transcript/TranscriptListView.swift index d2e2588a18..2872e5fb23 100644 --- a/apps/ios/Zeron/Transcript/TranscriptListView.swift +++ b/apps/ios/Zeron/Transcript/TranscriptListView.swift @@ -123,6 +123,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`) // @@ -698,6 +701,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) + } + func rowView(_ view: RowView, visualFor id: String) -> UIView? { if let live = visuals[id] { return live } guard let session = engine.visual(id: id) else { return nil } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 2a64ff48c3..9ed81584f5 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 { @@ -56,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 { @@ -119,6 +135,24 @@ 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 { + 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) } @@ -707,6 +741,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(); @@ -773,6 +810,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, @@ -1082,14 +1209,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(); } @@ -1115,6 +1236,94 @@ 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) { + // 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 + .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 8030551aa1..54e7449f7d 100644 --- a/crates/client/src/demo/fixtures.rs +++ b/crates/client/src/demo/fixtures.rs @@ -216,6 +216,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, @@ -445,7 +484,8 @@ pub(crate) fn seed( if let Some(status) = demo.status { doc.upsert_session(&Session { last_completed_turn: None, - running_subagents: 0, + // 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/mod.rs b/crates/client/src/demo/mod.rs index 021367ee9b..25835fda96 100644 --- a/crates/client/src/demo/mod.rs +++ b/crates/client/src/demo/mod.rs @@ -78,6 +78,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>, } @@ -98,6 +101,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), }) @@ -153,11 +157,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(); @@ -213,6 +219,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) @@ -437,18 +449,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, @@ -765,6 +780,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 @@ -900,6 +918,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/src/demo/transcripts.rs b/crates/client/src/demo/transcripts.rs index 64151ef4ce..5eb3ce8cde 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,174 @@ 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"; +/// 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 { + 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(input), + }, + status == SubagentStatus::Failed, + None, + ); + if let MessagePart::Tool { + subagent_ref, + subagent_status, + subagent_tail, + .. + } = &mut part + { + *subagent_ref = Some(doc.into()); + *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(), @@ -229,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"), @@ -252,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", @@ -284,6 +459,13 @@ 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.", + ), + veil_spawn("a1", "cjk"), tool( "k1", ToolCall::Glob { @@ -408,6 +590,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 +627,69 @@ 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![ + 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", @@ -658,6 +909,29 @@ 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), + 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 e315e554dd..3de5b2602e 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, }; @@ -50,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 f876a3bc1b..f0a0fb7aa6 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}; @@ -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, @@ -282,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(); @@ -295,18 +315,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 +370,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/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/src/workspace/view.rs b/crates/client/src/workspace/view.rs index 88f22989d6..d26c09a8a4 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 (Client, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); @@ -70,7 +71,10 @@ fn front_page_mirrors_the_desktop_sidebar() { ] ); 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); @@ -119,6 +123,203 @@ fn front_page_mirrors_the_desktop_sidebar() { assert_eq!(hits[0].session.id, "chat-ios-scroll"); } +#[test] +fn spawn_chips_open_their_subagent_transcript() { + let (client, _dir) = demo(fast()); + assert_eq!( + zeron_client::subagent_parent("chat-tabs--sub--toolu_1"), + Some("chat-tabs") + ); + 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, 4); + assert_eq!(ws.session("chat-tabs").unwrap().running_subagents, 0); + + 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-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)) + .unwrap() + .snapshot() + .transcript_messages(); + assert_eq!(veil_entries.len(), 2); + 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 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()); @@ -351,6 +552,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()); @@ -399,6 +623,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..6aba4312e0 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(); @@ -941,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() { @@ -982,6 +1043,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/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 diff --git a/crates/mobile/src/client_ffi/mod.rs b/crates/mobile/src/client_ffi/mod.rs index db69d8ed78..829aa1a243 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)?) } @@ -341,6 +349,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/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(); diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index c62862edae..84dd4d954a 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, @@ -856,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 { @@ -873,6 +879,7 @@ impl TryFrom for zc::NewSession { branch: n.branch, cwd: n.cwd, title: n.title, + parent_chat_id: n.parent_chat_id, }) } } diff --git a/crates/mobile/src/layout/display.rs b/crates/mobile/src/layout/display.rs index 40390c75c6..ad582d41b1 100644 --- a/crates/mobile/src/layout/display.rs +++ b/crates/mobile/src/layout/display.rs @@ -257,6 +257,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 de0842ff8e..351598afb4 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 { @@ -822,6 +830,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), @@ -832,6 +847,7 @@ impl RowBuilder { key: dkey, open, body, + link, }); } MessagePart::Reasoning { text, .. } => { @@ -859,6 +875,7 @@ impl RowBuilder { key: dkey, open, body, + link: None, }); } _ => {} @@ -1118,6 +1135,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 }