Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift
T

1195 lines
52 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import HapiProtocol
import Observation
/// Per-session chat interaction engine (A-M3ab) — the iOS counterpart of the
/// Android `ChatViewModel`'s interaction half, kept in HapiKit so the whole
/// surface runs under `swift test`:
///
/// - **Composer** (``composer``/``setComposerText(_:)``/``sendMessage(steer:)``):
/// optimistic send (`appendOptimistic` → POST → status settle), queue by
/// default with an explicit long-press steer intent
/// (`messageDelivery.ts` semantics), failed rows retried via
/// ``retryFailedMessage(localId:)`` (steer degrades to queue —
/// `getRetryDeliveryMode`); `session_inactive` (409) auto-resumes once and
/// retries, following a superseding session id with a window seed + draft
/// move + ``ChatInteractionEvent/sessionSuperseded(sessionId:)``. Drafts
/// persist per session via ``ChatDrafts`` (debounced, flushed on
/// ``deactivate()``).
/// - **Attachments** (``attachments``, a ``ComposerAttachments`` tray —
/// A-M3f): picks are prepared app-side (`AttachmentPreparer`, policy in
/// ``AttachmentPolicy``), upload on add, and the Ready set rides
/// `SendMessageRequest.attachments` — the optimistic row carries the
/// metadata so bubbles render thumbnails before the SSE echo. An unsettled
/// chip blocks the send with a notice; attachments-only sends post empty
/// text (wire: text OR attachments). ``appendDictatedText(_:)`` is the
/// dictation hand-off (A-M3f voice).
/// - **Queued bar** (``queuedRows``): uninvoked sends, web sort order
/// (immediate by submission, then scheduled by fire time), with Cancel
/// (optimistic DELETE; an `invoked` answer ingests the authoritative row as
/// sent), Edit (cancel + prefill, newer-draft guard) and Steer — one
/// in-flight queued operation at a time.
/// - **Permissions** (``resolvePermission(requestId:action:)``): flavor-exact
/// approve/deny bodies (`permissionApproveBody`), optimistic
/// ``PermissionRowOverride``s settled by the agentState patch (the
/// published ``permissionOverrides`` prunes rows whose request left
/// `agentState.requests`); hub 404/409 → benign "already handled".
/// - **Config** (``config``/``setPermissionMode(_:)``/``setModel(_:)``/
/// ``setEffort(_:)``/``setCollaborationMode(_:)``/``loadModelOptions()``): catalog pickers with
/// optimistic detail updates, rolled forward to server truth on error;
/// codex model catalog fetched per session.
///
/// `@Observable` computed surfaces (``composer``, ``queuedRows``, ``config``,
/// ``permissionOverrides``) read the observable `SessionListStore` and this
/// class's own observable storage, so SwiftUI tracks them without any push
/// plumbing; the one push input — window state — arrives through the window
/// controller's state stream started by ``activate()``.
@MainActor @Observable
public final class ChatInteractor {
public let sessionId: String
/// Composer attachment tray (A-M3f). The screen feeds prepared picks in
/// and renders `attachments.items`; ``sendMessage(steer:)`` consumes the
/// Ready set. Lives (and dies) with this interactor — see the tray's own
/// docs for the discard-on-deallocation semantics.
public let attachments: ComposerAttachments
// MARK: Observable storage
/// Composer text (owned here so drafts and edit-prefill flow through it).
public private(set) var composerText = ""
/// A send (or its resume recovery) is in flight.
public private(set) var isSending = false
/// Latest window state (feeds ``queuedRows``).
public private(set) var windowState: MessageWindowState?
/// Codex model catalog load state (feeds ``config``).
public private(set) var codexModels: CodexModelsState = .idle
/// One config POST at a time (compare-and-set, like the Android
/// `configOpPending`); exposed so the sheet can render a busy state.
public private(set) var configOpPending = false
/// Local focus intent, observed by the composer without changing its draft.
public private(set) var composerFocusRequest = 0
public private(set) var composerDestination: ComposerDestination = .chat
public private(set) var scratchlistBusy = false
public private(set) var scratchlistError: String?
public private(set) var scratchlistErrorDestination: ComposerDestination?
public private(set) var scratchlistEntryErrors: [String: String] = [:]
public private(set) var queuedScratchlistEntries: Set<String> = []
private var parkAttempt: (text: String, attachments: [ComposerAttachmentSnapshot], id: String)?
private struct ScratchlistQueueAttempt {
let entry: ScratchlistEntry
let localId: String
let target: String
let attachments: [AttachmentMetadata]
}
private var scratchlistQueueAttempts: [String: ScratchlistQueueAttempt] = [:]
private var scratchlistSendTarget: String?
// Screen-owned, not row-local: recycled plan cards retain operation state.
private var pendingCodexPlanId: String?
private var implementedCodexPlanIds: Set<String> = []
private var continuedCodexPlanIds: Set<String> = []
private var codexPlanErrors: [String: String] = [:]
private var queuedOpPending = false
/// Raw override map; the published view prunes settled requests.
private var overridesStore: [String: PermissionRowOverride] = [:]
/// One-shot effects: renavigation on supersede, toast notices. Set by the
/// owning screen model; replaced wholesale on re-activation.
@ObservationIgnored public var onEvent: (@MainActor (ChatInteractionEvent) -> Void)?
// MARK: Wiring
private let api: APIClient
private let sessionStore: SessionListStore
private let windows: MessageWindowControllers
private let drafts: (any ChatDrafts)?
private let draftSaveDebounce: Duration
private let now: () -> Int
/// Web `makeClientSideId('local')` twin; injectable for deterministic tests.
private let makeLocalId: () -> String
@ObservationIgnored private var controller: MessageWindowController?
@ObservationIgnored private var statesTask: Task<Void, Never>?
@ObservationIgnored private var draftTask: Task<Void, Never>?
@ObservationIgnored private var isActive = false
public init(
sessionId: String,
api: APIClient,
sessionStore: SessionListStore,
windows: MessageWindowControllers,
drafts: (any ChatDrafts)? = nil,
draftSaveDebounce: Duration = .milliseconds(300),
now: @escaping () -> Int = { Int(Date().timeIntervalSince1970 * 1000) },
makeLocalId: @escaping () -> String = { "local-\(UUID().uuidString)" }
) {
self.sessionId = sessionId
self.api = api
self.sessionStore = sessionStore
self.windows = windows
self.drafts = drafts
self.draftSaveDebounce = draftSaveDebounce
self.now = now
self.makeLocalId = makeLocalId
self.attachments = ComposerAttachments(api: api, sessionId: sessionId)
}
// MARK: - Lifecycle (paired with the screen's appear/disappear)
/// Opens the window, restores the draft, verifies optimistic queued rows
/// against the hub (web queued-state reconciliation on chat open), and
/// starts observing window state for the queued bar. Idempotent.
public func activate() {
guard !isActive else { return }
isActive = true
statesTask = Task { [weak self] in
guard let self else { return }
let controller = await self.windows.open(sessionId: self.sessionId)
guard self.isActive, !Task.isCancelled else { return }
self.controller = controller
self.restoreDraft()
// Reconcile begins with a draining tail sync, so it also covers
// the catch-up the Android start() runs first.
Task { try? await controller.reconcileQueuedState() }
let states = await controller.states()
for await state in states {
guard !Task.isCancelled else { return }
self.windowState = state
}
}
}
/// Stops window observation and flushes a pending debounced draft save
/// (the web analogue is the beforeunload persist). In-flight sends and
/// queued/config operations run to completion. Idempotent.
public func deactivate() {
guard isActive else { return }
isActive = false
statesTask?.cancel()
statesTask = nil
flushPendingDraft()
}
// MARK: - Composer
/// Composer bar state (steer offered only while a turn is active).
public var composer: ComposerState {
let live = currentSessionState()
return ComposerState(
text: composerText,
isSending: isSending,
canSteer: live.thinking && live.active
)
}
public func setComposerText(_ text: String) {
composerText = text
draftTask?.cancel()
guard let drafts else { return }
let sessionId = sessionId
let debounce = draftSaveDebounce
// Inherits main-actor isolation, so capturing the drafts store is safe.
draftTask = Task {
try? await Task.sleep(for: debounce)
guard !Task.isCancelled else { return }
drafts.save(sessionId: sessionId, text: text)
}
}
/// Submit the composer. Delivery defaults to durable queue; `steer` is
/// the explicit long-press intent that delivers into the active turn
/// (`deliveryMode: "steer"` — attachments may ride a steer, only
/// `scheduledAt` excludes them).
///
/// Ready attachments are consumed into `SendMessageRequest.attachments`;
/// an unsettled chip (uploading/failed) blocks the send with a notice.
/// Text may be empty when attachments exist (wire: text OR attachments).
public func sendMessage(steer: Bool = false) {
guard !isSending, !scratchlistBusy else { return }
if composerDestination == .scratchlist {
parkComposerDraft()
return
}
if attachments.hasUnsettled {
emit(.notice("Attachments are still uploading — wait, or retry/remove the failed ones"))
return
}
if attachments.hasScratchlistAttachments {
sendComposerWithScratchlistAttachments(steer: steer)
return
}
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
let attachmentMetadata = attachments.consume()
guard !text.isEmpty || attachmentMetadata != nil else { return }
composerText = ""
draftTask?.cancel()
draftTask = nil
isSending = true
let localId = makeLocalId()
let createdAt = now()
let deliveryMode: MessageDeliveryMode = steer ? .steer : .queue
Task { [weak self] in
guard let self else { return }
self.drafts?.clear(sessionId: self.sessionId)
if attachmentMetadata == nil && (text == "/clear" || text == "/new")
&& self.sessionStore.detail(for: self.sessionId)?.metadata?.capabilities?.concurrentClients == true {
defer { self.isSending = false }
do {
let result = try await self.api.clearConversation(id: self.sessionId)
self.sessionStore.scheduleRefresh()
self.emit(.sessionSuperseded(sessionId: result.sessionId))
} catch {
self.setComposerText(text)
self.emit(.notice(Self.errorMessage(error, fallback: "Failed to create conversation")))
}
return
}
await self.performSend(
text: text,
localId: localId,
createdAt: createdAt,
deliveryMode: deliveryMode,
attachments: attachmentMetadata,
scheduledAt: nil,
isRetry: false
)
}
}
/// Dictation transcript arrived: append with a space separator (web
/// `appendTranscript`).
public func appendDictatedText(_ transcript: String) {
setComposerText(appendTranscript(composerText, transcript: transcript))
}
/// Screen-level notices (attachment prep failures, mic permission) ride
/// the same event channel as interaction failures.
public func postNotice(_ message: String) {
emit(.notice(message))
}
/// Explicitly discard un-sent attachment uploads (best-effort hub
/// deletes). The tray's deallocation does the same implicitly when the
/// chat is left for good; attachments deliberately do not persist in
/// drafts v1.
public func discardAttachments() {
attachments.discardAllDetached()
}
/// Tap-to-retry on a failed optimistic row: re-fires the send with the
/// same localId. A retry cannot prove the original turn is still live —
/// steer degrades to queue (web `getRetryDeliveryMode`).
public func retryFailedMessage(localId: String) {
if let attempt = scratchlistQueueAttempts.values.first(where: { $0.localId == localId }) {
Task { await queueScratchlistEntry(attempt.entry) }
return
}
guard !isSending else { return }
isSending = true
Task { [weak self] in
guard let self else { return }
let store = await self.windowController()
let row = await store.state.messages
.first { $0.localId == localId && $0.status == .failed }
guard let row, let payload = Self.sendPayload(of: row) else {
self.isSending = false
return
}
await self.performSend(
text: payload.text,
localId: localId,
createdAt: row.createdAt,
deliveryMode: .queue,
attachments: payload.attachments,
scheduledAt: row.scheduledAt,
isRetry: true
)
}
}
/// `POST /abort` — confirm-free stop of the active turn.
public func abortSession() {
Task { [weak self] in
guard let self else { return }
do {
try await self.api.abortSession(id: self.sessionId)
} catch {
self.emit(.notice(Self.errorMessage(error, fallback: "Failed to abort")))
}
}
}
private struct SendPayload {
let text: String
let attachments: [AttachmentMetadata]?
}
/// Extract text + attachments from an optimistic user row's wire content.
private static func sendPayload(of row: WindowMessage) -> SendPayload? {
guard let inner = row.content.objectValue?["content"]?.objectValue,
let text = inner["text"]?.stringValue else {
return nil
}
var attachments: [AttachmentMetadata]?
if let raw = inner["attachments"],
let data = try? HapiJSON.encoder.encode(raw),
let decoded = try? HapiJSON.decoder.decode([AttachmentMetadata].self, from: data),
!decoded.isEmpty {
attachments = decoded
}
return SendPayload(text: text, attachments: attachments)
}
@discardableResult
private func performSend(
text: String,
localId: String,
createdAt: Int,
deliveryMode: MessageDeliveryMode,
attachments: [AttachmentMetadata]?,
scheduledAt: Int?,
isRetry: Bool,
targetSessionId: String? = nil
) async -> String? {
defer { isSending = false }
let target = targetSessionId ?? sessionId
let store = target == sessionId ? await windowController() : await windows.open(sessionId: target)
if isRetry {
await store.updateStatus(localId: localId, status: .sending)
} else {
await store.appendOptimistic(
localId: localId,
text: text,
attachments: attachments,
scheduledAt: scheduledAt,
deliveryMode: deliveryMode.rawValue,
createdAt: createdAt
)
}
let request = SendMessageRequest(
text: text,
localId: localId,
attachments: attachments,
scheduledAt: scheduledAt,
deliveryMode: deliveryMode
)
do {
try await api.sendMessage(sessionId: target, request)
await store.updateStatus(localId: localId, status: successStatus())
return target
} catch let error as APIError where error.status == 409 && error.code == "session_inactive" {
return await resumeAndRetry(store: store, request: request, localId: localId, fromSessionId: target)
} catch {
await store.updateStatus(localId: localId, status: .failed)
return nil
}
}
/// Queued while a turn is active, sent otherwise (web `onMutate`).
private func successStatus() -> MessageStatus {
currentSessionState().thinking ? .queued : .sent
}
/// `session_inactive` recovery (web `resolveSessionId` semantics): one
/// `POST /resume` with the current permission mode, then retry the send
/// against the id the hub returns. A different id supersedes this
/// session — seed the new window from this one, migrate the draft,
/// retarget the optimistic row, and tell the screen to renavigate.
private func resumeAndRetry(
store: MessageWindowController,
request: SendMessageRequest,
localId: String,
fromSessionId: String
) async -> String? {
let targetSessionId: String
do {
targetSessionId = try await api.resumeSession(
id: fromSessionId,
permissionMode: sessionStore.detail(for: fromSessionId)?.permissionMode
)
} catch {
await store.updateStatus(localId: localId, status: .failed)
emit(.notice("Session is inactive and could not be resumed"))
return nil
}
let optimisticRow = await store.state.messages.first { $0.localId == localId }
var targetStore = store
if targetSessionId != fromSessionId {
await windows.seed(fromSessionId: fromSessionId, toSessionId: targetSessionId)
targetStore = await windows.open(sessionId: targetSessionId)
if let optimisticRow {
// Seeding copies rows across, but make the hand-off explicit:
// the pending row must live in the target window only.
await targetStore.appendOptimistic(optimisticRow)
await store.removeMessage(localIdOrId: localId)
}
drafts?.move(fromSessionId: fromSessionId, toSessionId: targetSessionId)
}
// Resume succeeded: reflect activity locally, refresh the list row.
sessionStore.updateDetailLocal(fromSessionId) { $0.active = true }
sessionStore.scheduleRefresh()
scratchlistSendTarget = targetSessionId
var accepted = false
do {
try await api.sendMessage(sessionId: targetSessionId, request)
await targetStore.updateStatus(localId: localId, status: successStatus())
accepted = true
} catch {
await targetStore.updateStatus(localId: localId, status: .failed)
}
if targetSessionId != sessionId {
emit(.sessionSuperseded(sessionId: targetSessionId))
}
return accepted ? targetSessionId : nil
}
// MARK: - Queued bar
/// Uninvoked sends, ordered like the web (`sortQueuedMessages`): immediate
/// first in submission order, then scheduled by fire time.
public var queuedRows: [QueuedMessageRow] {
guard let windowState else { return [] }
let thinking = currentSessionState().thinking
let queued = windowState.messages.filter { $0.isQueuedForInvocation }
let sorted = queued.enumerated().sorted { lhs, rhs in
let lhsScheduled = lhs.element.scheduledAt != nil
let rhsScheduled = rhs.element.scheduledAt != nil
if lhsScheduled != rhsScheduled { return rhsScheduled }
let lhsKey = lhs.element.scheduledAt ?? lhs.element.createdAt
let rhsKey = rhs.element.scheduledAt ?? rhs.element.createdAt
if lhsKey != rhsKey { return lhsKey < rhsKey }
return lhs.offset < rhs.offset
}.map(\.element)
return sorted.map { row in
let preview = Self.queuedPreview(row)
let canAct = Self.hasServerEcho(row) && !queuedOpPending
return QueuedMessageRow(
id: row.id,
localId: row.localId,
text: preview.text,
attachmentNames: preview.attachmentNames,
scheduledAt: row.scheduledAt,
canAct: canAct,
canSteer: canAct && thinking && row.scheduledAt == nil && row.status != .indeterminate,
indeterminate: row.status == .indeterminate
)
}
}
/// Cancel one queued message: optimistic removal, `DELETE`; an `invoked`
/// answer means the agent already consumed it — ingest the authoritative
/// row as sent (web `useCancelQueuedMessage`). Errors restore the row.
public func cancelQueuedMessage(_ messageId: String) {
Task { [weak self] in
_ = await self?.cancelQueuedInternal(messageId)
}
}
private enum CancelVerdict {
case cancelled
case invoked
case busy
}
/// The cancel verdict, or nil on guard/error.
private func cancelQueuedInternal(_ messageId: String) async -> CancelVerdict? {
let store = await windowController()
guard let row = await store.state.messages.first(where: { $0.id == messageId }),
Self.hasServerEcho(row), !queuedOpPending else {
return nil
}
queuedOpPending = true
defer { queuedOpPending = false }
let localId = row.localId ?? row.id
await store.removeMessage(localIdOrId: localId)
do {
switch try await api.cancelMessage(sessionId: sessionId, messageId: messageId) {
case .cancelled:
return .cancelled
case .invoked(let message):
await store.applyCancelInvoked(localId: localId, message: WindowMessage(wire: message))
return .invoked
case .busy:
await store.appendOptimistic(row.withDeliveryState("indeterminate"))
try? await store.reconcileQueuedState()
return .busy
}
} catch {
await store.appendOptimistic(row)
emit(.notice(Self.errorMessage(error, fallback: "Failed to cancel queued message")))
return nil
}
}
public func retryIndeterminateMessage(_ messageId: String) {
guard !queuedOpPending else { return }
queuedOpPending = true
Task { [weak self] in
guard let self else { return }
defer { self.queuedOpPending = false }
do {
let response = try await api.retryIndeterminateMessage(sessionId: sessionId, messageId: messageId)
if response.status == "invoked", let message = response.message,
let localId = message.localId, let invokedAt = message.invokedAt {
await (windowController()).markConsumed(localIds: [localId], invokedAt: invokedAt)
} else if response.status == "retried" || response.status == "already-queued",
let localId = response.localId {
await (windowController()).markRequeued(localIds: [localId])
} else if response.status == "not-found" {
await (windowController()).removeMessage(localIdOrId: messageId)
emit(.notice("Message is no longer available"))
} else if response.status == "retry-unavailable" {
emit(.notice("Delivery is still being resolved"))
}
} catch {
emit(.notice(Self.errorMessage(error, fallback: "Failed to retry message")))
}
}
}
/// Edit = cancel + prefill composer (kept when the operator typed
/// meanwhile).
public func editQueuedMessage(_ messageId: String) {
Task { [weak self] in
guard let self else { return }
let store = await self.windowController()
guard let row = await store.state.messages.first(where: { $0.id == messageId }) else {
return
}
let preview = Self.queuedPreview(row)
let editText = preview.text.isEmpty
? preview.attachmentNames.joined(separator: ", ")
: preview.text
let composerAtEdit = self.composerText
switch await self.cancelQueuedInternal(messageId) {
case .cancelled:
if self.composerText == composerAtEdit {
self.setComposerText(editText)
} else {
self.emit(.notice("Message cancelled — kept your current draft"))
}
case .invoked:
self.emit(.notice("Already delivered to the agent"))
case .busy:
self.emit(.notice("Delivery outcome is unknown; message remains queued"))
case nil:
break
}
}
}
/// Steer one queued message into the active turn. Non-optimistic: the
/// `messages-consumed` event settles the row (web `useSteerQueuedMessage`);
/// an `invoked` answer reconciles a missed consume.
public func steerQueuedMessage(_ messageId: String) {
Task { [weak self] in
guard let self else { return }
let store = await self.windowController()
guard let row = await store.state.messages.first(where: { $0.id == messageId }),
Self.hasServerEcho(row), row.scheduledAt == nil, !self.queuedOpPending else {
return
}
self.queuedOpPending = true
defer { self.queuedOpPending = false }
do {
switch try await self.api.steerMessage(sessionId: self.sessionId, messageId: messageId) {
case .failed(let error, _):
self.emit(.notice(error))
case .invoked(let message):
if let invokedLocalId = message.localId, let invokedAt = message.invokedAt {
await store.markConsumed(localIds: [invokedLocalId], invokedAt: invokedAt)
}
case .steered:
break // messages-consumed removes the row.
}
} catch {
self.emit(.notice(Self.errorMessage(error, fallback: "Failed to steer message")))
}
}
}
private static func hasServerEcho(_ row: WindowMessage) -> Bool {
row.localId == nil || row.id != row.localId
}
private struct QueuedPreview {
let text: String
let attachmentNames: [String]
}
private static func queuedPreview(_ row: WindowMessage) -> QueuedPreview {
guard let normalized = normalizeDecryptedMessage(row.asDecryptedMessage),
case .user(let text, let attachments) = normalized.content else {
return QueuedPreview(text: "", attachmentNames: [])
}
return QueuedPreview(
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
attachmentNames: attachments?.map(\.filename) ?? []
)
}
// MARK: - Permissions
/// Per-request optimistic permission state. A settled request (gone from
/// `agentState.requests`) drops its override; a missing agentState means
/// the detail is (re)loading, not that the requests settled — never prune
/// on absence of evidence.
public var permissionOverrides: [String: PermissionRowOverride] {
prunedOverrides()
}
/// Raw agent flavor id (`claude`, `codex`, …); drives the permission
/// button sets.
public var flavor: String? {
currentFlavor()
}
/// Apply one permission decision. Wire bodies match the web
/// `PermissionFooter`/`AskUserQuestionFooter`/`RequestUserInputFooter`
/// exactly; 404/409 from the hub mean the request already settled
/// elsewhere — surfaced as a benign `alreadyHandled`.
public func resolvePermission(requestId: String, action: PermissionAction) {
overridesStore = prunedOverrides()
guard overridesStore[requestId] == nil else { return }
overridesStore[requestId] = .resolving
Task { [weak self] in
guard let self else { return }
do {
switch action {
case .deny:
try await self.api.denyPermission(sessionId: self.sessionId, requestId: requestId)
case .abort:
try await self.api.denyPermission(
sessionId: self.sessionId,
requestId: requestId,
decision: .abort
)
default:
try await self.api.approvePermission(
sessionId: self.sessionId,
requestId: requestId,
self.approveBody(requestId: requestId, action: action)
)
}
// Success: stay `resolving`; the agentState patch clears the
// pending request and the pruned view drops the override.
} catch let error as APIError where error.status == 404 || error.status == 409 {
self.overridesStore[requestId] = .alreadyHandled
self.emit(.notice("Request was already handled"))
} catch {
self.overridesStore.removeValue(forKey: requestId)
self.emit(.notice(Self.errorMessage(error, fallback: "Request failed")))
}
}
}
private func approveBody(requestId: String, action: PermissionAction) -> PermissionApproveRequest {
let request = sessionStore.detail(for: sessionId)?.agentState?.requests?[requestId]
return permissionApproveBody(
action: action,
flavor: currentFlavor(),
toolName: request?.tool,
arguments: request?.arguments
)
}
private func prunedOverrides() -> [String: PermissionRowOverride] {
guard !overridesStore.isEmpty else { return [:] }
guard let agentState = sessionStore.detail(for: sessionId)?.agentState else {
return overridesStore
}
let pendingIds = agentState.requests.map { Set($0.keys) } ?? []
return overridesStore.filter { pendingIds.contains($0.key) }
}
// MARK: - Session config
/// Session config sheet model (catalog-driven per flavor).
public var config: SessionConfigState {
buildSessionConfigState(
detail: sessionStore.detail(for: sessionId),
summary: sessionStore.sessions.first { $0.id == sessionId },
codexModels: codexModels
)
}
/// `POST /permission-mode` with an optimistic detail flip; server truth
/// on error.
public func setPermissionMode(_ mode: PermissionMode) {
let api = api
let sessionId = sessionId
runConfigChange(
optimistic: { $0.permissionMode = mode },
call: { try await api.setPermissionMode(sessionId: sessionId, mode: mode) }
)
}
/// Codex `POST /collaboration-mode`; shared terminals can edit too.
public func setCollaborationMode(_ mode: CodexCollaborationMode) {
let config = config
guard config.canChangeCollaborationMode, mode != (config.collaborationMode ?? .default) else { return }
let api = api
let sessionId = sessionId
runConfigChange(
optimistic: { $0.collaborationMode = mode },
call: { try await api.setCollaborationMode(sessionId: sessionId, mode: mode) }
)
}
/// `POST /model` — nil clears back to the agent default.
public func setModel(_ model: String?) {
let api = api
let sessionId = sessionId
runConfigChange(
optimistic: { $0.model = model },
call: { try await api.setModel(sessionId: sessionId, model: model.map(ModelSelection.id)) }
)
}
/// Effort switch, flavor-routed: claude → `POST /effort`; codex/opencode
/// → `POST /model-reasoning-effort`. Nil clears.
public func setEffort(_ effort: String?) {
let flavor = currentFlavor()
let usesReasoningEffort = flavor == "codex" || flavor == "opencode"
let api = api
let sessionId = sessionId
runConfigChange(
optimistic: { session in
if usesReasoningEffort {
session.modelReasoningEffort = effort
} else {
session.effort = effort
}
},
call: {
if usesReasoningEffort {
try await api.setModelReasoningEffort(
sessionId: sessionId,
modelReasoningEffort: effort
)
} else {
try await api.setEffort(sessionId: sessionId, effort: effort)
}
}
)
}
/// Fetch the codex model catalog for the picker (no-op for other flavors).
public func loadModelOptions() {
guard currentFlavor() == "codex" else { return }
switch codexModels {
case .loading, .loaded:
return
case .idle, .failed:
break
}
codexModels = .loading
Task { [weak self] in
guard let self else { return }
do {
let response = try await self.api.sessionCodexModels(sessionId: self.sessionId)
if response.success, let models = response.models {
self.codexModels = .loaded(models)
} else {
self.emit(.notice(response.error ?? "Failed to load models"))
self.codexModels = .failed
}
} catch {
self.emit(.notice(Self.errorMessage(error, fallback: "Failed to load models")))
self.codexModels = .failed
}
}
}
private func runConfigChange(
optimistic: (inout Session) -> Void,
call: @escaping @Sendable () async throws -> Void
) {
guard !configOpPending else { return }
configOpPending = true
sessionStore.updateDetailLocal(sessionId, optimistic)
Task { [weak self] in
guard let self else { return }
defer { self.configOpPending = false }
do {
try await call()
} catch {
// Roll back by rolling forward to server truth (an SSE patch
// may have moved other fields since the optimistic write).
_ = try? await self.sessionStore.loadSessionDetail(self.sessionId)
self.emit(.notice(Self.errorMessage(error, fallback: "Failed to update session")))
}
}
}
// MARK: - Codex plan client actions
public func codexPlanActions(planId: String) -> CodexPlanActionState {
let detail = sessionStore.detail(for: sessionId)
let available = detail?.active == true
&& detail?.metadata?.flavor == "codex"
&& detail?.metadata?.capabilities?.concurrentClients == true
&& detail?.agentState?.codexPlanProposalId == planId
&& !implementedCodexPlanIds.contains(planId)
&& !continuedCodexPlanIds.contains(planId)
return CodexPlanActionState(
available: available,
pending: pendingCodexPlanId == planId,
canAct: available && pendingCodexPlanId == nil && !isSending && !configOpPending
&& detail?.thinking != true,
error: codexPlanErrors[planId]
)
}
/// One request at a time, with no optimistic message or mode switch.
/// Refresh even on failure: another client may have consumed the proposal.
public func implementCodexPlan(planId: String) {
guard codexPlanActions(planId: planId).canAct else { return }
pendingCodexPlanId = planId
codexPlanErrors[planId] = nil
Task { [weak self] in
guard let self else { return }
defer { self.pendingCodexPlanId = nil }
do {
try await self.api.implementCodexPlan(sessionId: self.sessionId, planId: planId)
// Do not re-enable an accepted plan if the detail refresh fails
// or briefly returns state from before the queue acceptance.
self.implementedCodexPlanIds.insert(planId)
} catch {
let serverMessage = (error as? APIError)?.body.flatMap { body in
(try? HapiJSON.decoder.decode(JSONValue.self, from: Data(body.utf8)))?
.objectValue?["error"]?.stringValue
}
self.codexPlanErrors[planId] = serverMessage
?? Self.errorMessage(error, fallback: "Request failed")
}
_ = try? await self.sessionStore.loadSessionDetail(self.sessionId)
}
}
/// Continue planning dismisses this proposal’s actions and focuses the composer; it neither sends
/// text nor resolves an approval nor changes the collaboration mode.
public func continueCodexPlan(planId: String) {
guard codexPlanActions(planId: planId).canAct else { return }
continuedCodexPlanIds.insert(planId)
codexPlanErrors[planId] = nil
composerFocusRequest += 1
}
// MARK: - Internals
private func windowController() async -> MessageWindowController {
if let controller {
return controller
}
let opened = await windows.open(sessionId: sessionId)
controller = opened
return opened
}
private func currentFlavor() -> String? {
sessionStore.detail(for: sessionId)?.metadata?.flavor
?? sessionStore.sessions.first { $0.id == sessionId }?.metadata?.flavor
}
private struct SessionLiveState {
let active: Bool
let thinking: Bool
}
private func currentSessionState() -> SessionLiveState {
if let detail = sessionStore.detail(for: sessionId) {
return SessionLiveState(active: detail.active, thinking: detail.thinking)
}
let summary = sessionStore.sessions.first { $0.id == sessionId }
return SessionLiveState(
active: summary?.active ?? false,
thinking: summary?.thinking ?? false
)
}
private func restoreDraft() {
guard let drafts, composerText.isEmpty,
let draft = drafts.load(sessionId: sessionId), !draft.isEmpty else {
return
}
composerText = draft
}
/// A debounced draft save cancelled by screen exit would lose the last
/// keystrokes; persist synchronously instead (UserDefaults-backed).
private func flushPendingDraft() {
let pending = draftTask != nil && draftTask?.isCancelled == false
draftTask?.cancel()
draftTask = nil
guard pending, let drafts else { return }
drafts.save(sessionId: sessionId, text: composerText)
}
private func emit(_ event: ChatInteractionEvent) {
onEvent?(event)
}
private static func errorMessage(_ error: any Error, fallback: String) -> String {
(error as? LocalizedError)?.errorDescription ?? fallback
}
// MARK: - Scratchlist (A-M4b)
/// Per-session scratchlist store; nil ⇒ the scratchlist UI is hidden
/// (badge 0, park no-op). Injected by `HubSession` after construction —
/// a settable property rather than an init parameter keeps this addition
/// purely additive. Tests substitute fakes.
@ObservationIgnored public var scratchlist: (any SessionScratchlistStoring)?
/// Entry count for the chat toolbar's scratchlist badge — observable
/// through the store's own state (the Android `scratchlistCount` twin).
public var scratchlistCount: Int {
scratchlist?.state(sessionId).entries.count ?? 0
}
/// Scratchlist "To composer": insert `text` into the composer — an empty
/// composer takes it verbatim, an existing draft keeps its words and the
/// entry lands on a new line (the entry itself stays on the scratchlist,
/// like the web's promote-to-composer).
public func insertComposerText(_ text: String) {
guard !Self.isBlank(text) else { return }
let current = composerText
if Self.isBlank(current) {
setComposerText(text)
} else {
setComposerText(Self.trimmingTrailingWhitespace(current) + "\n" + text)
}
}
public var hasComposerDraft: Bool { !Self.isBlank(composerText) || !attachments.items.isEmpty }
public func setComposerDestination(_ destination: ComposerDestination) {
guard !scratchlistBusy, !isSending else { return }
composerDestination = destination
scratchlistError = nil
scratchlistErrorDestination = nil
}
public func focusComposer() { composerFocusRequest += 1 }
public func reportScratchlistError(_ message: String) {
scratchlistError = message
scratchlistErrorDestination = nil
}
public func retryScratchlistComposerOperation() {
switch scratchlistErrorDestination {
case .scratchlist: parkComposerDraft()
case .chat: sendMessage()
case nil: scratchlistError = nil
}
}
/// Capture both text and attachment identities; only clear the accepted
/// snapshot. A retry of the same draft uses the same hub entry id.
public func parkComposerDraft() {
guard scratchlist != nil, !scratchlistBusy, !isSending, hasComposerDraft else { return }
scratchlistBusy = true
Task {
defer { scratchlistBusy = false }
_ = await parkCurrentDraft()
}
}
private func parkCurrentDraft() async -> Bool {
guard let scratchlist else { return false }
let text = composerText
scratchlistError = nil
guard text.utf16.count <= ScratchlistCaps.maxTextLength else {
return failPark("Drafts can contain at most 10,000 characters — shorten the text before saving")
}
guard !attachments.hasUnsettled else {
return failPark("Attachments are still uploading — wait, or retry/remove the failed ones")
}
let snapshot = attachments.snapshot
guard !Self.isBlank(text) || !snapshot.isEmpty else { return false }
if parkAttempt?.text != text || parkAttempt?.attachments != snapshot {
guard !scratchlist.state(sessionId).atCap else { return failPark("Scratchlist is full (200 entries)") }
parkAttempt = (text, snapshot, "scratch-\(UUID().uuidString)")
}
let id = parkAttempt!.id
let prepared: ScratchlistTransfer.Parked
do {
prepared = try await ScratchlistTransfer.preparePark(api: api, store: scratchlist, sessionId: sessionId, snapshot: snapshot)
} catch { return failPark(error.localizedDescription) }
guard composerText == text, attachments.snapshot == snapshot, !attachments.hasUnsettled else {
await ScratchlistTransfer.cleanupPark(store: scratchlist, sessionId: sessionId, attachments: prepared.uploaded)
return failPark("Your input changed — review it and try again")
}
switch await scratchlist.createEntry(sessionId: sessionId, text: text, attachments: prepared.attachments, entryId: id) {
case .created(let entry):
attachments.markScratchlistPersisted(entry.attachments)
if composerText == text, attachments.snapshot == snapshot, !attachments.hasUnsettled {
setComposerText("")
attachments.discard(snapshot)
}
parkAttempt = nil
// A retried POST can return the first accepted set of attachments.
let canonicalIds = Set(entry.attachments.map(\.id))
await ScratchlistTransfer.cleanupPark(store: scratchlist, sessionId: sessionId,
attachments: prepared.uploaded.filter { !canonicalIds.contains($0.id) })
emit(.notice("Draft parked to scratchlist"))
return true
case .atCap:
await ScratchlistTransfer.cleanupPark(store: scratchlist, sessionId: sessionId, attachments: prepared.uploaded)
return failPark("Scratchlist is full (200 entries)")
case .failed:
await ScratchlistTransfer.cleanupPark(store: scratchlist, sessionId: sessionId, attachments: prepared.uploaded)
return failPark("Couldn't park the draft — check the hub connection")
}
}
private func failPark(_ message: String) -> Bool {
scratchlistError = message
scratchlistErrorDestination = .scratchlist
emit(.notice(message))
return false
}
/// The caller asks for a choice when there is an existing draft. Restoring
/// borrowed hub references is local and never resumes or sends a session.
@discardableResult
public func restoreScratchlistEntry(_ entry: ScratchlistEntry, choice: ScratchlistRestoreChoice = .append) async -> Bool {
guard !scratchlistBusy, !isSending, !queuedScratchlistEntries.contains(entry.entryId) else { return false }
scratchlistBusy = true
defer { scratchlistBusy = false }
scratchlistError = nil
if choice == .parkCurrent, hasComposerDraft {
guard await parkCurrentDraft() else { return false }
guard !hasComposerDraft else { return failPark("Your input changed — review it and try again") }
}
insertComposerText(entry.text)
attachments.restoreScratchlist(entry.attachments)
composerDestination = .chat
focusComposer()
return true
}
/// Direct queue sends have their own immutable payload; they never consume
/// the visible composer. Failed retries reuse the exact localId/uploads.
@discardableResult
public func queueScratchlistEntry(_ entry: ScratchlistEntry) async -> Bool {
guard let scratchlist, !scratchlistBusy, !isSending else { return false }
scratchlistBusy = true
defer { scratchlistBusy = false }
scratchlistEntryErrors[entry.entryId] = nil
if queuedScratchlistEntries.contains(entry.entryId) {
return await finishScratchlistQueue(entryId: entry.entryId, store: scratchlist)
}
do {
let attempt: ScratchlistQueueAttempt
let isRetry = scratchlistQueueAttempts[entry.entryId] != nil
if let previous = scratchlistQueueAttempts[entry.entryId] {
attempt = previous
} else {
let target = try await resolveScratchlistSendTarget(needsUpload: !entry.attachments.isEmpty)
let staged = try await ScratchlistTransfer.prepareSend(api: api, sourceSessionId: target,
targetSessionId: target, snapshot: ScratchlistTransfer.snapshot(entry))
// Stable across closing/reopening the chat and lost responses:
// the same saved version cannot be queued twice by a retry.
attempt = ScratchlistQueueAttempt(entry: entry, localId: "scratchlist-\(entry.entryId)-\(entry.updatedAt)",
target: target, attachments: staged)
scratchlistQueueAttempts[entry.entryId] = attempt
}
isSending = true
let acceptedTarget = await performSend(text: attempt.entry.text, localId: attempt.localId, createdAt: now(),
deliveryMode: .queue, attachments: attempt.attachments.isEmpty ? nil : attempt.attachments,
scheduledAt: nil, isRetry: isRetry, targetSessionId: attempt.target)
guard let acceptedTarget else {
scratchlistEntryErrors[entry.entryId] = "Couldn't queue the draft — retry"
return false
}
scratchlistSendTarget = acceptedTarget
queuedScratchlistEntries.insert(entry.entryId)
let removed = await finishScratchlistQueue(entryId: entry.entryId, store: scratchlist)
composerDestination = .chat
emit(.notice(removed ? "Draft added to the send queue" : "Already queued — couldn't remove the draft. Retry removal only."))
navigateAfterScratchlistSend()
return removed
} catch {
scratchlistEntryErrors[entry.entryId] = "Couldn't prepare the attachments — retry or remove the failed files"
return false
}
}
private func finishScratchlistQueue(entryId: String, store: any SessionScratchlistStoring) async -> Bool {
let removed = await store.deleteEntry(sessionId: scratchlistSendTarget ?? sessionId, entryId: entryId)
if removed {
scratchlistQueueAttempts[entryId] = nil
queuedScratchlistEntries.remove(entryId)
scratchlistEntryErrors[entryId] = nil
} else {
scratchlistEntryErrors[entryId] = "Already queued — couldn't remove the draft. Retry removal only."
}
return removed
}
private func resolveScratchlistSendTarget(needsUpload: Bool) async throws -> String {
if let scratchlistSendTarget { return scratchlistSendTarget }
guard needsUpload, !currentSessionState().active else { return sessionId }
let target = try await api.resumeSession(id: sessionId, permissionMode: sessionStore.detail(for: sessionId)?.permissionMode)
scratchlistSendTarget = target
sessionStore.updateDetailLocal(sessionId) { $0.active = true }
sessionStore.scheduleRefresh()
if target != sessionId {
await windows.seed(fromSessionId: sessionId, toSessionId: target)
drafts?.move(fromSessionId: sessionId, toSessionId: target)
}
return target
}
private func navigateAfterScratchlistSend() {
if let target = scratchlistSendTarget, target != sessionId { emit(.sessionSuperseded(sessionId: target)) }
}
private func sendComposerWithScratchlistAttachments(steer: Bool) {
let text = composerText
let snapshot = attachments.snapshot
isSending = true
Task {
defer { isSending = false }
do {
let target = try await resolveScratchlistSendTarget(needsUpload: true)
let staged = try await ScratchlistTransfer.prepareSend(api: api, sourceSessionId: target,
targetSessionId: target, snapshot: snapshot)
guard composerText == text, attachments.snapshot == snapshot, !attachments.hasUnsettled else {
for (source, staged) in zip(snapshot, staged) {
if case .scratchlist = source.source { _ = try? await api.deleteUpload(sessionId: target, path: staged.path) }
}
scratchlistError = "Your input changed — review it and try again"
scratchlistErrorDestination = .chat
return
}
setComposerText("")
attachments.consumePrepared(snapshot)
_ = await performSend(text: text, localId: makeLocalId(), createdAt: now(), deliveryMode: steer ? .steer : .queue,
attachments: staged, scheduledAt: nil, isRetry: false, targetSessionId: target)
navigateAfterScratchlistSend()
} catch {
scratchlistError = "Couldn't prepare the attachments — retry or remove the failed files"
scratchlistErrorDestination = .chat
}
}
}
private static func isBlank(_ text: String) -> Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private static func trimmingTrailingWhitespace(_ text: String) -> String {
guard let last = text.lastIndex(where: { !$0.isWhitespace }) else { return "" }
return String(text[...last])
}
}