Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionLifecycleErrors.swift
T
weishuandClaude Opus 5.5 132d256171 feat(ios): fork, rewind and export conversations
The hub's fork, rewind and export routes had no iOS UI. The chat's
menu now offers:

- Fork Conversation: when the agent advertises forkCurrent, the
  session is live and no Codex turn is running (the web gate). The new
  session opens in place of this chat.
- Rewind or Fork…: a sheet listing loaded user messages that have a
  native point in metadata.conversationHistoryPoints. Each row offers a
  confirmed Fork from Here or Rewind to Here, gated like web: forks
  need an idle turn except on Codex, and rewinds always need one. The
  actions live in a sheet, not on the transcript bubbles, because those
  keep text selection and cached row heights. After a rewind the tail
  resyncs with a history reset. An ambiguous native boundary (409
  ambiguous_native_boundary_fork_safe) offers a fork from the same
  point instead.
- Export Conversation…: saves the hub's lossless JSON payload through
  the Files save panel, using the web download's filename. A large
  export asks before retrying with force; a 413 explains the limit.
  Markdown export stays web-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

42 lines
1.6 KiB
Swift

import Foundation
import HapiProtocol
/// Hub-provided reopen failure text (the Android `formatReopenError` port):
/// a `{error, missing?}` body becomes `"<error> (missing: a, b)"`. Nil when
/// the failure carries no hub message, so the caller shows its own
/// localized fallback.
public func formatReopenError(_ error: any Error) -> String? {
guard let message = hubErrorMessage(error),
let body = (error as? APIError)?.body,
let value = try? HapiJSON.decoder.decode(JSONValue.self, from: Data(body.utf8)),
case .object(let object) = value else {
return nil
}
var missing: [String] = []
if case .array(let items)? = object["missing"] {
missing = items.compactMap { item in
if case .string(let field) = item { return field }
return nil
}
}
return missing.isEmpty ? message : "\(message) (missing: \(missing.joined(separator: ", ")))"
}
/// The hub's `{error}` text, or nil when the failure carries none.
public func hubErrorMessage(_ error: any Error) -> String? {
guard let body = (error as? APIError)?.body,
let value = try? HapiJSON.decoder.decode(JSONValue.self, from: Data(body.utf8)),
case .object(let object) = value,
case .string(let message)? = object["error"],
!message.isEmpty else {
return nil
}
return message
}
/// `DELETE /sessions/:id` answers 409 while the session is active (or
/// became active again) — the caller shows "archive first" wording.
public func isActiveSessionConflict(_ error: any Error) -> Bool {
(error as? APIError)?.status == 409
}