iOS remote: transcript fidelity + mid-session controls & model catalog
Bring NucleicRemote closer to desktop parity in two areas (the core sync loop was already at parity — shared protocol, control scope). Transcript fidelity (iOS): a client-side TranscriptProjection coalesces streaming text by messageID and folds each tool call's lifecycle (start/deltas/complete/result/fileChange) into one expandable card — fixing the duplicate started+completed rows. Adds Markdown bubbles, the gold Orchestra card for Task/Agent spawns, and the previously-dropped usage/cost, rate-limit, file-change, turn-boundary and session-started rows, plus a context-window % header badge. Mid-session controls + model catalog (protocol/host/iOS): project the host ModelCatalog over the wire as WireModelCatalog (in Welcome); add 5 control-scope setters (setSessionModel/Effort/Auto/AutoShip/ShipBranch) backed by the existing AppStore.mutateSession + SessionController hooks; enrich WireSessionSummary with model/effort/auto/autoShip/shipBranch/ contextInputTokens (all forward-compatible). The composer gains a model picker and a catalog-driven effort menu (per-backend caps: Codex→xhigh, Grok→auto), and the session header gains a model/effort/auto/autoship control bar. Tests: CBOR round-trips for the new messages, Welcome.modelCatalog, the new summary fields, forward-compat decode of old bytes, and the setters reaching the host. Verified in the Simulator (NUCLEIC_DEMO=1). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91b223166f
commit
277a76b85c
@@ -224,6 +224,40 @@ enum ModelCatalog {
|
||||
static var storedDefaultAutoShip: Bool {
|
||||
UserDefaults.standard.bool(forKey: defaultAutoShipKey)
|
||||
}
|
||||
|
||||
/// The wire projection sent to paired phones (SYNC §5.2) so their composer/header pickers
|
||||
/// mirror this catalog — same per-backend effort caps, context windows, and grouping —
|
||||
/// without re-deriving any backend rules on-device. Built from this single source of truth.
|
||||
static var wireCatalog: WireModelCatalog {
|
||||
let groups: [[WireModelCatalog.Model]] = modelGroups.map { group in
|
||||
group.map { sku in
|
||||
WireModelCatalog.Model(
|
||||
sku: sku,
|
||||
displayName: displayName(sku),
|
||||
backend: BackendID.forModel(sku) ?? .claudeCode,
|
||||
contextBadge: contextBadge(for: sku),
|
||||
contextWindow: contextWindow(for: sku),
|
||||
efforts: efforts(for: sku),
|
||||
effortNoun: effortNoun(for: sku))
|
||||
}
|
||||
}
|
||||
// Pretty labels only for the non-identity effort levels (e.g. "auto" → "Auto") plus the
|
||||
// orchestra sentinel; the phone shows the raw level for everything else.
|
||||
var effortNames: [String: String] = [:]
|
||||
for sku in models {
|
||||
for level in efforts(for: sku) where effortDisplayName(level) != level {
|
||||
effortNames[level] = effortDisplayName(level)
|
||||
}
|
||||
}
|
||||
effortNames[orchestraEffort] = effortDisplayName(orchestraEffort) // "Orchestra"
|
||||
return WireModelCatalog(
|
||||
groups: groups,
|
||||
effortDisplayNames: effortNames,
|
||||
orchestraSentinel: orchestraEffort,
|
||||
orchestraRequiresControlNote: orchestraRequiresControlNote,
|
||||
fallbackModel: fallbackModel,
|
||||
fallbackEffort: fallbackEffort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ struct NucleicApp: App {
|
||||
store.autoArchiveIdleInterval = AutoArchivePolicy.stored.interval
|
||||
store.archivedWorktreeCleanupInterval = ArchivedWorktreeCleanupPolicy.stored.interval
|
||||
store.setEnabledStatusProviders(StatusIndicatorSettings.storedEnabledProviders)
|
||||
// Hand the sync host the model/effort catalog so paired phones render the same
|
||||
// pickers (SYNC §5.2). Static data, injected once since ModelCatalog lives here.
|
||||
store.setSyncModelCatalog(ModelCatalog.wireCatalog)
|
||||
_store = State(initialValue: store)
|
||||
} catch {
|
||||
fatalError("Could not open the Nucleic store at \(support.path): \(error)")
|
||||
|
||||
@@ -3709,6 +3709,16 @@ public final class AppStore: ConflictArbiter {
|
||||
syncBroadcastContinuation?.yield(event)
|
||||
}
|
||||
|
||||
/// The model/effort catalog projected to paired phones. Injected by the app layer at
|
||||
/// startup because `ModelCatalog` lives in NucleicApp; `.empty` until then (a phone that
|
||||
/// receives an empty catalog falls back to its built-in effort list).
|
||||
private var injectedModelCatalog: WireModelCatalog = .empty
|
||||
|
||||
/// Hand the sync host the wire model catalog the app built from its `ModelCatalog`.
|
||||
public func setSyncModelCatalog(_ catalog: WireModelCatalog) {
|
||||
injectedModelCatalog = catalog
|
||||
}
|
||||
|
||||
/// Map a host `Session` to the wire `SessionSummary` the phone renders.
|
||||
fileprivate func wireSummary(for session: Session, pendingApprovals: Int = 0) -> WireSessionSummary {
|
||||
WireSessionSummary(
|
||||
@@ -3726,6 +3736,11 @@ public final class AppStore: ConflictArbiter {
|
||||
favorite: session.favorite,
|
||||
archived: session.archived,
|
||||
queuedMessage: session.queuedMessage,
|
||||
model: session.model,
|
||||
effort: session.effort,
|
||||
auto: session.auto,
|
||||
autoShip: session.autoShip,
|
||||
shipBranch: session.shipBranch,
|
||||
updatedAt: session.updatedAt)
|
||||
}
|
||||
}
|
||||
@@ -3746,6 +3761,8 @@ extension AppStore: SyncHostBridge {
|
||||
allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern])
|
||||
}
|
||||
|
||||
public var modelCatalog: WireModelCatalog { injectedModelCatalog }
|
||||
|
||||
public func sessionSummaries() async -> [WireSessionSummary] {
|
||||
var result: [WireSessionSummary] = []
|
||||
for (_, controller) in controllers {
|
||||
@@ -3905,6 +3922,41 @@ extension AppStore: SyncHostBridge {
|
||||
await controller.interrupt()
|
||||
}
|
||||
|
||||
// MARK: Mid-session model / reasoning / automation (control scope)
|
||||
|
||||
public func setSessionModel(_ id: SessionID, _ model: String?) async throws {
|
||||
try await mutateSessionForRemote(id) { await $0.setModel(model) }
|
||||
}
|
||||
public func setSessionEffort(_ id: SessionID, _ effort: String?) async throws {
|
||||
try await mutateSessionForRemote(id) { await $0.setEffort(effort) }
|
||||
}
|
||||
public func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws {
|
||||
try await mutateSessionForRemote(id) { await $0.setAuto(auto) }
|
||||
}
|
||||
public func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws {
|
||||
try await mutateSessionForRemote(id) { await $0.setAutoShip(autoShip) }
|
||||
}
|
||||
public func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws {
|
||||
try await mutateSessionForRemote(id) { await $0.setShipBranch(branch) }
|
||||
}
|
||||
|
||||
/// Apply a setter to a session addressed by id, then push the refreshed summary so the
|
||||
/// phone's header reflects the change immediately (these mutations emit no transcript
|
||||
/// event). Mirrors `sendInput`'s explicit `sessionUpdated` broadcast. Throws
|
||||
/// `.unknownSession` if the session is gone (so the phone surfaces it).
|
||||
private func mutateSessionForRemote(
|
||||
_ id: SessionID, _ body: (SessionController) async -> Void
|
||||
) async throws {
|
||||
guard let controller = controllers[id] else {
|
||||
throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
|
||||
}
|
||||
await body(controller)
|
||||
let snapshot = await controller.snapshot
|
||||
if id == openSessionID { openSession = snapshot.session }
|
||||
broadcast(.sessionUpdated(
|
||||
wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals.count)))
|
||||
}
|
||||
|
||||
public func broadcasts() async -> AsyncStream<HostBroadcast> {
|
||||
AsyncStream { continuation in syncBroadcastContinuation = continuation }
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ actor ConnectionHandler {
|
||||
let welcome = Welcome(
|
||||
grantedScope: grantedScope,
|
||||
host: await bridge.hostInfo,
|
||||
capabilities: await bridge.capabilities)
|
||||
capabilities: await bridge.capabilities,
|
||||
modelCatalog: await bridge.modelCatalog)
|
||||
send(.welcome(welcome))
|
||||
}
|
||||
|
||||
@@ -244,6 +245,21 @@ actor ConnectionHandler {
|
||||
case .discard(let id):
|
||||
guard requireControl(id) else { return }
|
||||
await guardedCall(sessionID: id) { try await self.bridge.discard(id) }
|
||||
case .setSessionModel(let id, let model):
|
||||
guard requireControl(id) else { return }
|
||||
await guardedCall(sessionID: id) { try await self.bridge.setSessionModel(id, model) }
|
||||
case .setSessionEffort(let id, let effort):
|
||||
guard requireControl(id) else { return }
|
||||
await guardedCall(sessionID: id) { try await self.bridge.setSessionEffort(id, effort) }
|
||||
case .setSessionAuto(let id, let flag):
|
||||
guard requireControl(id) else { return }
|
||||
await guardedCall(sessionID: id) { try await self.bridge.setSessionAuto(id, flag) }
|
||||
case .setSessionAutoShip(let id, let flag):
|
||||
guard requireControl(id) else { return }
|
||||
await guardedCall(sessionID: id) { try await self.bridge.setSessionAutoShip(id, flag) }
|
||||
case .setSessionShipBranch(let id, let branch):
|
||||
guard requireControl(id) else { return }
|
||||
await guardedCall(sessionID: id) { try await self.bridge.setSessionShipBranch(id, branch) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import NucleicProtocol
|
||||
public protocol SyncHostBridge: Sendable {
|
||||
var hostInfo: HostInfo { get async }
|
||||
var capabilities: WireCapabilities { get async }
|
||||
/// The model/effort catalog the phone's pickers mirror (SYNC §5.2), sent in `Welcome`.
|
||||
var modelCatalog: WireModelCatalog { get async }
|
||||
|
||||
/// Current session list, newest-activity first (the phone sorts attention-first itself).
|
||||
func sessionSummaries() async -> [WireSessionSummary]
|
||||
@@ -41,6 +43,12 @@ public protocol SyncHostBridge: Sendable {
|
||||
func deleteSessionRemote(_ id: SessionID) async throws
|
||||
func integrate(_ id: SessionID, _ mode: IntegrationMode) async throws
|
||||
func discard(_ id: SessionID) async throws
|
||||
// Mid-session model / reasoning / automation changes (the Mac header's affordances).
|
||||
func setSessionModel(_ id: SessionID, _ model: String?) async throws
|
||||
func setSessionEffort(_ id: SessionID, _ effort: String?) async throws
|
||||
func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws
|
||||
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws
|
||||
func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws
|
||||
|
||||
/// Live host-side changes to fan out to subscribed clients. One shared stream; `SyncHost`
|
||||
/// multiplexes per-connection filtering/verbosity on top.
|
||||
|
||||
@@ -25,6 +25,13 @@ public enum ClientMsg: Sendable, Equatable {
|
||||
case deleteSession(SessionID)
|
||||
case integrate(SessionID, IntegrationMode)
|
||||
case discard(SessionID)
|
||||
// Control scope: change a session's model / reasoning / automation mid-stream (the same
|
||||
// affordances the Mac header exposes). `nil` model/effort resets to the host/app default.
|
||||
case setSessionModel(SessionID, String?)
|
||||
case setSessionEffort(SessionID, String?)
|
||||
case setSessionAuto(SessionID, Bool)
|
||||
case setSessionAutoShip(SessionID, Bool)
|
||||
case setSessionShipBranch(SessionID, String?)
|
||||
}
|
||||
|
||||
/// Host → Client messages (SYNC_PROTOCOL §5.2). Forward-compatible: unknown tags decode to
|
||||
@@ -50,6 +57,7 @@ public enum HostMsg: Sendable, Equatable {
|
||||
private enum EnvelopeKey: String, CodingKey {
|
||||
case t, sessionID, approvalID, decision, input, tag
|
||||
case request, todoID, projectID, todoStatus, title, flag, mode
|
||||
case model, effort, branch
|
||||
}
|
||||
|
||||
extension ClientMsg: Codable {
|
||||
@@ -100,6 +108,26 @@ extension ClientMsg: Codable {
|
||||
try c.decode(SessionID.self, forKey: .sessionID),
|
||||
try c.decode(IntegrationMode.self, forKey: .mode))
|
||||
case "discard": self = .discard(try c.decode(SessionID.self, forKey: .sessionID))
|
||||
case "setSessionModel":
|
||||
self = .setSessionModel(
|
||||
try c.decode(SessionID.self, forKey: .sessionID),
|
||||
try c.decodeIfPresent(String.self, forKey: .model))
|
||||
case "setSessionEffort":
|
||||
self = .setSessionEffort(
|
||||
try c.decode(SessionID.self, forKey: .sessionID),
|
||||
try c.decodeIfPresent(String.self, forKey: .effort))
|
||||
case "setSessionAuto":
|
||||
self = .setSessionAuto(
|
||||
try c.decode(SessionID.self, forKey: .sessionID),
|
||||
try c.decode(Bool.self, forKey: .flag))
|
||||
case "setSessionAutoShip":
|
||||
self = .setSessionAutoShip(
|
||||
try c.decode(SessionID.self, forKey: .sessionID),
|
||||
try c.decode(Bool.self, forKey: .flag))
|
||||
case "setSessionShipBranch":
|
||||
self = .setSessionShipBranch(
|
||||
try c.decode(SessionID.self, forKey: .sessionID),
|
||||
try c.decodeIfPresent(String.self, forKey: .branch))
|
||||
case let other:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .t, in: c, debugDescription: "Unknown ClientMsg \(other)")
|
||||
@@ -165,6 +193,26 @@ extension ClientMsg: Codable {
|
||||
try c.encode(mode, forKey: .mode)
|
||||
case .discard(let id):
|
||||
try c.encode("discard", forKey: .t); try c.encode(id, forKey: .sessionID)
|
||||
case .setSessionModel(let id, let model):
|
||||
try c.encode("setSessionModel", forKey: .t)
|
||||
try c.encode(id, forKey: .sessionID)
|
||||
try c.encodeIfPresent(model, forKey: .model)
|
||||
case .setSessionEffort(let id, let effort):
|
||||
try c.encode("setSessionEffort", forKey: .t)
|
||||
try c.encode(id, forKey: .sessionID)
|
||||
try c.encodeIfPresent(effort, forKey: .effort)
|
||||
case .setSessionAuto(let id, let flag):
|
||||
try c.encode("setSessionAuto", forKey: .t)
|
||||
try c.encode(id, forKey: .sessionID)
|
||||
try c.encode(flag, forKey: .flag)
|
||||
case .setSessionAutoShip(let id, let flag):
|
||||
try c.encode("setSessionAutoShip", forKey: .t)
|
||||
try c.encode(id, forKey: .sessionID)
|
||||
try c.encode(flag, forKey: .flag)
|
||||
case .setSessionShipBranch(let id, let branch):
|
||||
try c.encode("setSessionShipBranch", forKey: .t)
|
||||
try c.encode(id, forKey: .sessionID)
|
||||
try c.encodeIfPresent(branch, forKey: .branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
|
||||
/// The model/effort catalog, projected over the wire so the phone's composer and session
|
||||
/// header can offer the *same* model and reasoning choices the Mac does — with the same
|
||||
/// per-backend effort caps (Codex tops out at `xhigh`, Grok exposes only `auto`) and the
|
||||
/// same context-window sizes — without re-deriving any backend rules on-device.
|
||||
///
|
||||
/// The host builds this from its `ModelCatalog` (a single source of truth) and sends it once
|
||||
/// in `Welcome`. Everything the phone needs to render a picker is precomputed here; the phone
|
||||
/// treats it as opaque data (SYNC_PROTOCOL §5.2).
|
||||
public struct WireModelCatalog: Sendable, Codable, Equatable {
|
||||
|
||||
/// One selectable model SKU, fully described. The `sku` is the exact string the phone
|
||||
/// passes back in `StartChatRequest.model` / `setSessionModel`; it also selects the
|
||||
/// backend (`BackendID.forModel`), already resolved into `backend` here.
|
||||
public struct Model: Sendable, Codable, Equatable, Identifiable {
|
||||
public let sku: String
|
||||
public let displayName: String // "Opus 4.8"
|
||||
public let backend: BackendID
|
||||
/// Context-window annotation that distinguishes same-named SKUs ("1M" vs "256K"); nil otherwise.
|
||||
public let contextBadge: String?
|
||||
public let contextWindow: Int // approximate input window, tokens
|
||||
/// Supported effort levels, lowest→highest, with per-backend caps already applied
|
||||
/// (so the phone shows exactly these and never an unsupported one).
|
||||
public let efforts: [String]
|
||||
/// The reasoning control's noun for this model — "Effort" (Claude) or "Reasoning" (Codex/Grok).
|
||||
public let effortNoun: String
|
||||
|
||||
public var id: String { sku }
|
||||
|
||||
public init(
|
||||
sku: String, displayName: String, backend: BackendID, contextBadge: String?,
|
||||
contextWindow: Int, efforts: [String], effortNoun: String
|
||||
) {
|
||||
self.sku = sku
|
||||
self.displayName = displayName
|
||||
self.backend = backend
|
||||
self.contextBadge = contextBadge
|
||||
self.contextWindow = contextWindow
|
||||
self.efforts = efforts
|
||||
self.effortNoun = effortNoun
|
||||
}
|
||||
}
|
||||
|
||||
/// Models split into per-provider runs (Claude, then GPT, then Grok), preserving order so
|
||||
/// the phone's model menu can set each provider apart with a divider — mirrors the Mac.
|
||||
public let groups: [[Model]]
|
||||
/// Pretty labels for non-identity effort levels (e.g. `"auto" → "Auto"`, the orchestra
|
||||
/// sentinel → "Orchestra"); plain API levels fall back to themselves.
|
||||
public let effortDisplayNames: [String: String]
|
||||
/// The effort-menu sentinel that selects Orchestra (an orchestration mode, not an API level).
|
||||
public let orchestraSentinel: String
|
||||
/// Short grayed annotation shown when Orchestra is unavailable (non-Control project).
|
||||
public let orchestraRequiresControlNote: String
|
||||
public let fallbackModel: String
|
||||
public let fallbackEffort: String
|
||||
|
||||
public init(
|
||||
groups: [[Model]], effortDisplayNames: [String: String], orchestraSentinel: String,
|
||||
orchestraRequiresControlNote: String, fallbackModel: String, fallbackEffort: String
|
||||
) {
|
||||
self.groups = groups
|
||||
self.effortDisplayNames = effortDisplayNames
|
||||
self.orchestraSentinel = orchestraSentinel
|
||||
self.orchestraRequiresControlNote = orchestraRequiresControlNote
|
||||
self.fallbackModel = fallbackModel
|
||||
self.fallbackEffort = fallbackEffort
|
||||
}
|
||||
|
||||
// MARK: - Convenience lookups (phone-side, pure reads)
|
||||
|
||||
public var allModels: [Model] { groups.flatMap { $0 } }
|
||||
|
||||
public func model(_ sku: String?) -> Model? {
|
||||
guard let sku else { return nil }
|
||||
return allModels.first { $0.sku == sku }
|
||||
}
|
||||
|
||||
/// SKUs that run on `backend` — for the in-session model menu, where the backend is fixed
|
||||
/// at creation. Returns the SKUs grouped contiguously by provider, flattened.
|
||||
public func models(for backend: BackendID) -> [Model] {
|
||||
allModels.filter { model in
|
||||
switch backend {
|
||||
case .claudeCode: return model.backend == .claudeCode
|
||||
case .codex, .codexExec: return model.backend == .codex
|
||||
case .grok: return model.backend == .grok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effort levels supported by `sku`, or the global fallback set if unknown.
|
||||
public func efforts(forModel sku: String?) -> [String] {
|
||||
model(sku)?.efforts ?? [fallbackEffort]
|
||||
}
|
||||
|
||||
public func displayName(_ sku: String?) -> String {
|
||||
model(sku)?.displayName ?? sku ?? fallbackModel
|
||||
}
|
||||
|
||||
public func contextBadge(_ sku: String?) -> String? { model(sku)?.contextBadge }
|
||||
|
||||
public func contextWindow(_ sku: String?) -> Int { model(sku)?.contextWindow ?? 200_000 }
|
||||
|
||||
public func effortNoun(forModel sku: String?) -> String { model(sku)?.effortNoun ?? "Effort" }
|
||||
|
||||
/// Pretty label for an effort level — Orchestra/Auto get named labels; plain levels read as-is.
|
||||
public func effortDisplayName(_ effort: String) -> String {
|
||||
effortDisplayNames[effort] ?? effort
|
||||
}
|
||||
|
||||
/// Whether `effort` selects Orchestra (case-insensitive against the sentinel).
|
||||
public func isOrchestra(_ effort: String?) -> Bool {
|
||||
guard let effort else { return false }
|
||||
return effort.caseInsensitiveCompare(orchestraSentinel) == .orderedSame
|
||||
}
|
||||
|
||||
/// An empty catalog — the default before a `Welcome` arrives and the host-side fallback.
|
||||
public static let empty = WireModelCatalog(
|
||||
groups: [], effortDisplayNames: [:], orchestraSentinel: "orchestra",
|
||||
orchestraRequiresControlNote: "Requires Nucleic Control",
|
||||
fallbackModel: "", fallbackEffort: "high")
|
||||
}
|
||||
@@ -88,15 +88,34 @@ public struct Welcome: Sendable, Codable, Equatable {
|
||||
public let grantedScope: DeviceScope
|
||||
public let host: HostInfo
|
||||
public let capabilities: WireCapabilities
|
||||
/// The model/effort catalog the phone's pickers render (SYNC §5.2). Optional on the wire so
|
||||
/// a phone talking to a host that predates the field decodes it as an empty catalog and falls
|
||||
/// back to its built-in effort list.
|
||||
public let modelCatalog: WireModelCatalog
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = SyncProtocol.version,
|
||||
grantedScope: DeviceScope, host: HostInfo, capabilities: WireCapabilities
|
||||
grantedScope: DeviceScope, host: HostInfo, capabilities: WireCapabilities,
|
||||
modelCatalog: WireModelCatalog = .empty
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.grantedScope = grantedScope
|
||||
self.host = host
|
||||
self.capabilities = capabilities
|
||||
self.modelCatalog = modelCatalog
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case protocolVersion, grantedScope, host, capabilities, modelCatalog
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.protocolVersion = try c.decode(Int.self, forKey: .protocolVersion)
|
||||
self.grantedScope = try c.decode(DeviceScope.self, forKey: .grantedScope)
|
||||
self.host = try c.decode(HostInfo.self, forKey: .host)
|
||||
self.capabilities = try c.decode(WireCapabilities.self, forKey: .capabilities)
|
||||
self.modelCatalog = try c.decodeIfPresent(WireModelCatalog.self, forKey: .modelCatalog) ?? .empty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +153,19 @@ public struct SessionSummary: Sendable, Codable, Equatable {
|
||||
/// the current turn finishes. `nil` = nothing queued. (Optional so older hosts/clients
|
||||
/// that omit the key decode it as "no queued message".)
|
||||
public let queuedMessage: String?
|
||||
/// The session's current model SKU / effort level — what the phone's header pickers show as
|
||||
/// selected and mutate via `setSessionModel` / `setSessionEffort`. `nil` = host/app default.
|
||||
public let model: String?
|
||||
public let effort: String?
|
||||
/// Auto-approval and autoship state (mirrors the Mac header toggles). Default `false` so
|
||||
/// summaries from a host that predates these fields decode cleanly.
|
||||
public let auto: Bool
|
||||
public let autoShip: Bool
|
||||
/// Per-session autoship destination override; `nil` inherits the project default.
|
||||
public let shipBranch: String?
|
||||
/// The latest turn's context-window occupancy (input tokens of the final model call), so the
|
||||
/// phone can show a context-usage badge without scanning the transcript. `nil` if unknown.
|
||||
public let contextInputTokens: Int?
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
@@ -141,6 +173,8 @@ public struct SessionSummary: Sendable, Codable, Equatable {
|
||||
status: SessionStatus, disposition: TurnDisposition? = nil, title: String, branch: String,
|
||||
lastSeq: UInt64, diffStat: DiffStat?, pendingApprovalCount: Int = 0,
|
||||
favorite: Bool = false, archived: Bool = false, queuedMessage: String? = nil,
|
||||
model: String? = nil, effort: String? = nil, auto: Bool = false, autoShip: Bool = false,
|
||||
shipBranch: String? = nil, contextInputTokens: Int? = nil,
|
||||
updatedAt: Date
|
||||
) {
|
||||
self.sessionID = sessionID
|
||||
@@ -157,8 +191,46 @@ public struct SessionSummary: Sendable, Codable, Equatable {
|
||||
self.favorite = favorite
|
||||
self.archived = archived
|
||||
self.queuedMessage = queuedMessage
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.auto = auto
|
||||
self.autoShip = autoShip
|
||||
self.shipBranch = shipBranch
|
||||
self.contextInputTokens = contextInputTokens
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionID, projectID, projectName, backend, status, disposition, title, branch
|
||||
case lastSeq, diffStat, pendingApprovalCount, favorite, archived, queuedMessage
|
||||
case model, effort, auto, autoShip, shipBranch, contextInputTokens, updatedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.sessionID = try c.decode(SessionID.self, forKey: .sessionID)
|
||||
self.projectID = try c.decode(String.self, forKey: .projectID)
|
||||
self.projectName = try c.decode(String.self, forKey: .projectName)
|
||||
self.backend = try c.decode(BackendID.self, forKey: .backend)
|
||||
self.status = try c.decode(SessionStatus.self, forKey: .status)
|
||||
self.disposition = try c.decodeIfPresent(TurnDisposition.self, forKey: .disposition)
|
||||
self.title = try c.decode(String.self, forKey: .title)
|
||||
self.branch = try c.decode(String.self, forKey: .branch)
|
||||
self.lastSeq = try c.decode(UInt64.self, forKey: .lastSeq)
|
||||
self.diffStat = try c.decodeIfPresent(DiffStat.self, forKey: .diffStat)
|
||||
self.pendingApprovalCount = try c.decodeIfPresent(Int.self, forKey: .pendingApprovalCount) ?? 0
|
||||
self.favorite = try c.decodeIfPresent(Bool.self, forKey: .favorite) ?? false
|
||||
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
|
||||
self.queuedMessage = try c.decodeIfPresent(String.self, forKey: .queuedMessage)
|
||||
// New fields — tolerate summaries from a host that predates them.
|
||||
self.model = try c.decodeIfPresent(String.self, forKey: .model)
|
||||
self.effort = try c.decodeIfPresent(String.self, forKey: .effort)
|
||||
self.auto = try c.decodeIfPresent(Bool.self, forKey: .auto) ?? false
|
||||
self.autoShip = try c.decodeIfPresent(Bool.self, forKey: .autoShip) ?? false
|
||||
self.shipBranch = try c.decodeIfPresent(String.self, forKey: .shipBranch)
|
||||
self.contextInputTokens = try c.decodeIfPresent(Int.self, forKey: .contextInputTokens)
|
||||
self.updatedAt = try c.decode(Date.self, forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the phone asks the host to land a session's branch (control scope). Mirrors the Mac's
|
||||
|
||||
@@ -147,6 +147,45 @@ import NucleicProtocol
|
||||
await host.stop()
|
||||
}
|
||||
|
||||
@Test func welcomeCarriesModelCatalog() async throws {
|
||||
let catalog = WireModelCatalog(
|
||||
groups: [[WireModelCatalog.Model(
|
||||
sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode,
|
||||
contextBadge: "256K", contextWindow: 256_000, efforts: ["low", "high"],
|
||||
effortNoun: "Effort")]],
|
||||
effortDisplayNames: ["orchestra": "Orchestra"], orchestraSentinel: "orchestra",
|
||||
orchestraRequiresControlNote: "Requires Nucleic Control",
|
||||
fallbackModel: "claude-opus-4-8", fallbackEffort: "high")
|
||||
let bridge = FakeSyncBridge(modelCatalog: catalog, sessions: [summary("s1")])
|
||||
let (host, _, _, recorder, _) = try await makePaired(bridge: bridge)
|
||||
|
||||
let ready = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }
|
||||
guard case .ready(let welcome) = ready else { Issue.record("no welcome"); return }
|
||||
#expect(welcome.modelCatalog == catalog)
|
||||
#expect(welcome.modelCatalog.efforts(forModel: "claude-opus-4-8") == ["low", "high"])
|
||||
await host.stop()
|
||||
}
|
||||
|
||||
@Test func midSessionSettersReachHost() async throws {
|
||||
let sid = SessionID(rawValue: "s1")
|
||||
let bridge = FakeSyncBridge(sessions: [summary("s1")])
|
||||
let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
|
||||
_ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }
|
||||
|
||||
await client.send(.setSessionModel(sid, "claude-sonnet-4-6"))
|
||||
await client.send(.setSessionEffort(sid, "xhigh"))
|
||||
await client.send(.setSessionAuto(sid, true))
|
||||
await client.send(.setSessionAutoShip(sid, true))
|
||||
await client.send(.setSessionShipBranch(sid, "main"))
|
||||
|
||||
#expect(await poll { await bridge.sessionModels }.first?.1 == "claude-sonnet-4-6")
|
||||
#expect(await poll { await bridge.sessionEfforts }.first?.1 == "xhigh")
|
||||
#expect(await poll { await bridge.sessionAutos }.first?.1 == true)
|
||||
#expect(await poll { await bridge.sessionAutoShips }.first?.1 == true)
|
||||
#expect(await poll { await bridge.sessionShipBranches }.first?.1 == "main")
|
||||
await host.stop()
|
||||
}
|
||||
|
||||
@Test func dashboardRequestReturnsSnapshot() async throws {
|
||||
let bridge = FakeSyncBridge(sessions: [summary("s1")])
|
||||
let snapshot = DashboardSnapshot(
|
||||
|
||||
@@ -64,6 +64,7 @@ final class ManualListener: SyncListener, @unchecked Sendable {
|
||||
actor FakeSyncBridge: SyncHostBridge {
|
||||
let hostInfo: HostInfo
|
||||
let capabilities: WireCapabilities
|
||||
let modelCatalog: WireModelCatalog
|
||||
|
||||
private var sessions: [WireSessionSummary]
|
||||
private var snapshots: [SessionID: SessionSnapshot]
|
||||
@@ -77,11 +78,13 @@ actor FakeSyncBridge: SyncHostBridge {
|
||||
init(
|
||||
hostInfo: HostInfo = HostInfo(hostID: "host", hostName: "Andrew's Mac"),
|
||||
capabilities: WireCapabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName]),
|
||||
modelCatalog: WireModelCatalog = .empty,
|
||||
sessions: [WireSessionSummary] = [],
|
||||
snapshots: [SessionID: SessionSnapshot] = [:]
|
||||
) {
|
||||
self.hostInfo = hostInfo
|
||||
self.capabilities = capabilities
|
||||
self.modelCatalog = modelCatalog
|
||||
self.sessions = sessions
|
||||
self.snapshots = snapshots
|
||||
}
|
||||
@@ -108,6 +111,11 @@ actor FakeSyncBridge: SyncHostBridge {
|
||||
private(set) var deletedSessions: [SessionID] = []
|
||||
private(set) var integrations: [(SessionID, IntegrationMode)] = []
|
||||
private(set) var discards: [SessionID] = []
|
||||
private(set) var sessionModels: [(SessionID, String?)] = []
|
||||
private(set) var sessionEfforts: [(SessionID, String?)] = []
|
||||
private(set) var sessionAutos: [(SessionID, Bool)] = []
|
||||
private(set) var sessionAutoShips: [(SessionID, Bool)] = []
|
||||
private(set) var sessionShipBranches: [(SessionID, String?)] = []
|
||||
var dashboard = DashboardSnapshot.empty
|
||||
|
||||
func dashboardSnapshot() async -> DashboardSnapshot { dashboard }
|
||||
@@ -122,6 +130,11 @@ actor FakeSyncBridge: SyncHostBridge {
|
||||
func deleteSessionRemote(_ id: SessionID) async throws { deletedSessions.append(id) }
|
||||
func integrate(_ id: SessionID, _ mode: IntegrationMode) async throws { integrations.append((id, mode)) }
|
||||
func discard(_ id: SessionID) async throws { discards.append(id) }
|
||||
func setSessionModel(_ id: SessionID, _ model: String?) async throws { sessionModels.append((id, model)) }
|
||||
func setSessionEffort(_ id: SessionID, _ effort: String?) async throws { sessionEfforts.append((id, effort)) }
|
||||
func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws { sessionAutos.append((id, auto)) }
|
||||
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws { sessionAutoShips.append((id, autoShip)) }
|
||||
func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws { sessionShipBranches.append((id, branch)) }
|
||||
|
||||
func broadcasts() -> AsyncStream<HostBroadcast> {
|
||||
AsyncStream { continuation in broadcastContinuation = continuation }
|
||||
|
||||
@@ -44,10 +44,97 @@ import Testing
|
||||
.deleteSession(sid),
|
||||
.integrate(sid, .squash),
|
||||
.discard(sid),
|
||||
// Mid-session controls — exercise both the value and the nil (reset) variants.
|
||||
.setSessionModel(sid, "claude-sonnet-4-6"),
|
||||
.setSessionModel(sid, nil),
|
||||
.setSessionEffort(sid, "xhigh"),
|
||||
.setSessionEffort(sid, nil),
|
||||
.setSessionAuto(sid, true),
|
||||
.setSessionAutoShip(sid, false),
|
||||
.setSessionShipBranch(sid, "main"),
|
||||
.setSessionShipBranch(sid, nil),
|
||||
]
|
||||
for msg in msgs { #expect(try roundTrip(msg) == msg) }
|
||||
}
|
||||
|
||||
private let catalog = WireModelCatalog(
|
||||
groups: [
|
||||
[WireModelCatalog.Model(
|
||||
sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode,
|
||||
contextBadge: "256K", contextWindow: 256_000,
|
||||
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
|
||||
[WireModelCatalog.Model(
|
||||
sku: "grok-build", displayName: "Grok Build", backend: .grok,
|
||||
contextBadge: nil, contextWindow: 256_000, efforts: ["auto"], effortNoun: "Reasoning")],
|
||||
],
|
||||
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
|
||||
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
|
||||
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
|
||||
|
||||
@Test func modelCatalogRoundTripsAndLooksUp() throws {
|
||||
#expect(try roundTrip(catalog) == catalog)
|
||||
// The phone reads everything it needs straight off the projection — no backend logic.
|
||||
#expect(catalog.models(for: .grok).map(\.sku) == ["grok-build"])
|
||||
#expect(catalog.efforts(forModel: "grok-build") == ["auto"])
|
||||
#expect(catalog.efforts(forModel: "claude-opus-4-8").last == "max")
|
||||
#expect(catalog.contextWindow("claude-opus-4-8") == 256_000)
|
||||
#expect(catalog.effortNoun(forModel: "grok-build") == "Reasoning")
|
||||
#expect(catalog.effortDisplayName("auto") == "Auto")
|
||||
#expect(catalog.isOrchestra("orchestra"))
|
||||
#expect(!catalog.isOrchestra("high"))
|
||||
}
|
||||
|
||||
@Test func welcomeCarriesCatalog() throws {
|
||||
let welcome = Welcome(
|
||||
grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Andrew's Mac"),
|
||||
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session]),
|
||||
modelCatalog: catalog)
|
||||
#expect(try roundTrip(HostMsg.welcome(welcome)) == .welcome(welcome))
|
||||
}
|
||||
|
||||
@Test func sessionSummaryNewFieldsRoundTrip() throws {
|
||||
let rich = SessionSummary(
|
||||
sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
|
||||
backend: .claudeCode, status: .running, title: "t", branch: "b", lastSeq: 5,
|
||||
diffStat: nil, model: "claude-sonnet-4-6", effort: "xhigh", auto: true,
|
||||
autoShip: true, shipBranch: "main", contextInputTokens: 84_000,
|
||||
updatedAt: Date(timeIntervalSince1970: 1))
|
||||
let back = try roundTrip(rich)
|
||||
#expect(back.model == "claude-sonnet-4-6")
|
||||
#expect(back.effort == "xhigh")
|
||||
#expect(back.auto == true)
|
||||
#expect(back.autoShip == true)
|
||||
#expect(back.shipBranch == "main")
|
||||
#expect(back.contextInputTokens == 84_000)
|
||||
}
|
||||
|
||||
@Test func sessionSummaryToleratesMissingNewFields() throws {
|
||||
// A summary encoded by a host that predates the model/effort/auto/autoShip fields:
|
||||
// it must decode with safe defaults rather than throwing (SYNC §9 forward-compat).
|
||||
struct LegacySummary: Encodable {
|
||||
let sessionID = SessionID(rawValue: "s1")
|
||||
let projectID = "p1"
|
||||
let projectName = "ProjA"
|
||||
let backend = BackendID.claudeCode
|
||||
let status = SessionStatus.running
|
||||
let title = "t"
|
||||
let branch = "b"
|
||||
let lastSeq: UInt64 = 5
|
||||
let updatedAt = Date(timeIntervalSince1970: 1)
|
||||
}
|
||||
let data = try CBOREncoder().encode(LegacySummary())
|
||||
let decoded = try CBORDecoder().decode(SessionSummary.self, from: data)
|
||||
#expect(decoded.model == nil)
|
||||
#expect(decoded.effort == nil)
|
||||
#expect(decoded.auto == false)
|
||||
#expect(decoded.autoShip == false)
|
||||
#expect(decoded.shipBranch == nil)
|
||||
#expect(decoded.contextInputTokens == nil)
|
||||
// Pre-existing non-optional fields also fall back rather than throwing.
|
||||
#expect(decoded.favorite == false)
|
||||
#expect(decoded.pendingApprovalCount == 0)
|
||||
}
|
||||
|
||||
@Test func dashboardRoundTrips() throws {
|
||||
let snapshot = DashboardSnapshot(
|
||||
counts: DashboardCounts(projects: 2, chats: 7, activeChats: 3, messages: 140, activeDays: 5),
|
||||
|
||||
@@ -38,6 +38,9 @@ final class RemoteStore: ObservableObject {
|
||||
@Published private(set) var sessions: [WireSessionSummary] = []
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
|
||||
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
|
||||
@Published private(set) var modelCatalog: WireModelCatalog = .empty
|
||||
|
||||
/// Home / Projects / To-Dos state — the dashboard projection.
|
||||
@Published private(set) var dashboard = DashboardSnapshot.empty
|
||||
@@ -85,6 +88,24 @@ final class RemoteStore: ObservableObject {
|
||||
hostName = "Andrew's Mac"
|
||||
grantedScope = .control
|
||||
capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
|
||||
modelCatalog = WireModelCatalog(
|
||||
groups: [
|
||||
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
|
||||
contextBadge: "1M", contextWindow: 1_000_000,
|
||||
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
|
||||
WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
|
||||
contextBadge: nil, contextWindow: 200_000,
|
||||
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
|
||||
[WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
|
||||
contextBadge: nil, contextWindow: 350_000,
|
||||
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
|
||||
[WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
|
||||
contextBadge: nil, contextWindow: 256_000,
|
||||
efforts: ["auto"], effortNoun: "Reasoning")],
|
||||
],
|
||||
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
|
||||
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
|
||||
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
|
||||
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
|
||||
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
|
||||
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
|
||||
@@ -179,9 +200,37 @@ final class RemoteStore: ObservableObject {
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
seenSeq.removeAll()
|
||||
if demoMode { seedDemoTranscript(sessionID); return }
|
||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
}
|
||||
|
||||
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
|
||||
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
|
||||
private func seedDemoTranscript(_ sessionID: SessionID) {
|
||||
func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
|
||||
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
|
||||
nativeType: nil, kind: kind)
|
||||
}
|
||||
openEvents = [
|
||||
event(1, .sessionStarted(SessionStarted(
|
||||
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
|
||||
event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
|
||||
event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
|
||||
event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
|
||||
event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
|
||||
event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
|
||||
event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
|
||||
event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
|
||||
event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
|
||||
event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
|
||||
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
|
||||
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
|
||||
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
|
||||
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
|
||||
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
||||
]
|
||||
}
|
||||
|
||||
func closeOpen() {
|
||||
if let id = openSessionID { send(.unsubscribe(id)) }
|
||||
openSessionID = nil
|
||||
@@ -238,6 +287,14 @@ final class RemoteStore: ObservableObject {
|
||||
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
|
||||
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
|
||||
|
||||
// Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
|
||||
// `nil` model/effort resets the session to the host/app default.
|
||||
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
|
||||
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
|
||||
func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
|
||||
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
|
||||
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func send(_ msg: ClientMsg) {
|
||||
@@ -272,6 +329,7 @@ final class RemoteStore: ObservableObject {
|
||||
hostName = welcome.host.hostName
|
||||
capabilities = welcome.capabilities
|
||||
grantedScope = welcome.grantedScope
|
||||
modelCatalog = welcome.modelCatalog
|
||||
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
|
||||
IdentityStore.savePairedHost(PairedHost(
|
||||
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
||||
|
||||
@@ -8,12 +8,14 @@ struct StartChatComposer: View {
|
||||
@State private var projectID: ProjectID?
|
||||
@State private var draft = ""
|
||||
@State private var auto = false
|
||||
@State private var model: String?
|
||||
@State private var effort = MobileEfforts.fallback
|
||||
|
||||
private var projects: [WireProject] { store.dashboard.projects }
|
||||
private var selected: WireProject? {
|
||||
projects.first { $0.id == projectID } ?? projects.first
|
||||
}
|
||||
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -28,7 +30,6 @@ struct StartChatComposer: View {
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, controlled: selected?.isNucleicControlled ?? false)
|
||||
Toggle(isOn: $auto) {
|
||||
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
|
||||
}
|
||||
@@ -36,14 +37,20 @@ struct StartChatComposer: View {
|
||||
.tint(Palette.accent)
|
||||
.font(.caption)
|
||||
}
|
||||
HStack {
|
||||
// The model also selects the backend, so the home composer offers every provider.
|
||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Describe a task…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && (selected?.isNucleicControlled ?? false))
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
|
||||
Button {
|
||||
if let project = selected {
|
||||
store.startChat(in: project.id, message: draft, effort: effort, auto: auto)
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
|
||||
draft = ""
|
||||
}
|
||||
} label: {
|
||||
@@ -56,6 +63,14 @@ struct StartChatComposer: View {
|
||||
}
|
||||
}
|
||||
.card()
|
||||
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
|
||||
.onChange(of: model) { _, newModel in
|
||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
||||
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
||||
effort = levels.last ?? MobileEfforts.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ struct ProjectDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let project: WireProject
|
||||
@State private var draft = ""
|
||||
@State private var model: String?
|
||||
@State private var effort = MobileEfforts.fallback
|
||||
|
||||
private var sessions: [WireSessionSummary] {
|
||||
@@ -60,15 +61,17 @@ struct ProjectDetailView: View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
EffortMenu(effort: $effort, controlled: project.isNucleicControlled)
|
||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model,
|
||||
controlled: project.isNucleicControlled)
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder).lineLimit(1...4)
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled)
|
||||
Button {
|
||||
store.startChat(in: project.id, message: draft, effort: effort)
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort)
|
||||
draft = ""
|
||||
} label: { Image(systemName: "arrow.up.circle.fill").font(.title2) }
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
||||
@@ -88,5 +91,12 @@ struct ProjectDetailView: View {
|
||||
}
|
||||
.navigationTitle(project.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: model) { _, newModel in
|
||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
||||
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
||||
effort = levels.last ?? MobileEfforts.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ struct SessionDetailView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if store.canControl, let summary { controlBar(summary) }
|
||||
|
||||
Divider()
|
||||
|
||||
if tab == 0 {
|
||||
@@ -58,6 +60,67 @@ struct SessionDetailView: View {
|
||||
.onDisappear { store.closeOpen() }
|
||||
}
|
||||
|
||||
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
|
||||
private var controlled: Bool {
|
||||
store.dashboard.projects.first { $0.id.rawValue == summary?.projectID }?.isNucleicControlled ?? false
|
||||
}
|
||||
|
||||
private var modelBinding: Binding<String?> {
|
||||
Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) })
|
||||
}
|
||||
private var effortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
let fallback = store.modelCatalog.fallbackEffort.isEmpty
|
||||
? MobileEfforts.fallback : store.modelCatalog.fallbackEffort
|
||||
return summary?.effort ?? fallback
|
||||
},
|
||||
set: { store.setSessionEffort(sessionID, $0) })
|
||||
}
|
||||
|
||||
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
|
||||
/// from the transcript exactly as the Mac header does.
|
||||
private var contextPercent: Int? {
|
||||
let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in
|
||||
switch event.kind {
|
||||
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
|
||||
case .usage(let usage): return usage.contextInputTokens
|
||||
default: return nil
|
||||
}
|
||||
}.first { $0 > 0 }
|
||||
guard let used else { return nil }
|
||||
let window = store.modelCatalog.contextWindow(summary?.model)
|
||||
guard window > 0 else { return nil }
|
||||
return min(100, Int((Double(used) / Double(window)) * 100))
|
||||
}
|
||||
|
||||
/// The model / effort / auto controls for the open session — the mobile echo of the Mac's
|
||||
/// session header. Reads current state from the summary; each change is a control intent.
|
||||
@ViewBuilder
|
||||
private func controlBar(_ summary: WireSessionSummary) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
||||
// No explicit model yet → use the session backend's default, so the menu
|
||||
// shows that backend's effort range rather than collapsing to one level.
|
||||
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
|
||||
controlled: controlled)
|
||||
Spacer(minLength: 4)
|
||||
if let percent = contextPercent {
|
||||
Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.help("Context window used")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) {
|
||||
Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash")
|
||||
}
|
||||
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionMenu(_ summary: WireSessionSummary) -> some View {
|
||||
Menu {
|
||||
@@ -68,6 +131,14 @@ struct SessionDetailView: View {
|
||||
Label(summary.favorite ? "Unfavorite" : "Favorite",
|
||||
systemImage: summary.favorite ? "star.slash" : "star")
|
||||
}
|
||||
// Autoship is a Nucleic Control capability — offered only for Control projects, and
|
||||
// the host couples it with auto-approval (enabling it turns Auto on).
|
||||
if controlled {
|
||||
Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: {
|
||||
Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship",
|
||||
systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox")
|
||||
}
|
||||
}
|
||||
if summary.status == .running {
|
||||
Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") }
|
||||
}
|
||||
@@ -140,19 +211,27 @@ struct SessionDetailView: View {
|
||||
|
||||
struct TranscriptList: View {
|
||||
let events: [AgentEvent]
|
||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||
|
||||
private var items: [TranscriptItem] {
|
||||
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(events, id: \.seq) { event in
|
||||
TranscriptRow(event: event).id(event.seq)
|
||||
ForEach(items) { item in
|
||||
TranscriptRow(item: item).id(item.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
// Coalescing means item count lags event count; key the autoscroll on the raw stream
|
||||
// so every streamed delta keeps the view pinned to the bottom.
|
||||
.onChange(of: events.count) {
|
||||
if let last = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } }
|
||||
if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -22,6 +24,15 @@ struct SettingsView: View {
|
||||
.font(.footnote.monospaced())
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show lock events", isOn: $showLockEvents)
|
||||
Toggle("Show raw events", isOn: $showRaw)
|
||||
} header: {
|
||||
Text("Transcript")
|
||||
} footer: {
|
||||
Text("Raw events are unrecognized backend output, shown for debugging.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showScanner = true
|
||||
|
||||
@@ -137,31 +137,110 @@ enum MobileEfforts {
|
||||
static func displayName(_ effort: String) -> String { isOrchestra(effort) ? "Orchestra" : effort }
|
||||
}
|
||||
|
||||
/// A compact effort selector for the remote composers. Orchestra sits below the API levels,
|
||||
/// set apart with a sparkles glyph and the purple accent. Orchestra is a Nucleic Control
|
||||
/// capability, so it's only selectable when the target project is under Control — disabled and
|
||||
/// labeled otherwise.
|
||||
extension WireModelCatalog {
|
||||
/// Effort levels to offer for `sku`. When no model is selected yet, fall back to the default
|
||||
/// model's range (not the lone fallback level), and to the built-in list before a `Welcome`
|
||||
/// catalog has arrived (an empty catalog, e.g. demo / older host).
|
||||
func offeredEfforts(forModel sku: String?) -> [String] {
|
||||
guard !groups.isEmpty else { return MobileEfforts.levels }
|
||||
let levels = efforts(forModel: sku ?? fallbackModel)
|
||||
return levels.isEmpty ? MobileEfforts.levels : levels
|
||||
}
|
||||
/// The orchestra sentinel, or the built-in default when no catalog is present.
|
||||
var orchestraSentinelOrFallback: String {
|
||||
orchestraSentinel.isEmpty ? MobileEfforts.orchestraSentinel : orchestraSentinel
|
||||
}
|
||||
/// Pretty label for an effort level, tolerating the empty (pre-`Welcome`) catalog.
|
||||
func offeredEffortDisplayName(_ effort: String) -> String {
|
||||
groups.isEmpty ? MobileEfforts.displayName(effort) : effortDisplayName(effort)
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact model selector driven by the wire catalog (SYNC §5.2). `backend == nil` — the
|
||||
/// home composer, where the model also selects the backend — shows every provider group with
|
||||
/// dividers; a fixed `backend` (in-session, where the backend is immutable) shows only that
|
||||
/// provider's models. Selecting sets the SKU; the binding's `nil` means "host default".
|
||||
struct ModelMenu: View {
|
||||
@Binding var model: String?
|
||||
let catalog: WireModelCatalog
|
||||
var backend: BackendID? = nil
|
||||
|
||||
private var groups: [[WireModelCatalog.Model]] {
|
||||
if let backend {
|
||||
let models = catalog.models(for: backend)
|
||||
return models.isEmpty ? [] : [models]
|
||||
}
|
||||
return catalog.groups
|
||||
}
|
||||
private var label: String { model.map { catalog.displayName($0) } ?? "Default model" }
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(Array(groups.enumerated()), id: \.offset) { index, group in
|
||||
if index > 0 { Divider() }
|
||||
ForEach(group) { item in
|
||||
Button { model = item.sku } label: { menuLabel(item) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "cpu")
|
||||
Text(label)
|
||||
if let badge = model.flatMap({ catalog.contextBadge($0) }) {
|
||||
Text(badge).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
.disabled(groups.isEmpty)
|
||||
}
|
||||
|
||||
@ViewBuilder private func menuLabel(_ item: WireModelCatalog.Model) -> some View {
|
||||
let name = item.contextBadge.map { "\(item.displayName) · \($0)" } ?? item.displayName
|
||||
if item.sku == model { Label(name, systemImage: "checkmark") } else { Text(name) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact effort selector for the remote composers, driven by the wire catalog so it offers
|
||||
/// exactly the levels the chosen model supports (Codex caps at xhigh, Grok exposes only Auto).
|
||||
/// Orchestra sits below the API levels with a sparkles glyph and gold accent; it's a Nucleic
|
||||
/// Control capability, so it's only selectable when the target project is under Control.
|
||||
struct EffortMenu: View {
|
||||
@Binding var effort: String
|
||||
let catalog: WireModelCatalog
|
||||
/// The model whose effort caps apply (nil → fall back to the built-in level list).
|
||||
var modelSKU: String? = nil
|
||||
/// Whether the target project is under Nucleic Control (gates Orchestra).
|
||||
var controlled: Bool = false
|
||||
|
||||
private var levels: [String] { catalog.offeredEfforts(forModel: modelSKU) }
|
||||
private var sentinel: String { catalog.orchestraSentinelOrFallback }
|
||||
private func isOrchestra(_ e: String) -> Bool { e.caseInsensitiveCompare(sentinel) == .orderedSame }
|
||||
private func display(_ e: String) -> String {
|
||||
isOrchestra(e) ? "Orchestra" : catalog.offeredEffortDisplayName(e)
|
||||
}
|
||||
|
||||
/// Orchestra only counts as active where it's permitted; a stray selection on a non-Control
|
||||
/// project reads as its underlying level so the trigger never shows Orchestra where it can't run.
|
||||
private var orchestraActive: Bool { controlled && MobileEfforts.isOrchestra(effort) }
|
||||
/// project (or a level the current model doesn't support) reads as the nearest supported level
|
||||
/// so the trigger never shows something that can't run.
|
||||
private var orchestraActive: Bool { controlled && isOrchestra(effort) }
|
||||
private var triggerEffort: String {
|
||||
orchestraActive ? effort : (MobileEfforts.isOrchestra(effort) ? MobileEfforts.fallback : effort)
|
||||
if orchestraActive { return effort }
|
||||
if isOrchestra(effort) { return levels.last ?? MobileEfforts.fallback }
|
||||
return levels.contains(effort) ? effort : (levels.last ?? MobileEfforts.fallback)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(MobileEfforts.levels, id: \.self) { level in
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button { effort = level } label: {
|
||||
if effort == level { Label(level, systemImage: "checkmark") } else { Text(level) }
|
||||
if effort == level { Label(display(level), systemImage: "checkmark") }
|
||||
else { Text(display(level)) }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button { effort = MobileEfforts.orchestraSentinel } label: {
|
||||
Button { effort = sentinel } label: {
|
||||
Label(
|
||||
controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control",
|
||||
systemImage: orchestraActive ? "checkmark" : "sparkles")
|
||||
@@ -170,7 +249,7 @@ struct EffortMenu: View {
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
|
||||
Text(MobileEfforts.displayName(triggerEffort))
|
||||
Text(display(triggerEffort))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One tool call as a collapsible card — the mobile echo of the Mac's tool group. Collapsed it
|
||||
/// shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full
|
||||
/// input, the result, and any files the call touched. The old remote rendered `started` and
|
||||
/// `completed` as two separate one-line rows and dropped results/file-changes entirely.
|
||||
struct ToolGroupRow: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: { header }.buttonStyle(.plain)
|
||||
if expanded { details }
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(
|
||||
group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ToolGlyph.icon(group.name))
|
||||
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
||||
Text(group.name).font(.caption.weight(.semibold))
|
||||
Text(group.input.compactSummary)
|
||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if !group.finished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder private var details: some View {
|
||||
let input = group.input.approvalDetail
|
||||
if !input.isEmpty {
|
||||
ToolBlock(label: "Input", text: input, mono: true)
|
||||
}
|
||||
if let result = group.result {
|
||||
ToolBlock(label: group.isError ? "Error" : "Result",
|
||||
text: result.compactSummary, mono: true, danger: group.isError)
|
||||
}
|
||||
if !group.fileChanges.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(group.fileChanges, id: \.path) { change in
|
||||
Label(change.path, systemImage: ToolGlyph.fileChange(change.change))
|
||||
.font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent spawn (`Task`/`Agent`) — the gold Orchestra card, mirroring the Mac's
|
||||
/// OrchestrationCard at phone fidelity so an orchestrated run reads distinctly from a plain tool.
|
||||
struct OrchestrationCard: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
private var subtitle: String {
|
||||
group.input["description"]?.stringValue
|
||||
?? group.input["prompt"]?.stringValue
|
||||
?? group.input.compactSummary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").foregroundStyle(Palette.orchestra)
|
||||
Text(group.finished ? "Orchestrated" : "Orchestrating")
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(Palette.orchestra)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if !group.finished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if expanded {
|
||||
ToolBlock(label: "Task", text: group.input.approvalDetail, mono: false)
|
||||
if let result = group.result {
|
||||
ToolBlock(label: "Result", text: result.compactSummary, mono: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Palette.orchestra.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.35), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A labeled, scroll-capped block of monospaced (or prose) text for a tool's input/result.
|
||||
private struct ToolBlock: View {
|
||||
let label: String
|
||||
let text: String
|
||||
var mono: Bool = true
|
||||
var danger: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
Text(text)
|
||||
.font(mono ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(danger ? Palette.danger : .secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxHeight: 220)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbols for the common tools + file-change kinds, so a glance reads the action.
|
||||
enum ToolGlyph {
|
||||
static func icon(_ name: String) -> String {
|
||||
switch name {
|
||||
case "Bash", "Shell": return "terminal"
|
||||
case "Read": return "doc.text"
|
||||
case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil"
|
||||
case "Grep", "Glob", "Search": return "magnifyingglass"
|
||||
case "WebFetch", "WebSearch": return "globe"
|
||||
case "Task", "Agent": return "sparkles"
|
||||
case "TodoWrite": return "checklist"
|
||||
case "AskUserQuestion": return "questionmark.bubble"
|
||||
default: return "wrench.and.screwdriver"
|
||||
}
|
||||
}
|
||||
static func fileChange(_ kind: FileChange.ChangeKind) -> String {
|
||||
switch kind {
|
||||
case .add: return "plus.circle"
|
||||
case .update: return "pencil.circle"
|
||||
case .delete: return "minus.circle"
|
||||
case .rename: return "arrow.triangle.branch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import NucleicProtocol
|
||||
|
||||
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
|
||||
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
|
||||
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
|
||||
/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's
|
||||
/// `TranscriptProjection` at phone fidelity.)
|
||||
struct TranscriptItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
/// The seq of the first event that created this item — stable scroll anchor + ordering.
|
||||
let seq: UInt64
|
||||
var kind: Kind
|
||||
|
||||
enum Role: Equatable { case user, assistant }
|
||||
|
||||
enum Kind: Equatable {
|
||||
case message(role: Role, text: String)
|
||||
case thinking(text: String)
|
||||
case tool(ToolGroup)
|
||||
/// A `Task`/`Agent` spawn — rendered as the gold Orchestra card.
|
||||
case orchestration(ToolGroup)
|
||||
case sessionStarted(model: String, cwd: String)
|
||||
case usage(Usage)
|
||||
case rateLimit(RateLimit)
|
||||
case turnBoundary
|
||||
case approval(toolName: String)
|
||||
case runFinished(outcome: RunFinished.Outcome)
|
||||
case error(message: String)
|
||||
case note(text: String, icon: String?, lockEvent: Bool)
|
||||
case raw(type: String, body: String)
|
||||
}
|
||||
}
|
||||
|
||||
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
||||
struct ToolGroup: Equatable {
|
||||
var toolCallID: String
|
||||
var name: String
|
||||
var input: JSONValue
|
||||
var result: JSONValue?
|
||||
var isError: Bool = false
|
||||
var finished: Bool = false
|
||||
/// Paths the call touched (from `fileChange` events tagged with this tool call).
|
||||
var fileChanges: [FilePatch] = []
|
||||
|
||||
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
|
||||
|
||||
/// Subagent orchestration (`Task`/`Agent`) gets the gold card treatment, like the Mac.
|
||||
var isOrchestration: Bool { name == "Task" || name == "Agent" }
|
||||
var isAskUserQuestion: Bool { name == "AskUserQuestion" }
|
||||
}
|
||||
|
||||
enum TranscriptProjection {
|
||||
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
|
||||
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
|
||||
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
||||
var items: [TranscriptItem] = []
|
||||
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
|
||||
var thinkingIndex: [String: Int] = [:]
|
||||
var toolIndex: [String: Int] = [:] // toolCallID → items index
|
||||
|
||||
for event in events {
|
||||
switch event.kind {
|
||||
case .userText(let chunk):
|
||||
coalesceText(.user, chunk, seq: event.seq, into: &items, index: &messageIndex)
|
||||
case .assistantText(let chunk):
|
||||
coalesceText(.assistant, chunk, seq: event.seq, into: &items, index: &messageIndex)
|
||||
case .thinking(let chunk):
|
||||
coalesceThinking(chunk, seq: event.seq, into: &items, index: &thinkingIndex)
|
||||
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
let finished: Bool = { if case .toolCallCompleted = event.kind { return true } else { return false } }()
|
||||
upsertTool(call, finished: finished, seq: event.seq, into: &items, index: &toolIndex)
|
||||
case .toolCallInputDelta:
|
||||
break // the final `toolCallCompleted` carries the assembled input
|
||||
case .toolResult(let result):
|
||||
attachResult(result, into: &items, index: toolIndex)
|
||||
case .fileChange(let change):
|
||||
attachFileChange(change, seq: event.seq, into: &items, index: toolIndex)
|
||||
|
||||
case .sessionStarted(let started):
|
||||
items.append(.init(id: "start-\(event.seq)", seq: event.seq,
|
||||
kind: .sessionStarted(model: started.model, cwd: started.cwd)))
|
||||
case .usage(let usage):
|
||||
items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage)))
|
||||
case .rateLimit(let limit):
|
||||
items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit)))
|
||||
case .turnCompleted:
|
||||
items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary))
|
||||
case .approvalRequested(let req):
|
||||
items.append(.init(id: "appr-\(event.seq)", seq: event.seq, kind: .approval(toolName: req.toolName)))
|
||||
case .approvalResolved:
|
||||
break // dismissal is reflected in the live approval card, not the transcript
|
||||
case .runFinished(let finished):
|
||||
items.append(.init(id: "fin-\(event.seq)", seq: event.seq,
|
||||
kind: .runFinished(outcome: finished.outcome)))
|
||||
case .error(let err):
|
||||
items.append(.init(id: "err-\(event.seq)", seq: event.seq, kind: .error(message: err.message)))
|
||||
case .note(let note):
|
||||
if note.lockEvent && !showLockEvents { break }
|
||||
items.append(.init(id: "note-\(event.seq)", seq: event.seq,
|
||||
kind: .note(text: note.text, icon: note.icon, lockEvent: note.lockEvent)))
|
||||
case .raw(let raw):
|
||||
guard showRaw else { break }
|
||||
items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
|
||||
kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Coalescing helpers
|
||||
|
||||
private static func coalesceText(
|
||||
_ role: TranscriptItem.Role, _ chunk: TextChunk, seq: UInt64,
|
||||
into items: inout [TranscriptItem], index: inout [String: Int]
|
||||
) {
|
||||
if let i = index[chunk.messageID], case .message(let r, let existing) = items[i].kind {
|
||||
// A non-partial chunk is the authoritative full text; partials accumulate.
|
||||
let text = chunk.isPartial ? existing + chunk.text : chunk.text
|
||||
items[i].kind = .message(role: r, text: text)
|
||||
} else {
|
||||
index[chunk.messageID] = items.count
|
||||
items.append(.init(id: "msg-\(chunk.messageID)", seq: seq,
|
||||
kind: .message(role: role, text: chunk.text)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func coalesceThinking(
|
||||
_ chunk: TextChunk, seq: UInt64, into items: inout [TranscriptItem], index: inout [String: Int]
|
||||
) {
|
||||
if let i = index[chunk.messageID], case .thinking(let existing) = items[i].kind {
|
||||
items[i].kind = .thinking(text: chunk.isPartial ? existing + chunk.text : chunk.text)
|
||||
} else {
|
||||
index[chunk.messageID] = items.count
|
||||
items.append(.init(id: "think-\(chunk.messageID)", seq: seq, kind: .thinking(text: chunk.text)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func upsertTool(
|
||||
_ call: ToolCall, finished: Bool, seq: UInt64,
|
||||
into items: inout [TranscriptItem], index: inout [String: Int]
|
||||
) {
|
||||
if let i = index[call.toolCallID], var group = currentGroup(items[i]) {
|
||||
group.name = call.name
|
||||
if !call.input.isEmptyValue { group.input = call.input }
|
||||
group.finished = group.finished || finished
|
||||
items[i].kind = wrap(group)
|
||||
} else {
|
||||
index[call.toolCallID] = items.count
|
||||
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
|
||||
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func attachResult(
|
||||
_ result: ToolResult, into items: inout [TranscriptItem], index: [String: Int]
|
||||
) {
|
||||
guard let i = index[result.toolCallID], var group = currentGroup(items[i]) else { return }
|
||||
group.result = result.content
|
||||
group.isError = result.isError
|
||||
group.finished = true
|
||||
items[i].kind = wrap(group)
|
||||
}
|
||||
|
||||
private static func attachFileChange(
|
||||
_ change: FileChange, seq: UInt64, into items: inout [TranscriptItem], index: [String: Int]
|
||||
) {
|
||||
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
|
||||
group.fileChanges.append(.init(path: change.path, change: change.kind))
|
||||
items[i].kind = wrap(group)
|
||||
}
|
||||
// Untagged file changes are folded into the diff stat, not the transcript.
|
||||
}
|
||||
|
||||
/// Extract a tool group from either the plain or orchestration kind.
|
||||
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
|
||||
switch item.kind {
|
||||
case .tool(let g), .orchestration(let g): return g
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a group in the right kind — orchestration spawns get the gold card.
|
||||
private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind {
|
||||
group.isOrchestration ? .orchestration(group) : .tool(group)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// Whether this value carries nothing worth keeping (so a later, fuller input wins).
|
||||
var isEmptyValue: Bool {
|
||||
switch self {
|
||||
case .null: return true
|
||||
case .object(let o): return o.isEmpty
|
||||
case .string(let s): return s.isEmpty
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,174 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Read-friendly rendering of one transcript event (UX_IOS §4). Assistant/user text as
|
||||
/// bubbles; tool calls as collapsible rows; everything else compact.
|
||||
/// Read-friendly rendering of one projected transcript row (UX_IOS §4) — the mobile echo of the
|
||||
/// Mac transcript. Assistant/user prose as Markdown bubbles, tool calls as collapsible cards,
|
||||
/// Orchestra spawns in gold, plus the informational rows (usage/cost, rate limits, turn
|
||||
/// boundaries, run outcome) the older remote dropped.
|
||||
struct TranscriptRow: View {
|
||||
let event: AgentEvent
|
||||
@State private var expanded = false
|
||||
let item: TranscriptItem
|
||||
|
||||
var body: some View {
|
||||
switch event.kind {
|
||||
case .userText(let chunk):
|
||||
bubble(chunk.text, role: .user)
|
||||
case .assistantText(let chunk):
|
||||
bubble(chunk.text, role: .assistant)
|
||||
case .thinking(let chunk):
|
||||
Text(chunk.text)
|
||||
.font(.callout.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
toolRow(name: call.name, detail: call.input.compactSummary)
|
||||
case .toolResult(let result):
|
||||
DisclosureGroup("Result") {
|
||||
Text(result.content.compactSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
case .approvalRequested(let req):
|
||||
Label("Approval requested: \(req.toolName)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
case .runFinished(let finished):
|
||||
Label("Run \(finished.outcome.rawValue)", systemImage: "flag.checkered")
|
||||
switch item.kind {
|
||||
case .message(let role, let text):
|
||||
MessageBubble(role: role, text: text)
|
||||
case .thinking(let text):
|
||||
ThinkingRow(text: text)
|
||||
case .tool(let group):
|
||||
ToolGroupRow(group: group)
|
||||
case .orchestration(let group):
|
||||
OrchestrationCard(group: group)
|
||||
case .sessionStarted(let model, let cwd):
|
||||
InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd)
|
||||
case .usage(let usage):
|
||||
UsageRow(usage: usage)
|
||||
case .rateLimit(let limit):
|
||||
RateLimitRow(limit: limit)
|
||||
case .turnBoundary:
|
||||
TurnBoundaryRow()
|
||||
case .approval(let toolName):
|
||||
Label("Approval requested: \(toolName)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(Palette.attention)
|
||||
case .runFinished(let outcome):
|
||||
RunFinishedRow(outcome: outcome)
|
||||
case .error(let message):
|
||||
Label(message, systemImage: "xmark.octagon.fill")
|
||||
.font(.caption).foregroundStyle(Palette.danger)
|
||||
case .note(let text, let icon, _):
|
||||
Label(text, systemImage: icon ?? "arrow.triangle.branch")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
case .error(let err):
|
||||
Label(err.message, systemImage: "xmark.octagon.fill")
|
||||
.font(.caption).foregroundStyle(.red)
|
||||
case .note(let note):
|
||||
Label(note.text, systemImage: note.icon ?? "arrow.triangle.branch")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
default:
|
||||
EmptyView()
|
||||
case .raw(let type, let body):
|
||||
Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Role { case user, assistant }
|
||||
private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bubble(_ text: String, role: Role) -> some View {
|
||||
/// Render an assistant/user message as Markdown (inline bold/italic/code/links), preserving the
|
||||
/// line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
|
||||
struct MessageBubble: View {
|
||||
let role: TranscriptItem.Role
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if role == .user { Spacer(minLength: 40) }
|
||||
Text(text)
|
||||
Text(Self.markdown(text))
|
||||
.textSelection(.enabled)
|
||||
.padding(10)
|
||||
.background(role == .user ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
|
||||
if role == .assistant { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
|
||||
private func toolRow(name: String, detail: String) -> some View {
|
||||
static func markdown(_ string: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: string, options: .init(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace,
|
||||
failurePolicy: .returnPartiallyParsedIfPossible))) ?? AttributedString(string)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThinkingRow: View {
|
||||
let text: String
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.callout.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// A muted single-line note with an optional trailing detail (e.g. session-started cwd).
|
||||
struct InfoLine: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
var detail: String? = nil
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
Text(text)
|
||||
if let detail { Text(detail).lineLimit(1).truncationMode(.middle).foregroundStyle(.tertiary) }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-turn token accounting + cost — the signal the desktop shows and the old remote dropped.
|
||||
struct UsageRow: View {
|
||||
let usage: Usage
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let input = usage.inputTokens { metric("arrow.down", tokens(input)) }
|
||||
if let output = usage.outputTokens { metric("arrow.up", tokens(output)) }
|
||||
if let cost = usage.costUSD, cost > 0 {
|
||||
Text(String(format: "$%.4f", cost)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
private func metric(_ icon: String, _ value: String) -> some View {
|
||||
HStack(spacing: 2) { Image(systemName: icon); Text(value) }
|
||||
}
|
||||
private func tokens(_ n: Int) -> String {
|
||||
n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate-limit warning so the user sees a window tightening before a turn fails — first-class,
|
||||
/// not buried as a raw event.
|
||||
struct RateLimitRow: View {
|
||||
let limit: RateLimit
|
||||
private var warn: Bool { (limit.status ?? "").contains("warning") || limit.status == "rejected" }
|
||||
var body: some View {
|
||||
Label(text, systemImage: warn ? "exclamationmark.circle.fill" : "clock.arrow.circlepath")
|
||||
.font(.caption).foregroundStyle(warn ? Palette.attention : .secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
private var text: String {
|
||||
var parts = ["Rate limit"]
|
||||
if let type = limit.rateLimitType { parts.append(type.replacingOccurrences(of: "_", with: " ")) }
|
||||
if let status = limit.status { parts.append(status.replacingOccurrences(of: "_", with: " ")) }
|
||||
if let resets = limit.resetsAt {
|
||||
parts.append("· resets \(resets.formatted(date: .omitted, time: .shortened))")
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
struct TurnBoundaryRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wrench.and.screwdriver").font(.caption).foregroundStyle(.secondary)
|
||||
Text(name).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1)
|
||||
Text("turn").font(.caption2).foregroundStyle(.tertiary)
|
||||
Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RunFinishedRow: View {
|
||||
let outcome: RunFinished.Outcome
|
||||
var body: some View {
|
||||
Label(label, systemImage: icon).font(.caption).foregroundStyle(color)
|
||||
}
|
||||
private var label: String { "Run \(outcome.rawValue)" }
|
||||
private var icon: String {
|
||||
switch outcome {
|
||||
case .completed: "flag.checkered"
|
||||
case .interrupted: "stop.circle"
|
||||
case .errored: "xmark.octagon"
|
||||
case .maxTurns: "flag.checkered"
|
||||
}
|
||||
}
|
||||
private var color: Color {
|
||||
switch outcome {
|
||||
case .completed, .maxTurns: Palette.success
|
||||
case .interrupted: Palette.paused
|
||||
case .errored: Palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user