chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
|
||||
/// Connection lifecycle reported to the UI.
|
||||
public enum ConnectionPhase: Equatable, Sendable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
/// Waiting before the next reconnect attempt.
|
||||
case reconnecting(attempt: Int)
|
||||
/// Gave up or was told to stop; `reason` is user-displayable.
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
/// Everything the UI observes from a connection: lifecycle changes plus
|
||||
/// decoded server events.
|
||||
public enum ConnectionOutput: Equatable, Sendable {
|
||||
case phase(ConnectionPhase)
|
||||
case event(ServerEvent)
|
||||
}
|
||||
|
||||
/// Actor owning one WebSocket connection to a jcode server.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - connect, authenticate, subscribe
|
||||
/// - decode incoming NDJSON frames into `ServerEvent`s
|
||||
/// - automatic reconnect with capped exponential backoff
|
||||
/// - request-ID assignment
|
||||
///
|
||||
/// It deliberately knows nothing about app state; consumers feed the output
|
||||
/// stream into `SessionReducer`.
|
||||
public actor Connection {
|
||||
public struct Configuration: Sendable {
|
||||
public var gateway: Gateway
|
||||
public var authToken: String
|
||||
/// Maximum reconnect attempts before reporting `.failed`. Nil retries forever.
|
||||
public var maxReconnectAttempts: Int?
|
||||
/// Base backoff delay in seconds, doubled per attempt and capped at 30s.
|
||||
public var baseBackoffSeconds: Double
|
||||
|
||||
public init(
|
||||
gateway: Gateway,
|
||||
authToken: String,
|
||||
maxReconnectAttempts: Int? = nil,
|
||||
baseBackoffSeconds: Double = 1.0
|
||||
) {
|
||||
self.gateway = gateway
|
||||
self.authToken = authToken
|
||||
self.maxReconnectAttempts = maxReconnectAttempts
|
||||
self.baseBackoffSeconds = baseBackoffSeconds
|
||||
}
|
||||
}
|
||||
|
||||
private let configuration: Configuration
|
||||
private let makeTransport: @Sendable () -> any WebSocketTransport
|
||||
private var transport: (any WebSocketTransport)?
|
||||
private var nextRequestID: UInt64 = 1
|
||||
private var runTask: Task<Void, Never>?
|
||||
private var continuation: AsyncStream<ConnectionOutput>.Continuation?
|
||||
private var targetSessionID: String?
|
||||
private var stopped = false
|
||||
/// Set when the server announced a reload; the next reconnect attempt
|
||||
/// skips backoff because the drop is expected and the server returns fast.
|
||||
private var expectServerReload = false
|
||||
/// Set when the server asked this client to close; reconnecting would
|
||||
/// fight the server, so the loop ends with a failed phase instead.
|
||||
private var closeRequestedReason: String?
|
||||
|
||||
public init(
|
||||
configuration: Configuration,
|
||||
makeTransport: @escaping @Sendable () -> any WebSocketTransport = {
|
||||
URLSessionWebSocketTransport()
|
||||
}
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.makeTransport = makeTransport
|
||||
}
|
||||
|
||||
/// Starts the connection loop. The returned stream yields phase changes
|
||||
/// and decoded events until `stop()` is called or the stream is cancelled.
|
||||
public func start(resumeSessionID: String? = nil) -> AsyncStream<ConnectionOutput> {
|
||||
targetSessionID = resumeSessionID
|
||||
stopped = false
|
||||
expectServerReload = false
|
||||
closeRequestedReason = nil
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: ConnectionOutput.self)
|
||||
self.continuation = continuation
|
||||
runTask = Task { await runLoop() }
|
||||
continuation.onTermination = { _ in
|
||||
Task { [weak self] in await self?.stop() }
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
guard !stopped else { return }
|
||||
stopped = true
|
||||
runTask?.cancel()
|
||||
runTask = nil
|
||||
if let transport {
|
||||
await transport.close()
|
||||
}
|
||||
transport = nil
|
||||
continuation?.yield(.phase(.disconnected))
|
||||
continuation?.finish()
|
||||
continuation = nil
|
||||
}
|
||||
|
||||
/// Sends a request, assigning it a fresh ID. Returns the assigned ID.
|
||||
@discardableResult
|
||||
public func send(_ build: @Sendable (UInt64) -> Request) async throws -> UInt64 {
|
||||
guard let transport else { throw TransportError.notConnected }
|
||||
let id = nextRequestID
|
||||
nextRequestID += 1
|
||||
let request = build(id)
|
||||
try await transport.send(text: request.encodedLine())
|
||||
return id
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func runLoop() async {
|
||||
var attempt = 0
|
||||
while !Task.isCancelled && !stopped {
|
||||
yield(.phase(attempt == 0 ? .connecting : .reconnecting(attempt: attempt)))
|
||||
let transport = makeTransport()
|
||||
do {
|
||||
try await transport.connect(
|
||||
url: configuration.gateway.webSocketURL,
|
||||
authToken: configuration.authToken
|
||||
)
|
||||
self.transport = transport
|
||||
yield(.phase(.connected))
|
||||
attempt = 0
|
||||
try await subscribeAndSync()
|
||||
try await receiveLoop(transport: transport)
|
||||
// Clean close: fall through to reconnect.
|
||||
} catch {
|
||||
if Task.isCancelled || stopped { break }
|
||||
if case TransportError.unauthorized = error {
|
||||
// Retrying with the same token can never succeed.
|
||||
self.transport = nil
|
||||
yield(
|
||||
.phase(
|
||||
.failed(
|
||||
reason:
|
||||
"This device is no longer paired with the server. Re-pair to reconnect."
|
||||
)))
|
||||
return
|
||||
}
|
||||
}
|
||||
self.transport = nil
|
||||
if Task.isCancelled || stopped { break }
|
||||
if let reason = closeRequestedReason {
|
||||
await transport.close()
|
||||
yield(.phase(.failed(reason: reason)))
|
||||
return
|
||||
}
|
||||
attempt += 1
|
||||
if let max = configuration.maxReconnectAttempts, attempt > max {
|
||||
yield(.phase(.failed(reason: "Could not reach server after \(max) attempts")))
|
||||
return
|
||||
}
|
||||
if expectServerReload {
|
||||
// The server told us it is restarting: reconnect eagerly with a
|
||||
// short fixed delay instead of exponential backoff.
|
||||
expectServerReload = false
|
||||
try? await Task.sleep(nanoseconds: UInt64(configuration.baseBackoffSeconds * 500_000_000))
|
||||
continue
|
||||
}
|
||||
let delay = min(
|
||||
configuration.baseBackoffSeconds * pow(2.0, Double(attempt - 1)), 30.0)
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
private func subscribeAndSync() async throws {
|
||||
let sessionID = targetSessionID
|
||||
try await send { .subscribe(id: $0, targetSessionID: sessionID) }
|
||||
try await send { .getHistory(id: $0) }
|
||||
}
|
||||
|
||||
private func receiveLoop(transport: any WebSocketTransport) async throws {
|
||||
while !Task.isCancelled && !stopped {
|
||||
guard let text = try await transport.receiveText() else { return }
|
||||
// A frame may contain multiple newline-delimited events.
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
if let event = try? ServerEvent.decode(line: String(line)) {
|
||||
switch event {
|
||||
case .sessionID(let sessionID):
|
||||
targetSessionID = sessionID
|
||||
case .reloading:
|
||||
expectServerReload = true
|
||||
case .sessionCloseRequested(let reason):
|
||||
closeRequestedReason =
|
||||
reason.isEmpty ? "Server closed this session" : reason
|
||||
default:
|
||||
break
|
||||
}
|
||||
yield(.event(event))
|
||||
if closeRequestedReason != nil { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func yield(_ output: ConnectionOutput) {
|
||||
continuation?.yield(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
|
||||
/// A saved server credential.
|
||||
public struct ServerCredential: Codable, Equatable, Sendable, Identifiable {
|
||||
public var id: String { "\(host):\(port)" }
|
||||
|
||||
public var host: String
|
||||
public var port: UInt16
|
||||
public var token: String
|
||||
public var serverName: String
|
||||
public var serverVersion: String
|
||||
public var pairedAt: Date
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
token: String,
|
||||
serverName: String,
|
||||
serverVersion: String,
|
||||
pairedAt: Date = Date()
|
||||
) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.token = token
|
||||
self.serverName = serverName
|
||||
self.serverVersion = serverVersion
|
||||
self.pairedAt = pairedAt
|
||||
}
|
||||
|
||||
public var gateway: Gateway {
|
||||
Gateway(host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage for paired-server credentials.
|
||||
public protocol CredentialStore: Sendable {
|
||||
func loadAll() -> [ServerCredential]
|
||||
func save(_ credential: ServerCredential)
|
||||
func remove(id: String)
|
||||
}
|
||||
|
||||
/// Keychain-backed store used on device. All credentials live under one
|
||||
/// keychain item as a JSON array; the auth token is the only secret and the
|
||||
/// set is expected to stay tiny.
|
||||
///
|
||||
/// Falls back to a JSON file in Application Support when the keychain is
|
||||
/// unavailable (e.g. unsigned simulator builds returning
|
||||
/// `errSecMissingEntitlement`). Real signed builds use the keychain.
|
||||
public struct KeychainCredentialStore: CredentialStore {
|
||||
private static let service = "com.jcode.mobile.servers"
|
||||
private static let account = "paired-servers"
|
||||
|
||||
public init() {}
|
||||
|
||||
public func loadAll() -> [ServerCredential] {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let data = result as? Data {
|
||||
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
|
||||
}
|
||||
if status != errSecItemNotFound {
|
||||
NSLog("JCodeKit: keychain read failed (%d), trying file fallback", status)
|
||||
}
|
||||
if let data = try? Data(contentsOf: Self.fallbackURL) {
|
||||
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
public func save(_ credential: ServerCredential) {
|
||||
var all = loadAll().filter { $0.id != credential.id }
|
||||
all.append(credential)
|
||||
persist(all)
|
||||
}
|
||||
|
||||
public func remove(id: String) {
|
||||
persist(loadAll().filter { $0.id != id })
|
||||
}
|
||||
|
||||
private func persist(_ credentials: [ServerCredential]) {
|
||||
guard let data = try? JSONEncoder().encode(credentials) else { return }
|
||||
var query = baseQuery()
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
status = SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
if status != errSecSuccess {
|
||||
NSLog("JCodeKit: keychain write failed (%d), using file fallback", status)
|
||||
persistToFile(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistToFile(_ data: Data) {
|
||||
let url = Self.fallbackURL
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtection])
|
||||
} catch {
|
||||
NSLog("JCodeKit: file fallback write failed: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static var fallbackURL: URL {
|
||||
let base =
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
||||
.first ?? FileManager.default.temporaryDirectory
|
||||
return base.appendingPathComponent("jcode-servers.json")
|
||||
}
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: Self.account,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store for tests and previews.
|
||||
public final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var credentials: [ServerCredential] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func loadAll() -> [ServerCredential] {
|
||||
lock.withLock { credentials }
|
||||
}
|
||||
|
||||
public func save(_ credential: ServerCredential) {
|
||||
lock.withLock {
|
||||
credentials.removeAll { $0.id == credential.id }
|
||||
credentials.append(credential)
|
||||
}
|
||||
}
|
||||
|
||||
public func remove(id: String) {
|
||||
lock.withLock {
|
||||
credentials.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// Describes a jcode server gateway endpoint.
|
||||
///
|
||||
/// The server exposes:
|
||||
/// - `GET http://host:port/health` reachability + version probe
|
||||
/// - `POST http://host:port/pair` pairing-code -> auth-token exchange
|
||||
/// - `ws://host:port/ws` newline-delimited JSON protocol over WebSocket
|
||||
public struct Gateway: Hashable, Sendable {
|
||||
public static let defaultPort: UInt16 = 7643
|
||||
|
||||
public var host: String
|
||||
public var port: UInt16
|
||||
|
||||
public init(host: String, port: UInt16 = Gateway.defaultPort) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
}
|
||||
|
||||
public var healthURL: URL {
|
||||
url(scheme: "http", path: "/health")
|
||||
}
|
||||
|
||||
public var pairURL: URL {
|
||||
url(scheme: "http", path: "/pair")
|
||||
}
|
||||
|
||||
public var webSocketURL: URL {
|
||||
url(scheme: "ws", path: "/ws")
|
||||
}
|
||||
|
||||
private func url(scheme: String, path: String) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.port = Int(port)
|
||||
components.path = path
|
||||
guard let url = components.url else {
|
||||
// Host strings from pairing are validated before reaching here;
|
||||
// a failure indicates programmer error.
|
||||
preconditionFailure("invalid gateway endpoint: \(scheme)://\(host):\(port)\(path)")
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `jcode://pair?host=H&port=P&code=C` URIs from QR codes.
|
||||
public enum PairURI {
|
||||
public struct Payload: Equatable, Sendable {
|
||||
public var gateway: Gateway
|
||||
public var code: String
|
||||
|
||||
public init(gateway: Gateway, code: String) {
|
||||
self.gateway = gateway
|
||||
self.code = code
|
||||
}
|
||||
}
|
||||
|
||||
public static func parse(_ string: String) -> Payload? {
|
||||
guard let components = URLComponents(string: string),
|
||||
components.scheme == "jcode",
|
||||
components.host == "pair" || components.path == "pair"
|
||||
|| components.host == nil && components.path == "/pair"
|
||||
else { return nil }
|
||||
let items = components.queryItems ?? []
|
||||
func value(_ name: String) -> String? {
|
||||
items.first(where: { $0.name == name })?.value
|
||||
}
|
||||
guard let host = value("host"), !host.isEmpty,
|
||||
let code = value("code"), !code.isEmpty
|
||||
else { return nil }
|
||||
let port = value("port").flatMap(UInt16.init) ?? Gateway.defaultPort
|
||||
return Payload(gateway: Gateway(host: host, port: port), code: code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// Exchanges a pairing code for a long-lived auth token via `POST /pair`.
|
||||
public struct PairingClient: Sendable {
|
||||
public struct Response: Equatable, Sendable {
|
||||
public var token: String
|
||||
public var serverName: String
|
||||
public var serverVersion: String
|
||||
|
||||
public init(token: String, serverName: String, serverVersion: String) {
|
||||
self.token = token
|
||||
self.serverName = serverName
|
||||
self.serverVersion = serverVersion
|
||||
}
|
||||
}
|
||||
|
||||
public enum PairingError: Error, Equatable {
|
||||
case invalidCode(String)
|
||||
case serverError(statusCode: Int, message: String)
|
||||
case invalidResponse
|
||||
}
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func pair(
|
||||
gateway: Gateway,
|
||||
code: String,
|
||||
deviceID: String,
|
||||
deviceName: String
|
||||
) async throws -> Response {
|
||||
var request = URLRequest(url: gateway.pairURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 15
|
||||
let body: [String: String] = [
|
||||
"code": code,
|
||||
"device_id": deviceID,
|
||||
"device_name": deviceName,
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PairingError.invalidResponse
|
||||
}
|
||||
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
|
||||
guard http.statusCode == 200 else {
|
||||
let message = object["error"] as? String ?? "HTTP \(http.statusCode)"
|
||||
if http.statusCode == 401 {
|
||||
throw PairingError.invalidCode(message)
|
||||
}
|
||||
throw PairingError.serverError(statusCode: http.statusCode, message: message)
|
||||
}
|
||||
guard let token = object["token"] as? String, !token.isEmpty else {
|
||||
throw PairingError.invalidResponse
|
||||
}
|
||||
return Response(
|
||||
token: token,
|
||||
serverName: object["server_name"] as? String ?? "jcode",
|
||||
serverVersion: object["server_version"] as? String ?? "unknown"
|
||||
)
|
||||
}
|
||||
|
||||
/// Probes `GET /health`. Returns true when the gateway is reachable.
|
||||
public func checkHealth(gateway: Gateway) async -> Bool {
|
||||
var request = URLRequest(url: gateway.healthURL)
|
||||
request.timeoutInterval = 5
|
||||
guard let (_, response) = try? await session.data(for: request),
|
||||
let http = response as? HTTPURLResponse
|
||||
else { return false }
|
||||
return http.statusCode == 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import Foundation
|
||||
|
||||
/// One entry in the rendered transcript.
|
||||
public struct TranscriptEntry: Equatable, Sendable, Identifiable {
|
||||
public enum Role: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
|
||||
public struct ToolCall: Equatable, Sendable, Identifiable {
|
||||
public enum Status: Equatable, Sendable {
|
||||
case streamingInput
|
||||
case running
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var input: String
|
||||
public var output: String
|
||||
public var status: Status
|
||||
|
||||
public init(
|
||||
id: String, name: String, input: String = "", output: String = "",
|
||||
status: Status = .streamingInput
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
public var id: UUID
|
||||
public var role: Role
|
||||
public var text: String
|
||||
public var reasoning: String
|
||||
public var toolCalls: [ToolCall]
|
||||
/// True while this entry is still receiving streamed content.
|
||||
public var isStreaming: Bool
|
||||
/// True while this entry is a soft-interrupt waiting for the server to
|
||||
/// inject it at a safe point. Cleared by `soft_interrupt_injected`.
|
||||
public var isQueued: Bool
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
role: Role,
|
||||
text: String,
|
||||
reasoning: String = "",
|
||||
toolCalls: [ToolCall] = [],
|
||||
isStreaming: Bool = false,
|
||||
isQueued: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.text = text
|
||||
self.reasoning = reasoning
|
||||
self.toolCalls = toolCalls
|
||||
self.isStreaming = isStreaming
|
||||
self.isQueued = isQueued
|
||||
}
|
||||
}
|
||||
|
||||
/// A transient, user-dismissible notice surfaced to the UI.
|
||||
///
|
||||
/// Covers out-of-band server signals that must never be silently dropped:
|
||||
/// push notifications, interrupts, and context compaction events.
|
||||
public struct Notice: Equatable, Sendable, Identifiable {
|
||||
public enum Kind: Equatable, Sendable {
|
||||
case info
|
||||
case notification
|
||||
case compaction
|
||||
}
|
||||
|
||||
public var id: UUID
|
||||
public var kind: Kind
|
||||
public var message: String
|
||||
|
||||
public init(id: UUID = UUID(), kind: Kind = .info, message: String) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
/// Full client-side session state derived from server events.
|
||||
public struct SessionState: Equatable, Sendable {
|
||||
public var phase: ConnectionPhase
|
||||
public var transcript: [TranscriptEntry]
|
||||
public var sessionID: String?
|
||||
public var sessionTitle: String?
|
||||
public var allSessions: [String]
|
||||
/// Human titles for sessions in `allSessions`, keyed by session ID.
|
||||
/// Populated from `session_renamed` broadcasts and history payloads.
|
||||
public var sessionTitles: [String: String]
|
||||
public var isProcessing: Bool
|
||||
public var isReasoning: Bool
|
||||
public var modelName: String?
|
||||
public var providerName: String?
|
||||
public var availableModels: [String]
|
||||
public var reasoningEffort: String?
|
||||
public var serverVersion: String?
|
||||
public var tokenInput: UInt64
|
||||
public var tokenOutput: UInt64
|
||||
public var statusDetail: String?
|
||||
/// Live turn-level provider phase (e.g. "authenticating", "waiting").
|
||||
public var serverPhase: String?
|
||||
public var errorBanner: String?
|
||||
public var notices: [Notice]
|
||||
/// Soft-interrupt messages queued mid-run, in send order, until the
|
||||
/// server confirms injection.
|
||||
public var pendingInterrupts: [String]
|
||||
|
||||
public var hasPendingInterrupts: Bool { !pendingInterrupts.isEmpty }
|
||||
|
||||
/// Human title for a session in the list, falling back to nil when unknown.
|
||||
public func title(forSession sessionID: String) -> String? {
|
||||
sessionTitles[sessionID]
|
||||
}
|
||||
|
||||
public init() {
|
||||
phase = .disconnected
|
||||
transcript = []
|
||||
sessionID = nil
|
||||
sessionTitle = nil
|
||||
allSessions = []
|
||||
sessionTitles = [:]
|
||||
isProcessing = false
|
||||
isReasoning = false
|
||||
modelName = nil
|
||||
providerName = nil
|
||||
availableModels = []
|
||||
reasoningEffort = nil
|
||||
serverVersion = nil
|
||||
tokenInput = 0
|
||||
tokenOutput = 0
|
||||
statusDetail = nil
|
||||
serverPhase = nil
|
||||
errorBanner = nil
|
||||
notices = []
|
||||
pendingInterrupts = []
|
||||
}
|
||||
}
|
||||
|
||||
/// Local intents that mutate state without a server round-trip.
|
||||
public enum LocalIntent: Equatable, Sendable {
|
||||
/// User submitted a message; append it optimistically.
|
||||
case userSentMessage(String)
|
||||
/// User queued a soft-interrupt message mid-run.
|
||||
case userQueuedInterrupt(String)
|
||||
/// User cancelled all queued soft-interrupts before they injected; the
|
||||
/// optimistic bubbles are removed because they will never reach the agent.
|
||||
case cancelledQueuedInterrupts
|
||||
/// Dismiss the current error banner.
|
||||
case dismissError
|
||||
/// Dismiss a transient notice by id.
|
||||
case dismissNotice(UUID)
|
||||
/// User cleared the conversation; wipe the transcript optimistically while
|
||||
/// keeping the connection and session metadata.
|
||||
case clearedConversation
|
||||
/// Reset everything (switching servers/sessions).
|
||||
case reset
|
||||
}
|
||||
|
||||
/// Pure state machine turning connection output into session state.
|
||||
///
|
||||
/// All streaming, tool lifecycle, and history-sync behavior lives here so it
|
||||
/// can be exhaustively unit tested on macOS without UI or network.
|
||||
public enum SessionReducer {
|
||||
public static func reduce(_ state: SessionState, _ output: ConnectionOutput) -> SessionState {
|
||||
switch output {
|
||||
case .phase(let phase):
|
||||
return reducePhase(state, phase)
|
||||
case .event(let event):
|
||||
return reduceEvent(state, event)
|
||||
}
|
||||
}
|
||||
|
||||
public static func reduce(_ state: SessionState, intent: LocalIntent) -> SessionState {
|
||||
var state = state
|
||||
switch intent {
|
||||
case .userSentMessage(let text):
|
||||
state.transcript.append(TranscriptEntry(role: .user, text: text))
|
||||
state.isProcessing = true
|
||||
state.errorBanner = nil
|
||||
case .userQueuedInterrupt(let text):
|
||||
state.transcript.append(TranscriptEntry(role: .user, text: text, isQueued: true))
|
||||
state.pendingInterrupts.append(text)
|
||||
case .cancelledQueuedInterrupts:
|
||||
state.transcript.removeAll { $0.isQueued }
|
||||
state.pendingInterrupts = []
|
||||
case .dismissError:
|
||||
state.errorBanner = nil
|
||||
case .dismissNotice(let id):
|
||||
state.notices.removeAll { $0.id == id }
|
||||
case .clearedConversation:
|
||||
state.transcript = []
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.errorBanner = nil
|
||||
case .reset:
|
||||
state = SessionState()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Phase
|
||||
|
||||
private static func reducePhase(_ state: SessionState, _ phase: ConnectionPhase)
|
||||
-> SessionState
|
||||
{
|
||||
var state = state
|
||||
state.phase = phase
|
||||
switch phase {
|
||||
case .connected:
|
||||
state.errorBanner = nil
|
||||
case .failed(let reason):
|
||||
state.errorBanner = reason
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
case .disconnected, .reconnecting:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
case .connecting:
|
||||
break
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
private static func reduceEvent(_ state: SessionState, _ event: ServerEvent) -> SessionState {
|
||||
var state = state
|
||||
switch event {
|
||||
case .textDelta(let text):
|
||||
withStreamingAssistant(&state) { $0.text += text }
|
||||
|
||||
case .reasoningDelta(let text):
|
||||
state.isReasoning = true
|
||||
withStreamingAssistant(&state) { $0.reasoning += text }
|
||||
|
||||
case .reasoningDone:
|
||||
state.isReasoning = false
|
||||
|
||||
case .textReplace(let text):
|
||||
withStreamingAssistant(&state) { $0.text = text }
|
||||
|
||||
case .toolStart(let id, let name):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
entry.toolCalls.append(.init(id: id, name: name))
|
||||
}
|
||||
|
||||
case .toolInput(let delta):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if !entry.toolCalls.isEmpty {
|
||||
entry.toolCalls[entry.toolCalls.count - 1].input += delta
|
||||
}
|
||||
}
|
||||
|
||||
case .toolExec(let id, let name):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
|
||||
entry.toolCalls[index].status = .running
|
||||
} else {
|
||||
entry.toolCalls.append(.init(id: id, name: name, status: .running))
|
||||
}
|
||||
}
|
||||
|
||||
case .toolDone(let id, let name, let output, let error):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
|
||||
entry.toolCalls[index].output = output
|
||||
entry.toolCalls[index].status = error.map { .failed($0) } ?? .succeeded
|
||||
} else {
|
||||
entry.toolCalls.append(
|
||||
.init(
|
||||
id: id, name: name, output: output,
|
||||
status: error.map { .failed($0) } ?? .succeeded
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
case .messageEnd:
|
||||
finishStreaming(&state)
|
||||
|
||||
case .connectionPhase(let phase):
|
||||
state.serverPhase = phase.isEmpty ? nil : phase
|
||||
|
||||
case .softInterruptInjected(let content, let displayRole, _, _):
|
||||
resolvePendingInterrupt(&state, content: content, displayRole: displayRole)
|
||||
|
||||
case .retryRollback(let attempt, let max):
|
||||
// The provider is replaying the response from the top: discard all
|
||||
// partial output from the current attempt so it does not duplicate.
|
||||
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
|
||||
state.transcript.removeLast()
|
||||
}
|
||||
state.isReasoning = false
|
||||
state.statusDetail = "Retrying (\(attempt)/\(max))"
|
||||
|
||||
case .done:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
drainPendingInterrupts(&state)
|
||||
|
||||
case .interrupted:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
drainPendingInterrupts(&state)
|
||||
state.notices.append(Notice(message: "Interrupted"))
|
||||
|
||||
case .error(_, let message, let retryAfterSecs):
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
if let retry = retryAfterSecs {
|
||||
state.errorBanner = "\(message) (retry in \(retry)s)"
|
||||
} else {
|
||||
state.errorBanner = message
|
||||
}
|
||||
|
||||
case .tokenUsage(let input, let output):
|
||||
state.tokenInput = input
|
||||
state.tokenOutput = output
|
||||
|
||||
case .statusDetail(let detail):
|
||||
state.statusDetail = detail
|
||||
|
||||
case .state(_, let sessionID, _, let isProcessing):
|
||||
state.sessionID = sessionID
|
||||
state.isProcessing = isProcessing
|
||||
|
||||
case .sessionID(let sessionID):
|
||||
state.sessionID = sessionID
|
||||
|
||||
case .sessionRenamed(let sessionID, let displayTitle):
|
||||
state.sessionTitles[sessionID] = displayTitle
|
||||
if state.sessionID == nil || state.sessionID == sessionID {
|
||||
state.sessionTitle = displayTitle
|
||||
}
|
||||
|
||||
case .history(let payload):
|
||||
state = applyHistory(state, payload)
|
||||
|
||||
case .modelChanged(_, let model, let error):
|
||||
if let error {
|
||||
state.errorBanner = error
|
||||
} else {
|
||||
state.modelName = model
|
||||
}
|
||||
|
||||
case .reasoningEffortChanged(_, let effort, let error):
|
||||
if let error {
|
||||
state.errorBanner = error
|
||||
} else {
|
||||
state.reasoningEffort = effort
|
||||
}
|
||||
|
||||
case .compactResult(_, let message, let success):
|
||||
if success {
|
||||
state.notices.append(Notice(kind: .compaction, message: message))
|
||||
} else {
|
||||
state.errorBanner = message
|
||||
}
|
||||
|
||||
case .availableModelsUpdated(let models, let providerModel):
|
||||
state.availableModels = models
|
||||
if let providerModel {
|
||||
state.modelName = providerModel
|
||||
}
|
||||
|
||||
case .compaction(let trigger, let tokensSaved):
|
||||
if let saved = tokensSaved, trigger != "background" {
|
||||
state.notices.append(
|
||||
Notice(kind: .compaction, message: "Context compacted (\(saved) tokens saved)"))
|
||||
}
|
||||
|
||||
case .notification(let fromName, let message):
|
||||
let prefix = fromName.map { "\($0): " } ?? ""
|
||||
state.notices.append(Notice(kind: .notification, message: prefix + message))
|
||||
|
||||
case .reloading:
|
||||
state.notices.append(Notice(message: "Server is updating, reconnecting shortly"))
|
||||
|
||||
case .sessionCloseRequested(let reason):
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
finishStreaming(&state)
|
||||
state.errorBanner = reason.isEmpty ? "Server closed this session" : reason
|
||||
|
||||
case .ack, .pong, .unknown:
|
||||
break
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private static func applyHistory(
|
||||
_ state: SessionState, _ payload: ServerEvent.HistoryPayload
|
||||
) -> SessionState {
|
||||
var state = state
|
||||
state.sessionID = payload.sessionID
|
||||
state.providerName = payload.providerName ?? state.providerName
|
||||
state.modelName = payload.providerModel ?? state.modelName
|
||||
if !payload.availableModels.isEmpty {
|
||||
state.availableModels = payload.availableModels
|
||||
}
|
||||
if !payload.allSessions.isEmpty {
|
||||
state.allSessions = payload.allSessions
|
||||
}
|
||||
state.serverVersion = payload.serverVersion ?? state.serverVersion
|
||||
state.sessionTitle = payload.displayTitle ?? state.sessionTitle
|
||||
state.reasoningEffort = payload.reasoningEffort ?? state.reasoningEffort
|
||||
if let title = payload.displayTitle {
|
||||
state.sessionTitles[payload.sessionID] = title
|
||||
}
|
||||
// History replaces the transcript wholesale, so optimistic queued
|
||||
// bubbles are rebuilt from the server's authoritative view.
|
||||
state.pendingInterrupts = []
|
||||
if let totals = payload.totalTokens {
|
||||
state.tokenInput = totals.input
|
||||
state.tokenOutput = totals.output
|
||||
}
|
||||
|
||||
// History replaces the transcript wholesale: it is the server's
|
||||
// authoritative view, used on connect and reconnect.
|
||||
state.transcript = payload.messages.compactMap { message in
|
||||
let role: TranscriptEntry.Role
|
||||
switch message.role {
|
||||
case "user": role = .user
|
||||
case "assistant": role = .assistant
|
||||
case "system": role = .system
|
||||
default: return nil
|
||||
}
|
||||
var toolCalls: [TranscriptEntry.ToolCall] = []
|
||||
if let data = message.toolData {
|
||||
toolCalls.append(
|
||||
.init(
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
input: data.input,
|
||||
output: data.output ?? "",
|
||||
status: data.error.map { .failed($0) }
|
||||
?? (data.output != nil ? .succeeded : .running)
|
||||
))
|
||||
} else {
|
||||
toolCalls = message.toolCalls.map { name in
|
||||
.init(id: name, name: name, status: .succeeded)
|
||||
}
|
||||
}
|
||||
// Skip empty assistant placeholders.
|
||||
if message.content.isEmpty && toolCalls.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return TranscriptEntry(role: role, text: message.content, toolCalls: toolCalls)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Marks the matching queued soft-interrupt as delivered. If the injection
|
||||
/// came from another client (no optimistic bubble), appends the content so
|
||||
/// the transcript still reflects what the agent saw.
|
||||
private static func resolvePendingInterrupt(
|
||||
_ state: inout SessionState, content: String, displayRole: String?
|
||||
) {
|
||||
if let index = state.pendingInterrupts.firstIndex(of: content) {
|
||||
state.pendingInterrupts.remove(at: index)
|
||||
}
|
||||
if let index = state.transcript.firstIndex(where: { $0.isQueued && $0.text == content })
|
||||
?? state.transcript.firstIndex(where: { $0.isQueued })
|
||||
{
|
||||
state.transcript[index].isQueued = false
|
||||
} else {
|
||||
let role: TranscriptEntry.Role = displayRole == "system" ? .system : .user
|
||||
state.transcript.append(TranscriptEntry(role: role, text: content))
|
||||
}
|
||||
}
|
||||
|
||||
/// The turn is over: anything still marked queued has either been consumed
|
||||
/// server-side or is moot, so stop showing it as pending.
|
||||
private static func drainPendingInterrupts(_ state: inout SessionState) {
|
||||
state.pendingInterrupts = []
|
||||
for index in state.transcript.indices where state.transcript[index].isQueued {
|
||||
state.transcript[index].isQueued = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutates the trailing streaming assistant entry, creating it if needed.
|
||||
private static func withStreamingAssistant(
|
||||
_ state: inout SessionState, _ mutate: (inout TranscriptEntry) -> Void
|
||||
) {
|
||||
if let last = state.transcript.indices.last,
|
||||
state.transcript[last].role == .assistant,
|
||||
state.transcript[last].isStreaming
|
||||
{
|
||||
mutate(&state.transcript[last])
|
||||
} else {
|
||||
var entry = TranscriptEntry(role: .assistant, text: "", isStreaming: true)
|
||||
mutate(&entry)
|
||||
state.transcript.append(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private static func finishStreaming(_ state: inout SessionState) {
|
||||
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
|
||||
state.transcript[last].isStreaming = false
|
||||
// Drop fully-empty assistant stubs (e.g. tool-only turns that
|
||||
// were replaced or cancelled before any text arrived).
|
||||
if state.transcript[last].text.isEmpty
|
||||
&& state.transcript[last].toolCalls.isEmpty
|
||||
&& state.transcript[last].reasoning.isEmpty
|
||||
{
|
||||
state.transcript.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
/// Abstraction over a WebSocket so `Connection` is testable with a fake.
|
||||
public protocol WebSocketTransport: Sendable {
|
||||
/// Opens the socket. Throws if the connection or auth fails.
|
||||
func connect(url: URL, authToken: String) async throws
|
||||
/// Sends one text frame.
|
||||
func send(text: String) async throws
|
||||
/// Receives the next text frame. Returns nil when the socket closes.
|
||||
func receiveText() async throws -> String?
|
||||
/// Closes the socket.
|
||||
func close() async
|
||||
}
|
||||
|
||||
/// URLSession-backed transport used in production.
|
||||
///
|
||||
/// Auth is sent via `Authorization: Bearer <token>` on the upgrade request,
|
||||
/// matching the gateway's preferred auth source.
|
||||
public actor URLSessionWebSocketTransport: WebSocketTransport {
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func connect(url: URL, authToken: String) async throws {
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = 10
|
||||
let task = URLSession.shared.webSocketTask(with: request)
|
||||
task.resume()
|
||||
self.task = task
|
||||
// Force the handshake to complete (and surface auth failures) by
|
||||
// sending a WebSocket-level ping before declaring success.
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
task.sendPing { error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The gateway rejects unknown/revoked tokens at the upgrade with
|
||||
// 401. Surface that distinctly so the connection loop can stop
|
||||
// retrying and prompt a re-pair instead of backing off forever.
|
||||
if let http = task.response as? HTTPURLResponse, http.statusCode == 401 {
|
||||
throw TransportError.unauthorized
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func send(text: String) async throws {
|
||||
guard let task else { throw TransportError.notConnected }
|
||||
try await task.send(.string(text))
|
||||
}
|
||||
|
||||
public func receiveText() async throws -> String? {
|
||||
guard let task else { throw TransportError.notConnected }
|
||||
while true {
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
do {
|
||||
message = try await task.receive()
|
||||
} catch {
|
||||
// Treat close as end-of-stream rather than error when the
|
||||
// task reports a normal closure.
|
||||
if task.closeCode != .invalid {
|
||||
return nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
switch message {
|
||||
case .string(let text):
|
||||
return text
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
return text
|
||||
}
|
||||
@unknown default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func close() async {
|
||||
task?.cancel(with: .normalClosure, reason: nil)
|
||||
task = nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum TransportError: Error, Equatable {
|
||||
case notConnected
|
||||
/// The server rejected the WebSocket upgrade as unauthorized (401):
|
||||
/// the pairing token is unknown or was revoked. Reconnecting with the
|
||||
/// same token cannot succeed; the device must re-pair.
|
||||
case unauthorized
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import Foundation
|
||||
|
||||
/// Client request to a jcode server.
|
||||
///
|
||||
/// Wire format mirrors `crates/jcode-protocol/src/wire.rs` (`#[serde(tag = "type")]`,
|
||||
/// snake_case tags). Only the requests the iOS app uses are modeled; the server
|
||||
/// ignores fields it does not expect.
|
||||
public enum Request: Equatable, Sendable {
|
||||
case subscribe(id: UInt64, targetSessionID: String?)
|
||||
case message(id: UInt64, content: String)
|
||||
case cancel(id: UInt64)
|
||||
case softInterrupt(id: UInt64, content: String, urgent: Bool)
|
||||
case cancelSoftInterrupts(id: UInt64)
|
||||
case ping(id: UInt64)
|
||||
case getHistory(id: UInt64)
|
||||
case resumeSession(id: UInt64, sessionID: String)
|
||||
case setModel(id: UInt64, model: String)
|
||||
case setReasoningEffort(id: UInt64, effort: String)
|
||||
case compact(id: UInt64)
|
||||
case renameSession(id: UInt64, title: String?)
|
||||
case clear(id: UInt64)
|
||||
|
||||
public var id: UInt64 {
|
||||
switch self {
|
||||
case let .subscribe(id, _), let .message(id, _), let .cancel(id),
|
||||
let .softInterrupt(id, _, _), let .cancelSoftInterrupts(id),
|
||||
let .ping(id), let .getHistory(id), let .resumeSession(id, _),
|
||||
let .setModel(id, _), let .setReasoningEffort(id, _), let .compact(id),
|
||||
let .renameSession(id, _), let .clear(id):
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the request as a single JSON line (no trailing newline).
|
||||
public func encodedLine() throws -> String {
|
||||
var object: [String: Any] = ["id": id]
|
||||
switch self {
|
||||
case let .subscribe(_, targetSessionID):
|
||||
object["type"] = "subscribe"
|
||||
if let targetSessionID {
|
||||
object["target_session_id"] = targetSessionID
|
||||
}
|
||||
case let .message(_, content):
|
||||
object["type"] = "message"
|
||||
object["content"] = content
|
||||
case .cancel:
|
||||
object["type"] = "cancel"
|
||||
case let .softInterrupt(_, content, urgent):
|
||||
object["type"] = "soft_interrupt"
|
||||
object["content"] = content
|
||||
object["urgent"] = urgent
|
||||
case .cancelSoftInterrupts:
|
||||
object["type"] = "cancel_soft_interrupts"
|
||||
case .ping:
|
||||
object["type"] = "ping"
|
||||
case .getHistory:
|
||||
object["type"] = "get_history"
|
||||
case let .resumeSession(_, sessionID):
|
||||
object["type"] = "resume_session"
|
||||
object["session_id"] = sessionID
|
||||
case let .setModel(_, model):
|
||||
object["type"] = "set_model"
|
||||
object["model"] = model
|
||||
case let .setReasoningEffort(_, effort):
|
||||
object["type"] = "set_reasoning_effort"
|
||||
object["effort"] = effort
|
||||
case .compact:
|
||||
object["type"] = "compact"
|
||||
case let .renameSession(_, title):
|
||||
object["type"] = "rename_session"
|
||||
if let title {
|
||||
object["title"] = title
|
||||
}
|
||||
case .clear:
|
||||
object["type"] = "clear"
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
||||
guard let line = String(data: data, encoding: .utf8) else {
|
||||
throw WireError.encodingFailed
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call as represented in history payloads.
|
||||
public struct ToolCallRecord: Equatable, Sendable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var input: String
|
||||
public var output: String?
|
||||
public var error: String?
|
||||
|
||||
public init(id: String, name: String, input: String, output: String?, error: String?) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in conversation history (response to `get_history`).
|
||||
public struct HistoryMessage: Equatable, Sendable {
|
||||
public var role: String
|
||||
public var content: String
|
||||
public var toolCalls: [String]
|
||||
public var toolData: ToolCallRecord?
|
||||
|
||||
public init(
|
||||
role: String, content: String, toolCalls: [String] = [], toolData: ToolCallRecord? = nil
|
||||
) {
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.toolCalls = toolCalls
|
||||
self.toolData = toolData
|
||||
}
|
||||
}
|
||||
|
||||
/// Server event from a jcode server. Unknown event types decode as `.unknown`
|
||||
/// so newer servers never break older apps.
|
||||
public enum ServerEvent: Equatable, Sendable {
|
||||
case ack(id: UInt64)
|
||||
case textDelta(text: String)
|
||||
case reasoningDelta(text: String)
|
||||
case reasoningDone(durationSecs: Double?)
|
||||
case textReplace(text: String)
|
||||
case toolStart(id: String, name: String)
|
||||
case toolInput(delta: String)
|
||||
case toolExec(id: String, name: String)
|
||||
case toolDone(id: String, name: String, output: String, error: String?)
|
||||
case tokenUsage(input: UInt64, output: UInt64)
|
||||
case statusDetail(detail: String)
|
||||
case connectionPhase(phase: String)
|
||||
case softInterruptInjected(content: String, displayRole: String?, point: String, toolsSkipped: Int?)
|
||||
case retryRollback(attempt: Int, max: Int)
|
||||
case messageEnd
|
||||
case interrupted
|
||||
case done(id: UInt64)
|
||||
case error(id: UInt64, message: String, retryAfterSecs: UInt64?)
|
||||
case pong(id: UInt64)
|
||||
case state(id: UInt64, sessionID: String, messageCount: Int, isProcessing: Bool)
|
||||
case sessionID(sessionID: String)
|
||||
case sessionRenamed(sessionID: String, displayTitle: String)
|
||||
case history(HistoryPayload)
|
||||
case modelChanged(id: UInt64, model: String, error: String?)
|
||||
case reasoningEffortChanged(id: UInt64, effort: String?, error: String?)
|
||||
case compactResult(id: UInt64, message: String, success: Bool)
|
||||
case availableModelsUpdated(models: [String], providerModel: String?)
|
||||
case compaction(trigger: String, tokensSaved: UInt64?)
|
||||
case notification(fromName: String?, message: String)
|
||||
case reloading(newSocket: String?)
|
||||
case sessionCloseRequested(reason: String)
|
||||
case unknown(type: String)
|
||||
|
||||
public struct HistoryPayload: Equatable, Sendable {
|
||||
public var id: UInt64
|
||||
public var sessionID: String
|
||||
public var messages: [HistoryMessage]
|
||||
public var providerName: String?
|
||||
public var providerModel: String?
|
||||
public var availableModels: [String]
|
||||
public var totalTokens: TokenTotals?
|
||||
public var allSessions: [String]
|
||||
public var serverVersion: String?
|
||||
public var displayTitle: String?
|
||||
public var reasoningEffort: String?
|
||||
|
||||
public struct TokenTotals: Equatable, Sendable {
|
||||
public var input: UInt64
|
||||
public var output: UInt64
|
||||
|
||||
public init(input: UInt64, output: UInt64) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
id: UInt64,
|
||||
sessionID: String,
|
||||
messages: [HistoryMessage],
|
||||
providerName: String? = nil,
|
||||
providerModel: String? = nil,
|
||||
availableModels: [String] = [],
|
||||
totalTokens: TokenTotals? = nil,
|
||||
allSessions: [String] = [],
|
||||
serverVersion: String? = nil,
|
||||
displayTitle: String? = nil,
|
||||
reasoningEffort: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.sessionID = sessionID
|
||||
self.messages = messages
|
||||
self.providerName = providerName
|
||||
self.providerModel = providerModel
|
||||
self.availableModels = availableModels
|
||||
self.totalTokens = totalTokens
|
||||
self.allSessions = allSessions
|
||||
self.serverVersion = serverVersion
|
||||
self.displayTitle = displayTitle
|
||||
self.reasoningEffort = reasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes one newline-delimited JSON event line.
|
||||
public static func decode(line: String) throws -> ServerEvent {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data),
|
||||
let object = parsed as? [String: Any]
|
||||
else {
|
||||
throw WireError.invalidJSON(line: line)
|
||||
}
|
||||
guard let type = object["type"] as? String else {
|
||||
throw WireError.missingType(line: line)
|
||||
}
|
||||
let json = JSONObject(object)
|
||||
switch type {
|
||||
case "ack":
|
||||
return .ack(id: json.uint64("id"))
|
||||
case "text_delta":
|
||||
return .textDelta(text: json.string("text"))
|
||||
case "reasoning_delta":
|
||||
return .reasoningDelta(text: json.string("text"))
|
||||
case "reasoning_done":
|
||||
return .reasoningDone(durationSecs: json.optionalDouble("duration_secs"))
|
||||
case "text_replace":
|
||||
return .textReplace(text: json.string("text"))
|
||||
case "tool_start":
|
||||
return .toolStart(id: json.string("id"), name: json.string("name"))
|
||||
case "tool_input":
|
||||
return .toolInput(delta: json.string("delta"))
|
||||
case "tool_exec":
|
||||
return .toolExec(id: json.string("id"), name: json.string("name"))
|
||||
case "tool_done":
|
||||
return .toolDone(
|
||||
id: json.string("id"),
|
||||
name: json.string("name"),
|
||||
output: json.string("output"),
|
||||
error: json.optionalString("error")
|
||||
)
|
||||
case "tokens":
|
||||
return .tokenUsage(input: json.uint64("input"), output: json.uint64("output"))
|
||||
case "status_detail":
|
||||
return .statusDetail(detail: json.string("detail"))
|
||||
case "connection_phase":
|
||||
return .connectionPhase(phase: json.string("phase"))
|
||||
case "soft_interrupt_injected":
|
||||
return .softInterruptInjected(
|
||||
content: json.string("content"),
|
||||
displayRole: json.optionalString("display_role"),
|
||||
point: json.string("point"),
|
||||
toolsSkipped: json.optionalInt("tools_skipped")
|
||||
)
|
||||
case "retry_rollback":
|
||||
return .retryRollback(attempt: json.int("attempt"), max: json.int("max"))
|
||||
case "message_end":
|
||||
return .messageEnd
|
||||
case "interrupted":
|
||||
return .interrupted
|
||||
case "done":
|
||||
return .done(id: json.uint64("id"))
|
||||
case "error":
|
||||
return .error(
|
||||
id: json.uint64("id"),
|
||||
message: json.string("message"),
|
||||
retryAfterSecs: json.optionalUInt64("retry_after_secs")
|
||||
)
|
||||
case "pong":
|
||||
return .pong(id: json.uint64("id"))
|
||||
case "state":
|
||||
return .state(
|
||||
id: json.uint64("id"),
|
||||
sessionID: json.string("session_id"),
|
||||
messageCount: json.int("message_count"),
|
||||
isProcessing: json.bool("is_processing")
|
||||
)
|
||||
case "session":
|
||||
return .sessionID(sessionID: json.string("session_id"))
|
||||
case "session_renamed":
|
||||
return .sessionRenamed(
|
||||
sessionID: json.string("session_id"),
|
||||
displayTitle: json.string("display_title")
|
||||
)
|
||||
case "history":
|
||||
return .history(decodeHistory(json))
|
||||
case "model_changed":
|
||||
return .modelChanged(
|
||||
id: json.uint64("id"),
|
||||
model: json.string("model"),
|
||||
error: json.optionalString("error")
|
||||
)
|
||||
case "reasoning_effort_changed":
|
||||
return .reasoningEffortChanged(
|
||||
id: json.uint64("id"),
|
||||
effort: json.optionalString("effort"),
|
||||
error: json.optionalString("error")
|
||||
)
|
||||
case "compact_result":
|
||||
return .compactResult(
|
||||
id: json.uint64("id"),
|
||||
message: json.string("message"),
|
||||
success: json.bool("success")
|
||||
)
|
||||
case "available_models_updated":
|
||||
return .availableModelsUpdated(
|
||||
models: json.stringArray("available_models"),
|
||||
providerModel: json.optionalString("provider_model")
|
||||
)
|
||||
case "compaction":
|
||||
return .compaction(
|
||||
trigger: json.string("trigger"),
|
||||
tokensSaved: json.optionalUInt64("tokens_saved")
|
||||
)
|
||||
case "notification":
|
||||
return .notification(
|
||||
fromName: json.optionalString("from_name"),
|
||||
message: json.string("message")
|
||||
)
|
||||
case "reloading":
|
||||
return .reloading(newSocket: json.optionalString("new_socket"))
|
||||
case "session_close_requested":
|
||||
return .sessionCloseRequested(reason: json.string("reason"))
|
||||
default:
|
||||
return .unknown(type: type)
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeHistory(_ json: JSONObject) -> HistoryPayload {
|
||||
let messages = json.objectArray("messages").map { msg -> HistoryMessage in
|
||||
var toolData: ToolCallRecord?
|
||||
if let td = msg.optionalObject("tool_data") {
|
||||
toolData = ToolCallRecord(
|
||||
id: td.string("id"),
|
||||
name: td.string("name"),
|
||||
input: td.string("input"),
|
||||
output: td.optionalString("output"),
|
||||
error: td.optionalString("error")
|
||||
)
|
||||
}
|
||||
return HistoryMessage(
|
||||
role: msg.string("role"),
|
||||
content: msg.string("content"),
|
||||
toolCalls: msg.stringArray("tool_calls"),
|
||||
toolData: toolData
|
||||
)
|
||||
}
|
||||
var totals: HistoryPayload.TokenTotals?
|
||||
if let pair = json.raw["total_tokens"] as? [Any], pair.count == 2,
|
||||
let input = JSONObject.coerceUInt64(pair[0]),
|
||||
let output = JSONObject.coerceUInt64(pair[1])
|
||||
{
|
||||
totals = HistoryPayload.TokenTotals(input: input, output: output)
|
||||
}
|
||||
return HistoryPayload(
|
||||
id: json.uint64("id"),
|
||||
sessionID: json.string("session_id"),
|
||||
messages: messages,
|
||||
providerName: json.optionalString("provider_name"),
|
||||
providerModel: json.optionalString("provider_model"),
|
||||
availableModels: json.stringArray("available_models"),
|
||||
totalTokens: totals,
|
||||
allSessions: json.stringArray("all_sessions"),
|
||||
serverVersion: json.optionalString("server_version"),
|
||||
displayTitle: json.optionalString("display_title"),
|
||||
reasoningEffort: json.optionalString("reasoning_effort")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum WireError: Error, Equatable {
|
||||
case encodingFailed
|
||||
case invalidJSON(line: String)
|
||||
case missingType(line: String)
|
||||
}
|
||||
|
||||
/// Lenient JSON accessor. The wire protocol omits absent optionals and the
|
||||
/// app must never crash on a server that is newer or older than itself.
|
||||
struct JSONObject {
|
||||
let raw: [String: Any]
|
||||
|
||||
init(_ raw: [String: Any]) {
|
||||
self.raw = raw
|
||||
}
|
||||
|
||||
func string(_ key: String) -> String {
|
||||
raw[key] as? String ?? ""
|
||||
}
|
||||
|
||||
func optionalString(_ key: String) -> String? {
|
||||
raw[key] as? String
|
||||
}
|
||||
|
||||
func bool(_ key: String) -> Bool {
|
||||
raw[key] as? Bool ?? false
|
||||
}
|
||||
|
||||
func int(_ key: String) -> Int {
|
||||
(raw[key] as? NSNumber)?.intValue ?? 0
|
||||
}
|
||||
|
||||
func optionalInt(_ key: String) -> Int? {
|
||||
(raw[key] as? NSNumber)?.intValue
|
||||
}
|
||||
|
||||
func uint64(_ key: String) -> UInt64 {
|
||||
Self.coerceUInt64(raw[key] ?? 0) ?? 0
|
||||
}
|
||||
|
||||
func optionalUInt64(_ key: String) -> UInt64? {
|
||||
raw[key].flatMap(Self.coerceUInt64)
|
||||
}
|
||||
|
||||
func optionalDouble(_ key: String) -> Double? {
|
||||
(raw[key] as? NSNumber)?.doubleValue
|
||||
}
|
||||
|
||||
func stringArray(_ key: String) -> [String] {
|
||||
raw[key] as? [String] ?? []
|
||||
}
|
||||
|
||||
func objectArray(_ key: String) -> [JSONObject] {
|
||||
(raw[key] as? [[String: Any]])?.map(JSONObject.init) ?? []
|
||||
}
|
||||
|
||||
func optionalObject(_ key: String) -> JSONObject? {
|
||||
(raw[key] as? [String: Any]).map(JSONObject.init)
|
||||
}
|
||||
|
||||
static func coerceUInt64(_ value: Any) -> UInt64? {
|
||||
if let number = value as? NSNumber {
|
||||
return number.uint64Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import JCodeKit
|
||||
import Observation
|
||||
|
||||
/// Observable glue between JCodeKit and the SwiftUI views.
|
||||
///
|
||||
/// Owns the credential store, the active `Connection`, and the derived
|
||||
/// `SessionState`. Contains no protocol or state-transition logic itself;
|
||||
/// everything flows through `SessionReducer`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppModel {
|
||||
// MARK: - Published state
|
||||
|
||||
private(set) var session = SessionState()
|
||||
private(set) var servers: [ServerCredential] = []
|
||||
var activeServer: ServerCredential?
|
||||
|
||||
/// Composer draft.
|
||||
var draft = ""
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private let store: any CredentialStore
|
||||
private var connection: Connection?
|
||||
private var pumpTask: Task<Void, Never>?
|
||||
|
||||
init(store: any CredentialStore = KeychainCredentialStore()) {
|
||||
self.store = store
|
||||
servers = store.loadAll()
|
||||
activeServer = servers.last
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
session.phase == .connected
|
||||
}
|
||||
|
||||
// MARK: - Pairing
|
||||
|
||||
func pair(gateway: Gateway, code: String, deviceName: String) async throws {
|
||||
let client = PairingClient()
|
||||
let response = try await client.pair(
|
||||
gateway: gateway,
|
||||
code: code,
|
||||
deviceID: deviceID(),
|
||||
deviceName: deviceName
|
||||
)
|
||||
let credential = ServerCredential(
|
||||
host: gateway.host,
|
||||
port: gateway.port,
|
||||
token: response.token,
|
||||
serverName: response.serverName,
|
||||
serverVersion: response.serverVersion
|
||||
)
|
||||
store.save(credential)
|
||||
servers = store.loadAll()
|
||||
activeServer = credential
|
||||
connect(to: credential)
|
||||
}
|
||||
|
||||
func removeServer(_ credential: ServerCredential) {
|
||||
store.remove(id: credential.id)
|
||||
servers = store.loadAll()
|
||||
if activeServer?.id == credential.id {
|
||||
disconnect()
|
||||
activeServer = servers.last
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection lifecycle
|
||||
|
||||
func connect(to credential: ServerCredential, sessionID: String? = nil) {
|
||||
session = SessionState()
|
||||
open(credential, sessionID: sessionID)
|
||||
}
|
||||
|
||||
/// Reconnects to the active server without discarding the rendered
|
||||
/// transcript; the history resync replaces it once the socket is back.
|
||||
func retryConnection() {
|
||||
guard let activeServer else { return }
|
||||
open(activeServer, sessionID: session.sessionID)
|
||||
}
|
||||
|
||||
private func open(_ credential: ServerCredential, sessionID: String?) {
|
||||
disconnect()
|
||||
activeServer = credential
|
||||
let connection = Connection(
|
||||
configuration: .init(
|
||||
gateway: credential.gateway,
|
||||
authToken: credential.token
|
||||
)
|
||||
)
|
||||
self.connection = connection
|
||||
pumpTask = Task { [weak self] in
|
||||
let stream = await connection.start(resumeSessionID: sessionID)
|
||||
for await output in stream {
|
||||
guard let self else { return }
|
||||
self.session = SessionReducer.reduce(self.session, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
pumpTask?.cancel()
|
||||
pumpTask = nil
|
||||
let connection = connection
|
||||
self.connection = nil
|
||||
Task { await connection?.stop() }
|
||||
session = SessionReducer.reduce(session, .phase(.disconnected))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func sendDraft() {
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
draft = ""
|
||||
if session.isProcessing {
|
||||
session = SessionReducer.reduce(session, intent: .userQueuedInterrupt(text))
|
||||
send { .softInterrupt(id: $0, content: text, urgent: false) }
|
||||
} else {
|
||||
session = SessionReducer.reduce(session, intent: .userSentMessage(text))
|
||||
send { .message(id: $0, content: text) }
|
||||
}
|
||||
}
|
||||
|
||||
func interrupt() {
|
||||
send { .cancel(id: $0) }
|
||||
}
|
||||
|
||||
func switchSession(_ sessionID: String) {
|
||||
guard let activeServer else { return }
|
||||
connect(to: activeServer, sessionID: sessionID)
|
||||
}
|
||||
|
||||
func setModel(_ model: String) {
|
||||
send { .setModel(id: $0, model: model) }
|
||||
}
|
||||
|
||||
func setReasoningEffort(_ effort: String) {
|
||||
send { .setReasoningEffort(id: $0, effort: effort) }
|
||||
}
|
||||
|
||||
/// Asks the server to compact the conversation context.
|
||||
func compactConversation() {
|
||||
send { .compact(id: $0) }
|
||||
}
|
||||
|
||||
func renameSession(_ title: String) {
|
||||
send { .renameSession(id: $0, title: title.isEmpty ? nil : title) }
|
||||
}
|
||||
|
||||
func dismissError() {
|
||||
session = SessionReducer.reduce(session, intent: .dismissError)
|
||||
}
|
||||
|
||||
func dismissNotice(_ id: UUID) {
|
||||
session = SessionReducer.reduce(session, intent: .dismissNotice(id))
|
||||
}
|
||||
|
||||
/// Clears the current conversation on the server and optimistically locally.
|
||||
func clearConversation() {
|
||||
session = SessionReducer.reduce(session, intent: .clearedConversation)
|
||||
send { .clear(id: $0) }
|
||||
}
|
||||
|
||||
/// Drops any soft-interrupt messages queued mid-run before they inject.
|
||||
func cancelQueuedInterrupts() {
|
||||
session = SessionReducer.reduce(session, intent: .cancelledQueuedInterrupts)
|
||||
send { .cancelSoftInterrupts(id: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func send(_ build: @escaping @Sendable (UInt64) -> Request) {
|
||||
guard let connection else { return }
|
||||
Task {
|
||||
do {
|
||||
try await connection.send(build)
|
||||
} catch {
|
||||
// Connection drops surface via phase changes; nothing to do here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceID() -> String {
|
||||
let key = "jcode.device.id"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) {
|
||||
return existing
|
||||
}
|
||||
let fresh = UUID().uuidString
|
||||
UserDefaults.standard.set(fresh, forKey: key)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "AppIcon.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0x14",
|
||||
"green": "0x0F",
|
||||
"red": "0x0F"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>jcode</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.jcode.mobile.pair</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>jcode</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan the QR code shown by `jcode pair` to connect to your server.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Connects to jcode servers on your local network or tailnet.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct JCodeMobileApp: App {
|
||||
@State private var model = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(model)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Lightweight markdown renderer for assistant messages.
|
||||
///
|
||||
/// Handles fenced code blocks as monospaced cards and renders everything else
|
||||
/// through SwiftUI's native AttributedString markdown (bold, italics, inline
|
||||
/// code, links). Deliberately not a full CommonMark implementation.
|
||||
struct MarkdownText: View {
|
||||
let text: String
|
||||
|
||||
init(_ text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in
|
||||
switch segment {
|
||||
case .prose(let prose):
|
||||
Text(attributed(prose))
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
case .code(let code, _):
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(12)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Segment {
|
||||
case prose(String)
|
||||
case code(String, language: String?)
|
||||
}
|
||||
|
||||
private var segments: [Segment] {
|
||||
var result: [Segment] = []
|
||||
var prose: [String] = []
|
||||
var code: [String] = []
|
||||
var language: String?
|
||||
var inCode = false
|
||||
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
if line.hasPrefix("```") {
|
||||
if inCode {
|
||||
result.append(.code(code.joined(separator: "\n"), language: language))
|
||||
code = []
|
||||
inCode = false
|
||||
} else {
|
||||
let joined = prose.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !joined.isEmpty {
|
||||
result.append(.prose(joined))
|
||||
}
|
||||
prose = []
|
||||
language = line.dropFirst(3).isEmpty ? nil : String(line.dropFirst(3))
|
||||
inCode = true
|
||||
}
|
||||
} else if inCode {
|
||||
code.append(String(line))
|
||||
} else {
|
||||
prose.append(String(line))
|
||||
}
|
||||
}
|
||||
if inCode {
|
||||
result.append(.code(code.joined(separator: "\n"), language: language))
|
||||
} else {
|
||||
let joined = prose.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !joined.isEmpty {
|
||||
result.append(.prose(joined))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func attributed(_ string: String) -> AttributedString {
|
||||
(try? AttributedString(
|
||||
markdown: string,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
)) ?? AttributedString(string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// Camera-based QR scanner for pairing codes.
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onScan: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let controller = ScannerViewController()
|
||||
controller.onScan = onScan
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, @preconcurrency AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScan: ((String) -> Void)?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var didScan = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input)
|
||||
else {
|
||||
showUnavailableLabel()
|
||||
return
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
showUnavailableLabel()
|
||||
return
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.bounds
|
||||
view.layer.addSublayer(preview)
|
||||
previewLayer = preview
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
didScan = false
|
||||
let session = session
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if !session.isRunning {
|
||||
session.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
let session = session
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if session.isRunning {
|
||||
session.stopRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !didScan,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
object.type == .qr,
|
||||
let value = object.stringValue
|
||||
else { return }
|
||||
didScan = true
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
onScan?(value)
|
||||
}
|
||||
|
||||
private func showUnavailableLabel() {
|
||||
let label = UILabel()
|
||||
label.text = "Camera unavailable"
|
||||
label.textColor = .white
|
||||
label.textAlignment = .center
|
||||
label.frame = view.bounds
|
||||
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Design tokens. Dark, calm, terminal-native; mint accent for live state.
|
||||
enum Theme {
|
||||
static let background = Color(hex: 0x0F0F14)
|
||||
static let surface = Color(hex: 0x1A1A1F)
|
||||
static let surfaceElevated = Color(hex: 0x242429)
|
||||
static let border = Color.white.opacity(0.08)
|
||||
static let mint = Color(hex: 0x4DD9A6)
|
||||
static let mintTint = Color(hex: 0x4DD9A6).opacity(0.15)
|
||||
static let textPrimary = Color.white.opacity(0.92)
|
||||
static let textSecondary = Color.white.opacity(0.55)
|
||||
static let textTertiary = Color.white.opacity(0.35)
|
||||
static let warning = Color(hex: 0xF59E0B)
|
||||
static let error = Color(hex: 0xD94D59)
|
||||
|
||||
static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.system(size: size, weight: weight, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Decorative icon font (SF Symbols) at a fixed point size.
|
||||
static func icon(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.system(size: size, weight: weight)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32) {
|
||||
self.init(
|
||||
red: Double((hex >> 16) & 0xFF) / 255.0,
|
||||
green: Double((hex >> 8) & 0xFF) / 255.0,
|
||||
blue: Double(hex & 0xFF) / 255.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra edge padding for chrome pinned to an edge with no system inset.
|
||||
///
|
||||
/// Home-button devices (iPhone SE class) report a zero bottom safe-area inset,
|
||||
/// so edge-pinned chrome needs explicit breathing room there; Dynamic Island
|
||||
/// devices already get it from the system insets. Derived from the root
|
||||
/// GeometryReader in RootView and injected via the environment: reading
|
||||
/// UIKit window insets during a SwiftUI body evaluation creates an
|
||||
/// AttributeGraph cycle that corrupts view-hierarchy updates.
|
||||
struct CompactEdgePads: Equatable {
|
||||
var top: CGFloat = 0
|
||||
var bottom: CGFloat = 0
|
||||
|
||||
/// Derives the pads from the container's safe-area insets.
|
||||
init(safeArea: EdgeInsets) {
|
||||
top = safeArea.top < 24 ? 12 : 0
|
||||
bottom = safeArea.bottom > 0 ? 0 : 12
|
||||
}
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var compactEdgePads = CompactEdgePads()
|
||||
}
|
||||
|
||||
/// Card container used across screens.
|
||||
struct Card<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Main conversation screen.
|
||||
struct ChatView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.compactEdgePads) private var edgePads
|
||||
@State private var showSettings = false
|
||||
@State private var sendCount = 0
|
||||
|
||||
var body: some View {
|
||||
@Bindable var model = model
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
if showConnectionBanner {
|
||||
ConnectionBanner(phase: model.session.phase) {
|
||||
model.retryConnection()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
if let banner = model.session.errorBanner {
|
||||
ErrorBanner(message: banner) {
|
||||
model.dismissError()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
if !model.session.notices.isEmpty {
|
||||
NoticeStack(
|
||||
notices: model.session.notices,
|
||||
onDismiss: { model.dismissNotice($0) }
|
||||
)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
TranscriptView(
|
||||
entries: model.session.transcript,
|
||||
isReasoning: model.session.isReasoning
|
||||
)
|
||||
|
||||
if model.session.hasPendingInterrupts {
|
||||
QueuedInterruptChip(count: model.session.pendingInterrupts.count) {
|
||||
model.cancelQueuedInterrupts()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Composer(
|
||||
draft: $model.draft,
|
||||
isProcessing: model.session.isProcessing,
|
||||
isConnected: model.isConnected,
|
||||
onSend: {
|
||||
sendCount += 1
|
||||
model.sendDraft()
|
||||
},
|
||||
onInterrupt: { model.interrupt() }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: sendCount)
|
||||
.sensoryFeedback(.impact(flexibility: .soft), trigger: finishedToolCallCount) {
|
||||
$1 > $0
|
||||
}
|
||||
.sensoryFeedback(.error, trigger: model.session.errorBanner) {
|
||||
$1 != nil
|
||||
}
|
||||
}
|
||||
|
||||
private var showConnectionBanner: Bool {
|
||||
switch model.session.phase {
|
||||
case .reconnecting, .disconnected, .failed: true
|
||||
case .connected, .connecting: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Finished tool calls on the streaming (last) entry; drives a subtle
|
||||
/// tick as tools complete without scanning the whole transcript.
|
||||
private var finishedToolCallCount: Int {
|
||||
model.session.transcript.last?.toolCalls.filter { call in
|
||||
switch call.status {
|
||||
case .succeeded, .failed: true
|
||||
case .streamingInput, .running: false
|
||||
}
|
||||
}.count ?? 0
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.session.sessionTitle ?? model.activeServer?.serverName ?? "jcode")
|
||||
.font(Theme.mono(16, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
if let modelName = model.session.modelName {
|
||||
Text(modelName)
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
StatusPill(phase: model.session.phase)
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
.accessibilityHint("Sessions, model, and servers")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.top, edgePads.top)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Message composer with send/interrupt.
|
||||
struct Composer: View {
|
||||
@Environment(\.compactEdgePads) private var edgePads
|
||||
@Binding var draft: String
|
||||
let isProcessing: Bool
|
||||
let isConnected: Bool
|
||||
let onSend: () -> Void
|
||||
let onInterrupt: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(
|
||||
isProcessing ? "Queue a message..." : "Message",
|
||||
text: $draft,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(1...6)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
if isProcessing {
|
||||
Button(action: onInterrupt) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Theme.error)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityLabel("Stop")
|
||||
.accessibilityHint("Interrupt the current response")
|
||||
}
|
||||
|
||||
Button(action: onSend) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundStyle(isConnected ? .black : Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(isConnected ? Theme.mint : Theme.surfaceElevated)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.disabled(!canSend)
|
||||
.accessibilityLabel(isProcessing ? "Queue message" : "Send message")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.bottom, edgePads.bottom)
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
isConnected && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Inline banner shown while the connection is down, with a manual retry.
|
||||
///
|
||||
/// The automatic reconnect loop keeps running underneath; the button just
|
||||
/// short-circuits the backoff wait for impatient humans.
|
||||
struct ConnectionBanner: View {
|
||||
let phase: ConnectionPhase
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.warning)
|
||||
.accessibilityHidden(true)
|
||||
Text(label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: onRetry) {
|
||||
Text("Retry")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Retry connection")
|
||||
.accessibilityHint("Reconnects to the server now")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Theme.warning.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.warning.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch phase {
|
||||
case .reconnecting(let attempt):
|
||||
"Connection lost, retrying (attempt \(attempt))"
|
||||
case .failed:
|
||||
"Connection failed"
|
||||
default:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip shown above the composer while soft-interrupt messages wait to be
|
||||
/// injected into the running turn, with a cancel affordance.
|
||||
struct QueuedInterruptChip: View {
|
||||
let count: Int
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "clock")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
Text(count == 1 ? "1 message queued" : "\(count) messages queued")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: onCancel) {
|
||||
Text("Cancel")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Theme.error)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Cancel queued messages")
|
||||
.accessibilityHint("Removes messages waiting to interrupt the response")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Theme.mintTint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.mint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Friendly placeholder for a fresh session, centered in the canvas.
|
||||
struct EmptyTranscript: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "terminal")
|
||||
.font(Theme.icon(40, weight: .light))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text("Ready when you are")
|
||||
.font(Theme.mono(16, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("Send a message to start driving this session.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// One transcript entry: user bubble, assistant markdown, or system note.
|
||||
struct EntryView: View {
|
||||
let entry: TranscriptEntry
|
||||
|
||||
var body: some View {
|
||||
switch entry.role {
|
||||
case .user:
|
||||
HStack {
|
||||
Spacer(minLength: 48)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(entry.text)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(12)
|
||||
.background(Theme.mintTint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.copyContextMenu(entry.text)
|
||||
if entry.isQueued {
|
||||
Label("queued", systemImage: "clock")
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.accessibilityLabel("Queued")
|
||||
.accessibilityHint("Delivers after the current response")
|
||||
}
|
||||
}
|
||||
}
|
||||
case .assistant:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !entry.reasoning.isEmpty {
|
||||
Text(entry.reasoning)
|
||||
.font(Theme.mono(12))
|
||||
.italic()
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(4)
|
||||
.copyContextMenu(entry.reasoning)
|
||||
}
|
||||
ForEach(entry.toolCalls) { call in
|
||||
ToolCallCard(call: call)
|
||||
}
|
||||
if !entry.text.isEmpty {
|
||||
MarkdownText(entry.text)
|
||||
.copyContextMenu(entry.text)
|
||||
}
|
||||
}
|
||||
case .system:
|
||||
Text(entry.text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.copyContextMenu(entry.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Long-press context menu offering to copy the given text.
|
||||
func copyContextMenu(_ text: String) -> some View {
|
||||
contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// First-run pairing: scan QR or type host/port/code.
|
||||
struct PairingView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@State private var host = ""
|
||||
@State private var port = String(Gateway.defaultPort)
|
||||
@State private var code = ""
|
||||
@State private var isPairing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showScanner = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
|
||||
if let errorMessage {
|
||||
ErrorBanner(message: errorMessage) {
|
||||
self.errorMessage = nil
|
||||
}
|
||||
.padding(.horizontal, -16)
|
||||
}
|
||||
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
field("Host", text: $host, placeholder: "devbox.tailnet.ts.net")
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
field("Port", text: $port, placeholder: "7643")
|
||||
.keyboardType(.numberPad)
|
||||
field("Pairing code", text: $code, placeholder: "123456")
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: pair) {
|
||||
HStack {
|
||||
if isPairing && !reduceMotion {
|
||||
ProgressView().tint(.black)
|
||||
}
|
||||
Text(isPairing ? "Pairing..." : "Pair")
|
||||
.font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(canPair ? Theme.mint : Theme.surfaceElevated)
|
||||
.foregroundStyle(canPair ? .black : Theme.textTertiary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!canPair || isPairing)
|
||||
.accessibilityLabel("Pair")
|
||||
.accessibilityHint("Connects using the host, port, and code above")
|
||||
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Scan QR from `jcode pair`", systemImage: "qrcode.viewfinder")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.surface)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Scan QR code")
|
||||
.accessibilityHint("Opens the camera to scan a pairing code")
|
||||
|
||||
Text("Run `jcode pair` on your machine, then scan the QR code or enter the code manually. Traffic stays on your tailnet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.dynamicTypeSize(.large ... .accessibility3)
|
||||
.sheet(isPresented: $showScanner) {
|
||||
QRScannerView { scanned in
|
||||
showScanner = false
|
||||
if let payload = PairURI.parse(scanned) {
|
||||
host = payload.gateway.host
|
||||
port = String(payload.gateway.port)
|
||||
code = payload.code
|
||||
pair()
|
||||
} else {
|
||||
errorMessage = "Not a jcode pairing QR code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("jcode")
|
||||
.font(Theme.mono(34, weight: .bold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("Pair with a server on your tailnet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
}
|
||||
|
||||
private var canPair: Bool {
|
||||
!host.trimmingCharacters(in: .whitespaces).isEmpty && !code.isEmpty
|
||||
&& UInt16(port) != nil
|
||||
}
|
||||
|
||||
private func field(_ label: String, text: Binding<String>, placeholder: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
TextField(placeholder, text: text)
|
||||
.font(Theme.mono(16))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(12)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
private func pair() {
|
||||
guard let portValue = UInt16(port) else { return }
|
||||
let gateway = Gateway(host: host.trimmingCharacters(in: .whitespaces), port: portValue)
|
||||
let pairCode = code
|
||||
isPairing = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
defer { isPairing = false }
|
||||
do {
|
||||
try await model.pair(
|
||||
gateway: gateway,
|
||||
code: pairCode,
|
||||
deviceName: UIDevice.current.name
|
||||
)
|
||||
} catch let error as PairingClient.PairingError {
|
||||
switch error {
|
||||
case .invalidCode(let message):
|
||||
errorMessage = message
|
||||
case .serverError(_, let message):
|
||||
errorMessage = message
|
||||
case .invalidResponse:
|
||||
errorMessage = "Unexpected response from server"
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Could not reach \(gateway.host):\(gateway.port)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Top-level router: pairing when no server, chat otherwise.
|
||||
struct RootView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var deepLinkError: String?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
if model.activeServer == nil {
|
||||
PairingView()
|
||||
} else {
|
||||
ChatView()
|
||||
}
|
||||
}
|
||||
.environment(\.compactEdgePads, CompactEdgePads(safeArea: proxy.safeAreaInsets))
|
||||
}
|
||||
.task {
|
||||
// Auto-connect to the most recent server on launch.
|
||||
if let server = model.activeServer, !model.isConnected {
|
||||
model.connect(to: server)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard let payload = PairURI.parse(url.absoluteString) else { return }
|
||||
Task {
|
||||
do {
|
||||
try await model.pair(
|
||||
gateway: payload.gateway,
|
||||
code: payload.code,
|
||||
deviceName: UIDevice.current.name
|
||||
)
|
||||
} catch {
|
||||
deepLinkError = "Pairing failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Pairing", isPresented: .init(
|
||||
get: { deepLinkError != nil },
|
||||
set: { if !$0 { deepLinkError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkError ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection status pill shown in the chat header.
|
||||
struct StatusPill: View {
|
||||
let phase: ConnectionPhase
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
Text(label)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 4)
|
||||
.background(Theme.surface)
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(Theme.border, lineWidth: 1))
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Connection")
|
||||
.accessibilityValue(label)
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch phase {
|
||||
case .connected: Theme.mint
|
||||
case .connecting, .reconnecting: Theme.warning
|
||||
case .disconnected, .failed: Theme.error
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch phase {
|
||||
case .connected: "live"
|
||||
case .connecting: "connecting"
|
||||
case .reconnecting(let attempt): "retry \(attempt)"
|
||||
case .disconnected: "offline"
|
||||
case .failed: "failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismissible error banner.
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Theme.error)
|
||||
.accessibilityHidden(true)
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(3)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Dismiss error")
|
||||
.accessibilityHint("Hides this error message")
|
||||
}
|
||||
.padding(12)
|
||||
.background(Theme.error.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.error.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack of dismissible notices for out-of-band server signals
|
||||
/// (push notifications, interrupts, context compaction).
|
||||
struct NoticeStack: View {
|
||||
let notices: [Notice]
|
||||
let onDismiss: (UUID) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(notices) { notice in
|
||||
NoticeRow(notice: notice) { onDismiss(notice.id) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoticeRow: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
let notice: Notice
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(tint)
|
||||
.accessibilityHidden(true)
|
||||
Text(notice.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(3)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Dismiss notice")
|
||||
.accessibilityHint("Hides this notice")
|
||||
}
|
||||
.padding(12)
|
||||
.background(tint.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
// Honor Reduce Motion: skip the slide/fade for motion-sensitive users.
|
||||
.transition(reduceMotion
|
||||
? .opacity
|
||||
: .move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch notice.kind {
|
||||
case .info: "info.circle.fill"
|
||||
case .notification: "bell.fill"
|
||||
case .compaction: "arrow.down.right.and.arrow.up.left"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch notice.kind {
|
||||
case .info: Theme.textSecondary
|
||||
case .notification: Theme.mint
|
||||
case .compaction: Theme.warning
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Sessions, servers, and info sections, split out to keep view files small.
|
||||
struct SettingsSessionsSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var renameDraft: String
|
||||
@Binding var showRename: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Sessions") {
|
||||
ForEach(model.session.allSessions, id: \.self) { sessionID in
|
||||
sessionRow(sessionID)
|
||||
}
|
||||
Button {
|
||||
renameDraft = model.session.sessionTitle ?? ""
|
||||
showRename = true
|
||||
} label: {
|
||||
Label("Rename current session", systemImage: "pencil")
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Opens a field to rename the active session")
|
||||
Button {
|
||||
model.compactConversation()
|
||||
} label: {
|
||||
Label("Compact conversation", systemImage: "arrow.down.right.and.arrow.up.left")
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Summarizes older messages to free context")
|
||||
Button {
|
||||
model.clearConversation()
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("New session (clear)", systemImage: "square.and.pencil")
|
||||
.foregroundStyle(Theme.mint)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Clears the conversation and starts fresh")
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionRow(_ sessionID: String) -> some View {
|
||||
let isActive = sessionID == model.session.sessionID
|
||||
let title = model.session.title(forSession: sessionID)
|
||||
return Button {
|
||||
model.switchSession(sessionID)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let title {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(shortSessionID(sessionID))
|
||||
.font(Theme.mono(title == nil ? 13 : 11))
|
||||
.foregroundStyle(title == nil ? Theme.textPrimary : Theme.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel("Session \(title ?? shortSessionID(sessionID))")
|
||||
.accessibilityValue(isActive ? "Current" : "")
|
||||
.accessibilityHint("Switches to this session")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
|
||||
private func shortSessionID(_ id: String) -> String {
|
||||
if id.count > 24 {
|
||||
return String(id.prefix(24)) + "…"
|
||||
}
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsServersSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var showPairNew: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Servers") {
|
||||
ForEach(model.servers) { server in
|
||||
let isActive = server.id == model.activeServer?.id
|
||||
Button {
|
||||
model.connect(to: server)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(server.serverName)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("\(server.host):\(String(server.port))")
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
if isActive {
|
||||
Circle()
|
||||
.fill(Theme.mint)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel(server.serverName)
|
||||
.accessibilityValue(isActive ? "Connected" : "")
|
||||
.accessibilityHint("Connects to this server")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
model.removeServer(server)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
showPairNew = true
|
||||
} label: {
|
||||
Label("Pair new server", systemImage: "plus")
|
||||
.foregroundStyle(Theme.mint)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Opens pairing to add a server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsInfoSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Section("Info") {
|
||||
row("Server version", model.session.serverVersion ?? "unknown")
|
||||
row("Provider", model.session.providerName ?? "unknown")
|
||||
row(
|
||||
"Tokens",
|
||||
"\(model.session.tokenInput) in / \(model.session.tokenOutput) out"
|
||||
)
|
||||
if let detail = model.session.statusDetail {
|
||||
row("Status", detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Settings sheet: model picker, reasoning effort, sessions, servers, info.
|
||||
struct SettingsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State var renameDraft = ""
|
||||
@State var showRename = false
|
||||
@State var showPairNew = false
|
||||
|
||||
/// Reasoning effort levels offered when the provider exposes the knob.
|
||||
static let reasoningEfforts = ["none", "low", "medium", "high", "xhigh"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
modelSection
|
||||
if model.session.reasoningEffort != nil {
|
||||
reasoningSection
|
||||
}
|
||||
SettingsSessionsSection(renameDraft: $renameDraft, showRename: $showRename)
|
||||
SettingsServersSection(showPairNew: $showPairNew)
|
||||
SettingsInfoSection()
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Theme.background)
|
||||
.dynamicTypeSize(.large ... .accessibility3)
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.alert("Rename session", isPresented: $showRename) {
|
||||
TextField("Title", text: $renameDraft)
|
||||
Button("Rename") {
|
||||
model.renameSession(renameDraft)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showPairNew) {
|
||||
NavigationStack {
|
||||
PairingView()
|
||||
.background(Theme.background)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showPairNew = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.onChange(of: model.activeServer?.id) {
|
||||
showPairNew = false
|
||||
}
|
||||
}
|
||||
|
||||
private var modelSection: some View {
|
||||
Section("Model") {
|
||||
ForEach(model.session.availableModels, id: \.self) { name in
|
||||
let isActive = name == model.session.modelName
|
||||
Button {
|
||||
model.setModel(name)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(name)
|
||||
.font(Theme.mono(13))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel("Model \(name)")
|
||||
.accessibilityValue(isActive ? "Selected" : "")
|
||||
.accessibilityHint("Selects this model")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reasoningSection: some View {
|
||||
Section("Reasoning effort") {
|
||||
ForEach(Self.reasoningEfforts, id: \.self) { effort in
|
||||
let isActive = effort == model.session.reasoningEffort
|
||||
Button {
|
||||
model.setReasoningEffort(effort)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(effort)
|
||||
.font(Theme.mono(13))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel("Reasoning effort \(effort)")
|
||||
.accessibilityValue(isActive ? "Selected" : "")
|
||||
.accessibilityHint("Sets how much the model reasons before answering")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Collapsible tool call card with live status.
|
||||
struct ToolCallCard: View {
|
||||
let call: TranscriptEntry.ToolCall
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
expanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
statusIcon
|
||||
Text(call.name)
|
||||
.font(Theme.mono(13, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
if expanded {
|
||||
if !call.input.isEmpty {
|
||||
codeBlock(call.input)
|
||||
}
|
||||
if !call.output.isEmpty {
|
||||
codeBlock(String(call.output.prefix(2000)))
|
||||
}
|
||||
if case let .failed(message) = call.status {
|
||||
Text(message)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch call.status {
|
||||
case .streamingInput, .running:
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.tint(Theme.mint)
|
||||
case .succeeded:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
case .failed:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.error)
|
||||
}
|
||||
}
|
||||
|
||||
private func codeBlock(_ text: String) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(text)
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(8)
|
||||
}
|
||||
.background(Theme.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Scrolling transcript with pinned-to-bottom auto-follow.
|
||||
///
|
||||
/// Auto-scroll only engages while the user is at (or near) the bottom, so
|
||||
/// scrolling up to read history is never hijacked by streaming output. A
|
||||
/// jump-to-latest button appears whenever the view is unpinned. Short threads
|
||||
/// are anchored to the bottom (chat convention); once the content exceeds the
|
||||
/// viewport it scrolls normally. An empty session shows a centered placeholder.
|
||||
struct TranscriptView: View {
|
||||
let entries: [TranscriptEntry]
|
||||
let isReasoning: Bool
|
||||
|
||||
/// True while the viewport is at (or near) the bottom of the content.
|
||||
@State private var isPinnedToBottom = true
|
||||
|
||||
/// Distance from the bottom below which the view counts as pinned.
|
||||
private static let pinThreshold: CGFloat = 56
|
||||
|
||||
var body: some View {
|
||||
if entries.isEmpty && !isReasoning {
|
||||
EmptyTranscript()
|
||||
} else {
|
||||
GeometryReader { viewport in
|
||||
scroller(viewportHeight: viewport.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scroller(viewportHeight: CGFloat) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
// A flexible top spacer pushes short content to the bottom of
|
||||
// the viewport; it collapses to zero once content overflows.
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
Spacer(minLength: 0)
|
||||
ForEach(entries) { entry in
|
||||
EntryView(entry: entry)
|
||||
.id(entry.id)
|
||||
}
|
||||
if isReasoning {
|
||||
thinkingRow
|
||||
}
|
||||
Color.clear.frame(height: 1).id("bottom")
|
||||
}
|
||||
.frame(minHeight: max(0, viewportHeight - 16), alignment: .bottom)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
GeometryReader { content in
|
||||
Color.clear.preference(
|
||||
key: BottomDistanceKey.self,
|
||||
value: content.frame(in: .named("transcript")).maxY
|
||||
- viewportHeight
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.coordinateSpace(name: "transcript")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onPreferenceChange(BottomDistanceKey.self) { distance in
|
||||
MainActor.assumeIsolated {
|
||||
let pinned = distance < Self.pinThreshold
|
||||
if pinned != isPinnedToBottom {
|
||||
isPinnedToBottom = pinned
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: entries.last?.text) {
|
||||
guard isPinnedToBottom else { return }
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
.onChange(of: entries.count) {
|
||||
// Follow new entries when pinned; always follow the user's
|
||||
// own sends so their message never lands off-screen.
|
||||
if isPinnedToBottom || entries.last?.role == .user {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !isPinnedToBottom {
|
||||
ScrollToBottomButton {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var thinkingRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(Theme.textTertiary)
|
||||
Text("thinking")
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the content's bottom edge sits below the viewport's bottom edge.
|
||||
private struct BottomDistanceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating jump-to-latest affordance shown while scrolled up.
|
||||
private struct ScrollToBottomButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
|
||||
}
|
||||
.accessibilityLabel("Scroll to bottom")
|
||||
.accessibilityHint("Jumps to the latest message")
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user