Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions apps/ios/Zeron/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
}

Expand All @@ -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] ?? []
}
Expand Down Expand Up @@ -531,6 +555,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).
Expand Down Expand Up @@ -615,11 +673,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)
Expand All @@ -628,10 +692,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()
Expand Down
Loading
Loading