diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md index 7afa579383d9..2155859ca84a 100644 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -1,12 +1,19 @@ --- name: ios-debugger-agent -description: Build, launch, inspect, and drive iOS apps with the repository-configured XcodeBuildMCP server. Use on macOS for iOS Simulator builds, focused native test runs, semantic UI automation, screenshots, logs, or debugging, including T3 Code Mobile verification. +description: Build, launch, inspect, and drive iOS apps with the repository-configured XcodeBuildMCP server. Use on macOS for iOS Simulator builds, focused native test runs, semantic UI automation, screenshots, logs, or debugging, including either the React Native T3 client in apps/mobile or the separate native SwiftUI client in apps/swift-ios. --- # iOS Debugger Agent Use the repository-configured `xcodebuildmcp` tools instead of requiring a globally installed Codex plugin. Prefer MCP tools over raw `xcodebuild`, `xcrun`, or `simctl` when the client exposes them. +T3 Code has two separate iOS clients. Identify the affected one before setting session defaults: + +- React Native mobile: `apps/mobile/ios/T3CodeDev.xcworkspace`, scheme `T3CodeDev` +- Native SwiftUI mobile: `apps/swift-ios/T3Code.xcodeproj`, scheme `T3Code` + +Do not assume an installed React Native build verifies SwiftUI behavior, or vice versa. Use [`test-t3-mobile`](../test-t3-mobile/SKILL.md) for the full isolated-backend and pairing workflow. + ## Confirm availability This workflow requires macOS 14.5 or newer, Xcode 16 or newer, and Node.js 18 or newer. The repository pins XcodeBuildMCP in both `.mcp.json` for Claude Code and `.codex/config.toml` for Codex. Project MCP servers may require one-time trust or approval followed by a new session. diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 0e11b50e1c83..944834417a5e 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -5,7 +5,7 @@ description: Launch, retain, and test the T3 Code web app in isolated developmen # Test T3 App -Use this skill for the web client. For iOS Simulator, Android Emulator, or physical-device testing against an isolated T3 backend, use the sibling [`test-t3-mobile`](../test-t3-mobile/SKILL.md) skill. +Use this skill for the web client. For iOS Simulator, Android Emulator, or physical-device testing against an isolated T3 backend, use the sibling [`test-t3-mobile`](../test-t3-mobile/SKILL.md) skill and select either the React Native client in `apps/mobile` or the separate native SwiftUI client in `apps/swift-ios` before building. ## Start an isolated web environment diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 3fcf94334fd6..d66cbd8a390b 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -1,19 +1,28 @@ --- name: test-t3-mobile -description: Launch and test T3 Code Mobile on an iOS Simulator or Android Emulator against disposable local T3 environments, including Metro and dev-client reuse, native rebuild decisions, per-client pairing, seeded projects, semantic UI control, screenshots, and iOS serve-sim streaming. Use after mobile UI or native changes, when reproducing phone or tablet behavior, pairing an emulator to isolated state, or verifying mobile behavior on macOS, Linux, or Windows. +description: "Launch and test either T3 Code mobile client against disposable local environments: the React Native iOS/Android app in apps/mobile or the separate native SwiftUI iOS app in apps/swift-ios. Covers client selection, native rebuild decisions, Metro and dev-client reuse for React Native only, per-client pairing, seeded projects, semantic UI control, screenshots, and iOS serve-sim streaming. Use after mobile UI or native changes, when reproducing phone or tablet behavior, pairing a device to isolated state, or verifying mobile behavior on macOS, Linux, or Windows." --- # Test T3 Mobile Run one focused, end-to-end mobile verification pass against disposable T3 state. Use the sibling [`test-t3-app`](../test-t3-app/SKILL.md) skill as the detailed reference for pairing-token semantics and SQLite fixtures. +## Choose the mobile client first + +T3 Code has two independent mobile implementations: + +- **React Native mobile** lives in `apps/mobile` and targets iOS and Android. Its development workflow uses Expo, Metro, and the `T3 Code Dev` identity documented below. +- **SwiftUI mobile** lives in `apps/swift-ios` and targets iOS. It is a native Xcode project and never uses Expo or Metro. Its Debug identity is `T3 Swift Dev`, bundle identifier `com.t3tools.t3code.swiftui.dev`, and URL scheme `t3code-swiftui-dev`. + +Inspect the affected paths and choose one before launching anything. Do not use one client as verification for the other. For SwiftUI, load [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), set the project to `/apps/swift-ios/T3Code.xcodeproj`, scheme `T3Code`, configuration `Debug`, and use the selected simulator. Then use this skill's disposable-backend, pairing, UI-driving, and cleanup guidance while skipping every Metro, Expo, and Android step. + Command examples use POSIX shell syntax. On Windows, use PowerShell equivalents: set variables with `$env:NAME = "value"`, use an explicit temporary directory from `[System.IO.Path]::GetTempPath()`, and run multiline examples on one line or with PowerShell backticks. Use `$env:ANDROID_HOME\platform-tools\adb.exe` when `adb` is not already on `PATH`. ## Select a viable platform Inspect the host and the affected code before launching processes: -- On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. +- On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. This can be either mobile client; use the project selected above. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. - On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. - When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK, emulator, or dev-client prerequisite rather than claiming verification. @@ -21,12 +30,13 @@ Do not treat unavailable iOS tooling as a blocker when Android is a valid repres ## Choose the lightest valid launch path -- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. +- For React Native JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. +- For SwiftUI changes, build or reuse the native `T3Code` scheme with `ios-debugger-agent`; Metro and Expo compatibility do not apply. - For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform. - Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project. - If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding. -The development identity on both platforms is: +The React Native development identity on both platforms is: - App: `T3 Code Dev` - Bundle/package identifier: `com.t3tools.t3code.dev` @@ -70,7 +80,7 @@ Enter the complete `http://` origin to make the test transport explicit. Bare IP ## Start or reuse Metro safely -Run Metro from `apps/mobile`. +This section applies only to React Native mobile. Run Metro from `apps/mobile`. Skip it entirely for SwiftUI mobile. 1. Inspect any process on the intended Metro port and its `/status` response. Reuse it only when it is healthy, belongs to this worktree, and matches `APP_VARIANT=development`, `--dev-client`, and scheme `t3code-dev`. 2. Never kill another worktree's Metro. Use a free explicit port when necessary. @@ -88,7 +98,7 @@ Run Metro from `apps/mobile`. 4. Open the exact development-client URL for the selected device and confirm the loaded bundle belongs to this worktree and Metro port. -### iOS launch +### React Native iOS launch Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session defaults: @@ -107,7 +117,7 @@ xcrun simctl openurl Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app. -### Android launch +### React Native Android launch Select one running emulator serial from `adb devices` and check the installed client: @@ -142,6 +152,8 @@ The helper opens this registered route: t3code-dev://connections/new?pairingUrl=&autoConnect=1 ``` +For SwiftUI, pass `t3code-swiftui-dev` as the helper's fifth argument. The default `t3code-dev` scheme selects the React Native development client. + The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. diff --git a/.agents/skills/test-t3-mobile/agents/openai.yaml b/.agents/skills/test-t3-mobile/agents/openai.yaml index e9518ce9e04a..6b865f972a94 100644 --- a/.agents/skills/test-t3-mobile/agents/openai.yaml +++ b/.agents/skills/test-t3-mobile/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Test T3 Mobile" - short_description: "Test T3 Code on iOS or Android" - default_prompt: "Use $test-t3-mobile to launch T3 Code against an isolated backend and verify the affected flow on an available simulator or emulator." + short_description: "Test React Native or SwiftUI T3 clients" + default_prompt: "Use $test-t3-mobile to select the affected React Native or SwiftUI client, launch it against an isolated backend, and verify the flow on an available simulator or emulator." diff --git a/.github/workflows/swift-ios.yml b/.github/workflows/swift-ios.yml new file mode 100644 index 000000000000..6d6e02755560 --- /dev/null +++ b/.github/workflows/swift-ios.yml @@ -0,0 +1,58 @@ +name: SwiftUI iOS + +on: + pull_request: + paths: + - "apps/swift-ios/**" + - "packages/contracts/**" + - "scripts/generate-swift-wire-fixtures.ts" + - "scripts/package.json" + - ".github/workflows/swift-ios.yml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + push: + branches: + - main + paths: + - "apps/swift-ios/**" + - "packages/contracts/**" + - "scripts/generate-swift-wire-fixtures.ts" + - "scripts/package.json" + - ".github/workflows/swift-ios.yml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + +concurrency: + group: swift-ios-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + name: Contract fixtures and native tests + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Verify generated wire fixtures + run: node scripts/generate-swift-wire-fixtures.ts --check + + - name: Test SwiftUI client + run: apps/swift-ios/Scripts/ci-test.sh diff --git a/AGENTS.md b/AGENTS.md index e3b5771d797c..23cb45b2e264 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,12 @@ T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. **Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. -**Mobile** is a React Native app for both iOS and Android, available on the App Store and Google Play. The mobile app allows for connecting to any T3 Code server to control work remotely. +**Mobile** has two separate clients that connect to the same T3 servers: + +- `apps/mobile` is the React Native app for iOS and Android, available on the App Store and Google Play. +- `apps/swift-ios` is the native SwiftUI app for iOS, with its own Xcode project, UI implementation, and app identities. + +Treat them as distinct clients. UI, navigation, persistence, and build changes in one do not automatically reach the other. When implementation details matter, say **React Native mobile** or **SwiftUI mobile** instead of referring ambiguously to “the mobile app.” ## A note from Theo @@ -67,9 +72,9 @@ We need to be on the same page with terminology. When communicating, use this la The most common defect in this repo is a change that works on the path you tested and is missing everywhere else. Before calling frontend work done, walk this list and say which entries applied: - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. -- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` +- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), React Native mobile, and SwiftUI mobile. Decide explicitly which mobile clients a change applies to. Shared TypeScript client logic lives in `packages/client-runtime`; the SwiftUI client is a separate native implementation. - **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and Antigravity each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". -- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. +- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. When a schema changes, update the server, web, desktop, React Native mobile, and SwiftUI mobile implementations as applicable. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. - **Docs.** Check whether the change makes existing guidance inaccurate. Apply the [documentation rules](#documentation) before adding anything. @@ -108,7 +113,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. - The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. -- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web and `test-t3-mobile` for either mobile client. Select the React Native or SwiftUI path before building. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. ## Pull requests @@ -147,10 +152,13 @@ Full glossary with file links: `docs/internals/glossary.md` ## Where code lives - `apps/server` - WebSocket, orchestration, providers, checkpointing. Effect-heavy: read `.repos/effect-smol/LLMS.md` before writing Effect code. -- `apps/web` - React/Vite UI. `apps/desktop` wraps it, `apps/mobile` is React Native, `apps/marketing` is the site. +- `apps/web` - React/Vite UI. `apps/desktop` wraps it and adds Electron behavior. +- `apps/mobile` - React Native client for iOS and Android. +- `apps/swift-ios` - Separate native SwiftUI client for iOS. +- `apps/marketing` - Marketing site. - `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic. - `packages/shared` - shared runtime utils, subpath exports, no barrel. -- `packages/client-runtime` - client code shared by web and mobile. +- `packages/client-runtime` - TypeScript client code shared by web and React Native mobile. SwiftUI implements the corresponding native client behavior separately. - `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. ## Taste diff --git a/apps/swift-ios/.gitignore b/apps/swift-ios/.gitignore new file mode 100644 index 000000000000..9a2d42b10dc4 --- /dev/null +++ b/apps/swift-ios/.gitignore @@ -0,0 +1,4 @@ +.derivedData/ +DerivedData/ +*.xcuserstate +xcuserdata/ diff --git a/apps/swift-ios/App/Cloud/T3ConnectAuth.swift b/apps/swift-ios/App/Cloud/T3ConnectAuth.swift new file mode 100644 index 000000000000..5bfd2294c3b9 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectAuth.swift @@ -0,0 +1,74 @@ +import ClerkKit +import Foundation + +public struct T3ConnectAccount: Equatable, Sendable { + public let id: String + public let email: String? + public let imageURL: URL? +} + +public enum T3ConnectAuthError: LocalizedError, Sendable { + case noSession + + public var errorDescription: String? { + switch self { + case .noSession: + "Sign in to your T3 account to use T3 Connect." + } + } +} + +enum T3ConnectAuthCallback { + static let scheme = PlatformRoute.nativeScheme + static let redirectURL = "\(scheme)://clerk-callback" +} + +/// Small ClerkKit boundary. Clerk owns encrypted session persistence and the +/// ASWebAuthenticationSession callback; the app only asks for the relay JWT. +@MainActor +public final class T3ConnectClerkSession { + private let clerk: Clerk + private let jwtTemplate: String + + public init(configuration: T3ConnectConfiguration) { + jwtTemplate = configuration.clerkJWTTemplate + clerk = Clerk.configure( + publishableKey: configuration.clerkPublishableKey, + options: .init( + redirectConfig: .init( + redirectUrl: T3ConnectAuthCallback.redirectURL, + callbackUrlScheme: T3ConnectAuthCallback.scheme + ) + ) + ) + } + + var client: Clerk { clerk } + + public var account: T3ConnectAccount? { + guard let user = clerk.user else { return nil } + return T3ConnectAccount( + id: user.id, + email: user.primaryEmailAddress?.emailAddress, + imageURL: URL(string: user.imageUrl) + ) + } + + public var isLoaded: Bool { clerk.isLoaded } + + public func refresh() async throws { + _ = try await clerk.refreshClient() + } + + public func signOut() async throws { + try await clerk.auth.signOut() + } + + public func relayToken() async throws -> String { + let token = try await clerk.auth.getToken( + .init(template: jwtTemplate, expirationBuffer: 20) + ) + guard let token, !token.isEmpty else { throw T3ConnectAuthError.noSession } + return token + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift new file mode 100644 index 000000000000..353df8937720 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift @@ -0,0 +1,516 @@ +import ClerkKit +import Foundation +import Observation +import OSLog + +public extension Notification.Name { + static let t3ConnectSessionChanged = Notification.Name("T3ConnectSessionChanged") +} + +@MainActor +public protocol T3ConnectCapable: AnyObject { + var t3ConnectController: T3ConnectController { get } + + /// Save and activate the relay-managed environment without treating its + /// bootstrap credential as a bearer token. Implementations prepare the + /// DPoP access token and socket ticket through `managedAuthorizer`. + func connectT3Environment( + _ credential: T3ConnectManagedEnvironmentCredential + ) async throws + + /// Ends the account session and removes only relay-managed runtime state. + /// Directly paired environments belong to the device and must survive. + func signOutT3Connect() async +} + +public struct T3ConnectCloudEnvironment: Identifiable, Equatable, Sendable { + public var id: String { environment.environmentId } + + public let environment: T3ConnectRelayEnvironment + public let status: T3ConnectRelayEnvironmentStatus? + public let statusError: String? + + public init( + environment: T3ConnectRelayEnvironment, + status: T3ConnectRelayEnvironmentStatus? = nil, + statusError: String? = nil + ) { + self.environment = environment + self.status = status + self.statusError = statusError + } +} + +@MainActor +protocol T3ConnectDeviceManaging: AnyObject { + var hasActiveAccount: Bool { get } + var currentRegisteredDeviceID: String? { get } + func registeredDevices() async throws -> [T3ConnectRelayDevice] + func unregisterDevice(id: String) async throws +} + +@MainActor +@Observable +public final class T3ConnectController: T3ConnectDeviceManaging { + private static let logger = Logger( + subsystem: "codes.t3.swift-ios", + category: "T3Connect" + ) + public let resolution: T3ConnectConfigurationResolution + public let managedAuthorizer: T3ConnectManagedEnvironmentAuthorizer + + public private(set) var account: T3ConnectAccount? { + didSet { + guard oldValue != account else { return } + var accountIDs: [String: String] = [:] + if let previousAccountID = oldValue?.id { + accountIDs["previousAccountID"] = previousAccountID + } + if let accountID = account?.id { + accountIDs["accountID"] = accountID + } + NotificationCenter.default.post( + name: .t3ConnectSessionChanged, + object: self, + userInfo: accountIDs + ) + } + } + public private(set) var environments: [T3ConnectCloudEnvironment] = [] + public private(set) var isRefreshing = false + public private(set) var busyEnvironmentID: String? + public var errorMessage: String? + + private let auth: T3ConnectClerkSession? + private let relay: T3ConnectRelayClient? + private var registeredDeviceID: String? + private var refreshGeneration: UInt64 = 0 + private var authorizationGeneration: UInt64 = 0 + private var isLocalAuthorizationInvalidated = false + private var isSignOutInProgress = false + private var authorizationOperationCount = 0 + private var authorizationOperationWaiters: [CheckedContinuation] = [] + private var signOutOperation: (@MainActor @Sendable () async throws -> Void)? + + public convenience init( + resolution: T3ConnectConfigurationResolution = T3ConnectConfiguration.resolve(), + transport: any HTTPTransport = URLSessionHTTPTransport(), + signer: T3ConnectDPoPSigner = T3ConnectDPoPSigner() + ) { + self.init( + resolution: resolution, + transport: transport, + signer: signer, + configureAuth: true, + signOutOperation: nil + ) + } + + private init( + resolution: T3ConnectConfigurationResolution, + transport: any HTTPTransport, + signer: T3ConnectDPoPSigner, + configureAuth: Bool, + signOutOperation: (@MainActor @Sendable () async throws -> Void)? + ) { + self.resolution = resolution + self.signOutOperation = signOutOperation + managedAuthorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + guard let configuration = resolution.configuration else { + auth = nil + relay = nil + return + } + auth = configureAuth ? T3ConnectClerkSession(configuration: configuration) : nil + relay = T3ConnectRelayClient( + configuration: configuration, + transport: transport, + signer: signer + ) + } + + convenience init( + resolution: T3ConnectConfigurationResolution, + transport: any HTTPTransport, + signer: T3ConnectDPoPSigner, + signOutOperation: @escaping @MainActor @Sendable () async throws -> Void + ) { + self.init( + resolution: resolution, + transport: transport, + signer: signer, + configureAuth: false, + signOutOperation: signOutOperation + ) + } + + public var unavailableReason: String? { + guard case let .unavailable(reason) = resolution else { return nil } + return reason + } + + public var currentRegisteredDeviceID: String? { registeredDeviceID } + var hasActiveAccount: Bool { account != nil } + + var clerk: Clerk? { auth?.client } + + public func refresh() async { + guard let auth, let relay else { return } + guard !isLocalAuthorizationInvalidated else { return } + refreshGeneration &+= 1 + let generation = refreshGeneration + let authGeneration = authorizationGeneration + isRefreshing = true + defer { + if refreshGeneration == generation { isRefreshing = false } + } + do { + if !auth.isLoaded { try await auth.refresh() } + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + await adoptAccount(auth.account, relay: relay) + guard account != nil else { + environments = [] + return + } + let token = try await auth.relayToken() + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + let records = try await relay.listEnvironments(clerkToken: token) + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + environments = records.map { T3ConnectCloudEnvironment(environment: $0) } + let loaded = await withTaskGroup( + of: T3ConnectCloudEnvironment.self, + returning: [T3ConnectCloudEnvironment].self + ) { group in + for record in records { + group.addTask { + do { + let status = try await relay.status(for: record, clerkToken: token) + return T3ConnectCloudEnvironment( + environment: record, + status: status + ) + } catch { + return T3ConnectCloudEnvironment( + environment: record, + statusError: error.localizedDescription + ) + } + } + } + var loaded: [T3ConnectCloudEnvironment] = [] + for await environment in group { loaded.append(environment) } + return loaded.sorted { + $0.environment.linkedAt > $1.environment.linkedAt + } + } + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + environments = loaded + } catch { + if refreshGeneration == generation { + errorMessage = error.localizedDescription + } + } + } + + /// A successful authentication flow is the only action that may restore a + /// locally signed-out Clerk session. + public func refreshAfterAuthentication() async { + isLocalAuthorizationInvalidated = false + authorizationGeneration &+= 1 + await refresh() + } + + public func signOut() async { + guard let relay else { return } + guard !isSignOutInProgress else { return } + isSignOutInProgress = true + let deviceID = registeredDeviceID + isLocalAuthorizationInvalidated = true + authorizationGeneration &+= 1 + let signOutAuthorizationGeneration = authorizationGeneration + refreshGeneration &+= 1 + let generation = refreshGeneration + isRefreshing = true + defer { + isSignOutInProgress = false + if refreshGeneration == generation { isRefreshing = false } + } + account = nil + environments = [] + registeredDeviceID = nil + await relay.clearTokenCache() + await waitForAuthorizationOperations() + + guard authorizationGeneration == signOutAuthorizationGeneration, + isLocalAuthorizationInvalidated else { return } + if let auth, + let deviceID, + let token = try? await auth.relayToken() { + guard authorizationGeneration == signOutAuthorizationGeneration, + isLocalAuthorizationInvalidated else { return } + // Remote delivery must not outlive the signed-in session on this + // install. A failed best-effort unregister must not trap the user + // in an account they are trying to leave. + try? await relay.unregisterDevice( + deviceID: deviceID, + clerkToken: token + ) + } + await relay.clearTokenCache() + + // Local authorization state is security-sensitive and must be cleared + // before Clerk performs network work. A failed remote sign-out can be + // reported, but it cannot leave relay tokens or managed state usable. + do { + guard authorizationGeneration == signOutAuthorizationGeneration, + isLocalAuthorizationInvalidated else { return } + if let signOutOperation { + try await signOutOperation() + } else if let auth { + try await auth.signOut() + } + Self.logger.info("T3 Connect account session signed out") + } catch { + guard refreshGeneration == generation else { return } + Self.logger.error( + "T3 Connect remote sign-out failed: \(error.localizedDescription, privacy: .private)" + ) + errorMessage = error.localizedDescription + } + } + + public func credential( + for environment: T3ConnectRelayEnvironment, + deviceID: String? = nil + ) async throws -> T3ConnectManagedEnvironmentCredential { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + busyEnvironmentID = environment.environmentId + defer { busyEnvironmentID = nil } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + let credential = try await relay.connect( + to: environment, + clerkToken: token, + deviceID: deviceID ?? registeredDeviceID + ) + try requireCurrentAuthorization(generation) + return credential + } + + /// Reacquires the one-use bootstrap credential needed to refresh a saved + /// managed environment. The current relay record is fetched again so an + /// expired access token never falls back to a manual bearer credential. + public func credential( + forEnvironmentID environmentID: String, + deviceID: String? = nil + ) async throws -> T3ConnectManagedEnvironmentCredential { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + let records = try await relay.listEnvironments(clerkToken: token) + try requireCurrentAuthorization(generation) + guard let environment = records.first(where: { $0.environmentId == environmentID }) else { + throw T3ConnectRelayError.invalidConfiguration( + "This environment is no longer linked to your T3 account." + ) + } + let credential = try await relay.connect( + to: environment, + clerkToken: token, + deviceID: deviceID ?? registeredDeviceID + ) + try requireCurrentAuthorization(generation) + return credential + } + + @discardableResult + public func unlink(_ environment: T3ConnectRelayEnvironment) async -> Bool { + guard let auth, let relay else { return false } + busyEnvironmentID = environment.environmentId + defer { busyEnvironmentID = nil } + do { + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.unlinkEnvironment( + environmentID: environment.environmentId, + clerkToken: token + ) + guard isAuthorizationCurrent(generation) else { return false } + environments.removeAll { $0.id == environment.environmentId } + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + public func registerDevice(_ registration: T3ConnectDeviceRegistration) async throws { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.registerDevice(registration, clerkToken: token) + guard isAuthorizationCurrent(generation) else { + try? await relay.unregisterDevice( + deviceID: registration.deviceId, + clerkToken: token + ) + await relay.clearTokenCache() + throw T3ConnectAuthError.noSession + } + registeredDeviceID = registration.deviceId + } + + public func registeredDevices() async throws -> [T3ConnectRelayDevice] { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + let devices = try await relay.listDevices(clerkToken: token) + try requireCurrentAuthorization(generation) + return devices + } + + public func unregisterDevice(id: String) async throws { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.unregisterDevice(deviceID: id, clerkToken: token) + try requireCurrentAuthorization(generation) + if registeredDeviceID == id { + registeredDeviceID = nil + } + } + + func rememberRegisteredDevice(id: String) { + registeredDeviceID = id + } + + public func registerLiveActivity( + _ registration: T3ConnectLiveActivityRegistration + ) async throws { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.registerLiveActivity(registration, clerkToken: token) + guard isAuthorizationCurrent(generation) else { + // Live activity registrations are device-scoped. Removing the + // device compensates for a registration that crossed sign-out. + try? await relay.unregisterDevice( + deviceID: registration.deviceId, + clerkToken: token + ) + await relay.clearTokenCache() + throw T3ConnectAuthError.noSession + } + } + + private func loadedRelayToken(_ auth: T3ConnectClerkSession) async throws -> String { + guard !isLocalAuthorizationInvalidated else { throw T3ConnectAuthError.noSession } + let generation = authorizationGeneration + if !auth.isLoaded { + try await auth.refresh() + } + guard authorizationGeneration == generation, + !isLocalAuthorizationInvalidated else { throw T3ConnectAuthError.noSession } + if let relay { + await adoptAccount(auth.account, relay: relay) + } else { + account = auth.account + } + guard account != nil else { throw T3ConnectAuthError.noSession } + let token = try await auth.relayToken() + guard authorizationGeneration == generation, + !isLocalAuthorizationInvalidated else { throw T3ConnectAuthError.noSession } + return token + } + + private func adoptAccount( + _ nextAccount: T3ConnectAccount?, + relay: T3ConnectRelayClient + ) async { + if account?.id != nextAccount?.id { + environments = [] + registeredDeviceID = nil + await relay.clearTokenCache() + } + account = nextAccount + } + + private func isAuthorizationCurrent(_ generation: UInt64) -> Bool { + authorizationGeneration == generation && !isLocalAuthorizationInvalidated + } + + private func requireCurrentAuthorization(_ generation: UInt64) throws { + guard isAuthorizationCurrent(generation) else { + throw T3ConnectAuthError.noSession + } + } + + private func beginAuthorizationOperation(_ generation: UInt64) throws { + try requireCurrentAuthorization(generation) + authorizationOperationCount += 1 + } + + private func endAuthorizationOperation() { + authorizationOperationCount -= 1 + guard authorizationOperationCount == 0 else { return } + let waiters = authorizationOperationWaiters + authorizationOperationWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func waitForAuthorizationOperations() async { + guard authorizationOperationCount > 0 else { return } + await withCheckedContinuation { continuation in + authorizationOperationWaiters.append(continuation) + } + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectConfiguration.swift b/apps/swift-ios/App/Cloud/T3ConnectConfiguration.swift new file mode 100644 index 000000000000..88de0b30a48d --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectConfiguration.swift @@ -0,0 +1,90 @@ +import Foundation + +public struct T3ConnectConfiguration: Equatable, Sendable { + public static let defaultClerkJWTTemplate = "t3-relay" + + public let clerkPublishableKey: String + public let clerkJWTTemplate: String + public let relayHTTPURL: URL + + public init( + clerkPublishableKey: String, + clerkJWTTemplate: String = Self.defaultClerkJWTTemplate, + relayHTTPURL: URL + ) { + self.clerkPublishableKey = clerkPublishableKey + self.clerkJWTTemplate = clerkJWTTemplate + self.relayHTTPURL = relayHTTPURL + } + + public static func resolve(bundle: Bundle = .main) -> T3ConnectConfigurationResolution { + resolve(infoDictionary: bundle.infoDictionary ?? [:]) + } + + public static func resolve( + infoDictionary: [String: Any] + ) -> T3ConnectConfigurationResolution { + let publishableKey = configuredString( + infoDictionary["T3ConnectClerkPublishableKey"] + ) + let relayHTTPValue = configuredString(infoDictionary["T3ConnectRelayHTTPURL"]) + let jwtTemplate = configuredString(infoDictionary["T3ConnectClerkJWTTemplate"]) + ?? defaultClerkJWTTemplate + + var missingKeys: [String] = [] + if publishableKey == nil { missingKeys.append("Clerk publishable key") } + if relayHTTPValue == nil { missingKeys.append("relay HTTP URL") } + guard missingKeys.isEmpty else { + return .unavailable( + reason: "This build is missing \(missingKeys.joined(separator: ", "))." + ) + } + + guard + let relayHTTPValue, + let relayHTTPURL = URL(string: relayHTTPValue), + relayHTTPURL.scheme?.lowercased() == "https", + relayHTTPURL.host != nil + else { + return .unavailable(reason: "The T3 Connect relay HTTP URL must use HTTPS.") + } + return .available( + T3ConnectConfiguration( + clerkPublishableKey: publishableKey!, + clerkJWTTemplate: jwtTemplate, + relayHTTPURL: normalizedBaseURL(relayHTTPURL) + ) + ) + } + + private static func configuredString(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("$(") else { return nil } + return trimmed + } + + private static func normalizedBaseURL(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + components.query = nil + components.fragment = nil + components.path = components.path.replacingOccurrences( + of: #"/+$"#, + with: "", + options: .regularExpression + ) + return components.url ?? url + } +} + +public enum T3ConnectConfigurationResolution: Equatable, Sendable { + case available(T3ConnectConfiguration) + case unavailable(reason: String) + + public var configuration: T3ConnectConfiguration? { + guard case let .available(configuration) = self else { return nil } + return configuration + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectDPoP.swift b/apps/swift-ios/App/Cloud/T3ConnectDPoP.swift new file mode 100644 index 000000000000..0257069a03a3 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectDPoP.swift @@ -0,0 +1,259 @@ +import CryptoKit +import Foundation +import Security + +public struct T3ConnectDPoPPublicJWK: Codable, Equatable, Sendable { + public let kty: String + public let crv: String + public let x: String + public let y: String + + fileprivate init(publicKey: P256.Signing.PublicKey) throws { + let representation = publicKey.x963Representation + guard representation.count == 65, representation.first == 0x04 else { + throw T3ConnectDPoPError.invalidPublicKey + } + kty = "EC" + crv = "P-256" + x = Data(representation[1..<33]).base64URLEncodedString() + y = Data(representation[33..<65]).base64URLEncodedString() + } + + public var canonicalThumbprintInput: String { + "{\"crv\":\"\(crv)\",\"kty\":\"\(kty)\",\"x\":\"\(x)\",\"y\":\"\(y)\"}" + } + + public var thumbprint: String { + Data(SHA256.hash(data: Data(canonicalThumbprintInput.utf8))) + .base64URLEncodedString() + } +} + +public struct T3ConnectDPoPProof: Equatable, Sendable { + public let value: String + public let thumbprint: String +} + +public enum T3ConnectDPoPError: LocalizedError, Sendable { + case invalidURL + case invalidPrivateKey + case invalidPublicKey + case invalidStoredKey + case keychain(OSStatus) + case encoding + + public var errorDescription: String? { + switch self { + case .invalidURL: + "The DPoP proof URL is invalid." + case .invalidPrivateKey: + "The DPoP private key is invalid." + case .invalidPublicKey: + "The DPoP public key is invalid." + case .invalidStoredKey: + "The saved DPoP identity is invalid." + case let .keychain(status): + SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)." + case .encoding: + "The DPoP proof could not be encoded." + } + } +} + +/// Owns the proof-of-possession identity used by both relay and environment requests. +/// Rotating this key invalidates every token bound to its JWK thumbprint, so the +/// production initializer persists it in the device-only Keychain. +public actor T3ConnectDPoPSigner { + private let service: String? + private let account: String + private var privateKey: P256.Signing.PrivateKey? + + public init( + service: String = "com.t3tools.t3code.swiftui.t3-connect-dpop", + account: String = "device-proof-key" + ) { + self.service = service + self.account = account + } + + /// Deterministic in-memory identity for focused tests and previews. + public init(privateKeyRawRepresentation: Data) throws { + do { + privateKey = try P256.Signing.PrivateKey(rawRepresentation: privateKeyRawRepresentation) + } catch { + throw T3ConnectDPoPError.invalidPrivateKey + } + service = nil + account = "in-memory" + } + + /// The signing key never rotates (see type docs), so the derived JWK and + /// its SHA-256 thumbprint are stable and cached. Both are recomputed on + /// every managed request otherwise. + private var cachedJWK: T3ConnectDPoPPublicJWK? + private var cachedThumbprint: String? + + public func publicJWK() throws -> T3ConnectDPoPPublicJWK { + if let cachedJWK { return cachedJWK } + let jwk = try T3ConnectDPoPPublicJWK(publicKey: try loadPrivateKey().publicKey) + cachedJWK = jwk + return jwk + } + + public func thumbprint() throws -> String { + if let cachedThumbprint { return cachedThumbprint } + let thumbprint = try publicJWK().thumbprint + cachedThumbprint = thumbprint + return thumbprint + } + + public func proof( + method: String, + url: URL, + accessToken: String? = nil, + issuedAt: Date = Date(), + identifier: UUID = UUID() + ) throws -> T3ConnectDPoPProof { + guard let normalizedURL = Self.normalizedHTU(url) else { + throw T3ConnectDPoPError.invalidURL + } + + let key = try loadPrivateKey() + let jwk = try publicJWK() + let header = Header(typ: "dpop+jwt", alg: "ES256", jwk: jwk) + let payload = Payload( + htm: method.uppercased(), + htu: normalizedURL.absoluteString, + jti: identifier.uuidString.lowercased(), + iat: Int(issuedAt.timeIntervalSince1970.rounded(.down)), + ath: accessToken.map(Self.accessTokenHash) + ) + guard + let headerPart = try? Self.proofEncoder.encode(header).base64URLEncodedString(), + let payloadPart = try? Self.proofEncoder.encode(payload).base64URLEncodedString() + else { + throw T3ConnectDPoPError.encoding + } + let signingInput = "\(headerPart).\(payloadPart)" + let signature = try key.signature(for: Data(signingInput.utf8)) + return T3ConnectDPoPProof( + value: "\(signingInput).\(signature.rawRepresentation.base64URLEncodedString())", + thumbprint: jwk.thumbprint + ) + } + + public static func normalizedHTU(_ url: URL) -> URL? { + guard + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme != nil, + components.host != nil + else { return nil } + components.query = nil + components.fragment = nil + switch (components.scheme?.lowercased(), components.port) { + case ("http", 80), ("https", 443), ("ws", 80), ("wss", 443): + components.port = nil + default: + break + } + return components.url + } + + public static func accessTokenHash(_ accessToken: String) -> String { + Data(SHA256.hash(data: Data(accessToken.utf8))).base64URLEncodedString() + } + + private func loadPrivateKey() throws -> P256.Signing.PrivateKey { + if let privateKey { return privateKey } + guard let service else { throw T3ConnectDPoPError.invalidStoredKey } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess { + guard let data = item as? Data else { throw T3ConnectDPoPError.invalidStoredKey } + do { + let restored = try P256.Signing.PrivateKey(rawRepresentation: data) + privateKey = restored + return restored + } catch { + throw T3ConnectDPoPError.invalidStoredKey + } + } + guard status == errSecItemNotFound else { throw T3ConnectDPoPError.keychain(status) } + + let generated = P256.Signing.PrivateKey() + var insertion = query + insertion.removeValue(forKey: kSecReturnData as String) + insertion.removeValue(forKey: kSecMatchLimit as String) + insertion[kSecValueData as String] = generated.rawRepresentation + insertion[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let insertionStatus = SecItemAdd(insertion as CFDictionary, nil) + if insertionStatus == errSecDuplicateItem { + // Another signer instance won the first-launch race. Preserve that + // identity instead of rotating to this actor's generated key. + return try readExistingPrivateKey(service: service) + } + guard insertionStatus == errSecSuccess else { + throw T3ConnectDPoPError.keychain(insertionStatus) + } + privateKey = generated + return generated + } + + private func readExistingPrivateKey(service: String) throws -> P256.Signing.PrivateKey { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { throw T3ConnectDPoPError.keychain(status) } + guard let data = item as? Data else { throw T3ConnectDPoPError.invalidStoredKey } + do { + let restored = try P256.Signing.PrivateKey(rawRepresentation: data) + privateKey = restored + return restored + } catch { + throw T3ConnectDPoPError.invalidStoredKey + } + } + + private static let proofEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + + private struct Header: Encodable { + let typ: String + let alg: String + let jwk: T3ConnectDPoPPublicJWK + } + + private struct Payload: Encodable { + let htm: String + let htu: String + let jti: String + let iat: Int + let ath: String? + } +} + +extension Data { + fileprivate func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift b/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift new file mode 100644 index 000000000000..9c6641ed5d28 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift @@ -0,0 +1,444 @@ +import Foundation + +public struct T3ConnectPreparedEnvironmentConnection: Sendable { + public let authorization: T3ConnectEnvironmentAccessToken + public let webSocketURL: URL + + public init( + authorization: T3ConnectEnvironmentAccessToken, + webSocketURL: URL + ) { + self.authorization = authorization + self.webSocketURL = webSocketURL + } +} + +/// Converts the relay's short-lived environment bootstrap credential into the +/// DPoP access token and one-time WebSocket ticket understood by a T3 server. +/// The same signer must authorize every later HTTP request for that token. +public actor T3ConnectManagedEnvironmentAuthorizer { + public static let standardScopes = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + ] + + private struct AccessTokenResponse: Decodable, Sendable { + let accessToken: String + let issuedTokenType: String + let tokenType: String + let expiresIn: Double + let scope: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } + } + + private struct WebSocketTicketResponse: Decodable, Sendable { + let ticket: String + let expiresAt: String + } + + private struct ErrorBody: Decodable, Sendable { + let message: String? + let reason: String? + let traceId: String? + } + + private let transport: any HTTPTransport + private let signer: T3ConnectDPoPSigner + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + signer: T3ConnectDPoPSigner = T3ConnectDPoPSigner() + ) { + self.transport = transport + self.signer = signer + } + + public func prepare( + _ credential: T3ConnectManagedEnvironmentCredential, + scopes: [String] = standardScopes, + clientLabel: String? = nil + ) async throws -> T3ConnectPreparedEnvironmentConnection { + let accessToken = try await exchange( + credential, + scopes: scopes, + clientLabel: clientLabel + ) + let webSocketURL = try await webSocketURL(using: accessToken) + return T3ConnectPreparedEnvironmentConnection( + authorization: accessToken, + webSocketURL: webSocketURL + ) + } + + public func exchange( + _ credential: T3ConnectManagedEnvironmentCredential, + scopes: [String] = standardScopes, + clientLabel: String? = nil + ) async throws -> T3ConnectEnvironmentAccessToken { + guard let httpBaseURL = credential.endpoint.httpBaseURL else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed environment HTTP URL is invalid." + ) + } + let thumbprint = try await signer.thumbprint() + guard thumbprint == credential.proofKeyThumbprint else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed credential is bound to a different device identity." + ) + } + let target = endpoint(httpBaseURL, path: ["oauth", "token"]) + let proof = try await signer.proof(method: "POST", url: target) + var fields = [ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": credential.bootstrapCredential, + "subject_token_type": "urn:t3:params:oauth:token-type:environment-bootstrap", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": scopes.joined(separator: " "), + "client_device_type": "mobile", + "client_os": ProcessInfo.processInfo.operatingSystemVersionString, + ] + if let clientLabel, !clientLabel.isEmpty { + fields["client_label"] = clientLabel + } + var request = URLRequest(url: target) + request.httpMethod = "POST" + request.httpBody = Self.formEncoded(fields) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(proof.value, forHTTPHeaderField: "DPoP") + let response = try await send(request, as: AccessTokenResponse.self) + let grantedScopes = Set(response.scope.split(separator: " ").map(String.init)) + guard response.tokenType == "DPoP", + response.issuedTokenType + == "urn:ietf:params:oauth:token-type:access_token", + response.accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + response.expiresIn.isFinite, + response.expiresIn > 0, + grantedScopes == Set(scopes) else { + if grantedScopes != Set(scopes) { + throw T3ConnectRelayError.unexpectedScope( + requested: scopes, + granted: response.scope + ) + } + throw T3ConnectRelayError.invalidResponse + } + return T3ConnectEnvironmentAccessToken( + environmentID: credential.environmentID, + label: credential.label, + endpoint: credential.endpoint, + accessToken: response.accessToken, + expiresAt: Date().addingTimeInterval(response.expiresIn), + scopes: response.scope.split(separator: " ").map(String.init), + proofKeyThumbprint: thumbprint + ) + } + + /// Adds a fresh request-bound proof. Call this immediately before sending; + /// reusing a proof defeats replay protection and is rejected by the server. + public func authorize( + _ request: URLRequest, + using authorization: T3ConnectEnvironmentAccessToken + ) async throws -> URLRequest { + guard let url = request.url else { throw T3ConnectDPoPError.invalidURL } + let proof = try await signer.proof( + method: request.httpMethod ?? "GET", + url: url, + accessToken: authorization.accessToken + ) + guard proof.thumbprint == authorization.proofKeyThumbprint else { + throw T3ConnectRelayError.invalidConfiguration( + "The environment token is bound to a different device identity." + ) + } + var authorized = request + authorized.setValue( + "DPoP \(authorization.accessToken)", + forHTTPHeaderField: "Authorization" + ) + authorized.setValue(proof.value, forHTTPHeaderField: "DPoP") + return authorized + } + + public func proofKeyThumbprint() async throws -> String { + try await signer.thumbprint() + } + + public func descriptor(at httpBaseURL: URL) async throws -> EnvironmentDescriptor { + try await send( + URLRequest( + url: endpoint( + httpBaseURL, + path: [".well-known", "t3", "environment"] + ) + ), + as: EnvironmentDescriptor.self + ) + } + + public func webSocketURL( + using authorization: T3ConnectEnvironmentAccessToken + ) async throws -> URL { + guard + let httpBaseURL = authorization.endpoint.httpBaseURL, + let webSocketBaseURL = authorization.endpoint.webSocketBaseURL, + httpBaseURL.scheme?.lowercased() == "https", + let httpHost = httpBaseURL.host, + webSocketBaseURL.scheme?.lowercased() == "wss", + let webSocketHost = webSocketBaseURL.host, + httpHost.caseInsensitiveCompare(webSocketHost) == .orderedSame, + (httpBaseURL.port ?? 443) == (webSocketBaseURL.port ?? 443) + else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed environment endpoint is invalid." + ) + } + let target = endpoint( + httpBaseURL, + path: ["api", "auth", "websocket-ticket"] + ) + var ticketRequest = URLRequest(url: target) + ticketRequest.httpMethod = "POST" + ticketRequest = try await authorize(ticketRequest, using: authorization) + let ticket = try await send(ticketRequest, as: WebSocketTicketResponse.self) + guard !ticket.ticket.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3ConnectRelayError.invalidResponse + } + + var components = URLComponents( + url: webSocketBaseURL, + resolvingAgainstBaseURL: false + ) + if components?.path.isEmpty == true || components?.path == "/" { + components?.path = "/ws" + } + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "wsTicket" } + queryItems.append(URLQueryItem(name: "wsTicket", value: ticket.ticket)) + components?.queryItems = queryItems + guard let url = components?.url else { throw T3ConnectDPoPError.invalidURL } + return url + } + + private func endpoint(_ baseURL: URL, path: [String]) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + components?.path = "" + components?.query = nil + components?.fragment = nil + let origin = components?.url ?? baseURL + return path.reduce(origin) { partial, component in + partial.appendingPathComponent(component) + } + } + + private func send( + _ request: URLRequest, + as type: Response.Type + ) async throws -> Response { + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + } catch { + throw T3ConnectNetworkError.wrapping(error) + } + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(ErrorBody.self, from: data) + throw T3ConnectRelayError.response( + status: response.statusCode, + message: body?.message ?? body?.reason ?? "Environment authorization failed.", + traceID: body?.traceId + ) + } + do { + return try JSONDecoder.t3.decode(type, from: data) + } catch { + throw T3ConnectRelayError.invalidResponse + } + } + + private static func formEncoded(_ fields: [String: String]) -> Data { + var components = URLComponents() + components.queryItems = fields.keys.sorted().map { + URLQueryItem(name: $0, value: fields[$0]) + } + return Data((components.percentEncodedQuery ?? "").utf8) + } +} + +/// Adapts the T3 Connect token lifecycle to Core's environment transport. +/// Refresh work is coalesced per environment because shell, detail, and socket +/// reconnect requests can all discover expiration at the same time. +public actor T3ConnectRuntimeAuthorization: ManagedEnvironmentAuthorizing { + public typealias BootstrapProvider = @Sendable (String) async throws + -> T3ConnectManagedEnvironmentCredential + + private struct InFlightRefresh: Sendable { + let id: UUID + let task: Task + } + + private let authorizer: T3ConnectManagedEnvironmentAuthorizer + private let bootstrapProvider: BootstrapProvider + private var refreshTasks: [String: InFlightRefresh] = [:] + + @MainActor + public init(controller: T3ConnectController) { + authorizer = controller.managedAuthorizer + bootstrapProvider = { environmentID in + try await controller.credential(forEnvironmentID: environmentID) + } + } + + public init( + authorizer: T3ConnectManagedEnvironmentAuthorizer, + bootstrapProvider: @escaping BootstrapProvider + ) { + self.authorizer = authorizer + self.bootstrapProvider = bootstrapProvider + } + + public func credentialRequiresRefresh( + _ credential: EnvironmentCredential, + environment: Environment + ) async throws -> Bool { + _ = try Self.authorization(environment: environment, credential: credential) + return try await authorizer.proofKeyThumbprint() != credential.proofKeyThumbprint + } + + public func authorize( + _ request: URLRequest, + environment: Environment, + credential: EnvironmentCredential + ) async throws -> URLRequest { + let authorization = try Self.authorization( + environment: environment, + credential: credential + ) + return try await authorizer.authorize(request, using: authorization) + } + + public func refreshCredential( + for environment: Environment, + replacing credential: EnvironmentCredential + ) async throws -> EnvironmentCredential { + _ = try Self.authorization(environment: environment, credential: credential) + if let refresh = refreshTasks[environment.id] { + return try await refresh.task.value + } + + let authorizer = self.authorizer + let bootstrapProvider = self.bootstrapProvider + let task = Task { + let bootstrap = try await bootstrapProvider(environment.id) + try Self.validate( + bootstrap: bootstrap, + environment: environment + ) + guard let httpBaseURL = bootstrap.endpoint.httpBaseURL else { + throw T3ConnectRelayError.environmentMismatch + } + let descriptor = try await authorizer.descriptor(at: httpBaseURL) + guard descriptor.environmentId == environment.id else { + throw T3ConnectRelayError.environmentMismatch + } + let authorization = try await authorizer.exchange(bootstrap) + return try Self.credential( + authorization: authorization, + environment: environment + ) + } + let refreshID = UUID() + refreshTasks[environment.id] = InFlightRefresh(id: refreshID, task: task) + do { + let credential = try await task.value + finishRefresh(environmentID: environment.id, id: refreshID) + return credential + } catch { + finishRefresh(environmentID: environment.id, id: refreshID) + throw error + } + } + + private func finishRefresh(environmentID: String, id: UUID) { + guard refreshTasks[environmentID]?.id == id else { return } + refreshTasks.removeValue(forKey: environmentID) + } + + private static func authorization( + environment: Environment, + credential: EnvironmentCredential + ) throws -> T3ConnectEnvironmentAccessToken { + guard environment.kind == .managedDPoP, + credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id, + let expiresAt = credential.expiresAt, + let proofKeyThumbprint = credential.proofKeyThumbprint, + let endpoint = managedEndpoint(for: environment) else { + throw HTTPError.incompatibleCredential + } + return T3ConnectEnvironmentAccessToken( + environmentID: environment.id, + label: environment.label, + endpoint: endpoint, + accessToken: credential.accessToken, + expiresAt: expiresAt, + scopes: credential.scopes, + proofKeyThumbprint: proofKeyThumbprint + ) + } + + private static func credential( + authorization: T3ConnectEnvironmentAccessToken, + environment: Environment + ) throws -> EnvironmentCredential { + guard authorization.environmentID == environment.id, + authorization.proofKeyThumbprint.isEmpty == false, + authorization.endpoint.httpBaseURL == environment.httpBaseURL, + authorization.endpoint.webSocketBaseURL == environment.webSocketBaseURL else { + throw T3ConnectRelayError.environmentMismatch + } + return .managedDPoP( + accessToken: authorization.accessToken, + expiresAt: authorization.expiresAt, + scopes: authorization.scopes, + environmentID: authorization.environmentID, + proofKeyThumbprint: authorization.proofKeyThumbprint + ) + } + + private static func validate( + bootstrap: T3ConnectManagedEnvironmentCredential, + environment: Environment + ) throws { + guard bootstrap.environmentID == environment.id, + bootstrap.proofKeyThumbprint.isEmpty == false, + bootstrap.endpoint.httpBaseURL == environment.httpBaseURL, + bootstrap.endpoint.webSocketBaseURL == environment.webSocketBaseURL else { + throw T3ConnectRelayError.environmentMismatch + } + } + + private static func managedEndpoint( + for environment: Environment + ) -> T3ConnectManagedEndpoint? { + guard environment.httpBaseURL.scheme?.lowercased() == "https", + environment.webSocketBaseURL.scheme?.lowercased() == "wss", + environment.httpBaseURL.host != nil, + environment.webSocketBaseURL.host != nil else { return nil } + return T3ConnectManagedEndpoint( + httpBaseUrl: environment.httpBaseURL.absoluteString, + wsBaseUrl: environment.webSocketBaseURL.absoluteString, + providerKind: .t3Relay + ) + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectRelayClient.swift b/apps/swift-ios/App/Cloud/T3ConnectRelayClient.swift new file mode 100644 index 000000000000..84f2f945c00f --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectRelayClient.swift @@ -0,0 +1,533 @@ +import Foundation + +public enum T3ConnectRelayError: LocalizedError, Sendable { + case invalidConfiguration(String) + case invalidResponse + case response(status: Int, message: String, traceID: String?) + case unexpectedScope(requested: [String], granted: String) + case environmentMismatch + + public var errorDescription: String? { + switch self { + case let .invalidConfiguration(message): + message + case .invalidResponse: + "T3 Connect returned an invalid response." + case let .response(status, message, traceID): + traceID.map { "\(message) (trace \($0))" } ?? "\(message) (HTTP \(status))" + case let .unexpectedScope(requested, granted): + "T3 Connect granted \(granted) instead of \(requested.joined(separator: " "))." + case .environmentMismatch: + "T3 Connect returned credentials for a different environment." + } + } +} + +struct T3ConnectRelayErrorBody: Decodable, Sendable { + let message: String? + let reason: String? + let code: String? + let dpopFailureReason: DPoPFailureReason? + let maxTunnels: Int? + let traceId: String? +} + +enum T3ConnectRelayErrorPresentation { + static func message( + for error: T3ConnectRelayErrorBody, + requestUsesDPoP: Bool + ) -> String { + switch error.code { + case "auth_invalid": + switch error.reason { + case "missing_bearer", "invalid_bearer": + return "Relay rejected the cloud session token." + case "invalid_dpop" where requestUsesDPoP: + return DPoPFailurePresentation.message( + "Relay rejected the DPoP proof.", + reason: error.dpopFailureReason + ) + case "not_authorized": + return "Relay rejected the authenticated request." + default: + break + } + case "environment_link_proof_expired": + return "Relay rejected an expired environment link proof." + case "environment_link_proof_invalid": + if let reason = error.reason { + return "Relay rejected the environment link proof (\(reason))." + } + case "environment_connect_not_authorized": + if error.reason == "environment_link_not_found" { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet." + } + if let reason = error.reason { + return "Relay rejected the environment connection request (\(reason))." + } + return "Relay rejected the environment connection request." + case "environment_endpoint_unavailable": + if let reason = error.reason { + return "Relay could not reach the environment endpoint (\(reason))." + } + case "environment_endpoint_timed_out": + return "Relay timed out while contacting the environment endpoint." + case "environment_link_failed": + if let reason = error.reason { + return "Relay could not link the environment (\(reason))." + } + case "environment_link_unavailable": + if let reason = error.reason { + return "Relay cannot provision the managed endpoint (\(reason))." + } + case "environment_link_limit_exceeded": + if let maxTunnels = error.maxTunnels { + return "Relay refused the link: this account already has its maximum of \(maxTunnels) managed tunnels. Unlink an environment to free one up." + } + return "Relay refused the link because this account has reached its managed tunnel limit. Unlink an environment to free one up." + case "agent_activity_publish_proof_expired": + return "Relay rejected an expired agent activity publish proof." + case "agent_activity_publish_proof_invalid": + if let reason = error.reason { + return "Relay rejected the agent activity publish proof (\(reason))." + } + case "internal_error": + if let reason = error.reason { + return "Relay encountered an internal error (\(reason))." + } + default: + break + } + return error.message ?? error.reason ?? error.code ?? "T3 Connect request failed." + } +} + +public actor T3ConnectRelayClient { + private struct CachedToken: Sendable { + let accessToken: String + let expiresAt: Date + let scopes: [T3ConnectRelayScope] + let thumbprint: String + } + + private struct EnvironmentList: Decodable, Sendable { + let environments: [T3ConnectRelayEnvironment] + } + + private struct DeviceList: Decodable, Sendable { + let devices: [T3ConnectRelayDevice] + } + + private struct ConnectResponse: Decodable, Sendable { + let environmentId: String + let endpoint: T3ConnectManagedEndpoint + let credential: String + let expiresAt: String + } + + private struct OKResponse: Decodable, Sendable { + let ok: Bool + } + + private let configuration: T3ConnectConfiguration + private let transport: any HTTPTransport + private let signer: T3ConnectDPoPSigner + private var cachedTokens: [String: CachedToken] = [:] + + public init( + configuration: T3ConnectConfiguration, + transport: any HTTPTransport = URLSessionHTTPTransport(), + signer: T3ConnectDPoPSigner = T3ConnectDPoPSigner() + ) { + self.configuration = configuration + self.transport = transport + self.signer = signer + } + + public func listEnvironments(clerkToken: String) async throws + -> [T3ConnectRelayEnvironment] + { + var request = request(path: ["v1", "environments"], method: "GET") + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: EnvironmentList.self).environments + } + + public func listDevices(clerkToken: String) async throws -> [T3ConnectRelayDevice] { + var request = request(path: ["v1", "client", "devices"], method: "GET") + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: DeviceList.self).devices + } + + public func createEnvironmentLinkChallenge( + clerkToken: String, + request payload: T3ConnectEnvironmentLinkChallengeRequest + ) async throws -> T3ConnectEnvironmentLinkChallenge { + var request = try jsonRequest( + path: ["v1", "client", "environment-link-challenges"], + method: "POST", + payload: payload + ) + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: T3ConnectEnvironmentLinkChallenge.self) + } + + public func linkEnvironment( + clerkToken: String, + request payload: T3ConnectEnvironmentLinkRequest + ) async throws -> T3ConnectEnvironmentLinkResponse { + var request = try jsonRequest( + path: ["v1", "client", "environment-links"], + method: "POST", + payload: payload + ) + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: T3ConnectEnvironmentLinkResponse.self) + } + + public func unlinkEnvironment(environmentID: String, clerkToken: String) async throws { + var request = request( + path: ["v1", "client", "environment-links", environmentID], + method: "DELETE" + ) + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + try requireOK(try await send(request, as: OKResponse.self)) + } + + public func status( + for environment: T3ConnectRelayEnvironment, + clerkToken: String + ) async throws -> T3ConnectRelayEnvironmentStatus { + let path = ["v1", "environments", environment.environmentId, "status"] + let result: T3ConnectRelayEnvironmentStatus = try await sendDPoP( + path: path, + method: "POST", + body: nil, + clerkToken: clerkToken, + scopes: [.environmentStatus] + ) + guard + result.environmentId == environment.environmentId, + result.endpoint == environment.endpoint, + result.descriptor?.environmentId == nil + || result.descriptor?.environmentId == environment.environmentId + else { throw T3ConnectRelayError.environmentMismatch } + return result + } + + public func connect( + to environment: T3ConnectRelayEnvironment, + clerkToken: String, + deviceID: String? = nil + ) async throws -> T3ConnectManagedEnvironmentCredential { + let thumbprint = try await signer.thumbprint() + let payload = ConnectRequest( + deviceId: deviceID, + clientProofKeyThumbprint: thumbprint + ) + let body = try JSONEncoder.t3.encode(payload) + let response: ConnectResponse = try await sendDPoP( + path: ["v1", "environments", environment.environmentId, "connect"], + method: "POST", + body: body, + clerkToken: clerkToken, + scopes: [.environmentConnect] + ) + guard + response.environmentId == environment.environmentId, + response.endpoint == environment.endpoint + else { throw T3ConnectRelayError.environmentMismatch } + guard !response.credential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !response.expiresAt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3ConnectRelayError.invalidResponse + } + return T3ConnectManagedEnvironmentCredential( + environmentID: response.environmentId, + label: environment.label, + endpoint: response.endpoint, + bootstrapCredential: response.credential, + bootstrapExpiresAt: response.expiresAt, + proofKeyThumbprint: thumbprint + ) + } + + public func registerDevice( + _ registration: T3ConnectDeviceRegistration, + clerkToken: String + ) async throws { + guard registration.iosMajorVersion >= 18 else { + throw T3ConnectRelayError.invalidConfiguration( + "T3 Connect device registration requires iOS 18 or newer." + ) + } + let body = try JSONEncoder.t3.encode(registration) + let response: OKResponse = try await sendDPoP( + path: ["v1", "mobile", "devices"], + method: "POST", + body: body, + clerkToken: clerkToken, + scopes: [.mobileRegistration] + ) + try requireOK(response) + } + + public func registerLiveActivity( + _ registration: T3ConnectLiveActivityRegistration, + clerkToken: String + ) async throws { + let body = try JSONEncoder.t3.encode(registration) + let response: OKResponse = try await sendDPoP( + path: ["v1", "mobile", "live-activities"], + method: "POST", + body: body, + clerkToken: clerkToken, + scopes: [.mobileRegistration] + ) + try requireOK(response) + } + + public func unregisterDevice(deviceID: String, clerkToken: String) async throws { + let response: OKResponse = try await sendDPoP( + path: ["v1", "mobile", "devices", deviceID], + method: "DELETE", + body: nil, + clerkToken: clerkToken, + scopes: [.mobileRegistration] + ) + try requireOK(response) + } + + public func clearTokenCache() { + cachedTokens.removeAll() + } + + private func sendDPoP( + path: [String], + method: String, + body: Data?, + clerkToken: String, + scopes: [T3ConnectRelayScope] + ) async throws -> Response { + let target = endpoint(path) + let authorization = try await authorize( + clerkToken: clerkToken, + scopes: scopes, + method: method, + url: target + ) + var request = URLRequest(url: target) + request.httpMethod = method + request.httpBody = body + request.setValue( + "DPoP \(authorization.accessToken)", + forHTTPHeaderField: "Authorization" + ) + request.setValue(authorization.proof, forHTTPHeaderField: "DPoP") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + do { + return try await send(request, as: Response.self) + } catch let error as T3ConnectRelayError where error.isRejectedAuthorization { + cachedTokens.removeValue(forKey: tokenCacheKey(scopes, clerkToken: clerkToken)) + let refreshed = try await authorize( + clerkToken: clerkToken, + scopes: scopes, + method: method, + url: target + ) + request.setValue("DPoP \(refreshed.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(refreshed.proof, forHTTPHeaderField: "DPoP") + return try await send(request, as: Response.self) + } + } + + private func authorize( + clerkToken: String, + scopes: [T3ConnectRelayScope], + method: String, + url: URL + ) async throws -> (accessToken: String, proof: String) { + let thumbprint = try await signer.thumbprint() + let cacheKey = tokenCacheKey(scopes, clerkToken: clerkToken) + let token: CachedToken + if let cached = cachedTokens[cacheKey], + cached.thumbprint == thumbprint, + cached.expiresAt.timeIntervalSinceNow > 5 + { + token = cached + } else { + token = try await exchangeRelayAccessToken( + clerkToken: clerkToken, + scopes: scopes, + thumbprint: thumbprint + ) + cachedTokens[cacheKey] = token + } + let proof = try await signer.proof( + method: method, + url: url, + accessToken: token.accessToken + ) + return (token.accessToken, proof.value) + } + + private func exchangeRelayAccessToken( + clerkToken: String, + scopes: [T3ConnectRelayScope], + thumbprint: String + ) async throws -> CachedToken { + let target = endpoint(["v1", "client", "dpop-token"]) + let proof = try await signer.proof(method: "POST", url: target) + let requestedScopes = scopes.map(\.rawValue).sorted() + let fields = [ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": clerkToken, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "resource": configuration.relayHTTPURL.absoluteString, + "scope": requestedScopes.joined(separator: " "), + "client_id": "t3-mobile", + ] + var request = URLRequest(url: target) + request.httpMethod = "POST" + request.httpBody = Self.formEncoded(fields) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(proof.value, forHTTPHeaderField: "DPoP") + let response = try await send(request, as: T3ConnectRelayAccessToken.self) + let granted = Set(response.scope.split(separator: " ").map(String.init)) + guard granted == Set(requestedScopes) else { + throw T3ConnectRelayError.unexpectedScope( + requested: requestedScopes, + granted: response.scope + ) + } + guard !response.accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + response.issuedTokenType + == "urn:ietf:params:oauth:token-type:access_token", + response.tokenType == "DPoP", + response.expiresIn > 0 else { + throw T3ConnectRelayError.invalidResponse + } + return CachedToken( + accessToken: response.accessToken, + expiresAt: Date().addingTimeInterval(TimeInterval(response.expiresIn)), + scopes: scopes, + thumbprint: thumbprint + ) + } + + private func request(path: [String], method: String) -> URLRequest { + var request = URLRequest(url: endpoint(path)) + request.httpMethod = method + return request + } + + private func jsonRequest( + path: [String], + method: String, + payload: Payload + ) throws -> URLRequest { + var request = request(path: path, method: method) + request.httpBody = try JSONEncoder.t3.encode(payload) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } + + private func endpoint(_ path: [String]) -> URL { + var components = URLComponents( + url: configuration.relayHTTPURL, + resolvingAgainstBaseURL: false + ) + components?.path = "" + components?.query = nil + components?.fragment = nil + let origin = components?.url ?? configuration.relayHTTPURL + return path.reduce(origin) { partial, component in + partial.appendingPathComponent(component) + } + } + + private func requireOK(_ response: OKResponse) throws { + guard response.ok else { throw T3ConnectRelayError.invalidResponse } + } + + private func send( + _ request: URLRequest, + as type: Response.Type + ) async throws -> Response { + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + } catch { + throw T3ConnectNetworkError.wrapping(error) + } + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(T3ConnectRelayErrorBody.self, from: data) + throw T3ConnectRelayError.response( + status: response.statusCode, + message: body.map { + T3ConnectRelayErrorPresentation.message( + for: $0, + requestUsesDPoP: request.value(forHTTPHeaderField: "DPoP") != nil + ) + } + ?? "T3 Connect request failed.", + traceID: body?.traceId + ) + } + do { + return try JSONDecoder.t3.decode(type, from: data) + } catch { + throw T3ConnectRelayError.invalidResponse + } + } + + private func tokenCacheKey( + _ scopes: [T3ConnectRelayScope], + clerkToken: String + ) -> String { + let account = Self.clerkSubject(clerkToken) + ?? T3ConnectDPoPSigner.accessTokenHash(clerkToken) + return "\(account)|\(scopes.map(\.rawValue).sorted().joined(separator: " "))" + } + + /// JWT claims are used only as a cache partition, never as authentication. + /// If Clerk changes token shape, the opaque token hash remains safe. + private static func clerkSubject(_ token: String) -> String? { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { return nil } + var base64 = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: base64), + let claims = try? JSONDecoder().decode(ClerkCacheClaims.self, from: data), + claims.sub.isEmpty == false else { return nil } + return claims.sub + } + + private static func formEncoded(_ fields: [String: String]) -> Data { + var components = URLComponents() + components.queryItems = fields.keys.sorted().map { + URLQueryItem(name: $0, value: fields[$0]) + } + return Data((components.percentEncodedQuery ?? "").utf8) + } + + private struct ConnectRequest: Encodable { + let deviceId: String? + let clientProofKeyThumbprint: String + } + + private struct ClerkCacheClaims: Decodable { + let sub: String + } +} + +private extension T3ConnectRelayError { + var isRejectedAuthorization: Bool { + guard case let .response(status, _, _) = self else { return false } + return status == 401 + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectRelayModels.swift b/apps/swift-ios/App/Cloud/T3ConnectRelayModels.swift new file mode 100644 index 000000000000..dcb59d862ad0 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectRelayModels.swift @@ -0,0 +1,261 @@ +import Foundation + +public enum T3ConnectRelayScope: String, Codable, CaseIterable, Sendable { + case environmentConnect = "environment:connect" + case environmentStatus = "environment:status" + case mobileRegistration = "mobile:registration" +} + +public enum T3ConnectManagedEndpointProvider: String, Codable, Sendable { + case manual + case cloudflareTunnel = "cloudflare_tunnel" + case t3Relay = "t3_relay" +} + +public struct T3ConnectManagedEndpoint: Codable, Equatable, Sendable { + public let httpBaseUrl: String + public let wsBaseUrl: String + public let providerKind: T3ConnectManagedEndpointProvider + + public var httpBaseURL: URL? { URL(string: httpBaseUrl) } + public var webSocketBaseURL: URL? { URL(string: wsBaseUrl) } +} + +public struct T3ConnectRelayEnvironment: Codable, Identifiable, Equatable, Sendable { + public var id: String { environmentId } + + public let environmentId: String + public let label: String + public let endpoint: T3ConnectManagedEndpoint + public let linkedAt: String +} + +public struct T3ConnectRelayEnvironmentStatus: Codable, Equatable, Sendable { + public enum Value: String, Codable, Sendable { + case online + case offline + } + + public let environmentId: String + public let endpoint: T3ConnectManagedEndpoint + public let status: Value + public let checkedAt: String + public let descriptor: EnvironmentDescriptor? + public let error: String? + public let traceId: String? +} + +public struct T3ConnectManagedEnvironmentCredential: Codable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let endpoint: T3ConnectManagedEndpoint + public let bootstrapCredential: String + public let bootstrapExpiresAt: String + public let proofKeyThumbprint: String +} + +public struct T3ConnectEnvironmentLinkChallengeRequest: Codable, Equatable, Sendable { + public let notificationsEnabled: Bool + public let liveActivitiesEnabled: Bool + public let managedTunnelsEnabled: Bool + + public init( + notificationsEnabled: Bool, + liveActivitiesEnabled: Bool, + managedTunnelsEnabled: Bool = true + ) { + self.notificationsEnabled = notificationsEnabled + self.liveActivitiesEnabled = liveActivitiesEnabled + self.managedTunnelsEnabled = managedTunnelsEnabled + } +} + +public struct T3ConnectEnvironmentLinkChallenge: Codable, Equatable, Sendable { + public let challenge: String + public let expiresAt: String +} + +public struct T3ConnectEnvironmentLinkRequest: Codable, Equatable, Sendable { + public let deviceId: String? + public let proof: String + public let notificationsEnabled: Bool + public let liveActivitiesEnabled: Bool + public let managedTunnelsEnabled: Bool + + public init( + deviceID: String? = nil, + proof: String, + notificationsEnabled: Bool, + liveActivitiesEnabled: Bool, + managedTunnelsEnabled: Bool = true + ) { + deviceId = deviceID + self.proof = proof + self.notificationsEnabled = notificationsEnabled + self.liveActivitiesEnabled = liveActivitiesEnabled + self.managedTunnelsEnabled = managedTunnelsEnabled + } +} + +public struct T3ConnectManagedEndpointRuntime: Codable, Equatable, Sendable { + public let providerKind: T3ConnectManagedEndpointProvider + public let connectorToken: String + public let tunnelId: String? + public let tunnelName: String? +} + +public struct T3ConnectEnvironmentLinkResponse: Codable, Equatable, Sendable { + public let ok: Bool + public let cloudUserId: String + public let environmentId: String + public let endpoint: T3ConnectManagedEndpoint + public let endpointRuntime: T3ConnectManagedEndpointRuntime? + public let relayIssuer: String + public let environmentCredential: String + public let cloudMintPublicKey: String +} + +public struct T3ConnectDevicePreferences: Codable, Equatable, Sendable { + public let liveActivitiesEnabled: Bool + public let notificationsEnabled: Bool + public let notifyOnApproval: Bool + public let notifyOnInput: Bool + public let notifyOnCompletion: Bool + public let notifyOnFailure: Bool + + public init( + liveActivitiesEnabled: Bool = true, + notificationsEnabled: Bool = true, + notifyOnApproval: Bool = true, + notifyOnInput: Bool = true, + notifyOnCompletion: Bool = true, + notifyOnFailure: Bool = true + ) { + self.liveActivitiesEnabled = liveActivitiesEnabled + self.notificationsEnabled = notificationsEnabled + self.notifyOnApproval = notifyOnApproval + self.notifyOnInput = notifyOnInput + self.notifyOnCompletion = notifyOnCompletion + self.notifyOnFailure = notifyOnFailure + } +} + +public struct T3ConnectDeviceRegistration: Codable, Equatable, Sendable { + public enum APNSEnvironment: String, Codable, Sendable { + case sandbox + case production + } + + public let deviceId: String + public let label: String + public let platform: String + public let iosMajorVersion: Int + public let appVersion: String? + public let bundleId: String? + public let apsEnvironment: APNSEnvironment? + public let pushToken: String? + public let pushToStartToken: String? + public let preferences: T3ConnectDevicePreferences + + public init( + deviceID: String, + label: String, + iosMajorVersion: Int, + appVersion: String? = nil, + bundleID: String? = nil, + apsEnvironment: APNSEnvironment? = nil, + pushToken: String? = nil, + pushToStartToken: String? = nil, + preferences: T3ConnectDevicePreferences = .init() + ) { + deviceId = deviceID + self.label = label + platform = "ios" + self.iosMajorVersion = iosMajorVersion + self.appVersion = appVersion + bundleId = bundleID + self.apsEnvironment = apsEnvironment + self.pushToken = pushToken + self.pushToStartToken = pushToStartToken + self.preferences = preferences + } +} + +public struct T3ConnectLiveActivityRegistration: Codable, Equatable, Sendable { + public let deviceId: String + public let activityPushToken: String + + public init(deviceID: String, activityPushToken: String) { + deviceId = deviceID + self.activityPushToken = activityPushToken + } +} + +public struct T3ConnectRelayDevice: Codable, Identifiable, Equatable, Sendable { + public struct Notifications: Codable, Equatable, Sendable { + public let enabled: Bool + public let notifyOnApproval: Bool + public let notifyOnInput: Bool + public let notifyOnCompletion: Bool + public let notifyOnFailure: Bool + } + + public struct LiveActivities: Codable, Equatable, Sendable { + public let enabled: Bool + } + + public var id: String { deviceId } + + public let deviceId: String + public let label: String + public let platform: String + public let iosMajorVersion: Int + public let appVersion: String? + public let notifications: Notifications + public let liveActivities: LiveActivities + public let updatedAt: String +} + +public struct T3ConnectRelayAccessToken: Codable, Equatable, Sendable { + public let accessToken: String + public let issuedTokenType: String + public let tokenType: String + public let expiresIn: Int + public let scope: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } +} + +public struct T3ConnectEnvironmentAccessToken: Codable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let endpoint: T3ConnectManagedEndpoint + public let accessToken: String + public let expiresAt: Date + public let scopes: [String] + public let proofKeyThumbprint: String + + public init( + environmentID: String, + label: String, + endpoint: T3ConnectManagedEndpoint, + accessToken: String, + expiresAt: Date, + scopes: [String], + proofKeyThumbprint: String + ) { + self.environmentID = environmentID + self.label = label + self.endpoint = endpoint + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + self.proofKeyThumbprint = proofKeyThumbprint + } +} diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift new file mode 100644 index 000000000000..30eb96eb8a52 --- /dev/null +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -0,0 +1,7714 @@ +import Foundation +import OSLog + +extension FeatureInputAnswer { + var jsonValue: JSONValue { + switch self { + case let .text(value): + .string(value) + case let .selections(values): + .array(values.map(JSONValue.string)) + } + } +} + +private struct T3ConnectManagedCleanupError: LocalizedError { + let failureCount: Int + + var errorDescription: String? { + "Couldn’t remove \(failureCount) managed T3 Connect " + + (failureCount == 1 ? "environment." : "environments.") + } +} + +/// Composes the transport-focused Core layer with the UI-focused Features layer. +@MainActor +final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, + FeatureProjectCreationClient, FeatureWorkspaceAssetResolving, FeatureAttachmentAssetResolving, + FeatureFeedbackSubmitting, T3ConnectCapable +{ + private static let maximumRetainedThreadDetails = 6 + private static let t3ConnectLogger = Logger( + subsystem: "codes.t3.swift-ios", + category: "T3Connect" + ) + private static let initialThreadUserTurnLimit = 10 + private static let olderThreadPageUserTurnLimit = 20 + private static let projectFaviconRefreshInterval: TimeInterval = 15 * 60 + private static let projectFaviconFallbackMarker = "project-favicon-missing" + private static let sourceControlStatusStreamTimeoutSeconds: TimeInterval = 30 + + private let runtime: EnvironmentRuntime + let t3ConnectController: T3ConnectController + private let t3ConnectDeviceManager: any T3ConnectDeviceManaging + private let hasMatchingT3ConnectController: Bool + private let settingsStore: UserDefaults + private let projectFaviconStore: FeatureProjectFaviconStore + private let fallbackPollingInitialDelay: Duration + private let fallbackPollingInterval: Duration + private let aggregateRefreshInterval: Duration + private let aggregateIdleRefreshInterval: Duration + private let aggregateFailureRefreshInterval: Duration + private let aggregateRefreshSleep: @Sendable (Duration) async throws -> Void + private let environmentShellTimeoutInterval: TimeInterval + private let threadSnapshotTimeoutInterval: TimeInterval + private let catchUpDelay: @Sendable () async throws -> Void + private let threadRetryDelay: @Sendable (Int) async throws -> Void + private let aggregateEnvironmentLoader: @Sendable (EnvironmentRuntime) async throws -> [Environment] + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var activeEnvironment: Environment? + private var client: T3Client? + private var latestShell: OrchestrationShellSnapshot? + private var environmentClients: [String: T3Client] = [:] + private var shellsByEnvironmentID: [String: OrchestrationShellSnapshot] = [:] + private var shellProjectionCache: [String: NativeShellProjection] = [:] + private var indexedShellMembership: [NativeShellMembership]? + private var indexedProvisionalRoutes: [String: ProvisionalThreadRoute] = [:] + private var archivedThreadsByEnvironmentID: [String: [FeatureThread]] = [:] + private var archivedShellThreadsByEnvironmentID: [ + String: [String: OrchestrationThreadShell] + ] = [:] + private var projectEnvironmentIDs: [String: String] = [:] + private var projectWireIDs: [String: String] = [:] + private var threadEnvironmentIDs: [String: String] = [:] + private var threadWireIDs: [String: String] = [:] + private var provisionalThreadRoutes: [String: ProvisionalThreadRoute] = [:] + private var pendingThreadCreations: [PendingThreadCreation] = [] + private var environmentConnectionStates: [String: FeatureConnection.State] = [:] + private var environmentConnectionDetails: [String: String] = [:] + private var latestServerConfig: ServerConfigSnapshot? + private var serverConfigsByEnvironmentID: [String: ServerConfigSnapshot] = [:] + private var latestSnapshot: FeatureSnapshot? + private var activeThreadID: String? + private var activeThreadEnvironmentID: String? + private var latestDetails: [String: FeatureThreadDetail] = [:] + private var threadResumeStates: [String: NativeThreadResumeState] = [:] + private var detailRenderCaches: [String: NativeDetailRenderCache] = [:] + private var detailCacheRecency: [String] = [] + private var attachmentURLs: [AttachmentCacheKey: CachedAttachmentURL] = [:] + private var projectFaviconRefreshTasks: [ + FeatureProjectFaviconCacheKey: Task + ] = [:] + private var sourceControlMonitors: [ + NativeSourceControlMonitorKey: NativeSourceControlMonitor + ] = [:] + private var pendingBootstrapSubmissions: [PendingBootstrapSubmission] = [] + private var pendingTurnSubmissions: [String: PendingTurnSubmission] = [:] + private var approvalRoutes: [String: PendingRequestRoute] = [:] + private var inputRoutes: [String: PendingRequestRoute] = [:] + private var relayDeviceSessionIDs: Set = [] + private struct TerminalKey: Hashable { + let threadID: String + let terminalID: String + } + + private var terminalSnapshots: [TerminalKey: FeatureTerminalSnapshot] = [:] + // Keep versions unique when a terminal cache is evicted or an environment reconnects. + private var terminalLifecycleVersion = 0 + private var pollingTask: Task? + private var fallbackPollingTask: Task? + private var configurationTask: Task? + private var aggregateRefreshTask: Task? + private var aggregateRefreshID: UUID? + private var shellPublishTask: Task? + private var archivedRefreshTask: Task? + private var detailRefreshTask: Task? + private var detailStreamTask: Task? + private var detailCatchUpTask: Task? + private var detailCatchUpID: UUID? + private var detailCompletionReceived = false + private var detailWasSynchronized = false + private var activeDetailConnectionID: UUID? + private var detailPublishTask: Task? + private var detailRefreshPending = false + private var detailRefreshGeneration = 0 + private var detailStreamGeneration = 0 + private var pendingDetailRenderMutations = NativeDetailRenderMutations() + private var environmentGeneration = 0 + private var lastShellEventAt: Date? + private var activeRawThread: OrchestrationThread? + private var activeThreadSequence: Int? + private var activeThreadPage: FeatureThreadPage? + private var threadHistoryEpoch = 0 + private var detailSnapshotRequiredAfterEpoch: Int? + private var pendingOlderThreadPage: PendingOlderThreadPage? + + nonisolated static let defaultAggregateRefreshInterval: Duration = .seconds(5) + nonisolated static let defaultAggregateIdleRefreshInterval: Duration = .seconds(10) + nonisolated static let defaultAggregateFailureRefreshInterval: Duration = .seconds(20) + + init( + runtime: EnvironmentRuntime? = nil, + t3ConnectController: T3ConnectController? = nil, + t3ConnectDeviceManager: (any T3ConnectDeviceManaging)? = nil, + settingsStore: UserDefaults = .standard, + projectFaviconStore: FeatureProjectFaviconStore = FeatureProjectFaviconStore(), + fallbackPollingInitialDelay: Duration = .seconds(3), + fallbackPollingInterval: Duration = .seconds(2), + aggregateRefreshInterval: Duration = NativeFeatureClient.defaultAggregateRefreshInterval, + aggregateIdleRefreshInterval: Duration = NativeFeatureClient.defaultAggregateIdleRefreshInterval, + aggregateFailureRefreshInterval: Duration = NativeFeatureClient.defaultAggregateFailureRefreshInterval, + aggregateRefreshSleep: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + }, + environmentShellTimeoutInterval: TimeInterval = 6, + threadSnapshotTimeoutInterval: TimeInterval = 8, + catchUpDelay: @escaping @Sendable () async throws -> Void = { + try await Task.sleep(for: .seconds(2)) + }, + threadRetryDelay: @escaping @Sendable (Int) async throws -> Void = { attempt in + try await Task.sleep(for: .seconds(min(5, 0.25 * pow(2, Double(min(5, attempt - 1)))))) + }, + aggregateEnvironmentLoader: @escaping @Sendable (EnvironmentRuntime) async throws -> [Environment] = { + try await $0.environments() + } + ) { + let controller: T3ConnectController + if let t3ConnectController { + controller = t3ConnectController + } else if let runtime { + controller = T3ConnectController( + resolution: .unavailable( + reason: runtime.supportsManagedAuthorization + ? "This client runtime requires its matching T3 Connect controller." + : "This client runtime was created without T3 Connect authorization." + ) + ) + } else { + controller = T3ConnectController() + } + self.t3ConnectController = controller + self.t3ConnectDeviceManager = t3ConnectDeviceManager ?? controller + hasMatchingT3ConnectController = t3ConnectController != nil || runtime == nil + self.runtime = runtime ?? EnvironmentRuntime( + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + self.settingsStore = settingsStore + self.projectFaviconStore = projectFaviconStore + self.fallbackPollingInitialDelay = fallbackPollingInitialDelay + self.fallbackPollingInterval = fallbackPollingInterval + self.aggregateRefreshInterval = aggregateRefreshInterval + self.aggregateIdleRefreshInterval = aggregateIdleRefreshInterval + self.aggregateFailureRefreshInterval = aggregateFailureRefreshInterval + self.aggregateRefreshSleep = aggregateRefreshSleep + self.environmentShellTimeoutInterval = environmentShellTimeoutInterval + self.threadSnapshotTimeoutInterval = threadSnapshotTimeoutInterval + self.catchUpDelay = catchUpDelay + self.threadRetryDelay = threadRetryDelay + self.aggregateEnvironmentLoader = aggregateEnvironmentLoader + let pair = AsyncStream.makeStream() + stream = pair.stream + continuation = pair.continuation + } + + deinit { + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + aggregateRefreshTask?.cancel() + shellPublishTask?.cancel() + archivedRefreshTask?.cancel() + detailRefreshTask?.cancel() + detailStreamTask?.cancel() + detailCatchUpTask?.cancel() + detailPublishTask?.cancel() + projectFaviconRefreshTasks.values.forEach { $0.cancel() } + continuation.finish() + } + + func initialSnapshot() async throws -> FeatureSnapshot { + let environments = try await runtime.environments() + guard let activeClient = try await runtime.activeClient() else { + await clearActiveEnvironment() + let snapshot = disconnectedSnapshot(environments: environments) + latestSnapshot = snapshot + return snapshot + } + // The runtime actor can change its active selection at any suspension + // point. Derive both values from one client so the snapshot cannot pair + // one environment with another environment's connection. + let environment = activeClient.environment + + await adoptEnvironment(environment, client: activeClient) + let generation = environmentGeneration + let loads = await loadEnvironmentShells(environments.filter(\.isEnabled)) + guard isCurrentSession(client: activeClient, generation: generation) else { + throw CancellationError() + } + reconcileEnvironmentLoads(loads, savedEnvironments: environments) + latestShell = shellsByEnvironmentID[environment.id] + startPolling(activeClient) + let activeIsReachable = loads.contains { + $0.environment.id == environment.id && $0.shell != nil + } + if activeIsReachable { + scheduleArchivedRefresh(client: activeClient, environment: environment) + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: activeIsReachable ? .connected : .disconnected, + connectionDetail: activeIsReachable ? nil : "That server is currently unreachable." + ) + latestSnapshot = snapshot + return snapshot + } + + func resumeAfterBackground(reconnect: Bool) async { + let sessionGeneration = environmentGeneration + let selectedRoute = activeThreadID.flatMap { try? threadRoute(for: $0) } + detailWasSynchronized = false + activeDetailConnectionID = nil + for id in threadResumeStates.keys { + threadResumeStates[id]?.wasSynchronized = false + } + if let selectedRoute { + retainActiveThread() + continuation.yield(.threadSync(id: selectedRoute.uiID, state: .catchingUp)) + } + // Only wake the inbox connection and the selected thread's computer. + // Other saved computers must not delay foreground recovery. + if reconnect { + var wakingClients: [T3Client] = [] + if let client { wakingClients.append(client) } + if let selectedRoute, !wakingClients.contains(where: { $0 === selectedRoute.client }) { + wakingClients.append(selectedRoute.client) + } + await withTaskGroup(of: Void.self) { group in + for client in wakingClients { group.addTask { await client.reconnect() } } + } + } + guard sessionGeneration == environmentGeneration else { return } + if let client { startPolling(client) } + if let selectedRoute, activeThreadID == selectedRoute.uiID, + isKnownClient(selectedRoute.client, environmentID: selectedRoute.environmentID, generation: sessionGeneration) { + resetDetailRefresh() + resetDetailStream() + startDetailStream(selectedRoute) + } + } + + func backgroundSnapshot() async throws -> FeatureSnapshot { + let environments = try await runtime.environments() + guard let activeClient = try await runtime.activeClient() else { + return disconnectedSnapshot(environments: environments) + } + let environment = activeClient.environment + let generation = environmentGeneration + let loads = await loadEnvironmentShells(environments.filter(\.isEnabled)) + guard let currentClient = try await runtime.activeClient(), + currentClient === activeClient, + generation == environmentGeneration else { + throw CancellationError() + } + + reconcileEnvironmentLoads(loads, savedEnvironments: environments) + let activeIsReachable = loads.contains { + $0.environment.id == environment.id && $0.shell != nil + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: activeIsReachable ? .connected : .disconnected, + connectionDetail: activeIsReachable ? nil : "That server is currently unreachable." + ) + latestSnapshot = snapshot + return snapshot + } + + func events() -> AsyncStream { + stream + } + + func pair(endpoint: String, token: String?) async throws { + let pairedClient: T3Client + if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + pairedClient = try await runtime.pair( + host: endpoint, + code: token, + clientLabel: "T3 Code Swift" + ) + } else { + pairedClient = try await runtime.pair(url: endpoint, clientLabel: "T3 Code Swift") + } + await adoptEnvironment(pairedClient.environment, client: pairedClient) + startPolling(pairedClient) + } + + func connectT3Environment( + _ credential: T3ConnectManagedEnvironmentCredential + ) async throws { + guard hasMatchingT3ConnectController else { + throw T3ConnectRelayError.invalidConfiguration( + "This client runtime requires its matching T3 Connect controller." + ) + } + guard runtime.supportsManagedAuthorization else { + throw T3ConnectRelayError.invalidConfiguration( + "This client runtime was created without T3 Connect authorization." + ) + } + guard credential.environmentID.isEmpty == false, + let httpBaseURL = credential.endpoint.httpBaseURL, + let webSocketBaseURL = credential.endpoint.webSocketBaseURL, + httpBaseURL.scheme?.lowercased() == "https", + webSocketBaseURL.scheme?.lowercased() == "wss", + let httpHost = httpBaseURL.host, + let webSocketHost = webSocketBaseURL.host, + httpHost.caseInsensitiveCompare(webSocketHost) == .orderedSame, + (httpBaseURL.port ?? 443) == (webSocketBaseURL.port ?? 443) else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed environment endpoint is invalid." + ) + } + + let descriptor = try await runtime.descriptor(at: httpBaseURL) + guard descriptor.environmentId == credential.environmentID else { + throw T3ConnectRelayError.environmentMismatch + } + let authorization = try await t3ConnectController.managedAuthorizer.exchange( + credential, + clientLabel: "T3 Code SwiftUI" + ) + guard authorization.environmentID == descriptor.environmentId, + authorization.endpoint == credential.endpoint, + authorization.proofKeyThumbprint == credential.proofKeyThumbprint else { + throw T3ConnectRelayError.environmentMismatch + } + + let environment = Environment( + id: descriptor.environmentId, + label: descriptor.label, + httpBaseURL: httpBaseURL, + webSocketBaseURL: webSocketBaseURL, + kind: .managedDPoP, + descriptor: descriptor + ) + let savedCredential = EnvironmentCredential.managedDPoP( + accessToken: authorization.accessToken, + expiresAt: authorization.expiresAt, + scopes: authorization.scopes, + environmentID: authorization.environmentID, + proofKeyThumbprint: authorization.proofKeyThumbprint + ) + let managedClient = try await runtime.saveManagedEnvironment( + environment, + credential: savedCredential + ) + await adoptEnvironment(environment, client: managedClient) + do { + try await refresh(client: managedClient) + } catch { + let environments = (try? await runtime.environments()) ?? [environment] + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: .connecting, + connectionDetail: "Connected securely. Loading this environment." + ) + publish(snapshot) + } + startPolling(managedClient) + } + + func signOutT3Connect() async { + // Clear the account and relay-token cache even when Clerk's remote + // sign-out fails, then revoke every locally minted managed credential. + // Manual pairings are device-owned and deliberately survive sign-out. + await t3ConnectController.signOut() + do { + let managedIDs = try await runtime.environments() + .filter { $0.kind == .managedDPoP } + .map(\.id) + var failureCount = 0 + for id in managedIDs { + var cleanupFailed = false + do { + try await runtime.revokeCredential(id: id) + } catch { + cleanupFailed = true + Self.t3ConnectLogger.error( + "Managed credential revocation failed: \(error.localizedDescription, privacy: .private)" + ) + } + do { + try await removeEnvironment(id: id) + // `remove` retries credential deletion, so its success + // supersedes an earlier revocation error. + cleanupFailed = false + } catch { + cleanupFailed = true + Self.t3ConnectLogger.error( + "Managed environment removal failed: \(error.localizedDescription, privacy: .private)" + ) + } + if cleanupFailed { + failureCount += 1 + } + } + guard failureCount == 0 else { + throw T3ConnectManagedCleanupError(failureCount: failureCount) + } + Self.t3ConnectLogger.info("Cleared managed T3 Connect runtime state") + } catch { + Self.t3ConnectLogger.error( + "Managed T3 Connect cleanup failed: \(error.localizedDescription, privacy: .private)" + ) + t3ConnectController.errorMessage = error.localizedDescription + } + } + + func setEnvironmentEnabled(id: String, enabled: Bool) async throws { + try await runtime.setEnabled(id: id, enabled: enabled) + if !enabled { + environmentConnectionStates[id] = .disconnected + environmentConnectionDetails[id] = nil + environmentClients[id] = nil + shellsByEnvironmentID[id] = nil + shellProjectionCache[id] = nil + serverConfigsByEnvironmentID[id] = nil + providerCatalogCache[id] = nil + archivedThreadsByEnvironmentID[id] = nil + archivedShellThreadsByEnvironmentID[id] = nil + } + } + + func removeEnvironment(id: String) async throws { + let removesActiveEnvironment = activeEnvironment?.id == id + let environment = try await runtime.environments().first { $0.id == id } + if environment?.kind == .managedDPoP { + try await runtime.revokeCredential(id: id) + } + try await runtime.remove(id: id) + if removesActiveEnvironment { + await clearActiveEnvironment(disconnectClient: false) + } + } + + func disconnect() async { + await clearActiveEnvironment() + } + + func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { + try await usageSummaries(input, refreshPricing: false) + } + + func usageSummaries(_ input: UsageSummaryInput, refreshPricing: Bool) async throws -> [FeatureEnvironmentUsage] { + var result: [FeatureEnvironmentUsage] = [] + for try await update in usageSummaryUpdates(input, refreshPricing: refreshPricing) { + result = update + } + return result + } + + func usageSummaryUpdates( + _ input: UsageSummaryInput, + refreshPricing: Bool + ) -> AsyncThrowingStream<[FeatureEnvironmentUsage], Error> { + let runtime = runtime + return AsyncThrowingStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let task = Task { + do { + let environments = try await runtime.environments().filter(\.isEnabled) + var results = environments.map { + FeatureEnvironmentUsage( + environmentID: $0.id, label: $0.label, + summary: nil, isPending: true + ) + } + continuation.yield(results) + try await withThrowingTaskGroup(of: (Int, FeatureEnvironmentUsage).self) { group in + for (index, environment) in environments.enumerated() { + group.addTask { + let probe = await runtime.ephemeralClient(for: environment) + let result: FeatureEnvironmentUsage + do { + var pricingError: String? + if refreshPricing { + do { _ = try await probe.refreshUsageRates() } + catch is CancellationError { throw CancellationError() } + catch { pricingError = "Could not refresh prices. Showing the available rates." } + } + let summary = try await probe.usageSummary(input) + try Task.checkCancellation() + result = FeatureEnvironmentUsage( + environmentID: environment.id, label: environment.label, + summary: summary, errorMessage: pricingError + ) + } catch is CancellationError { + await probe.disconnect() + throw CancellationError() + } catch { + result = FeatureEnvironmentUsage( + environmentID: environment.id, label: environment.label, + summary: nil, errorMessage: "This environment could not report usage." + ) + } + await probe.disconnect() + return (index, result) + } + } + for try await (index, result) in group { + try Task.checkCancellation() + results[index] = result + continuation.yield(results) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func usageLimitsUpdates() -> AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error> { + let runtime = runtime + return AsyncThrowingStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let task = Task { + do { + let environments = try await runtime.environments().filter(\.isEnabled) + let rows = environments.map { + FeatureEnvironmentUsageLimits(environmentID: $0.id, label: $0.label, isPending: true) + } + let collector = NativeUsageLimitsCollector(rows: rows, continuation: continuation) + continuation.yield(rows) + await withTaskGroup(of: Void.self) { group in + for (index, environment) in environments.enumerated() { + group.addTask { + // This view owns these subscriptions. Closing it releases all + // quota streams without disturbing the inbox or thread socket. + let probe = await runtime.ephemeralClient(for: environment) + do { + let config = try await probe.serverConfig() + try Task.checkCancellation() + await collector.update(index: index, config: config) + for try await event in await probe.serverConfigEvents() { + try Task.checkCancellation() + if case .unrelated = event { continue } + let config = try await probe.serverConfig() + await collector.update(index: index, config: config) + } + } catch is CancellationError { + // View or tab changes own cancellation, not a connection failure. + } catch { + await collector.fail(index: index, message: error.localizedDescription) + } + await probe.disconnect() + } + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func refreshUsageLimits() async throws -> [FeatureEnvironmentUsageLimits] { + let environments = try await runtime.environments().filter(\.isEnabled) + let runtime = runtime + return try await withThrowingTaskGroup(of: (Int, FeatureEnvironmentUsageLimits).self) { group in + for (index, environment) in environments.enumerated() { + group.addTask { + let probe = await runtime.ephemeralClient(for: environment) + let row: FeatureEnvironmentUsageLimits + do { + let config = try await probe.refreshProviders(refreshModels: false) + try Task.checkCancellation() + row = FeatureEnvironmentUsageLimits( + environmentID: environment.id, label: environment.label, + providers: config.providers, sources: config.usageLimitSources + ) + } catch is CancellationError { + await probe.disconnect() + throw CancellationError() + } catch { + row = FeatureEnvironmentUsageLimits( + environmentID: environment.id, label: environment.label, + isConnected: false, errorMessage: error.localizedDescription + ) + } + await probe.disconnect() + return (index, row) + } + } + var results: [(Int, FeatureEnvironmentUsageLimits)] = [] + for try await row in group { results.append(row) } + return results.sorted { $0.0 < $1.0 }.map(\.1) + } + } + + func consumeResetCredit( + environmentID: String, + instanceID: String + ) async throws -> ProviderConsumeResetCreditResult { + guard try await runtime.environments().contains(where: { $0.id == environmentID && $0.isEnabled }) else { + throw NativeFeatureClientError.environmentNotFound + } + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.consumeResetCredit(instanceID: instanceID) + } + + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + { + try await pullRequestLists(input, inEnvironment: nil) + } + + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] { + try await pullRequestLists(input, inEnvironment: environmentID) + } + + private func pullRequestLists( + _ input: PullRequestListInput, + inEnvironment environmentID: String? + ) async throws -> [FeaturePullRequestEnvironmentList] { + let environments = try await runtime.environments().filter { + $0.isEnabled + && $0.descriptor?.capabilities.pullRequests == true + && (environmentID == nil || $0.id == environmentID) + } + let runtime = runtime + return await withTaskGroup(of: FeaturePullRequestEnvironmentList.self) { group in + for environment in environments { + group.addTask { + let probe = await runtime.ephemeralClient(for: environment) + do { + let result = try await probe.pullRequests(input) + await probe.disconnect() + return FeaturePullRequestEnvironmentList( + environmentID: environment.id, + environmentName: environment.label, + result: result, + errorMessage: nil + ) + } catch { + await probe.disconnect() + return FeaturePullRequestEnvironmentList( + environmentID: environment.id, + environmentName: environment.label, + result: nil, + errorMessage: error.localizedDescription + ) + } + } + } + var results: [FeaturePullRequestEnvironmentList] = [] + for await result in group { results.append(result) } + return results.sorted { $0.environmentName < $1.environmentName } + } + } + + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail { + try await projectCreationClient(environmentID: target.environmentID) + .pullRequestDetail(target.reference) + } + + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + { + try await projectCreationClient(environmentID: target.environmentID) + .pullRequestActivity(target.reference) + } + + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + { + try await projectCreationClient(environmentID: target.environmentID).pullRequestDiff( + PullRequestDiffInput( + projectId: target.reference.projectId, + repository: target.reference.repository, + number: target.reference.number, + cursor: cursor, + commit: nil + ) + ) + } + + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws { + try await projectCreationClient(environmentID: target.environmentID).runPullRequestAction( + target.reference, + action: action, + mergeMethod: mergeMethod, + updateMethod: updateMethod + ) + } + + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws { + try await projectCreationClient(environmentID: target.environmentID).updatePullRequest( + target.reference, + title: title, + body: body + ) + } + + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .commentOnPullRequest(target.reference, body: body) + } + + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .submitPullRequestReview( + target.reference, + verdict: verdict, + body: body, + comments: comments + ) + } + + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .replyToPullRequestThread(target.reference, threadID: threadID, body: body) + } + + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .setPullRequestThreadResolved( + target.reference, + threadID: threadID, + resolved: resolved + ) + } + + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .setPullRequestReaction( + target.reference, + subjectID: subjectID, + content: content, + reacted: reacted + ) + } + + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + { + try await projectCreationClient(environmentID: target.environmentID) + .pullRequestReviewerCandidates(target.reference) + } + + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .requestPullRequestReviewers( + target.reference, + reviewers: reviewers, + requested: requested + ) + } + + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws { + if let target { + try await projectCreationClient(environmentID: target.environmentID) + .invalidatePullRequests(target.reference) + return + } + let environments = try await runtime.environments().filter(\.isEnabled) + for environment in environments { + try? await projectCreationClient(environmentID: environment.id).invalidatePullRequests() + } + } + + private func adoptEnvironment( + _ environment: Environment, + client newClient: T3Client + ) async { + if activeEnvironment?.id == environment.id, client === newClient { + activeEnvironment = environment + environmentClients[environment.id] = newClient + latestShell = shellsByEnvironmentID[environment.id] + startAggregateRefresh(newClient) + return + } + let previousClient = client + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + aggregateRefreshTask?.cancel() + archivedRefreshTask?.cancel() + pollingTask = nil + fallbackPollingTask = nil + configurationTask = nil + aggregateRefreshTask = nil + aggregateRefreshID = nil + archivedRefreshTask = nil + clearEnvironmentState(preserveEnvironmentSnapshots: true) + activeEnvironment = environment + client = newClient + environmentClients[environment.id] = newClient + latestShell = shellsByEnvironmentID[environment.id] + if let previousClient, previousClient !== newClient { + await previousClient.disconnect() + } + startAggregateRefresh(newClient) + } + + private func clearActiveEnvironment(disconnectClient: Bool = true) async { + let previousClient = client + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + aggregateRefreshTask?.cancel() + archivedRefreshTask?.cancel() + pollingTask = nil + fallbackPollingTask = nil + configurationTask = nil + aggregateRefreshTask = nil + aggregateRefreshID = nil + archivedRefreshTask = nil + clearEnvironmentState() + client = nil + activeEnvironment = nil + if disconnectClient, let previousClient { + await previousClient.disconnect() + } + } + + private func clearEnvironmentState(preserveEnvironmentSnapshots: Bool = false) { + environmentGeneration &+= 1 + resetDetailRefresh() + resetDetailStream() + archivedRefreshTask?.cancel() + archivedRefreshTask = nil + shellPublishTask?.cancel() + shellPublishTask = nil + latestShell = nil + lastShellEventAt = nil + latestServerConfig = nil + if !preserveEnvironmentSnapshots { + environmentClients.removeAll() + shellsByEnvironmentID.removeAll() + shellProjectionCache.removeAll() + indexedShellMembership = nil + indexedProvisionalRoutes.removeAll() + serverConfigsByEnvironmentID.removeAll() + providerCatalogCache.removeAll() + archivedThreadsByEnvironmentID.removeAll() + archivedShellThreadsByEnvironmentID.removeAll() + projectEnvironmentIDs.removeAll() + projectWireIDs.removeAll() + threadEnvironmentIDs.removeAll() + threadWireIDs.removeAll() + provisionalThreadRoutes.removeAll() + environmentConnectionStates.removeAll() + environmentConnectionDetails.removeAll() + } + latestSnapshot = nil + activeThreadID = nil + activeThreadEnvironmentID = nil + activeRawThread = nil + activeThreadSequence = nil + activeThreadPage = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + latestDetails.removeAll() + threadResumeStates.removeAll() + detailRenderCaches.removeAll() + detailCacheRecency.removeAll() + attachmentURLs.removeAll() + pendingBootstrapSubmissions.removeAll() + pendingTurnSubmissions.removeAll() + approvalRoutes.removeAll() + inputRoutes.removeAll() + terminalSnapshots.removeAll() + } + + private func isCurrentSession(client: T3Client, generation: Int) -> Bool { + guard generation == environmentGeneration, let currentClient = self.client else { + return false + } + return currentClient === client + } + + private func isKnownClient( + _ client: T3Client, + environmentID: String, + generation: Int + ) -> Bool { + generation == environmentGeneration + && environmentClients[environmentID] === client + } + + func addProject(path: String) async throws { + guard let environmentID = activeEnvironment?.id else { + throw NativeFeatureClientError.notConnected + } + try await addProject(environmentID: environmentID, path: path) + } + + func addProject(environmentID: String, path: String) async throws { + let client = try await projectCreationClient(environmentID: environmentID) + try await createProject(client: client, path: path) + } + + func browseProjectFolders( + environmentID: String, + partialPath: String + ) async throws -> FilesystemBrowseResult { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.browseFilesystem(partialPath: partialPath) + } + + func workspaceAssetURL(threadID: String, path: String) async throws -> URL { + let route = try threadRoute(for: threadID) + return try await route.client.resolvedAssetURL( + resource: .workspaceFile(threadID: route.wireID, path: path) + ) + } + + func nativeAppIconURL(threadID: String, app: ToolNativeAppReference) async throws -> URL { + let route = try threadRoute(for: threadID) + return try await route.client.resolvedAssetURL(resource: .nativeAppIcon(app)) + } + + func mediaAssetURL(threadID: String, path: String) async throws -> URL { + try await mediaAsset(threadID: threadID, path: path).url + } + + func mediaAsset(threadID: String, path: String) async throws -> ResolvedAssetURL { + let route = try threadRoute(for: threadID) + do { + return try await route.client.resolvedAsset( + resource: .mediaFile(threadID: route.wireID, path: path) + ) + } catch let RPCError.remote(message) + where message.localizedCaseInsensitiveContains("media-file") + && (message.localizedCaseInsensitiveContains("schema") + || message.localizedCaseInsensitiveContains("unsupported") + || message.localizedCaseInsensitiveContains("unknown tag") + || message.localizedCaseInsensitiveContains("unknown discriminator")) { + return try await route.client.resolvedAsset( + resource: .workspaceFile(threadID: route.wireID, path: path) + ) + } + } + + func submitCodexFeedback(threadID: String, reason: String?) async throws -> String { + let route = try threadRoute(for: threadID) + return try await route.client.uploadFeedback( + threadID: route.wireID, + reason: reason + ).feedbackId + } + + func cachedProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? { + let key = FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) + return try? await projectFaviconStore.value(for: key)?.data + } + + func refreshProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? { + let key = FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) + let cached = try? await projectFaviconStore.value(for: key) + if let cached, + Date.now.timeIntervalSince(cached.lastCheckedAt) + < Self.projectFaviconRefreshInterval { + return cached.data + } + if let task = projectFaviconRefreshTasks[key] { + return await task.value + } + + guard let client = environmentClients[environmentID] else { + try? await projectFaviconStore.record( + data: nil, + revision: nil, + for: key + ) + return cached?.data + } + + let store = projectFaviconStore + let task = Task { + do { + let resolved = try await client.resolvedAsset( + resource: .projectFavicon(cwd: workspaceRoot) + ) + let revision = resolved.url.lastPathComponent.removingPercentEncoding + ?? resolved.url.lastPathComponent + if revision == Self.projectFaviconFallbackMarker { + try await store.record(data: nil, revision: nil, for: key) + return cached?.data + } + if cached?.revision == revision, cached?.data != nil { + try await store.record(data: nil, revision: revision, for: key) + return cached?.data + } + + let (data, response) = try await URLSession.shared.data(from: resolved.url) + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + !data.isEmpty, + data.count <= FeatureProjectFaviconStore.maximumDataSize else { + throw CocoaError(.fileReadCorruptFile) + } + guard let renderable = await FeatureProjectFaviconImageDecoder.renderableData( + from: data + ) else { + throw CocoaError(.fileReadCorruptFile) + } + try await store.record(data: renderable, revision: revision, for: key) + return renderable + } catch { + try? await store.record(data: nil, revision: nil, for: key) + return cached?.data + } + } + projectFaviconRefreshTasks[key] = task + let value = await task.value + projectFaviconRefreshTasks[key] = nil + return value + } + + func discoverProjectSources( + environmentID: String + ) async throws -> SourceControlDiscoveryResult { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.discoverSourceControl() + } + + func lookupProjectRepository( + environmentID: String, + provider: SourceControlProviderKind, + repository: String + ) async throws -> SourceControlRepositoryInfo { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.lookupRepository( + provider: provider, + repository: repository + ) + } + + func cloneProjectRepository( + environmentID: String, + remoteURL: String, + destinationPath: String + ) async throws -> SourceControlCloneResult { + let client = try await projectCreationClient(environmentID: environmentID) + do { + return try await client.cloneRepository( + remoteURL: remoteURL, + destinationPath: destinationPath + ) + } catch let error as RPCError { + switch error { + case .connectionUnavailable, .disconnected, .responseTimedOut: + // The clone RPC is not receipt-bearing, so a lost reply is + // ambiguous. Confirm the requested destination became a Git + // repository with a primary remote before moving on to the + // independently retryable project-registration step. + if let refs = try? await client.listVCSRefs( + cwd: destinationPath, + refresh: true, + limit: 1 + ), refs.isRepo, refs.hasPrimaryRemote { + return SourceControlCloneResult( + cwd: destinationPath, + remoteUrl: remoteURL, + repository: nil + ) + } + throw error + case .remote, .protocolViolation: + throw error + } + } + } + + private func createProject(client: T3Client, path: String) async throws { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NativeFeatureClientError.invalidProjectPath + } + let title = ProjectCreationPath.lastPathComponent(trimmed) + let projectID = UUID().uuidString + do { + _ = try await client.createProject( + projectID: projectID, + title: title.isEmpty ? "Project" : title, + workspaceRoot: trimmed, + defaultModel: client.environment.id == activeEnvironment?.id + ? fallbackModelSelection( + environmentID: client.environment.id, + projectID: nil, + shell: shellsByEnvironmentID[client.environment.id] + ) + : nil + ) + } catch { + // The dispatch reply may be lost after the server persisted the + // project. A fresh shell turns that ambiguous failure into success + // and also makes retrying clone registration idempotent. + guard await recoverCreatedProject( + client: client, + projectID: projectID, + path: trimmed + ) else { + throw error + } + return + } + + do { + try await refresh(client: client) + } catch { + guard await recoverCreatedProject( + client: client, + projectID: projectID, + path: trimmed + ) else { + throw error + } + } + } + + private func recoverCreatedProject( + client: T3Client, + projectID: String, + path: String + ) async -> Bool { + let environment = client.environment + let generation = environmentGeneration + guard let fetchedShell = try? await client.shellSnapshot(), + isKnownClient(client, environmentID: environment.id, generation: generation) else { + return false + } + let shell = newestShell(fetchedShell, for: environment) + guard shell.projects.contains(where: { + $0.id == projectID + || ProjectCreationPath.normalizedForComparison($0.workspaceRoot) + == ProjectCreationPath.normalizedForComparison(path) + }) else { + return false + } + await emitSnapshot(shell, client: client, expectedGeneration: generation) + return true + } + + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] { + let route = try projectRoute(for: projectID) + let project = try project(for: route) + var refs: [VCSRef] = [] + var cursor: Int? + var seenCursors = Set() + repeat { + let result = try await route.client.listVCSRefs( + cwd: project.workspaceRoot, + cursor: cursor, + refresh: refresh && cursor == nil, + limit: 100 + ) + guard result.isRepo else { return [] } + refs.append(contentsOf: result.refs) + guard let nextCursor = result.nextCursor, + seenCursors.insert(nextCursor).inserted else { + break + } + cursor = nextCursor + } while true + + return refs.map { ref in + FeatureWorkspaceBranch( + name: ref.name, + isRemote: ref.isRemote ?? false, + isCurrent: ref.current, + isDefault: ref.isDefault, + worktreePath: ref.worktreePath + ) + } + } + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + let route = try projectRoute(for: projectID) + let client = route.client + let environment = client.environment + let generation = environmentGeneration + let model = modelSelection( + selection, + projectID: route.wireID, + environmentID: environment.id, + shell: shellsByEnvironmentID[environment.id] + ) + let resolvedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) + let threadTitle = resolvedTitle?.isEmpty == false ? resolvedTitle! : "New thread" + let signature = ThreadCreationSignature( + projectID: projectID, + title: threadTitle, + model: model + ) + let pending: PendingThreadCreation + if let existing = pendingThreadCreations.first(where: { $0.signature == signature }) { + pending = existing + } else { + pending = PendingThreadCreation(signature: signature, threadID: UUID().uuidString) + pendingThreadCreations.append(pending) + } + var recoveredShell: OrchestrationShellSnapshot? + do { + _ = try await client.createThread( + threadID: pending.threadID, + projectID: route.wireID, + title: threadTitle, + model: model, + runtimeMode: .fullAccess + ) + } catch { + guard Self.isAmbiguousDispatchFailure(error) else { + removePendingThreadCreation(threadID: pending.threadID) + throw error + } + if let shell = try? await client.shellSnapshot(), + shell.threads.contains(where: { $0.id == pending.threadID }) { + recoveredShell = shell + } else { + // Keep this ID while the outcome is ambiguous. A retry of the + // same creation attempt must not make another thread. + throw error + } + } + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + removePendingThreadCreation(threadID: pending.threadID) + registerProvisionalThread(wireID: pending.threadID, environmentID: environment.id) + let refreshedShell: OrchestrationShellSnapshot? + if let recoveredShell { + refreshedShell = recoveredShell + } else { + refreshedShell = try? await client.shellSnapshot() + } + if let refreshedShell { + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + let shell = newestShell(refreshedShell, for: environment) + await emitSnapshot(shell, client: client, expectedGeneration: generation) + if let created = shell.threads.first(where: { $0.id == pending.threadID }) { + provisionalThreadRoutes[FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + )] = nil + return mapThread(created, environment: environment) + } + } + return FeatureThread( + id: FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + ), + wireID: pending.threadID, + projectID: route.uiID, + environmentID: environment.id, + environmentName: environment.label, + title: threadTitle, + providerID: model.instanceId, + providerName: providerDisplayName(model.instanceId), + modelID: model.model + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await createThreadAndSend( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false, + attachments: attachments + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await createThreadAndSendResolved( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments, + submissionIdentity: nil + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws -> FeatureThread { + try await createThreadAndSendResolved( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments, + submissionIdentity: identity + ) + } + + private func createThreadAndSendResolved( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + submissionIdentity: FeatureSubmissionIdentity? + ) async throws -> FeatureThread { + let route = try projectRoute(for: projectID) + let client = route.client + let environment = client.environment + let generation = environmentGeneration + let routedProject = try project(for: route) + let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + guard workspaceMode != .worktree || branch?.isEmpty == false else { + throw NativeFeatureClientError.branchRequired + } + let worktreePath = workspaceMode == .local ? worktreePath : nil + let model = modelSelection( + selection, + projectID: route.wireID, + environmentID: environment.id, + shell: shellsByEnvironmentID[environment.id] + ) + let title = Self.title(from: prompt, hasAttachments: !attachments.isEmpty) + let uploads = try makeUploadAttachments(attachments) + if !uploads.isEmpty { _ = try await client.serverConfig() } + let runtime = coreRuntimeMode(runtimeMode) + let interaction = coreInteractionMode(interactionMode) + let signature = BootstrapSubmissionSignature( + projectID: projectID, + prompt: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments + ) + let pending: PendingBootstrapSubmission + let explicitIdentity = submissionIdentity.map { commandIdentity($0) } + if let explicitIdentity, + let existing = pendingBootstrapSubmissions.first(where: { + $0.identity == explicitIdentity + }) { + pending = existing + } else if explicitIdentity == nil, + let existing = pendingBootstrapSubmissions.first(where: { + $0.signature == signature + }) { + pending = existing + } else { + pending = PendingBootstrapSubmission( + signature: signature, + threadID: submissionIdentity?.threadID ?? UUID().uuidString, + identity: explicitIdentity ?? CommandIdentity(), + worktreeBranchName: workspaceMode == .worktree + ? Self.temporaryWorktreeBranchName( + seed: submissionIdentity?.threadID + ) + : nil + ) + pendingBootstrapSubmissions.append(pending) + } + + do { + _ = try await client.createThreadAndSend( + threadID: pending.threadID, + projectID: route.wireID, + title: title, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + branch: branch, + worktreePath: worktreePath, + worktreePreparation: pending.worktreeBranchName.flatMap { worktreeBranch in + branch.map { + ThreadWorktreePreparation( + projectCwd: routedProject.workspaceRoot, + baseBranch: $0, + branch: worktreeBranch, + startFromOrigin: startFromOrigin + ) + } + }, + attachments: uploads, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + // A connection can disappear after the server accepted the command + // but before its reply reaches us. Bootstrap expansion creates the + // thread before dispatching the stable final turn, so recover an + // interrupted empty thread by sending only that original turn. + let recovered = try await recoverBootstrap( + client: client, + pending: pending, + projectID: route.wireID, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + attachments: uploads + ) + guard recovered else { + await resetFailedBootstrapIfConfirmed( + client: client, + pending: pending, + projectCwd: routedProject.workspaceRoot + ) + throw error + } + } + + registerProvisionalThread(wireID: pending.threadID, environmentID: environment.id) + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + removePendingBootstrap(identity: pending.identity) + // Dispatch acceptance is the commit point. A dropped refresh must not + // turn a successful first turn into a retry that creates a duplicate. + if let refreshedShell = try? await client.shellSnapshot() { + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + let shell = newestShell(refreshedShell, for: environment) + await emitSnapshot(shell, client: client, expectedGeneration: generation) + if let created = shell.threads.first(where: { $0.id == pending.threadID }) { + provisionalThreadRoutes[FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + )] = nil + return mapThread(created, environment: environment) + } + } + return FeatureThread( + id: FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + ), + wireID: pending.threadID, + projectID: route.uiID, + environmentID: environment.id, + environmentName: environment.label, + title: title, + branch: workspaceMode == .worktree ? pending.worktreeBranchName : branch, + worktreePath: worktreePath, + providerID: model.instanceId, + providerName: providerDisplayName(model.instanceId), + modelID: model.model, + modelOptions: mapOptionSelections(model.options), + runtimeMode: runtimeMode, + interactionMode: interactionMode.mobileNormalized + ) + } + + private func recoverBootstrap( + client: T3Client, + pending: PendingBootstrapSubmission, + projectID: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode, + attachments: [UploadChatImageAttachment] + ) async throws -> Bool { + guard let snapshot = try? await client.threadSnapshot(id: pending.threadID) else { + return false + } + if snapshot.thread.messages.contains(where: { + $0.id == pending.identity.messageID + }) { + return true + } + guard snapshot.thread.projectId == projectID, + snapshot.thread.deletedAt == nil, + snapshot.thread.messages.isEmpty else { + return false + } + + do { + _ = try await client.sendTurn( + threadID: pending.threadID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: attachments, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + guard await messageWasCommitted( + client: client, + threadID: pending.threadID, + messageID: pending.identity.messageID + ) else { + throw error + } + } + return true + } + + /// A failed bootstrap can leave its generated worktree behind after the + /// server rolls back the thread. Only reset the retry identity after a + /// fresh shell confirms the thread is absent; ambiguous network failures + /// keep the stable IDs so the normal recovery path remains idempotent. + private func resetFailedBootstrapIfConfirmed( + client: T3Client, + pending: PendingBootstrapSubmission, + projectCwd: String + ) async { + guard let shell = try? await client.shellSnapshot(), + !shell.threads.contains(where: { $0.id == pending.threadID }) else { + return + } + + if let branch = pending.worktreeBranchName, + let refs = try? await client.listVCSRefs( + cwd: projectCwd, + query: branch, + refresh: true, + limit: 100 + ), + let path = refs.refs.first(where: { + $0.name == branch && $0.isRemote != true + })?.worktreePath { + // Never force-remove: setup scripts may have left useful changes. + // A clean orphan is safe to reclaim; a dirty one remains visible + // through normal worktree management. + try? await client.removeWorktree(cwd: projectCwd, path: path) + } + + removePendingBootstrap(identity: pending.identity) + } + + private func removePendingBootstrap(identity: CommandIdentity) { + pendingBootstrapSubmissions.removeAll { $0.identity == identity } + } + + private func removePendingThreadCreation(threadID: String) { + pendingThreadCreations.removeAll { $0.threadID == threadID } + } + + private static func isAmbiguousDispatchFailure(_ error: any Error) -> Bool { + if let error = error as? RPCError { + switch error { + case .connectionUnavailable, .disconnected, .responseTimedOut: + return true + case .remote, .protocolViolation: + return false + } + } + if let error = error as? HTTPError { + switch error { + case .invalidResponse: + return true + case .status, .missingCredential, .incompatibleCredential, + .managedAuthorizationUnavailable, .unauthenticatedSession: + return false + } + } + // URL loading errors and cancellation can happen after the request + // body crossed the network. Reusing the ID is safe in either case. + return true + } + + func renameThread(id: String, title: String) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.rename(threadID: route.wireID, title: title) + updateCachedArchivedThread(id: route.uiID) { $0.title = title } + try? await refresh(client: route.client) + } + + func regenerateThreadTitle(id: String) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.regenerateTitle(threadID: route.wireID) + try? await refresh(client: route.client) + } + + func setThreadArchived(id: String, archived: Bool) async throws { + let route = try threadRoute(for: id) + let cached = cachedThread(id: route.uiID) + _ = try await route.client.archive(threadID: route.wireID, archived: archived) + reconcileArchivedCache(thread: cached, route: route, archived: archived) + await emitCachedSnapshot(for: route.environmentID) + try? await refresh(client: route.client, includeArchived: true) + } + + func setThreadSettled(id: String, settled: Bool) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.settle(threadID: route.wireID, settled: settled) + try? await refresh(client: route.client) + } + + func setThreadSnoozed(id: String, until: Date?) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.snooze(threadID: route.wireID, until: until) + try? await refresh(client: route.client) + } + + func setThreadPinned(id: String, pinned: Bool) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.pin(threadID: route.wireID, pinned: pinned) + try? await refresh(client: route.client) + } + + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.setRuntimeMode( + threadID: route.wireID, + mode: coreRuntimeMode(mode) + ) + try? await refresh(client: route.client) + if activeThreadID == route.uiID { + try? await refreshThread(id: route.uiID, client: route.client) + } + } + + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.setInteractionMode( + threadID: route.wireID, + mode: coreInteractionMode(mode) + ) + try? await refresh(client: route.client) + if activeThreadID == route.uiID { + try? await refreshThread(id: route.uiID, client: route.client) + } + } + + func deleteThread(id: String) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.delete(threadID: route.wireID) + archivedThreadsByEnvironmentID[route.environmentID]?.removeAll { + $0.id == route.uiID + } + if let shell = shellsByEnvironmentID[route.environmentID] { + shellsByEnvironmentID[route.environmentID] = OrchestrationShellSnapshot( + snapshotSequence: shell.snapshotSequence, + projects: shell.projects, + threads: shell.threads.filter { $0.id != route.wireID }, + updatedAt: shell.updatedAt + ) + } + provisionalThreadRoutes[route.uiID] = nil + if activeThreadID == route.uiID { + resetDetailRefresh() + resetDetailStream() + activeThreadID = nil + activeThreadEnvironmentID = nil + } + latestDetails[route.uiID] = nil + threadResumeStates[route.uiID] = nil + detailRenderCaches[route.uiID] = nil + detailCacheRecency.removeAll { $0 == route.uiID } + await emitCachedSnapshot(for: route.environmentID) + try? await refresh(client: route.client, includeArchived: true) + } + + func loadThread(id: String) async throws -> FeatureThreadDetail { + try await loadThread(id: id, fresh: false) + } + + func loadThread(id: String, fresh: Bool) async throws -> FeatureThreadDetail { + let route = try threadRoute(for: id) + let client = route.client + let environment = client.environment + let generation = environmentGeneration + retainActiveThread() + resetDetailRefresh() + resetDetailStream() + activeThreadID = route.uiID + activeThreadEnvironmentID = environment.id + threadHistoryEpoch &+= 1 + let historyEpoch = threadHistoryEpoch + pendingOlderThreadPage = nil + activeThreadPage = nil + activeRawThread = nil + activeThreadSequence = nil + let supportsPagination = serverConfigsByEnvironmentID[ + environment.id + ]?.threadSnapshotPagination == true + let supportsResume = serverConfigsByEnvironmentID[ + environment.id + ]?.threadResumeCompletionMarker == true + if !fresh, supportsResume, + let cached = threadResumeStates[route.uiID], cached.client === client, + cached.page == nil || supportsPagination, + var detail = latestDetails[route.uiID], + detailRenderCaches[route.uiID]?.isInitialized == true { + let currentConnectionID = await client.currentConnectionID() + guard !Task.isCancelled, + isKnownClient(client, environmentID: environment.id, generation: generation), + threadHistoryEpoch == historyEpoch, + activeThreadID == route.uiID, + activeThreadEnvironmentID == environment.id else { throw CancellationError() } + let warmConnectionID = cached.wasSynchronized + && cached.connectionID != nil && cached.connectionID == currentConnectionID + ? currentConnectionID + : nil + activeRawThread = cached.thread + activeThreadSequence = cached.sequence + activeThreadPage = cached.page + detail.page = cached.page + markThreadCacheRecentlyUsed(route.uiID) + startDetailStream(route, warmConnectionID: warmConnectionID) + return detail + } + continuation.yield(.threadSync(id: route.uiID, state: .catchingUp)) + let snapshot: OrchestrationThreadDetailSnapshot + do { + snapshot = try await client.threadSnapshot( + id: route.wireID, + turnLimit: supportsPagination ? Self.initialThreadUserTurnLimit : nil, + timeoutInterval: threadSnapshotTimeoutInterval + ) + } catch { + if !Task.isCancelled, threadHistoryEpoch == historyEpoch, + activeThreadID == route.uiID { + continuation.yield(.threadSync(id: route.uiID, state: .failed(error.localizedDescription))) + // A failed HTTP request must not prevent the socket snapshot + // from recovering this thread when the connection returns. + startDetailStream(route) + } + throw error + } + guard isKnownClient(client, environmentID: environment.id, generation: generation), + threadHistoryEpoch == historyEpoch, + activeThreadID == route.uiID, + activeThreadEnvironmentID == environment.id else { + throw CancellationError() + } + activeThreadPage = featurePage(snapshot.page) + let detail = mapDetail( + snapshot.thread, + environment: environment, + sourceSequence: snapshot.snapshotSequence, + page: activeThreadPage + ) + activeRawThread = snapshot.thread + activeThreadSequence = snapshot.snapshotSequence + latestDetails[route.uiID] = detail + startDetailStream(route) + if !supportsResume { markDetailSynchronized(route) } + return detail + } + + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? { + let route = try threadRoute(for: id) + guard activeThreadID == route.uiID, + activeThreadEnvironmentID == route.environmentID, + serverConfigsByEnvironmentID[ + route.environmentID + ]?.threadSnapshotPagination == true, + var page = activeThreadPage, + page.hasMore, + !page.isLoading, + let beforeCursor = page.beforeCursor else { + return latestDetails[id] + } + + let generation = environmentGeneration + let epoch = threadHistoryEpoch + let loadedSequence = activeThreadSequence ?? 0 + page.isLoading = true + activeThreadPage = page + publishActivePageState(threadID: route.uiID) + + do { + let snapshot = try await route.client.threadSnapshot( + id: route.wireID, + turnLimit: Self.olderThreadPageUserTurnLimit, + beforeCursor: beforeCursor + ) + guard isKnownClient( + route.client, + environmentID: route.environmentID, + generation: generation + ), activeThreadID == route.uiID else { + throw CancellationError() + } + guard threadHistoryEpoch == epoch, + snapshot.snapshotSequence >= loadedSequence else { + clearOlderThreadLoading(threadID: route.uiID) + return latestDetails[route.uiID] + } + + if let watermark = snapshot.page?.threadSequence, + watermark > (activeThreadSequence ?? 0) { + pendingOlderThreadPage = PendingOlderThreadPage( + snapshot: snapshot, + epoch: epoch, + threadID: route.uiID, + environmentID: route.environmentID + ) + return latestDetails[route.uiID] + } + return mergeOlderThreadPage(snapshot, route: route) + } catch { + if activeThreadID == route.uiID, threadHistoryEpoch == epoch { + clearOlderThreadLoading(threadID: route.uiID) + } + throw error + } + } + + func releaseThread(id: String) { + guard activeThreadID == id else { return } + retainActiveThread() + resetDetailRefresh() + resetDetailStream() + activeThreadID = nil + activeThreadEnvironmentID = nil + activeRawThread = nil + activeThreadSequence = nil + activeThreadPage = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + continuation.yield(.threadSync(id: id, state: nil)) + markThreadCacheRecentlyUsed(id) + evictOldThreadCachesIfNeeded() + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection? + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: [] + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: nil, + attachments: attachments, + submissionIdentity: nil + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: nil, + attachments: attachments, + submissionIdentity: identity + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: runtimeMode, + attachments: attachments, + submissionIdentity: identity + ) + } + + private func sendMessageResolved( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode requestedRuntimeMode: FeatureRuntimeMode?, + attachments: [FeatureUploadAttachment], + submissionIdentity: FeatureSubmissionIdentity? + ) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + guard let shellThread = shellsByEnvironmentID[environmentID]?.threads + .first(where: { $0.id == route.wireID }) else { + throw NativeFeatureClientError.threadNotFound + } + let model = selection.map(coreModelSelection) + let uploads = try makeUploadAttachments(attachments) + if !uploads.isEmpty { _ = try await client.serverConfig() } + let runtimeMode = coreRuntimeMode( + requestedRuntimeMode ?? mapRuntimeMode(shellThread.runtimeMode) + ) + let interactionMode = InteractionMode.default + let signature = TurnSubmissionSignature( + text: text, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + attachments: attachments + ) + let pending: PendingTurnSubmission + let explicitIdentity = submissionIdentity.map { commandIdentity($0) } + if let explicitIdentity, + let existing = pendingTurnSubmissions[route.uiID], + existing.identity == explicitIdentity { + pending = existing + } else if explicitIdentity == nil, + let existing = pendingTurnSubmissions[route.uiID], + existing.signature == signature { + pending = existing + } else { + pending = PendingTurnSubmission( + signature: signature, + identity: explicitIdentity ?? CommandIdentity() + ) + pendingTurnSubmissions[route.uiID] = pending + } + + do { + _ = try await client.sendTurn( + threadID: submissionIdentity?.threadID ?? route.wireID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: uploads, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + guard await messageWasCommitted( + client: client, + threadID: submissionIdentity?.threadID ?? route.wireID, + messageID: pending.identity.messageID + ) else { + // Keep the stable identity. Retrying the same restored draft + // cannot enqueue a duplicate turn after an ambiguous failure. + throw error + } + } + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + if pendingTurnSubmissions[route.uiID]?.identity == pending.identity { + pendingTurnSubmissions[route.uiID] = nil + } + // Live sync reconciles these snapshots. Refreshes are opportunistic + // after the accepted command so transient reads cannot invite a + // duplicate user turn. + try? await refreshThread(id: route.uiID, client: client) + try? await refresh(client: client) + } + + private func messageWasCommitted( + client: T3Client, + threadID: String, + messageID: String + ) async -> Bool { + guard let snapshot = try? await client.threadSnapshot(id: threadID) else { + return false + } + return snapshot.thread.messages.contains { $0.id == messageID } + } + + func cancelTurn(threadID: String) async throws { + let route = try threadRoute(for: threadID) + let turnID = shellsByEnvironmentID[route.environmentID]?.threads + .first(where: { $0.id == route.wireID })? + .latestTurn? + .turnId + _ = try await route.client.interrupt(threadID: route.wireID, turnID: turnID) + try? await refresh(client: route.client) + } + + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws { + guard let request = approvalRoutes[id] else { + throw NativeFeatureClientError.approvalNotFound + } + let route = try threadRoute(for: request.threadID) + _ = try await route.client.respondToApproval( + threadID: route.wireID, + requestID: request.wireID, + decision: decision.wireValue + ) + approvalRoutes[id] = nil + removeCachedApproval(id: id, threadID: route.uiID) + try? await refreshThread(id: route.uiID, client: route.client) + } + + func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws { + guard let request = inputRoutes[id] else { + throw NativeFeatureClientError.inputRequestNotFound + } + let route = try threadRoute(for: request.threadID) + _ = try await route.client.respondToUserInput( + threadID: route.wireID, + requestID: request.wireID, + answers: answers.mapValues(\.jsonValue) + ) + inputRoutes[id] = nil + removeCachedInput(id: id, threadID: route.uiID) + try? await refreshThread(id: route.uiID, client: route.client) + } + + func saveSettings(_ settings: FeatureSettings) async throws { + let data = try JSONEncoder().encode(settings) + settingsStore.set(data, forKey: Self.settingsKey) + latestSnapshot?.settings = settings + } + + func setProviderEnabled(environmentID: String, instanceID: String, enabled: Bool) async throws { + let client = try await projectCreationClient(environmentID: environmentID) + try await requireScope("orchestration:operate", client: client) + let config = try await client.serverConfig() + guard let provider = config.providers.first(where: { $0.instanceId == instanceID }), provider.driver == "antigravity" else { + throw FeatureCapabilityUnavailable("Provider settings") + } + try await client.setProviderEnabled(instanceID: instanceID, driver: provider.driver, enabled: enabled) + } + + func providerSetup(environmentID: String, instanceID: String, action: ProviderSetupAction) async throws -> ProviderSetupEvent { + let client = try await projectCreationClient(environmentID: environmentID) + try await requireScope("orchestration:operate", client: client) + return try await client.providerSetup(instanceID: instanceID, action: action) + } + + func providerSetupEvents(environmentID: String, instanceID: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let client = try await projectCreationClient(environmentID: environmentID) + try await requireScope("orchestration:operate", client: client) + let config = try await client.serverConfig() + let setup = config.providers.first { $0.instanceId == instanceID }?.setup + try await withThrowingTaskGroup(of: Void.self) { group in + if setup?.canAuthenticate == true { + group.addTask { + for try await state in await client.providerAuthEvents(instanceID: instanceID) { + continuation.yield(.auth(state)) + } + } + } + if setup?.canInstall == true { + group.addTask { + for try await state in await client.providerInstallEvents(instanceID: instanceID) { + continuation.yield(.install(state)) + } + } + } + try await group.waitForAll() + } + continuation.finish() + } catch { continuation.finish(throwing: error) } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func refreshProviders(environmentID: String) async throws -> [FeatureProvider] { + try await refreshProviderCatalog(environmentID: environmentID, cwd: nil, refreshModels: true) + } + + func refreshWorkspaceProviders(environmentID: String, cwd: String, instanceID: String) async throws -> [FeatureProvider] { + if let config = serverConfigsByEnvironmentID[environmentID], + config.providers.first(where: { $0.instanceId == instanceID })?.workspaceSnapshots?.contains(where: { $0.cwd == cwd }) == true { + return mapConfigProviders(config.providers) + } + return try await refreshProviderCatalog(environmentID: environmentID, cwd: cwd, instanceID: instanceID, refreshModels: false) + } + + private func refreshProviderCatalog(environmentID: String, cwd: String?, instanceID: String? = nil, refreshModels: Bool) async throws -> [FeatureProvider] { + let client = try await projectCreationClient(environmentID: environmentID) + let generation = environmentGeneration + let config = try await client.refreshProviders(cwd: cwd, instanceID: instanceID, refreshModels: refreshModels) + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + setServerConfig(config, environmentID: environmentID) + if environmentID == activeEnvironment?.id { latestServerConfig = config } + let providers = mapConfigProviders(config.providers) + providerCatalogCache[environmentID] = providers + if let shell = shellsByEnvironmentID[environmentID] { + await emitSnapshot(shell, client: client, expectedGeneration: generation) + } + return providers + } + + func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings { + if case let .afterDays(days) = change, + let days, + !(1...90).contains(days) { + throw NativeFeatureClientError.invalidAutomaticSettlementDays + } + + let client = try await projectCreationClient(environmentID: environmentID) + let previous = serverConfigsByEnvironmentID[environmentID] + let capabilities = previous?.environment?.capabilities + ?? client.environment.descriptor?.capabilities + guard capabilities?.threadAutoSettlement == true else { + throw FeatureCapabilityUnavailable("Automatic settlement settings") + } + + let serverChange: ServerSettingsChange = switch change { + case let .onMerge(value): .sidebarAutoSettleOnMerge(value) + case let .afterDays(value): .sidebarAutoSettleAfterDays(value) + } + let settings = try await saveServerPreferences(client: client, environmentID: environmentID, change: serverChange) + await fanOutSharedPreferences(from: environmentID, change: serverChange) + return FeatureAutomaticSettlementSettings( + onMerge: settings.sidebarAutoSettleOnMerge, + afterDays: settings.sidebarAutoSettleAfterDays + ) + } + + func serverPreferences(environmentID: String) async throws -> ServerSettingsSnapshot { + if let settings = serverConfigsByEnvironmentID[environmentID]?.settings { return settings } + let client = try await projectCreationClient(environmentID: environmentID) + guard let settings = try await client.serverConfig().settings else { + throw FeatureCapabilityUnavailable("Server preferences") + } + return settings + } + + func sharedPreferenceMismatches(environmentID: String) -> [String] { + guard let source = serverConfigsByEnvironmentID[environmentID]?.settings else { return [] } + return sharedPreferenceTargetIDs.filter { id in + guard id != environmentID, + let target = serverConfigsByEnvironmentID[id]?.settings else { return false } + let supportsRestart = supportsRestartContinuation(environmentID: environmentID) + && supportsRestartContinuation(environmentID: id) + return source.sharedPatch(supportsRestartContinuation: supportsRestart) + != target.sharedPatch(supportsRestartContinuation: supportsRestart) + }.map { id in latestSnapshot?.environments.first { $0.id == id }?.name ?? id } + } + + private func supportsRestartContinuation(environmentID: String) -> Bool { + serverConfigsByEnvironmentID[environmentID]?.environment?.capabilities.threadRestartContinuation == true + } + + private var sharedPreferenceTargetIDs: [String] { + serverConfigsByEnvironmentID.keys.filter { id in + environmentConnectionStates[id] == .connected + && serverConfigsByEnvironmentID[id]?.environment?.capabilities.threadAutoSettlement == true + && serverConfigsByEnvironmentID[id]?.settings != nil + }.sorted() + } + + func updateServerPreferences(environmentID: String, change: ServerSettingsChange) async throws { + let client = try await projectCreationClient(environmentID: environmentID) + let generation = environmentGeneration + let config = try await client.serverConfig() + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + setServerConfig(config, environmentID: environmentID) + switch change { + case .environmentIcon: + guard config.environment?.capabilities.environmentIcon == true else { + throw FeatureCapabilityUnavailable("Environment icons") + } + case .continueThreadsAfterServerUpdate: + guard config.environment?.capabilities.threadRestartContinuation == true else { + throw FeatureCapabilityUnavailable("Restart continuation") + } + default: + guard config.environment?.capabilities.threadAutoSettlement == true else { + throw FeatureCapabilityUnavailable("Shared preferences") + } + } + guard let supportedChange = NativeSharedPreferenceChange.filter( + change, + supportsRestartContinuation: supportsRestartContinuation(environmentID: environmentID) + ) else { throw FeatureCapabilityUnavailable("Restart continuation") } + _ = try await saveServerPreferences(client: client, environmentID: environmentID, change: supportedChange) + if case .environmentIcon = supportedChange { return } + await fanOutSharedPreferences(from: environmentID, change: supportedChange) + } + + private func fanOutSharedPreferences(from sourceID: String, change: ServerSettingsChange) async { + let sourceSupportsRestart = supportsRestartContinuation(environmentID: sourceID) + for id in sharedPreferenceTargetIDs where id != sourceID { + guard let targetChange = NativeSharedPreferenceChange.filter( + change, + supportsRestartContinuation: sourceSupportsRestart + && supportsRestartContinuation(environmentID: id) + ) else { continue } + do { + let client = try await projectCreationClient(environmentID: id) + _ = try await saveServerPreferences(client: client, environmentID: id, change: targetChange) + } catch { + // Keep the last real settings so the mismatch remains visible and can be retried. + } + } + } + + private func saveServerPreferences(client: T3Client, environmentID: String, change: ServerSettingsChange) async throws -> ServerSettingsSnapshot { + let generation = environmentGeneration + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + let settings = try await client.updateSettings(change) + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + let previous = serverConfigsByEnvironmentID[environmentID] + let config = ServerConfigSnapshot( + providers: previous?.providers ?? [], + settings: settings, + threadSnapshotPagination: previous?.threadSnapshotPagination, + threadResumeCompletionMarker: previous?.threadResumeCompletionMarker, + environment: previous?.environment, + usageLimitSources: previous?.usageLimitSources ?? [] + ) + setServerConfig(config, environmentID: environmentID) + if environmentID == activeEnvironment?.id { + latestServerConfig = config + } + if let shell = shellsByEnvironmentID[environmentID] { + await emitSnapshot(shell, client: client, expectedGeneration: generation) + } + return settings + } + + var managesServerSessions: Bool { + !t3ConnectDeviceManager.hasActiveAccount + } + + func loadDeviceSessions() async throws -> [FeatureDeviceSession] { + if t3ConnectDeviceManager.hasActiveAccount { + let devices = try await t3ConnectDeviceManager.registeredDevices() + relayDeviceSessionIDs = Set(devices.map(\.deviceId)) + return devices.map { + FeatureDeviceSession( + relayDevice: $0, + currentDeviceID: t3ConnectDeviceManager.currentRegisteredDeviceID + ) + } + } + + relayDeviceSessionIDs.removeAll() + let client = try requireClient() + try await requireScope("access:read", client: client) + return try await client.clientSessions().map { session in + FeatureDeviceSession( + sessionID: session.sessionId, + label: session.client.label, + deviceType: FeatureDeviceType(rawValue: session.client.deviceType) ?? .unknown, + operatingSystem: session.client.os, + browser: session.client.browser, + ipAddress: session.client.ipAddress, + issuedAt: parseDate(session.issuedAt), + expiresAt: parseDate(session.expiresAt), + lastConnectedAt: session.lastConnectedAt.map(parseDate), + isConnected: session.connected, + isCurrent: session.current + ) + } + } + + func revokeDeviceSession(id: String) async throws { + if relayDeviceSessionIDs.contains(id) { + try await t3ConnectDeviceManager.unregisterDevice(id: id) + relayDeviceSessionIDs.remove(id) + return + } + + let client = try requireClient() + try await requireScope("access:write", client: client) + guard try await client.revokeClientSession(id: id) else { + throw NativeFeatureClientError.deviceSessionNotFound + } + } + + func revokeOtherDeviceSessions() async throws { + if !relayDeviceSessionIDs.isEmpty { + guard let currentID = t3ConnectDeviceManager.currentRegisteredDeviceID else { + throw NativeFeatureClientError.currentDeviceUnknown + } + let otherIDs = relayDeviceSessionIDs.filter { $0 != currentID } + for id in otherIDs { + try await t3ConnectDeviceManager.unregisterDevice(id: id) + } + relayDeviceSessionIDs.subtract(otherIDs) + return + } + + let client = try requireClient() + try await requireScope("access:write", client: client) + _ = try await client.revokeOtherClientSessions() + } + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let result = try await route.client.listProjectEntries(cwd: context.cwd) + return NativeWorkspaceMapper.files(result.entries, directory: path) + } + + func searchProjectFiles( + projectID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + let route = try projectRoute(for: projectID) + let project = try project(for: route) + let result = try await route.client.searchProjectEntries( + cwd: project.workspaceRoot, + query: query, + limit: limit + ) + return result.entries.map(Self.mapSearchEntry) + } + + func searchThreadFiles( + threadID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let result = try await route.client.searchProjectEntries( + cwd: context.cwd, + query: query, + limit: limit + ) + return result.entries.map(Self.mapSearchEntry) + } + + private static func mapSearchEntry(_ entry: ProjectEntry) -> FeatureFileEntry { + let name = URL(fileURLWithPath: entry.path).lastPathComponent + return FeatureFileEntry( + path: entry.path, + name: name, + kind: entry.kind == .directory ? .directory : .file, + isHidden: name.hasPrefix(".") + ) + } + + func readFile(threadID: String, path: String) async throws -> FeatureFileContent { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let result = try await route.client.readProjectFile( + cwd: context.cwd, + relativePath: path + ) + return FeatureFileContent( + path: result.relativePath, + text: result.contents, + language: NativeWorkspaceMapper.language(for: result.relativePath), + isTruncated: result.truncated, + totalBytes: result.byteLength + ) + } + + func loadReview(threadID: String) async throws -> FeatureReview { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let preview = try await route.client.reviewDiffPreview(cwd: context.cwd) + return NativeWorkspaceMapper.review(preview) + } + + func loadReviewFileContents( + threadID: String, + file: FeatureReviewFile + ) async throws -> FeatureReviewFileContents? { + guard file.change != .binary, let sourceKind = file.sourceKind else { return nil } + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let changeType: String = switch file.change { + case .added: "new" + case .deleted: "deleted" + case .renamed: file.additions == 0 && file.deletions == 0 + ? "rename-pure" + : "rename-changed" + case .modified, .binary: "change" + } + let contents = try await route.client.reviewDiffFileContents( + cwd: context.cwd, + sourceKind: sourceKind, + changeType: changeType, + baseRef: file.sourceBaseReference, + headRef: file.sourceHeadReference, + oldPath: file.previousPath ?? file.path, + newPath: file.path + ) + return FeatureReviewFileContents( + oldContents: contents.oldContents, + newContents: contents.newContents + ) + } + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + return NativeWorkspaceMapper.sourceControl( + try await route.client.refreshVCSStatus(cwd: context.cwd) + ) + } + + func sourceControlStatuses( + threadID: String + ) async throws -> AsyncThrowingStream { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + let events = await client.vcsStatusEvents(cwd: context.cwd) + // Each element is a whole status, so only the newest one is ever useful. + let (statuses, continuation) = AsyncThrowingStream.makeStream( + of: FeatureSourceControlStatus.self, + bufferingPolicy: .bufferingNewest(1) + ) + let task = Task { [weak self] in + // The server publishes `remoteUpdated` only when the remote + // fingerprint changes, and backs off silently when a remote refresh + // fails, so the remote half may never arrive. Bound the wait rather + // than leaving the screen loading forever, and say so instead of + // leaving the status quietly half-known. + let deadline = Task { + try? await Task.sleep( + for: .seconds(Self.sourceControlStatusStreamTimeoutSeconds) + ) + guard !Task.isCancelled else { return } + continuation.finish(throwing: NativeFeatureClientError.remoteStatusUnavailable) + } + defer { deadline.cancel() } + + var accumulator = NativeSourceControlStatusAccumulator() + do { + for try await event in events { + // Superseded by cancellation or an environment switch: the + // stream is over, but nothing about it was malformed, so it + // must not run the end-of-stream validation below. + guard !Task.isCancelled else { + continuation.finish() + return + } + guard let self else { + continuation.finish() + return + } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + continuation.finish() + return + } + if let status = accumulator.consume(event) { + continuation.yield(status) + } + if accumulator.isComplete { + continuation.finish() + return + } + } + if Task.isCancelled { + continuation.finish() + } else { + try accumulator.validateEnd() + continuation.finish() + } + } catch is CancellationError { + continuation.finish() + } catch { + if Task.isCancelled { + continuation.finish() + } else { + continuation.finish(throwing: error) + } + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + return statuses + } + + func sourceControlStatusEvents(threadID: String) -> AsyncStream { + let stream = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + + guard let route = try? threadRoute(for: threadID), + let context = try? workspaceContext(route: route) else { + stream.continuation.finish() + return stream.stream + } + + let key = NativeSourceControlMonitorKey( + environmentID: route.environmentID, + workingDirectory: URL(fileURLWithPath: context.cwd).standardizedFileURL.path + ) + let subscriberID = UUID() + let monitor: NativeSourceControlMonitor + + if let existing = sourceControlMonitors[key] { + monitor = existing + } else { + monitor = NativeSourceControlMonitor() + sourceControlMonitors[key] = monitor + } + + monitor.continuations[subscriberID] = stream.continuation + if let latest = monitor.latestStatus { + stream.continuation.yield(latest) + } + stream.continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.removeSourceControlSubscriber(subscriberID, for: key) + } + } + + if monitor.task == nil { + let monitorID = monitor.id + monitor.task = Task { [weak self] in + await self?.observeSourceControlStatus( + client: route.client, + key: key, + monitorID: monitorID + ) + } + } + + return stream.stream + } + + private func observeSourceControlStatus( + client: T3Client, + key: NativeSourceControlMonitorKey, + monitorID: UUID + ) async { + let events = await client.vcsStatusEvents(cwd: key.workingDirectory) + var accumulator = NativeSourceControlStatusAccumulator() + + do { + for try await event in events { + guard !Task.isCancelled, + sourceControlMonitors[key]?.id == monitorID else { + break + } + + guard let status = accumulator.consume(event) else { continue } + guard let monitor = sourceControlMonitors[key], + monitor.id == monitorID, + monitor.latestStatus != status else { + continue + } + monitor.latestStatus = status + monitor.continuations.values.forEach { $0.yield(status) } + } + } catch { + // Existing rows keep their last known PR until the next subscription. + } + + guard sourceControlMonitors[key]?.id == monitorID else { return } + let monitor = sourceControlMonitors.removeValue(forKey: key) + monitor?.continuations.values.forEach { $0.finish() } + } + + private func removeSourceControlSubscriber( + _ subscriberID: UUID, + for key: NativeSourceControlMonitorKey + ) { + guard let monitor = sourceControlMonitors[key] else { return } + monitor.continuations.removeValue(forKey: subscriberID) + guard monitor.continuations.isEmpty else { return } + monitor.task?.cancel() + sourceControlMonitors.removeValue(forKey: key) + } + + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let context = try workspaceContext(route: route) + + if action == .pull { + _ = try await client.pull(cwd: context.cwd) + } else { + let progress = try await client.runGitAction( + cwd: context.cwd, + action: NativeWorkspaceMapper.gitAction(action), + commitMessage: message + ) + for try await event in progress { + if event.kind == "action_failed" { + throw RPCError.remote(event.message ?? "The source-control action failed.") + } + } + } + } + + func terminalSnapshot( + threadID: String, + terminalID: String + ) async throws -> FeatureTerminalSnapshot { + let route = try threadRoute(for: threadID) + let key = TerminalKey(threadID: route.uiID, terminalID: terminalID) + if let snapshot = terminalSnapshots[key] { + return snapshot + } + let context = try workspaceContext(route: route) + let snapshot = FeatureTerminalSnapshot( + threadID: route.uiID, + terminalID: terminalID, + workingDirectory: context.cwd, + lifecycleVersion: nextTerminalLifecycleVersion() + ) + terminalSnapshots[key] = snapshot + return snapshot + } + + func terminalHostOS(threadID: String) -> String? { + guard let route = try? threadRoute(for: threadID) else { return nil } + return serverConfigsByEnvironmentID[route.environmentID]?.environment?.platform.os + ?? route.client.environment.descriptor?.platform.os + } + + func terminalEvents( + threadID: String, + terminalID: String + ) -> AsyncStream { + guard let route = try? threadRoute(for: threadID), + let context = try? workspaceContext(route: route) else { + return AsyncStream { continuation in continuation.finish() } + } + let environmentID = route.environmentID + let client = route.client + let uiThreadID = route.uiID + let wireThreadID = route.wireID + let key = TerminalKey(threadID: uiThreadID, terminalID: terminalID) + let generation = environmentGeneration + return AsyncStream { continuation in + if let snapshot = terminalSnapshots[key] { + continuation.yield(snapshot) + } + let task = Task { [weak self] in + do { + let events = try await client.attachTerminal( + threadID: wireThreadID, + terminalID: terminalID, + cwd: context.cwd, + worktreePath: context.worktreePath, + columns: 80, + rows: 24 + ) + for try await event in events { + guard !Task.isCancelled else { break } + guard let self else { break } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + break + } + let snapshot = self.consumeTerminalEvent( + event, + threadID: uiThreadID, + terminalID: terminalID + ) + continuation.yield(snapshot) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + guard let self else { + continuation.finish() + return + } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + continuation.finish() + return + } + var snapshot = self.terminalSnapshots[key] + ?? FeatureTerminalSnapshot( + threadID: uiThreadID, + terminalID: terminalID, + workingDirectory: context.cwd + ) + snapshot.state = .failed + snapshot.error = error.localizedDescription + snapshot.lifecycleVersion = self.nextTerminalLifecycleVersion() + self.terminalSnapshots[key] = snapshot + continuation.yield(snapshot) + continuation.finish() + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + func terminalSessions(threadID: String) -> AsyncStream<[FeatureTerminalSnapshot]> { + guard let route = try? threadRoute(for: threadID) else { + return AsyncStream { continuation in continuation.finish() } + } + let environmentID = route.environmentID + let client = route.client + let uiThreadID = route.uiID + let wireThreadID = route.wireID + let generation = environmentGeneration + return AsyncStream { continuation in + let task = Task { [weak self] in + var summaries = [TerminalSummary]() + do { + for try await event in await client.terminalMetadataEvents() { + guard !Task.isCancelled else { break } + guard let self else { break } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + break + } + + switch event.type { + case "snapshot": + summaries = (event.terminals ?? []).filter { + $0.threadId == wireThreadID + } + case "upsert": + if let summary = event.terminal, + summary.threadId == wireThreadID { + summaries.removeAll { $0.terminalId == summary.terminalId } + summaries.append(summary) + } + case "remove": + if event.threadId == wireThreadID, + let terminalID = event.terminalId { + summaries.removeAll { $0.terminalId == terminalID } + let key = TerminalKey(threadID: uiThreadID, terminalID: terminalID) + if var cached = self.terminalSnapshots[key] { + cached.state = .stopped + cached.lifecycleVersion = self.nextTerminalLifecycleVersion() + self.terminalSnapshots[key] = cached + } + } + default: + break + } + + let sessions = summaries + .sorted { + $0.terminalId.localizedStandardCompare($1.terminalId) + == .orderedAscending + } + .map { self.mergeTerminalSummary($0, threadID: uiThreadID) } + continuation.yield(sessions) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish() + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + func openTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + let context = try workspaceContext(route: route) + let snapshot = try await client.openTerminal( + threadID: route.wireID, + terminalID: terminalID, + cwd: context.cwd, + worktreePath: context.worktreePath, + columns: columns, + rows: rows + ) + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + let mapped = NativeWorkspaceMapper.terminal(snapshot) + var scoped = mapped + scoped.threadID = route.uiID + scoped.buffer = Self.cappedTerminalBuffer(scoped.buffer) + scoped.lifecycleVersion = nextTerminalLifecycleVersion() + terminalSnapshots[TerminalKey(threadID: route.uiID, terminalID: terminalID)] = scoped + } + + func writeTerminal(threadID: String, terminalID: String, data: String) async throws { + let route = try threadRoute(for: threadID) + try await route.client.writeTerminal( + threadID: route.wireID, + terminalID: terminalID, + data: data + ) + } + + func resizeTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws { + let route = try threadRoute(for: threadID) + try await route.client.resizeTerminal( + threadID: route.wireID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + } + + func clearTerminal(threadID: String, terminalID: String) async throws { + let route = try threadRoute(for: threadID) + try await route.client.clearTerminal( + threadID: route.wireID, + terminalID: terminalID + ) + } + + func closeTerminal(threadID: String, terminalID: String) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + try await client.closeTerminal(threadID: route.wireID, terminalID: terminalID) + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + let context = try workspaceContext(route: route) + terminalSnapshots[TerminalKey(threadID: route.uiID, terminalID: terminalID)] = + FeatureTerminalSnapshot( + threadID: route.uiID, + terminalID: terminalID, + workingDirectory: context.cwd, + lifecycleVersion: nextTerminalLifecycleVersion() + ) + } + + private func requireClient() throws -> T3Client { + guard let client else { throw NativeFeatureClientError.notConnected } + return client + } + + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? { + let client = try await projectCreationClient(environmentID: environmentID) + _ = try await client.serverConfig() + let prepared = try await client.prepareAttachment( + makeUploadAttachments([attachment])[0] + ) + return prepared.map { + FeatureUploadedAttachmentReference( + environmentID: $0.environmentID, + attachmentID: $0.attachmentID + ) + } + } + + private func projectCreationClient(environmentID: String) async throws -> T3Client { + if let client = environmentClients[environmentID] { + return client + } + guard let environment = try await runtime.environments().first(where: { + $0.id == environmentID + }) else { + throw NativeFeatureClientError.environmentNotFound + } + let client = await runtime.client(for: environment) + environmentClients[environmentID] = client + return client + } + + private func projectRoute(for projectID: String) throws -> NativeProjectRoute { + guard let environmentID = projectEnvironmentIDs[projectID], + let wireID = projectWireIDs[projectID], + let client = environmentClients[environmentID] else { + throw NativeFeatureClientError.projectNotFound + } + return NativeProjectRoute( + uiID: FeatureScopedID.project(environmentID: environmentID, wireID: wireID), + wireID: wireID, + environmentID: environmentID, + client: client + ) + } + + private func project(for route: NativeProjectRoute) throws -> OrchestrationProject { + guard let project = shellsByEnvironmentID[route.environmentID]?.projects.first(where: { + $0.id == route.wireID + }) else { + throw NativeFeatureClientError.projectNotFound + } + return project + } + + private func threadRoute(for threadID: String) throws -> NativeThreadRoute { + guard let environmentID = threadEnvironmentIDs[threadID], + let wireID = threadWireIDs[threadID], + let client = environmentClients[environmentID] else { + throw NativeFeatureClientError.threadNotFound + } + return NativeThreadRoute( + uiID: FeatureScopedID.thread(environmentID: environmentID, wireID: wireID), + wireID: wireID, + environmentID: environmentID, + client: client + ) + } + + private func registerProvisionalThread(wireID: String, environmentID: String) { + let uiID = FeatureScopedID.thread(environmentID: environmentID, wireID: wireID) + provisionalThreadRoutes[uiID] = ProvisionalThreadRoute( + environmentID: environmentID, + wireID: wireID + ) + threadEnvironmentIDs[uiID] = environmentID + threadWireIDs[uiID] = wireID + } + + private func cachedThread(id: String) -> FeatureThread? { + latestSnapshot?.threads.first(where: { $0.id == id }) + ?? archivedThreadsByEnvironmentID.values.lazy + .flatMap { $0 } + .first(where: { $0.id == id }) + } + + private func updateCachedArchivedThread( + id: String, + update: (inout FeatureThread) -> Void + ) { + for environmentID in Array(archivedThreadsByEnvironmentID.keys) { + guard var threads = archivedThreadsByEnvironmentID[environmentID], + let index = threads.firstIndex(where: { $0.id == id }) else { + continue + } + update(&threads[index]) + archivedThreadsByEnvironmentID[environmentID] = threads + return + } + } + + private func reconcileArchivedCache( + thread: FeatureThread?, + route: NativeThreadRoute, + archived: Bool + ) { + archivedThreadsByEnvironmentID[route.environmentID, default: []] + .removeAll { $0.id == route.uiID } + var archivedShellThreads = archivedShellThreadsByEnvironmentID[ + route.environmentID, + default: [:] + ] + let previouslyArchivedShell = archivedShellThreads.removeValue( + forKey: route.wireID + ) + + if archived, var thread { + // Keep the accepted lifecycle transition visible until both live + // and archived follow-up reads converge, including when the + // owning passive device drops immediately after the command. + thread.isArchived = true + archivedThreadsByEnvironmentID[route.environmentID, default: []].append(thread) + } + + if let shell = shellsByEnvironmentID[route.environmentID] { + if archived { + if let liveThread = shell.threads.first(where: { $0.id == route.wireID }) { + archivedShellThreads[route.wireID] = liveThread + } + shellsByEnvironmentID[route.environmentID] = OrchestrationShellSnapshot( + snapshotSequence: shell.snapshotSequence, + projects: shell.projects, + threads: shell.threads.filter { $0.id != route.wireID }, + updatedAt: shell.updatedAt + ) + } else if let previouslyArchivedShell { + var threads = shell.threads.filter { $0.id != route.wireID } + threads.append(Self.unarchived(previouslyArchivedShell)) + shellsByEnvironmentID[route.environmentID] = OrchestrationShellSnapshot( + snapshotSequence: shell.snapshotSequence, + projects: shell.projects, + threads: threads, + updatedAt: shell.updatedAt + ) + } + } + archivedShellThreadsByEnvironmentID[route.environmentID] = archivedShellThreads + } + + private static func unarchived( + _ thread: OrchestrationThreadShell + ) -> OrchestrationThreadShell { + OrchestrationThreadShell( + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + branchPullRequest: thread.branchPullRequest, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: nil, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt, + activeOrderKey: thread.activeOrderKey, + snoozedUntil: thread.snoozedUntil, + snoozedAt: thread.snoozedAt, + pinnedAt: thread.pinnedAt, + session: thread.session, + latestUserMessageAt: thread.latestUserMessageAt, + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + hasActionableProposedPlan: thread.hasActionableProposedPlan, + backgroundLiveness: thread.backgroundLiveness + ) + } + + private func emitCachedSnapshot(for environmentID: String) async { + guard let client = environmentClients[environmentID], + let shell = shellsByEnvironmentID[environmentID] else { + return + } + await emitSnapshot(shell, client: client, expectedGeneration: environmentGeneration) + } + + private func removeCachedApproval(id: String, threadID: String) { + guard var detail = latestDetails[threadID] else { return } + detail.approvals.removeAll { $0.id == id } + if detail.approvals.isEmpty, detail.thread.state == .waitingForApproval { + detail.thread.state = detail.userInputs.isEmpty ? .idle : .waitingForInput + } + publish(detail, threadID: threadID) + } + + private func removeCachedInput(id: String, threadID: String) { + guard var detail = latestDetails[threadID] else { return } + detail.userInputs.removeAll { $0.id == id } + if detail.userInputs.isEmpty, detail.thread.state == .waitingForInput { + detail.thread.state = detail.approvals.isEmpty ? .idle : .waitingForApproval + } + publish(detail, threadID: threadID) + } + + private func workspaceContext(route: NativeThreadRoute) throws -> ( + cwd: String, + worktreePath: String? + ) { + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }) else { + throw NativeFeatureClientError.workspaceNotFound + } + return ( + cwd: thread.worktreePath ?? project.workspaceRoot, + worktreePath: thread.worktreePath + ) + } + + private func consumeTerminalEvent( + _ event: TerminalEvent, + threadID: String, + terminalID: String + ) -> FeatureTerminalSnapshot { + let key = TerminalKey(threadID: threadID, terminalID: terminalID) + if let coreSnapshot = event.snapshot { + var snapshot = NativeWorkspaceMapper.terminal(coreSnapshot) + snapshot.threadID = threadID + snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) + snapshot.lifecycleVersion = nextTerminalLifecycleVersion() + terminalSnapshots[key] = snapshot + return snapshot + } + + var snapshot = terminalSnapshots[key] + ?? FeatureTerminalSnapshot(threadID: threadID, terminalID: terminalID) + switch event.type { + case "started", "restarted": + snapshot.state = .running + snapshot.lifecycleVersion = nextTerminalLifecycleVersion() + case "output": + snapshot.buffer.append(event.data ?? "") + snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) + case "exited": + snapshot.state = .exited + snapshot.exitCode = event.exitCode + snapshot.lifecycleVersion = nextTerminalLifecycleVersion() + case "closed": + snapshot.state = .stopped + snapshot.lifecycleVersion = nextTerminalLifecycleVersion() + case "error": + snapshot.state = .failed + snapshot.error = event.message + snapshot.lifecycleVersion = nextTerminalLifecycleVersion() + case "cleared": + snapshot.buffer = "" + case "activity": + snapshot.title = event.label ?? snapshot.title + snapshot.hasRunningSubprocess = event.hasRunningSubprocess + ?? snapshot.hasRunningSubprocess + default: + break + } + terminalSnapshots[key] = snapshot + return snapshot + } + + private func mergeTerminalSummary( + _ summary: TerminalSummary, + threadID: String + ) -> FeatureTerminalSnapshot { + let key = TerminalKey(threadID: threadID, terminalID: summary.terminalId) + var snapshot = NativeWorkspaceMapper.terminal(summary) + snapshot.threadID = threadID + if let cached = terminalSnapshots[key] { + snapshot.buffer = cached.buffer + snapshot.error = cached.error + snapshot.lifecycleVersion = cached.lifecycleVersion + } else { + snapshot.lifecycleVersion = nextTerminalLifecycleVersion() + } + terminalSnapshots[key] = snapshot + return snapshot + } + + private func nextTerminalLifecycleVersion() -> Int { + terminalLifecycleVersion += 1 + return terminalLifecycleVersion + } + + /// A verbose command can stream megabytes; the viewer only ever shows the + /// tail, so cap retained history to keep layout and memory bounded. + private static let terminalBufferLimit = 512 * 1024 + + private static func cappedTerminalBuffer(_ buffer: String) -> String { + let utf8 = buffer.utf8 + guard utf8.count > terminalBufferLimit else { return buffer } + // Slice in UTF-8 bytes (the unit the limit is defined in), then snap + // forward to a character boundary so multibyte output cannot blow + // past the cap or tear a scalar. + let byteStart = utf8.index(utf8.endIndex, offsetBy: -terminalBufferLimit) + var start = byteStart.samePosition(in: buffer) + if start == nil { + var probe = byteStart + while probe < utf8.endIndex, start == nil { + probe = utf8.index(after: probe) + start = probe.samePosition(in: buffer) + } + } + guard let start else { return buffer } + let tail = buffer[start...] + // Trim to the next line boundary so the top of the view isn't a torn line. + if let newline = tail.firstIndex(of: "\n") { + return String(tail[tail.index(after: newline)...]) + } + return String(tail) + } + + private func startPolling(_ activeClient: T3Client) { + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + let generation = environmentGeneration + pollingTask = Task { [weak self] in + while !Task.isCancelled, + self?.isCurrentSession(client: activeClient, generation: generation) == true + { + do { + await activeClient.connect() + guard + self?.isCurrentSession( + client: activeClient, + generation: generation + ) == true + else { + return + } + let sequence = self?.latestShell?.snapshotSequence + let events = await activeClient.shellEvents(after: sequence, reconnect: false) + // Re-bind self per event instead of holding it strongly across + // the indefinite stream, so the client can deinit mid-stream. + for try await item in events { + guard !Task.isCancelled, + let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) + else { + break + } + self.lastShellEventAt = .now + self.emitConnection(.connected) + switch item { + case let .snapshot(shell): + await self.consume( + shell: shell, + client: activeClient, + generation: generation, + refreshActiveThread: true + ) + case .projectUpserted, .projectRemoved, .threadUpserted, .threadRemoved: + await self.consume(delta: item, client: activeClient, generation: generation) + case .refreshRequired: + if let shell = try? await activeClient.shellSnapshot() { + await self.consume( + shell: shell, + client: activeClient, + generation: generation, + refreshActiveThread: true + ) + } + case .synchronized: + break + } + } + } catch is CancellationError { + return + } catch { + // The independent HTTP fallback below keeps the workspace + // fresh while the socket reconnects. + } + + guard !Task.isCancelled, + let self, + self.isCurrentSession(client: activeClient, generation: generation) + else { + return + } + self.lastShellEventAt = nil + self.emitConnection( + .reconnecting, + detail: "Live updates paused. Refreshing over HTTP." + ) + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + } + } + let fallbackPollingInitialDelay = fallbackPollingInitialDelay + let fallbackPollingInterval = fallbackPollingInterval + fallbackPollingTask = Task { [weak self] in + do { + try await Task.sleep(for: fallbackPollingInitialDelay) + } catch { + return + } + while !Task.isCancelled { + guard let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + let socketIsSynchronized = + await activeClient.liveConnectionActive() + && self.lastShellEventAt != nil + if !socketIsSynchronized { + self.emitConnection( + .reconnecting, + detail: "Live updates reconnecting. Refreshing over HTTP." + ) + do { + let shell = try await activeClient.shellSnapshot() + guard !Task.isCancelled, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + await self.consumeFallbackShell( + shell: shell, + client: activeClient, + generation: generation + ) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + self.emitConnection( + .reconnecting, + detail: "Server unreachable. Retrying automatically." + ) + } + } + do { + try await Task.sleep(for: fallbackPollingInterval) + } catch { + return + } + } + } + configurationTask = Task { [weak self] in + do { + for try await event in await activeClient.serverConfigEvents() { + guard !Task.isCancelled, + let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + break + } + switch event { + case let .snapshot(config): + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + case let .providerStatuses(providers): + let previous = self.serverConfigsByEnvironmentID[ + activeClient.environment.id + ] + let config = ServerConfigSnapshot( + providers: providers, + settings: previous?.settings, + threadSnapshotPagination: previous?.threadSnapshotPagination, + threadResumeCompletionMarker: previous?.threadResumeCompletionMarker, + environment: previous?.environment + ?? self.latestServerConfig?.environment, + usageLimitSources: previous?.usageLimitSources + ?? self.latestServerConfig?.usageLimitSources ?? [] + ) + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + case let .settingsUpdated(settings): + let previous = self.serverConfigsByEnvironmentID[ + activeClient.environment.id + ] + let providers = previous?.providers + ?? self.latestServerConfig?.providers ?? [] + let config = ServerConfigSnapshot( + providers: providers, + settings: settings, + threadSnapshotPagination: previous?.threadSnapshotPagination + ?? self.latestServerConfig?.threadSnapshotPagination, + threadResumeCompletionMarker: previous?.threadResumeCompletionMarker + ?? self.latestServerConfig?.threadResumeCompletionMarker, + environment: previous?.environment + ?? self.latestServerConfig?.environment, + usageLimitSources: previous?.usageLimitSources + ?? self.latestServerConfig?.usageLimitSources ?? [] + ) + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + case let .usageLimitSourcesUpdated(sources): + guard let previous = self.serverConfigsByEnvironmentID[activeClient.environment.id] + ?? self.latestServerConfig else { continue } + let config = ServerConfigSnapshot( + providers: previous.providers, + settings: previous.settings, + threadSnapshotPagination: previous.threadSnapshotPagination, + threadResumeCompletionMarker: previous.threadResumeCompletionMarker, + environment: previous.environment, + usageLimitSources: sources + ) + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + // Limits have their own subscription. A quota update + // does not change home rows or the model catalog. + continue + case .unrelated: + continue + } + if let shell = self.latestShell { + await self.emitSnapshot( + shell, client: activeClient, expectedGeneration: generation + ) + } + } + } catch is CancellationError { + return + } catch { + // The shell and thread streams remain useful on older servers + // that do not expose the provider catalogue subscription. + } + } + } + + /// Non-active environments do not hold WebSocket subscriptions. A quiet + /// HTTP refresh keeps their home rows and reachability useful without + /// multiplying live streams or creating a high-frequency battery cost. + private func startAggregateRefresh(_ activeClient: T3Client) { + aggregateRefreshTask?.cancel() + let generation = environmentGeneration + let refreshID = UUID() + let fastInterval = aggregateRefreshInterval + let idleInterval = aggregateIdleRefreshInterval + let failureInterval = aggregateFailureRefreshInterval + let sleep = aggregateRefreshSleep + let loadEnvironments = aggregateEnvironmentLoader + aggregateRefreshID = refreshID + aggregateRefreshTask = Task { [weak self] in + var nextInterval = fastInterval + var failureBackoffs: [String: Duration] = [:] + while !Task.isCancelled { + let elapsedInterval = nextInterval + do { + try await sleep(nextInterval) + } catch { + return + } + guard let self, + self.aggregateRefreshID == refreshID, + self.isCurrentSession( + client: activeClient, + generation: generation + ), + let activeEnvironment = self.activeEnvironment else { + return + } + let environments: [Environment] + do { + environments = try await loadEnvironments(self.runtime) + } catch is CancellationError where Task.isCancelled { + return + } catch { + // Persistence can be briefly unavailable while another + // actor atomically replaces the environment document. + // Back off while keeping the loop alive for recovery. + nextInterval = failureInterval + continue + } + guard !Task.isCancelled, + self.aggregateRefreshID == refreshID, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + let passiveEnvironments = environments.filter { + $0.isEnabled && $0.id != activeEnvironment.id + } + guard !passiveEnvironments.isEmpty else { + nextInterval = idleInterval + continue + } + let passiveIDs = Set(passiveEnvironments.map(\.id)) + failureBackoffs = failureBackoffs.reduce(into: [:]) { result, entry in + guard passiveIDs.contains(entry.key) else { return } + result[entry.key] = max(.zero, entry.value - elapsedInterval) + } + let refreshableEnvironments = passiveEnvironments.filter { + failureBackoffs[$0.id, default: .zero] <= .zero + } + guard !refreshableEnvironments.isEmpty else { + nextInterval = fastInterval + continue + } + let loads = await self.loadEnvironmentShells(refreshableEnvironments) + guard !Task.isCancelled, + self.aggregateRefreshID == refreshID, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + let shellsChanged = loads.contains { load in + guard let shell = load.shell else { return false } + return shell != self.shellsByEnvironmentID[load.environment.id] + } + let hasActiveWork = loads.contains { load in + load.shell.map(Self.shellNeedsFrequentAggregateRefresh) == true + } + for load in loads { + if load.shell == nil { + failureBackoffs[load.environment.id] = failureInterval + } else { + failureBackoffs[load.environment.id] = nil + } + } + self.reconcileEnvironmentLoads(loads, savedEnvironments: environments) + let currentConnection = self.latestSnapshot?.connection + ?? FeatureConnection( + state: .disconnected, + environmentName: activeEnvironment.label, + endpoint: activeEnvironment.httpBaseURL.absoluteString + ) + let snapshot = self.makeSnapshot( + environments: environments, + activeEnvironment: activeEnvironment, + connectionState: currentConnection.state, + connectionDetail: currentConnection.detail + ) + self.publish(snapshot) + if shellsChanged || hasActiveWork { + nextInterval = fastInterval + } else { + nextInterval = idleInterval + } + } + } + } + + nonisolated private static func shellNeedsFrequentAggregateRefresh( + _ shell: OrchestrationShellSnapshot + ) -> Bool { + shell.threads.contains { thread in + thread.session?.status == "starting" + || thread.session?.status == "running" + || thread.latestTurn?.state == "running" + || thread.hasPendingApprovals + || thread.hasPendingUserInput + || thread.backgroundLiveness == .working + || thread.backgroundLiveness == .monitoring + } + } + + private func consume( + shell: OrchestrationShellSnapshot, + client: T3Client, + generation: Int, + refreshActiveThread: Bool + ) async { + guard !Task.isCancelled, + isCurrentSession(client: client, generation: generation), + shell.snapshotSequence >= (latestShell?.snapshotSequence ?? .min) else { + return + } + shellPublishTask?.cancel() + shellPublishTask = nil + latestShell = shell + shellsByEnvironmentID[client.environment.id] = shell + await emitSnapshot(shell, client: client, expectedGeneration: generation) + guard isCurrentSession(client: client, generation: generation) else { return } + if refreshActiveThread, let threadID = activeThreadID { + scheduleDetailRefresh(threadID: threadID, client: client) + } + } + + /// HTTP fallback refreshes data while preserving the socket's reconnecting + /// state. The generation travels through the awaited snapshot publish so a + /// task from a previous environment session cannot publish late results. + private func consumeFallbackShell( + shell: OrchestrationShellSnapshot, + client: T3Client, + generation: Int + ) async { + guard isCurrentSession(client: client, generation: generation), + shell.snapshotSequence >= (latestShell?.snapshotSequence ?? .min) else { + return + } + shellPublishTask?.cancel() + shellPublishTask = nil + latestShell = shell + shellsByEnvironmentID[client.environment.id] = shell + await emitSnapshot( + shell, + client: client, + expectedGeneration: generation, + markSourceConnected: false + ) + guard isCurrentSession(client: client, generation: generation), + let threadID = activeThreadID else { + return + } + scheduleDetailRefresh(threadID: threadID, client: client) + } + + private func consume(delta: ShellStreamItem, client: T3Client, generation: Int) async { + guard !Task.isCancelled, + isCurrentSession(client: client, generation: generation) else { return } + guard let current = latestShell else { + if let shell = try? await client.shellSnapshot() { + await consume( + shell: shell, client: client, generation: generation, refreshActiveThread: true + ) + } + return + } + + let sequence: Int + + switch delta { + case let .projectUpserted(nextSequence, _): + sequence = nextSequence + case let .projectRemoved(nextSequence, _): + sequence = nextSequence + case let .threadUpserted(nextSequence, _): + sequence = nextSequence + case let .threadRemoved(nextSequence, _): + sequence = nextSequence + case .snapshot, .synchronized, .refreshRequired: + return + } + + // Replayed deltas are expected after reconnect. They must be entirely + // side-effect free, including for cached detail and selection state. + guard sequence > current.snapshotSequence else { return } + + var projects = current.projects + var threads = current.threads + var changedThreadID: String? + var shouldRefreshArchived = false + + switch delta { + case let .projectUpserted(_, project): + if let index = projects.firstIndex(where: { $0.id == project.id }) { + projects[index] = project + } else { + projects.append(project) + } + case let .projectRemoved(_, projectID): + projects.removeAll { $0.id == projectID } + case let .threadUpserted(_, thread): + changedThreadID = FeatureScopedID.thread( + environmentID: client.environment.id, wireID: thread.id + ) + archivedThreadsByEnvironmentID[client.environment.id]?.removeAll { + ($0.wireID ?? $0.id) == thread.id + } + if let index = threads.firstIndex(where: { $0.id == thread.id }) { + threads[index] = thread + } else { + threads.append(thread) + } + case let .threadRemoved(_, threadID): + let uiThreadID = FeatureScopedID.thread( + environmentID: client.environment.id, wireID: threadID + ) + changedThreadID = uiThreadID + shouldRefreshArchived = true + threads.removeAll { $0.id == threadID } + latestDetails[uiThreadID] = nil + detailRenderCaches[uiThreadID] = nil + detailCacheRecency.removeAll { $0 == uiThreadID } + if activeThreadID == uiThreadID { + resetDetailRefresh() + resetDetailStream() + activeThreadID = nil + activeThreadEnvironmentID = nil + activeRawThread = nil + activeThreadSequence = nil + activeThreadPage = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + } + case .snapshot, .synchronized, .refreshRequired: + return + } + + let shell = OrchestrationShellSnapshot( + snapshotSequence: sequence, + projects: projects, + threads: threads, + updatedAt: current.updatedAt + ) + latestShell = shell + // Keep the source cache current during the coalesced UI publish. A + // concurrent config or HTTP refresh must not restore an older shell. + shellsByEnvironmentID[client.environment.id] = shell + scheduleShellPublish(client) + if shouldRefreshArchived { + scheduleArchivedRefresh(client: client, environment: client.environment) + } + if let changedThreadID, activeThreadID == changedThreadID { + scheduleDetailRefresh(threadID: changedThreadID, client: client) + } + } + + /// Shell streams can emit many metadata updates during one provider turn. + /// Home only needs the newest row state, so publish at most four times per + /// second while the selected transcript continues on its dedicated stream. + private func scheduleShellPublish(_ client: T3Client) { + guard shellPublishTask == nil else { return } + let generation = environmentGeneration + shellPublishTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard let self else { return } + guard !Task.isCancelled, + self.isCurrentSession(client: client, generation: generation), + let shell = self.latestShell else { + return + } + self.shellPublishTask = nil + await self.emitSnapshot(shell, client: client, expectedGeneration: generation) + } + } + + private func scheduleDetailRefresh( + threadID: String, + client: T3Client, + force: Bool = false + ) { + guard activeThreadID == threadID, + activeThreadEnvironmentID == client.environment.id else { return } + guard force || detailStreamTask == nil else { return } + if force { + detailWasSynchronized = false + // This required read owns recovery now. An older fallback must not + // replace its loading state with an error from a stale snapshot. + detailCatchUpTask?.cancel() + detailCatchUpTask = nil + detailCatchUpID = nil + continuation.yield(.threadSync(id: threadID, state: .catchingUp)) + } + guard detailRefreshTask == nil else { + detailRefreshPending = true + return + } + detailRefreshPending = false + detailRefreshGeneration &+= 1 + let generation = detailRefreshGeneration + let sessionGeneration = environmentGeneration + detailRefreshTask = Task { [weak self] in + do { + // Shell updates can be coalesced. A required replacement cannot + // apply more thread events until its snapshot arrives. + if !force { + try await Task.sleep(for: .milliseconds(250)) + } + } catch { + self?.finishDetailRefresh(generation: generation, client: client) + return + } + guard let self else { return } + if !Task.isCancelled, + self.activeThreadID == threadID, + self.isKnownClient( + client, + environmentID: client.environment.id, + generation: sessionGeneration + ) { + do { + try await self.refreshThread(id: threadID, client: client) + } catch is CancellationError { + // Closing a thread cancels its read without changing its status. + } catch { + if !Task.isCancelled, + self.detailRefreshGeneration == generation, + self.activeThreadID == threadID, + self.activeRawThread == nil || self.detailStreamTask == nil { + self.continuation.yield(.threadSync( + id: threadID, state: .failed(error.localizedDescription) + )) + } + } + } + self.finishDetailRefresh(generation: generation, client: client) + } + } + + private func startDetailStream(_ route: NativeThreadRoute, warmConnectionID: UUID? = nil) { + detailStreamGeneration &+= 1 + detailCompletionReceived = false + detailWasSynchronized = warmConnectionID != nil + activeDetailConnectionID = warmConnectionID + let streamGeneration = detailStreamGeneration + let sessionGeneration = environmentGeneration + continuation.yield(.threadSync( + id: route.uiID, state: warmConnectionID != nil ? .live : .catchingUp + )) + ensureDetailCatchUpFallback(route, generation: streamGeneration) + let retryDelay = threadRetryDelay + detailStreamTask = Task { [weak self] in + var failedAttempts = 0 + var recoveringFromFailure = false + while !Task.isCancelled, + self?.isCurrentDetail(route, generation: streamGeneration) == true, + self?.environmentGeneration == sessionGeneration { + // The next connection resumes from applied state, not from + // the cursor captured when the user first opened the thread. + let sequence = self?.activeRawThread == nil ? nil : self?.activeThreadSequence + let supportsPagination = self?.serverConfigsByEnvironmentID[ + route.environmentID + ]?.threadSnapshotPagination == true + let subscriptionEpoch = self?.threadHistoryEpoch ?? 0 + var failedConnectionID: UUID? + do { + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: streamGeneration) == true, + self?.environmentGeneration == sessionGeneration else { return } + let subscription = try await route.client.threadEvents( + threadID: route.wireID, + after: sequence, + turnLimit: supportsPagination ? Self.initialThreadUserTurnLimit : nil + ) + let subscriptionConnectionID = subscription.connectionID + failedConnectionID = subscriptionConnectionID + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: streamGeneration) == true, + self?.environmentGeneration == sessionGeneration else { return } + if self?.detailWasSynchronized == true, + subscriptionConnectionID != self?.activeDetailConnectionID { + self?.detailWasSynchronized = false + self?.continuation.yield(.threadSync(id: route.uiID, state: .catchingUp)) + } + self?.activeDetailConnectionID = subscriptionConnectionID + for try await item in subscription.events { + if case .synchronized = item { + let connectionID = await route.client.currentConnectionID() + guard !Task.isCancelled, let self, + self.isCurrentDetail(route, generation: streamGeneration), + self.environmentGeneration == sessionGeneration else { return } + if connectionID != subscriptionConnectionID { + self.activeDetailConnectionID = nil + } + } + guard !Task.isCancelled, let self, + self.isCurrentDetail(route, generation: streamGeneration), + self.environmentGeneration == sessionGeneration else { return } + failedAttempts = 0 + if recoveringFromFailure { + recoveringFromFailure = false + self.continuation.yield(.threadSync(id: route.uiID, state: .catchingUp)) + self.ensureDetailCatchUpFallback(route, generation: streamGeneration) + } + self.consumeDetailStreamItem( + item, route: route, subscriptionEpoch: subscriptionEpoch + ) + } + // A thread subscription stays open until its owner leaves. + // A clean end is not proof that the thread is still live. + throw RPCError.protocolViolation("The live thread stream ended.") + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: streamGeneration) == true else { return } + if Self.isTerminalThreadStreamFailure(error) { + self?.failDetailStream(route, message: "Could not synchronize the thread. Try again.") + do { + _ = try await route.client.waitForConnection(after: failedConnectionID) + } catch { return } + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: streamGeneration) == true else { return } + self?.continuation.yield(.threadSync(id: route.uiID, state: .catchingUp)) + self?.ensureDetailCatchUpFallback(route, generation: streamGeneration) + continue + } + switch error { + case RPCError.disconnected, RPCError.connectionUnavailable: + break + default: + self?.failDetailStream(route, message: error.localizedDescription) + recoveringFromFailure = true + failedAttempts = min(6, failedAttempts + 1) + do { try await retryDelay(failedAttempts) } + catch { return } + continue + } + } + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: streamGeneration) == true else { return } + self?.detailCompletionReceived = false + self?.detailWasSynchronized = false + self?.activeDetailConnectionID = nil + self?.continuation.yield(.threadSync(id: route.uiID, state: .reconnecting)) + self?.ensureDetailCatchUpFallback(route, generation: streamGeneration) + do { + _ = try await route.client.waitForConnection(after: failedConnectionID) + } + catch { return } + } + } + } + + private static func isTerminalThreadStreamFailure(_ error: any Error) -> Bool { + if error is DecodingError { return true } + if case RPCError.protocolViolation = error { return true } + return false + } + + private func failDetailStream(_ route: NativeThreadRoute, message: String) { + // Drain applied updates before retaining the diagnostic. An older HTTP + // read must not replace this failure with an unrelated loading state. + flushDetailPublish(route) + resetDetailRefresh() + detailCatchUpTask?.cancel() + detailCatchUpTask = nil + detailCatchUpID = nil + detailCompletionReceived = false + detailWasSynchronized = false + activeDetailConnectionID = nil + continuation.yield(.threadSync(id: route.uiID, state: .failed(message))) + } + + private func isCurrentDetail(_ route: NativeThreadRoute, generation: Int) -> Bool { + detailStreamGeneration == generation + && activeThreadID == route.uiID + && environmentClients[route.environmentID] === route.client + } + + private func ensureDetailCatchUpFallback(_ route: NativeThreadRoute, generation: Int) { + guard detailCatchUpTask == nil, detailRefreshTask == nil else { return } + let id = UUID() + detailCatchUpID = id + let delay = catchUpDelay + detailCatchUpTask = Task { [weak self] in + defer { + if self?.detailCatchUpID == id { + self?.detailCatchUpTask = nil + self?.detailCatchUpID = nil + } + } + do { + try await delay() + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: generation) == true else { return } + try await self?.refreshThread( + id: route.uiID, + client: route.client, + expectedStreamGeneration: generation + ) + guard !Task.isCancelled, let self, + self.isCurrentDetail(route, generation: generation), + self.activeRawThread != nil, + !self.detailRefreshPending else { return } + if self.serverConfigsByEnvironmentID[route.environmentID]? + .threadResumeCompletionMarker == true { + self.continuation.yield(.threadSync(id: route.uiID, state: .reconnecting)) + } else { + self.markDetailSynchronized(route) + } + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, + self?.isCurrentDetail(route, generation: generation) == true else { return } + self?.continuation.yield(.threadSync(id: route.uiID, state: .failed(error.localizedDescription))) + } + } + } + + private func markDetailSynchronized(_ route: NativeThreadRoute) { + guard activeRawThread != nil, !detailRefreshPending else { return } + // Flush the final message before publishing the completion state. + // Otherwise the loading label can vanish one render before the text. + flushDetailPublish(route) + detailCatchUpTask?.cancel() + detailCatchUpTask = nil + detailCatchUpID = nil + detailWasSynchronized = true + continuation.yield(.threadSync(id: route.uiID, state: .live)) + } + + private func beginWarmReplayIfNeeded(_ route: NativeThreadRoute) { + guard detailWasSynchronized, !detailCompletionReceived, + serverConfigsByEnvironmentID[route.environmentID]?.threadResumeCompletionMarker == true else { return } + detailWasSynchronized = false + continuation.yield(.threadSync(id: route.uiID, state: .catchingUp)) + ensureDetailCatchUpFallback(route, generation: detailStreamGeneration) + } + + private func consumeDetailStreamItem( + _ item: ThreadStreamItem, + route: NativeThreadRoute, + subscriptionEpoch: Int + ) { + switch item { + case .synchronized: + detailCompletionReceived = true + guard activeRawThread != nil else { return } + markDetailSynchronized(route) + return + case let .snapshot(snapshot): + // A cursor-less event cannot prove that an already-requested + // snapshot includes it. Keep the post-event read until it does. + if let requiredEpoch = detailSnapshotRequiredAfterEpoch, + subscriptionEpoch < requiredEpoch { return } + guard snapshot.snapshotSequence >= (activeThreadSequence ?? 0), + activeRawThread == nil || snapshot.snapshotSequence > (activeThreadSequence ?? 0) else { return } + beginWarmReplayIfNeeded(route) + resetDetailRefresh() + detailSnapshotRequiredAfterEpoch = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeThreadSequence = snapshot.snapshotSequence + activeRawThread = snapshot.thread + activeThreadPage = featurePage(snapshot.page) + scheduleRawDetailPublish(route: route, mutation: .full) + if detailCompletionReceived { markDetailSynchronized(route) } + case let .event(event): + guard let current = activeRawThread else { + // Do not apply later events to a snapshot that missed earlier + // ones. It must cover every event skipped while replacing it. + if case let .number(value) = event["sequence"], + let sequence = Int(exactly: value), sequence >= 0 { + activeThreadSequence = max(activeThreadSequence ?? 0, sequence) + } else { + // An event without a cursor needs a read started after it. + threadHistoryEpoch &+= 1 + detailSnapshotRequiredAfterEpoch = threadHistoryEpoch + pendingOlderThreadPage = nil + } + scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true) + return + } + let reduction = NativeThreadDetailReducer.apply(event, to: current) + if reduction.sequence < 0 { + threadHistoryEpoch &+= 1 + detailSnapshotRequiredAfterEpoch = threadHistoryEpoch + pendingOlderThreadPage = nil + activeRawThread = nil + discardPendingDetailPublish() + scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true) + return + } + guard reduction.sequence > (activeThreadSequence ?? 0) else { return } + beginWarmReplayIfNeeded(route) + switch reduction.result { + case let .updated(thread): + activeThreadSequence = reduction.sequence + activeRawThread = thread + scheduleRawDetailPublish(route: route, mutation: reduction.renderMutation) + tryMergePendingOlderThreadPage(route: route) + case .unchanged: + activeThreadSequence = reduction.sequence + tryMergePendingOlderThreadPage(route: route) + case .refresh: + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeThreadSequence = reduction.sequence + activeRawThread = nil + discardPendingDetailPublish() + scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true) + } + } + if serverConfigsByEnvironmentID[route.environmentID]?.threadResumeCompletionMarker != true { + markDetailSynchronized(route) + } + } + + private func scheduleRawDetailPublish( + route: NativeThreadRoute, + mutation: NativeDetailRenderMutation + ) { + pendingDetailRenderMutations.formUnion(mutation) + guard detailPublishTask == nil else { return } + let streamGeneration = detailStreamGeneration + detailPublishTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(80)) + guard let self else { return } + guard !Task.isCancelled, + self.detailStreamGeneration == streamGeneration, + self.activeThreadID == route.uiID, + self.activeRawThread != nil else { + return + } + self.detailPublishTask = nil + self.flushDetailPublish(route) + } + } + + private func flushDetailPublish(_ route: NativeThreadRoute) { + guard pendingDetailRenderMutations.hasUpdates, + activeThreadID == route.uiID, let rawThread = activeRawThread else { return } + detailPublishTask?.cancel() + detailPublishTask = nil + let mutations = pendingDetailRenderMutations + pendingDetailRenderMutations = NativeDetailRenderMutations() + let previousDetail = latestDetails[route.uiID] + let detail = mapDetail( + rawThread, environment: route.client.environment, + sourceSequence: activeThreadSequence ?? 0, mutations: mutations, + page: activeThreadPage + ) + let delta = makeDetailDelta(previous: previousDetail, next: detail, mutations: mutations) + publish(detail, threadID: route.uiID, renderCacheIsSource: true, delta: delta) + } + + private func retainActiveThread() { + guard let id = activeThreadID, let route = try? threadRoute(for: id) else { return } + guard let raw = activeRawThread, let sequence = activeThreadSequence else { + threadResumeStates[id] = nil + return + } + flushDetailPublish(route) + var page = activeThreadPage + page?.isLoading = false + threadResumeStates[id] = NativeThreadResumeState( + client: route.client, thread: raw, sequence: sequence, page: page, + wasSynchronized: detailWasSynchronized, + connectionID: activeDetailConnectionID + ) + } + + private func finishDetailRefresh(generation: Int, client: T3Client) { + guard detailRefreshGeneration == generation else { return } + detailRefreshTask = nil + let needsTrailingRefresh = detailRefreshPending + detailRefreshPending = false + if needsTrailingRefresh, let threadID = activeThreadID { + // Events received without a base snapshot cannot be reduced. Read + // again even when the stream is open so those events are included. + scheduleDetailRefresh(threadID: threadID, client: client, force: true) + } + } + + private func resetDetailRefresh() { + detailRefreshGeneration &+= 1 + detailRefreshTask?.cancel() + detailRefreshTask = nil + detailRefreshPending = false + } + + private func resetDetailStream() { + detailStreamGeneration &+= 1 + detailCompletionReceived = false + detailWasSynchronized = false + activeDetailConnectionID = nil + detailSnapshotRequiredAfterEpoch = nil + detailStreamTask?.cancel() + detailStreamTask = nil + detailCatchUpTask?.cancel() + detailCatchUpTask = nil + detailCatchUpID = nil + discardPendingDetailPublish() + } + + private func discardPendingDetailPublish() { + detailPublishTask?.cancel() + detailPublishTask = nil + pendingDetailRenderMutations = NativeDetailRenderMutations() + } + + private func loadEnvironmentShells( + _ environments: [Environment] + ) async -> [EnvironmentShellLoad] { + let activeEnvironmentID = activeEnvironment?.id + let environmentsWithCachedConfig = Set(serverConfigsByEnvironmentID.keys) + let shellTimeoutInterval = environmentShellTimeoutInterval + let runtime = runtime + var clients: [(environment: Environment, client: T3Client)] = [] + clients.reserveCapacity(environments.count) + for environment in environments { + clients.append( + (environment, await runtime.client(for: environment)) + ) + } + + return await withTaskGroup(of: EnvironmentShellLoad.self) { group in + for pair in clients { + group.addTask { + let shell = try? await pair.client.shellSnapshot( + timeoutInterval: shellTimeoutInterval + ) + guard shell != nil else { + return EnvironmentShellLoad( + environment: pair.environment, + client: pair.client, + shell: nil, + config: nil + ) + } + + let isActive = pair.environment.id == activeEnvironmentID + let shouldFetchConfig = isActive + || !environmentsWithCachedConfig.contains(pair.environment.id) + var config: ServerConfigSnapshot? + if shouldFetchConfig { + if isActive { + config = try? await pair.client.serverConfig() + } else { + // A passive catalogue is a bounded one-shot RPC on + // an uncached client. Never disconnect the shared + // client because the environment may become active + // while this aggregate load is in flight. + let probe = await runtime.ephemeralClient( + for: pair.environment + ) + config = try? await probe.serverConfig() + await probe.disconnect() + } + } + return EnvironmentShellLoad( + environment: pair.environment, + client: pair.client, + shell: shell, + config: config + ) + } + } + var loads: [EnvironmentShellLoad] = [] + loads.reserveCapacity(environments.count) + for await load in group { + loads.append(load) + } + return loads + } + } + + /// Successful reads replace that environment's cache. Failed reads leave + /// its last-known rows intact, so one offline machine cannot empty home. + private func reconcileEnvironmentLoads( + _ loads: [EnvironmentShellLoad], + savedEnvironments: [Environment] + ) { + let savedIDs = Set(savedEnvironments.map(\.id)) + environmentClients = environmentClients.filter { savedIDs.contains($0.key) } + shellsByEnvironmentID = shellsByEnvironmentID.filter { savedIDs.contains($0.key) } + shellProjectionCache = shellProjectionCache.filter { savedIDs.contains($0.key) } + serverConfigsByEnvironmentID = serverConfigsByEnvironmentID.filter { + savedIDs.contains($0.key) + } + providerCatalogCache = providerCatalogCache.filter { + savedIDs.contains($0.key) + } + archivedThreadsByEnvironmentID = archivedThreadsByEnvironmentID.filter { + savedIDs.contains($0.key) + } + archivedShellThreadsByEnvironmentID = archivedShellThreadsByEnvironmentID.filter { + savedIDs.contains($0.key) + } + environmentConnectionStates = environmentConnectionStates.filter { + savedIDs.contains($0.key) + } + environmentConnectionDetails = environmentConnectionDetails.filter { + savedIDs.contains($0.key) + } + + for load in loads { + environmentClients[load.environment.id] = load.client + if let config = load.config { + setServerConfig(config, environmentID: load.environment.id) + if load.environment.id == activeEnvironment?.id { + latestServerConfig = config + } + } + if let shell = load.shell { + if shell.snapshotSequence + >= (shellsByEnvironmentID[load.environment.id]?.snapshotSequence ?? .min) { + shellsByEnvironmentID[load.environment.id] = shell + } + environmentConnectionStates[load.environment.id] = .connected + environmentConnectionDetails[load.environment.id] = nil + } else { + environmentConnectionStates[load.environment.id] = .disconnected + environmentConnectionDetails[load.environment.id] = + "That server is currently unreachable." + } + } + rebuildEntityIndexes(savedEnvironments) + } + + private func newestShell( + _ candidate: OrchestrationShellSnapshot, + for environment: Environment + ) -> OrchestrationShellSnapshot { + let latest: OrchestrationShellSnapshot + if let cached = shellsByEnvironmentID[environment.id], + cached.snapshotSequence > candidate.snapshotSequence { + latest = cached + } else { + latest = candidate + shellsByEnvironmentID[environment.id] = candidate + } + if activeEnvironment?.id == environment.id { + latestShell = latest + } + return latest + } + + private func rebuildEntityIndexes(_ environments: [Environment]) { + let savedIDs = Set(environments.map(\.id)) + provisionalThreadRoutes = provisionalThreadRoutes.filter { + savedIDs.contains($0.value.environmentID) + } + + // Metadata changes do not change routing. Avoid rebuilding scoped IDs + // and ambiguity sets for every title, activity, or settlement update. + let membership = environments.map { environment in + NativeShellMembership( + environmentID: environment.id, + projectIDs: shellsByEnvironmentID[environment.id]?.projects.map(\.id) ?? [], + threadIDs: shellsByEnvironmentID[environment.id]?.threads.map(\.id) ?? [], + archivedIDs: archivedThreadsByEnvironmentID[environment.id]?.map { + $0.wireID ?? $0.id + } ?? [] + ) + } + guard membership != indexedShellMembership + || provisionalThreadRoutes != indexedProvisionalRoutes else { return } + + var nextProjectEnvironments: [String: String] = [:] + var nextProjectWireIDs: [String: String] = [:] + var nextThreadEnvironments: [String: String] = [:] + var nextThreadWireIDs: [String: String] = [:] + var projectCandidates: [String: Set] = [:] + var threadCandidates: [String: Set] = [:] + var materializedThreadIDs: Set = [] + + for environment in environments { + let environmentID = environment.id + for project in shellsByEnvironmentID[environmentID]?.projects ?? [] { + let uiID = FeatureScopedID.project( + environmentID: environmentID, + wireID: project.id + ) + nextProjectEnvironments[uiID] = environmentID + nextProjectWireIDs[uiID] = project.id + projectCandidates[project.id, default: []].insert( + EntityWireOwner(environmentID: environmentID, wireID: project.id) + ) + } + for thread in shellsByEnvironmentID[environmentID]?.threads ?? [] { + let uiID = FeatureScopedID.thread( + environmentID: environmentID, + wireID: thread.id + ) + nextThreadEnvironments[uiID] = environmentID + nextThreadWireIDs[uiID] = thread.id + materializedThreadIDs.insert(uiID) + threadCandidates[thread.id, default: []].insert( + EntityWireOwner(environmentID: environmentID, wireID: thread.id) + ) + } + for thread in archivedThreadsByEnvironmentID[environmentID] ?? [] { + let wireID = thread.wireID ?? thread.id + let uiID = FeatureScopedID.thread( + environmentID: environmentID, + wireID: wireID + ) + nextThreadEnvironments[uiID] = environmentID + nextThreadWireIDs[uiID] = wireID + materializedThreadIDs.insert(uiID) + threadCandidates[wireID, default: []].insert( + EntityWireOwner(environmentID: environmentID, wireID: wireID) + ) + } + } + + provisionalThreadRoutes = provisionalThreadRoutes.filter { + !materializedThreadIDs.contains($0.key) + } + for (uiID, provisional) in provisionalThreadRoutes { + nextThreadEnvironments[uiID] = provisional.environmentID + nextThreadWireIDs[uiID] = provisional.wireID + threadCandidates[provisional.wireID, default: []].insert( + EntityWireOwner( + environmentID: provisional.environmentID, + wireID: provisional.wireID + ) + ) + } + + // Raw IDs remain accepted for source-compatible fixtures only when + // their owner is unambiguous. Native snapshots always use scoped IDs. + for (rawID, candidates) in projectCandidates where candidates.count == 1 { + guard let owner = candidates.first else { continue } + nextProjectEnvironments[rawID] = owner.environmentID + nextProjectWireIDs[rawID] = owner.wireID + } + for (rawID, candidates) in threadCandidates where candidates.count == 1 { + guard let owner = candidates.first else { continue } + nextThreadEnvironments[rawID] = owner.environmentID + nextThreadWireIDs[rawID] = owner.wireID + } + + projectEnvironmentIDs = nextProjectEnvironments + projectWireIDs = nextProjectWireIDs + threadEnvironmentIDs = nextThreadEnvironments + threadWireIDs = nextThreadWireIDs + indexedShellMembership = membership + indexedProvisionalRoutes = provisionalThreadRoutes + } + + private func refresh(client: T3Client, includeArchived: Bool = false) async throws { + let environment = client.environment + let generation = environmentGeneration + let shell = try await client.shellSnapshot() + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + guard shell.snapshotSequence + >= (shellsByEnvironmentID[environment.id]?.snapshotSequence ?? .min) else { + return + } + shellsByEnvironmentID[environment.id] = shell + if activeEnvironment?.id == environment.id { + latestShell = shell + } + if includeArchived, + let archivedShell = try? await client.archivedShellSnapshot(), + isKnownClient(client, environmentID: environment.id, generation: generation) { + archivedThreadsByEnvironmentID[environment.id] = archivedShell.threads.map { + mapThread($0, environment: environment) + } + archivedShellThreadsByEnvironmentID[environment.id] = Dictionary( + uniqueKeysWithValues: archivedShell.threads.map { ($0.id, $0) } + ) + } + await emitSnapshot(shell, client: client, expectedGeneration: generation) + } + + private func scheduleArchivedRefresh(client: T3Client, environment: Environment) { + archivedRefreshTask?.cancel() + let generation = environmentGeneration + archivedRefreshTask = Task { [weak self] in + guard let self, + let archivedShell = try? await client.archivedShellSnapshot(), + !Task.isCancelled, + self.isCurrentSession(client: client, generation: generation) else { + return + } + self.archivedThreadsByEnvironmentID[environment.id] = archivedShell.threads.map { + self.mapThread($0, environment: environment) + } + self.archivedShellThreadsByEnvironmentID[environment.id] = Dictionary( + uniqueKeysWithValues: archivedShell.threads.map { ($0.id, $0) } + ) + if let shell = self.latestShell { + await self.emitSnapshot(shell, client: client, expectedGeneration: generation) + } + } + } + + private func refreshThread( + id: String, client: T3Client, expectedStreamGeneration: Int? = nil + ) async throws { + let route = try threadRoute(for: id) + guard route.client === client else { + throw NativeFeatureClientError.threadNotFound + } + let environment = route.client.environment + let generation = environmentGeneration + let historyEpoch = threadHistoryEpoch + let supportsPagination = serverConfigsByEnvironmentID[ + environment.id + ]?.threadSnapshotPagination == true + let snapshot = try await client.threadSnapshot( + id: route.wireID, + turnLimit: supportsPagination ? Self.initialThreadUserTurnLimit : nil, + timeoutInterval: threadSnapshotTimeoutInterval + ) + guard !Task.isCancelled, + isKnownClient(client, environmentID: environment.id, generation: generation), + expectedStreamGeneration.map({ isCurrentDetail(route, generation: $0) }) ?? true else { + throw CancellationError() + } + if activeThreadID == route.uiID { + if activeRawThread == nil, historyEpoch != threadHistoryEpoch { + return + } + guard snapshot.snapshotSequence >= (activeThreadSequence ?? 0) else { + if activeRawThread == nil { + if !detailRefreshPending { + throw NativeFeatureClientError.threadSnapshotOutdated + } + } else if detailCompletionReceived + || serverConfigsByEnvironmentID[environment.id]?.threadResumeCompletionMarker != true { + markDetailSynchronized(route) + } + return + } + discardPendingDetailPublish() + // This snapshot includes the skipped events, so their pending + // request is satisfied without another HTTP read. + detailRefreshPending = false + detailSnapshotRequiredAfterEpoch = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeRawThread = snapshot.thread + activeThreadSequence = snapshot.snapshotSequence + activeThreadPage = featurePage(snapshot.page) + } else if let cached = threadResumeStates[route.uiID], + snapshot.snapshotSequence < cached.sequence { + return + } + let detail = mapDetail( + snapshot.thread, + environment: environment, + sourceSequence: snapshot.snapshotSequence, + page: activeThreadID == route.uiID ? activeThreadPage : featurePage(snapshot.page) + ) + publish(detail, threadID: route.uiID) + threadResumeStates[route.uiID] = NativeThreadResumeState( + client: client, thread: snapshot.thread, sequence: snapshot.snapshotSequence, + page: featurePage(snapshot.page), + wasSynchronized: false, + connectionID: nil + ) + if activeThreadID == route.uiID, + detailCompletionReceived + || serverConfigsByEnvironmentID[environment.id]?.threadResumeCompletionMarker != true { + markDetailSynchronized(route) + } + } + + /// Snapshots belong to the client that read them, not the selected inbox + /// connection. Keep that source and its session through the awaited read. + private func emitSnapshot( + _ shell: OrchestrationShellSnapshot, + client sourceClient: T3Client, + expectedGeneration: Int, + markSourceConnected: Bool = true + ) async { + let sourceEnvironment = sourceClient.environment + guard !Task.isCancelled, + let environment = activeEnvironment, + isKnownClient( + sourceClient, environmentID: sourceEnvironment.id, generation: expectedGeneration + ) else { return } + let environments = (try? await runtime.environments()) ?? [environment] + guard !Task.isCancelled, + isKnownClient( + sourceClient, environmentID: sourceEnvironment.id, generation: expectedGeneration + ), + activeEnvironment?.id == environment.id, + environments.contains(where: { $0.id == sourceEnvironment.id && $0.isEnabled }), + shell.snapshotSequence + >= (shellsByEnvironmentID[sourceEnvironment.id]?.snapshotSequence ?? .min) else { + return + } + shellsByEnvironmentID[sourceEnvironment.id] = shell + if markSourceConnected { + environmentConnectionStates[sourceEnvironment.id] = .connected + environmentConnectionDetails[sourceEnvironment.id] = nil + } + if sourceEnvironment.id == environment.id { + latestShell = shell + } + rebuildEntityIndexes(environments) + synchronizeActiveDetail( + with: shell, + environment: sourceEnvironment + ) + let connectionState: FeatureConnection.State + let connectionDetail: String? + if sourceEnvironment.id == environment.id, markSourceConnected { + connectionState = .connected + connectionDetail = nil + } else { + connectionState = latestSnapshot?.connection.state + ?? environmentConnectionStates[environment.id] + ?? .disconnected + connectionDetail = latestSnapshot?.connection.detail + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: connectionState, + connectionDetail: connectionDetail + ) + publish(snapshot) + } + + /// The detail stream does not carry shell-only background liveness. Merge + /// that small state directly so a settled parent turn still reads as live. + private func synchronizeActiveDetail( + with shell: OrchestrationShellSnapshot, + environment: Environment + ) { + guard activeThreadEnvironmentID == environment.id, + let threadID = activeThreadID, + let wireID = threadWireIDs[threadID], + let shellThread = shell.threads.first(where: { $0.id == wireID }), + var detail = latestDetails[threadID] else { + return + } + + let backgroundLiveness = shellThread.backgroundLiveness + let backgroundWorkIsActive = backgroundLiveness == .working + let sessionIsLive = shellThread.session?.status == "starting" + || shellThread.session?.status == "running" + detail.thread.state = Self.resolveThreadState( + latestTurn: shellThread.latestTurn, + session: shellThread.session, + hasApprovals: !detail.approvals.isEmpty, + hasUserInput: !detail.userInputs.isEmpty, + backgroundLiveness: backgroundLiveness + ) + detail.thread.workingStartedAt = workingStartedAt( + latestTurn: shellThread.latestTurn, + session: shellThread.session, + backgroundWorkIsActive: backgroundWorkIsActive, + fallbackUpdatedAt: shellThread.updatedAt + ) + if shell.snapshotSequence >= (activeThreadSequence ?? .min) { + applyShellMetadataAuthority(from: shellThread, to: &detail.thread) + if let compaction = detailRenderCaches[threadID]?.compaction { + detail.isCompacting = compaction.isActive( + sessionStatus: shellThread.session?.status, + latestTurnState: shellThread.latestTurn?.state, + latestTurnRequestedAt: (shellThread.latestTurn?.requestedAt).flatMap(parseValidDate) + ) + } + } + detail.backgroundWorkIsActive = backgroundWorkIsActive + detail.activeSubagentCount = backgroundWorkIsActive || sessionIsLive + ? detailRenderCaches[threadID]?.subagents.activeCount ?? 0 + : 0 + guard latestDetails[threadID] != detail else { return } + publish(detail, threadID: threadID, renderCacheIsSource: true) + } + + /// Thread-only shell changes stay granular so Home does not replace and + /// diff the aggregate snapshot for every active turn update. Structural + /// changes retain the canonical snapshot event as a safe fallback. + private func publish(_ snapshot: FeatureSnapshot) { + guard let previous = latestSnapshot else { + latestSnapshot = snapshot + continuation.yield(.snapshot(snapshot)) + return + } + guard previous != snapshot else { return } + latestSnapshot = snapshot + + guard canPublishThreadDelta(from: previous, to: snapshot) else { + continuation.yield(.snapshot(snapshot)) + return + } + + let previousByID = previous.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + let nextByID = snapshot.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + let removedIDs = previous.threads.compactMap { thread in + nextByID[thread.id] == nil ? thread.id : nil + } + let changedThreads = snapshot.threads.filter { previousByID[$0.id] != $0 } + + guard !removedIDs.isEmpty || !changedThreads.isEmpty else { + // A count-only project correction has no corresponding thread + // event that could reproduce it in the feature model. + continuation.yield(.snapshot(snapshot)) + return + } + for id in removedIDs { + continuation.yield(.threadRemoved(id: id)) + } + for thread in changedThreads { + continuation.yield(.thread(thread)) + } + } + + private func canPublishThreadDelta( + from previous: FeatureSnapshot, + to next: FeatureSnapshot + ) -> Bool { + previous.connection == next.connection + && previous.environments == next.environments + && previous.providers == next.providers + && previous.providersByEnvironment == next.providersByEnvironment + && previous.preferencesByEnvironment == next.preferencesByEnvironment + && previous.settings == next.settings + && projectsMatchIgnoringThreadCounts(previous.projects, next.projects) + } + + private func projectsMatchIgnoringThreadCounts( + _ lhs: [FeatureProject], + _ rhs: [FeatureProject] + ) -> Bool { + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { left, right in + left.id == right.id + && left.wireID == right.wireID + && left.environmentID == right.environmentID + && left.name == right.name + && left.path == right.path + && left.defaultSelection == right.defaultSelection + && left.repositoryIdentity == right.repositoryIdentity + && left.projectIcon == right.projectIcon + && left.createdAt == right.createdAt + && left.updatedAt == right.updatedAt + } + } + + /// Preserve the unchanged transcript prefix when a streaming update only + /// replaces the tail message. The public event remains authoritative and + /// backwards compatible for non-native FeatureClient implementations. + private func publish( + _ detail: FeatureThreadDetail, + threadID: String, + renderCacheIsSource: Bool = false, + delta: FeatureDetailDelta? = nil + ) { + if renderCacheIsSource { + // Reducer-provided mutations already updated the authoritative + // cache. Avoid a prefix comparison across the entire transcript. + latestDetails[threadID] = detail + if let delta { + continuation.yield(.detailDelta(detail, delta)) + } else { + continuation.yield(.detail(detail)) + } + return + } + let next = latestDetails[threadID].map { current in + mergedDetail(current: current, incoming: detail) + } ?? detail + guard latestDetails[threadID] != next else { return } + latestDetails[threadID] = next + if let cache = detailRenderCaches[threadID] { + cache.approvals = next.approvals + cache.userInputs = next.userInputs + } + continuation.yield(.detail(next)) + } + + private func makeDetailDelta( + previous: FeatureThreadDetail?, + next: FeatureThreadDetail, + mutations: NativeDetailRenderMutations + ) -> FeatureDetailDelta? { + guard !mutations.requiresFullRebuild, + let previous, + next.messages.count >= previous.messages.count else { + return nil + } + + var changedIDs = Set(mutations.messages.map(\.id)) + for activity in mutations.activities { + if NativeActivityNotice.accepts(activity) { + changedIDs.insert("activity-\(activity.id)") + } else if NativeWorkLogAccumulator.accepts(activity) { + changedIDs.insert("work-log-\(activity.turnId ?? "unscoped")") + } + } + + guard let cache = detailRenderCaches[next.thread.id] else { return nil } + let changedMessages = changedIDs.compactMap { id in + cache.mergedIndexByID[id].map { cache.mergedMessages[$0] } + } + let appendedCount = next.messages.count - previous.messages.count + let appendedMessageIDs = appendedCount == 0 + ? [] + : next.messages.suffix(appendedCount).map(\.id) + + // A newly rendered entity with an older timestamp can be inserted into + // history. That rare path takes one authoritative diff instead of + // applying an invalid append-only delta. + guard appendedMessageIDs.allSatisfy(changedIDs.contains) else { return nil } + return FeatureDetailDelta( + changedMessages: changedMessages, + appendedMessageIDs: appendedMessageIDs + ) + } + + private func mergedDetail( + current: FeatureThreadDetail, + incoming: FeatureThreadDetail + ) -> FeatureThreadDetail { + FeatureThreadDetail( + thread: incoming.thread, + messages: replacingChangedSuffix(current.messages, with: incoming.messages), + approvals: replacingChangedSuffix(current.approvals, with: incoming.approvals), + userInputs: replacingChangedSuffix(current.userInputs, with: incoming.userInputs), + page: incoming.page, + activeSubagentCount: incoming.activeSubagentCount, + backgroundWorkIsActive: incoming.backgroundWorkIsActive, + isCompacting: incoming.isCompacting == true + ) + } + + private func replacingChangedSuffix( + _ current: [Element], + with incoming: [Element] + ) -> [Element] { + guard current != incoming else { return current } + let prefixCount = zip(current, incoming).prefix { pair in + pair.0 == pair.1 + }.count + var result = current + result.replaceSubrange(prefixCount..., with: incoming.dropFirst(prefixCount)) + return result + } + + private func disconnectedSnapshot( + environments: [Environment], + detail: String? = nil + ) -> FeatureSnapshot { + FeatureSnapshot( + connection: .init(state: .disconnected, detail: detail), + environments: environments.map { mapEnvironment($0, activeID: nil) }, + settings: loadSettings() + ) + } + + private func emitConnection( + _ state: FeatureConnection.State, + detail: String? = nil + ) { + guard let environment = activeEnvironment else { return } + // Shell event loops call this per event; only publish real transitions. + guard environmentConnectionStates[environment.id] != state + || environmentConnectionDetails[environment.id] != detail else { return } + environmentConnectionStates[environment.id] = state + environmentConnectionDetails[environment.id] = detail + let connection = FeatureConnection( + state: state, + environmentName: environment.label, + endpoint: environment.httpBaseURL.absoluteString, + detail: detail + ) + if var snapshot = latestSnapshot { + snapshot.connection = connection + if let index = snapshot.environments.firstIndex(where: { $0.id == environment.id }) { + snapshot.environments[index].connectionState = state + snapshot.environments[index].connectionDetail = detail + } + latestSnapshot = snapshot + continuation.yield(.snapshot(snapshot)) + return + } + continuation.yield(.connection(connection)) + } + + private func makeSnapshot( + environments: [Environment], + activeEnvironment: Environment, + connectionState: FeatureConnection.State, + connectionDetail: String? = nil + ) -> FeatureSnapshot { + let enabledEnvironments = environments.filter(\.isEnabled) + let enabledIDs = Set(enabledEnvironments.map(\.id)) + shellProjectionCache = shellProjectionCache.filter { enabledIDs.contains($0.key) } + var threads: [FeatureThread] = [] + var projects: [FeatureProject] = [] + for environment in enabledEnvironments { + // Take ownership while updating so the cache does not copy its + // retained arrays when one row changes. + var projection = shellProjectionCache.removeValue(forKey: environment.id) + ?? NativeShellProjection() + let providerNames = (serverConfigsByEnvironmentID[environment.id]?.providers ?? []) + .reduce(into: [String: String]()) { names, provider in + // Match threadProviderName's first matching instance. + if names[provider.instanceId] == nil { + names[provider.instanceId] = provider.displayName + ?? providerDisplayName(provider.driver) + } + } + let live = projection.mapThreads( + shellsByEnvironmentID[environment.id]?.threads ?? [], + environment: environment, + providerNames: providerNames + ) { mapThread($0, environment: environment) } + let liveIDs = Set(live.map(\.id)) + let cached = (archivedThreadsByEnvironmentID[environment.id] ?? []).filter { + !liveIDs.contains($0.id) + } + threads.append(contentsOf: live) + threads.append(contentsOf: cached) + var threadCountByProjectID: [String: Int] = [:] + for thread in live { threadCountByProjectID[thread.projectID, default: 0] += 1 } + for thread in cached { threadCountByProjectID[thread.projectID, default: 0] += 1 } + let serverDefault = serverConfigsByEnvironmentID[environment.id]?.settings?.defaultModelSelection + let mappedProjects = projection.mapProjects( + shellsByEnvironmentID[environment.id]?.projects ?? [], + defaultModelSelection: serverDefault + ) { project in + let uiID = FeatureScopedID.project( + environmentID: environment.id, + wireID: project.id + ) + var mapped = FeatureProject( + id: uiID, + wireID: project.id, + environmentID: environment.id, + name: project.title, + path: project.workspaceRoot, + threadCount: 0, + defaultSelection: (project.defaultModelSelection ?? serverDefault).map(mapSelection), + repositoryIdentity: project.repositoryIdentity.map { + FeatureRepositoryIdentity( + canonicalKey: $0.canonicalKey, + rootPath: $0.rootPath, + displayName: $0.displayName, + name: $0.name + ) + }, + createdAt: project.createdAt, + updatedAt: project.updatedAt + ) + mapped.projectIcon = project.projectIcon + return mapped + } + for var project in mappedProjects { + project.threadCount = threadCountByProjectID[project.id, default: 0] + projects.append(project) + } + shellProjectionCache[environment.id] = projection + } + let providersByEnvironment = enabledEnvironments.reduce( + into: [String: [FeatureProvider]]() + ) { catalogues, environment in + guard let shell = shellsByEnvironmentID[environment.id] else { return } + catalogues[environment.id] = mapProviders( + environmentID: environment.id, + shell: shell, + config: serverConfigsByEnvironmentID[environment.id] + ) + } + let preferencesByEnvironment = enabledEnvironments.reduce( + into: [String: FeatureEnvironmentPreferences]() + ) { preferences, environment in + guard let config = serverConfigsByEnvironmentID[environment.id], + let serverSettings = config.settings else { + return + } + let defaultWorkspaceMode: FeatureWorkspaceMode = + switch serverSettings.defaultThreadEnvMode { + case .local: .local + case .worktree: .worktree + } + let groupingMode: FeatureEnvironmentPreferences.ProjectGroupingMode = + switch serverSettings.sidebarProjectGroupingMode { + case .repositoryPath: .repositoryPath + case .separate: .separate + case .repository, nil: .repository + } + let groupingOverrides = serverSettings.sidebarProjectGroupingOverrides? + .mapValues { mode -> FeatureEnvironmentPreferences.ProjectGroupingMode in + switch mode { + case .repository: return .repository + case .repositoryPath: return .repositoryPath + case .separate: return .separate + } + } ?? [:] + let capabilities = config.environment?.capabilities + ?? environment.descriptor?.capabilities + let supportsAutomaticSettlement = capabilities?.threadAutoSettlement == true + let supportsImageUploads = capabilities?.attachmentUploads == true + let maxFileAttachmentBytes = supportsImageUploads + ? capabilities?.fileAttachments.map { + min(ManagedAttachmentFileStore.maximumBytes, max(0, $0.maxUploadBytes)) + } + : nil + preferences[environment.id] = FeatureEnvironmentPreferences( + defaultWorkspaceMode: defaultWorkspaceMode, + newWorktreesStartFromOrigin: serverSettings.newWorktreesStartFromOrigin, + projectGroupingMode: groupingMode, + projectGroupingOverrides: groupingOverrides, + automaticSettlement: supportsAutomaticSettlement + ? FeatureAutomaticSettlementSettings( + onMerge: serverSettings.sidebarAutoSettleOnMerge, + afterDays: serverSettings.sidebarAutoSettleAfterDays + ) + : nil, + supportsImageUploads: supportsImageUploads, + maxFileAttachmentBytes: maxFileAttachmentBytes, + continueThreadsAfterServerUpdate: capabilities?.threadRestartContinuation == true + ? serverSettings.continueThreadsAfterServerUpdate + : nil + ) + } + return FeatureSnapshot( + connection: FeatureConnection( + state: connectionState, + environmentName: activeEnvironment.label, + endpoint: activeEnvironment.httpBaseURL.absoluteString, + detail: connectionDetail + ), + environments: environments.map { + mapEnvironment($0, activeID: activeEnvironment.id) + }, + projects: projects, + threads: threads, + providers: providersByEnvironment[activeEnvironment.id] ?? [], + providersByEnvironment: providersByEnvironment, + preferencesByEnvironment: preferencesByEnvironment, + settings: loadSettings() + ) + } + + private func mapEnvironment(_ environment: Environment, activeID: String?) -> FeatureEnvironment { + var mapped = FeatureEnvironment( + id: environment.id, + name: environment.label, + endpoint: environment.httpBaseURL.absoluteString, + isActive: environment.id == activeID, + isEnabled: environment.isEnabled, + source: environment.kind == .managedDPoP ? .t3Connect : .direct, + connectionState: environment.isEnabled + ? environmentConnectionStates[environment.id] + : .disconnected, + connectionDetail: environment.isEnabled + ? environmentConnectionDetails[environment.id] + : nil + ) + mapped.machineIcon = serverConfigsByEnvironmentID[environment.id]?.settings?.environmentIcon + ?? environment.descriptor?.platform.machine + mapped.canCustomizeIcon = serverConfigsByEnvironmentID[environment.id]?.environment?.capabilities.environmentIcon + ?? environment.descriptor?.capabilities.environmentIcon + return mapped + } + + private func mapThread( + _ thread: OrchestrationThreadShell, + environment: Environment + ) -> FeatureThread { + let backgroundLiveness = thread.backgroundLiveness + let backgroundWorkIsActive = backgroundLiveness == .working + return FeatureThread( + id: FeatureScopedID.thread(environmentID: environment.id, wireID: thread.id), + wireID: thread.id, + projectID: FeatureScopedID.project( + environmentID: environment.id, + wireID: thread.projectId + ), + environmentID: environment.id, + environmentName: environment.label, + title: thread.title, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + branchPullRequest: thread.branchPullRequest, + createdAt: parseDate(thread.createdAt), + updatedAt: parseDate(thread.updatedAt), + state: Self.resolveThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: thread.hasPendingApprovals, + hasUserInput: thread.hasPendingUserInput, + backgroundLiveness: backgroundLiveness + ), + providerID: thread.modelSelection.instanceId, + providerName: threadProviderName( + session: thread.session, + modelSelection: thread.modelSelection, + environmentID: environment.id + ), + modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + unsettledAt: thread.unsettledAt.flatMap(parseValidDate), + activeOrderKey: thread.activeOrderKey, + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.latestUserMessageAt, + latestTurn: thread.latestTurn + ), + snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + pinnedAt: thread.pinnedAt.map(parseDate), + supportsSettlement: environment.descriptor?.capabilities.threadSettlement, + supportsSnooze: environment.descriptor?.capabilities.threadSnooze, + supportsPinning: environment.descriptor?.capabilities.threadPinning, + supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, + supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + workingStartedAt: workingStartedAt( + latestTurn: thread.latestTurn, + session: thread.session, + backgroundWorkIsActive: backgroundWorkIsActive, + fallbackUpdatedAt: thread.updatedAt + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.flatMap(parseValidDate), + settlementFacts: settlementFacts( + override: thread.settledOverride, + session: thread.session, + hasApprovals: thread.hasPendingApprovals, + hasUserInput: thread.hasPendingUserInput, + latestUserMessageAt: thread.latestUserMessageAt, + latestTurn: thread.latestTurn + ), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + } + + private func mapThread( + _ thread: OrchestrationThread, + environment: Environment + ) -> FeatureThread { + let backgroundLiveness = backgroundLiveness( + threadID: thread.id, + environmentID: environment.id + ) + let backgroundWorkIsActive = backgroundLiveness == .working + return FeatureThread( + id: FeatureScopedID.thread(environmentID: environment.id, wireID: thread.id), + wireID: thread.id, + projectID: FeatureScopedID.project( + environmentID: environment.id, + wireID: thread.projectId + ), + environmentID: environment.id, + environmentName: environment.label, + title: thread.title, + preview: previewText(thread.messages.last?.text), + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + branchPullRequest: thread.branchPullRequest, + createdAt: parseDate(thread.createdAt), + updatedAt: parseDate(thread.updatedAt), + state: Self.resolveThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: false, + hasUserInput: false, + backgroundLiveness: backgroundLiveness + ), + providerID: thread.modelSelection.instanceId, + providerName: threadProviderName( + session: thread.session, + modelSelection: thread.modelSelection, + environmentID: environment.id + ), + modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + unsettledAt: thread.unsettledAt.flatMap(parseValidDate), + activeOrderKey: thread.activeOrderKey, + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.messages.last(where: { $0.role == "user" })?.createdAt, + latestTurn: thread.latestTurn + ), + snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + pinnedAt: thread.pinnedAt.map(parseDate), + supportsSettlement: environment.descriptor?.capabilities.threadSettlement, + supportsSnooze: environment.descriptor?.capabilities.threadSnooze, + supportsPinning: environment.descriptor?.capabilities.threadPinning, + supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, + supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + workingStartedAt: workingStartedAt( + latestTurn: thread.latestTurn, + session: thread.session, + backgroundWorkIsActive: backgroundWorkIsActive, + fallbackUpdatedAt: thread.updatedAt + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.flatMap(parseValidDate), + settlementFacts: settlementFacts( + override: thread.settledOverride, + session: thread.session, + hasApprovals: false, + hasUserInput: false, + latestUserMessageAt: thread.messages.last(where: { $0.role == "user" })?.createdAt, + latestTurn: thread.latestTurn + ), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + } + + private func mapDetail( + _ thread: OrchestrationThread, + environment: Environment, + sourceSequence: Int, + mutations: NativeDetailRenderMutations? = nil, + page: FeatureThreadPage? = nil + ) -> FeatureThreadDetail { + let threadID = FeatureScopedID.thread( + environmentID: environment.id, + wireID: thread.id + ) + let cache = detailRenderCaches[threadID] ?? NativeDetailRenderCache() + detailRenderCaches[threadID] = cache + markThreadCacheRecentlyUsed(threadID) + + if !cache.isInitialized || mutations == nil || mutations?.requiresFullRebuild == true { + cache.compaction = NativeContextCompactionState() + cache.messagesByID = thread.messages.reduce(into: [:]) { result, raw in + result[raw.id] = mapMessage(raw, environmentID: environment.id) + cache.compaction.apply(raw, createdAt: parseDate(raw.createdAt)) + } + resetPendingRequests(thread, environment: environment, cache: cache) + let notices = thread.activities.compactMap { activity in + cache.compaction.apply(activity) + return NativeActivityNotice.message(activity, createdAt: parseDate(activity.createdAt)) + } + let sessionIsLive = thread.session?.status == "starting" + || thread.session?.status == "running" + let activityMessages = (notices + collapsedWorkLogs( + thread.activities, + sessionIsLive: sessionIsLive + )) + .sorted { $0.createdAt < $1.createdAt } + seedWorkLogs(thread.activities, sessionIsLive: sessionIsLive, cache: cache) + cache.subagents.reset(with: thread.activities) + let messages = thread.messages.compactMap { cache.messagesByID[$0.id] } + cache.mergedMessages = (messages + activityMessages) + .sorted { $0.createdAt < $1.createdAt } + rebuildMergedIndexes(cache) + cache.isInitialized = true + } else if let mutations { + for message in mutations.messages { + let mapped = mapMessage(message, environmentID: environment.id) + cache.messagesByID[message.id] = mapped + cache.compaction.apply(message, createdAt: mapped.createdAt) + upsertMergedMessage(mapped, cache: cache) + } + for activity in mutations.activities { + applyActivityMutation( + activity, + threadID: threadID, + environment: environment, + cache: cache + ) + } + } else { + assertionFailure("Initialized detail caches require an incremental mutation") + } + + var mappedThread = mapThread(thread, environment: environment) + let backgroundLiveness = backgroundLiveness( + threadID: thread.id, + environmentID: environment.id + ) + let backgroundWorkIsActive = backgroundLiveness == .working + let sessionIsLive = thread.session?.status == "starting" + || thread.session?.status == "running" + if !sessionIsLive { + for (groupID, var accumulator) in cache.workLogsByGroupID + where accumulator.hasActiveWork { + accumulator.clearActiveWork() + cache.workLogsByGroupID[groupID] = accumulator + upsertMergedMessage(accumulator.message(groupID: groupID), cache: cache) + } + } + mappedThread.state = Self.resolveThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: !cache.approvals.isEmpty, + hasUserInput: !cache.userInputs.isEmpty, + backgroundLiveness: backgroundLiveness + ) + mappedThread.settlementFacts?.hasPendingApprovals = !cache.approvals.isEmpty + mappedThread.settlementFacts?.hasPendingUserInput = !cache.userInputs.isEmpty + if let shell = shellsByEnvironmentID[environment.id], + let shellThread = shell.threads.first(where: { $0.id == thread.id }), + shell.snapshotSequence >= sourceSequence { + applyShellMetadataAuthority(from: shellThread, to: &mappedThread) + } + return FeatureThreadDetail( + thread: mappedThread, + messages: cache.mergedMessages, + approvals: cache.approvals, + userInputs: cache.userInputs, + page: page, + activeSubagentCount: backgroundWorkIsActive || sessionIsLive + ? cache.subagents.activeCount + : 0, + backgroundWorkIsActive: backgroundWorkIsActive, + isCompacting: cache.compaction.isActive( + sessionStatus: thread.session?.status, + latestTurnState: thread.latestTurn?.state, + latestTurnRequestedAt: (thread.latestTurn?.requestedAt).flatMap(parseValidDate) + ) + ) + } + + private func backgroundLiveness( + threadID: String, + environmentID: String + ) -> OrchestrationBackgroundLiveness? { + if let live = shellsByEnvironmentID[environmentID]?.threads + .first(where: { $0.id == threadID })?.backgroundLiveness { + return live + } + return archivedShellThreadsByEnvironmentID[environmentID]?[threadID]? + .backgroundLiveness + } + + private func markThreadCacheRecentlyUsed(_ threadID: String) { + detailCacheRecency.removeAll { $0 == threadID } + detailCacheRecency.append(threadID) + } + + private func evictOldThreadCachesIfNeeded() { + while detailCacheRecency.count > Self.maximumRetainedThreadDetails { + let threadID = detailCacheRecency.removeFirst() + guard threadID != activeThreadID else { + detailCacheRecency.append(threadID) + break + } + latestDetails[threadID] = nil + threadResumeStates[threadID] = nil + detailRenderCaches[threadID] = nil + terminalSnapshots = terminalSnapshots.filter { $0.key.threadID != threadID } + } + } + + private func featurePage( + _ page: OrchestrationThreadDetailPage?, + isLoading: Bool = false + ) -> FeatureThreadPage? { + page.map { + FeatureThreadPage( + beforeCursor: $0.beforeCursor, + hasMore: $0.hasMore, + isLoading: isLoading + ) + } + } + + private func publishActivePageState(threadID: String) { + guard var detail = latestDetails[threadID] else { return } + detail.page = activeThreadPage + publish(detail, threadID: threadID, renderCacheIsSource: true) + } + + private func clearOlderThreadLoading(threadID: String) { + pendingOlderThreadPage = nil + activeThreadPage?.isLoading = false + publishActivePageState(threadID: threadID) + } + + private func tryMergePendingOlderThreadPage(route: NativeThreadRoute) { + guard let pending = pendingOlderThreadPage, + pending.threadID == route.uiID, + pending.environmentID == route.environmentID else { return } + guard pending.epoch == threadHistoryEpoch else { + clearOlderThreadLoading(threadID: route.uiID) + return + } + if let watermark = pending.snapshot.page?.threadSequence, + watermark > (activeThreadSequence ?? 0) { + return + } + pendingOlderThreadPage = nil + _ = mergeOlderThreadPage(pending.snapshot, route: route) + } + + @discardableResult + private func mergeOlderThreadPage( + _ snapshot: OrchestrationThreadDetailSnapshot, + route: NativeThreadRoute + ) -> FeatureThreadDetail? { + guard activeThreadID == route.uiID, + let loadedThread = activeRawThread, + let currentDetail = latestDetails[route.uiID] else { + clearOlderThreadLoading(threadID: route.uiID) + return latestDetails[route.uiID] + } + + let mergedThread = mergingOlderHistory(snapshot.thread, into: loadedThread) + let olderMessages = renderedHistoryMessages( + snapshot.thread, + environmentID: route.environmentID + ) + let loadedMessageIDs = Set(currentDetail.messages.map(\.id)) + let mergedMessages = ( + olderMessages.filter { !loadedMessageIDs.contains($0.id) } + + currentDetail.messages + ).sorted { $0.createdAt < $1.createdAt } + + activeRawThread = mergedThread + activeThreadPage = featurePage(snapshot.page) + + if let cache = detailRenderCaches[route.uiID] { + for rawMessage in snapshot.thread.messages where cache.messagesByID[rawMessage.id] == nil { + cache.messagesByID[rawMessage.id] = mapMessage( + rawMessage, + environmentID: route.environmentID + ) + } + cache.mergedMessages = mergedMessages + rebuildMergedIndexes(cache) + } + + let detail = FeatureThreadDetail( + thread: currentDetail.thread, + messages: mergedMessages, + approvals: currentDetail.approvals, + userInputs: currentDetail.userInputs, + page: activeThreadPage, + activeSubagentCount: currentDetail.activeSubagentCount, + backgroundWorkIsActive: currentDetail.backgroundWorkIsActive, + isCompacting: currentDetail.isCompacting == true + ) + publish(detail, threadID: route.uiID, renderCacheIsSource: true) + return detail + } + + private func renderedHistoryMessages( + _ thread: OrchestrationThread, + environmentID: String + ) -> [FeatureMessage] { + let messages = thread.messages.map { + mapMessage($0, environmentID: environmentID) + } + let workIsLive = thread.session?.status == "starting" + || thread.session?.status == "running" + || backgroundLiveness(threadID: thread.id, environmentID: environmentID) == .working + let activities = thread.activities.compactMap { + NativeActivityNotice.message($0, createdAt: parseDate($0.createdAt)) + } + + collapsedWorkLogs(thread.activities, sessionIsLive: workIsLive) + return (messages + activities).sorted { $0.createdAt < $1.createdAt } + } + + private func mergingOlderHistory( + _ older: OrchestrationThread, + into loaded: OrchestrationThread + ) -> OrchestrationThread { + func prependByID( + _ olderRows: [Element], + _ loadedRows: [Element] + ) -> [Element] where Element.ID: Hashable { + let loadedIDs = Set(loadedRows.map(\.id)) + return olderRows.filter { !loadedIDs.contains($0.id) } + loadedRows + } + + let loadedCheckpointTurns = Set(loaded.checkpoints.map(\.turnId)) + return OrchestrationThread( + id: loaded.id, + projectId: loaded.projectId, + title: loaded.title, + modelSelection: loaded.modelSelection, + runtimeMode: loaded.runtimeMode, + interactionMode: loaded.interactionMode, + branch: loaded.branch, + worktreePath: loaded.worktreePath, + linkedPullRequest: loaded.linkedPullRequest, + branchPullRequest: loaded.branchPullRequest, + latestTurn: loaded.latestTurn, + createdAt: loaded.createdAt, + updatedAt: loaded.updatedAt, + archivedAt: loaded.archivedAt, + settledOverride: loaded.settledOverride, + settledAt: loaded.settledAt, + unsettledAt: loaded.unsettledAt, + activeOrderKey: loaded.activeOrderKey, + snoozedUntil: loaded.snoozedUntil, + snoozedAt: loaded.snoozedAt, + pinnedAt: loaded.pinnedAt, + deletedAt: loaded.deletedAt, + messages: prependByID(older.messages, loaded.messages), + activities: prependByID(older.activities, loaded.activities), + checkpoints: older.checkpoints.filter { + !loadedCheckpointTurns.contains($0.turnId) + } + loaded.checkpoints, + session: loaded.session + ) + } + + private func rebuildMergedIndexes(_ cache: NativeDetailRenderCache) { + cache.mergedIndexByID = cache.mergedMessages.enumerated().reduce(into: [:]) { + $0[$1.element.id] = $1.offset + } + } + + /// Known stream events are chronological, so new render entities land at + /// the tail and existing streaming/work-log entities patch in constant time. + private func upsertMergedMessage( + _ message: FeatureMessage, + cache: NativeDetailRenderCache + ) { + if let index = cache.mergedIndexByID[message.id] { + cache.mergedMessages[index] = message + return + } + if let last = cache.mergedMessages.last, last.createdAt > message.createdAt { + // Out-of-order events are rare; preserve correctness while keeping + // the normal append path independent of transcript size. + cache.mergedMessages.append(message) + cache.mergedMessages.sort { $0.createdAt < $1.createdAt } + rebuildMergedIndexes(cache) + return + } + cache.mergedIndexByID[message.id] = cache.mergedMessages.count + cache.mergedMessages.append(message) + } + + private func applyActivityMutation( + _ activity: OrchestrationActivity, + threadID: String, + environment: Environment, + cache: NativeDetailRenderCache + ) { + cache.compaction.apply(activity) + cache.subagents.apply(activity) + applyApprovalActivity( + activity, + threadID: threadID, + environment: environment, + cache: cache + ) + applyUserInputActivity( + activity, + threadID: threadID, + environment: environment, + cache: cache + ) + if let notice = NativeActivityNotice.message(activity, createdAt: parseDate(activity.createdAt)) { + upsertMergedMessage(notice, cache: cache) + } + guard NativeWorkLogAccumulator.accepts(activity), + cache.workLogActivityIDs.insert(activity.id).inserted else { + return + } + let groupID = activity.turnId ?? "unscoped" + var accumulator = cache.workLogsByGroupID[groupID] ?? NativeWorkLogAccumulator() + accumulator.append( + activity, + preview: previewText(activity.payload["detail"]?.stringValue), + createdAt: parseDate(activity.createdAt) + ) + cache.workLogsByGroupID[groupID] = accumulator + guard accumulator.hasContent else { return } + let message = accumulator.message(groupID: groupID) + upsertMergedMessage(message, cache: cache) + } + + /// Decorate-sort so each timestamp is parsed once (via the memoized date + /// cache) instead of inside an O(n log n) comparator. Raw string order is + /// not safe here: the wire can mix fractional and non-fractional ISO8601 + /// representations, which sort lexicographically wrong. Ties keep wire + /// order so a request and its resolution never swap. + private func sortedByCreation( + _ activities: [OrchestrationActivity] + ) -> [OrchestrationActivity] { + var decorated: [(index: Int, date: Date, activity: OrchestrationActivity)] = [] + decorated.reserveCapacity(activities.count) + for (index, activity) in activities.enumerated() { + decorated.append((index, parseDate(activity.createdAt), activity)) + } + decorated.sort { lhs, rhs in + lhs.date != rhs.date ? lhs.date < rhs.date : lhs.index < rhs.index + } + return decorated.map(\.activity) + } + + private func seedWorkLogs( + _ activities: [OrchestrationActivity], + sessionIsLive: Bool, + cache: NativeDetailRenderCache + ) { + cache.workLogsByGroupID.removeAll(keepingCapacity: true) + cache.workLogActivityIDs.removeAll(keepingCapacity: true) + for activity in sortedByCreation(activities) + where NativeWorkLogAccumulator.accepts(activity) { + cache.workLogActivityIDs.insert(activity.id) + let groupID = activity.turnId ?? "unscoped" + var accumulator = cache.workLogsByGroupID[groupID] ?? NativeWorkLogAccumulator() + accumulator.append( + activity, + preview: previewText(activity.payload["detail"]?.stringValue), + createdAt: parseDate(activity.createdAt) + ) + cache.workLogsByGroupID[groupID] = accumulator + } + if !sessionIsLive { + for groupID in cache.workLogsByGroupID.keys { + cache.workLogsByGroupID[groupID]?.clearActiveWork() + } + } + } + + private func applyApprovalActivity( + _ activity: OrchestrationActivity, + threadID: String, + environment: Environment, + cache: NativeDetailRenderCache + ) { + guard let requestID = activity.payload["requestId"]?.stringValue else { return } + let uiRequestID = FeatureScopedID.approval( + environmentID: environment.id, + wireID: requestID + ) + switch activity.kind { + case "approval.requested": + guard !cache.closedApprovalRequestIDs.contains(requestID), + activity.payload["requestType"]?.stringValue != "tool_user_input", + activity.payload["requestType"]?.stringValue != "auth_tokens_refresh" else { + return + } + let kind = Self.approvalKind(activity.payload) + let appName = activity.payload["appName"]?.stringValue + let approval = FeatureApproval( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + kind: kind, + title: appName ?? activity.summary, + detail: activity.payload["detail"]?.stringValue ?? activity.summary, + appName: appName, + options: Self.approvalOptions(activity.payload) + ) + cache.approvals.removeAll { $0.id == uiRequestID } + cache.approvals.append(approval) + cache.approvals.sort { $0.id < $1.id } + approvalRoutes[uiRequestID] = PendingRequestRoute( + threadID: threadID, + wireID: requestID + ) + case "approval.resolved": + cache.closedApprovalRequestIDs.insert(requestID) + cache.approvals.removeAll { $0.id == uiRequestID } + approvalRoutes[uiRequestID] = nil + case "provider.approval.respond.failed": + guard Self.isTerminalRequestFailure(activity) else { return } + cache.closedApprovalRequestIDs.insert(requestID) + cache.approvals.removeAll { $0.id == uiRequestID } + approvalRoutes[uiRequestID] = nil + default: + return + } + } + + private func applyUserInputActivity( + _ activity: OrchestrationActivity, + threadID: String, + environment: Environment, + cache: NativeDetailRenderCache + ) { + guard let requestID = activity.payload["requestId"]?.stringValue else { return } + let uiRequestID = FeatureScopedID.input( + environmentID: environment.id, + wireID: requestID + ) + switch activity.kind { + case "user-input.requested": + guard !cache.closedUserInputRequestIDs.contains(requestID), + let questions = parseInputQuestions(activity.payload), !questions.isEmpty else { + return + } + let request = FeatureUserInput( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + questions: questions + ) + cache.userInputs.removeAll { $0.id == uiRequestID } + cache.userInputs.append(request) + cache.userInputs.sort { $0.id < $1.id } + inputRoutes[uiRequestID] = PendingRequestRoute( + threadID: threadID, + wireID: requestID + ) + case "user-input.resolved": + cache.closedUserInputRequestIDs.insert(requestID) + cache.userInputs.removeAll { $0.id == uiRequestID } + inputRoutes[uiRequestID] = nil + case "provider.user-input.respond.failed": + guard Self.isTerminalRequestFailure(activity) else { return } + cache.closedUserInputRequestIDs.insert(requestID) + cache.userInputs.removeAll { $0.id == uiRequestID } + inputRoutes[uiRequestID] = nil + default: + return + } + } + + private func mapMessage( + _ message: OrchestrationMessage, + environmentID: String + ) -> FeatureMessage { + FeatureMessage( + id: message.id, + role: mapRole(message.role), + text: message.text, + createdAt: parseDate(message.createdAt), + state: message.streaming ? .streaming : .complete, + attachments: (message.attachments ?? []).map { + FeatureMessageAttachment( + id: $0.id, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: $0.sizeBytes, + url: cachedAttachmentURL(for: $0.id, environmentID: environmentID) + ) + } + ) + } + + /// Lifecycle updates can number in the thousands on a long turn. Keep the + /// primary transcript message-sized while preserving a bounded, expandable + /// summary for each turn. + private func collapsedWorkLogs( + _ activities: [OrchestrationActivity], + sessionIsLive: Bool + ) -> [FeatureMessage] { + let groups = Dictionary(grouping: sortedByCreation(activities).filter { + NativeWorkLogAccumulator.accepts($0) + }) { activity in + activity.turnId ?? "unscoped" + } + return groups.compactMap { groupID, group in + var accumulator = NativeWorkLogAccumulator() + for activity in group { + accumulator.append( + activity, + preview: previewText(activity.payload["detail"]?.stringValue), + createdAt: parseDate(activity.createdAt) + ) + } + if !sessionIsLive { accumulator.clearActiveWork() } + return accumulator.hasContent ? accumulator.message(groupID: groupID) : nil + } + } + + /// Snapshot replay and live updates share terminal request rules. Request IDs + /// are unique, so a late requested activity must not reopen a resolved request. + private func resetPendingRequests( + _ thread: OrchestrationThread, + environment: Environment, + cache: NativeDetailRenderCache + ) { + for approval in cache.approvals { approvalRoutes[approval.id] = nil } + for input in cache.userInputs { inputRoutes[input.id] = nil } + cache.approvals.removeAll(keepingCapacity: true) + cache.userInputs.removeAll(keepingCapacity: true) + cache.closedApprovalRequestIDs.removeAll(keepingCapacity: true) + cache.closedUserInputRequestIDs.removeAll(keepingCapacity: true) + let threadID = FeatureScopedID.thread( + environmentID: environment.id, + wireID: thread.id + ) + for activity in sortedByCreation(thread.activities) { + applyApprovalActivity(activity, threadID: threadID, environment: environment, cache: cache) + applyUserInputActivity(activity, threadID: threadID, environment: environment, cache: cache) + } + } + + private static func isTerminalRequestFailure(_ activity: OrchestrationActivity) -> Bool { + let fragments: [String] + switch activity.kind { + case "provider.approval.respond.failed": + fragments = [ + "stale pending approval request", + "unknown pending approval request", + "unknown pending permission request", + "unknown pending codex approval request", + ] + case "provider.user-input.respond.failed": + fragments = [ + "stale pending user-input request", + "unknown pending user-input request", + "unknown pending user input request", + "unknown pending codex user input request", + ] + default: + return false + } + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + return fragments.contains { detail.contains($0) } + } + + private static func approvalKind(_ payload: JSONValue) -> FeatureApprovalKind { + switch payload["requestKind"]?.stringValue { + case "command": return .command + case "file-read": return .fileRead + case "file-change": return .fileChange + case "mcp-elicitation": return .mcpElicitation + default: break + } + switch payload["requestType"]?.stringValue { + case "file_read_approval": return .fileRead + case "file_change_approval", "apply_patch_approval": return .fileChange + case "mcp_elicitation_approval": return .mcpElicitation + default: return .command + } + } + + private static func approvalOptions(_ payload: JSONValue) -> [FeatureApprovalOption]? { + guard case let .array(values)? = payload["options"] else { return nil } + let options = values.compactMap { value -> FeatureApprovalOption? in + guard let wireDecision = value["decision"]?.stringValue, + let decision = FeatureApprovalDecision(wireValue: wireDecision), + let label = value["label"]?.stringValue, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return FeatureApprovalOption(decision: decision, label: label) + } + return options.isEmpty ? nil : options + } + + private func parseInputQuestions(_ payload: JSONValue) -> [FeatureInputQuestion]? { + guard case let .array(rawQuestions)? = payload["questions"] else { return nil } + return rawQuestions.compactMap { rawQuestion in + guard case let .object(question) = rawQuestion, + let id = question["id"]?.stringValue, + let header = question["header"]?.stringValue, + let text = question["question"]?.stringValue else { + return nil + } + let options: [FeatureInputOption] + if case let .array(rawOptions)? = question["options"] { + options = rawOptions.compactMap { rawOption in + guard case let .object(option) = rawOption, + let label = option["label"]?.stringValue else { + return nil + } + return FeatureInputOption( + label: label, + detail: option["description"]?.stringValue ?? "" + ) + } + } else { + options = [] + } + let allowsMultiple: Bool + if case let .bool(value)? = question["multiSelect"] { + allowsMultiple = value + } else { + allowsMultiple = false + } + return FeatureInputQuestion( + id: id, + header: header, + question: text, + options: options, + allowsMultiple: allowsMultiple + ) + } + } + + nonisolated static func resolveThreadState( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession?, + hasApprovals: Bool, + hasUserInput: Bool, + backgroundLiveness: OrchestrationBackgroundLiveness? + ) -> FeatureThreadState { + if hasApprovals { return .waitingForApproval } + if hasUserInput { return .waitingForInput } + if session?.status == "starting" { return .queued } + if session?.status == "running" || latestTurn?.state == "running" { return .working } + if session?.status == "error" || latestTurn?.state == "error" { return .failed } + if backgroundLiveness == .working { return .working } + if backgroundLiveness == .monitoring { return .monitoring } + if latestTurn?.state == "completed" { return .completed } + return .idle + } + + private func mapRole(_ role: String) -> FeatureMessageRole { + switch role { + case "user": .user + case "assistant": .assistant + case "system": .system + default: .tool + } + } + + private func isSettled(_ override: String?, settledAt: String?) -> Bool { + if override == "active" { return false } + return override == "settled" || settledAt != nil + } + + private func settlementFacts( + override: String?, + session: OrchestrationSession?, + hasApprovals: Bool, + hasUserInput: Bool, + latestUserMessageAt: String?, + latestTurn: OrchestrationLatestTurn? + ) -> FeatureThreadSettlementFacts { + return FeatureThreadSettlementFacts( + settlementOverride: override.flatMap(FeatureThreadSettlementOverride.init(rawValue:)), + sessionStatus: session?.status, + hasPendingApprovals: hasApprovals, + hasPendingUserInput: hasUserInput, + latestUserMessageAt: latestUserMessageAt.flatMap(parseValidDate), + latestTurn: latestTurn.map { + FeatureThreadSettlementFacts.LatestTurn( + requestedAt: parseValidDate($0.requestedAt), + startedAt: $0.startedAt.flatMap(parseValidDate), + completedAt: $0.completedAt.flatMap(parseValidDate), + requestedAtIsInvalid: parseValidDate($0.requestedAt) == nil, + startedAtIsInvalid: $0.startedAt.map { parseValidDate($0) == nil } ?? false, + completedAtIsInvalid: $0.completedAt.map { parseValidDate($0) == nil } ?? false + ) + } + ) + } + + private func applyShellMetadataAuthority( + from shell: OrchestrationThreadShell, + to thread: inout FeatureThread + ) { + thread.isSettled = isSettled(shell.settledOverride, settledAt: shell.settledAt) + thread.keepsActive = shell.settledOverride == "active" + thread.settledAt = shell.settledAt.flatMap(parseValidDate) + thread.unsettledAt = shell.unsettledAt.flatMap(parseValidDate) + thread.activeOrderKey = shell.activeOrderKey + thread.linkedPullRequest = shell.linkedPullRequest + thread.branchPullRequest = shell.branchPullRequest + thread.settlementFacts = settlementFacts( + override: shell.settledOverride, + session: shell.session, + hasApprovals: shell.hasPendingApprovals, + hasUserInput: shell.hasPendingUserInput, + latestUserMessageAt: shell.latestUserMessageAt, + latestTurn: shell.latestTurn + ) + } + + private func mapRuntimeMode(_ mode: RuntimeMode) -> FeatureRuntimeMode { + switch mode { + case .approvalRequired: .approvalRequired + case .autoAcceptEdits: .autoAcceptEdits + case .auto: .automatic + case .fullAccess: .fullAccess + } + } + + private func coreRuntimeMode(_ mode: FeatureRuntimeMode) -> RuntimeMode { + switch mode { + case .approvalRequired: .approvalRequired + case .autoAcceptEdits: .autoAcceptEdits + case .automatic: .auto + case .fullAccess: .fullAccess + } + } + + private func mapInteractionMode(_: InteractionMode) -> FeatureInteractionMode { + .standard + } + + private func coreInteractionMode(_: FeatureInteractionMode) -> InteractionMode { + .default + } + + /// Reuse mapped models across shell and settings updates. The config + /// setter invalidates an entry only when its provider snapshots change. + private var providerCatalogCache: [String: [FeatureProvider]] = [:] + + /// Single write path for server configs so the provider catalog cache can + /// never go stale against the config that feeds it. + private func setServerConfig(_ config: ServerConfigSnapshot, environmentID: String) { + if serverConfigsByEnvironmentID[environmentID]?.providers != config.providers { + providerCatalogCache[environmentID] = nil + } + serverConfigsByEnvironmentID[environmentID] = config + } + + private func mapProviders( + environmentID: String, + shell: OrchestrationShellSnapshot, + config: ServerConfigSnapshot? + ) -> [FeatureProvider] { + if let providers = config?.providers, !providers.isEmpty { + if let cached = providerCatalogCache[environmentID] { return cached } + let mapped = mapConfigProviders(providers) + providerCatalogCache[environmentID] = mapped + return mapped + } + return mapShellFallbackProviders(shell) + } + + private func mapConfigProviders( + _ providers: [ServerProviderSnapshot] + ) -> [FeatureProvider] { + Self.normalizedProviders(providers.map { provider in + var mapped = FeatureProvider( + id: provider.instanceId, + name: provider.displayName ?? providerDisplayName(provider.driver), + isAvailable: provider.enabled + && provider.installed + && provider.status != "disabled" + && provider.status != "error" + && provider.auth.status != "unauthenticated" + && provider.availability != "unavailable", + driver: provider.driver, + requiresNewThreadForModelChange: + provider.requiresNewThreadForModelChange ?? false, + models: provider.models.map { model in + let options = (model.capabilities?.optionDescriptors ?? []) + .map(mapOptionDescriptor) + return FeatureModel( + id: model.slug, + name: model.name, + detail: model.subProvider ?? model.shortName, + supportsReasoning: options.contains { descriptor in + let searchable = "\(descriptor.id) \(descriptor.label)".lowercased() + return searchable.contains("reason") + || searchable.contains("effort") + || searchable.contains("thinking") + }, + isDefault: model.isDefault ?? false, + isLegacy: model.isLegacy, + options: options + ) + }, + slashCommands: (provider.slashCommands ?? []).map { command in + FeatureProviderSlashCommand( + name: command.name, + description: command.description, + inputHint: command.input?.hint + ) + }, + skills: (provider.skills ?? []).map(Self.mapSkill) + ) + mapped.setup = provider.setup + mapped.isEnabled = provider.enabled + mapped.isInstalled = provider.installed + mapped.authStatus = provider.auth.status + mapped.statusMessage = provider.message + mapped.workspaceSnapshots = provider.workspaceSnapshots?.map { workspace in + FeatureProviderWorkspace( + cwd: workspace.cwd, + slashCommands: workspace.slashCommands.map { + FeatureProviderSlashCommand(name: $0.name, description: $0.description, inputHint: $0.input?.hint) + }, + skills: workspace.skills.map(Self.mapSkill) + ) + } + return mapped + }) + } + + private static func mapSkill(_ skill: ServerProviderSkillSnapshot) -> FeatureProviderSkill { + var mapped = FeatureProviderSkill( + name: skill.name, displayName: skill.displayName, + description: skill.description, shortDescription: skill.shortDescription, + path: skill.path, scope: skill.scope, isEnabled: skill.enabled + ) + mapped.userInvocationOnly = skill.userInvocationOnly + mapped.userInvocable = skill.userInvocable + return mapped + } + + /// Without a server config the catalog is inferred from selections in the + /// shell, which is cheap enough to rebuild per publish. + private func mapShellFallbackProviders( + _ shell: OrchestrationShellSnapshot + ) -> [FeatureProvider] { + var modelsByProvider: [String: Set] = [:] + for selection in shell.projects.compactMap(\.defaultModelSelection) + + shell.threads.map(\.modelSelection) { + modelsByProvider[selection.instanceId, default: []].insert(selection.model) + } + if modelsByProvider.isEmpty { + modelsByProvider["codex"] = ["gpt-5.6-sol"] + } + return modelsByProvider.keys.sorted().map { providerID in + FeatureProvider( + id: providerID, + name: providerDisplayName(providerID), + driver: providerID, + models: (modelsByProvider[providerID] ?? []).sorted().map { + FeatureModel(id: $0, name: $0) + } + ) + } + } + + static func normalizedProviders( + _ providers: [FeatureProvider] + ) -> [FeatureProvider] { + var normalized: [FeatureProvider] = [] + var providerIndexByID: [String: Int] = [:] + + for var provider in providers { + var seenModelIDs = Set() + provider.models = provider.models.filter { + seenModelIDs.insert($0.id).inserted + } + if let index = providerIndexByID[provider.id] { + var existing = normalized[index] + var existingModelIDs = Set(existing.models.map(\.id)) + existing.models.append(contentsOf: provider.models.filter { + existingModelIDs.insert($0.id).inserted + }) + normalized[index] = existing + } else { + providerIndexByID[provider.id] = normalized.count + normalized.append(provider) + } + } + return normalized + } + + private func modelSelection( + _ selection: FeatureSelection?, + projectID: String, + environmentID: String, + shell: OrchestrationShellSnapshot? + ) -> ModelSelection { + if let selection { + return coreModelSelection(selection) + } + if let projectDefault = shell?.projects + .first(where: { $0.id == projectID })? + .defaultModelSelection { + return projectDefault + } + if let serverDefault = serverConfigsByEnvironmentID[environmentID]?.settings?.defaultModelSelection { + return serverDefault + } + return fallbackModelSelection( + environmentID: environmentID, + projectID: projectID, + shell: shell + ) + } + + /// Fallback selection is resolved against the target environment. This + /// matters when a passive machine exposes a different provider catalogue + /// than the currently active one. + private func fallbackModelSelection( + environmentID: String, + projectID: String?, + shell: OrchestrationShellSnapshot? + ) -> ModelSelection { + let config = serverConfigsByEnvironmentID[environmentID] + let appSelection = loadSettings().defaultSelection + if let selection = appSelection, let config { + if configSupports(selection, config: config) { + return coreModelSelection(selection) + } + } + if let configuredDefault = defaultModelSelection(in: config) { + return configuredDefault + } + if let projectID, + let recentProjectSelection = shell?.threads + .first(where: { $0.projectId == projectID })? + .modelSelection { + return recentProjectSelection + } + if let knownSelection = shell?.projects.compactMap(\.defaultModelSelection).first + ?? shell?.threads.first?.modelSelection { + return knownSelection + } + if let selection = appSelection { + return coreModelSelection(selection) + } + return ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") + } + + private func configSupports( + _ selection: FeatureSelection, + config: ServerConfigSnapshot + ) -> Bool { + config.providers.contains { provider in + provider.instanceId == selection.providerID + && providerCanRun(provider) + && provider.models.contains { $0.slug == selection.modelID } + } + } + + private func defaultModelSelection( + in config: ServerConfigSnapshot? + ) -> ModelSelection? { + guard let providers = config?.providers else { return nil } + for provider in providers where providerCanRun(provider) { + if let model = provider.models.first(where: { $0.isDefault == true }) { + return ModelSelection(instanceId: provider.instanceId, model: model.slug) + } + } + for provider in providers where providerCanRun(provider) { + if let model = provider.models.first { + return ModelSelection(instanceId: provider.instanceId, model: model.slug) + } + } + return nil + } + + private func providerCanRun(_ provider: ServerProviderSnapshot) -> Bool { + provider.enabled + && provider.installed + && provider.status != "disabled" + && provider.status != "error" + && provider.auth.status != "unauthenticated" + && provider.availability != "unavailable" + } + + private func coreModelSelection(_ selection: FeatureSelection) -> ModelSelection { + let options = selection.options.map { option in + ModelSelection.OptionSelection( + id: option.id, + value: coreOptionValue(option.value) + ) + } + return ModelSelection( + instanceId: selection.providerID, + model: selection.modelID, + options: options.isEmpty ? nil : options + ) + } + + private func mapSelection(_ selection: ModelSelection) -> FeatureSelection { + FeatureSelection( + providerID: selection.instanceId, + modelID: selection.model, + options: mapOptionSelections(selection.options) + ) + } + + private func coreOptionValue(_ value: FeatureModelOptionValue) -> JSONValue { + switch value { + case let .string(rawValue): + return .string(rawValue) + case let .boolean(rawValue): + return .bool(rawValue) + } + } + + private func mapOptionSelections( + _ selections: [ModelSelection.OptionSelection]? + ) -> [FeatureModelOptionSelection] { + (selections ?? []).compactMap { selection in + let value: FeatureModelOptionValue + switch selection.value { + case let .string(rawValue): + value = .string(rawValue) + case let .bool(rawValue): + value = .boolean(rawValue) + default: + return nil + } + return FeatureModelOptionSelection(id: selection.id, value: value) + } + } + + private func mapOptionDescriptor( + _ descriptor: ServerProviderOptionDescriptor + ) -> FeatureModelOptionDescriptor { + switch descriptor { + case let .select(value): + let defaultValue = value.currentValue + ?? value.options.first(where: { $0.isDefault == true })?.id + return FeatureModelOptionDescriptor( + id: value.id, + label: value.label, + detail: value.description, + kind: .select, + choices: value.options.map { + FeatureModelOptionChoice( + id: $0.id, + label: $0.label, + detail: $0.description, + isDefault: $0.isDefault ?? false + ) + }, + defaultValue: defaultValue.map(FeatureModelOptionValue.string), + promptInjectedValues: value.promptInjectedValues + ) + case let .boolean(value): + return FeatureModelOptionDescriptor( + id: value.id, + label: value.label, + detail: value.description, + kind: .boolean, + defaultValue: value.currentValue.map(FeatureModelOptionValue.boolean) + ) + } + } + + private func providerDisplayName(_ id: String) -> String { + switch id { + case "codex": "Codex" + case "claudeAgent", "claude": "Claude" + case "cursor": "Cursor" + case "grok": "Grok" + case "opencode": "OpenCode" + case "antigravity": "Antigravity" + default: id + } + } + + private func threadProviderName( + session: OrchestrationSession?, + modelSelection: ModelSelection, + environmentID: String + ) -> String { + if let name = session?.providerName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return name + } + let providerID = session?.providerInstanceId ?? modelSelection.instanceId + if let provider = serverConfigsByEnvironmentID[environmentID]?.providers.first(where: { + $0.instanceId == providerID + }) { + return provider.displayName ?? providerDisplayName(provider.driver) + } + return providerDisplayName(providerID) + } + + private func cachedAttachmentURL( + for id: String, + environmentID: String? = nil + ) -> URL? { + guard let environmentID = environmentID ?? activeEnvironment?.id else { + return nil + } + let key = AttachmentCacheKey(environmentID: environmentID, attachmentID: id) + guard let cached = attachmentURLs[key] else { return nil } + guard cached.expiresAt > Date().addingTimeInterval(30) else { + attachmentURLs[key] = nil + return nil + } + return cached.url + } + + func attachmentAssetURL( + threadID: String, + attachment: FeatureMessageAttachment + ) async throws -> URL { + try Task.checkCancellation() + let route = try threadRoute(for: threadID) + let generation = environmentGeneration + if let cached = cachedAttachmentURL(for: attachment.id, environmentID: route.environmentID) { + return cached + } + let resolved = try await route.client.resolvedAsset( + resource: .attachment( + id: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType + ) + ) + try Task.checkCancellation() + guard isKnownClient( + route.client, environmentID: route.environmentID, generation: generation + ) else { throw CancellationError() } + let key = AttachmentCacheKey( + environmentID: route.environmentID, attachmentID: attachment.id + ) + // URLs are small, but a session can visit thousands of attachments. + if attachmentURLs.count >= 256 { + let expiry = Date().addingTimeInterval(30) + attachmentURLs = attachmentURLs.filter { $0.value.expiresAt > expiry } + if attachmentURLs.count >= 256, let oldest = attachmentURLs.min(by: { + $0.value.expiresAt < $1.value.expiresAt + })?.key { + attachmentURLs[oldest] = nil + } + } + attachmentURLs[key] = CachedAttachmentURL( + url: resolved.url, expiresAt: resolved.expiresAt + ) + return resolved.url + } + + private func lastActivityDate( + latestUserMessageAt: String?, + latestTurn: OrchestrationLatestTurn? + ) -> Date? { + [ + latestUserMessageAt, + latestTurn?.requestedAt, + latestTurn?.startedAt, + latestTurn?.completedAt, + ] + .compactMap { $0.flatMap(parseValidDate) } + .max() + } + + private func failureDate( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession? + ) -> Date? { + guard session?.status == "error" || latestTurn?.state == "error" else { + return nil + } + return [ + session?.updatedAt, + latestTurn?.completedAt, + latestTurn?.startedAt, + latestTurn?.requestedAt, + ] + .compactMap { $0.flatMap(parseValidDate) } + .max() + } + + private func workingStartedAt( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession?, + backgroundWorkIsActive: Bool = false, + fallbackUpdatedAt: String? = nil + ) -> Date? { + let directSessionIsLive = session?.status == "starting" + || session?.status == "running" + || latestTurn?.state == "running" + guard directSessionIsLive || backgroundWorkIsActive else { + return nil + } + let candidates: [String?] + if directSessionIsLive, let latestTurn, latestTurn.completedAt == nil { + candidates = [ + latestTurn.startedAt, + latestTurn.requestedAt, + session?.updatedAt, + ] + } else if backgroundWorkIsActive { + candidates = [ + latestTurn?.startedAt, + latestTurn?.requestedAt, + session?.updatedAt, + fallbackUpdatedAt, + ] + } else { + candidates = [session?.updatedAt] + } + return candidates.lazy.compactMap { $0.flatMap(self.parseValidDate) }.first + } + + private func makeUploadAttachments( + _ attachments: [FeatureUploadAttachment] + ) throws -> [UploadChatAttachment] { + guard attachments.count <= 8 else { + throw NativeFeatureClientError.tooManyAttachments + } + return try attachments.map { + let reference = $0.uploadedReference.map { + UploadedAttachmentReference( + environmentID: $0.environmentID, + attachmentID: $0.attachmentID + ) + } + if let ownedFile = $0.ownedFile { + return try UploadChatAttachment( + id: $0.id, + fileURL: ownedFile.url, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: ownedFile.byteCount, + uploadedReference: reference + ) + } + return try UploadChatAttachment( + id: $0.id, + data: $0.data, + name: $0.name, + mimeType: $0.mimeType, + uploadedReference: reference + ) + } + } + + private func requireScope(_ scope: String, client: T3Client) async throws { + let session = try await client.authSession() + guard session.scopes?.contains(scope) == true else { + throw NativeFeatureClientError.missingScope(scope) + } + } + + private static func title(from prompt: String, hasAttachments: Bool) -> String { + let compact = prompt + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + guard !compact.isEmpty else { + return hasAttachments ? "Image task" : "New thread" + } + guard compact.count > 72 else { return compact } + return "\(compact.prefix(69).trimmingCharacters(in: .whitespacesAndNewlines))..." + } + + private func commandIdentity( + _ identity: FeatureSubmissionIdentity + ) -> CommandIdentity { + CommandIdentity( + commandID: identity.commandID, + messageID: identity.messageID, + createdAt: Self.fractionalDateFormatter.string(from: identity.createdAt) + ) + } + + private static func temporaryWorktreeBranchName(seed: String? = nil) -> String { + let suffix = seed ?? UUID().uuidString + return "t3code/\(suffix.prefix(8).lowercased())" + } + + private func previewText(_ text: String?) -> String? { + guard let text else { return nil } + let compact = text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard !compact.isEmpty else { return nil } + return compact.count > 160 ? "\(compact.prefix(157))..." : compact + } + + private func loadSettings() -> FeatureSettings { + guard let data = settingsStore.data(forKey: Self.settingsKey), + let settings = try? JSONDecoder().decode(FeatureSettings.self, from: data) else { + return FeatureSettings() + } + return settings + } + + private func parseDate(_ value: String) -> Date { + parseValidDate(value) ?? .distantPast + } + + /// Reuse unchanged timestamps across snapshot mappings. Keep the cache bounded + /// even when a long session receives many different event times. + private func parseValidDate(_ value: String) -> Date? { + if let cached = parsedDates[value] { return cached } + guard let parsed = NativeTimestampParser.parse(value) else { return nil } + if parsedDates.count >= 4096 { parsedDates.removeAll(keepingCapacity: true) } + parsedDates[value] = parsed + return parsed + } + + private var parsedDates: [String: Date] = [:] + + private static let settingsKey = "swift-ios.feature-settings.v1" + private static let fractionalDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() +} + +extension FeatureDeviceSession { + init(relayDevice: T3ConnectRelayDevice, currentDeviceID: String?) { + let updatedAt = Self.t3ConnectRelayDate(relayDevice.updatedAt) + self.init( + sessionID: relayDevice.deviceId, + label: relayDevice.label, + deviceType: relayDevice.platform.lowercased().contains("ipad") ? .tablet : .mobile, + operatingSystem: "iOS \(relayDevice.iosMajorVersion)", + browser: relayDevice.appVersion.map { "T3 Code \($0)" }, + issuedAt: updatedAt, + expiresAt: .distantFuture, + lastConnectedAt: updatedAt, + isConnected: false, + isCurrent: relayDevice.deviceId == currentDeviceID + ) + } + + private static func t3ConnectRelayDate(_ value: String) -> Date { + (try? Date(value, strategy: .iso8601)) ?? .distantPast + } +} + +enum NativeDetailRenderMutation: Equatable { + case full + case message(OrchestrationMessage) + case activity(OrchestrationActivity) + case metadata + case none +} + +struct NativeDetailRenderMutations { + private(set) var hasUpdates = false + private(set) var requiresFullRebuild = false + private(set) var messages: [OrchestrationMessage] = [] + private(set) var activities: [OrchestrationActivity] = [] + + mutating func formUnion(_ mutation: NativeDetailRenderMutation) { + if mutation != .none { hasUpdates = true } + guard !requiresFullRebuild else { return } + switch mutation { + case .full: + requiresFullRebuild = true + messages.removeAll(keepingCapacity: true) + activities.removeAll(keepingCapacity: true) + case let .message(message): + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = message + } else { + messages.append(message) + } + case let .activity(activity): + if let index = activities.firstIndex(where: { $0.id == activity.id }) { + activities[index] = activity + } else { + activities.append(activity) + } + case .metadata, .none: + break + } + } +} + +private final class NativeDetailRenderCache { + var isInitialized = false + var messagesByID: [String: FeatureMessage] = [:] + var mergedMessages: [FeatureMessage] = [] + var mergedIndexByID: [String: Int] = [:] + var workLogsByGroupID: [String: NativeWorkLogAccumulator] = [:] + var workLogActivityIDs: Set = [] + var approvals: [FeatureApproval] = [] + var userInputs: [FeatureUserInput] = [] + var closedApprovalRequestIDs: Set = [] + var closedUserInputRequestIDs: Set = [] + var subagents = FeatureActiveSubagentTracker() + var compaction = NativeContextCompactionState() +} + +enum NativeActivityNotice { + static func accepts(_ activity: OrchestrationActivity) -> Bool { + activity.tone == "error" || activity.kind == "runtime.warning" + || activity.kind == "context-compaction" + } + + static func message(_ activity: OrchestrationActivity, createdAt: Date) -> FeatureMessage? { + guard accepts(activity) else { return nil } + let text: String + if activity.kind == "context-compaction" { + text = activity.summary + } else if let message = activity.payload["message"]?.stringValue, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + text = message + } else if let detail = activity.payload["detail"]?.stringValue, + !detail.isEmpty, detail != activity.summary { + text = "\(activity.summary)\n\(detail)" + } else { + text = activity.summary + } + return FeatureMessage( + id: "activity-\(activity.id)", + role: .system, + text: text, + createdAt: createdAt, + state: .complete, + toolName: activity.kind + ) + } +} + +/// The latest compaction request and its matching result survive live updates +/// without scanning the retained activity history for each streamed token. +struct NativeContextCompactionState { + private var requestID: String? + private var requestedAt: Date? + private var settled = false + + mutating func apply(_ message: OrchestrationMessage, createdAt: Date) { + guard message.role == "user", + FeatureContextCompaction.isCommand( + message.text, hasAttachments: message.attachments?.isEmpty == false + ) else { return } + if let requestedAt, createdAt < requestedAt { return } + if requestID != message.id { + requestID = message.id + requestedAt = createdAt + settled = false + } + } + + mutating func apply(_ activity: OrchestrationActivity) { + guard activity.kind == "context-compaction" || activity.kind == "provider.turn.start.failed", + let requestID, activity.payload["requestId"]?.stringValue == requestID else { return } + settled = true + } + + func isActive( + sessionStatus: String?, + latestTurnState: String?, + latestTurnRequestedAt: Date? + ) -> Bool { + guard !settled, let requestedAt, + sessionStatus == "starting" || sessionStatus == "running" else { return false } + let turnRequestedAt = latestTurnRequestedAt ?? requestedAt + return requestedAt > turnRequestedAt + || (latestTurnState == "running" && requestedAt == turnRequestedAt) + } +} + +enum NativeSharedPreferenceChange { + static func filter( + _ change: ServerSettingsChange, + supportsRestartContinuation: Bool + ) -> ServerSettingsChange? { + guard !supportsRestartContinuation else { return change } + switch change { + case .continueThreadsAfterServerUpdate: + return nil + case let .sharedPreferences(.object(fields)): + var fields = fields + fields.removeValue(forKey: "continueThreadsAfterServerUpdate") + return fields.isEmpty ? nil : .sharedPreferences(.object(fields)) + default: + return change + } + } +} + +struct NativeWorkLogAccumulator { + private static let terminalKinds = Set([ + "tool.completed", "task.completed", "turn.plan.updated", + ]) + private static let activeKinds = Set(["tool.started", "tool.updated"]) + private static let imageExtensions = Set([ + "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "tif", "tiff", "webp", + ]) + + private(set) var count = 0 + private var visibleLines: [String] = [] + private var createdAt = Date.distantPast + private var activeEntries: [String: String] = [:] + private var activeOrder: [String] = [] + private var imagePaths: [String] = [] + private var toolPresentation: ToolActivityPresentation? + private var activePresentations: [String: ToolActivityPresentation] = [:] + + var hasActiveWork: Bool { !activeEntries.isEmpty } + var hasContent: Bool { count > 0 || hasActiveWork || !imagePaths.isEmpty } + + static func accepts(_ activity: OrchestrationActivity) -> Bool { + activeKinds.contains(activity.kind) + || (activity.tone != "error" && terminalKinds.contains(activity.kind)) + } + + mutating func append( + _ activity: OrchestrationActivity, + preview: String?, + createdAt: Date + ) { + if count == 0 && activeEntries.isEmpty { + self.createdAt = createdAt + } + let key = Self.lifecycleKey(activity) + toolPresentation = ToolActivityPresentation(payload: activity.payload) ?? activePresentations[key] + let label = activity.payload["title"]?.stringValue ?? activity.summary + let lifecycleStatus = activity.payload["status"]?.stringValue + let isTerminalUpdate = activity.kind == "tool.updated" + && lifecycleStatus.map { $0 != "inProgress" && $0 != "in_progress" } == true + if Self.activeKinds.contains(activity.kind) && !isTerminalUpdate + && activity.tone != "error" { + activeEntries[key] = label + activePresentations[key] = toolPresentation + activeOrder.removeAll { $0 == key } + activeOrder.append(key) + } else { + activeEntries[key] = nil + activePresentations[key] = nil + activeOrder.removeAll { $0 == key } + guard activity.tone != "error" else { return } + count += 1 + visibleLines.append("• \(preview ?? activity.summary)") + if visibleLines.count > 40 { + visibleLines.removeFirst(visibleLines.count - 40) + } + } + if let path = Self.viewedImagePath(activity), !imagePaths.contains(path) { + imagePaths.append(path) + if imagePaths.count > 8 { imagePaths.removeFirst(imagePaths.count - 8) } + } + } + + mutating func clearActiveWork() { + activeEntries.removeAll(keepingCapacity: true) + activePresentations.removeAll(keepingCapacity: true) + activeOrder.removeAll(keepingCapacity: true) + } + + func message(groupID: String) -> FeatureMessage { + var lines: [String] = [] + if count > visibleLines.count { + lines.append("\(count - visibleLines.count) earlier updates hidden") + } + lines.append(contentsOf: visibleLines) + var message = FeatureMessage( + id: "work-log-\(groupID)", + role: .tool, + text: lines.joined(separator: "\n"), + createdAt: createdAt, + state: .complete, + toolName: "Work log · \(count)", + workLogImagePaths: imagePaths.isEmpty ? nil : imagePaths, + activeWorkLabel: activeOrder.last.flatMap { activeEntries[$0] } + ) + message.toolPresentation = activeOrder.last.flatMap { activePresentations[$0] } ?? toolPresentation + return message + } + + private static func lifecycleKey(_ activity: OrchestrationActivity) -> String { + if let id = activity.payload["toolCallId"]?.stringValue + ?? activity.payload["data"]?["toolCallId"]?.stringValue { + return "id:\(id)" + } + let itemType = activity.payload["itemType"]?.stringValue ?? "" + let title = activity.payload["title"]?.stringValue ?? activity.summary + let detail = activity.payload["detail"]?.stringValue ?? "" + return "fallback:\([itemType, title, detail].map(normalizedLifecycleText).joined(separator: "|"))" + } + + private static func normalizedLifecycleText(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences( + of: #"\s+(complete|completed)$"#, + with: "", + options: .regularExpression + ) + } + + private static func viewedImagePath(_ activity: OrchestrationActivity) -> String? { + let itemType = normalizedLifecycleText(activity.payload["itemType"]?.stringValue ?? "") + let title = normalizedLifecycleText(activity.payload["title"]?.stringValue ?? activity.summary) + let qualifies = activity.payload["requestKind"]?.stringValue == "file-read" + || itemType == "image_view" + || (itemType == "dynamic_tool_call" && title == "read file") + guard qualifies, + let detail = activity.payload["detail"]?.stringValue, + !detail.contains("\n"), !detail.contains("\r") else { return nil } + let path = detail.trimmingCharacters(in: .whitespacesAndNewlines) + guard let ext = path.split(separator: ".").last?.lowercased(), + imageExtensions.contains(String(ext)) else { return nil } + return path + } +} + +enum NativeThreadDetailReductionResult: Equatable { + case updated(OrchestrationThread) + case unchanged + case refresh +} + +struct NativeThreadDetailReduction: Equatable { + let sequence: Int + let result: NativeThreadDetailReductionResult + let renderMutation: NativeDetailRenderMutation + + init( + sequence: Int, + result: NativeThreadDetailReductionResult, + renderMutation: NativeDetailRenderMutation = .metadata + ) { + self.sequence = sequence + self.result = result + self.renderMutation = renderMutation + } +} + +/// Swift counterpart to client-runtime's thread reducer for the detail event +/// subset sent by `subscribeThread`. Destructive and forward-unknown events +/// deliberately request an authoritative snapshot. +enum NativeThreadDetailReducer { + static func apply( + _ event: JSONValue, + to thread: OrchestrationThread + ) -> NativeThreadDetailReduction { + guard case let .object(object) = event, + let type = object["type"]?.stringValue, + let occurredAt = object["occurredAt"]?.stringValue, + let sequence = intValue(object["sequence"]), + let payload = object["payload"], + payload["threadId"]?.stringValue == thread.id else { + return NativeThreadDetailReduction( + sequence: -1, + result: .refresh, + renderMutation: .full + ) + } + + let result: NativeThreadDetailReductionResult + var renderMutation = NativeDetailRenderMutation.metadata + switch type { + case "thread.settled": + result = reduceSettled(payload: payload, thread: thread) + case "thread.unsettled": + result = reduceUnsettled(payload: payload, thread: thread) + case "thread.meta-updated": + result = reduceMetadata(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.message-sent": + result = reduceMessage( + payload: payload, + occurredAt: occurredAt, + thread: thread, + renderMutation: &renderMutation + ) + case "thread.activity-appended": + result = reduceActivity( + payload: payload, + occurredAt: occurredAt, + thread: thread, + renderMutation: &renderMutation + ) + case "thread.session-set": + result = reduceSession(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.turn-diff-completed": + result = reduceTurnDiff(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.proposed-plan-upserted": + // Proposed plans are not rendered by the native detail model yet. + result = .unchanged + renderMutation = .none + case "thread.reverted": + result = .refresh + renderMutation = .full + default: + result = .refresh + renderMutation = .full + } + return NativeThreadDetailReduction( + sequence: sequence, + result: result, + renderMutation: renderMutation + ) + } + + private static func reduceSettled( + payload: JSONValue, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let settledAt = payload["settledAt"]?.stringValue, + let updatedAt = payload["updatedAt"]?.stringValue else { + return .refresh + } + var updated = replacing( + thread, + settlement: SettlementReplacement( + override: "settled", + settledAt: settledAt, + unsettledAt: nil + ), + updatedAt: updatedAt + ) + updated.activeOrderKey = nil + return .updated(updated) + } + + private static func reduceUnsettled( + payload: JSONValue, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let reason = payload["reason"]?.stringValue, + let updatedAt = payload["updatedAt"]?.stringValue else { + return .refresh + } + return .updated( + replacing( + thread, + settlement: SettlementReplacement( + override: reason == "user" ? "active" : nil, + settledAt: nil, + unsettledAt: thread.settledOverride == "active" + ? thread.unsettledAt + : updatedAt + ), + updatedAt: updatedAt + ) + ) + } + + private static func reduceMetadata( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard case let .object(values) = payload, + values["linkedPullRequest"] != nil + || values["branchPullRequest"] != nil + || values["activeOrderKey"] != nil else { + return .refresh + } + guard !["title", "modelSelection", "branch", "worktreePath"].contains(where: { + values[$0] != nil + }) else { + return .refresh + } + var updated = replacing( + thread, + updatedAt: payload["updatedAt"]?.stringValue ?? occurredAt + ) + if let rawLink = values["linkedPullRequest"] { + if rawLink == .null { + updated.linkedPullRequest = nil + } else { + guard let decoded = try? rawLink.decode(ThreadLinkedPullRequest.self) else { + return .refresh + } + updated.linkedPullRequest = decoded + } + } + if let rawBranchLink = values["branchPullRequest"] { + if rawBranchLink == .null { + updated.branchPullRequest = nil + } else { + guard let decoded = try? rawBranchLink.decode(ThreadLinkedPullRequest.self) else { + return .refresh + } + updated.branchPullRequest = decoded + } + } + if let rawOrder = values["activeOrderKey"] { + if rawOrder == .null { + updated.activeOrderKey = nil + } else { + guard let order = rawOrder.stringValue, + !order.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .refresh + } + updated.activeOrderKey = order + } + } + return .updated(updated) + } + + private static func reduceMessage( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread, + renderMutation: inout NativeDetailRenderMutation + ) -> NativeThreadDetailReductionResult { + guard let id = payload["messageId"]?.stringValue, + let role = payload["role"]?.stringValue, + let text = payload["text"]?.stringValue, + let streaming = boolValue(payload["streaming"]), + let createdAt = payload["createdAt"]?.stringValue, + let updatedAt = payload["updatedAt"]?.stringValue else { + return .refresh + } + let turnID = payload["turnId"]?.stringValue + let attachments: [ChatAttachment]? + if let rawAttachments = payload["attachments"], rawAttachments != .null { + guard let decoded = try? rawAttachments.decode([ChatAttachment].self) else { + return .refresh + } + attachments = decoded + } else { + attachments = nil + } + + var messages = thread.messages + let existingIndex = messages.last?.id == id + ? messages.indices.last + : messages.firstIndex(where: { $0.id == id }) + if let index = existingIndex { + let existing = messages[index] + messages[index] = OrchestrationMessage( + id: existing.id, + role: existing.role, + text: streaming ? existing.text + text : (text.isEmpty ? existing.text : text), + attachments: attachments ?? existing.attachments, + turnId: turnID, + streaming: streaming, + createdAt: existing.createdAt, + updatedAt: streaming ? existing.updatedAt : updatedAt + ) + renderMutation = .message(messages[index]) + } else { + let message = OrchestrationMessage( + id: id, + role: role, + text: text, + attachments: attachments, + turnId: turnID, + streaming: streaming, + createdAt: createdAt, + updatedAt: updatedAt + ) + messages.append(message) + renderMutation = .message(message) + } + + var latestTurn = thread.latestTurn + var checkpoints = thread.checkpoints + if role == "assistant", let turnID, + latestTurn == nil || latestTurn?.turnId == turnID { + let turnStillRunning = thread.session?.status == "running" + && thread.session?.activeTurnId == turnID + let settlesTurn = !streaming && !turnStillRunning + let previous = latestTurn?.turnId == turnID ? latestTurn : nil + let state = settlesTurn + ? (previous?.state == "interrupted" || previous?.state == "error" + ? previous!.state + : "completed") + : "running" + latestTurn = OrchestrationLatestTurn( + turnId: turnID, + state: state, + requestedAt: previous?.requestedAt ?? createdAt, + startedAt: previous?.startedAt ?? createdAt, + completedAt: settlesTurn ? updatedAt : previous?.completedAt, + assistantMessageId: id + ) + checkpoints = checkpoints.map { checkpoint in + guard checkpoint.turnId == turnID, + checkpoint.assistantMessageId == nil else { return checkpoint } + return CheckpointSummary( + turnId: checkpoint.turnId, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + status: checkpoint.status, + files: checkpoint.files, + assistantMessageId: id, + completedAt: checkpoint.completedAt + ) + } + } + return .updated( + replacing( + thread, + messages: messages, + checkpoints: checkpoints, + latestTurn: latestTurn, + updatedAt: occurredAt + ) + ) + } + + private static func reduceActivity( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread, + renderMutation: inout NativeDetailRenderMutation + ) -> NativeThreadDetailReductionResult { + guard let raw = payload["activity"], + let activity = try? raw.decode(OrchestrationActivity.self) else { + return .refresh + } + renderMutation = .activity(activity) + return .updated( + // The render cache owns the event tail. Keeping the authoritative + // snapshot array shared avoids copying tens of thousands of old + // activities for each append; a resnapshot rebuilds after recovery. + replacing(thread, updatedAt: occurredAt) + ) + } + + private static func reduceSession( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let raw = payload["session"], + let session = try? raw.decode(OrchestrationSession.self) else { + return .refresh + } + var latestTurn = thread.latestTurn + if session.status == "running", let activeTurnID = session.activeTurnId { + let previous = latestTurn?.turnId == activeTurnID ? latestTurn : nil + latestTurn = OrchestrationLatestTurn( + turnId: activeTurnID, + state: "running", + requestedAt: previous?.requestedAt ?? session.updatedAt, + startedAt: previous?.startedAt ?? session.updatedAt, + completedAt: nil, + assistantMessageId: previous?.assistantMessageId + ) + } else if latestTurn?.state == "running", + let settledState = settledTurnState(session.status), + let current = latestTurn { + latestTurn = OrchestrationLatestTurn( + turnId: current.turnId, + state: settledState, + requestedAt: current.requestedAt, + startedAt: current.startedAt, + completedAt: session.updatedAt, + assistantMessageId: current.assistantMessageId + ) + } + return .updated( + replacing( + thread, + latestTurn: latestTurn, + session: session, + updatedAt: occurredAt + ) + ) + } + + private static func reduceTurnDiff( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let turnID = payload["turnId"]?.stringValue, + let turnCount = intValue(payload["checkpointTurnCount"]), + let checkpointRef = payload["checkpointRef"]?.stringValue, + let status = payload["status"]?.stringValue, + let completedAt = payload["completedAt"]?.stringValue, + let rawFiles = payload["files"], + let files = try? rawFiles.decode([CheckpointFile].self) else { + return .refresh + } + let assistantMessageID = payload["assistantMessageId"]?.stringValue + let checkpoint = CheckpointSummary( + turnId: turnID, + checkpointTurnCount: turnCount, + checkpointRef: checkpointRef, + status: status, + files: files, + assistantMessageId: assistantMessageID, + completedAt: completedAt + ) + if let existing = thread.checkpoints.first(where: { $0.turnId == turnID }), + existing.status != "missing", status == "missing" { + return .unchanged + } + var checkpoints = thread.checkpoints.filter { $0.turnId != turnID } + checkpoints.append(checkpoint) + checkpoints.sort { $0.checkpointTurnCount < $1.checkpointTurnCount } + + var latestTurn = thread.latestTurn + let stillRunning = thread.session?.status == "running" + && thread.session?.activeTurnId == turnID + if !stillRunning, latestTurn == nil || latestTurn?.turnId == turnID { + latestTurn = OrchestrationLatestTurn( + turnId: turnID, + state: status == "error" ? "error" : "completed", + requestedAt: latestTurn?.requestedAt ?? completedAt, + startedAt: latestTurn?.startedAt ?? completedAt, + completedAt: completedAt, + assistantMessageId: assistantMessageID + ) + } + return .updated( + replacing( + thread, + checkpoints: checkpoints, + latestTurn: latestTurn, + updatedAt: occurredAt + ) + ) + } + + private struct SettlementReplacement { + let override: String? + let settledAt: String? + let unsettledAt: String? + } + + private static func replacing( + _ thread: OrchestrationThread, + messages: [OrchestrationMessage]? = nil, + activities: [OrchestrationActivity]? = nil, + checkpoints: [CheckpointSummary]? = nil, + latestTurn: OrchestrationLatestTurn? = nil, + session: OrchestrationSession? = nil, + settlement: SettlementReplacement? = nil, + updatedAt: String + ) -> OrchestrationThread { + OrchestrationThread( + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + branchPullRequest: thread.branchPullRequest, + latestTurn: latestTurn ?? thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: updatedAt, + archivedAt: thread.archivedAt, + settledOverride: settlement == nil ? thread.settledOverride : settlement?.override, + settledAt: settlement == nil ? thread.settledAt : settlement?.settledAt, + unsettledAt: settlement == nil ? thread.unsettledAt : settlement?.unsettledAt, + activeOrderKey: thread.activeOrderKey, + snoozedUntil: thread.snoozedUntil, + snoozedAt: thread.snoozedAt, + pinnedAt: thread.pinnedAt, + deletedAt: thread.deletedAt, + messages: messages ?? thread.messages, + activities: activities ?? thread.activities, + checkpoints: checkpoints ?? thread.checkpoints, + session: session ?? thread.session + ) + } + + private static func settledTurnState(_ status: String) -> String? { + switch status { + case "idle", "ready": "completed" + case "error": "error" + case "interrupted", "stopped": "interrupted" + default: nil + } + } + + private static func intValue(_ value: JSONValue?) -> Int? { + guard case let .number(number)? = value else { return nil } + return Int(exactly: number) + } + + private static func boolValue(_ value: JSONValue?) -> Bool? { + guard case let .bool(boolean)? = value else { return nil } + return boolean + } +} + +/// Shell metadata often changes for only one row. Keep the mapped values for +/// equal source records, including across a fresh HTTP snapshot or a reorder. +struct NativeShellRowProjection { + private var sources: [Source] = [] + private var rows: [Row] = [] + + mutating func map(_ next: [Source], transform: (Source) -> Row) -> [Row] { + guard next != sources else { return rows } + var previousIndexByID: [Source.ID: Int]? + let nextRows = next.enumerated().map { index, source in + if index < sources.count, sources[index].id == source.id { + return sources[index] == source ? rows[index] : transform(source) + } + // Most deltas keep order. Only build the lookup after an insert, + // removal, or reorder moves a row to a different position. + if previousIndexByID == nil { + previousIndexByID = sources.enumerated().reduce(into: [:]) { + $0[$1.element.id] = $1.offset + } + } + if let oldIndex = previousIndexByID?[source.id], sources[oldIndex] == source { + return rows[oldIndex] + } + return transform(source) + } + sources = next + rows = nextRows + return nextRows + } +} + +struct NativeShellProjection { + private struct ThreadContext: Equatable { + let environment: Environment + let providerNames: [String: String] + } + + private var threadContext: ThreadContext? + private var threads = NativeShellRowProjection() + private var projectDefaultModelSelection: ModelSelection? + private var projects = NativeShellRowProjection() + + mutating func mapProjects( + _ source: [OrchestrationProject], + defaultModelSelection: ModelSelection?, + transform: (OrchestrationProject) -> FeatureProject + ) -> [FeatureProject] { + if projectDefaultModelSelection != defaultModelSelection { + projects = NativeShellRowProjection() + projectDefaultModelSelection = defaultModelSelection + } + return projects.map(source, transform: transform) + } + + mutating func mapThreads( + _ source: [OrchestrationThreadShell], + environment: Environment, + providerNames: [String: String], + transform: (OrchestrationThreadShell) -> FeatureThread + ) -> [FeatureThread] { + let context = ThreadContext(environment: environment, providerNames: providerNames) + if threadContext != context { + threads = NativeShellRowProjection() + threadContext = context + } + return threads.map(source, transform: transform) + } +} + +private struct NativeShellMembership: Equatable { + let environmentID: String + let projectIDs: [String] + let threadIDs: [String] + let archivedIDs: [String] +} + +private struct AttachmentCacheKey: Hashable { + let environmentID: String + let attachmentID: String +} + +private struct CachedAttachmentURL { + let url: URL + let expiresAt: Date +} + +private struct EnvironmentShellLoad: Sendable { + let environment: Environment + let client: T3Client + let shell: OrchestrationShellSnapshot? + let config: ServerConfigSnapshot? +} + +private struct EntityWireOwner: Hashable { + let environmentID: String + let wireID: String +} + +private struct NativeProjectRoute { + let uiID: String + let wireID: String + let environmentID: String + let client: T3Client +} + +private struct PendingOlderThreadPage { + let snapshot: OrchestrationThreadDetailSnapshot + let epoch: Int + let threadID: String + let environmentID: String +} + +private struct NativeThreadRoute { + let uiID: String + let wireID: String + let environmentID: String + let client: T3Client +} + +private struct NativeThreadResumeState { + let client: T3Client + let thread: OrchestrationThread + let sequence: Int + let page: FeatureThreadPage? + var wasSynchronized: Bool + let connectionID: UUID? +} + +private struct NativeSourceControlMonitorKey: Hashable { + let environmentID: String + let workingDirectory: String +} + +@MainActor +private final class NativeSourceControlMonitor { + let id = UUID() + var latestStatus: FeatureSourceControlStatus? + var continuations: [UUID: AsyncStream.Continuation] = [:] + var task: Task? +} + +private struct ProvisionalThreadRoute: Equatable { + let environmentID: String + let wireID: String +} + +private struct PendingRequestRoute { + let threadID: String + let wireID: String +} + +private struct CommandIdentity: Equatable { + let commandID: String + let messageID: String + let createdAt: String + + init( + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) { + self.commandID = commandID + self.messageID = messageID + self.createdAt = createdAt + } +} + +private struct BootstrapSubmissionSignature: Equatable { + let projectID: String + let prompt: String + let model: ModelSelection + let runtimeMode: RuntimeMode + let interactionMode: InteractionMode + let workspaceMode: FeatureWorkspaceMode + let branch: String? + let worktreePath: String? + let startFromOrigin: Bool + let attachments: [FeatureUploadAttachment] +} + +private struct PendingBootstrapSubmission { + let signature: BootstrapSubmissionSignature + let threadID: String + let identity: CommandIdentity + let worktreeBranchName: String? +} + +private struct ThreadCreationSignature: Equatable { + let projectID: String + let title: String + let model: ModelSelection +} + +private struct PendingThreadCreation { + let signature: ThreadCreationSignature + let threadID: String +} + +private struct TurnSubmissionSignature: Equatable { + let text: String + let model: ModelSelection? + let runtimeMode: RuntimeMode + let interactionMode: InteractionMode + let attachments: [FeatureUploadAttachment] +} + +private struct PendingTurnSubmission { + let signature: TurnSubmissionSignature + let identity: CommandIdentity +} + +private enum NativeFeatureClientError: LocalizedError { + case notConnected + case environmentNotFound + case projectNotFound + case threadNotFound + case threadSnapshotOutdated + case workspaceNotFound + case approvalNotFound + case inputRequestNotFound + case invalidProjectPath + case branchRequired + case deviceSessionNotFound + case currentDeviceUnknown + case missingScope(String) + case tooManyAttachments + case invalidAutomaticSettlementDays + case remoteStatusUnavailable + + var errorDescription: String? { + switch self { + case .notConnected: "Connect to a T3 environment first." + case .environmentNotFound: "That T3 environment is no longer available." + case .projectNotFound: "The selected project is no longer available." + case .threadNotFound: "The selected thread is no longer available." + case .threadSnapshotOutdated: "The computer has not finished updating this thread. Try again." + case .workspaceNotFound: "The thread workspace is no longer available." + case .approvalNotFound: "The approval request is no longer active." + case .inputRequestNotFound: "The input request is no longer active." + case .invalidProjectPath: "Enter a workspace path on the connected environment." + case .branchRequired: "Choose a base branch for the new worktree." + case .deviceSessionNotFound: "That device session is no longer active." + case .currentDeviceUnknown: "This installation has not registered for device access yet." + case .missingScope: "This connection does not have permission to manage devices." + case .tooManyAttachments: "You can attach up to 8 files per message." + case .invalidAutomaticSettlementDays: "Choose a value from 1 to 90 days." + case .remoteStatusUnavailable: + "Couldn't check the remote status. Try reloading." + } + } +} diff --git a/apps/swift-ios/App/NativeTimestampParser.swift b/apps/swift-ios/App/NativeTimestampParser.swift new file mode 100644 index 000000000000..365728110c27 --- /dev/null +++ b/apps/swift-ios/App/NativeTimestampParser.swift @@ -0,0 +1,51 @@ +import Foundation + +@MainActor +enum NativeTimestampParser { + private static let fractionalStyle = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + private static let wholeSecondStyle = Date.ISO8601FormatStyle() + private static let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let wholeSecondFormatter = ISO8601DateFormatter() + + static func parse(_ value: String) -> Date? { + if isStandardServerTimestamp(value) { + let style = value.utf8.count == 24 ? fractionalStyle : wholeSecondStyle + if let parsed = try? style.parse(value) { + // Match the existing formatter's millisecond date values. + return Date(timeIntervalSince1970: (parsed.timeIntervalSince1970 * 1_000).rounded() / 1_000) + } + } + return fractionalFormatter.date(from: value) ?? wholeSecondFormatter.date(from: value) + } + + /// The modern parser accepts invalid clock values and keeps extra fractional + /// precision. Use it only for normal UTC server timestamps. + private static func isStandardServerTimestamp(_ value: String) -> Bool { + value.utf8.withContiguousStorageIfAvailable { bytes in + guard bytes.count == 20 || bytes.count == 24 else { return false } + for index in bytes.indices { + let byte = bytes[index] + switch index { + case 4, 7: + guard byte == UInt8(ascii: "-") else { return false } + case 10: + guard byte == UInt8(ascii: "T") else { return false } + case 13, 16: + guard byte == UInt8(ascii: ":") else { return false } + case 19: + guard byte == (bytes.count == 20 ? UInt8(ascii: "Z") : UInt8(ascii: ".")) else { return false } + case 23: + guard byte == UInt8(ascii: "Z") else { return false } + default: + guard (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) else { return false } + } + } + let hour = (bytes[11] - UInt8(ascii: "0")) * 10 + bytes[12] - UInt8(ascii: "0") + return hour < 24 && bytes[14] <= UInt8(ascii: "5") && bytes[17] <= UInt8(ascii: "5") + } ?? false + } +} diff --git a/apps/swift-ios/App/NativeUsageLimitsCollector.swift b/apps/swift-ios/App/NativeUsageLimitsCollector.swift new file mode 100644 index 000000000000..d3819d7aac80 --- /dev/null +++ b/apps/swift-ios/App/NativeUsageLimitsCollector.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Each environment can update its limits without waiting for the other computers. +actor NativeUsageLimitsCollector { + private var rows: [FeatureEnvironmentUsageLimits] + private let continuation: AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error>.Continuation + + init( + rows: [FeatureEnvironmentUsageLimits], + continuation: AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error>.Continuation + ) { + self.rows = rows + self.continuation = continuation + } + + func update(index: Int, config: ServerConfigSnapshot) { + let previous = rows[index] + let next = FeatureEnvironmentUsageLimits( + environmentID: previous.environmentID, + label: previous.label, + providers: config.providers, + sources: config.usageLimitSources + ) + guard next != previous else { return } + rows[index] = next + continuation.yield(rows) + } + + func fail(index: Int, message: String) { + let previous = rows[index] + rows[index] = FeatureEnvironmentUsageLimits( + environmentID: previous.environmentID, + label: previous.label, + providers: previous.providers, + sources: previous.sources, + isConnected: false, + errorMessage: message + ) + continuation.yield(rows) + } +} diff --git a/apps/swift-ios/App/NativeWorkspaceMapper.swift b/apps/swift-ios/App/NativeWorkspaceMapper.swift new file mode 100644 index 000000000000..530567022324 --- /dev/null +++ b/apps/swift-ios/App/NativeWorkspaceMapper.swift @@ -0,0 +1,448 @@ +import Foundation + +enum NativeWorkspaceMapper { + static func files( + _ entries: [ProjectEntry], + directory: String? + ) -> [FeatureFileEntry] { + let directory = normalize(directory ?? "") + let prefix = directory.isEmpty ? "" : "\(directory)/" + var children: [String: FeatureFileEntry] = [:] + + for entry in entries { + let fullPath = normalize(entry.path) + guard fullPath.hasPrefix(prefix) else { continue } + let remainder = String(fullPath.dropFirst(prefix.count)) + guard !remainder.isEmpty else { continue } + + let component = remainder.split(separator: "/", maxSplits: 1).first.map(String.init)! + let childPath = prefix + component + let isNested = remainder.contains("/") + let kind: FeatureFileKind = isNested || entry.kind == .directory + ? .directory + : .file + if children[childPath]?.kind == .directory { + continue + } + children[childPath] = FeatureFileEntry( + path: childPath, + name: component, + kind: kind, + isHidden: component.hasPrefix(".") + ) + } + + return Array(children.values).featureFiltered(by: "", includesHidden: true) + } + + static func language(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "swift": "swift" + case "ts", "tsx": "typescript" + case "js", "jsx", "mjs", "cjs": "javascript" + case "json": "json" + case "md", "mdx": "markdown" + case "css", "scss": "css" + case "html", "htm": "html" + case "xml", "svg": "xml" + case "sh", "zsh", "bash": "shell" + case "py": "python" + case "rs": "rust" + case "go": "go" + case "rb": "ruby" + case "sql": "sql" + case "toml": "toml" + case "yml", "yaml": "yaml" + default: nil + } + } + + static func review(_ preview: ReviewDiffPreview) -> FeatureReview { + FeatureReview( + title: "Working tree", + baseReference: preview.sources.compactMap(\.baseRef).first, + files: preview.sources.flatMap(parseDiff), + isTruncated: preview.sources.contains(where: \.truncated) + ) + } + + static func sourceControl(_ status: VCSStatus) -> FeatureSourceControlStatus { + sourceControl( + isRepository: status.isRepo, + branch: status.refName, + files: status.workingTree.files, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pullRequest: status.pr + ) + } + + static func sourceControl( + local: VCSLocalStatus, + remote: VCSRemoteStatus? + ) -> FeatureSourceControlStatus { + sourceControl( + isRepository: local.isRepo, + branch: local.refName, + files: local.workingTree.files, + aheadCount: remote?.aheadCount ?? 0, + behindCount: remote?.behindCount ?? 0, + pullRequest: remote?.pr + ) + } + + private static func sourceControl( + isRepository: Bool, + branch: String?, + files: [VCSWorkingTreeFile], + aheadCount: Int, + behindCount: Int, + pullRequest: VCSChangeRequest?, + isRemoteKnown: Bool = true + ) -> FeatureSourceControlStatus { + FeatureSourceControlStatus( + isRepository: isRepository, + branch: branch, + aheadCount: aheadCount, + behindCount: behindCount, + isRemoteKnown: isRemoteKnown, + files: files.map { + FeatureSourceControlFile( + path: $0.path, + state: .modified, + isStaged: false + ) + }, + pullRequest: pullRequest.map { + FeaturePullRequest( + number: $0.number, + title: $0.title, + state: $0.state, + url: URL(string: $0.url), + updatedAt: $0.updatedAt + ) + } + ) + } + + /// The streamed counterpart, where the remote half arrives separately and + /// may still be pending. + static func sourceControl( + local: VCSLocalStatus, + remote: VCSRemoteStatus?, + isRemoteKnown: Bool + ) -> FeatureSourceControlStatus { + sourceControl( + isRepository: local.isRepo, + branch: local.refName, + files: local.workingTree.files, + aheadCount: remote?.aheadCount ?? 0, + behindCount: remote?.behindCount ?? 0, + pullRequest: remote?.pr, + isRemoteKnown: isRemoteKnown + ) + } + + static func gitAction(_ action: FeatureSourceControlAction) -> GitStackedAction { + switch action { + case .commit: .commit + case .push: .push + case .createPullRequest: .createPullRequest + case .commitAndPush: .commitAndPush + case .commitPushAndCreatePullRequest: .commitPushAndPullRequest + case .pull: + // Pull has a dedicated VCS endpoint and never reaches this mapping. + .push + } + } + + static func terminal(_ snapshot: TerminalSessionSnapshot) -> FeatureTerminalSnapshot { + FeatureTerminalSnapshot( + threadID: snapshot.threadId, + terminalID: snapshot.terminalId, + state: terminalState(snapshot.status), + title: snapshot.label, + workingDirectory: snapshot.cwd, + buffer: snapshot.history, + exitCode: snapshot.exitCode, + updatedAt: snapshot.updatedAt + ) + } + + static func terminal(_ summary: TerminalSummary) -> FeatureTerminalSnapshot { + FeatureTerminalSnapshot( + threadID: summary.threadId, + terminalID: summary.terminalId, + state: terminalState(summary.status), + title: summary.label, + workingDirectory: summary.cwd, + exitCode: summary.exitCode, + hasRunningSubprocess: summary.hasRunningSubprocess, + updatedAt: summary.updatedAt + ) + } + + private static func terminalState(_ status: TerminalSessionStatus) -> FeatureTerminalState { + switch status { + case .starting: .starting + case .running: .running + case .exited: .exited + case .error: .failed + } + } + + private static func normalize(_ path: String) -> String { + path.replacingOccurrences(of: "\\", with: "/") + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + } + + private static func parseDiff(_ source: ReviewDiffSource) -> [FeatureReviewFile] { + let rawLines = source.diff.split( + separator: "\n", + omittingEmptySubsequences: false + ).map(String.init) + var files: [FeatureReviewFile] = [] + var currentPath: String? + var previousPath: String? + var change = FeatureReviewChangeKind.modified + var lines: [FeatureDiffLine] = [] + var oldLine: Int? + var newLine: Int? + var additions = 0 + var deletions = 0 + + func finishFile() { + guard let currentPath else { return } + files.append( + FeatureReviewFile( + path: currentPath, + previousPath: previousPath, + change: change, + additions: additions, + deletions: deletions, + lines: annotateChangedSpans(lines), + sourceKind: source.kind, + sourceBaseReference: source.baseRef, + sourceHeadReference: source.headRef + ) + ) + } + + for (index, line) in rawLines.enumerated() { + if line.hasPrefix("diff --git ") { + finishFile() + let parts = line.split(separator: " ") + currentPath = parts.count > 3 ? stripDiffPrefix(String(parts[3])) : source.title + previousPath = parts.count > 2 ? stripDiffPrefix(String(parts[2])) : nil + change = .modified + lines = [] + oldLine = nil + newLine = nil + additions = 0 + deletions = 0 + continue + } + if line.hasPrefix("new file mode ") { + change = .added + continue + } + if line.hasPrefix("deleted file mode ") { + change = .deleted + continue + } + if line.hasPrefix("rename from ") { + previousPath = String(line.dropFirst("rename from ".count)) + change = .renamed + continue + } + if line.hasPrefix("rename to ") { + currentPath = String(line.dropFirst("rename to ".count)) + change = .renamed + continue + } + if line.hasPrefix("Binary files ") || line == "GIT binary patch" { + change = .binary + continue + } + if line.hasPrefix("+++ ") { + let path = String(line.dropFirst(4)) + if path != "/dev/null" { currentPath = stripDiffPrefix(path) } + continue + } + if line.hasPrefix("--- ") { + let path = String(line.dropFirst(4)) + if path != "/dev/null" { previousPath = stripDiffPrefix(path) } + continue + } + if line.hasPrefix("@@") { + let ranges = line.split(separator: " ") + oldLine = ranges.count > 1 ? rangeStart(String(ranges[1])) : nil + newLine = ranges.count > 2 ? rangeStart(String(ranges[2])) : nil + lines.append( + FeatureDiffLine( + id: "\(source.id)-\(index)", + kind: .hunk, + text: line + ) + ) + continue + } + + let kind: FeatureDiffLineKind + let rendered: String + let renderedOld: Int? + let renderedNew: Int? + if line.hasPrefix("+") { + kind = .addition + rendered = String(line.dropFirst()) + renderedOld = nil + renderedNew = newLine + newLine = newLine.map { $0 + 1 } + additions += 1 + } else if line.hasPrefix("-") { + kind = .deletion + rendered = String(line.dropFirst()) + renderedOld = oldLine + renderedNew = nil + oldLine = oldLine.map { $0 + 1 } + deletions += 1 + } else if line.hasPrefix(" ") { + kind = .context + rendered = String(line.dropFirst()) + renderedOld = oldLine + renderedNew = newLine + oldLine = oldLine.map { $0 + 1 } + newLine = newLine.map { $0 + 1 } + } else { + continue + } + lines.append( + FeatureDiffLine( + id: "\(source.id)-\(index)", + kind: kind, + oldLine: renderedOld, + newLine: renderedNew, + text: rendered + ) + ) + } + finishFile() + + if files.isEmpty, !source.diff.isEmpty { + return [ + FeatureReviewFile( + path: source.title, + change: .modified, + additions: additions, + deletions: deletions, + lines: annotateChangedSpans(lines), + sourceKind: source.kind, + sourceBaseReference: source.baseRef, + sourceHeadReference: source.headRef + ), + ] + } + return files + } + + /// Git presents replacements as adjacent deletion/addition blocks. Pairing those + /// lines here keeps the view dumb and makes word-level highlighting stable on scroll. + private static func annotateChangedSpans( + _ source: [FeatureDiffLine] + ) -> [FeatureDiffLine] { + var lines = source + var index = 0 + while index < lines.count { + guard lines[index].kind == .deletion || lines[index].kind == .addition else { + index += 1 + continue + } + let start = index + while index < lines.count, + lines[index].kind == .deletion || lines[index].kind == .addition { + index += 1 + } + let changedIndices = start ..< index + let deletions = changedIndices.filter { lines[$0].kind == .deletion } + let additions = changedIndices.filter { lines[$0].kind == .addition } + for (deletionIndex, additionIndex) in zip(deletions, additions) { + let spans = FeatureDiffWordHighlighter.spans( + old: lines[deletionIndex].text, + new: lines[additionIndex].text + ) + lines[deletionIndex].spans = spans.old + lines[additionIndex].spans = spans.new + } + } + return lines + } + + private static func stripDiffPrefix(_ path: String) -> String { + if path.hasPrefix("a/") || path.hasPrefix("b/") { + return String(path.dropFirst(2)) + } + return path + } + + private static func rangeStart(_ range: String) -> Int? { + Int( + range + .drop(while: { $0 == "-" || $0 == "+" }) + .split(separator: ",", maxSplits: 1) + .first + ?? "" + ) + } +} + +/// Folds `vcs.subscribeStatus` events into successive UI statuses. Modelled on +/// `applyGitStatusStreamEvent` in packages/shared — a snapshot replaces both +/// halves, while the last known remote half is carried across same-branch local +/// updates and discarded when the branch changes. The explicit pending state is +/// needed because this client presents partial status. +struct NativeSourceControlStatusAccumulator { + private var local: VCSLocalStatus? + private var remote: VCSRemoteStatus? + /// Tracked separately from `remote` because the remote half can legitimately + /// resolve to nil — "known to be absent" is not the same as "still pending". + private var isRemoteResolved = false + private(set) var isComplete = false + + mutating func consume(_ event: VCSStatusEvent) -> FeatureSourceControlStatus? { + switch event { + case let .snapshot(nextLocal, nextRemote): + local = nextLocal + remote = nextRemote + isRemoteResolved = nextRemote != nil + case let .localUpdated(nextLocal): + if let previousLocal = local, previousLocal.refName != nextLocal.refName { + remote = nil + isRemoteResolved = false + } + local = nextLocal + case let .remoteUpdated(nextRemote): + remote = nextRemote + isRemoteResolved = true + } + + guard let local else { return nil } + // A workspace with no repository or no primary remote never receives a + // remote half, so nothing is pending in those cases. + let isRemoteKnown = isRemoteResolved || !local.isRepo || !local.hasPrimaryRemote + isComplete = isRemoteKnown + return NativeWorkspaceMapper.sourceControl( + local: local, + remote: remote, + isRemoteKnown: isRemoteKnown + ) + } + + func validateEnd() throws { + guard isComplete else { + throw RPCError.protocolViolation( + "The source-control status stream ended before completion." + ) + } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformAgentAwareness.swift b/apps/swift-ios/App/Platform/PlatformAgentAwareness.swift new file mode 100644 index 000000000000..38f1d03034d1 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformAgentAwareness.swift @@ -0,0 +1,457 @@ +import ActivityKit +import Foundation +import WidgetKit + +extension Notification.Name { + static let platformLiveActivityChanged = Notification.Name( + "T3PlatformLiveActivityChanged" + ) +} + +enum PlatformAgentAwarenessProjection { + static let terminalVisibilityWindow: TimeInterval = 15 * 60 + static let maximumRows = 5 + static let minimumPersistenceInterval: TimeInterval = 30 + + static func aggregate( + snapshot: FeatureSnapshot, + now: Date = .now + ) -> T3RelayAgentActivityAggregateState { + // Defensive against duplicate project IDs in aggregate snapshots; this + // runs on every snapshot revision, so it must never trap. + let projects = snapshot.projects.reduce(into: [String: FeatureProject]()) { + $0[$1.id] = $0[$1.id] ?? $1 + } + let eligible = snapshot.threads.filter { thread in + guard !thread.isArchived else { return false } + if isActive(thread.state) { return true } + guard thread.state == .completed || thread.state == .failed else { return false } + return now.timeIntervalSince(thread.updatedAt) < terminalVisibilityWindow + } + let rows = eligible.compactMap { thread -> T3RelayAgentActivityAggregateRow? in + guard let project = projects[thread.projectID] else { return nil } + let environmentID = thread.environmentID ?? project.environmentID + let threadID = thread.wireID ?? thread.id + let phase = phase(for: thread.state) + return T3RelayAgentActivityAggregateRow( + environmentId: environmentID, + threadId: threadID, + projectTitle: project.name, + threadTitle: thread.title, + modelTitle: modelTitle( + for: thread, + environmentID: environmentID, + snapshot: snapshot + ), + phase: phase, + status: status(for: thread.state), + updatedAt: thread.updatedAt.ISO8601Format(), + deepLink: PlatformRoute.thread( + environmentID: environmentID, + threadID: threadID + ).url?.absoluteString ?? "/" + ) + } + .sorted { left, right in + let leftPriority = priority(left.phase) + let rightPriority = priority(right.phase) + if leftPriority != rightPriority { return leftPriority < rightPriority } + return left.updatedAt > right.updatedAt + } + let visibleRows = Array(rows.prefix(maximumRows)) + let activeCount = eligible.count { isActive($0.state) } + let attentionCount = eligible.count { + $0.state == .waitingForApproval || $0.state == .waitingForInput + } + let subtitle: String + if attentionCount > 0 { + subtitle = attentionCount == 1 + ? "1 task needs attention" + : "\(attentionCount) tasks need attention" + } else if activeCount > 0 { + subtitle = activeCount == 1 ? "1 active task" : "\(activeCount) active tasks" + } else if visibleRows.contains(where: { $0.phase == .failed }) { + subtitle = "Agent work failed" + } else if !visibleRows.isEmpty { + subtitle = "Agent work completed" + } else { + subtitle = "Ready for a task" + } + return T3RelayAgentActivityAggregateState( + title: "T3 Code", + subtitle: subtitle, + activeCount: activeCount, + updatedAt: now.ISO8601Format(), + activities: visibleRows + ) + } + + static func widgetSnapshot( + snapshot: FeatureSnapshot, + now: Date = .now + ) -> T3TaskWidgetSnapshot { + let aggregate = aggregate(snapshot: snapshot, now: now) + return T3TaskWidgetSnapshot( + updatedAt: aggregate.updatedAt, + tasks: aggregate.activities + ) + } + + static func nextTerminalExpiry( + snapshot: FeatureSnapshot, + now: Date = .now + ) -> Date? { + snapshot.threads.lazy + .filter { + !$0.isArchived && ($0.state == .completed || $0.state == .failed) + } + .map { $0.updatedAt.addingTimeInterval(terminalVisibilityWindow) } + .filter { $0 > now } + .min() + } + + private static func isActive(_ state: FeatureThreadState) -> Bool { + switch state { + case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: + true + case .idle, .failed, .completed: + false + } + } + + private static func phase(for state: FeatureThreadState) -> T3AgentActivityPhase { + switch state { + case .queued: .starting + case .working: .running + case .monitoring: .running + case .waitingForApproval: .waitingForApproval + case .waitingForInput: .waitingForInput + case .failed: .failed + case .completed: .completed + case .idle: .stale + } + } + + private static func status(for state: FeatureThreadState) -> String { + switch state { + case .queued: "Starting" + case .working: "Working" + case .monitoring: "Monitoring" + case .waitingForApproval: "Approval" + case .waitingForInput: "Input" + case .failed: "Failed" + case .completed: "Done" + case .idle: "Idle" + } + } + + private static func priority(_ phase: T3AgentActivityPhase) -> Int { + switch phase { + case .waitingForApproval, .waitingForInput: 0 + case .failed: 1 + case .starting, .running: 2 + case .completed, .stale: 3 + } + } + + private static func modelTitle( + for thread: FeatureThread, + environmentID: String, + snapshot: FeatureSnapshot + ) -> String { + let providers = snapshot.providersByEnvironment?[environmentID] ?? [] + let provider = thread.providerID.flatMap { providerID in + providers.first { $0.id == providerID } + } + if let modelID = thread.modelID, + let model = provider?.models.first(where: { $0.id == modelID }) + { + return model.name + } + return thread.modelID ?? provider?.name ?? thread.providerName ?? "" + } +} + +@MainActor +final class PlatformAgentAwarenessCoordinator { + static let shared = PlatformAgentAwarenessCoordinator() + + private let updateLiveActivity: @MainActor ( + T3RelayAgentActivityAggregateState, + Bool, + Date + ) async throws -> Void + private let endLiveActivities: @MainActor () async -> Void + + private struct Signature: Equatable { + let activeCount: Int + let subtitle: String + let rows: [T3RelayAgentActivityAggregateRow] + let enabled: Bool + } + + private struct Synchronization { + let signature: Signature + let aggregate: T3RelayAgentActivityAggregateState + let enabled: Bool + let now: Date + } + + private var activityUpdateTask: Task? + private var widgetUpdateTask: Task? + private var terminalExpiryTask: Task? + private var lastSignature: Signature? + private var inFlightSignature: Signature? + private var synchronizationGeneration = 0 + private var widgetGeneration = 0 + private var terminalExpiryGeneration = 0 + + init( + updateLiveActivity: @escaping @MainActor ( + T3RelayAgentActivityAggregateState, + Bool, + Date + ) async throws -> Void = { aggregate, enabled, now in + try await PlatformAgentAwarenessCoordinator.synchronizeLiveActivity( + aggregate: aggregate, + enabled: enabled, + now: now + ) + }, + endLiveActivities: @escaping @MainActor () async -> Void = { + await PlatformAgentAwarenessCoordinator.endAllLiveActivities() + } + ) { + self.updateLiveActivity = updateLiveActivity + self.endLiveActivities = endLiveActivities + } + + func synchronize(snapshot: FeatureSnapshot, liveActivitiesEnabled: Bool) { + let now = Date.now + scheduleTerminalExpiry( + snapshot: snapshot, + liveActivitiesEnabled: liveActivitiesEnabled, + now: now + ) + let aggregate = PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: now + ) + let signature = Signature( + activeCount: aggregate.activeCount, + subtitle: aggregate.subtitle, + // `updatedAt` advances throughout a turn without changing visible + // state. Bucket it so long work still refreshes ActivityKit's stale + // date without writing widget state for every shell delta. + rows: aggregate.activities.map { row in + var row = row + let bucket = floor( + now.timeIntervalSince1970 / PlatformAgentAwarenessProjection.minimumPersistenceInterval + ) * PlatformAgentAwarenessProjection.minimumPersistenceInterval + row.updatedAt = Date(timeIntervalSince1970: bucket).ISO8601Format() + return row + }, + enabled: liveActivitiesEnabled + ) + let synchronization = Synchronization( + signature: signature, + aggregate: aggregate, + enabled: liveActivitiesEnabled, + now: now + ) + if signature == lastSignature { + if inFlightSignature != nil { + activityUpdateTask?.cancel() + synchronizationGeneration &+= 1 + inFlightSignature = nil + activityUpdateTask = nil + } + return + } + guard signature != inFlightSignature else { return } + + let widgetSnapshot = T3TaskWidgetSnapshot( + updatedAt: aggregate.updatedAt, + tasks: aggregate.activities + ) + scheduleWidgetUpdate(widgetSnapshot) + + schedule(synchronization) + } + + /// Account sign-out invalidates the cached account-scoped projection before + /// removing its activity. Only a later snapshot may publish new content. + func resetAndResynchronizeLiveActivity() { + activityUpdateTask?.cancel() + terminalExpiryTask?.cancel() + terminalExpiryGeneration &+= 1 + terminalExpiryTask = nil + synchronizationGeneration &+= 1 + let generation = synchronizationGeneration + lastSignature = nil + inFlightSignature = nil + scheduleWidgetUpdate(.empty) + activityUpdateTask = Task { @MainActor [weak self] in + guard let self else { return } + await endLiveActivities() + guard synchronizationGeneration == generation else { return } + activityUpdateTask = nil + } + } + + private func schedule(_ synchronization: Synchronization) { + activityUpdateTask?.cancel() + synchronizationGeneration &+= 1 + let generation = synchronizationGeneration + inFlightSignature = synchronization.signature + activityUpdateTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await updateLiveActivity( + synchronization.aggregate, + synchronization.enabled, + synchronization.now + ) + try Task.checkCancellation() + guard synchronizationGeneration == generation, + inFlightSignature == synchronization.signature else { return } + lastSignature = synchronization.signature + inFlightSignature = nil + activityUpdateTask = nil + } catch { + guard synchronizationGeneration == generation, + inFlightSignature == synchronization.signature else { return } + // Keep the completed signature unchanged so the next identical + // snapshot retries a failed or cancelled ActivityKit operation. + inFlightSignature = nil + activityUpdateTask = nil + } + } + } + + private func scheduleWidgetUpdate(_ snapshot: T3TaskWidgetSnapshot) { + widgetUpdateTask?.cancel() + widgetGeneration &+= 1 + let generation = widgetGeneration + widgetUpdateTask = Task { @MainActor [weak self] in + let saved = await PlatformWidgetSnapshotWriter.shared.save( + snapshot, + generation: generation + ) + guard let self, saved, widgetGeneration == generation else { return } + WidgetCenter.shared.reloadTimelines(ofKind: "T3RecentTasksWidget") + widgetUpdateTask = nil + } + } + + private func scheduleTerminalExpiry( + snapshot: FeatureSnapshot, + liveActivitiesEnabled: Bool, + now: Date + ) { + terminalExpiryTask?.cancel() + terminalExpiryGeneration &+= 1 + let generation = terminalExpiryGeneration + guard let expiry = PlatformAgentAwarenessProjection.nextTerminalExpiry( + snapshot: snapshot, + now: now + ) else { + terminalExpiryTask = nil + return + } + let delay = max(0, expiry.timeIntervalSince(now)) + terminalExpiryTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard let self, + terminalExpiryGeneration == generation else { return } + terminalExpiryTask = nil + synchronize( + snapshot: snapshot, + liveActivitiesEnabled: liveActivitiesEnabled + ) + } + } + + private static func synchronizeLiveActivity( + aggregate: T3RelayAgentActivityAggregateState, + enabled: Bool, + now: Date + ) async throws { + try Task.checkCancellation() + let activities = Activity.activities + + guard enabled, ActivityAuthorizationInfo().areActivitiesEnabled else { + for activity in activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + try Task.checkCancellation() + notifyActivityChanged() + return + } + + let state = try LiveActivityAttributes.ContentState(aggregate: aggregate) + let content = ActivityContent( + state: state, + staleDate: now.addingTimeInterval(10 * 60) + ) + + if aggregate.activeCount == 0 { + for activity in activities { + await activity.end( + content, + dismissalPolicy: .after(now.addingTimeInterval(5 * 60)) + ) + } + try Task.checkCancellation() + notifyActivityChanged() + return + } + + if let primary = activities.first { + await primary.update(content) + for duplicate in activities.dropFirst() { + await duplicate.end(nil, dismissalPolicy: .immediate) + } + } else { + _ = try Activity.request( + attributes: LiveActivityAttributes(), + content: content, + pushType: .token + ) + } + try Task.checkCancellation() + notifyActivityChanged() + } + + private static func endAllLiveActivities() async { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + notifyActivityChanged() + } + + private static func notifyActivityChanged() { + NotificationCenter.default.post(name: .platformLiveActivityChanged, object: nil) + } +} + +private actor PlatformWidgetSnapshotWriter { + static let shared = PlatformWidgetSnapshotWriter() + + private var latestGeneration = 0 + + func save(_ snapshot: T3TaskWidgetSnapshot, generation: Int) -> Bool { + guard generation >= latestGeneration else { return false } + latestGeneration = generation + do { + try T3TaskWidgetSnapshotStore.save(snapshot) + return true + } catch { + return false + } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformBackgroundRefresh.swift b/apps/swift-ios/App/Platform/PlatformBackgroundRefresh.swift new file mode 100644 index 000000000000..2debade12977 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformBackgroundRefresh.swift @@ -0,0 +1,93 @@ +import BackgroundTasks +import Foundation + +@MainActor +final class PlatformBackgroundRefreshCoordinator { + typealias RefreshAction = @MainActor @Sendable () async -> Bool + + static let shared = PlatformBackgroundRefreshCoordinator() + static var identifier: String { + "\(Bundle.main.bundleIdentifier ?? "com.t3tools.t3code.swiftui").refresh" + } + + private var refreshAction: RefreshAction? + private var isRegistered = false + + func install(refreshAction: @escaping RefreshAction) { + self.refreshAction = refreshAction + } + + func register() { + guard !isRegistered else { return } + isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.identifier, + using: nil + ) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + Task { @MainActor in + await Self.shared.handle(refreshTask) + } + } + } + + func schedule() { + guard isRegistered else { return } + let request = BGAppRefreshTaskRequest(identifier: Self.identifier) + request.earliestBeginDate = Date().addingTimeInterval( + PlatformBackgroundRefreshPolicy.minimumDelay + ) + try? BGTaskScheduler.shared.submit(request) + } + + private func handle(_ task: BGAppRefreshTask) async { + schedule() + guard let refreshAction else { + task.setTaskCompleted(success: false) + return + } + + // Install cancellation before creating the operation. If expiration + // wins the race, `install` immediately cancels the new task. + let cancellation = PlatformBackgroundRefreshCancellation() + task.expirationHandler = { + cancellation.cancel() + } + let operation = Task { @MainActor in + await refreshAction() + } + cancellation.install(operation) + let succeeded = await operation.value + task.setTaskCompleted(success: succeeded && !operation.isCancelled) + } +} + +private final class PlatformBackgroundRefreshCancellation: @unchecked Sendable { + private let lock = NSLock() + private var operation: Task? + private var didExpire = false + + func install(_ operation: Task) { + let shouldCancel = lock.withLock { + self.operation = operation + return didExpire + } + if shouldCancel { operation.cancel() } + } + + func cancel() { + let operation = lock.withLock { + didExpire = true + return self.operation + } + operation?.cancel() + } +} + +enum PlatformBackgroundRefreshPolicy { + /// iOS chooses the actual cadence. Fifteen minutes is merely the earliest + /// useful retry and avoids repeatedly asking the scheduler for immediate work. + static let minimumDelay: TimeInterval = 15 * 60 +} diff --git a/apps/swift-ios/App/Platform/PlatformCloudDelivery.swift b/apps/swift-ios/App/Platform/PlatformCloudDelivery.swift new file mode 100644 index 000000000000..28272f1ab063 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformCloudDelivery.swift @@ -0,0 +1,396 @@ +import ActivityKit +import CryptoKit +import Foundation +import Security +import UIKit + +enum PlatformInstallationIdentity { + private static let service = "com.t3tools.t3code.swiftui.installation" + private static let account = "device-id" + private static let fallbackKey = "swift-ios.installation-id.v1" + + static func value() -> String { + if let stored = readKeychainValue() { return stored } + let created = UUID().uuidString.lowercased() + let insertion: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: Data(created.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemAdd(insertion as CFDictionary, nil) + if status == errSecSuccess { return created } + if status == errSecDuplicateItem, let winner = readKeychainValue() { return winner } + return value(defaults: .standard) + } + + /// Injectable fallback used only when Keychain is unavailable and by tests. + static func value(defaults: UserDefaults) -> String { + if let existing = defaults.string(forKey: fallbackKey), !existing.isEmpty { + return existing + } + let created = UUID().uuidString.lowercased() + defaults.set(created, forKey: fallbackKey) + return created + } + + private static func readKeychainValue() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + UUID(uuidString: value) != nil else { + return nil + } + return value.lowercased() + } +} + +enum PlatformCloudDeliveryRegistrationFactory { + static func registration( + deviceID: String, + deviceName: String, + systemVersion: OperatingSystemVersion, + appVersion: String?, + bundleID: String?, + pushToken: String?, + pushToStartToken: String?, + settings: FeatureSettings, + apsEnvironment: T3ConnectDeviceRegistration.APNSEnvironment + ) -> T3ConnectDeviceRegistration { + T3ConnectDeviceRegistration( + deviceID: deviceID, + label: deviceName, + iosMajorVersion: systemVersion.majorVersion, + appVersion: appVersion, + bundleID: bundleID, + apsEnvironment: apsEnvironment, + pushToken: pushToken, + pushToStartToken: pushToStartToken, + preferences: T3ConnectDevicePreferences( + liveActivitiesEnabled: settings.liveActivitiesEnabled, + notificationsEnabled: settings.notificationsEnabled + ) + ) + } +} + +/// Keeps the relay's device record aligned with this installation. Token values +/// only travel over DPoP-authenticated relay requests; local success caches store +/// SHA-256 fingerprints rather than reusable push credentials. +@MainActor +final class PlatformCloudDeliveryCoordinator { + static let shared = PlatformCloudDeliveryCoordinator() + + private let defaults: UserDefaults + private let tokenSink: PlatformPersistedDeviceTokenSink + private let deviceID: String + + private weak var controller: T3ConnectController? + private var settings: FeatureSettings? + private var needsRegistration = false + private var registrationTask: Task? + private var observerTasks: [Task] = [] + private var pushToStartTask: Task? + private var activityUpdatesTask: Task? + private var activityTokenTasks: [String: Task] = [:] + private var pendingActivityTokens: Set = [] + private var retryTask: Task? + private var observedAccountID: String? + + private let deviceFingerprintKey = "swift-ios.cloud-delivery-device.v1" + private let deviceRegisteredAtKey = "swift-ios.cloud-delivery-device-date.v1" + private let activityFingerprintKey = "swift-ios.cloud-delivery-activities.v1" + private let healingInterval: TimeInterval = 60 + + init( + defaults: UserDefaults = .standard, + tokenSink: PlatformPersistedDeviceTokenSink? = nil, + deviceID: String? = nil + ) { + self.defaults = defaults + self.tokenSink = tokenSink ?? .shared + self.deviceID = deviceID ?? PlatformInstallationIdentity.value() + } + + deinit { + registrationTask?.cancel() + pushToStartTask?.cancel() + activityUpdatesTask?.cancel() + retryTask?.cancel() + observerTasks.forEach { $0.cancel() } + activityTokenTasks.values.forEach { $0.cancel() } + } + + func install(controller: T3ConnectController) { + self.controller = controller + guard observerTasks.isEmpty else { + handleAccountChange(to: controller.account?.id) + requestRegistration() + return + } + observedAccountID = controller.account?.id + + for name in [Notification.Name.platformDeviceTokenChanged, .platformLiveActivityChanged] { + observerTasks.append(Task { @MainActor [weak self] in + for await _ in NotificationCenter.default.notifications(named: name) { + guard !Task.isCancelled else { return } + self?.refreshActivityTokenObservers() + self?.requestRegistration() + } + }) + } + observerTasks.append(Task { @MainActor [weak self] in + for await notification in NotificationCenter.default.notifications( + named: .t3ConnectSessionChanged + ) { + guard !Task.isCancelled, let self else { return } + guard let controller = self.controller, + let notificationController = notification.object as? T3ConnectController, + notificationController === controller else { continue } + handleAccountChange(to: controller.account?.id) + refreshActivityTokenObservers() + requestRegistration() + } + }) + + if #available(iOS 17.2, *) { + pushToStartTask = Task { @MainActor [weak self] in + for await _ in Activity.pushToStartTokenUpdates { + guard !Task.isCancelled else { return } + self?.requestRegistration() + } + } + } + activityUpdatesTask = Task { @MainActor [weak self] in + for await activity in Activity.activityUpdates { + guard !Task.isCancelled, let self else { return } + if let token = activity.pushToken?.hexadecimalString { + pendingActivityTokens.insert(token) + } + refreshActivityTokenObservers() + requestRegistration() + } + } + refreshActivityTokenObservers() + requestRegistration() + } + + func synchronize(settings: FeatureSettings) { + self.settings = settings + refreshActivityTokenObservers() + requestRegistration() + } + + private func refreshActivityTokenObservers() { + let activities = Activity.activities + let currentIDs = Set(activities.map(\.id)) + for id in Array(activityTokenTasks.keys) where !currentIDs.contains(id) { + activityTokenTasks.removeValue(forKey: id)?.cancel() + } + + for activity in activities { + if let token = activity.pushToken?.hexadecimalString { + pendingActivityTokens.insert(token) + } + guard activityTokenTasks[activity.id] == nil else { continue } + activityTokenTasks[activity.id] = Task { @MainActor [weak self] in + for await token in activity.pushTokenUpdates { + guard !Task.isCancelled else { return } + self?.pendingActivityTokens.insert(token.hexadecimalString) + self?.requestRegistration() + } + } + } + } + + private func requestRegistration() { + retryTask?.cancel() + retryTask = nil + needsRegistration = true + guard registrationTask == nil else { return } + registrationTask = Task { @MainActor [weak self] in + guard let self else { return } + while needsRegistration, !Task.isCancelled { + needsRegistration = false + await registerCurrentState() + await Task.yield() + } + registrationTask = nil + if needsRegistration { requestRegistration() } + } + } + + private func registerCurrentState() async { + guard let controller, let settings else { return } + let registration = currentRegistration(settings: settings) + guard registration.iosMajorVersion >= 18 else { return } + controller.rememberRegisteredDevice(id: deviceID) + + let accountBeforeRequest = controller.account?.id + let fingerprint = Self.fingerprint( + registration, + accountID: accountBeforeRequest + ) + let canReuseRegistration = accountBeforeRequest != nil + && defaults.string(forKey: deviceFingerprintKey) == fingerprint + && Date.now.timeIntervalSince( + defaults.object(forKey: deviceRegisteredAtKey) as? Date ?? .distantPast + ) < healingInterval + + do { + if !canReuseRegistration { + try await controller.registerDevice(registration) + guard accountBeforeRequest == nil + || controller.account?.id == accountBeforeRequest else { + needsRegistration = true + return + } + defaults.set( + Self.fingerprint(registration, accountID: controller.account?.id), + forKey: deviceFingerprintKey + ) + defaults.set(Date.now, forKey: deviceRegisteredAtKey) + } + + guard let accountID = controller.account?.id else { return } + let now = Date.now.timeIntervalSince1970 + var completed = (defaults.dictionary(forKey: activityFingerprintKey) ?? [:]) + .compactMapValues { ($0 as? NSNumber)?.doubleValue } + .filter { now - $0.value < healingInterval } + for token in Array(pendingActivityTokens) { + let tokenFingerprint = Self.fingerprint( + "\(deviceID)|\(token)", + accountID: accountID + ) + guard completed[tokenFingerprint] == nil else { + pendingActivityTokens.remove(token) + continue + } + try await controller.registerLiveActivity( + T3ConnectLiveActivityRegistration( + deviceID: deviceID, + activityPushToken: token + ) + ) + guard controller.account?.id == accountID else { + needsRegistration = true + return + } + completed[tokenFingerprint] = now + pendingActivityTokens.remove(token) + } + let bounded = completed.sorted { $0.value > $1.value }.prefix(8) + defaults.set( + Dictionary(uniqueKeysWithValues: bounded.map { ($0.key, $0.value) }), + forKey: activityFingerprintKey + ) + scheduleRetry(after: healingInterval) + } catch is CancellationError { + return + } catch is T3ConnectAuthError { + return + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + scheduleRetry(after: 15) + return + } + } catch { + // Signed-out and offline states are expected. The next account, + // network, foreground, or token event retries without noisy UI. + scheduleRetry(after: 15) + return + } + } + + private func scheduleRetry(after delay: TimeInterval) { + retryTask?.cancel() + retryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + retryTask = nil + refreshActivityTokenObservers() + requestRegistration() + } + } + + private func clearSuccessfulRegistrationCache() { + defaults.removeObject(forKey: deviceFingerprintKey) + defaults.removeObject(forKey: deviceRegisteredAtKey) + defaults.removeObject(forKey: activityFingerprintKey) + } + + private func handleAccountChange(to accountID: String?) { + guard observedAccountID != accountID else { return } + clearSuccessfulRegistrationCache() + pendingActivityTokens.removeAll() + PlatformAgentAwarenessCoordinator.shared.resetAndResynchronizeLiveActivity() + observedAccountID = accountID + } + + private func currentRegistration(settings: FeatureSettings) -> T3ConnectDeviceRegistration { + let version = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String + let pushToStartToken: String? = if #available(iOS 17.2, *) { + Activity.pushToStartToken?.hexadecimalString + } else { + nil + } + var effectiveSettings = settings + effectiveSettings.notificationsEnabled = settings.notificationsEnabled + && PlatformNotificationService.shared.enabled + && tokenSink.currentToken != nil + effectiveSettings.liveActivitiesEnabled = settings.liveActivitiesEnabled + && ActivityAuthorizationInfo().areActivitiesEnabled + return PlatformCloudDeliveryRegistrationFactory.registration( + deviceID: deviceID, + deviceName: UIDevice.current.name, + systemVersion: ProcessInfo.processInfo.operatingSystemVersion, + appVersion: version, + bundleID: Bundle.main.bundleIdentifier, + pushToken: tokenSink.currentToken, + pushToStartToken: pushToStartToken, + settings: effectiveSettings, + apsEnvironment: Self.apsEnvironment + ) + } + + private static var apsEnvironment: T3ConnectDeviceRegistration.APNSEnvironment { + #if DEBUG + .sandbox + #else + .production + #endif + } + + private static func fingerprint( + _ value: Value, + accountID: String? + ) -> String { + let data = (try? JSONEncoder.t3.encode(value)) ?? Data() + return fingerprint(Data((accountID ?? "unloaded").utf8) + data) + } + + private static func fingerprint(_ value: String, accountID: String?) -> String { + fingerprint(Data("\(accountID ?? "unloaded")|\(value)".utf8)) + } + + private static func fingerprint(_ data: Data) -> String { + Data(SHA256.hash(data: data)).hexadecimalString + } +} + +private extension Data { + var hexadecimalString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apps/swift-ios/App/Platform/PlatformDeepLinks.swift b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift new file mode 100644 index 000000000000..94d96d941649 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift @@ -0,0 +1,308 @@ +import Foundation + +enum PlatformRoute: Codable, Hashable, Identifiable, Sendable { + #if DEBUG + static let nativeScheme = "t3code-swiftui-dev" + #else + static let nativeScheme = "t3code-swiftui" + #endif + + case connection(endpoint: String, token: String?) + case environment(id: String) + case project(environmentID: String?, projectID: String) + case thread(environmentID: String?, threadID: String) + case newTask(environmentID: String?, projectID: String?) + + var id: String { + switch self { + case let .connection(endpoint, token): + "connection:\(endpoint):\(token ?? "")" + case let .environment(id): + "environment:\(id)" + case let .project(environmentID, projectID): + "project:\(environmentID ?? ""):\(projectID)" + case let .thread(environmentID, threadID): + "thread:\(environmentID ?? ""):\(threadID)" + case let .newTask(environmentID, projectID): + "new-task:\(environmentID ?? ""):\(projectID ?? "")" + } + } + + var url: URL? { + var components = URLComponents() + components.scheme = Self.nativeScheme + + switch self { + case let .connection(endpoint, token): + components.host = "connect" + components.queryItems = [URLQueryItem(name: "endpoint", value: endpoint)] + if let token { + components.queryItems?.append(URLQueryItem(name: "token", value: token)) + } + case let .environment(id): + components.host = "environments" + components.queryItems = [URLQueryItem(name: "environment", value: id)] + case let .project(environmentID, projectID): + components.host = "projects" + components.queryItems = [ + environmentID.map { URLQueryItem(name: "environment", value: $0) }, + URLQueryItem(name: "project", value: projectID), + ].compactMap { $0 } + case let .thread(environmentID, threadID): + components.host = "threads" + components.queryItems = [ + environmentID.map { URLQueryItem(name: "environment", value: $0) }, + URLQueryItem(name: "thread", value: threadID), + ].compactMap { $0 } + case let .newTask(environmentID, projectID): + components.host = "new-task" + components.queryItems = [ + environmentID.map { URLQueryItem(name: "environment", value: $0) }, + projectID.map { URLQueryItem(name: "project", value: $0) }, + ].compactMap { $0 } + } + return components.url + } +} + +enum PlatformDeepLinkError: LocalizedError, Equatable { + case unsupportedURL + case missingIdentifier + case invalidIdentifier + + var errorDescription: String? { + switch self { + case .unsupportedURL: + "That T3 Code link is not supported." + case .missingIdentifier: + "That T3 Code link is missing its destination." + case .invalidIdentifier: + "That T3 Code link contains an invalid destination." + } + } +} + +enum PlatformDeepLinkParser { + private static let trustedWebHosts: Set = [ + "app.t3.codes", + "t3.codes", + "www.t3.codes", + ] + + static func parse(_ url: URL) throws -> PlatformRoute { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased() + else { + throw PlatformDeepLinkError.unsupportedURL + } + + let query = queryValues(components.queryItems ?? []) + if ["t3", "t3code", "t3code-swiftui", "t3code-swiftui-dev"].contains(scheme) { + let segments = customSchemeSegments(components) + if isConnectionRoute(segments: segments, query: query) { + return try connectionRoute(url) + } + return try navigationRoute(segments: segments, query: query) + } + + guard ["http", "https"].contains(scheme) else { + throw PlatformDeepLinkError.unsupportedURL + } + + guard let host = components.host?.lowercased(), trustedWebHosts.contains(host) else { + throw PlatformDeepLinkError.unsupportedURL + } + + let segments = pathSegments(components.percentEncodedPath) + if isConnectionRoute(segments: segments, query: query) { + return try connectionRoute(url) + } + + if let explicit = try? navigationRoute(segments: segments, query: query) { + return explicit + } + + // Web thread routes use /:environmentID/:threadID. + if segments.count >= 2 { + return .thread( + environmentID: try validatedIdentifier(segments[0]), + threadID: try validatedIdentifier(segments[1]) + ) + } + throw PlatformDeepLinkError.unsupportedURL + } + + static func parse(_ value: String) throws -> PlatformRoute { + guard let url = URL(string: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw PlatformDeepLinkError.unsupportedURL + } + return try parse(url) + } + + private static func navigationRoute( + segments: [String], + query: [String: String] + ) throws -> PlatformRoute { + let head = segments.first?.lowercased() ?? "" + let tail = Array(segments.dropFirst()) + let queryEnvironment = query["environment"] ?? query["environmentid"] ?? query["env"] + let queryProject = query["project"] ?? query["projectid"] + let queryThread = query["thread"] ?? query["threadid"] + + switch head { + case "thread", "threads": + let values = try routeIdentifiers( + tail: tail, + queryEnvironment: queryEnvironment, + queryDestination: queryThread + ) + return .thread(environmentID: values.environmentID, threadID: values.destinationID) + case "project", "projects": + let values = try routeIdentifiers( + tail: tail, + queryEnvironment: queryEnvironment, + queryDestination: queryProject + ) + return .project(environmentID: values.environmentID, projectID: values.destinationID) + case "environment", "environments", "server", "servers": + guard let rawID = tail.first ?? queryEnvironment else { + throw PlatformDeepLinkError.missingIdentifier + } + return .environment(id: try validatedIdentifier(rawID)) + case "new", "new-task", "compose": + return .newTask( + environmentID: try queryEnvironment.map(validatedIdentifier), + projectID: try queryProject.map(validatedIdentifier) + ) + default: + if let queryThread { + return .thread( + environmentID: try queryEnvironment.map(validatedIdentifier), + threadID: try validatedIdentifier(queryThread) + ) + } + if let queryProject { + return .project( + environmentID: try queryEnvironment.map(validatedIdentifier), + projectID: try validatedIdentifier(queryProject) + ) + } + throw PlatformDeepLinkError.unsupportedURL + } + } + + private static func routeIdentifiers( + tail: [String], + queryEnvironment: String?, + queryDestination: String? + ) throws -> (environmentID: String?, destinationID: String) { + if let queryDestination { + return ( + try queryEnvironment.map(validatedIdentifier), + try validatedIdentifier(queryDestination) + ) + } + if tail.count >= 2 { + return ( + try validatedIdentifier(tail[0]), + try validatedIdentifier(tail[1]) + ) + } + guard let destination = tail.first else { + throw PlatformDeepLinkError.missingIdentifier + } + return (try queryEnvironment.map(validatedIdentifier), try validatedIdentifier(destination)) + } + + private static func connectionRoute(_ url: URL) throws -> PlatformRoute { + do { + let details = try ConnectionDetailsParser.parse(url.absoluteString) + return .connection(endpoint: details.endpoint, token: details.pairingCode) + } catch { + throw PlatformDeepLinkError.unsupportedURL + } + } + + private static func isConnectionRoute( + segments: [String], + query: [String: String] + ) -> Bool { + let head = segments.first?.lowercased() + return ["connect", "pair", "pairing"].contains(head) + || query["pairingurl"] != nil + || query["pairing_url"] != nil + || query["endpoint"] != nil + || query["server"] != nil + || query["host"] != nil + } + + private static func customSchemeSegments(_ components: URLComponents) -> [String] { + var result: [String] = [] + if let host = components.host, !host.isEmpty { + result.append(host) + } + result.append(contentsOf: pathSegments(components.percentEncodedPath)) + return result + } + + private static func pathSegments(_ percentEncodedPath: String) -> [String] { + percentEncodedPath + .split(separator: "/", omittingEmptySubsequences: true) + .map { String($0).removingPercentEncoding ?? String($0) } + } + + private static func queryValues(_ items: [URLQueryItem]) -> [String: String] { + items.reduce(into: [:]) { result, item in + guard let value = item.value, !value.isEmpty else { return } + result[item.name.lowercased()] = value + } + } + + private static func validatedIdentifier(_ rawValue: String) throws -> String { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { throw PlatformDeepLinkError.missingIdentifier } + guard value.utf8.count <= 1_024, + value != ".", + value != "..", + value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) + else { + throw PlatformDeepLinkError.invalidIdentifier + } + return value + } +} + +/// One-shot storage bridges app intents and notification launches to the live scene. +final class PlatformRouteMailbox: @unchecked Sendable { + static let shared = PlatformRouteMailbox() + + private let defaults: UserDefaults + private let key: String + private let lock = NSLock() + + init(defaults: UserDefaults = .standard, key: String = "swift-ios.pending-platform-route.v1") { + self.defaults = defaults + self.key = key + } + + func put(_ route: PlatformRoute) { + lock.withLock { + defaults.set(try? JSONEncoder().encode(route), forKey: key) + } + } + + func take() -> PlatformRoute? { + lock.withLock { + guard let data = defaults.data(forKey: key) else { return nil } + defaults.removeObject(forKey: key) + return try? JSONDecoder().decode(PlatformRoute.self, from: data) + } + } + + func peek() -> PlatformRoute? { + lock.withLock { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(PlatformRoute.self, from: data) + } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformFeedback.swift b/apps/swift-ios/App/Platform/PlatformFeedback.swift new file mode 100644 index 000000000000..f50b1be5a77a --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformFeedback.swift @@ -0,0 +1,66 @@ +import UIKit + +enum PlatformFeedbackKind: Equatable, Sendable { + case success + case warning + case error +} + +struct PlatformThreadSignal: Equatable, Sendable { + let kind: PlatformFeedbackKind + let thread: FeatureThread +} + +enum PlatformThreadTransitionClassifier { + /// Previous states are kept as a bare `[id: state]` map so each home + /// revision retains a dictionary of enums, not a copy of every thread. + static func signals( + previous: [String: FeatureThreadState]?, + current: [FeatureThread] + ) -> [PlatformThreadSignal] { + guard let previous else { return [] } + + return current.compactMap { thread in + guard let oldState = previous[thread.id], oldState != thread.state else { return nil } + let kind: PlatformFeedbackKind? = switch thread.state { + case .waitingForApproval, .waitingForInput: + .warning + case .failed: + .error + case .completed where oldState == .working + || oldState == .queued + || oldState == .monitoring: + .success + default: + nil + } + return kind.map { PlatformThreadSignal(kind: $0, thread: thread) } + } + } +} + +@MainActor +final class PlatformHapticEngine { + static let shared = PlatformHapticEngine() + + func emit(_ kind: PlatformFeedbackKind, enabled: Bool) { + guard enabled else { return } + let generator = UINotificationFeedbackGenerator() + generator.prepare() + switch kind { + case .success: + generator.notificationOccurred(.success) + case .warning: + generator.notificationOccurred(.warning) + case .error: + generator.notificationOccurred(.error) + } + } + + func selection(enabled: Bool) { + guard enabled else { return } + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + } +} diff --git a/apps/swift-ios/App/Platform/PlatformIncomingShare.swift b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift new file mode 100644 index 000000000000..49da8570d236 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift @@ -0,0 +1,437 @@ +import Foundation +import Observation +import SwiftUI + +enum PlatformIncomingShareError: LocalizedError, Equatable { + case missingImage(String) + case invalidImage(String) + case missingFile(String) + case invalidFile(String) + case invalidEnvelope + + var errorDescription: String? { + switch self { + case let .missingImage(name): + "The shared image \(name) is no longer available. Share it again to retry." + case let .invalidImage(name): + "The shared image \(name) is incomplete or too large. Share it again to retry." + case let .missingFile(name): + "The shared file \(name) is no longer available. Share it again to retry." + case let .invalidFile(name): + "The shared file \(name) is incomplete or too large. Share it again to retry." + case .invalidEnvelope: + "This shared item is invalid. Share it again to retry." + } + } +} + +struct PlatformIncomingShareSource: Sendable { + var loadAll: @Sendable () async -> [T3IncomingShareEnvelope] + var data: @Sendable (T3IncomingShareImage) async throws -> Data + var fileURL: @Sendable (T3IncomingShareFile) async throws -> URL + var remove: @Sendable (String) async throws -> Void + + init( + loadAll: @escaping @Sendable () async -> [T3IncomingShareEnvelope], + data: @escaping @Sendable (T3IncomingShareImage) async throws -> Data, + remove: @escaping @Sendable (String) async throws -> Void, + fileURL: @escaping @Sendable (T3IncomingShareFile) async throws -> URL = { file in + guard let url = T3IncomingShareStore.fileURL(for: file) else { + throw PlatformIncomingShareError.missingFile(file.fileName) + } + return url + } + ) { + self.loadAll = loadAll + self.data = data + self.fileURL = fileURL + self.remove = remove + } + + static let live = PlatformIncomingShareSource( + loadAll: { + await Task.detached(priority: .utility) { + T3IncomingShareStore.loadAll() + }.value + }, + data: { image in + guard let root = T3SharedContainer.rootURL?.standardizedFileURL, + let url = T3IncomingShareStore.fileURL(for: image)?.standardizedFileURL, + url.path.hasPrefix(root.path + "/") else { + throw PlatformIncomingShareError.missingImage(image.fileName) + } + let data = try await Task.detached(priority: .userInitiated) { + guard FileManager.default.fileExists(atPath: url.path) else { + throw PlatformIncomingShareError.missingImage(image.fileName) + } + return try Data(contentsOf: url, options: .mappedIfSafe) + }.value + guard !data.isEmpty, + data.count <= T3IncomingShareStore.maximumImageBytes, + data.count == image.byteCount else { + throw PlatformIncomingShareError.invalidImage(image.fileName) + } + return data + }, + remove: { id in + guard UUID(uuidString: id) != nil else { + throw PlatformIncomingShareError.invalidEnvelope + } + try await Task.detached(priority: .utility) { + try T3IncomingShareStore.remove(id: id) + }.value + }, + fileURL: { file in + guard let root = T3SharedContainer.rootURL?.standardizedFileURL, + let url = T3IncomingShareStore.fileURL(for: file)?.standardizedFileURL, + url.path.hasPrefix(root.path + "/") else { + throw PlatformIncomingShareError.missingFile(file.fileName) + } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, + let byteCount = values.fileSize, + byteCount > 0, + byteCount <= T3IncomingShareStore.maximumFileBytes, + byteCount == file.byteCount else { + throw PlatformIncomingShareError.invalidFile(file.fileName) + } + return url + } + ) +} + +struct PlatformIncomingShareDraftRepository: Sendable { + var importContent: @Sendable ( + _ shareID: String, + _ text: String, + _ attachments: [FeatureDraftAttachment], + _ key: String, + _ maximumAttachmentCount: Int + ) async throws -> FeatureComposerDraft + + static let live = PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumAttachmentCount in + try await FeatureComposerDraftStore.shared.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumAttachmentCount + ) + } + ) +} + +/// Moves one extension envelope into the durable new-task draft. The saved +/// attachment identifiers make the operation idempotent if inbox cleanup fails +/// after the atomic draft write. +struct PlatformIncomingSharePipeline: Sendable { + static let maximumAttachmentCount = 8 + + private let source: PlatformIncomingShareSource + private let drafts: PlatformIncomingShareDraftRepository + private let prepareImage: @Sendable (Data, Int) async throws -> FeatureDraftAttachment + private let attachmentFileStore: ManagedAttachmentFileStore + + init( + source: PlatformIncomingShareSource = .live, + drafts: PlatformIncomingShareDraftRepository = .live, + prepareImage: @escaping @Sendable (Data, Int) async throws -> FeatureDraftAttachment = { + data, + ordinal in + try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + }, + attachmentFileStore: ManagedAttachmentFileStore = ManagedAttachmentFileStore() + ) { + self.source = source + self.drafts = drafts + self.prepareImage = prepareImage + self.attachmentFileStore = attachmentFileStore + } + + func pendingEnvelopes() async -> [T3IncomingShareEnvelope] { + await source.loadAll() + } + + func importEnvelope( + _ envelope: T3IncomingShareEnvelope, + into project: FeatureProject, + draftKey: String? = nil + ) async throws -> FeatureComposerDraft { + guard UUID(uuidString: envelope.id) != nil else { + throw PlatformIncomingShareError.invalidEnvelope + } + guard envelope.images.count + envelope.files.count <= Self.maximumAttachmentCount else { + throw PlatformIncomingShareError.invalidEnvelope + } + let key = draftKey ?? FeatureComposerDraftStore.newTaskKey(project: project) + var prepared: [FeatureDraftAttachment] = [] + prepared.reserveCapacity(envelope.images.count + envelope.files.count) + for (offset, image) in envelope.images.enumerated() { + let data = try await source.data(image) + let attachment = try await prepareImage( + data, + offset + 1 + ) + prepared.append(Self.stableAttachment(attachment, for: image)) + } + for file in envelope.files { + guard let attachmentID = UUID(uuidString: file.id) else { + throw PlatformIncomingShareError.invalidEnvelope + } + let sourceURL = try await source.fileURL(file) + let ownedFile: FeatureOwnedAttachmentFile + do { + ownedFile = try attachmentFileStore.copyOwnedFile( + from: sourceURL, + attachmentID: attachmentID, + originalFileName: file.fileName, + maximumBytes: T3IncomingShareStore.maximumFileBytes + ) + } catch ManagedAttachmentFileError.alreadyExists { + ownedFile = try Self.existingOwnedFile( + in: attachmentFileStore, + sourceURL: sourceURL, + attachmentID: attachmentID, + file: file + ) + } + guard ownedFile.byteCount == file.byteCount else { + throw PlatformIncomingShareError.invalidFile(file.fileName) + } + prepared.append(FeatureDraftAttachment( + id: attachmentID, + ownedFile: ownedFile, + thumbnailData: nil, + filename: file.fileName, + mimeType: file.mimeType, + uploadedReference: nil + )) + } + + let merged = try await drafts.importContent( + envelope.id, + envelope.text, + prepared, + key, + Self.maximumAttachmentCount + ) + + // The repository's actor operation atomically merges the latest draft + // and records the share ID. Never acknowledge the inbox before it ends. + try await source.remove(envelope.id) + return merged + } + + private static func existingOwnedFile( + in store: ManagedAttachmentFileStore, + sourceURL: URL, + attachmentID: UUID, + file: T3IncomingShareFile + ) throws -> FeatureOwnedAttachmentFile { + let pathExtension = URL(fileURLWithPath: file.fileName).pathExtension + let ownedName = pathExtension.isEmpty + ? attachmentID.uuidString + : "\(attachmentID.uuidString).\(pathExtension.lowercased())" + let existing = try store.resolvedFile(fileName: ownedName, byteCount: file.byteCount) + let values = try existing.url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, + values.fileSize == file.byteCount, + try filesMatch(sourceURL, existing.url) else { + throw PlatformIncomingShareError.invalidFile(file.fileName) + } + return existing + } + + private static func filesMatch(_ lhsURL: URL, _ rhsURL: URL) throws -> Bool { + let lhs = try FileHandle(forReadingFrom: lhsURL) + let rhs = try FileHandle(forReadingFrom: rhsURL) + defer { try? lhs.close(); try? rhs.close() } + while true { + let left = try lhs.read(upToCount: 256 * 1_024) ?? Data() + let right = try rhs.read(upToCount: 256 * 1_024) ?? Data() + guard left == right else { return false } + if left.isEmpty { return true } + } + } + + private static func stableAttachment( + _ attachment: FeatureDraftAttachment, + for image: T3IncomingShareImage + ) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: UUID(uuidString: image.id) ?? attachment.id, + data: attachment.data, + thumbnailData: attachment.thumbnailData, + filename: attachment.filename, + mimeType: attachment.mimeType + ) + } +} + +@MainActor +@Observable +final class PlatformIncomingShareCoordinator { + private(set) var pendingEnvelope: T3IncomingShareEnvelope? + private(set) var isImporting = false + + private let pipeline: PlatformIncomingSharePipeline + private var isRefreshing = false + private var lastNoProjectNoticeID: String? + + init(pipeline: PlatformIncomingSharePipeline = PlatformIncomingSharePipeline()) { + self.pipeline = pipeline + } + + /// Returns true once per pending envelope when the app cannot offer a + /// destination. The envelope remains in the shared container. + func refresh(hasProjects: Bool) async -> Bool { + guard pendingEnvelope == nil, !isRefreshing, !isImporting else { + return pendingEnvelope != nil + && !hasProjects + && markNoProjectNoticeIfNeeded() + } + isRefreshing = true + let envelopes = await pipeline.pendingEnvelopes() + isRefreshing = false + pendingEnvelope = envelopes.first + guard pendingEnvelope != nil, !hasProjects else { return false } + return markNoProjectNoticeIfNeeded() + } + + func dismissDestination() { + guard !isImporting else { return } + pendingEnvelope = nil + } + + func importPending(into project: FeatureProject, draftKey: String? = nil) async throws { + guard let pendingEnvelope, !isImporting else { return } + isImporting = true + do { + _ = try await pipeline.importEnvelope( + pendingEnvelope, + into: project, + draftKey: draftKey + ) + self.pendingEnvelope = nil + lastNoProjectNoticeID = nil + isImporting = false + } catch { + isImporting = false + throw error + } + } + + private func markNoProjectNoticeIfNeeded() -> Bool { + guard let id = pendingEnvelope?.id, + lastNoProjectNoticeID != id else { + return false + } + lastNoProjectNoticeID = id + return true + } +} + +struct PlatformIncomingShareDestinationSheet: View { + let envelope: T3IncomingShareEnvelope + let projects: [FeatureProject] + let environments: [FeatureEnvironment] + let isImporting: Bool + let onCancel: () -> Void + let onSelect: (FeatureProject) -> Void + + var body: some View { + NavigationStack { + List { + if !summary.isEmpty { + Section { + Text(summary) + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(3) + } + .listRowBackground(Color(uiColor: .systemBackground)) + } + + Section("Choose a project") { + ForEach(projects) { project in + Button { + onSelect(project) + } label: { + HStack(spacing: 12) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(project.name) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + if let environmentName = environmentName(for: project) { + Text(environmentName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + if isImporting { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .frame(minHeight: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isImporting) + .listRowBackground(Color(uiColor: .systemBackground)) + } + } + + if !envelope.warnings.isEmpty { + Section { + ForEach(envelope.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(uiColor: .systemBackground)) + } + } + .scrollContentBackground(.hidden) + .background(Color(uiColor: .systemBackground)) + .navigationTitle("Start a task") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + .disabled(isImporting) + } + } + } + .background(Color(uiColor: .systemBackground).ignoresSafeArea()) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled(isImporting) + } + + private var summary: String { + let attachmentCount = envelope.images.count + envelope.files.count + if !envelope.text.isEmpty, attachmentCount > 0 { + return "\(envelope.text)\n\(attachmentCount) file\(attachmentCount == 1 ? "" : "s")" + } + if !envelope.text.isEmpty { return envelope.text } + guard attachmentCount > 0 else { return "" } + return "\(attachmentCount) shared file\(attachmentCount == 1 ? "" : "s")" + } + + private func environmentName(for project: FeatureProject) -> String? { + guard environments.count > 1 else { return nil } + return environments.first { $0.id == project.environmentID }?.name + } +} diff --git a/apps/swift-ios/App/Platform/PlatformNotifications.swift b/apps/swift-ios/App/Platform/PlatformNotifications.swift new file mode 100644 index 000000000000..ab965d3474f2 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformNotifications.swift @@ -0,0 +1,309 @@ +@preconcurrency import UserNotifications +import UIKit + +extension Notification.Name { + static let platformRouteReceived = Notification.Name("T3PlatformRouteReceived") + static let platformDeviceTokenChanged = Notification.Name("T3PlatformDeviceTokenChanged") +} + +enum PlatformNotificationPayload { + private static let routeKeys = ["t3_route", "route", "url", "deep_link", "deeplink"] + + static func route(from userInfo: [AnyHashable: Any]) -> PlatformRoute? { + for key in routeKeys { + if let value = value(named: key, in: userInfo), + let route = try? PlatformDeepLinkParser.parse(value) { + return route + } + } + + let environmentID = value(named: "environment_id", in: userInfo) + ?? value(named: "environmentId", in: userInfo) + if let threadID = value(named: "thread_id", in: userInfo) + ?? value(named: "threadId", in: userInfo) { + return .thread(environmentID: environmentID, threadID: threadID) + } + if let projectID = value(named: "project_id", in: userInfo) + ?? value(named: "projectId", in: userInfo) { + return .project(environmentID: environmentID, projectID: projectID) + } + return nil + } + + private static func value(named name: String, in userInfo: [AnyHashable: Any]) -> String? { + userInfo.first { key, _ in + String(describing: key).caseInsensitiveCompare(name) == .orderedSame + }.flatMap { _, value in + let string = value as? String + return string?.isEmpty == false ? string : nil + } + } +} + +@MainActor +protocol PlatformDeviceTokenSink: AnyObject { + func registered(token: String) + func registrationFailed(_ error: Error) + func invalidated() +} + +/// Persists the APNs identity and publishes a seam for server registration. +/// The environment client can subscribe without coupling UIApplicationDelegate to transport code. +@MainActor +final class PlatformPersistedDeviceTokenSink: PlatformDeviceTokenSink { + static let shared = PlatformPersistedDeviceTokenSink() + + private let defaults: UserDefaults + private let key = "swift-ios.apns-device-token.v1" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var currentToken: String? { + defaults.string(forKey: key) + } + + func registered(token: String) { + defaults.set(token, forKey: key) + NotificationCenter.default.post( + name: .platformDeviceTokenChanged, + object: nil, + userInfo: ["token": token] + ) + } + + func registrationFailed(_ error: Error) { + NotificationCenter.default.post( + name: .platformDeviceTokenChanged, + object: nil, + userInfo: ["error": error.localizedDescription] + ) + } + + func invalidated() { + defaults.removeObject(forKey: key) + NotificationCenter.default.post(name: .platformDeviceTokenChanged, object: nil) + } +} + +@MainActor +final class PlatformNotificationService: NSObject, UNUserNotificationCenterDelegate { + static let shared = PlatformNotificationService() + + private let center: UNUserNotificationCenter + private let tokenSink: any PlatformDeviceTokenSink + private let authorizationStatus: @MainActor () async -> UNAuthorizationStatus + private let authorizationRequest: @MainActor () async -> Bool + private let updateRemoteRegistration: @MainActor (Bool) -> Void + private var preferenceRevision: UInt64 = 0 + private(set) var enabled = false + + init( + center: UNUserNotificationCenter = .current(), + tokenSink: (any PlatformDeviceTokenSink)? = nil, + authorizationStatus: (@MainActor () async -> UNAuthorizationStatus)? = nil, + authorizationRequest: (@MainActor () async -> Bool)? = nil, + updateRemoteRegistration: (@MainActor (Bool) -> Void)? = nil + ) { + self.center = center + self.tokenSink = tokenSink ?? PlatformPersistedDeviceTokenSink.shared + self.authorizationStatus = authorizationStatus ?? { + await center.notificationSettings().authorizationStatus + } + self.authorizationRequest = authorizationRequest ?? { + (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) == true + } + self.updateRemoteRegistration = updateRemoteRegistration ?? { enabled in + if enabled { + UIApplication.shared.registerForRemoteNotifications() + } else { + UIApplication.shared.unregisterForRemoteNotifications() + center.removeAllPendingNotificationRequests() + center.removeAllDeliveredNotifications() + } + } + super.init() + } + + func installDelegate() { + center.delegate = self + } + + /// Returns nil when a newer preference change supersedes this check. + @discardableResult + func synchronize(enabled: Bool) async -> Bool? { + installDelegate() + preferenceRevision &+= 1 + let revision = preferenceRevision + + guard enabled else { + self.enabled = false + updateRemoteRegistration(false) + tokenSink.invalidated() + return false + } + + let status = await authorizationStatus() + guard preferenceRevision == revision else { return nil } + let authorized = isAuthorized(status) + self.enabled = authorized + if authorized { + updateRemoteRegistration(true) + } + return authorized + } + + /// Call only in response to an explicit user action such as enabling the + /// Notifications toggle. Startup synchronization never presents a prompt. + @discardableResult + func requestAuthorization() async -> Bool? { + installDelegate() + preferenceRevision &+= 1 + let revision = preferenceRevision + let status = await authorizationStatus() + guard preferenceRevision == revision else { return nil } + let authorized: Bool + switch status { + case .notDetermined: + authorized = await authorizationRequest() + case .authorized, .provisional, .ephemeral: + authorized = true + case .denied: + authorized = false + @unknown default: + authorized = false + } + + guard preferenceRevision == revision else { return nil } + enabled = authorized + if authorized { + updateRemoteRegistration(true) + } + return authorized + } + + func schedule(_ signal: PlatformThreadSignal) async { + guard enabled else { return } + let revision = preferenceRevision + let settings = await center.notificationSettings() + guard enabled, preferenceRevision == revision, + [.authorized, .provisional, .ephemeral].contains(settings.authorizationStatus) else { + return + } + + let content = UNMutableNotificationContent() + content.title = notificationTitle(for: signal.kind) + content.body = signal.thread.title + content.sound = .default + content.threadIdentifier = signal.thread.id + if let url = PlatformRoute.thread( + environmentID: signal.thread.environmentID, + threadID: signal.thread.wireID ?? signal.thread.id + ).url { + content.userInfo = ["t3_route": url.absoluteString] + } + + let request = UNNotificationRequest( + identifier: "thread:\(signal.thread.id):\(signal.thread.state.rawValue)", + content: content, + trigger: nil + ) + try? await center.add(request) + } + + func didRegisterForRemoteNotifications(deviceToken: Data) { + guard enabled else { return } + tokenSink.registered(token: deviceToken.map { String(format: "%02x", $0) }.joined()) + } + + func didFailToRegisterForRemoteNotifications(_ error: Error) { + guard enabled else { return } + tokenSink.registrationFailed(error) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping @Sendable () -> Void + ) { + let route = PlatformNotificationPayload.route( + from: response.notification.request.content.userInfo + ) + DispatchQueue.main.async { + defer { completionHandler() } + guard let route else { return } + PlatformRouteMailbox.shared.put(route) + NotificationCenter.default.post( + name: .platformRouteReceived, + object: nil, + userInfo: ["route": route] + ) + } + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + await MainActor.run { enabled ? [.banner, .sound] : [] } + } + + private func notificationTitle(for kind: PlatformFeedbackKind) -> String { + switch kind { + case .success: + "Task completed" + case .warning: + "T3 Code needs you" + case .error: + "Task failed" + } + } + + private func isAuthorized(_ status: UNAuthorizationStatus) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + true + case .notDetermined, .denied: + false + @unknown default: + false + } + } +} + +@MainActor +final class T3PlatformAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + PlatformBackgroundRefreshCoordinator.shared.register() + PlatformNotificationService.shared.installDelegate() + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + PlatformNotificationService.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + PlatformNotificationService.shared.didFailToRegisterForRemoteNotifications(error) + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + completionHandler( + PlatformNotificationPayload.route(from: userInfo) == nil ? .noData : .newData + ) + } +} diff --git a/apps/swift-ios/App/Platform/PlatformRootView.swift b/apps/swift-ios/App/Platform/PlatformRootView.swift new file mode 100644 index 000000000000..2e4794959920 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformRootView.swift @@ -0,0 +1,389 @@ +import SwiftUI + +struct PlatformRootView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @Bindable private var model: FeatureRootModel + + @State private var navigationRequest: FeatureWorkspaceNavigationRequest? + @State private var pendingRoute: PlatformRoute? + @State private var previousThreadStates: [String: FeatureThreadState]? + @State private var lastNotificationPreference: Bool? + @State private var incomingShareCoordinator = PlatformIncomingShareCoordinator() + @State private var incomingShareNeedsProject = false + @State private var importedShareProjectID: String? + @State private var recentThreadsPersistenceTask: Task? + + init(model: FeatureRootModel) { + self.model = model + } + + var body: some View { + FeatureRootView( + model: model, + navigationRequest: navigationRequest, + onNavigationRequestConsumed: { requestID in + guard navigationRequest?.id == requestID else { return } + navigationRequest = nil + } + ) + .environment(\.openURL, OpenURLAction { url in + // Links tapped inside the app (message Markdown above all) would + // otherwise leave for Safari or be rejected by an unregistered + // scheme, so keep the ones this device can already show. + guard let route = PlatformInAppLinkRouter.route(for: url, in: model.snapshot) else { + return .systemAction + } + handle(route) + return .handled + }) + .onOpenURL { url in + handle(url: url, letOnboardingConfirmConnection: true) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + guard let url = activity.webpageURL else { return } + handle(url: url, letOnboardingConfirmConnection: false) + } + .onReceive(NotificationCenter.default.publisher(for: .platformRouteReceived)) { note in + guard let route = note.userInfo?["route"] as? PlatformRoute else { return } + _ = PlatformRouteMailbox.shared.take() + handle(route) + } + .onReceive(NotificationCenter.default.publisher(for: .t3ConnectSessionChanged)) { note in + guard let capability = model.client as? any T3ConnectCapable, + let controller = note.object as? T3ConnectController, + controller === capability.t3ConnectController else { return } + let previousAccountID = note.userInfo?["previousAccountID"] as? String + let accountID = note.userInfo?["accountID"] as? String + guard Self.shouldRemoveManagedEnvironments( + previousAccountID: previousAccountID, + accountID: accountID, + isSigningOut: model.isSigningOutT3Connect + ) else { return } + Task { @MainActor in + guard !model.isSigningOutT3Connect else { return } + await model.removeManagedEnvironmentsAfterAccountChange() + } + } + .onChange(of: model.isLoading, initial: true) { _, isLoading in + guard !isLoading else { return } + processThreadChanges() + synchronizeNotificationPreference() + synchronizeCloudDelivery() + consumePendingRouteIfPossible() + consumeMailboxRouteIfAvailable() + refreshIncomingShares() + } + .onChange(of: model.homePresentationRevision) { _, _ in + processThreadChanges() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + Task { await model.applicationDidBecomeActive() } + consumeMailboxRouteIfAvailable() + synchronizeNotificationPreference() + synchronizeCloudDelivery() + refreshIncomingShares() + } else if phase == .background { + model.applicationDidEnterBackground() + PlatformBackgroundRefreshCoordinator.shared.schedule() + } + } + .onChange(of: model.snapshot.settings.notificationsEnabled) { _, _ in + synchronizeNotificationPreference() + synchronizeCloudDelivery() + } + .onChange(of: model.snapshot.settings.liveActivitiesEnabled) { _, _ in + synchronizeAgentAwareness() + synchronizeCloudDelivery() + } + .onChange(of: model.snapshot.projects.map(\.id)) { _, _ in + refreshIncomingShares() + } + .sheet(item: presentedIncomingShare, onDismiss: openImportedShareDraft) { envelope in + PlatformIncomingShareDestinationSheet( + envelope: envelope, + projects: incomingShareProjects, + environments: model.snapshot.environments, + isImporting: incomingShareCoordinator.isImporting, + onCancel: incomingShareCoordinator.dismissDestination, + onSelect: importIncomingShare(into:) + ) + } + .alert("Create a project to continue", isPresented: $incomingShareNeedsProject) { + Button("Not now", role: .cancel) {} + Button("Create project") { + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .newTask(projectID: nil) + ) + } + } message: { + Text("Your share is saved. Connect an environment and create a project to finish importing it.") + } + } + + static func shouldRemoveManagedEnvironments( + previousAccountID: String?, + accountID: String?, + isSigningOut: Bool + ) -> Bool { + guard !isSigningOut, let previousAccountID else { return false } + return previousAccountID != accountID + } + + private var incomingShareProjects: [FeatureProject] { + DailyUXCreationContext.projects(in: model.snapshot).sorted { + if $0.name.localizedStandardCompare($1.name) == .orderedSame { + return $0.environmentID < $1.environmentID + } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + + private var presentedIncomingShare: Binding { + Binding( + get: { + guard !incomingShareProjects.isEmpty else { return nil } + return incomingShareCoordinator.pendingEnvelope + }, + set: { value in + guard value == nil, importedShareProjectID == nil else { return } + incomingShareCoordinator.dismissDestination() + } + ) + } + + private var shouldShowWorkspace: Bool { + FeatureRootPresentation.showsWorkspace( + snapshot: model.snapshot, + isManagingConnections: model.isManagingConnections + ) + } + + private func handle(url: URL, letOnboardingConfirmConnection: Bool) { + do { + let route = try PlatformDeepLinkParser.parse(url) + if case .connection = route, + letOnboardingConfirmConnection, + !shouldShowWorkspace { + // ConnectionOnboardingView owns the confirmation UI for cold pairing links. + return + } + handle(route) + } catch { + model.errorMessage = error.localizedDescription + } + } + + private func handle(_ route: PlatformRoute) { + guard !model.isLoading else { + pendingRoute = route + return + } + Task { @MainActor in + await consume(route) + } + } + + private func consumePendingRouteIfPossible() { + guard let route = pendingRoute else { return } + pendingRoute = nil + handle(route) + } + + private func consumeMailboxRouteIfAvailable() { + guard !model.isLoading, let route = PlatformRouteMailbox.shared.take() else { return } + handle(route) + } + + private func synchronizeNotificationPreference() { + guard !model.isLoading else { return } + let preference = model.snapshot.settings.notificationsEnabled + let previous = lastNotificationPreference + lastNotificationPreference = preference + + Task { + let authorized: Bool? + if preference, previous == false { + // Ask for permission when the user enables notifications. + authorized = await PlatformNotificationService.shared.requestAuthorization() + } else { + authorized = await PlatformNotificationService.shared.synchronize(enabled: preference) + } + guard let authorized, preference, !authorized, + model.snapshot.settings.notificationsEnabled else { + return + } + + // Keep the app toggle honest when authorization is absent or revoked. + await model.savePreference(\.notificationsEnabled, value: false) + } + } + + private func synchronizeCloudDelivery() { + guard !model.isLoading else { return } + PlatformCloudDeliveryCoordinator.shared.synchronize( + settings: model.snapshot.settings + ) + } + + private func refreshIncomingShares() { + guard !model.isLoading else { return } + let hasProjects = !incomingShareProjects.isEmpty + Task { @MainActor in + if await incomingShareCoordinator.refresh(hasProjects: hasProjects) { + incomingShareNeedsProject = true + } + } + } + + private func importIncomingShare(into project: FeatureProject) { + guard !incomingShareCoordinator.isImporting else { return } + importedShareProjectID = project.id + Task { @MainActor in + do { + try await incomingShareCoordinator.importPending( + into: project, + draftKey: FeatureComposerDraftStore.newTaskKey( + project: project, + in: model.snapshot + ) + ) + } catch { + importedShareProjectID = nil + model.errorMessage = error.localizedDescription + } + } + } + + private func openImportedShareDraft() { + guard let projectID = importedShareProjectID else { return } + importedShareProjectID = nil + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .newTask(projectID: projectID) + ) + PlatformHapticEngine.shared.emit( + .success, + enabled: model.snapshot.settings.hapticsEnabled + ) + } + + @MainActor + private func consume(_ route: PlatformRoute) async { + switch route { + case let .connection(endpoint, token): + if await model.pair(endpoint: endpoint, token: token) { + PlatformHapticEngine.shared.emit( + .success, + enabled: model.snapshot.settings.hapticsEnabled + ) + } + case let .environment(id): + guard await enableEnvironmentIfNeeded(id) else { return } + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + case let .thread(environmentID, threadID): + guard await enableEnvironmentIfNeeded(environmentID), + let thread = PlatformRouteResolver.thread( + in: model.snapshot, + environmentID: environmentID, + id: threadID + ) + else { + if model.errorMessage == nil { model.errorMessage = "That thread is not available on this device." } + return + } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .thread(id: thread.id) + ) + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + case let .project(environmentID, projectID): + guard await enableEnvironmentIfNeeded(environmentID), + let project = PlatformRouteResolver.project( + in: model.snapshot, + environmentID: environmentID, + id: projectID + ) + else { + if model.errorMessage == nil { model.errorMessage = "That project is not available on this device." } + return + } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .project(id: project.id) + ) + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + case let .newTask(environmentID, projectID): + guard await enableEnvironmentIfNeeded(environmentID) else { return } + let resolvedProject = projectID.flatMap { + PlatformRouteResolver.project( + in: model.snapshot, + environmentID: environmentID, + id: $0 + ) + } + if projectID != nil, resolvedProject == nil { + model.errorMessage = "That project is not available on this device." + return + } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .newTask(projectID: resolvedProject?.id) + ) + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + } + } + + @MainActor + private func enableEnvironmentIfNeeded(_ id: String?) async -> Bool { + guard let id else { return true } + guard let environment = model.snapshot.environments.first(where: { $0.id == id }) else { + model.errorMessage = "That environment is not saved on this device." + return false + } + guard !environment.isEnabled else { return true } + return await model.setEnvironmentEnabled(id, enabled: true) + } + + /// Home revisions are coalesced by FeatureRootModel, so this performs one + /// bounded scan per meaningful snapshot change rather than on every render. + private func processThreadChanges() { + let current = model.snapshot.threads.reduce(into: [String: FeatureThreadState]()) { + $0[$1.id] = $1.state + } + let signals = PlatformThreadTransitionClassifier.signals( + previous: previousThreadStates, + current: model.snapshot.threads + ) + previousThreadStates = current + recentThreadsPersistenceTask?.cancel() + let threads = model.snapshot.threads + recentThreadsPersistenceTask = Task.detached(priority: .utility) { + guard !Task.isCancelled else { return } + PlatformRecentThreadStore.shared.update(from: threads) + } + synchronizeAgentAwareness() + + for signal in signals { + if scenePhase == .active { + PlatformHapticEngine.shared.emit( + signal.kind, + enabled: model.snapshot.settings.hapticsEnabled + ) + } else if model.snapshot.settings.notificationsEnabled { + Task { await PlatformNotificationService.shared.schedule(signal) } + } + } + } + + private func synchronizeAgentAwareness() { + PlatformAgentAwarenessCoordinator.shared.synchronize( + snapshot: model.snapshot, + liveActivitiesEnabled: model.snapshot.settings.liveActivitiesEnabled + ) + } +} diff --git a/apps/swift-ios/App/Platform/PlatformRouteResolver.swift b/apps/swift-ios/App/Platform/PlatformRouteResolver.swift new file mode 100644 index 000000000000..4b93b99512e9 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformRouteResolver.swift @@ -0,0 +1,88 @@ +import Foundation + +enum PlatformRouteResolver { + static func thread( + in snapshot: FeatureSnapshot, + environmentID: String?, + id: String + ) -> FeatureThread? { + let matches = snapshot.threads.filter { thread in + (environmentID == nil || thread.environmentID == environmentID) + && (thread.id == id || thread.wireID == id) + } + guard environmentID != nil || matches.count == 1 else { return nil } + return matches.max { $0.updatedAt < $1.updatedAt } + } + + static func project( + in snapshot: FeatureSnapshot, + environmentID: String?, + id: String + ) -> FeatureProject? { + let matches = snapshot.projects.filter { project in + (environmentID == nil || project.environmentID == environmentID) + && (project.id == id || project.wireID == id) + } + guard environmentID != nil || matches.count == 1 else { return nil } + return matches.first + } +} + +/// Decides which links tapped inside the app navigate in place instead of +/// being handed to the system. +/// +/// A T3 link only stays in the app when it names a destination this device can +/// already show, so unknown web links keep opening on the web instead of +/// failing with an in-app error. Pairing links are always left to the system so +/// onboarding keeps owning connection confirmation. +enum PlatformInAppLinkRouter { + static func route(for url: URL, in snapshot: FeatureSnapshot) -> PlatformRoute? { + guard let route = try? PlatformDeepLinkParser.parse(url) else { return nil } + + switch route { + case .connection: + return nil + case let .thread(environmentID, threadID): + guard isSavedEnvironment(environmentID, in: snapshot), + PlatformRouteResolver.thread( + in: snapshot, + environmentID: environmentID, + id: threadID + ) != nil + else { + return nil + } + return route + case let .project(environmentID, projectID): + guard isSavedEnvironment(environmentID, in: snapshot), + PlatformRouteResolver.project( + in: snapshot, + environmentID: environmentID, + id: projectID + ) != nil + else { + return nil + } + return route + case let .environment(id): + guard isSavedEnvironment(id, in: snapshot) else { return nil } + return route + case let .newTask(environmentID, projectID): + guard isSavedEnvironment(environmentID, in: snapshot) else { return nil } + guard let projectID else { return route } + guard PlatformRouteResolver.project( + in: snapshot, + environmentID: environmentID, + id: projectID + ) != nil else { + return nil + } + return route + } + } + + private static func isSavedEnvironment(_ id: String?, in snapshot: FeatureSnapshot) -> Bool { + guard let id else { return true } + return snapshot.environments.contains { $0.id == id } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformShortcuts.swift b/apps/swift-ios/App/Platform/PlatformShortcuts.swift new file mode 100644 index 000000000000..b8378d984092 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformShortcuts.swift @@ -0,0 +1,142 @@ +import AppIntents +import Foundation + +struct PlatformRecentThreadRecord: Codable, Equatable, Sendable { + let id: String + let environmentID: String? + let wireID: String + let title: String + let environmentName: String? + let updatedAt: Date +} + +final class PlatformRecentThreadStore: @unchecked Sendable { + static let shared = PlatformRecentThreadStore() + + private let defaults: UserDefaults + private let key: String + private let lock = NSLock() + + init(defaults: UserDefaults = .standard, key: String = "swift-ios.recent-threads.v1") { + self.defaults = defaults + self.key = key + } + + func update(from threads: [FeatureThread]) { + let records = threads + .filter { !$0.isArchived } + .sorted { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id < rhs.id + } + .prefix(12) + .map { + PlatformRecentThreadRecord( + id: $0.id, + environmentID: $0.environmentID, + wireID: $0.wireID ?? $0.id, + title: $0.title, + environmentName: $0.environmentName, + updatedAt: $0.updatedAt + ) + } + lock.withLock { + defaults.set(try? JSONEncoder().encode(records), forKey: key) + } + } + + func records() -> [PlatformRecentThreadRecord] { + lock.withLock { + guard let data = defaults.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([PlatformRecentThreadRecord].self, from: data)) ?? [] + } + } +} + +struct PlatformRecentThreadEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "T3 Code Thread") + static let defaultQuery = PlatformRecentThreadQuery() + + let id: String + let environmentID: String? + let wireID: String + let title: String + let environmentName: String? + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: environmentName.map { "\($0)" } + ) + } + + init(record: PlatformRecentThreadRecord) { + id = record.id + environmentID = record.environmentID + wireID = record.wireID + title = record.title + environmentName = record.environmentName + } +} + +struct PlatformRecentThreadQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [PlatformRecentThreadEntity] { + let requested = Set(identifiers) + return PlatformRecentThreadStore.shared.records() + .filter { requested.contains($0.id) } + .map(PlatformRecentThreadEntity.init) + } + + func suggestedEntities() async throws -> [PlatformRecentThreadEntity] { + PlatformRecentThreadStore.shared.records().map(PlatformRecentThreadEntity.init) + } +} + +struct NewT3TaskIntent: AppIntent { + static let title: LocalizedStringResource = "New T3 Code Task" + static let description = IntentDescription("Open the native composer and start a task.") + static let openAppWhenRun = true + + func perform() async throws -> some IntentResult { + PlatformRouteMailbox.shared.put(.newTask(environmentID: nil, projectID: nil)) + return .result() + } +} + +struct OpenRecentT3ThreadIntent: AppIntent { + static let title: LocalizedStringResource = "Open Recent T3 Code Thread" + static let description = IntentDescription("Open a recent thread in T3 Code.") + static let openAppWhenRun = true + + @Parameter(title: "Thread") + var thread: PlatformRecentThreadEntity + + func perform() async throws -> some IntentResult { + PlatformRouteMailbox.shared.put( + .thread(environmentID: thread.environmentID, threadID: thread.wireID) + ) + return .result() + } +} + +struct T3PlatformShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NewT3TaskIntent(), + phrases: [ + "Start a task in \(.applicationName)", + "New task in \(.applicationName)", + ], + shortTitle: "New Task", + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenRecentT3ThreadIntent(), + phrases: [ + "Open a recent thread in \(.applicationName)", + ], + shortTitle: "Open Thread", + systemImageName: "bubble.left.and.bubble.right" + ) + } +} diff --git a/apps/swift-ios/App/RootView.swift b/apps/swift-ios/App/RootView.swift new file mode 100644 index 000000000000..cb2ec784aca6 --- /dev/null +++ b/apps/swift-ios/App/RootView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// Owns app-wide presentation while the injected feature root owns product navigation. +struct RootView: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .background(T3Colors.background.ignoresSafeArea()) + .tint(T3Colors.accent) + } +} diff --git a/apps/swift-ios/App/T3CodeApp.swift b/apps/swift-ios/App/T3CodeApp.swift new file mode 100644 index 000000000000..d2854016003a --- /dev/null +++ b/apps/swift-ios/App/T3CodeApp.swift @@ -0,0 +1,29 @@ +import SwiftUI + +@main +@MainActor +struct T3CodeApp: App { + @UIApplicationDelegateAdaptor(T3PlatformAppDelegate.self) private var appDelegate + @State private var model: FeatureRootModel + + init() { + let client = NativeFeatureClient() + let model = FeatureRootModel(client: client) + _model = State(initialValue: model) + PlatformCloudDeliveryCoordinator.shared.install( + controller: client.t3ConnectController + ) + PlatformBackgroundRefreshCoordinator.shared.install { [weak model] in + guard let model else { return false } + return await model.refreshInBackground() + } + } + + var body: some Scene { + WindowGroup { + RootView { + PlatformRootView(model: model) + } + } + } +} diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift new file mode 100644 index 000000000000..c6795b4267bf --- /dev/null +++ b/apps/swift-ios/Core/Attachments.swift @@ -0,0 +1,231 @@ +import Foundation + +public enum ImageAttachmentError: LocalizedError, Equatable, Sendable { + case empty + case tooLarge(actualBytes: Int, maximumBytes: Int) + case invalidName + case invalidMIMEType + + public var errorDescription: String? { + switch self { + case .empty: "The selected image is empty." + case let .tooLarge(actualBytes, maximumBytes): + "The image is \(actualBytes) bytes. T3 accepts up to \(maximumBytes) bytes." + case .invalidName: "The image needs a valid file name." + case .invalidMIMEType: "The selected file is not a supported image." + } + } +} + +public enum FileAttachmentError: LocalizedError, Equatable, Sendable { + case empty + case tooLarge(actualBytes: Int, maximumBytes: Int) + case invalidName + case invalidMIMEType + case invalidFileURL + case unsupported + case tooMany(maximum: Int) + + public var errorDescription: String? { + switch self { + case .empty: "The selected file is empty." + case let .tooLarge(actualBytes, maximumBytes): + "The file is \(actualBytes) bytes. T3 accepts up to \(maximumBytes) bytes." + case .invalidName: "The file needs a valid name." + case .invalidMIMEType: "The file needs a valid MIME type." + case .invalidFileURL: "The attachment file is no longer available." + case .unsupported: "This environment does not support file attachments." + case let .tooMany(maximum): "You can attach up to \(maximum) files per message." + } + } +} + +public struct UploadedAttachmentReference: Codable, Equatable, Sendable { + public let environmentID: String + public let attachmentID: String + + public init(environmentID: String, attachmentID: String) { + self.environmentID = environmentID + self.attachmentID = attachmentID + } +} + +/// A validated turn attachment. Images can remain inline for older servers. +/// Generic files always stay file-backed and require the upload capability. +public struct UploadChatAttachment: Equatable, Sendable { + public static let maximumBytes = 10 * 1024 * 1024 + public static let maximumFileBytes = 50 * 1024 * 1024 + + enum Source: Equatable, Sendable { + case imageData(Data) + case file(URL) + } + + public let id: UUID + public let type: String + public let name: String + public let mimeType: String + public let sizeBytes: Int + public let uploadedReference: UploadedAttachmentReference? + let source: Source + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: UploadedAttachmentReference? = nil + ) throws { + guard !data.isEmpty else { throw ImageAttachmentError.empty } + guard data.count <= Self.maximumBytes else { + throw ImageAttachmentError.tooLarge( + actualBytes: data.count, + maximumBytes: Self.maximumBytes + ) + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 255 else { + throw ImageAttachmentError.invalidName + } + let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedMIME.hasPrefix("image/"), normalizedMIME.count <= 100 else { + throw ImageAttachmentError.invalidMIMEType + } + self.id = id + type = "image" + self.name = normalizedName + self.mimeType = normalizedMIME + sizeBytes = data.count + self.uploadedReference = uploadedReference + source = .imageData(data) + } + + public init( + id: UUID = UUID(), + fileURL: URL, + name: String, + mimeType: String, + sizeBytes: Int, + uploadedReference: UploadedAttachmentReference? = nil + ) throws { + guard fileURL.isFileURL else { throw FileAttachmentError.invalidFileURL } + guard sizeBytes > 0 else { throw FileAttachmentError.empty } + guard sizeBytes <= Self.maximumFileBytes else { + throw FileAttachmentError.tooLarge( + actualBytes: sizeBytes, + maximumBytes: Self.maximumFileBytes + ) + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 255 else { + throw FileAttachmentError.invalidName + } + let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalizedMIME.isEmpty, normalizedMIME.count <= 100, + !normalizedMIME.contains(where: { $0.isWhitespace || $0.isNewline }) else { + throw FileAttachmentError.invalidMIMEType + } + self.id = id + type = "file" + self.name = normalizedName + self.mimeType = normalizedMIME + self.sizeBytes = sizeBytes + self.uploadedReference = uploadedReference + source = .file(fileURL) + } + + var jsonValue: JSONValue { + var value: [String: JSONValue] = [ + "type": .string(type), + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ] + if case let .imageData(data) = source { + value["dataUrl"] = .string( + "data:\(mimeType);base64,\(data.base64EncodedString())" + ) + } + return .object(value) + } + + func uploadedJSONValue(id: String) -> JSONValue { + .object([ + "type": .string(type), + "id": .string(id), + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ]) + } +} + +/// Keeps the existing image API and call sites source-compatible. +public typealias UploadChatImageAttachment = UploadChatAttachment + +public struct AttachmentCreateUploadURLResult: Codable, Equatable, Sendable { + public let attachmentId: String + public let relativeUrl: String + public let expiresAt: Double +} + +public enum AssetResource: Equatable, Sendable { + case workspaceFile(threadID: String, path: String) + case mediaFile(threadID: String, path: String) + case attachment(id: String, fileName: String? = nil, mimeType: String? = nil) + case projectFavicon(cwd: String) + case nativeAppIcon(ToolNativeAppReference) + + var jsonValue: JSONValue { + switch self { + case let .nativeAppIcon(app): + var reference: [String: JSONValue] = ["_tag": .string(app._tag)] + if let id = app.appId { reference["appId"] = .string(id) } + if let name = app.displayName { reference["displayName"] = .string(name) } + return .object(["_tag": .string("native-app-icon"), "app": .object(reference)]) + case let .workspaceFile(threadID, path): + return .object([ + "_tag": .string("workspace-file"), + "threadId": .string(threadID), + "path": .string(path), + ]) + case let .mediaFile(threadID, path): + return .object([ + "_tag": .string("media-file"), + "threadId": .string(threadID), + "path": .string(path), + ]) + case let .attachment(id, fileName, mimeType): + var value: [String: JSONValue] = [ + "_tag": .string("attachment"), + "attachmentId": .string(id), + ] + if let fileName { value["fileName"] = .string(fileName) } + if let mimeType { value["mimeType"] = .string(mimeType) } + return .object(value) + case let .projectFavicon(cwd): + return .object([ + "_tag": .string("project-favicon"), + "cwd": .string(cwd), + ]) + } + } +} + +public struct AssetImageDimensions: Codable, Equatable, Sendable { + public let width: Int + public let height: Int +} + +public struct AssetCreateURLResult: Codable, Equatable, Sendable { + public let relativeUrl: String + /// Unix epoch milliseconds from the server contract. + public let expiresAt: Double + public let imageDimensions: AssetImageDimensions? +} + +public struct ResolvedAssetURL: Equatable, Sendable { + public let url: URL + public let expiresAt: Date + public var imageDimensions: AssetImageDimensions? = nil +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift new file mode 100644 index 000000000000..ed0af78e3a8f --- /dev/null +++ b/apps/swift-ios/Core/HTTP.swift @@ -0,0 +1,703 @@ +import Foundation + +public protocol HTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) + func upload(for request: URLRequest, fromFile fileURL: URL) async throws + -> (Data, HTTPURLResponse) +} + +public extension HTTPTransport { + /// Test transports can keep recording URLRequest bodies without providing + /// a second transport implementation. Production overrides this method. + func upload(for request: URLRequest, fromFile fileURL: URL) async throws + -> (Data, HTTPURLResponse) + { + var request = request + request.httpBody = try Data(contentsOf: fileURL) + return try await data(for: request) + } +} + +/// The Core transport deliberately knows nothing about Clerk or the relay. +/// A managed-environment adapter supplies request-bound DPoP proofs and can +/// reacquire a bound access token when the current one expires or is rejected. +public protocol ManagedEnvironmentAuthorizing: Sendable { + func credentialRequiresRefresh( + _ credential: EnvironmentCredential, + environment: Environment + ) async throws -> Bool + + func authorize( + _ request: URLRequest, + environment: Environment, + credential: EnvironmentCredential + ) async throws -> URLRequest + + func refreshCredential( + for environment: Environment, + replacing credential: EnvironmentCredential + ) async throws -> EnvironmentCredential +} + +public struct URLSessionHTTPTransport: HTTPTransport { + private let session: URLSession + + public init(session: URLSession? = nil) { + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = [ + "Accept-Encoding": HTTPRequestPolicy.acceptEncoding, + ] + self.session = URLSession(configuration: configuration) + } + } + + public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + // URLSession transparently decodes gzip responses before returning + // their body. Applying the policy here is a final guard for requests + // constructed outside EnvironmentAPI. + let (data, response) = try await session.data(for: HTTPRequestPolicy.prepare(request)) + guard let httpResponse = response as? HTTPURLResponse else { + throw HTTPError.invalidResponse + } + return (data, httpResponse) + } + + public func upload(for request: URLRequest, fromFile fileURL: URL) async throws + -> (Data, HTTPURLResponse) + { + let (data, response) = try await session.upload( + for: HTTPRequestPolicy.prepare(request), + fromFile: fileURL + ) + guard let httpResponse = response as? HTTPURLResponse else { + throw HTTPError.invalidResponse + } + return (data, httpResponse) + } +} + +/// Shared wire-level defaults for HTTP requests. +/// +/// Foundation's URL loading system transparently decompresses gzip response +/// bodies. The explicit offer matters because T3 only compresses JSON when the +/// client advertises support. +public enum HTTPRequestPolicy { + public static let acceptEncoding = "gzip" + + public static func prepare(_ request: URLRequest) -> URLRequest { + var prepared = request + if prepared.value(forHTTPHeaderField: "Accept-Encoding") == nil { + prepared.setValue(acceptEncoding, forHTTPHeaderField: "Accept-Encoding") + } + if prepared.value(forHTTPHeaderField: "Accept") == nil { + prepared.setValue("application/json", forHTTPHeaderField: "Accept") + } + return prepared + } +} + +public enum HTTPError: LocalizedError, Sendable { + case invalidResponse + case status(Int, message: String, traceID: String?) + case missingCredential + case incompatibleCredential + case managedAuthorizationUnavailable + case unauthenticatedSession + + public var errorDescription: String? { + switch self { + case .invalidResponse: + "The server returned an invalid response." + case let .status(status, message, traceID): + traceID.map { "\(message) (trace \($0))" } ?? "\(message) (HTTP \(status))" + case .missingCredential: + "This environment has no saved credential." + case .incompatibleCredential: + "This environment's saved authentication method is invalid. Connect it again." + case .managedAuthorizationUnavailable: + "This build cannot authorize a managed T3 Connect environment." + case .unauthenticatedSession: + "The environment rejected the session authorization." + } + } +} + +struct T3ConnectNetworkError: LocalizedError, Sendable { + static let hint = + "Your DNS or firewall may be blocking T3 Connect. Try another network, such as a phone hotspot." + + let message: String + + var errorDescription: String? { "\(message) \(Self.hint)" } + + // A failed transport can be an outage or filtering. Keep protocol and + // authentication errors unchanged because they have a server response. + static func wrapping(_ error: any Error) -> any Error { + guard let error = error as? URLError else { return error } + switch error.code { + case .timedOut, .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, + .networkConnectionLost, .notConnectedToInternet: + return Self(message: error.localizedDescription) + default: + return error + } + } +} + +enum DPoPFailureReason: Decodable, Equatable, Sendable { + case timeWindow + case keyMismatch + case requestMismatch + case tokenMismatch + case replay + case invalidProof + case unknown + + init(from decoder: any Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "time_window": self = .timeWindow + case "key_mismatch": self = .keyMismatch + case "request_mismatch": self = .requestMismatch + case "token_mismatch": self = .tokenMismatch + case "replay": self = .replay + case "invalid_proof": self = .invalidProof + default: self = .unknown + } + } +} + +enum DPoPFailurePresentation { + static let clockHint = + "Hint: Check that automatic date and time is enabled on both devices, then try again." + static let unknownHint = + "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices." + static let retryHint = "Hint: Try again. If the problem continues, copy the trace ID." + + static func message(_ message: String, reason: DPoPFailureReason?) -> String { + let hint = if reason == .timeWindow { + clockHint + } else if reason == nil { + unknownHint + } else { + retryHint + } + return "\(message) \(hint)" + } +} + +struct EnvironmentErrorBody: Decodable { + let message: String? + let reason: String? + let dpopFailureReason: DPoPFailureReason? + let traceId: String? +} + +public actor EnvironmentAPI { + private static let managedRefreshMargin: TimeInterval = 60 + + private let transport: any HTTPTransport + private let credentials: any CredentialStore + private let managedAuthorization: (any ManagedEnvironmentAuthorizing)? + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + credentials: any CredentialStore, + managedAuthorization: (any ManagedEnvironmentAuthorizing)? = nil + ) { + self.transport = transport + self.credentials = credentials + self.managedAuthorization = managedAuthorization + } + + public func descriptor(at httpBaseURL: URL) async throws -> EnvironmentDescriptor { + try await send( + URLRequest(url: endpoint(httpBaseURL, path: "/.well-known/t3/environment")), + as: EnvironmentDescriptor.self + ) + } + + public func shellSnapshot( + for environment: Environment, + timeoutInterval: TimeInterval? = nil + ) async throws + -> OrchestrationShellSnapshot + { + try await authorized( + environment: environment, + path: "/api/orchestration/shell", + method: "GET", + timeoutInterval: timeoutInterval, + as: OrchestrationShellSnapshot.self + ) + } + + public func readModel(for environment: Environment) async throws -> OrchestrationReadModel { + try await authorized( + environment: environment, + path: "/api/orchestration/snapshot", + method: "GET", + as: OrchestrationReadModel.self + ) + } + + public func threadSnapshot( + id: String, + environment: Environment, + turnLimit: Int? = nil, + beforeCursor: String? = nil, + timeoutInterval: TimeInterval? = nil + ) async throws -> OrchestrationThreadDetailSnapshot { + let encodedID = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + var queryItems: [URLQueryItem] = [] + if let turnLimit { + queryItems.append(URLQueryItem(name: "turnLimit", value: String(turnLimit))) + } + if let beforeCursor { + queryItems.append(URLQueryItem(name: "beforeCursor", value: beforeCursor)) + } + return try await authorized( + environment: environment, + path: "/api/orchestration/threads/\(encodedID)", + queryItems: queryItems, + method: "GET", + timeoutInterval: timeoutInterval, + as: OrchestrationThreadDetailSnapshot.self + ) + } + + public func dispatch( + _ command: JSONValue, + environment: Environment + ) async throws -> DispatchResult { + try await authorized( + environment: environment, + path: "/api/orchestration/dispatch", + method: "POST", + body: JSONEncoder.t3.encode(command), + as: DispatchResult.self + ) + } + + public func pullRequestDiff( + _ input: PullRequestDiffInput, + environment: Environment + ) async throws -> PullRequestDiffResult { + try await authorized( + environment: environment, + path: "/api/pull-requests/diff", + method: "POST", + body: JSONEncoder.t3.encode(input), + timeoutInterval: 60, + as: PullRequestDiffResult.self + ) + } + + /// Upload URLs carry their own short-lived signature and do not need the + /// environment's bearer token or DPoP authorization headers. + public func uploadAttachment( + _ data: Data, + mimeType: String, + to url: URL + ) async throws { + var request = URLRequest(url: url, timeoutInterval: 60) + request.httpMethod = "POST" + request.httpBody = data + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") + request.setValue(String(data.count), forHTTPHeaderField: "Content-Length") + + let (responseData, response) = try await transport.data( + for: HTTPRequestPolicy.prepare(request) + ) + guard (200...299).contains(response.statusCode) else { + let detail = String(data: responseData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw HTTPError.status( + response.statusCode, + message: detail.flatMap { $0.isEmpty ? nil : $0 } ?? "Image upload failed.", + traceID: nil + ) + } + } + + /// Production uses URLSession's file upload API so large attachments never + /// become one in-memory Data value. + public func uploadAttachment( + fileURL: URL, + byteCount: Int, + mimeType: String, + to url: URL + ) async throws { + var request = URLRequest(url: url, timeoutInterval: 60) + request.httpMethod = "POST" + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") + request.setValue(String(byteCount), forHTTPHeaderField: "Content-Length") + + let (responseData, response) = try await transport.upload( + for: HTTPRequestPolicy.prepare(request), + fromFile: fileURL + ) + guard (200...299).contains(response.statusCode) else { + let detail = String(data: responseData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw HTTPError.status( + response.statusCode, + message: detail.flatMap { $0.isEmpty ? nil : $0 } ?? "File upload failed.", + traceID: nil + ) + } + } + + public func webSocketTicket(for environment: Environment) async throws -> WebSocketTicket { + try await authorized( + environment: environment, + path: "/api/auth/websocket-ticket", + method: "POST", + as: WebSocketTicket.self + ) + } + + public func session(for environment: Environment) async throws -> AuthSessionState { + try await authorized( + environment: environment, + path: "/api/auth/session", + method: "GET", + isUnauthorizedResponse: { !$0.authenticated }, + as: AuthSessionState.self + ) + } + + public func clientSessions(for environment: Environment) async throws + -> [AuthClientSession] + { + try await authorized( + environment: environment, + path: "/api/auth/clients", + method: "GET", + as: [AuthClientSession].self + ) + } + + public func revokeClientSession( + id: String, + environment: Environment + ) async throws -> AuthClientSessionRevokeResult { + try await authorized( + environment: environment, + path: "/api/auth/clients/revoke", + method: "POST", + body: JSONEncoder.t3.encode(["sessionId": id]), + as: AuthClientSessionRevokeResult.self + ) + } + + public func revokeOtherClientSessions( + for environment: Environment + ) async throws -> AuthOtherClientSessionsRevokeResult { + try await authorized( + environment: environment, + path: "/api/auth/clients/revoke-others", + method: "POST", + as: AuthOtherClientSessionsRevokeResult.self + ) + } + + private func authorized( + environment: Environment, + path: String, + queryItems: [URLQueryItem] = [], + method: String, + body: Data? = nil, + timeoutInterval: TimeInterval? = nil, + isUnauthorizedResponse: (@Sendable (Result) -> Bool)? = nil, + as type: Result.Type + ) async throws -> Result { + guard let credential = try await credentials.credential(for: environment.id) else { + throw HTTPError.missingCredential + } + + switch environment.kind { + case .bearer, .local: + guard credential.authorizationMethod == .bearer else { + throw HTTPError.incompatibleCredential + } + var request = makeRequest( + environment: environment, + path: path, + queryItems: queryItems, + method: method, + body: body + ) + if let timeoutInterval { + request.timeoutInterval = timeoutInterval + } + request.setValue( + "Bearer \(credential.accessToken)", + forHTTPHeaderField: "Authorization" + ) + return try await send(request, as: type) + + case .managedDPoP: + guard credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id else { + throw HTTPError.incompatibleCredential + } + guard let managedAuthorization else { + throw HTTPError.managedAuthorizationUnavailable + } + + var current = credential + let bindingRequiresRefresh = try await managedAuthorization + .credentialRequiresRefresh(current, environment: environment) + if current.expiresAt?.timeIntervalSinceNow ?? 0 <= Self.managedRefreshMargin + || bindingRequiresRefresh { + current = try await refreshManagedCredential( + current, + environment: environment, + using: managedAuthorization + ) + } + var request = try await managedAuthorization.authorize( + makeRequest( + environment: environment, + path: path, + queryItems: queryItems, + method: method, + body: body + ), + environment: environment, + credential: current + ) + if let timeoutInterval { + request.timeoutInterval = timeoutInterval + } + do { + return try await send( + request, + isManagedRequest: true, + isUnauthorizedResponse: isUnauthorizedResponse, + as: type + ) + } catch let error as HTTPError where error.isRejectedAuthorization { + if let saved = try await newestUsableManagedCredential( + replacing: current, + environment: environment, + using: managedAuthorization + ) { + current = saved + } else { + current = try await refreshManagedCredential( + current, + environment: environment, + using: managedAuthorization + ) + } + var retry = try await managedAuthorization.authorize( + makeRequest( + environment: environment, + path: path, + queryItems: queryItems, + method: method, + body: body + ), + environment: environment, + credential: current + ) + if let timeoutInterval { + retry.timeoutInterval = timeoutInterval + } + return try await send( + retry, + isManagedRequest: true, + isUnauthorizedResponse: isUnauthorizedResponse, + as: type + ) + } + } + } + + private func makeRequest( + environment: Environment, + path: String, + queryItems: [URLQueryItem], + method: String, + body: Data? + ) -> URLRequest { + var request = URLRequest( + url: endpoint(environment.httpBaseURL, path: path, queryItems: queryItems) + ) + request.httpMethod = method + request.httpBody = body + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func refreshManagedCredential( + _ credential: EnvironmentCredential, + environment: Environment, + using managedAuthorization: any ManagedEnvironmentAuthorizing + ) async throws -> EnvironmentCredential { + if let current = try await newestUsableManagedCredential( + replacing: credential, + environment: environment, + using: managedAuthorization + ) { + return current + } + let refreshed = try await managedAuthorization.refreshCredential( + for: environment, + replacing: credential + ) + guard refreshed.authorizationMethod == .dpop, + refreshed.managedEnvironmentID == environment.id, + refreshed.proofKeyThumbprint?.isEmpty == false else { + throw HTTPError.incompatibleCredential + } + guard try await credentials.replaceCredential( + refreshed, + ifMatching: credential, + for: environment.id + ) else { + if let current = try await newestUsableManagedCredential( + replacing: credential, + environment: environment, + using: managedAuthorization + ) { + return current + } + throw HTTPError.missingCredential + } + return refreshed + } + + private func newestUsableManagedCredential( + replacing credential: EnvironmentCredential, + environment: Environment, + using managedAuthorization: any ManagedEnvironmentAuthorizing + ) async throws -> EnvironmentCredential? { + guard let saved = try await credentials.credential(for: environment.id), + saved != credential, + saved.authorizationMethod == .dpop, + saved.managedEnvironmentID == environment.id, + saved.proofKeyThumbprint?.isEmpty == false, + saved.expiresAt?.timeIntervalSinceNow ?? 0 > Self.managedRefreshMargin else { + return nil + } + let requiresRefresh = try await managedAuthorization.credentialRequiresRefresh( + saved, + environment: environment + ) + guard !requiresRefresh else { return nil } + return saved + } + + private func send( + _ request: URLRequest, + isManagedRequest: Bool = false, + isUnauthorizedResponse: (@Sendable (Result) -> Bool)? = nil, + as type: Result.Type + ) async throws -> Result { + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + } catch { + throw isManagedRequest ? T3ConnectNetworkError.wrapping(error) : error + } + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(EnvironmentErrorBody.self, from: data) + let message: String + if response.statusCode == 401, + request.value(forHTTPHeaderField: "DPoP") != nil, + body?.reason == "invalid_credential" + { + message = DPoPFailurePresentation.message( + "The environment credential is invalid.", + reason: body?.dpopFailureReason + ) + } else { + message = body?.message ?? body?.reason ?? "Environment request failed." + } + throw HTTPError.status( + response.statusCode, + message: message, + traceID: body?.traceId + ) + } + let result = try JSONDecoder.t3.decode(type, from: data) + if isUnauthorizedResponse?(result) == true { + throw HTTPError.unauthenticatedSession + } + return result + } +} + +private extension HTTPError { + var isRejectedAuthorization: Bool { + switch self { + case .unauthenticatedSession: return true + case let .status(status, _, _): return status == 401 + default: return false + } + } +} + +public struct DispatchResult: Codable, Equatable, Sendable { + public let sequence: Int +} + +public struct WebSocketTicket: Codable, Equatable, Sendable { + public let ticket: String + public let expiresAt: String +} + +public struct AuthSessionState: Codable, Equatable, Sendable { + public let authenticated: Bool + public let scopes: [String]? + public let sessionMethod: String? + public let expiresAt: String? +} + +public struct AuthClientMetadata: Codable, Equatable, Sendable { + public let label: String? + public let ipAddress: String? + public let userAgent: String? + public let deviceType: String + public let os: String? + public let browser: String? +} + +public struct AuthClientSession: Codable, Identifiable, Equatable, Sendable { + public var id: String { sessionId } + + public let sessionId: String + public let subject: String + public let scopes: [String] + public let method: String + public let client: AuthClientMetadata + public let issuedAt: String + public let expiresAt: String + public let lastConnectedAt: String? + public let connected: Bool + public let current: Bool +} + +public struct AuthClientSessionRevokeResult: Codable, Equatable, Sendable { + public let revoked: Bool +} + +public struct AuthOtherClientSessionsRevokeResult: Codable, Equatable, Sendable { + public let revokedCount: Int +} + +func endpoint(_ baseURL: URL, path: String, queryItems: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.path = path + components.queryItems = queryItems.isEmpty ? nil : queryItems + components.fragment = nil + return components.url! +} diff --git a/apps/swift-ios/Core/JSONValue.swift b/apps/swift-ios/Core/JSONValue.swift new file mode 100644 index 000000000000..c149d9a19037 --- /dev/null +++ b/apps/swift-ios/Core/JSONValue.swift @@ -0,0 +1,111 @@ +import Foundation + +/// A lossless, Sendable JSON representation used at protocol boundaries that +/// intentionally carry provider-defined payloads. +public enum JSONValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case integer(Int64) + case unsignedInteger(UInt64) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int64.self) { + let double = Double(value) + self = Int64(exactly: double) == value ? .number(double) : .integer(value) + } else if let value = try? container.decode(UInt64.self) { + let double = Double(value) + self = UInt64(exactly: double) == value + ? .number(double) + : .unsignedInteger(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .integer(value): + try container.encode(value) + case let .unsignedInteger(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } + + public subscript(key: String) -> JSONValue? { + guard case let .object(object) = self else { return nil } + return object[key] + } + + public var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + public static func encode( + _ value: T, + encoder: JSONEncoder = .t3 + ) throws -> JSONValue { + let data = try encoder.encode(value) + return try JSONDecoder.t3.decode(JSONValue.self, from: data) + } + + public func decode( + _ type: T.Type, + decoder: JSONDecoder = .t3 + ) throws -> T { + // The intermediate bytes are discarded immediately, so skip the + // deterministic-output formatting the wire encoder pays for. + try decoder.decode(type, from: JSONEncoder.t3Intermediate.encode(self)) + } +} + +// Encoders and decoders are configured once and never mutated afterwards, so +// shared instances are safe for concurrent use and avoid rebuilding coder +// state on every RPC message. +extension JSONEncoder { + /// Deterministic output for wire payloads. + public static let t3: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + + /// Throwaway intermediate encoding (JSONValue -> concrete type bridging). + static let t3Intermediate: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return encoder + }() +} + +extension JSONDecoder { + public static let t3 = JSONDecoder() +} diff --git a/apps/swift-ios/Core/LocalNetworkProbe.swift b/apps/swift-ios/Core/LocalNetworkProbe.swift new file mode 100644 index 000000000000..878a55606a8c --- /dev/null +++ b/apps/swift-ios/Core/LocalNetworkProbe.swift @@ -0,0 +1,179 @@ +import Foundation + +public struct ConnectionProbeResult: Equatable, Sendable { + public let baseURL: URL + public let descriptor: EnvironmentDescriptor + public let latency: Duration +} + +public enum ConnectionProbeError: LocalizedError, Equatable, Sendable { + case invalidURL + case unavailableHost(String) + case timeout(String) + case likelyLocalNetworkDenied(String) + case serverRejected(status: Int, message: String) + case transport(String) + + public var errorDescription: String? { + switch self { + case .invalidURL: + "Enter a valid T3 server address." + case let .unavailableHost(host): + "Could not reach \(host). Check that the server is running and both devices are online." + case let .timeout(host): + "\(host) did not respond in time." + case let .likelyLocalNetworkDenied(host): + "Local Network access appears to be off for T3 Code. Allow it in Settings, then retry \(host)." + case let .serverRejected(status, message): + "\(message) (HTTP \(status))" + case let .transport(message): + message + } + } +} + +/// Performs the public environment-descriptor request before token exchange. +/// +/// iOS does not expose a direct Local Network privacy authorization query. A +/// real connection attempt is the authoritative way to trigger/check access; +/// failures carrying POSIX permission evidence are reported separately from an +/// offline server. +public actor LocalNetworkProbe { + private let transport: any HTTPTransport + + public init(transport: any HTTPTransport = URLSessionHTTPTransport()) { + self.transport = transport + } + + public func probe( + address rawAddress: String, + timeout: TimeInterval = 5 + ) async throws -> ConnectionProbeResult { + let fields: PairingInputFields + do { + fields = try PairingURL.parseFields(rawAddress) + } catch { + throw ConnectionProbeError.invalidURL + } + guard let baseURL = try? PairingURL.httpBaseURL(for: fields.host), + let host = baseURL.host else { + throw ConnectionProbeError.invalidURL + } + + var request = URLRequest( + url: endpoint(baseURL, path: "/.well-known/t3/environment"), + timeoutInterval: max(1, timeout) + ) + request.httpMethod = "GET" + let clock = ContinuousClock() + let startedAt = clock.now + do { + let (data, response) = try await transport.data( + for: HTTPRequestPolicy.prepare(request) + ) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(JSONValue.self, from: data) + throw ConnectionProbeError.serverRejected( + status: response.statusCode, + message: body?["message"]?.stringValue + ?? body?["reason"]?.stringValue + ?? "The server rejected the connection probe." + ) + } + let descriptor = try JSONDecoder.t3.decode(EnvironmentDescriptor.self, from: data) + return ConnectionProbeResult( + baseURL: baseURL, + descriptor: descriptor, + latency: startedAt.duration(to: clock.now) + ) + } catch let error as ConnectionProbeError { + throw error + } catch { + throw Self.classify(error, host: host, isLocal: Self.isLocalHost(host)) + } + } + + public static func classify( + _ error: any Error, + host: String, + isLocal: Bool + ) -> ConnectionProbeError { + let nsError = error as NSError + let urlCode = URLError.Code(rawValue: nsError.code) + if nsError.domain == NSURLErrorDomain { + switch urlCode { + case .timedOut: + return .timeout(host) + case .cannotFindHost, .dnsLookupFailed, .cannotConnectToHost: + if isLocal, hasPermissionDenialEvidence(nsError) { + return .likelyLocalNetworkDenied(host) + } + return .unavailableHost(host) + case .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, + .dataNotAllowed: + if isLocal, hasPermissionDenialEvidence(nsError) { + return .likelyLocalNetworkDenied(host) + } + return .unavailableHost(host) + default: + break + } + } + if isLocal, hasPermissionDenialEvidence(nsError) { + return .likelyLocalNetworkDenied(host) + } + return .transport(nsError.localizedDescription) + } + + public static func isLocalHost(_ host: String) -> Bool { + let value = host + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + .lowercased() + if value == "localhost" || value == "::1" || value.hasSuffix(".local") { + return true + } + if value.hasPrefix("10.") + || value.hasPrefix("127.") + || value.hasPrefix("192.168.") + || value.hasPrefix("169.254.") + { + return true + } + let octets = value.split(separator: ".").compactMap { Int($0) } + if octets.count == 4, octets[0] == 172, (16...31).contains(octets[1]) { + return true + } + // IPv6 unique-local and link-local ranges. + return value.hasPrefix("fc") + || value.hasPrefix("fd") + || value.hasPrefix("fe8") + || value.hasPrefix("fe9") + || value.hasPrefix("fea") + || value.hasPrefix("feb") + } + + private static func hasPermissionDenialEvidence(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, error.code == 1 || error.code == 13 { + return true + } + let description = [ + error.localizedDescription, + error.localizedFailureReason, + error.localizedRecoverySuggestion, + String(describing: error.userInfo), + ] + .compactMap { $0 } + .joined(separator: " ") + .lowercased() + if description.contains("local network prohibited") + || description.contains("localnetworkdenied") + || description.contains("local network denied") + { + return true + } + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return hasPermissionDenialEvidence(underlying) + } + return false + } +} diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift new file mode 100644 index 000000000000..7749a51fa326 --- /dev/null +++ b/apps/swift-ios/Core/Models.swift @@ -0,0 +1,721 @@ +import Foundation + +/// Arrays sent by the server are allowed to grow new element variants before a +/// mobile release catches up. Decode each element independently so one future +/// project, thread, message, or activity cannot discard the rest of a snapshot. +@propertyWrapper +public struct ForwardCompatibleArray: Codable, Equatable, Sendable +where Element: Codable & Equatable & Sendable { + public var wrappedValue: [Element] + + public init(wrappedValue: [Element]) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: any Decoder) throws { + var container = try decoder.unkeyedContainer() + var values: [Element] = [] + values.reserveCapacity(container.count ?? 0) + while !container.isAtEnd { + // `superDecoder` advances the unkeyed container even when the + // element itself is not understood by this client. + let elementDecoder = try container.superDecoder() + if let value = try? Element(from: elementDecoder) { + values.append(value) + } + } + wrappedValue = values + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.unkeyedContainer() + for value in wrappedValue { + try container.encode(value) + } + } +} + +public enum EnvironmentKind: String, Codable, Sendable { + case bearer + case local + case managedDPoP = "managed-dpop" +} + +public struct Environment: Codable, Identifiable, Equatable, Sendable { + public let id: String + public var label: String + public var httpBaseURL: URL + public var webSocketBaseURL: URL + public var kind: EnvironmentKind + public var descriptor: EnvironmentDescriptor? + public var isEnabled: Bool + + public init( + id: String, + label: String, + httpBaseURL: URL, + webSocketBaseURL: URL, + kind: EnvironmentKind = .bearer, + descriptor: EnvironmentDescriptor? = nil, + isEnabled: Bool = true + ) { + self.id = id + self.label = label + self.httpBaseURL = httpBaseURL + self.webSocketBaseURL = webSocketBaseURL + self.kind = kind + self.descriptor = descriptor + self.isEnabled = isEnabled + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case httpBaseURL + case webSocketBaseURL + case kind + case descriptor + case isEnabled + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + label = try container.decode(String.self, forKey: .label) + httpBaseURL = try container.decode(URL.self, forKey: .httpBaseURL) + webSocketBaseURL = try container.decode(URL.self, forKey: .webSocketBaseURL) + kind = try container.decodeIfPresent(EnvironmentKind.self, forKey: .kind) ?? .bearer + descriptor = try container.decodeIfPresent(EnvironmentDescriptor.self, forKey: .descriptor) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + } +} + +public struct EnvironmentDescriptor: Codable, Equatable, Sendable { + public struct Platform: Codable, Equatable, Sendable { + public let os: String + public let arch: String + public var machine: String? = nil + } + + public struct Capabilities: Codable, Equatable, Sendable { + public struct FileAttachments: Codable, Equatable, Sendable { + public let maxUploadBytes: Int + } + + public let repositoryIdentity: Bool + public let connectionProbe: Bool? + public let attachmentUploads: Bool? + public let fileAttachments: FileAttachments? + public let pullRequests: Bool? + public let threadSettlement: Bool? + public let threadAutoSettlement: Bool? + public var threadRestartContinuation: Bool? = nil + public let threadSnooze: Bool? + public let threadPinning: Bool? + public let threadTitleRegeneration: Bool? + public let threadPullRequestLinking: Bool? + public let serverSelfUpdate: String? + public let serverSelfUpdateProgress: Bool? + public var environmentIcon: Bool? = nil + public var usageLimitSources: Bool? = nil + + private enum CodingKeys: String, CodingKey { + case repositoryIdentity + case connectionProbe + case attachmentUploads + case fileAttachments + case pullRequests + case threadSettlement + case threadAutoSettlement + case threadRestartContinuation + case threadSnooze + case threadPinning + case threadTitleRegeneration + case threadPullRequestLinking + case serverSelfUpdate + case serverSelfUpdateProgress + case environmentIcon + case usageLimitSources + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + environmentIcon = try container.decodeIfPresent(Bool.self, forKey: .environmentIcon) + usageLimitSources = try container.decodeIfPresent(Bool.self, forKey: .usageLimitSources) + repositoryIdentity = + try container.decodeIfPresent(Bool.self, forKey: .repositoryIdentity) ?? false + connectionProbe = try container.decodeIfPresent(Bool.self, forKey: .connectionProbe) + attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) + fileAttachments = try container.decodeIfPresent( + FileAttachments.self, + forKey: .fileAttachments + ) + pullRequests = try container.decodeIfPresent(Bool.self, forKey: .pullRequests) + threadSettlement = try container.decodeIfPresent(Bool.self, forKey: .threadSettlement) + threadAutoSettlement = try container.decodeIfPresent( + Bool.self, + forKey: .threadAutoSettlement + ) + threadRestartContinuation = try container.decodeIfPresent( + Bool.self, + forKey: .threadRestartContinuation + ) + threadSnooze = try container.decodeIfPresent(Bool.self, forKey: .threadSnooze) + threadPinning = try container.decodeIfPresent(Bool.self, forKey: .threadPinning) + threadTitleRegeneration = try container.decodeIfPresent( + Bool.self, + forKey: .threadTitleRegeneration + ) + threadPullRequestLinking = try container.decodeIfPresent( + Bool.self, + forKey: .threadPullRequestLinking + ) + serverSelfUpdate = try container.decodeIfPresent(String.self, forKey: .serverSelfUpdate) + serverSelfUpdateProgress = try container.decodeIfPresent( + Bool.self, + forKey: .serverSelfUpdateProgress + ) + } + } + + public let environmentId: String + public let label: String + public let platform: Platform + public let serverVersion: String + public let capabilities: Capabilities +} + +public struct ProviderUploadFeedbackResult: Codable, Equatable, Sendable { + public let feedbackId: String +} + +public enum EnvironmentCredentialAuthorizationMethod: String, Codable, Sendable { + case bearer + case dpop +} + +public struct EnvironmentCredential: Codable, Equatable, Sendable, + CustomStringConvertible, CustomDebugStringConvertible +{ + public let accessToken: String + public let expiresAt: Date? + public let scopes: [String] + public let authorizationMethod: EnvironmentCredentialAuthorizationMethod + public let managedEnvironmentID: String? + public let proofKeyThumbprint: String? + + public init(accessToken: String, expiresAt: Date? = nil, scopes: [String] = []) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + authorizationMethod = .bearer + managedEnvironmentID = nil + proofKeyThumbprint = nil + } + + public static func managedDPoP( + accessToken: String, + expiresAt: Date, + scopes: [String], + environmentID: String, + proofKeyThumbprint: String + ) -> EnvironmentCredential { + EnvironmentCredential( + accessToken: accessToken, + expiresAt: expiresAt, + scopes: scopes, + authorizationMethod: .dpop, + managedEnvironmentID: environmentID, + proofKeyThumbprint: proofKeyThumbprint + ) + } + + public var description: String { + "EnvironmentCredential(method: \(authorizationMethod.rawValue), token: )" + } + + public var debugDescription: String { description } + + private init( + accessToken: String, + expiresAt: Date?, + scopes: [String], + authorizationMethod: EnvironmentCredentialAuthorizationMethod, + managedEnvironmentID: String?, + proofKeyThumbprint: String? + ) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + self.authorizationMethod = authorizationMethod + self.managedEnvironmentID = managedEnvironmentID + self.proofKeyThumbprint = proofKeyThumbprint + } + + private enum CodingKeys: String, CodingKey { + case accessToken + case expiresAt + case scopes + case authorizationMethod + case managedEnvironmentID + case proofKeyThumbprint + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try container.decode(String.self, forKey: .accessToken) + expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) + scopes = try container.decodeIfPresent([String].self, forKey: .scopes) ?? [] + authorizationMethod = try container.decodeIfPresent( + EnvironmentCredentialAuthorizationMethod.self, + forKey: .authorizationMethod + ) ?? .bearer + managedEnvironmentID = try container.decodeIfPresent( + String.self, + forKey: .managedEnvironmentID + ) + proofKeyThumbprint = try container.decodeIfPresent( + String.self, + forKey: .proofKeyThumbprint + ) + + if authorizationMethod == .dpop { + guard expiresAt != nil, + managedEnvironmentID?.isEmpty == false, + proofKeyThumbprint?.isEmpty == false else { + throw DecodingError.dataCorruptedError( + forKey: .authorizationMethod, + in: container, + debugDescription: "A DPoP credential is missing its binding metadata." + ) + } + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(accessToken, forKey: .accessToken) + try container.encodeIfPresent(expiresAt, forKey: .expiresAt) + try container.encode(scopes, forKey: .scopes) + try container.encode(authorizationMethod, forKey: .authorizationMethod) + try container.encodeIfPresent(managedEnvironmentID, forKey: .managedEnvironmentID) + try container.encodeIfPresent(proofKeyThumbprint, forKey: .proofKeyThumbprint) + } +} + +public struct ModelSelection: Codable, Equatable, Sendable { + public struct OptionSelection: Codable, Equatable, Sendable { + public let id: String + public let value: JSONValue + + public init(id: String, value: JSONValue) { + self.id = id + self.value = value + } + } + + public let instanceId: String + public let model: String + public let options: [OptionSelection]? + + public init(instanceId: String, model: String, options: [OptionSelection]? = nil) { + self.instanceId = instanceId + self.model = model + self.options = options + } + + private enum CodingKeys: String, CodingKey { + case instanceId, provider, model, options + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + instanceId = try container.decodeIfPresent(String.self, forKey: .instanceId) + ?? container.decode(String.self, forKey: .provider) + model = try container.decode(String.self, forKey: .model) + if let canonical = try? container.decode([OptionSelection].self, forKey: .options) { + options = canonical + } else if let legacy = try? container.decode( + [String: JSONValue].self, + forKey: .options + ) { + options = legacy.keys.sorted().map { OptionSelection(id: $0, value: legacy[$0]!) } + } else { + options = nil + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(instanceId, forKey: .instanceId) + try container.encode(model, forKey: .model) + try container.encodeIfPresent(options, forKey: .options) + } +} + +public struct RepositoryIdentity: Codable, Equatable, Sendable { + public struct Locator: Codable, Equatable, Sendable { + public let source: String + public let remoteName: String + public let remoteUrl: String + } + + public let canonicalKey: String + public let locator: Locator + public let rootPath: String? + public let displayName: String? + public let provider: String? + public let owner: String? + public let name: String? +} + +public struct ProjectScript: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let command: String + public let icon: String + public let runOnWorktreeCreate: Bool + public let previewUrl: String? + public let autoOpenPreview: Bool? +} + +public struct OrchestrationProject: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let workspaceRoot: String + public let repositoryIdentity: RepositoryIdentity? + public let defaultModelSelection: ModelSelection? + public let scripts: [ProjectScript] + public let createdAt: String + public let updatedAt: String + public let deletedAt: String? + public var projectIcon: ProjectIconOverride? = nil +} + +public struct ProjectIconOverride: Codable, Equatable, Hashable, Sendable { + public let kind: String + public var name: String? = nil + public var color: String? = nil + public var emoji: String? = nil +} + +public enum RuntimeMode: String, Codable, CaseIterable, Sendable { + case approvalRequired = "approval-required" + case autoAcceptEdits = "auto-accept-edits" + case auto + case fullAccess = "full-access" +} + +public enum InteractionMode: String, Codable, CaseIterable, Sendable { + case `default` + case plan +} + +public struct OrchestrationLatestTurn: Codable, Equatable, Sendable { + public let turnId: String + public let state: String + public let requestedAt: String + public let startedAt: String? + public let completedAt: String? + public let assistantMessageId: String? +} + +public struct OrchestrationSession: Codable, Equatable, Sendable { + public let threadId: String + public let status: String + public let providerName: String? + public let providerInstanceId: String? + public let runtimeMode: RuntimeMode + public let activeTurnId: String? + public let lastError: String? + public let updatedAt: String +} + +public enum OrchestrationBackgroundLiveness: String, Codable, Equatable, Sendable { + case working + case monitoring +} + +public struct ThreadLinkedPullRequest: Codable, Equatable, Hashable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + public let url: String + + public init(projectId: String, repository: String, number: Int, url: String) { + self.projectId = projectId + self.repository = repository + self.number = number + self.url = url + } +} + +public struct OrchestrationThreadShell: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let projectId: String + public let title: String + public let modelSelection: ModelSelection + public let runtimeMode: RuntimeMode + public let interactionMode: InteractionMode + public let branch: String? + public let worktreePath: String? + public var linkedPullRequest: ThreadLinkedPullRequest? = nil + public var branchPullRequest: ThreadLinkedPullRequest? = nil + public let latestTurn: OrchestrationLatestTurn? + public let createdAt: String + public let updatedAt: String + public let archivedAt: String? + public let settledOverride: String? + public let settledAt: String? + public var unsettledAt: String? = nil + public var activeOrderKey: String? = nil + public let snoozedUntil: String? + public let snoozedAt: String? + public let pinnedAt: String? + public let session: OrchestrationSession? + public let latestUserMessageAt: String? + public let hasPendingApprovals: Bool + public let hasPendingUserInput: Bool + public let hasActionableProposedPlan: Bool + public let backgroundLiveness: OrchestrationBackgroundLiveness? +} + +public struct OrchestrationMessage: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let role: String + public let text: String + public let attachments: [ChatAttachment]? + public let turnId: String? + public let streaming: Bool + public let createdAt: String + public let updatedAt: String +} + +public struct ChatAttachment: Codable, Identifiable, Equatable, Sendable { + public let type: String + public let id: String + public let name: String + public let mimeType: String + public let sizeBytes: Int +} + +public struct OrchestrationActivity: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let tone: String + public let kind: String + public let summary: String + public let payload: JSONValue + public let turnId: String? + public let sequence: Int? + public let createdAt: String +} + +public struct CheckpointFile: Codable, Equatable, Sendable { + public let path: String + public let kind: String + public let additions: Int + public let deletions: Int +} + +public struct CheckpointSummary: Codable, Equatable, Sendable { + public let turnId: String + public let checkpointTurnCount: Int + public let checkpointRef: String + public let status: String + public let files: [CheckpointFile] + public let assistantMessageId: String? + public let completedAt: String +} + +public struct OrchestrationThread: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let projectId: String + public let title: String + public let modelSelection: ModelSelection + public let runtimeMode: RuntimeMode + public let interactionMode: InteractionMode + public let branch: String? + public let worktreePath: String? + public var linkedPullRequest: ThreadLinkedPullRequest? = nil + public var branchPullRequest: ThreadLinkedPullRequest? = nil + public let latestTurn: OrchestrationLatestTurn? + public let createdAt: String + public let updatedAt: String + public let archivedAt: String? + public let settledOverride: String? + public let settledAt: String? + public var unsettledAt: String? = nil + public var activeOrderKey: String? = nil + public let snoozedUntil: String? + public let snoozedAt: String? + public let pinnedAt: String? + public let deletedAt: String? + @ForwardCompatibleArray public var messages: [OrchestrationMessage] + @ForwardCompatibleArray public var activities: [OrchestrationActivity] + @ForwardCompatibleArray public var checkpoints: [CheckpointSummary] + public let session: OrchestrationSession? +} + +public struct OrchestrationShellSnapshot: Codable, Equatable, Sendable { + public let snapshotSequence: Int + @ForwardCompatibleArray public var projects: [OrchestrationProject] + @ForwardCompatibleArray public var threads: [OrchestrationThreadShell] + public let updatedAt: String +} + +public struct OrchestrationReadModel: Codable, Equatable, Sendable { + public let snapshotSequence: Int + @ForwardCompatibleArray public var projects: [OrchestrationProject] + @ForwardCompatibleArray public var threads: [OrchestrationThread] + public let updatedAt: String +} + +public struct OrchestrationThreadDetailSnapshot: Codable, Equatable, Sendable { + public let snapshotSequence: Int + public let thread: OrchestrationThread + public let page: OrchestrationThreadDetailPage? + + public init( + snapshotSequence: Int, + thread: OrchestrationThread, + page: OrchestrationThreadDetailPage? = nil + ) { + self.snapshotSequence = snapshotSequence + self.thread = thread + self.page = page + } +} + +public struct OrchestrationThreadDetailPage: Codable, Equatable, Sendable { + public let beforeCursor: String? + public let hasMore: Bool + public let snapshotSequence: Int + public let threadSequence: Int? + + public init( + beforeCursor: String?, + hasMore: Bool, + snapshotSequence: Int, + threadSequence: Int? = nil + ) { + self.beforeCursor = beforeCursor + self.hasMore = hasMore + self.snapshotSequence = snapshotSequence + self.threadSequence = threadSequence + } +} + +public enum ShellStreamItem: Decodable, Sendable { + case synchronized + case snapshot(OrchestrationShellSnapshot) + case projectUpserted(sequence: Int, project: OrchestrationProject) + case projectRemoved(sequence: Int, projectID: String) + case threadUpserted(sequence: Int, thread: OrchestrationThreadShell) + case threadRemoved(sequence: Int, threadID: String) + /// A newer server emitted a delta this build cannot reduce. The live client + /// should fetch an authoritative shell snapshot and keep the stream alive. + case refreshRequired + + private enum CodingKeys: String, CodingKey { + case kind, sequence, snapshot, project, projectId, thread, threadId + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "synchronized": + self = .synchronized + case "snapshot": + guard let snapshot = try? container.decode( + OrchestrationShellSnapshot.self, + forKey: .snapshot + ) else { + self = .refreshRequired + return + } + self = .snapshot(snapshot) + case "project-upserted": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let project = try? container.decode( + OrchestrationProject.self, + forKey: .project + ) else { + self = .refreshRequired + return + } + self = .projectUpserted( + sequence: sequence, + project: project + ) + case "project-removed": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let projectID = try? container.decode(String.self, forKey: .projectId) else { + self = .refreshRequired + return + } + self = .projectRemoved( + sequence: sequence, + projectID: projectID + ) + case "thread-upserted": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let thread = try? container.decode( + OrchestrationThreadShell.self, + forKey: .thread + ) else { + self = .refreshRequired + return + } + self = .threadUpserted( + sequence: sequence, + thread: thread + ) + case "thread-removed": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let threadID = try? container.decode(String.self, forKey: .threadId) else { + self = .refreshRequired + return + } + self = .threadRemoved( + sequence: sequence, + threadID: threadID + ) + default: + self = .refreshRequired + } + } +} + +public enum ThreadStreamItem: Decodable, Sendable { + case synchronized + case snapshot(OrchestrationThreadDetailSnapshot) + case event(JSONValue) + + private enum CodingKeys: String, CodingKey { case kind, snapshot, event } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "synchronized": + self = .synchronized + case "snapshot": + guard let snapshot = try? container.decode( + OrchestrationThreadDetailSnapshot.self, + forKey: .snapshot + ) else { + self = .event(.null) + return + } + self = .snapshot(snapshot) + case "event": + self = .event((try? container.decode(JSONValue.self, forKey: .event)) ?? .null) + default: + // The detail reducer already treats an unrecognized event as an + // authoritative-refresh request. Reuse that path without adding a + // second stream state or terminating the subscription. + self = .event(.null) + } + } +} diff --git a/apps/swift-ios/Core/PairingService.swift b/apps/swift-ios/Core/PairingService.swift new file mode 100644 index 000000000000..9cf2b41cc1e0 --- /dev/null +++ b/apps/swift-ios/Core/PairingService.swift @@ -0,0 +1,159 @@ +import Foundation + +public struct TokenExchangeResult: Decodable, Sendable { + public let accessToken: String + public let issuedTokenType: String + public let tokenType: String + public let expiresIn: Double + public let scope: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } +} + +public actor PairingService { + private let transport: any HTTPTransport + private let environmentStore: EnvironmentStore + private let credentialStore: any CredentialStore + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + environmentStore: EnvironmentStore, + credentialStore: any CredentialStore + ) { + self.transport = transport + self.environmentStore = environmentStore + self.credentialStore = credentialStore + } + + @discardableResult + public func pair( + url pairingURL: String, + label clientLabel: String? = nil + ) async throws -> Environment { + try await pair(target: PairingURL.resolve(pairingURL), clientLabel: clientLabel) + } + + @discardableResult + public func pair( + host: String, + code: String, + label clientLabel: String? = nil + ) async throws -> Environment { + try await pair( + target: PairingURL.resolve(host: host, pairingCode: code), + clientLabel: clientLabel + ) + } + + private func pair( + target: PairingTarget, + clientLabel: String? + ) async throws -> Environment { + let api = EnvironmentAPI(transport: transport, credentials: credentialStore) + let descriptor = try await api.descriptor(at: target.httpBaseURL) + let access = try await exchange(target: target, clientLabel: clientLabel) + guard access.tokenType == "Bearer" else { + throw HTTPError.status( + 400, + message: "The environment issued an unsupported \(access.tokenType) token.", + traceID: nil + ) + } + let environment = Environment( + id: descriptor.environmentId, + label: descriptor.label, + httpBaseURL: target.httpBaseURL, + webSocketBaseURL: target.webSocketBaseURL, + descriptor: descriptor + ) + let credential = EnvironmentCredential( + accessToken: access.accessToken, + expiresAt: Date().addingTimeInterval(access.expiresIn), + scopes: access.scope.split(separator: " ").map(String.init) + ) + // Store the secret first. A catalog record must never point at a + // credential that failed to persist. Capture the previous credential in + // the same actor operation so a concurrent refresh cannot be lost. + let previousCredential = try await credentialStore.swapCredential( + credential, + for: environment.id + ) + do { + try await environmentStore.upsert(environment) + if try await environmentStore.activeEnvironmentID() == nil { + try await environmentStore.setActiveEnvironment(id: environment.id) + } + } catch { + if let previousCredential { + _ = try? await credentialStore.replaceCredential( + previousCredential, + ifMatching: credential, + for: environment.id + ) + } else { + _ = try? await credentialStore.removeCredential( + ifMatching: credential, + for: environment.id + ) + } + throw error + } + return environment + } + + private func exchange( + target: PairingTarget, + clientLabel: String? + ) async throws -> TokenExchangeResult { + var fields = [ + URLQueryItem( + name: "grant_type", + value: "urn:ietf:params:oauth:grant-type:token-exchange" + ), + URLQueryItem(name: "subject_token", value: target.credential), + URLQueryItem( + name: "subject_token_type", + value: "urn:t3:params:oauth:token-type:environment-bootstrap" + ), + URLQueryItem( + name: "requested_token_type", + value: "urn:ietf:params:oauth:token-type:access_token" + ), + URLQueryItem(name: "client_device_type", value: "mobile"), + URLQueryItem(name: "client_os", value: "iOS"), + URLQueryItem(name: "client_surface", value: "mobile"), + ] + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + !appVersion.isEmpty { + fields.append(URLQueryItem(name: "client_app_version", value: appVersion)) + } + if let clientLabel, !clientLabel.isEmpty { + fields.append(URLQueryItem(name: "client_label", value: clientLabel)) + } + var form = URLComponents() + form.queryItems = fields + var request = URLRequest(url: endpoint(target.httpBaseURL, path: "/oauth/token")) + request.httpMethod = "POST" + request.httpBody = form.percentEncodedQuery?.data(using: .utf8) + request.setValue( + "application/x-www-form-urlencoded", + forHTTPHeaderField: "Content-Type" + ) + let (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(JSONValue.self, from: data) + throw HTTPError.status( + response.statusCode, + message: body?["reason"]?.stringValue ?? "Pairing failed.", + traceID: body?["traceId"]?.stringValue + ) + } + return try JSONDecoder.t3.decode(TokenExchangeResult.self, from: data) + } +} diff --git a/apps/swift-ios/Core/PairingURL.swift b/apps/swift-ios/Core/PairingURL.swift new file mode 100644 index 000000000000..d07b87a51705 --- /dev/null +++ b/apps/swift-ios/Core/PairingURL.swift @@ -0,0 +1,302 @@ +import Foundation + +public struct PairingTarget: Equatable, Sendable { + public let credential: String + public let httpBaseURL: URL + public let webSocketBaseURL: URL +} + +/// Display fields produced from pasted text or a scanned QR payload. +public struct PairingInputFields: Equatable, Sendable { + public let host: String + public let pairingCode: String + public let label: String? +} + +public enum PairingURLError: LocalizedError, Equatable { + case emptyInput + case invalidURL + case unsupportedScheme + case missingToken + case missingHost + case emptyQRCode + case invalidQRCode + + public var errorDescription: String? { + switch self { + case .emptyInput: "Enter a server address." + case .invalidURL: "Pairing URL is invalid." + case .unsupportedScheme: "Pairing URL uses an unsupported scheme." + case .missingToken: "Pairing URL is missing its token." + case .missingHost: "Pairing URL is missing its environment host." + case .emptyQRCode: "Scanned QR code did not contain a pairing URL." + case .invalidQRCode: "Scanned QR code is not a T3 pairing link." + } + } +} + +public enum PairingURL { + private static let supportedSchemes = Set(["http", "https", "ws", "wss"]) + + /// Resolves a complete pairing link from a clipboard, universal link, or + /// QR scanner. `t3code://pair?pairingUrl=...` wrappers are unwrapped. + public static func resolve(_ rawValue: String) throws -> PairingTarget { + let extracted = try extractPairingURL(from: rawValue, qrInput: false) + let fields = try parseFields(extracted) + return try directTarget(host: fields.host, credential: requireToken(fields.pairingCode)) + } + + /// Resolves split form fields. If the host field contains a complete + /// pairing URL, its embedded token wins. This lets pasting a full link into + /// the host field immediately populate both inputs. + public static func resolve(host: String, pairingCode: String) throws -> PairingTarget { + let fields = try parseFields(host) + let embeddedCode = fields.pairingCode.trimmingCharacters(in: .whitespacesAndNewlines) + let code = embeddedCode.isEmpty ? pairingCode : embeddedCode + return try directTarget(host: fields.host, credential: requireToken(code)) + } + + /// Splits a complete URL or loose `host code` connection string into the + /// two fields shown by onboarding. + public static func parseFields(_ rawValue: String) throws -> PairingInputFields { + let extracted = try extractPairingURL(from: rawValue, qrInput: false) + + if let loose = looseHostAndCode(extracted) { + let normalized = try normalizedBaseURL(loose.host) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: loose.code, + label: nil + ) + } + + guard let components = strictURLComponents(extracted) else { + // Bare hosts are valid form input even before the code is entered. + let normalized = try normalizedBaseURL(extracted) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: "", + label: nil + ) + } + try requireSupportedScheme(components.scheme) + + let query = components.queryItems ?? [] + let fragment = queryItems(fromFragment: components.fragment) + let token = (fragment + query) + .first(where: { $0.name.caseInsensitiveCompare("token") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label = query + .first(where: { $0.name.caseInsensitiveCompare("label") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let hosted = query + .first(where: { $0.name.caseInsensitiveCompare("host") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines), + !hosted.isEmpty + { + let normalized = try normalizedBaseURL(hosted) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: token, + label: label?.isEmpty == false ? label : nil + ) + } + + guard components.host != nil else { throw PairingURLError.missingHost } + let normalized = try normalizedBaseURL(extracted) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: token, + label: label?.isEmpty == false ? label : nil + ) + } + + /// Extracts a pairing URL from a QR payload. Native deep links generated + /// by the React Native client are accepted alongside ordinary URLs. + public static func pairingURL(fromQRCode payload: String) throws -> String { + try extractPairingURL(from: payload, qrInput: true) + } + + public static func build(host: String, pairingCode: String) throws -> String { + let base = try normalizedBaseURL(host) + var components = URLComponents(url: base, resolvingAgainstBaseURL: false)! + components.path = "/pair" + components.percentEncodedFragment = URLComponents().withQueryItems([ + URLQueryItem(name: "token", value: try requireToken(pairingCode)), + ]).percentEncodedQuery + guard let value = components.url?.absoluteString else { + throw PairingURLError.invalidURL + } + return value + } + + /// Converts any supported pairing transport into the HTTP origin used by + /// onboarding's environment probe. + static func httpBaseURL(for rawValue: String) throws -> URL { + try httpBaseURL(from: normalizedBaseURL(rawValue)) + } + + private static func extractPairingURL( + from rawValue: String, + qrInput: Bool + ) throws -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw qrInput ? PairingURLError.emptyQRCode : PairingURLError.emptyInput + } + + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == "t3code" + else { + return trimmed + } + let wrapped = components.queryItems? + .first(where: { $0.name.caseInsensitiveCompare("pairingUrl") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !wrapped.isEmpty else { + throw qrInput ? PairingURLError.invalidQRCode : PairingURLError.invalidURL + } + return wrapped + } + + private static func looseHostAndCode(_ value: String) -> (host: String, code: String)? { + // URLs legitimately contain percent-encoded or query whitespace, so + // only use this fallback when the last whitespace-delimited token + // looks like a compact pairing code. + let fields = value.split(whereSeparator: \.isWhitespace).map(String.init) + guard fields.count >= 2, let code = fields.last, + code.range(of: #"^[A-Za-z0-9_-]{4,256}$"#, options: .regularExpression) != nil + else { + return nil + } + let host = fields.dropLast().joined(separator: " ") + guard host.contains(".") || host.contains(":") || host.hasPrefix("/") else { + return nil + } + return (host, code) + } + + private static func directTarget(host: String, credential: String) throws -> PairingTarget { + try target(baseURL: normalizedBaseURL(host), credential: credential) + } + + private static func normalizedBaseURL(_ rawValue: String) throws -> URL { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw PairingURLError.emptyInput } + + let withoutLeadingSlashes = trimmed.replacingOccurrences( + of: #"^/+"#, + with: "", + options: .regularExpression + ) + let normalized: String + if withoutLeadingSlashes.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, + options: .regularExpression + ) != nil { + normalized = withoutLeadingSlashes + } else { + normalized = "https://\(withoutLeadingSlashes)" + } + guard var components = URLComponents(string: normalized), + components.url != nil + else { + throw PairingURLError.invalidURL + } + try requireSupportedScheme(components.scheme) + guard components.host != nil else { throw PairingURLError.missingHost } + components.path = "/" + components.query = nil + components.fragment = nil + guard let base = components.url else { throw PairingURLError.invalidURL } + return base + } + + private static func strictURLComponents(_ value: String) -> URLComponents? { + guard value.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, + options: .regularExpression + ) != nil else { + return nil + } + return URLComponents(string: value) + } + + private static func queryItems(fromFragment fragment: String?) -> [URLQueryItem] { + guard let fragment, !fragment.isEmpty else { return [] } + var components = URLComponents() + components.percentEncodedQuery = fragment + return components.queryItems ?? [] + } + + private static func displayHost(_ url: URL) -> String { + var value = url.absoluteString + if value.hasSuffix("/") { value.removeLast() } + return value + } + + private static func target(baseURL: URL, credential: String) throws -> PairingTarget { + guard var http = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), + var socket = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + else { + throw PairingURLError.invalidURL + } + switch http.scheme?.lowercased() { + case "ws": http.scheme = "http" + case "wss": http.scheme = "https" + default: break + } + switch socket.scheme?.lowercased() { + case "http": socket.scheme = "ws" + case "https": socket.scheme = "wss" + default: break + } + guard let httpURL = http.url, let socketURL = socket.url else { + throw PairingURLError.invalidURL + } + return PairingTarget( + credential: credential, + httpBaseURL: httpURL, + webSocketBaseURL: socketURL + ) + } + + private static func httpBaseURL(from baseURL: URL) throws -> URL { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + else { + throw PairingURLError.invalidURL + } + switch components.scheme?.lowercased() { + case "ws": components.scheme = "http" + case "wss": components.scheme = "https" + default: break + } + guard let url = components.url else { throw PairingURLError.invalidURL } + return url + } + + private static func requireSupportedScheme(_ scheme: String?) throws { + guard supportedSchemes.contains(scheme?.lowercased() ?? "") else { + throw PairingURLError.unsupportedScheme + } + } + + private static func requireToken(_ token: String?) throws -> String { + let trimmed = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { throw PairingURLError.missingToken } + return trimmed + } +} + +private extension URLComponents { + func withQueryItems(_ items: [URLQueryItem]) -> URLComponents { + var copy = self + copy.queryItems = items + return copy + } +} diff --git a/apps/swift-ios/Core/Persistence.swift b/apps/swift-ios/Core/Persistence.swift new file mode 100644 index 000000000000..cadcd3781cc2 --- /dev/null +++ b/apps/swift-ios/Core/Persistence.swift @@ -0,0 +1,352 @@ +import Foundation +import Security + +public protocol CredentialStore: Sendable { + func credential(for environmentID: String) async throws -> EnvironmentCredential? + func setCredential(_ credential: EnvironmentCredential, for environmentID: String) async throws + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) async throws -> EnvironmentCredential? + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) async throws -> Bool + func removeCredential(for environmentID: String) async throws + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) async throws -> Bool +} + +protocol KeychainCredentialBackend: Sendable { + func credential(for environmentID: String) throws -> EnvironmentCredential? + func setCredential(_ credential: EnvironmentCredential, for environmentID: String) throws + func removeCredential(for environmentID: String) throws +} + +public enum CredentialStoreError: LocalizedError, Sendable { + case keychain(OSStatus) + case invalidData + + public var errorDescription: String? { + switch self { + case let .keychain(status): + SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)." + case .invalidData: + "The saved environment credential is invalid." + } + } +} + +/// Access tokens are deliberately isolated from the environment catalog so +/// catalog exports and backups never contain authentication material. +public actor KeychainCredentialStore: CredentialStore { + private static let keychainLock = NSLock() + private let service: String + private let accessibility: CFString + private let backend: (any KeychainCredentialBackend)? + + public init( + service: String = "codes.t3.swift-ios.environment-credentials", + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) { + self.service = service + self.accessibility = accessibility + backend = nil + } + + init( + service: String, + backend: any KeychainCredentialBackend, + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) { + self.service = service + self.accessibility = accessibility + self.backend = backend + } + + public func credential(for environmentID: String) throws -> EnvironmentCredential? { + try Self.keychainLock.withLock { + try readCredential(for: environmentID) + } + } + + public func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws { + try Self.keychainLock.withLock { + try writeCredential(credential, for: environmentID) + } + } + + public func removeCredential(for environmentID: String) throws { + try Self.keychainLock.withLock { + try deleteCredential(for: environmentID) + } + } + + public func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws -> EnvironmentCredential? { + try Self.keychainLock.withLock { + let previousCredential = try readCredential(for: environmentID) + try writeCredential(credential, for: environmentID) + return previousCredential + } + } + + public func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) throws -> Bool { + try Self.keychainLock.withLock { + guard try readCredential(for: environmentID) == expected else { return false } + try writeCredential(credential, for: environmentID) + return true + } + } + + public func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) throws -> Bool { + try Self.keychainLock.withLock { + guard try readCredential(for: environmentID) == expected else { return false } + try deleteCredential(for: environmentID) + return true + } + } + + private func readCredential(for environmentID: String) throws -> EnvironmentCredential? { + if let backend { + return try backend.credential(for: environmentID) + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: environmentID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw CredentialStoreError.keychain(status) } + guard let data = item as? Data else { throw CredentialStoreError.invalidData } + do { + return try JSONDecoder.t3.decode(EnvironmentCredential.self, from: data) + } catch { + throw CredentialStoreError.invalidData + } + } + + private func writeCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws { + if let backend { + try backend.setCredential(credential, for: environmentID) + return + } + let data = try JSONEncoder.t3.encode(credential) + let lookup: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: environmentID, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: accessibility, + ] + let updateStatus = SecItemUpdate(lookup as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecItemNotFound { + var insertion = lookup + attributes.forEach { insertion[$0.key] = $0.value } + let status = SecItemAdd(insertion as CFDictionary, nil) + guard status == errSecSuccess else { throw CredentialStoreError.keychain(status) } + } else if updateStatus != errSecSuccess { + throw CredentialStoreError.keychain(updateStatus) + } + } + + private func deleteCredential(for environmentID: String) throws { + if let backend { + try backend.removeCredential(for: environmentID) + return + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: environmentID, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialStoreError.keychain(status) + } + } +} + +public actor InMemoryCredentialStore: CredentialStore { + private var credentials: [String: EnvironmentCredential] + + public init(credentials: [String: EnvironmentCredential] = [:]) { + self.credentials = credentials + } + + public func credential(for environmentID: String) -> EnvironmentCredential? { + credentials[environmentID] + } + + public func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + credentials[environmentID] = credential + } + + public func removeCredential(for environmentID: String) { + credentials.removeValue(forKey: environmentID) + } + + public func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + credentials.updateValue(credential, forKey: environmentID) + } + + public func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard credentials[environmentID] == expected else { return false } + credentials[environmentID] = credential + return true + } + + public func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard credentials[environmentID] == expected else { return false } + credentials.removeValue(forKey: environmentID) + return true + } +} + +public actor EnvironmentStore { + private struct Document: Codable { + let version: Int + var environments: [Environment] + var activeEnvironmentID: String? + } + + public let fileURL: URL + + /// Snapshot publishes read the catalog several times a second, so the + /// decoded document is cached and invalidated by writes on this actor. + private var cached: Document? + + public init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.fileURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("environments.json", isDirectory: false) + } + } + + public func load() throws -> [Environment] { + try loadDocument().environments + } + + public func activeEnvironmentID() throws -> String? { + try loadDocument().activeEnvironmentID + } + + public func setActiveEnvironment(id: String?) throws { + var document = try loadDocument() + document.activeEnvironmentID = id + try save(document) + } + + @discardableResult + public func setEnabled(id: String, enabled: Bool) throws -> [Environment] { + var document = try loadDocument() + guard let index = document.environments.firstIndex(where: { $0.id == id }) else { + return document.environments + } + document.environments[index].isEnabled = enabled + if !enabled, document.activeEnvironmentID == id { + document.activeEnvironmentID = document.environments.first { + $0.isEnabled && $0.id != id + }?.id + } + try save(document) + return document.environments + } + + public func save(_ environments: [Environment]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var document = try loadDocument() + document.environments = environments + try save(document) + } + + @discardableResult + public func upsert(_ environment: Environment) throws -> [Environment] { + var environments = try load() + if let index = environments.firstIndex(where: { $0.id == environment.id }) { + environments[index] = environment + } else { + environments.append(environment) + } + try save(environments) + return environments + } + + @discardableResult + public func remove(id: String) throws -> [Environment] { + var document = try loadDocument() + document.environments.removeAll { $0.id == id } + if document.activeEnvironmentID == id { + document.activeEnvironmentID = document.environments.first(where: \.isEnabled)?.id + } + try save(document) + return document.environments + } + + private func loadDocument() throws -> Document { + if let cached { return cached } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return Document(version: 1, environments: [], activeEnvironmentID: nil) + } + let data = try Data(contentsOf: fileURL) + let document = try JSONDecoder.t3.decode(Document.self, from: data) + cached = document + return document + } + + private func save(_ document: Document) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try JSONEncoder.t3.encode(document).write(to: fileURL, options: .atomic) + cached = document + } +} diff --git a/apps/swift-ios/Core/ProviderSetupModels.swift b/apps/swift-ios/Core/ProviderSetupModels.swift new file mode 100644 index 000000000000..00ee1dedfcfb --- /dev/null +++ b/apps/swift-ios/Core/ProviderSetupModels.swift @@ -0,0 +1,93 @@ +import Foundation + +public struct ProviderSetupCapabilities: Codable, Equatable, Hashable, Sendable { + public let canAuthenticate: Bool + public let canInstall: Bool +} + +public struct ProviderAuthState: Codable, Equatable, Sendable { + public let instanceId: String + public let phase: String + public let flowId: String? + public let authorizationUrl: String? + public let expiresAt: String? + public let message: String? + + public var isActive: Bool { ["starting", "waiting", "verifying"].contains(phase) } +} + +public struct ProviderInstallState: Codable, Equatable, Sendable { + public let driver: String + public let operationId: String? + public let phase: String + public let downloadedBytes: Int64 + public let totalBytes: Int64? + public let version: String? + public let installedVersion: String? + public let canRemove: Bool + public let message: String? + + public var isActive: Bool { ["downloading", "extracting", "verifying"].contains(phase) } +} + +public enum ProviderSetupEvent: Sendable { + case auth(ProviderAuthState) + case install(ProviderInstallState) +} + +public enum ProviderSetupAction: Sendable { + case signIn + case completeSignIn(flowID: String, callbackURL: String) + case cancelSignIn(flowID: String) + case signOut + case install + case cancelInstall(operationID: String) + case remove + + var method: String { + switch self { + case .signIn: "provider.auth.start" + case .completeSignIn: "provider.auth.complete" + case .cancelSignIn: "provider.auth.cancel" + case .signOut: "provider.auth.logout" + case .install: "provider.install.start" + case .cancelInstall: "provider.install.cancel" + case .remove: "provider.install.remove" + } + } + + func payload(instanceID: String) -> JSONValue { + var fields: [String: JSONValue] = ["instanceId": .string(instanceID)] + switch self { + case let .completeSignIn(flowID, callbackURL): + fields["flowId"] = .string(flowID) + fields["callbackUrl"] = .string(callbackURL) + case let .cancelSignIn(flowID): fields["flowId"] = .string(flowID) + case let .cancelInstall(operationID): fields["operationId"] = .string(operationID) + default: break + } + return .object(fields) + } +} + +enum ProviderSettingsPatch { + static func enabled(settings: JSONValue, instanceID: String, driver: String, enabled: Bool) -> JSONValue { + var instances: [String: JSONValue] = if case let .object(values) = settings["providerInstances"] { values } else { [:] } + var instance: [String: JSONValue] = if case let .object(values) = instances[instanceID] { values } else { ["driver": .string(driver)] } + var config: [String: JSONValue] = if case let .object(values) = instance["config"] ?? settings["providers"]?[driver] { values } else { [:] } + config["enabled"] = nil + instance["config"] = .object(config) + instance["enabled"] = .bool(enabled) + instances[instanceID] = .object(instance) + var patch: [String: JSONValue] = ["providerInstances": .object(instances)] + if instanceID == "antigravity" { + // The explicit instance now owns these settings. Clear the legacy copy. + patch["providers"] = .object(["antigravity": .object([ + "enabled": .bool(false), "authMethod": .string("oauth-personal"), + "apiKey": .string(""), "gcpProject": .string(""), "gcpLocation": .string(""), + "binaryPath": .string(""), "customModels": .array([]), + ])]) + } + return .object(patch) + } +} diff --git a/apps/swift-ios/Core/PullRequestWireModels.swift b/apps/swift-ios/Core/PullRequestWireModels.swift new file mode 100644 index 000000000000..a7303e64499d --- /dev/null +++ b/apps/swift-ios/Core/PullRequestWireModels.swift @@ -0,0 +1,525 @@ +import Foundation + +public enum PullRequestInvolvement: String, Codable, CaseIterable, Sendable { + case all + case reviewing + case authored +} + +public enum PullRequestState: String, Codable, CaseIterable, Sendable { + case open + case closed + case merged +} + +public enum PullRequestListState: String, Codable, CaseIterable, Sendable { + case all + case open + case closed + case merged +} + +public enum PullRequestReviewDecision: String, Codable, Sendable { + case approved + case changesRequested = "changes-requested" + case reviewRequired = "review-required" +} + +public enum PullRequestChecksState: String, Codable, Sendable { + case passing + case failing + case pending +} + +public enum PullRequestMergeability: String, Codable, Sendable { + case mergeable + case conflicting + case unknown +} + +public enum PullRequestAction: String, Codable, CaseIterable, Sendable { + case merge + case ready + case draft + case close + case reopen + case updateBranch = "update-branch" + case enableAutoMerge = "enable-auto-merge" + case disableAutoMerge = "disable-auto-merge" +} + +public enum PullRequestMergeMethod: String, Codable, CaseIterable, Sendable { + case merge + case squash + case rebase +} + +public enum PullRequestUpdateMethod: String, Codable, CaseIterable, Sendable { + case merge + case rebase +} + +public enum PullRequestBaseComparison: String, Codable, Sendable { + case upToDate = "up-to-date" + case behind + case unknown +} + +public struct PullRequestActor: Codable, Equatable, Sendable { + public let login: String + public let name: String? + public let avatarUrl: String? +} + +public struct PullRequestLabel: Codable, Equatable, Sendable, Identifiable { + public var id: String { name } + public let name: String + public let color: String? +} + +public enum PullRequestCheckStatus: String, Codable, Sendable { + case pending + case success + case failure + case skipped + case neutral + case cancelled +} + +public struct PullRequestCheck: Codable, Equatable, Sendable, Identifiable { + public var id: String { name } + public let name: String + public let status: PullRequestCheckStatus + public let description: String? + public let url: String? +} + +public enum PullRequestReactionContent: String, Codable, CaseIterable, Sendable { + case thumbsUp = "thumbs-up" + case thumbsDown = "thumbs-down" + case laugh + case hooray + case confused + case heart + case rocket + case eyes +} + +public struct PullRequestReaction: Codable, Equatable, Sendable, Identifiable { + public var id: PullRequestReactionContent { content } + public let content: PullRequestReactionContent + public let count: Int + public let actors: [String] + public let viewerHasReacted: Bool +} + +public enum PullRequestCommentKind: String, Codable, Sendable { + case issueComment = "issue-comment" + case reviewComment = "review-comment" + case review +} + +public struct PullRequestComment: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let kind: PullRequestCommentKind + public let author: PullRequestActor? + public let body: String + public let createdAt: String + public let url: String? + public let path: String? + public let reviewState: String? + public let reactions: [PullRequestReaction]? +} + +public enum PullRequestDiffSide: String, Codable, Sendable { + case left + case right +} + +public enum PullRequestReviewVerdict: String, Codable, CaseIterable, Sendable { + case comment + case approve + case requestChanges = "request-changes" +} + +public struct PullRequestThreadComment: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let author: PullRequestActor? + public let body: String + public let createdAt: String + public let url: String? + public let reactions: [PullRequestReaction]? +} + +public struct PullRequestReviewThread: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let path: String + public let line: Int? + public let side: PullRequestDiffSide + public let isResolved: Bool + public let isOutdated: Bool + public let comments: [PullRequestThreadComment] + public let commentCount: Int? + public let nextCommentsCursor: String? +} + +public struct PullRequestCommit: Codable, Equatable, Sendable, Identifiable { + public var id: String { oid } + public let oid: String + public let messageHeadline: String + public let committedDate: String + public let additions: Int? + public let deletions: Int? + public let authors: [PullRequestActor]? +} + +public struct PullRequestReviewCapabilities: Codable, Equatable, Sendable { + public let inlineComment: Bool + public let reply: Bool + public let resolve: Bool + public let verdicts: [PullRequestReviewVerdict] +} + +public struct PullRequestEditCapabilities: Codable, Equatable, Sendable { + public let changeRequest: Bool + public let comment: Bool +} + +public struct PullRequestReviewerCapabilities: Codable, Equatable, Sendable { + public let request: Bool + public let listCandidates: Bool +} + +public struct PullRequestCapabilities: Codable, Equatable, Sendable { + public let diff: Bool + public let comment: Bool + public let actions: [PullRequestAction] + public let mergeMethods: [PullRequestMergeMethod] + public let updateMethods: [PullRequestUpdateMethod]? + public let search: Bool + public let reactions: Bool? + public let review: PullRequestReviewCapabilities + public let reviewers: PullRequestReviewerCapabilities + public let edit: PullRequestEditCapabilities? +} + +public struct PullRequestViewerPermissions: Codable, Equatable, Sendable { + public let actions: [PullRequestAction] + public let comment: Bool + public let resolve: Bool + public let verdicts: [PullRequestReviewVerdict] + public let requestReviewers: Bool + public let updateMethods: [PullRequestUpdateMethod]? +} + +public struct PullRequestMergeCapabilities: Codable, Equatable, Sendable { + public let merge: Bool + public let squash: Bool + public let rebase: Bool +} + +public struct PullRequestListFilters: Codable, Equatable, Sendable { + public let draft: String? + public let review: String? + public let checks: String? + public let labels: [[String]]? + public let excludedLabels: [String]? + public let author: String? + + public init( + draft: String? = nil, + review: String? = nil, + checks: String? = nil, + labels: [[String]]? = nil, + excludedLabels: [String]? = nil, + author: String? = nil + ) { + self.draft = draft + self.review = review + self.checks = checks + self.labels = labels + self.excludedLabels = excludedLabels + self.author = author + } +} + +public struct PullRequestListInput: Codable, Equatable, Sendable { + public let state: PullRequestListState + public let involvement: PullRequestInvolvement? + public let filters: PullRequestListFilters? + public let projectId: String? + public let projectIds: [String]? + public let host: String? + public let limit: Int? + public let cursors: [String: String]? + public let query: String? + + public init( + state: PullRequestListState = .open, + involvement: PullRequestInvolvement? = .all, + filters: PullRequestListFilters? = nil, + projectId: String? = nil, + projectIds: [String]? = nil, + host: String? = nil, + limit: Int? = 99, + cursors: [String: String]? = nil, + query: String? = nil + ) { + self.state = state + self.involvement = involvement + self.filters = filters + self.projectId = projectId + self.projectIds = projectIds + self.host = host + self.limit = limit + self.cursors = cursors + self.query = query + } +} + +public struct PullRequestListEntry: Codable, Equatable, Sendable, Identifiable { + public var id: String { "\(host) \(repository)#\(number)" } + public let provider: SourceControlProviderKind + public let host: String + public let projectId: String + public let projectTitle: String + public let repository: String + public let number: Int + public let title: String + public let url: String + public let author: PullRequestActor? + public let headBranch: String + public let baseBranch: String + public let state: PullRequestState + public let isDraft: Bool + public let mergeability: PullRequestMergeability + public let additions: Int + public let deletions: Int + public let createdAt: String + public let updatedAt: String + public let viewerReviewRequested: Bool + public let labels: [PullRequestLabel] + public let reviewDecision: PullRequestReviewDecision? + public let checksState: PullRequestChecksState? +} + +public struct PullRequestProviderSummary: Codable, Equatable, Sendable { + public let host: String + public let kind: SourceControlProviderKind + public let searchesOnHost: Bool + public let projectCount: Int + public let configured: Bool + public let detail: String? +} + +public struct PullRequestListProjectError: Codable, Equatable, Sendable, Identifiable { + public var id: String { projectId } + public let projectId: String + public let projectTitle: String + public let message: String +} + +public struct PullRequestListResult: Codable, Equatable, Sendable { + public let viewers: [String: String] + public let providers: [PullRequestProviderSummary] + public let entries: [PullRequestListEntry] + public let errors: [PullRequestListProjectError] + public let truncated: Bool + public let nextCursors: [String: String] + + func appending(_ page: Self) -> Self { + var entryIDs = Set(entries.map(\.id)) + var providerHosts = Set(providers.map(\.host)) + var errorProjectIDs = Set(errors.map(\.projectId)) + + return Self( + viewers: viewers.merging(page.viewers) { _, latest in latest }, + providers: providers + page.providers.filter { + providerHosts.insert($0.host).inserted + }, + entries: entries + page.entries.filter { + entryIDs.insert($0.id).inserted + }, + errors: errors + page.errors.filter { + errorProjectIDs.insert($0.projectId).inserted + }, + truncated: page.truncated, + nextCursors: page.nextCursors + ) + } +} + +public struct PullRequestRef: Codable, Equatable, Hashable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + + public init(projectId: String, repository: String, number: Int) { + self.projectId = projectId + self.repository = repository + self.number = number + } + + var jsonObject: [String: JSONValue] { + get throws { + guard case let .object(value) = try JSONValue.encode(self) else { return [:] } + return value + } + } +} + +public struct PullRequestDetail: Codable, Equatable, Sendable { + public let provider: SourceControlProviderKind + public let capabilities: PullRequestCapabilities + public let viewerPermissions: PullRequestViewerPermissions + public let projectId: String + public let projectTitle: String + public let workspaceRoot: String + public let repository: String + public let number: Int + public let title: String + public let body: String + public let url: String + public let author: PullRequestActor? + public let state: PullRequestState + public let isDraft: Bool + public let mergeability: PullRequestMergeability + public let additions: Int + public let deletions: Int + public let changedFiles: Int + public let headBranch: String + public let baseBranch: String + public let createdAt: String + public let updatedAt: String + public let mergedAt: String? + public let closedAt: String? + public let reviewers: [PullRequestActor] + public let labels: [PullRequestLabel] + public let checks: [PullRequestCheck] + public let mergeCapabilities: PullRequestMergeCapabilities + public let viewer: String? + public let baseComparison: PullRequestBaseComparison? + public let behindBy: Int? + public let autoMergeEnabled: Bool? +} + +public struct PullRequestActivity: Codable, Equatable, Sendable { + public let author: PullRequestActor? + public let reviewers: [PullRequestActor]? + public let comments: [PullRequestComment] + public let commentCount: Int + public let commentsTruncated: Bool + public let reviewThreads: [PullRequestReviewThread] + public let commits: [PullRequestCommit] + public let reactions: [PullRequestReaction]? +} + +public struct PullRequestDiffInput: Codable, Equatable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + public let cursor: String? + public let commit: String? +} + +public struct PullRequestOmittedFileStat: Codable, Equatable, Sendable { + public let path: String + public let additions: Double + public let deletions: Double +} + +public struct PullRequestDiffResult: Codable, Equatable, Sendable { + public let patch: String + public let truncated: Bool + public let nextCursor: String? + public let omittedFileStats: [PullRequestOmittedFileStat]? +} + +public struct PullRequestReviewPosition: Codable, Equatable, Sendable { + public let kind: String + public let newLine: Int? + public let oldLine: Int? + public let side: PullRequestDiffSide? + + public static func added(_ line: Int) -> Self { + .init(kind: "added", newLine: line, oldLine: nil, side: nil) + } + + public static func deleted(_ line: Int) -> Self { + .init(kind: "deleted", newLine: nil, oldLine: line, side: nil) + } + + public static func context(old: Int, new: Int, side: PullRequestDiffSide) -> Self { + .init(kind: "context", newLine: new, oldLine: old, side: side) + } +} + +public struct PullRequestReviewCommentDraft: Codable, Equatable, Sendable, Identifiable { + public var id = UUID() + public let path: String + public let oldPath: String? + public let position: PullRequestReviewPosition + public var body: String + + enum CodingKeys: String, CodingKey { case path, oldPath, position, body } + + public init( + id: UUID = UUID(), + path: String, + oldPath: String? = nil, + position: PullRequestReviewPosition, + body: String + ) { + self.id = id + self.path = path + self.oldPath = oldPath + self.position = position + self.body = body + } +} + +public struct PullRequestReviewerCandidate: Codable, Equatable, Sendable, Identifiable { + public let login: String + public let name: String? + public let avatarUrl: String? + public let id: String + public let kind: String + public let isRequested: Bool +} + +public struct PullRequestReviewerCandidateList: Codable, Equatable, Sendable { + public let candidates: [PullRequestReviewerCandidate] + public let truncated: Bool +} + +public struct FeaturePullRequestEnvironmentList: Identifiable, Equatable, Sendable { + public var id: String { environmentID } + public let environmentID: String + public let environmentName: String + public let result: PullRequestListResult? + public let errorMessage: String? + + public init( + environmentID: String, + environmentName: String, + result: PullRequestListResult?, + errorMessage: String? + ) { + self.environmentID = environmentID + self.environmentName = environmentName + self.result = result + self.errorMessage = errorMessage + } +} + +public struct FeaturePullRequestTarget: Hashable, Sendable { + public let environmentID: String + public let environmentName: String + public let reference: PullRequestRef + + public init(environmentID: String, environmentName: String, reference: PullRequestRef) { + self.environmentID = environmentID + self.environmentName = environmentName + self.reference = reference + } +} diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift new file mode 100644 index 000000000000..5b5306a47d01 --- /dev/null +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -0,0 +1,410 @@ +import Foundation + +public struct ServerProviderAuthSnapshot: Codable, Equatable, Sendable { + public let status: String + public let type: String? + public let label: String? + public let email: String? +} + +public struct ServerProviderOptionChoice: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let description: String? + public let isDefault: Bool? +} + +public struct ServerSelectOptionDescriptor: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let description: String? + public let options: [ServerProviderOptionChoice] + public let currentValue: String? + public let promptInjectedValues: [String]? +} + +public struct ServerBooleanOptionDescriptor: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let description: String? + public let currentValue: Bool? +} + +public enum ServerProviderOptionDescriptor: Codable, Equatable, Sendable { + case select(ServerSelectOptionDescriptor) + case boolean(ServerBooleanOptionDescriptor) + + private enum CodingKeys: String, CodingKey { case type } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "select": + self = .select(try ServerSelectOptionDescriptor(from: decoder)) + case "boolean": + self = .boolean(try ServerBooleanOptionDescriptor(from: decoder)) + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown provider option type \(type)" + ) + } + } + + public func encode(to encoder: any Encoder) throws { + switch self { + case let .select(value): + try value.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("select", forKey: .type) + case let .boolean(value): + try value.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("boolean", forKey: .type) + } + } +} + +public struct ServerModelCapabilities: Codable, Equatable, Sendable { + public let optionDescriptors: [ServerProviderOptionDescriptor]? +} + +public struct ServerProviderModelSnapshot: Codable, Identifiable, Equatable, Sendable { + public var id: String { slug } + + public let slug: String + public let name: String + public let shortName: String? + public let subProvider: String? + public let isCustom: Bool + public let isDefault: Bool? + public let isLegacy: Bool? + public let capabilities: ServerModelCapabilities? +} + +public struct ServerProviderSlashCommandSnapshot: Codable, Equatable, Sendable { + public struct Input: Codable, Equatable, Sendable { + public let hint: String + } + + public let name: String + public let description: String? + public let input: Input? +} + +public struct ServerProviderSkillSnapshot: Codable, Equatable, Sendable { + public let name: String + public let description: String? + public let path: String + public let scope: String? + public let enabled: Bool + public let displayName: String? + public let shortDescription: String? + public var userInvocationOnly: Bool? = nil + public var userInvocable: Bool? = nil +} + +public struct ServerProviderWorkspaceSnapshot: Codable, Equatable, Sendable { + public let cwd: String + public let checkedAt: String + public let slashCommands: [ServerProviderSlashCommandSnapshot] + public let skills: [ServerProviderSkillSnapshot] +} + +public struct ServerProviderSnapshot: Codable, Identifiable, Equatable, Sendable { + public var id: String { instanceId } + + public let instanceId: String + public let driver: String + public let displayName: String? + public let accentColor: String? + public let badgeLabel: String? + public let showInteractionModeToggle: Bool? + public let requiresNewThreadForModelChange: Bool? + public let enabled: Bool + public let installed: Bool + public let version: String? + public let status: String + public let auth: ServerProviderAuthSnapshot + public let checkedAt: String + public let message: String? + public let availability: String? + public let unavailableReason: String? + public let models: [ServerProviderModelSnapshot] + public let slashCommands: [ServerProviderSlashCommandSnapshot]? + public let skills: [ServerProviderSkillSnapshot]? + public var workspaceSnapshots: [ServerProviderWorkspaceSnapshot]? = nil + public var setup: ProviderSetupCapabilities? = nil + public var usageLimits: ServerProviderUsageLimits? = nil +} + +public enum ServerThreadEnvironmentMode: String, Codable, Equatable, Sendable { + case local + case worktree +} + +public enum ServerProjectGroupingMode: String, Codable, Equatable, Sendable { + case repository + case repositoryPath = "repository_path" + case separate +} + +/// New-thread preferences are server-authoritative, so every saved environment +/// can resolve these differently even though they share one mobile client. +public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { + public var defaultModelSelection: ModelSelection? = nil + public let defaultThreadEnvMode: ServerThreadEnvironmentMode + public let newWorktreesStartFromOrigin: Bool + public let sidebarProjectGroupingMode: ServerProjectGroupingMode? + public let sidebarProjectGroupingOverrides: [String: ServerProjectGroupingMode]? + public let sidebarAutoSettleOnMerge: Bool + public let sidebarAutoSettleAfterDays: Double? + public let continueThreadsAfterServerUpdate: Bool + public var environmentIcon: String? = nil + public var sourceControlWritingStyle: JSONValue? = nil + + public var sharedPatch: JSONValue { + sharedPatch(supportsRestartContinuation: false) + } + + /// Include restart continuation only when both environments support it. + public func sharedPatch(supportsRestartContinuation: Bool) -> JSONValue { + var fields: [String: JSONValue] = [ + "sidebarAutoSettleAfterDays": sidebarAutoSettleAfterDays.map(JSONValue.number) ?? .null, + "sidebarAutoSettleOnMerge": .bool(sidebarAutoSettleOnMerge), + "defaultThreadEnvMode": .string(defaultThreadEnvMode.rawValue), + "newWorktreesStartFromOrigin": .bool(newWorktreesStartFromOrigin), + ] + if let sourceControlWritingStyle { fields["sourceControlWritingStyle"] = sourceControlWritingStyle } + if supportsRestartContinuation { + fields["continueThreadsAfterServerUpdate"] = .bool(continueThreadsAfterServerUpdate) + } + return .object(fields) + } + + public init( + defaultThreadEnvMode: ServerThreadEnvironmentMode = .local, + newWorktreesStartFromOrigin: Bool = true, + sidebarProjectGroupingMode: ServerProjectGroupingMode? = nil, + sidebarProjectGroupingOverrides: [String: ServerProjectGroupingMode]? = nil, + sidebarAutoSettleOnMerge: Bool = true, + sidebarAutoSettleAfterDays: Double? = 3, + continueThreadsAfterServerUpdate: Bool = false + ) { + self.defaultThreadEnvMode = defaultThreadEnvMode + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.sidebarProjectGroupingMode = sidebarProjectGroupingMode + self.sidebarProjectGroupingOverrides = sidebarProjectGroupingOverrides + self.sidebarAutoSettleOnMerge = sidebarAutoSettleOnMerge + self.sidebarAutoSettleAfterDays = sidebarAutoSettleAfterDays + self.continueThreadsAfterServerUpdate = continueThreadsAfterServerUpdate + } + + private enum CodingKeys: String, CodingKey { + case defaultModelSelection + case defaultThreadEnvMode + case newWorktreesStartFromOrigin + case sidebarProjectGroupingMode + case sidebarProjectGroupingOverrides + case sidebarAutoSettleOnMerge + case sidebarAutoSettleAfterDays + case continueThreadsAfterServerUpdate + case environmentIcon + case sourceControlWritingStyle + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + defaultModelSelection = try container.decodeIfPresent(ModelSelection.self, forKey: .defaultModelSelection) + environmentIcon = try container.decodeIfPresent(String.self, forKey: .environmentIcon) + sourceControlWritingStyle = try container.decodeIfPresent(JSONValue.self, forKey: .sourceControlWritingStyle) + continueThreadsAfterServerUpdate = try container.decodeIfPresent( + Bool.self, + forKey: .continueThreadsAfterServerUpdate + ) ?? false + defaultThreadEnvMode = try container.decodeIfPresent( + ServerThreadEnvironmentMode.self, + forKey: .defaultThreadEnvMode + ) ?? .local + newWorktreesStartFromOrigin = try container.decodeIfPresent( + Bool.self, + forKey: .newWorktreesStartFromOrigin + ) ?? true + sidebarProjectGroupingMode = try container.decodeIfPresent( + ServerProjectGroupingMode.self, + forKey: .sidebarProjectGroupingMode + ) + sidebarProjectGroupingOverrides = try container.decodeIfPresent( + [String: ServerProjectGroupingMode].self, + forKey: .sidebarProjectGroupingOverrides + ) + sidebarAutoSettleOnMerge = try container.decodeIfPresent( + Bool.self, + forKey: .sidebarAutoSettleOnMerge + ) ?? true + sidebarAutoSettleAfterDays = if container.contains(.sidebarAutoSettleAfterDays) { + try container.decodeIfPresent(Double.self, forKey: .sidebarAutoSettleAfterDays) + } else { + 3 + } + } +} + +public enum ServerSettingsChange: Equatable, Sendable { + case sidebarAutoSettleOnMerge(Bool) + case sidebarAutoSettleAfterDays(Double?) + case defaultThreadEnvMode(ServerThreadEnvironmentMode) + case newWorktreesStartFromOrigin(Bool) + case continueThreadsAfterServerUpdate(Bool) + case environmentIcon(String?) + case sharedPreferences(JSONValue) + + public var jsonValue: JSONValue { + switch self { + case let .defaultThreadEnvMode(value): .object(["defaultThreadEnvMode": .string(value.rawValue)]) + case let .newWorktreesStartFromOrigin(value): .object(["newWorktreesStartFromOrigin": .bool(value)]) + case let .continueThreadsAfterServerUpdate(value): + .object(["continueThreadsAfterServerUpdate": .bool(value)]) + case let .environmentIcon(value): .object(["environmentIcon": value.map(JSONValue.string) ?? .null]) + case let .sharedPreferences(value): value + case let .sidebarAutoSettleOnMerge(value): + .object(["sidebarAutoSettleOnMerge": .bool(value)]) + case let .sidebarAutoSettleAfterDays(value): + .object(["sidebarAutoSettleAfterDays": value.map(JSONValue.number) ?? .null]) + } + } +} + +/// Narrow decode view of the much larger `ServerConfig` RPC result. +public struct ServerConfigSnapshot: Codable, Equatable, Sendable { + public let providers: [ServerProviderSnapshot] + public let settings: ServerSettingsSnapshot? + public let threadSnapshotPagination: Bool? + public let threadResumeCompletionMarker: Bool? + public let environment: EnvironmentDescriptor? + public var usageLimitSources: [UsageLimitSourceSnapshot] + + public init( + providers: [ServerProviderSnapshot], + settings: ServerSettingsSnapshot? = nil, + threadSnapshotPagination: Bool? = nil, + threadResumeCompletionMarker: Bool? = nil, + environment: EnvironmentDescriptor? = nil, + usageLimitSources: [UsageLimitSourceSnapshot] = [] + ) { + self.providers = providers + self.settings = settings + self.threadSnapshotPagination = threadSnapshotPagination + self.threadResumeCompletionMarker = threadResumeCompletionMarker + self.environment = environment + self.usageLimitSources = usageLimitSources + } + + private enum CodingKeys: String, CodingKey { + case providers, settings, threadSnapshotPagination, threadResumeCompletionMarker, environment + case usageLimitSources + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + providers = try container.decode( + [LossyDecodableElement].self, + forKey: .providers + ).compactMap(\.value) + settings = try container.decodeIfPresent(ServerSettingsSnapshot.self, forKey: .settings) + threadSnapshotPagination = try container.decodeIfPresent( + Bool.self, + forKey: .threadSnapshotPagination + ) + environment = try container.decodeIfPresent(EnvironmentDescriptor.self, forKey: .environment) + threadResumeCompletionMarker = try container.decodeIfPresent( + Bool.self, forKey: .threadResumeCompletionMarker + ) + usageLimitSources = try container.decodeIfPresent( + ForwardCompatibleArray.self, + forKey: .usageLimitSources + )?.wrappedValue ?? [] + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(providers, forKey: .providers) + try container.encodeIfPresent(settings, forKey: .settings) + try container.encodeIfPresent( + threadSnapshotPagination, + forKey: .threadSnapshotPagination + ) + try container.encodeIfPresent(environment, forKey: .environment) + try container.encodeIfPresent(threadResumeCompletionMarker, forKey: .threadResumeCompletionMarker) + try container.encode(usageLimitSources, forKey: .usageLimitSources) + } +} + +private struct LossyDecodableElement: Decodable { + let value: Value? + + init(from decoder: any Decoder) throws { + value = try? Value(from: decoder) + } +} + +public enum ServerConfigStreamEvent: Decodable, Sendable { + case snapshot(ServerConfigSnapshot) + case providerStatuses([ServerProviderSnapshot]) + case settingsUpdated(ServerSettingsSnapshot) + case usageLimitSourcesUpdated([UsageLimitSourceSnapshot]) + case unrelated(type: String) + + private enum CodingKeys: String, CodingKey { case type, config, payload } + private struct ProviderPayload: Decodable { + let providers: [ServerProviderSnapshot] + + private enum CodingKeys: String, CodingKey { case providers } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + providers = try container.decode( + [LossyDecodableElement].self, + forKey: .providers + ).compactMap(\.value) + } + } + private struct SettingsPayload: Decodable { let settings: ServerSettingsSnapshot } + private struct UsageLimitSourcesPayload: Decodable { + @ForwardCompatibleArray var sources: [UsageLimitSourceSnapshot] + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "snapshot": + self = .snapshot( + try container.decode(ServerConfigSnapshot.self, forKey: .config) + ) + case "providerStatuses": + self = .providerStatuses( + try container.decode(ProviderPayload.self, forKey: .payload).providers + ) + case "settingsUpdated": + self = .settingsUpdated( + try container.decode(SettingsPayload.self, forKey: .payload).settings + ) + case "usageLimitSourcesUpdated": + self = .usageLimitSourcesUpdated( + try container.decode(UsageLimitSourcesPayload.self, forKey: .payload).sources + ) + default: + self = .unrelated(type: type) + } + } +} + +public struct ServerRefreshProvidersResult: Codable, Equatable, Sendable { + @ForwardCompatibleArray public var providers: [ServerProviderSnapshot] + + public init(providers: [ServerProviderSnapshot]) { + self.providers = providers + } +} diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift new file mode 100644 index 000000000000..0466b93202f2 --- /dev/null +++ b/apps/swift-ios/Core/T3Client.swift @@ -0,0 +1,2309 @@ +import Foundation + +enum MobileClientMetadata { + static var osMajorVersion: Int { + ProcessInfo.processInfo.operatingSystemVersion.majorVersion + } + + static var deviceModel: String { + if let simulatedModel = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], + !simulatedModel.isEmpty { + return simulatedModel + } + var system = utsname() + uname(&system) + let machineSize = MemoryLayout.size(ofValue: system.machine) + return withUnsafePointer(to: &system.machine) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: machineSize) { + String(cString: $0) + } + } + } +} + +public actor T3Client { + public let environment: Environment + private let api: EnvironmentAPI + private let rpc: WebSocketRPCClient + private let configSnapshotWaitTimeout: Duration + private var latestServerEnvironment: EnvironmentDescriptor? + private var serverConfigCache: ServerConfigSnapshot? + private var serverConfigGeneration: UInt64 = 0 + private var serverConfigTask: Task? + private var serverConfigWaiters: [UUID: CheckedContinuation] = [:] + private var serverConfigListeners: [UUID: AsyncThrowingStream.Continuation] = [:] + + public init( + environment: Environment, + credentialStore: any CredentialStore, + httpTransport: any HTTPTransport = URLSessionHTTPTransport(), + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector(), + managedAuthorization: (any ManagedEnvironmentAuthorizing)? = nil, + rpcConnectionWaitTimeout: Duration = .seconds(4) + ) { + self.environment = environment + let api = EnvironmentAPI( + transport: httpTransport, + credentials: credentialStore, + managedAuthorization: managedAuthorization + ) + self.api = api + self.configSnapshotWaitTimeout = rpcConnectionWaitTimeout + self.rpc = WebSocketRPCClient( + connector: webSocketConnector, + connectionWaitTimeout: rpcConnectionWaitTimeout + ) { + let ticket = try await api.webSocketTicket(for: environment) + var components = URLComponents( + url: environment.webSocketBaseURL, + resolvingAgainstBaseURL: false + )! + if components.path.isEmpty || components.path == "/" { + components.path = "/ws" + } + var query = components.queryItems ?? [] + query.removeAll { + $0.name == "wsTicket" + || $0.name == "clientSurface" + || $0.name == "clientAppVersion" + || $0.name == "clientOs" + || $0.name == "clientOsMajorVersion" + || $0.name == "clientDeviceModel" + } + query.append(URLQueryItem(name: "wsTicket", value: ticket.ticket)) + query.append(URLQueryItem(name: "clientSurface", value: "mobile")) + query.append(URLQueryItem(name: "clientOs", value: "iOS")) + query.append(URLQueryItem( + name: "clientOsMajorVersion", + value: String(MobileClientMetadata.osMajorVersion) + )) + let deviceModel = MobileClientMetadata.deviceModel + if !deviceModel.isEmpty { + query.append(URLQueryItem(name: "clientDeviceModel", value: deviceModel)) + } + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + !appVersion.isEmpty { + query.append(URLQueryItem(name: "clientAppVersion", value: appVersion)) + } + components.queryItems = query + guard let url = components.url else { throw PairingURLError.invalidURL } + return url + } + } + + public func connect() async { + await rpc.start() + } + + public func disconnect() async { + stopServerConfigSubscription(error: RPCError.disconnected) + await rpc.stop() + } + + public func reconnect() async { + await rpc.reconnect() + } + + public func liveConnectionActive() async -> Bool { + await rpc.isConnected() + } + + public func currentConnectionID() async -> UUID? { + await rpc.currentConnectionID() + } + + public func waitForConnection(after previous: UUID?) async throws -> UUID { + try await rpc.waitForConnection(after: previous) + } + + public func shellSnapshot( + timeoutInterval: TimeInterval? = nil + ) async throws -> OrchestrationShellSnapshot { + try await api.shellSnapshot( + for: environment, + timeoutInterval: timeoutInterval + ) + } + + public func readModel() async throws -> OrchestrationReadModel { + try await api.readModel(for: environment) + } + + public func archivedShellSnapshot() async throws -> OrchestrationShellSnapshot { + try await rpc.request( + RPCMethod.getArchivedShellSnapshot.rawValue, + as: OrchestrationShellSnapshot.self + ) + } + + public func threadSnapshot( + id: String, + turnLimit: Int? = nil, + beforeCursor: String? = nil, + timeoutInterval: TimeInterval? = nil + ) async throws -> OrchestrationThreadDetailSnapshot { + try await api.threadSnapshot( + id: id, + environment: environment, + turnLimit: turnLimit, + beforeCursor: beforeCursor, + timeoutInterval: timeoutInterval + ) + } + + public func serverConfig() async throws -> ServerConfigSnapshot { + if let serverConfigCache { return serverConfigCache } + startServerConfigSubscriptionIfNeeded() + return try await withThrowingTaskGroup(of: ServerConfigSnapshot.self) { group in + group.addTask { try await self.waitForServerConfigSnapshot() } + group.addTask { + try await Task.sleep(for: self.configSnapshotWaitTimeout) + throw RPCError.responseTimedOut + } + defer { group.cancelAll() } + return try await group.next()! + } + } + + public func updateSettings(_ change: ServerSettingsChange) async throws + -> ServerSettingsSnapshot + { + return try await rpc.request( + RPCMethod.serverUpdateSettings.rawValue, + payload: .object(["patch": change.jsonValue]), + as: ServerSettingsSnapshot.self + ) + } + + public func refreshProviders(cwd: String? = nil, instanceID: String? = nil, refreshModels: Bool = true) async throws -> ServerConfigSnapshot { + let current = try await serverConfig() + let generation = serverConfigGeneration + let result: ServerRefreshProvidersResult = try await rpc.request( + RPCMethod.serverRefreshProviders.rawValue, + payload: .object([ + "refreshModels": .bool(refreshModels), + ].merging(cwd.map { ["cwd": .string($0)] } ?? [:]) { _, new in new } + .merging(instanceID.map { ["instanceId": .string($0)] } ?? [:]) { _, new in new }), + as: ServerRefreshProvidersResult.self + ) + guard generation == serverConfigGeneration else { throw CancellationError() } + let latest = serverConfigCache ?? current + let config = ServerConfigSnapshot( + providers: result.providers, + settings: latest.settings, + threadSnapshotPagination: latest.threadSnapshotPagination, + threadResumeCompletionMarker: latest.threadResumeCompletionMarker, + environment: latest.environment, + usageLimitSources: latest.usageLimitSources + ) + cacheServerConfig(config) + serverConfigListeners.values.forEach { $0.yield(.snapshot(config)) } + return config + } + + public func consumeResetCredit(instanceID: String) async throws -> ProviderConsumeResetCreditResult { + try await rpc.request( + RPCMethod.providerConsumeResetCredit.rawValue, + payload: .object(["instanceId": .string(instanceID)]), + as: ProviderConsumeResetCreditResult.self + ) + } + + public func usageSummary(_ input: UsageSummaryInput) async throws -> UsageSummary { + try await rpc.request( + RPCMethod.serverGetUsageSummary.rawValue, + payload: try JSONValue.encode(input), + as: UsageSummary.self + ) + } + + public func refreshUsageRates() async throws -> UsagePricing { + try await rpc.request("server.refreshUsageRates", as: UsagePricing.self) + } + + public func setProviderEnabled(instanceID: String, driver: String, enabled: Bool) async throws { + let settings = try await rpc.request("server.getSettings", as: JSONValue.self) + let patch = ProviderSettingsPatch.enabled(settings: settings, instanceID: instanceID, driver: driver, enabled: enabled) + let _: JSONValue = try await rpc.request("server.updateSettings", payload: .object(["patch": patch]), as: JSONValue.self) + } + + public func providerSetup(instanceID: String, action: ProviderSetupAction) async throws -> ProviderSetupEvent { + switch action { + case .signIn, .completeSignIn, .cancelSignIn, .signOut: + return .auth(try await rpc.request(action.method, payload: action.payload(instanceID: instanceID), as: ProviderAuthState.self)) + case .install, .cancelInstall, .remove: + return .install(try await rpc.request(action.method, payload: action.payload(instanceID: instanceID), as: ProviderInstallState.self)) + } + } + + public func providerAuthEvents(instanceID: String) async -> AsyncThrowingStream { + await rpc.subscribe("provider.auth.subscribe", payload: .object(["instanceId": .string(instanceID)]), as: ProviderAuthState.self) + } + + public func providerInstallEvents(instanceID: String) async -> AsyncThrowingStream { + await rpc.subscribe("provider.install.subscribe", payload: .object(["instanceId": .string(instanceID)]), as: ProviderInstallState.self) + } + + public func pullRequests(_ input: PullRequestListInput) async throws -> PullRequestListResult { + try await rpc.request( + RPCMethod.pullRequestsList.rawValue, + payload: try JSONValue.encode(input), + as: PullRequestListResult.self + ) + } + + public func pullRequestDetail(_ reference: PullRequestRef) async throws -> PullRequestDetail { + try await rpc.request( + RPCMethod.pullRequestsDetail.rawValue, + payload: try JSONValue.encode(reference), + as: PullRequestDetail.self + ) + } + + public func pullRequestActivity(_ reference: PullRequestRef) async throws + -> PullRequestActivity + { + try await rpc.request( + RPCMethod.pullRequestsActivity.rawValue, + payload: try JSONValue.encode(reference), + as: PullRequestActivity.self + ) + } + + public func pullRequestDiff(_ input: PullRequestDiffInput) async throws + -> PullRequestDiffResult + { + try await api.pullRequestDiff(input, environment: environment) + } + + public func runPullRequestAction( + _ reference: PullRequestRef, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod? = nil, + updateMethod: PullRequestUpdateMethod? = nil + ) async throws { + var payload = try reference.jsonObject + payload["action"] = .string(action.rawValue) + if let mergeMethod { payload["mergeMethod"] = .string(mergeMethod.rawValue) } + if let updateMethod { payload["updateMethod"] = .string(updateMethod.rawValue) } + try await rpc.request( + RPCMethod.pullRequestsRunAction.rawValue, + payload: .object(payload) + ) + } + + public func updatePullRequest( + _ reference: PullRequestRef, + title: String? = nil, + body: String? = nil + ) async throws { + var payload = try reference.jsonObject + if let title { payload["title"] = .string(title) } + if let body { payload["body"] = .string(body) } + try await rpc.request(RPCMethod.pullRequestsUpdate.rawValue, payload: .object(payload)) + } + + public func commentOnPullRequest(_ reference: PullRequestRef, body: String) async throws { + var payload = try reference.jsonObject + payload["body"] = .string(body) + try await rpc.request(RPCMethod.pullRequestsComment.rawValue, payload: .object(payload)) + } + + public func updatePullRequestComment( + _ reference: PullRequestRef, + commentID: String, + kind: PullRequestCommentKind, + body: String + ) async throws { + var payload = try reference.jsonObject + payload["commentId"] = .string(commentID) + payload["kind"] = .string(kind.rawValue) + payload["body"] = .string(body) + try await rpc.request( + RPCMethod.pullRequestsUpdateComment.rawValue, + payload: .object(payload) + ) + } + + public func submitPullRequestReview( + _ reference: PullRequestRef, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { + var payload = try reference.jsonObject + payload["verdict"] = .string(verdict.rawValue) + payload["body"] = .string(body) + payload["comments"] = try .encode(comments) + try await rpc.request( + RPCMethod.pullRequestsSubmitReview.rawValue, + payload: .object(payload) + ) + } + + public func replyToPullRequestThread( + _ reference: PullRequestRef, + threadID: String, + body: String + ) async throws { + var payload = try reference.jsonObject + payload["threadId"] = .string(threadID) + payload["body"] = .string(body) + try await rpc.request( + RPCMethod.pullRequestsReplyToThread.rawValue, + payload: .object(payload) + ) + } + + public func setPullRequestThreadResolved( + _ reference: PullRequestRef, + threadID: String, + resolved: Bool + ) async throws { + var payload = try reference.jsonObject + payload["threadId"] = .string(threadID) + payload["resolved"] = .bool(resolved) + try await rpc.request( + RPCMethod.pullRequestsSetThreadResolution.rawValue, + payload: .object(payload) + ) + } + + public func setPullRequestReaction( + _ reference: PullRequestRef, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { + var payload = try reference.jsonObject + if let subjectID { payload["subjectId"] = .string(subjectID) } + payload["content"] = .string(content.rawValue) + payload["reacted"] = .bool(reacted) + try await rpc.request( + RPCMethod.pullRequestsSetReaction.rawValue, + payload: .object(payload) + ) + } + + public func pullRequestReviewerCandidates(_ reference: PullRequestRef) async throws + -> PullRequestReviewerCandidateList + { + try await rpc.request( + RPCMethod.pullRequestsReviewerCandidates.rawValue, + payload: try JSONValue.encode(reference), + as: PullRequestReviewerCandidateList.self + ) + } + + public func requestPullRequestReviewers( + _ reference: PullRequestRef, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { + var payload = try reference.jsonObject + payload["reviewers"] = .array(reviewers.map { + .object(["id": .string($0.id), "kind": .string($0.kind)]) + }) + payload["requested"] = .bool(requested) + try await rpc.request( + RPCMethod.pullRequestsRequestReviewers.rawValue, + payload: .object(payload) + ) + } + + public func invalidatePullRequests(_ reference: PullRequestRef? = nil) async throws { + var payload: [String: JSONValue] = [:] + if let reference { payload["reference"] = try JSONValue.encode(reference) } + try await rpc.request( + RPCMethod.pullRequestsInvalidate.rawValue, + payload: .object(payload) + ) + } + + public func serverConfigEvents() async + -> AsyncThrowingStream + { + let id = UUID() + let stream = AsyncThrowingStream { continuation in + serverConfigListeners[id] = continuation + if let serverConfigCache { continuation.yield(.snapshot(serverConfigCache)) } + continuation.onTermination = { @Sendable _ in + Task { await self.removeServerConfigListener(id) } + } + } + startServerConfigSubscriptionIfNeeded() + return stream + } + + private func startServerConfigSubscriptionIfNeeded() { + guard serverConfigTask == nil else { return } + serverConfigGeneration &+= 1 + let generation = serverConfigGeneration + serverConfigTask = Task { [weak self] in + guard let self else { return } + let stream = await rpc.subscribe( + RPCMethod.subscribeServerConfig.rawValue, + payload: .object(["usageLimitSources": .bool(true)]), + as: ServerConfigStreamEvent.self + ) + do { + for try await event in stream { + guard !Task.isCancelled else { return } + await self.consumeServerConfig(event, generation: generation) + } + await self.finishServerConfigSubscription(generation: generation, error: RPCError.disconnected) + } catch { + await self.handleServerConfigSubscriptionFailure(error, generation: generation) + } + } + } + + private func consumeServerConfig(_ event: ServerConfigStreamEvent, generation: UInt64) { + guard generation == serverConfigGeneration else { return } + var emittedEvent = event + switch event { + case var .snapshot(config): + // Wire snapshots omit sources. Keep them until a capable server + // publishes its current set. Drop them when source support is gone. + config.usageLimitSources = config.environment?.capabilities.usageLimitSources == true + ? serverConfigCache?.usageLimitSources ?? config.usageLimitSources + : [] + cacheServerConfig(config) + emittedEvent = .snapshot(config) + case let .providerStatuses(providers): + if let current = serverConfigCache { + cacheServerConfig(.init( + providers: providers, + settings: current.settings, + threadSnapshotPagination: current.threadSnapshotPagination, + threadResumeCompletionMarker: current.threadResumeCompletionMarker, + environment: current.environment, + usageLimitSources: current.usageLimitSources + )) + } + case let .settingsUpdated(settings): + if let current = serverConfigCache { + cacheServerConfig(.init( + providers: current.providers, + settings: settings, + threadSnapshotPagination: current.threadSnapshotPagination, + threadResumeCompletionMarker: current.threadResumeCompletionMarker, + environment: current.environment, + usageLimitSources: current.usageLimitSources + )) + } + case let .usageLimitSourcesUpdated(sources): + guard var current = serverConfigCache, + current.environment?.capabilities.usageLimitSources == true else { return } + current.usageLimitSources = sources + cacheServerConfig(current) + case .unrelated: break + } + serverConfigListeners.values.forEach { $0.yield(emittedEvent) } + } + + private func cacheServerConfig(_ config: ServerConfigSnapshot) { + serverConfigCache = config + latestServerEnvironment = config.environment + let waiters = serverConfigWaiters.values + serverConfigWaiters.removeAll() + waiters.forEach { $0.resume(returning: config) } + } + + private func waitForServerConfigSnapshot() async throws -> ServerConfigSnapshot { + if let serverConfigCache { return serverConfigCache } + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + serverConfigWaiters[id] = continuation + } + } onCancel: { + Task { await self.cancelServerConfigWaiter(id) } + } + } + + private func cancelServerConfigWaiter(_ id: UUID) { + serverConfigWaiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + + private func removeServerConfigListener(_ id: UUID) { serverConfigListeners[id] = nil } + + private func handleServerConfigSubscriptionFailure(_ error: any Error, generation: UInt64) async { + guard generation == serverConfigGeneration else { return } + guard isUnsupportedServerConfigSubscription(error) else { + finishServerConfigSubscription(generation: generation, error: error) + return + } + do { + let config: ServerConfigSnapshot = try await rpc.request( + RPCMethod.serverGetConfig.rawValue, + as: ServerConfigSnapshot.self + ) + guard generation == serverConfigGeneration else { return } + cacheServerConfig(config) + serverConfigListeners.values.forEach { $0.yield(.snapshot(config)) } + serverConfigTask = nil + } catch { + finishServerConfigSubscription(generation: generation, error: error) + } + } + + private func isUnsupportedServerConfigSubscription(_ error: any Error) -> Bool { + guard case let RPCError.remote(message) = error else { return false } + let value = message.lowercased() + guard value.contains(RPCMethod.subscribeServerConfig.rawValue.lowercased()) else { + return false + } + return value.contains("unsupported method") || value.contains("unknown rpc") + || value.contains("unknown request") || value.contains("method not found") + } + + private func finishServerConfigSubscription(generation: UInt64, error: any Error) { + guard generation == serverConfigGeneration else { return } + serverConfigTask = nil + serverConfigCache = nil + latestServerEnvironment = nil + let waiters = serverConfigWaiters.values + serverConfigWaiters.removeAll() + waiters.forEach { $0.resume(throwing: error) } + let listeners = serverConfigListeners.values + serverConfigListeners.removeAll() + listeners.forEach { $0.finish(throwing: error) } + } + + private func stopServerConfigSubscription(error: any Error) { + serverConfigGeneration &+= 1 + serverConfigTask?.cancel() + serverConfigTask = nil + serverConfigCache = nil + latestServerEnvironment = nil + let waiters = serverConfigWaiters.values + serverConfigWaiters.removeAll() + waiters.forEach { $0.resume(throwing: error) } + let listeners = serverConfigListeners.values + serverConfigListeners.removeAll() + listeners.forEach { $0.finish(throwing: error) } + } + + public func clientSessions() async throws -> [AuthClientSession] { + try await api.clientSessions(for: environment) + } + + public func authSession() async throws -> AuthSessionState { + try await api.session(for: environment) + } + + @discardableResult + public func revokeClientSession(id: String) async throws -> Bool { + try await api.revokeClientSession(id: id, environment: environment).revoked + } + + @discardableResult + public func revokeOtherClientSessions() async throws -> Int { + try await api.revokeOtherClientSessions(for: environment).revokedCount + } + + /// HTTP live-sync fallback. Each iteration is an independent request, so a + /// transient network loss naturally reconnects without replaying commands. + public func pollShell( + every interval: Duration = .seconds(2) + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + var lastSequence: Int? + while !Task.isCancelled { + do { + let snapshot = try await self.shellSnapshot() + if lastSequence != snapshot.snapshotSequence { + lastSequence = snapshot.snapshotSequence + continuation.yield(snapshot) + } + } catch is CancellationError { + break + } catch { + // Keep retrying transient HTTP failures. Authentication + // failures surface from direct loads and pairing UI. + } + try? await Task.sleep(for: interval) + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + public func shellEvents( + after sequence: Int? = nil, + reconnect: Bool = true + ) async -> AsyncThrowingStream { + var payload: [String: JSONValue] = ["requestCompletionMarker": .bool(true)] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + return await rpc.subscribe( + RPCMethod.subscribeShell.rawValue, + payload: .object(payload), + reconnect: reconnect, + as: ShellStreamItem.self + ) + } + + public func threadEvents( + threadID: String, + after sequence: Int? = nil, + turnLimit: Int? = nil + ) async throws -> (events: AsyncThrowingStream, connectionID: UUID) { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "requestCompletionMarker": .bool(true), + ] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + if let turnLimit { payload["turnLimit"] = .number(Double(turnLimit)) } + return try await rpc.subscribeOnCurrentConnection( + RPCMethod.subscribeThread.rawValue, + payload: .object(payload), + as: ThreadStreamItem.self + ) + } + + @discardableResult + public func dispatch(_ command: JSONValue) async throws -> DispatchResult { + guard await rpc.isConnected() else { + return try await api.dispatch(command, environment: environment) + } + do { + return try await dispatchOverWebSocket(command) + } catch RPCError.connectionUnavailable { + // The request provably never crossed the socket, so HTTP is a safe + // fallback without risking duplicate side effects. + return try await api.dispatch(command, environment: environment) + } + } + + @discardableResult + public func sendTurn( + threadID: String, + text: String, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + model: ModelSelection? = nil, + attachments: [UploadChatImageAttachment] = [], + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) async throws -> DispatchResult { + let uploadedAttachments = try await prepareTurnAttachments(attachments) + return try await dispatch( + try OrchestrationCommands.sendTurn( + threadID: threadID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: attachments, + uploadedAttachments: uploadedAttachments, + commandID: commandID, + messageID: messageID, + createdAt: createdAt + ) + ) + } + + @discardableResult + public func createThread( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil + ) async throws -> DispatchResult { + try await dispatch( + try OrchestrationCommands.createThread( + threadID: threadID, + projectID: projectID, + title: title, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + branch: branch, + worktreePath: worktreePath + ) + ) + } + + /// Creates a thread and starts its first turn through the server-supported + /// message-first bootstrap path. + @discardableResult + public func createThreadAndSend( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + worktreePreparation: ThreadWorktreePreparation? = nil, + attachments: [UploadChatImageAttachment] = [], + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) async throws -> DispatchResult { + let uploadedAttachments = try await prepareTurnAttachments(attachments) + return try await dispatchOverWebSocket( + try OrchestrationCommands.createThreadAndSend( + threadID: threadID, + projectID: projectID, + title: title, + text: text, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + branch: branch, + worktreePath: worktreePath, + worktreePreparation: worktreePreparation, + attachments: attachments, + uploadedAttachments: uploadedAttachments, + commandID: commandID, + messageID: messageID, + createdAt: createdAt + ) + ) + } + + private func dispatchOverWebSocket(_ command: JSONValue) async throws -> DispatchResult { + try await rpc.request( + RPCMethod.dispatchCommand.rawValue, + payload: command, + as: DispatchResult.self + ) + } + + @discardableResult + public func createProject( + projectID: String = UUID().uuidString, + title: String, + workspaceRoot: String, + defaultModel: ModelSelection? = nil, + createWorkspaceRootIfMissing: Bool = false + ) async throws -> DispatchResult { + try await dispatch( + try OrchestrationCommands.createProject( + projectID: projectID, + title: title, + workspaceRoot: workspaceRoot, + defaultModel: defaultModel, + createWorkspaceRootIfMissing: createWorkspaceRootIfMissing + ) + ) + } + + @discardableResult + public func archive(threadID: String, archived: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.archive(threadID: threadID, archived: archived)) + } + + @discardableResult + public func delete(threadID: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.deleteThread(threadID: threadID)) + } + + @discardableResult + public func rename(threadID: String, title: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.rename(threadID: threadID, title: title)) + } + + @discardableResult + public func regenerateTitle(threadID: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.regenerateTitle(threadID: threadID)) + } + + @discardableResult + public func interrupt(threadID: String, turnID: String? = nil) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.interrupt(threadID: threadID, turnID: turnID) + ) + } + + @discardableResult + public func respondToApproval( + threadID: String, + requestID: String, + decision: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.respondToApproval( + threadID: threadID, + requestID: requestID, + decision: decision + ) + ) + } + + @discardableResult + public func respondToUserInput( + threadID: String, + requestID: String, + answers: [String: JSONValue] + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.respondToUserInput( + threadID: threadID, + requestID: requestID, + answers: answers + ) + ) + } + + @discardableResult + public func settle(threadID: String, settled: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.settle(threadID: threadID, settled: settled)) + } + + @discardableResult + public func snooze(threadID: String, until: Date?) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.snooze(threadID: threadID, until: until) + ) + } + + @discardableResult + public func pin(threadID: String, pinned: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.pin(threadID: threadID, pinned: pinned)) + } + + @discardableResult + public func setRuntimeMode( + threadID: String, + mode: RuntimeMode + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.setRuntimeMode(threadID: threadID, mode: mode) + ) + } + + @discardableResult + public func setInteractionMode( + threadID: String, + mode: InteractionMode + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.setInteractionMode(threadID: threadID, mode: mode) + ) + } + + // MARK: Workspace files + + public func listProjectEntries(cwd: String) async throws -> ProjectEntriesResult { + try await rpc.request( + RPCMethod.projectsListEntries.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: ProjectEntriesResult.self + ) + } + + public func searchProjectEntries( + cwd: String, + query: String, + limit: Int = 100 + ) async throws -> ProjectEntriesResult { + try await rpc.request( + RPCMethod.projectsSearchEntries.rawValue, + payload: .object([ + "cwd": .string(cwd), + "query": .string(query), + "limit": .number(Double(limit)), + ]), + as: ProjectEntriesResult.self + ) + } + + public func readProjectFile( + cwd: String, + relativePath: String + ) async throws -> ProjectReadFileResult { + try await rpc.request( + RPCMethod.projectsReadFile.rawValue, + payload: .object([ + "cwd": .string(cwd), + "relativePath": .string(relativePath), + ]), + as: ProjectReadFileResult.self + ) + } + + public func writeProjectFile( + cwd: String, + relativePath: String, + contents: String + ) async throws -> ProjectWriteFileResult { + try await rpc.request( + RPCMethod.projectsWriteFile.rawValue, + payload: .object([ + "cwd": .string(cwd), + "relativePath": .string(relativePath), + "contents": .string(contents), + ]), + as: ProjectWriteFileResult.self + ) + } + + public func browseFilesystem( + partialPath: String, + cwd: String? = nil + ) async throws -> FilesystemBrowseResult { + var payload: [String: JSONValue] = ["partialPath": .string(partialPath)] + if let cwd { payload["cwd"] = .string(cwd) } + return try await rpc.request( + RPCMethod.filesystemBrowse.rawValue, + payload: .object(payload), + as: FilesystemBrowseResult.self + ) + } + + /// Issues a short-lived authenticated URL for a persisted attachment, + /// workspace preview, or project favicon. + public func createAssetURL(resource: AssetResource) async throws -> AssetCreateURLResult { + try await rpc.request( + RPCMethod.assetsCreateURL.rawValue, + payload: .object(["resource": resource.jsonValue]), + as: AssetCreateURLResult.self + ) + } + + public func createAttachmentUploadURL( + type: String? = nil, + name: String, + mimeType: String, + sizeBytes: Int + ) async throws -> AttachmentCreateUploadURLResult { + var payload: [String: JSONValue] = [ + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ] + if let type { payload["type"] = .string(type) } + return try await rpc.request( + RPCMethod.attachmentsCreateUploadURL.rawValue, + payload: .object(payload), + as: AttachmentCreateUploadURLResult.self + ) + } + + public func deleteAttachment(id: String) async throws { + try await rpc.request( + RPCMethod.attachmentsDelete.rawValue, + payload: .object(["attachmentId": .string(id)]) + ) + } + + public func uploadFeedback( + threadID: String, + reason: String? = nil + ) async throws -> ProviderUploadFeedbackResult { + var payload: [String: JSONValue] = ["threadId": .string(threadID)] + if let reason { + payload["reason"] = .string(reason) + } + return try await rpc.request( + RPCMethod.providerUploadFeedback.rawValue, + payload: .object(payload), + as: ProviderUploadFeedbackResult.self + ) + } + + private func prepareTurnAttachments( + _ attachments: [UploadChatImageAttachment] + ) async throws -> [JSONValue]? { + guard !attachments.isEmpty else { return nil } + guard attachments.count <= 8 else { throw FileAttachmentError.tooMany(maximum: 8) } + + let capabilities = latestServerEnvironment?.capabilities + ?? environment.descriptor?.capabilities + let containsFiles = attachments.contains { $0.type == "file" } + let supportsImageUploads = capabilities?.attachmentUploads == true + let fileCapability = capabilities?.fileAttachments + if containsFiles, !supportsImageUploads || fileCapability == nil { + throw FileAttachmentError.unsupported + } + + if let fileCapability { + let maximumBytes = min( + UploadChatAttachment.maximumFileBytes, + max(0, fileCapability.maxUploadBytes) + ) + for attachment in attachments where attachment.type == "file" { + guard attachment.sizeBytes <= maximumBytes else { + throw FileAttachmentError.tooLarge( + actualBytes: attachment.sizeBytes, + maximumBytes: maximumBytes + ) + } + } + } + guard containsFiles || supportsImageUploads else { return nil } + + var prepared: [JSONValue] = [] + for attachment in attachments { + if let reference = try await prepareAttachment(attachment) { + prepared.append(attachment.uploadedJSONValue(id: reference.attachmentID)) + } else { + prepared.append(attachment.jsonValue) + } + } + return prepared + } + + /// Uploads one attachment for this environment. Older servers keep images + /// inline, so a nil result means the caller must use the image data URL. + public func prepareAttachment( + _ attachment: UploadChatAttachment + ) async throws -> UploadedAttachmentReference? { + try Task.checkCancellation() + let capabilities = latestServerEnvironment?.capabilities + ?? environment.descriptor?.capabilities + let supportsUploads = capabilities?.attachmentUploads == true + if attachment.type == "file" { + guard supportsUploads, let fileCapability = capabilities?.fileAttachments else { + throw FileAttachmentError.unsupported + } + let maximumBytes = min( + UploadChatAttachment.maximumFileBytes, + max(0, fileCapability.maxUploadBytes) + ) + guard attachment.sizeBytes <= maximumBytes else { + throw FileAttachmentError.tooLarge( + actualBytes: attachment.sizeBytes, + maximumBytes: maximumBytes + ) + } + } else if !supportsUploads { + return nil + } + + if let reference = attachment.uploadedReference, + reference.environmentID == environment.id, + !reference.attachmentID.isEmpty { + do { + _ = try await createAssetURL(resource: .attachment(id: reference.attachmentID)) + try Task.checkCancellation() + return reference + } catch where Self.isAttachmentNotFound(error) { + // The server expired the attachment. Upload the retained bytes again. + } + } + + let upload = try await createAttachmentUploadURL( + type: attachment.type == "file" ? "file" : nil, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes + ) + do { + try Task.checkCancellation() + guard let url = URL( + string: upload.relativeUrl, + relativeTo: environment.httpBaseURL + )?.absoluteURL else { + throw RPCError.protocolViolation("The attachment upload URL is invalid.") + } + switch attachment.source { + case let .imageData(data): + try await api.uploadAttachment(data, mimeType: attachment.mimeType, to: url) + case let .file(fileURL): + guard let actualBytes = try? fileURL.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ), + actualBytes.isRegularFile == true, + actualBytes.fileSize == attachment.sizeBytes else { + throw FileAttachmentError.invalidFileURL + } + try await api.uploadAttachment( + fileURL: fileURL, + byteCount: attachment.sizeBytes, + mimeType: attachment.mimeType, + to: url + ) + } + try Task.checkCancellation() + return UploadedAttachmentReference( + environmentID: environment.id, + attachmentID: upload.attachmentId + ) + } catch { + // Cleanup must not keep the composer in Uploading after the + // transfer has failed, especially if the WebSocket is offline. + Task { try? await self.deleteAttachment(id: upload.attachmentId) } + throw error + } + } + + private static func isAttachmentNotFound(_ error: any Error) -> Bool { + guard case let RPCError.remote(message) = error else { return false } + let normalized = message.lowercased() + return normalized.contains("attachment") + && (normalized.contains("not found") || normalized.contains("does not exist")) + } + + public func resolvedAssetURL(resource: AssetResource) async throws -> URL { + try await resolvedAsset(resource: resource).url + } + + public func resolvedAsset(resource: AssetResource) async throws -> ResolvedAssetURL { + let result = try await createAssetURL(resource: resource) + guard let url = URL( + string: result.relativeUrl, + relativeTo: environment.httpBaseURL + )?.absoluteURL else { + throw RPCError.protocolViolation("The server returned an invalid asset URL.") + } + return ResolvedAssetURL( + url: url, + expiresAt: Date(timeIntervalSince1970: result.expiresAt / 1_000), + imageDimensions: result.imageDimensions + ) + } + + // MARK: VCS and source control + + public func refreshVCSStatus(cwd: String) async throws -> VCSStatus { + try await rpc.request( + RPCMethod.vcsRefreshStatus.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: VCSStatus.self + ) + } + + public func vcsStatusEvents(cwd: String) async + -> AsyncThrowingStream + { + await rpc.subscribe( + RPCMethod.subscribeVCSStatus.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: VCSStatusEvent.self + ) + } + + public func listVCSRefs( + cwd: String, + query: String? = nil, + cursor: Int? = nil, + kind: String? = nil, + refresh: Bool = false, + limit: Int = 100 + ) async throws -> VCSRefsResult { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "refresh": .bool(refresh), + "limit": .number(Double(limit)), + ] + if let query { payload["query"] = .string(query) } + if let cursor { payload["cursor"] = .number(Double(cursor)) } + if let kind { payload["refKind"] = .string(kind) } + return try await rpc.request( + RPCMethod.vcsListRefs.rawValue, + payload: .object(payload), + as: VCSRefsResult.self + ) + } + + public func pull(cwd: String) async throws -> VCSPullResult { + try await rpc.request( + RPCMethod.vcsPull.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: VCSPullResult.self + ) + } + + public func createVCSRef( + cwd: String, + name: String, + switchToRef: Bool = true + ) async throws -> VCSCreateRefResult { + try await rpc.request( + RPCMethod.vcsCreateRef.rawValue, + payload: .object([ + "cwd": .string(cwd), + "refName": .string(name), + "switchRef": .bool(switchToRef), + ]), + as: VCSCreateRefResult.self + ) + } + + public func switchVCSRef(cwd: String, name: String) async throws -> VCSSwitchRefResult { + try await rpc.request( + RPCMethod.vcsSwitchRef.rawValue, + payload: .object([ + "cwd": .string(cwd), + "refName": .string(name), + ]), + as: VCSSwitchRefResult.self + ) + } + + public func createWorktree( + cwd: String, + refName: String, + newRefName: String? = nil, + baseRefName: String? = nil, + path: String? = nil + ) async throws -> VCSCreateWorktreeResult { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "refName": .string(refName), + "path": path.map(JSONValue.string) ?? .null, + ] + if let newRefName { payload["newRefName"] = .string(newRefName) } + if let baseRefName { payload["baseRefName"] = .string(baseRefName) } + return try await rpc.request( + RPCMethod.vcsCreateWorktree.rawValue, + payload: .object(payload), + as: VCSCreateWorktreeResult.self + ) + } + + public func removeWorktree(cwd: String, path: String, force: Bool = false) async throws { + try await rpc.request( + RPCMethod.vcsRemoveWorktree.rawValue, + payload: .object([ + "cwd": .string(cwd), + "path": .string(path), + "force": .bool(force), + ]) + ) + } + + public func initializeVCS(cwd: String, kind: String? = nil) async throws { + var payload: [String: JSONValue] = ["cwd": .string(cwd)] + if let kind { payload["kind"] = .string(kind) } + try await rpc.request(RPCMethod.vcsInitialize.rawValue, payload: .object(payload)) + } + + public func runGitAction( + cwd: String, + action: GitStackedAction, + commitMessage: String? = nil, + featureBranch: Bool? = nil, + filePaths: [String]? = nil, + actionID: String = UUID().uuidString + ) async throws -> AsyncThrowingStream { + var payload: [String: JSONValue] = [ + "actionId": .string(actionID), + "cwd": .string(cwd), + "action": .string(action.rawValue), + ] + if let commitMessage { payload["commitMessage"] = .string(commitMessage) } + if let featureBranch { payload["featureBranch"] = .bool(featureBranch) } + if let filePaths { payload["filePaths"] = .array(filePaths.map(JSONValue.string)) } + // A command stream must fail on disconnect instead of replaying a + // potentially successful commit or push. + return await rpc.subscribe( + RPCMethod.gitRunStackedAction.rawValue, + payload: .object(payload), + reconnect: false, + as: GitActionProgressEvent.self + ) + } + + public func lookupRepository( + provider: SourceControlProviderKind, + repository: String, + cwd: String? = nil + ) async throws -> SourceControlRepositoryInfo { + var payload: [String: JSONValue] = [ + "provider": .string(provider.rawValue), + "repository": .string(repository), + ] + if let cwd { payload["cwd"] = .string(cwd) } + return try await rpc.request( + RPCMethod.sourceControlLookup.rawValue, + payload: .object(payload), + as: SourceControlRepositoryInfo.self + ) + } + + public func discoverSourceControl() async throws -> SourceControlDiscoveryResult { + try await rpc.request( + RPCMethod.serverDiscoverSourceControl.rawValue, + payload: .object([:]), + as: SourceControlDiscoveryResult.self + ) + } + + public func cloneRepository( + provider: SourceControlProviderKind? = nil, + repository: String? = nil, + remoteURL: String? = nil, + destinationPath: String, + cloneProtocol: String? = nil + ) async throws -> SourceControlCloneResult { + var payload: [String: JSONValue] = ["destinationPath": .string(destinationPath)] + if let provider { payload["provider"] = .string(provider.rawValue) } + if let repository { payload["repository"] = .string(repository) } + if let remoteURL { payload["remoteUrl"] = .string(remoteURL) } + if let cloneProtocol { payload["protocol"] = .string(cloneProtocol) } + return try await rpc.request( + RPCMethod.sourceControlClone.rawValue, + payload: .object(payload), + as: SourceControlCloneResult.self + ) + } + + public func publishRepository( + cwd: String, + provider: SourceControlProviderKind, + repository: String, + visibility: String, + remoteName: String? = nil, + cloneProtocol: String? = nil + ) async throws -> SourceControlPublishResult { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "provider": .string(provider.rawValue), + "repository": .string(repository), + "visibility": .string(visibility), + ] + if let remoteName { payload["remoteName"] = .string(remoteName) } + if let cloneProtocol { payload["protocol"] = .string(cloneProtocol) } + return try await rpc.request( + RPCMethod.sourceControlPublish.rawValue, + payload: .object(payload), + as: SourceControlPublishResult.self + ) + } + + // MARK: Review + + public func reviewDiffPreview( + cwd: String, + baseRef: String? = nil, + ignoreWhitespace: Bool = false + ) async throws -> ReviewDiffPreview { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "ignoreWhitespace": .bool(ignoreWhitespace), + ] + if let baseRef { payload["baseRef"] = .string(baseRef) } + return try await rpc.request( + RPCMethod.reviewDiffPreview.rawValue, + payload: .object(payload), + as: ReviewDiffPreview.self + ) + } + + public func reviewDiffFileContents( + cwd: String, + sourceKind: String, + changeType: String, + baseRef: String?, + headRef: String?, + oldPath: String, + newPath: String + ) async throws -> ReviewDiffFileContents { + let payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "sourceKind": .string(sourceKind), + "changeType": .string(changeType), + "baseRef": baseRef.map(JSONValue.string) ?? .null, + "headRef": headRef.map(JSONValue.string) ?? .null, + "oldPath": .string(oldPath), + "newPath": .string(newPath), + ] + return try await rpc.request( + RPCMethod.reviewDiffFileContents.rawValue, + payload: .object(payload), + as: ReviewDiffFileContents.self + ) + } + + // MARK: Terminal + + public func openTerminal( + threadID: String, + terminalID: String, + cwd: String, + worktreePath: String? = nil, + columns: Int? = nil, + rows: Int? = nil, + environmentVariables: [String: String]? = nil + ) async throws -> TerminalSessionSnapshot { + let payload = try terminalPayload( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + return try await rpc.request( + RPCMethod.terminalOpen.rawValue, + payload: payload, + as: TerminalSessionSnapshot.self + ) + } + + public func attachTerminal( + threadID: String, + terminalID: String, + cwd: String? = nil, + worktreePath: String? = nil, + columns: Int? = nil, + rows: Int? = nil, + environmentVariables: [String: String]? = nil, + restartIfNotRunning: Bool = false + ) async throws -> AsyncThrowingStream { + var payload = try terminalPayloadObject( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + payload["restartIfNotRunning"] = .bool(restartIfNotRunning) + return await rpc.subscribe( + RPCMethod.terminalAttach.rawValue, + payload: .object(payload), + as: TerminalEvent.self + ) + } + + public func terminalEvents() async -> AsyncThrowingStream { + await rpc.subscribe( + RPCMethod.subscribeTerminalEvents.rawValue, + as: TerminalEvent.self + ) + } + + public func terminalMetadataEvents() async + -> AsyncThrowingStream + { + await rpc.subscribe( + RPCMethod.subscribeTerminalMetadata.rawValue, + as: TerminalMetadataEvent.self + ) + } + + public func writeTerminal( + threadID: String, + terminalID: String, + data: String + ) async throws { + try await rpc.request( + RPCMethod.terminalWrite.rawValue, + payload: .object([ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + "data": .string(data), + ]) + ) + } + + public func resizeTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws { + try await rpc.request( + RPCMethod.terminalResize.rawValue, + payload: .object([ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + "cols": .number(Double(columns)), + "rows": .number(Double(rows)), + ]) + ) + } + + public func clearTerminal(threadID: String, terminalID: String) async throws { + try await rpc.request( + RPCMethod.terminalClear.rawValue, + payload: terminalIdentity(threadID: threadID, terminalID: terminalID) + ) + } + + public func restartTerminal( + threadID: String, + terminalID: String, + cwd: String, + worktreePath: String? = nil, + columns: Int, + rows: Int, + environmentVariables: [String: String]? = nil + ) async throws -> TerminalSessionSnapshot { + let payload = try terminalPayload( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + return try await rpc.request( + RPCMethod.terminalRestart.rawValue, + payload: payload, + as: TerminalSessionSnapshot.self + ) + } + + public func closeTerminal( + threadID: String, + terminalID: String? = nil, + deleteHistory: Bool = false + ) async throws { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "deleteHistory": .bool(deleteHistory), + ] + if let terminalID { payload["terminalId"] = .string(terminalID) } + try await rpc.request(RPCMethod.terminalClose.rawValue, payload: .object(payload)) + } + + private func terminalIdentity(threadID: String, terminalID: String) -> JSONValue { + .object([ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + ]) + } + + private func terminalPayload( + threadID: String, + terminalID: String, + cwd: String?, + worktreePath: String?, + columns: Int?, + rows: Int?, + environmentVariables: [String: String]? + ) throws -> JSONValue { + .object( + try terminalPayloadObject( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + ) + } + + private func terminalPayloadObject( + threadID: String, + terminalID: String, + cwd: String?, + worktreePath: String?, + columns: Int?, + rows: Int?, + environmentVariables: [String: String]? + ) throws -> [String: JSONValue] { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + ] + if let cwd { payload["cwd"] = .string(cwd) } + if let worktreePath { + payload["worktreePath"] = .string(worktreePath) + } else if cwd != nil { + payload["worktreePath"] = .null + } + if let columns { payload["cols"] = .number(Double(columns)) } + if let rows { payload["rows"] = .number(Double(rows)) } + if let environmentVariables { + payload["env"] = try JSONValue.encode(environmentVariables) + } + return payload + } +} + +/// Owns persisted environment selection and constructs scoped clients without +/// introducing UI-framework state into Core. +public struct EnvironmentPersistenceError: LocalizedError, Sendable { + public let operationError: String + public let rollbackErrors: [String] + + public var errorDescription: String? { + "\(operationError) Recovery also failed: \(rollbackErrors.joined(separator: "; "))" + } +} + +public actor EnvironmentRuntime { + public let environmentStore: EnvironmentStore + public let credentialStore: any CredentialStore + public nonisolated let supportsManagedAuthorization: Bool + private let httpTransport: any HTTPTransport + private let webSocketConnector: any WebSocketConnecting + private let managedAuthorization: (any ManagedEnvironmentAuthorizing)? + private let rpcConnectionWaitTimeout: Duration + private var clients: [String: T3Client] = [:] + + public init( + environmentStore: EnvironmentStore = EnvironmentStore(), + credentialStore: any CredentialStore = KeychainCredentialStore(), + httpTransport: any HTTPTransport = URLSessionHTTPTransport(), + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector(), + managedAuthorization: (any ManagedEnvironmentAuthorizing)? = nil, + rpcConnectionWaitTimeout: Duration = .seconds(4) + ) { + self.environmentStore = environmentStore + self.credentialStore = credentialStore + self.httpTransport = httpTransport + self.webSocketConnector = webSocketConnector + self.managedAuthorization = managedAuthorization + self.rpcConnectionWaitTimeout = rpcConnectionWaitTimeout + supportsManagedAuthorization = managedAuthorization != nil + } + + public func environments() async throws -> [Environment] { + try await environmentStore.load() + } + + public func activeEnvironment() async throws -> Environment? { + let environments = try await environmentStore.load() + let enabled = environments.filter(\.isEnabled) + guard !enabled.isEmpty else { return nil } + let activeID = try await environmentStore.activeEnvironmentID() + return enabled.first(where: { $0.id == activeID }) ?? enabled[0] + } + + @discardableResult + public func activate(id: String) async throws -> T3Client { + let environments = try await environmentStore.load() + guard let environment = environments.first(where: { $0.id == id }) else { + throw RPCError.remote("Environment \(id) is not saved.") + } + guard environment.isEnabled else { + throw RPCError.remote("Environment \(id) is disabled.") + } + try await environmentStore.setActiveEnvironment(id: id) + return await client(for: environment) + } + + public func setEnabled(id: String, enabled: Bool) async throws { + let environments = try await environmentStore.setEnabled(id: id, enabled: enabled) + guard environments.contains(where: { $0.id == id }) else { + throw RPCError.remote("Environment \(id) is not saved.") + } + if !enabled, let client = clients[id] { + await client.disconnect() + } + } + + public func activeClient() async throws -> T3Client? { + guard let environment = try await activeEnvironment() else { return nil } + return await client(for: environment) + } + + @discardableResult + public func pair(url: String, clientLabel: String? = nil) async throws -> T3Client { + let service = PairingService( + transport: httpTransport, + environmentStore: environmentStore, + credentialStore: credentialStore + ) + let environment = try await service.pair(url: url, label: clientLabel) + try await environmentStore.setActiveEnvironment(id: environment.id) + return await client(for: environment) + } + + @discardableResult + public func pair( + host: String, + code: String, + clientLabel: String? = nil + ) async throws -> T3Client { + let service = PairingService( + transport: httpTransport, + environmentStore: environmentStore, + credentialStore: credentialStore + ) + let environment = try await service.pair(host: host, code: code, label: clientLabel) + try await environmentStore.setActiveEnvironment(id: environment.id) + return await client(for: environment) + } + + public func descriptor(at httpBaseURL: URL) async throws -> EnvironmentDescriptor { + let api = EnvironmentAPI(transport: httpTransport, credentials: credentialStore) + return try await api.descriptor(at: httpBaseURL) + } + + /// Persists a fully validated managed environment. Both the environment + /// metadata and the tagged DPoP credential must agree before either can + /// replace an existing manual connection with the same server identity. + @discardableResult + public func saveManagedEnvironment( + _ environment: Environment, + credential: EnvironmentCredential + ) async throws -> T3Client { + guard environment.kind == .managedDPoP, + environment.descriptor?.environmentId == environment.id, + credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id, + credential.proofKeyThumbprint?.isEmpty == false else { + throw HTTPError.incompatibleCredential + } + + let previousEnvironment = try await environmentStore.load() + .first(where: { $0.id == environment.id }) + let previousActiveID = try await environmentStore.activeEnvironmentID() + let previousCredential = try await credentialStore.swapCredential( + credential, + for: environment.id + ) + do { + try await environmentStore.upsert(environment) + try await environmentStore.setActiveEnvironment(id: environment.id) + } catch { + let operationError = error + var rollbackErrors: [String] = [] + do { + if let previousCredential { + _ = try await credentialStore.replaceCredential( + previousCredential, + ifMatching: credential, + for: environment.id + ) + } else { + _ = try await credentialStore.removeCredential( + ifMatching: credential, + for: environment.id + ) + } + } catch { + rollbackErrors.append("credential: \(error.localizedDescription)") + } + // EnvironmentStore's individual mutations are actor-atomic. Undo + // only this record so a concurrent save for another environment + // cannot be lost while this actor is reentrant across awaits. + do { + if let previousEnvironment { + _ = try await environmentStore.upsert(previousEnvironment) + } else { + _ = try await environmentStore.remove(id: environment.id) + } + } catch { + rollbackErrors.append("environment catalog: \(error.localizedDescription)") + } + do { + let activeIDAfterFailure = try await environmentStore.activeEnvironmentID() + if activeIDAfterFailure == environment.id { + try await environmentStore.setActiveEnvironment(id: previousActiveID) + } + } catch { + rollbackErrors.append("active environment: \(error.localizedDescription)") + } + guard rollbackErrors.isEmpty else { + throw EnvironmentPersistenceError( + operationError: operationError.localizedDescription, + rollbackErrors: rollbackErrors + ) + } + throw operationError + } + return await client(for: environment) + } + + public func remove(id: String) async throws { + let previousEnvironment = try await environmentStore.load() + .first(where: { $0.id == id }) + let previousActiveID = try await environmentStore.activeEnvironmentID() + // Never leave a catalog entry pointing at a credential that was + // already destroyed when the catalog write itself fails. + try await environmentStore.remove(id: id) + do { + try await credentialStore.removeCredential(for: id) + } catch { + let operationError = error + var rollbackErrors: [String] = [] + if let previousEnvironment { + do { + _ = try await environmentStore.upsert(previousEnvironment) + } catch { + rollbackErrors.append("environment catalog: \(error.localizedDescription)") + } + } + do { + try await environmentStore.setActiveEnvironment(id: previousActiveID) + } catch { + rollbackErrors.append("active environment: \(error.localizedDescription)") + } + guard rollbackErrors.isEmpty else { + throw EnvironmentPersistenceError( + operationError: operationError.localizedDescription, + rollbackErrors: rollbackErrors + ) + } + throw operationError + } + if let client = clients.removeValue(forKey: id) { + await client.disconnect() + } + } + + /// Revokes local access before best-effort catalog cleanup. Account + /// sign-out uses this so a failed file write cannot leave a managed DPoP + /// credential usable. + public func revokeCredential(id: String) async throws { + try await credentialStore.removeCredential(for: id) + if let client = clients.removeValue(forKey: id) { + await client.disconnect() + } + } + + /// Returns the cached client for a saved environment without changing the + /// environment used for new projects and threads. + public func client(for environment: Environment) async -> T3Client { + if let existing = clients[environment.id] { + if existing.environment == environment { + return existing + } + // Publish the replacement before disconnecting the stale client. + // Actor methods are reentrant across that await; removing first + // allowed a concurrent caller to construct a second replacement. + let replacement = T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector, + managedAuthorization: managedAuthorization, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + clients[environment.id] = replacement + Task { await existing.disconnect() } + return replacement + } + let client = T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector, + managedAuthorization: managedAuthorization, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + clients[environment.id] = client + return client + } + + /// Creates an uncached client for bounded one-shot WebSocket RPCs. Passive + /// environment probes must not stop or mutate the shared client if that + /// environment becomes active while the probe is in flight. + public func ephemeralClient(for environment: Environment) -> T3Client { + T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector, + managedAuthorization: managedAuthorization, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + } +} + +public enum RPCMethod: String, Sendable { + case serverProbe = "server.probe" + case serverGetConfig = "server.getConfig" + case serverRefreshProviders = "server.refreshProviders" + case serverUpdateSettings = "server.updateSettings" + case serverGetUsageSummary = "server.getUsageSummary" + case pullRequestsList = "pullRequests.list" + case pullRequestsDetail = "pullRequests.detail" + case pullRequestsActivity = "pullRequests.activity" + case pullRequestsRunAction = "pullRequests.runAction" + case pullRequestsUpdate = "pullRequests.update" + case pullRequestsComment = "pullRequests.comment" + case pullRequestsUpdateComment = "pullRequests.updateComment" + case pullRequestsSubmitReview = "pullRequests.submitReview" + case pullRequestsReplyToThread = "pullRequests.replyToThread" + case pullRequestsSetThreadResolution = "pullRequests.setThreadResolution" + case pullRequestsSetReaction = "pullRequests.setReaction" + case pullRequestsInvalidate = "pullRequests.invalidate" + case pullRequestsReviewerCandidates = "pullRequests.reviewerCandidates" + case pullRequestsRequestReviewers = "pullRequests.requestReviewers" + case dispatchCommand = "orchestration.dispatchCommand" + case getArchivedShellSnapshot = "orchestration.getArchivedShellSnapshot" + case subscribeShell = "orchestration.subscribeShell" + case subscribeThread = "orchestration.subscribeThread" + case projectsListEntries = "projects.listEntries" + case projectsSearchEntries = "projects.searchEntries" + case projectsReadFile = "projects.readFile" + case projectsWriteFile = "projects.writeFile" + case filesystemBrowse = "filesystem.browse" + case assetsCreateURL = "assets.createUrl" + case attachmentsCreateUploadURL = "attachments.createUploadUrl" + case attachmentsDelete = "attachments.delete" + case providerUploadFeedback = "provider.uploadFeedback" + case providerConsumeResetCredit = "provider.consumeResetCredit" + case subscribeServerConfig + case serverDiscoverSourceControl = "server.discoverSourceControl" + case subscribeVCSStatus = "subscribeVcsStatus" + case vcsPull = "vcs.pull" + case vcsRefreshStatus = "vcs.refreshStatus" + case vcsListRefs = "vcs.listRefs" + case vcsCreateRef = "vcs.createRef" + case vcsSwitchRef = "vcs.switchRef" + case vcsCreateWorktree = "vcs.createWorktree" + case vcsRemoveWorktree = "vcs.removeWorktree" + case vcsInitialize = "vcs.init" + case gitRunStackedAction = "git.runStackedAction" + case sourceControlLookup = "sourceControl.lookupRepository" + case sourceControlClone = "sourceControl.cloneRepository" + case sourceControlPublish = "sourceControl.publishRepository" + case reviewDiffPreview = "review.getDiffPreview" + case reviewDiffFileContents = "review.getDiffFileContents" + case terminalOpen = "terminal.open" + case terminalAttach = "terminal.attach" + case terminalWrite = "terminal.write" + case terminalResize = "terminal.resize" + case terminalClear = "terminal.clear" + case terminalRestart = "terminal.restart" + case terminalClose = "terminal.close" + case subscribeTerminalEvents + case subscribeTerminalMetadata +} + +public enum OrchestrationCommands { + public static func createThread( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + .object([ + "type": .string("thread.create"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "projectId": .string(projectID), + "title": .string(title), + "modelSelection": try .encode(model), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "branch": branch.map(JSONValue.string) ?? .null, + "worktreePath": worktreePath.map(JSONValue.string) ?? .null, + "createdAt": .string(createdAt), + ]) + } + + public static func createProject( + projectID: String = UUID().uuidString, + title: String, + workspaceRoot: String, + defaultModel: ModelSelection? = nil, + createWorkspaceRootIfMissing: Bool = false, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("project.create"), + "commandId": .string(commandID), + "projectId": .string(projectID), + "title": .string(title), + "workspaceRoot": .string(workspaceRoot), + "createWorkspaceRootIfMissing": .bool(createWorkspaceRootIfMissing), + "createdAt": .string(createdAt), + ] + if let defaultModel { + value["defaultModelSelection"] = try JSONValue.encode(defaultModel) + } else { + value["defaultModelSelection"] = .null + } + return .object(value) + } + + public static func sendTurn( + threadID: String, + text: String, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + model: ModelSelection? = nil, + attachments: [UploadChatImageAttachment] = [], + uploadedAttachments: [JSONValue]? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var command: [String: JSONValue] = [ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object([ + "messageId": .string(messageID), + "role": .string("user"), + "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ]), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "createdAt": .string(createdAt), + ] + if let model { + command["modelSelection"] = try .encode(model) + } + return .object(command) + } + + public static func createThreadAndSend( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + worktreePreparation: ThreadWorktreePreparation? = nil, + attachments: [UploadChatImageAttachment] = [], + uploadedAttachments: [JSONValue]? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var create: [String: JSONValue] = [ + "projectId": .string(projectID), + "title": .string(title), + "modelSelection": try .encode(model), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "branch": branch.map(JSONValue.string) ?? .null, + "worktreePath": worktreePath.map(JSONValue.string) ?? .null, + "createdAt": .string(createdAt), + ] + create["createdAt"] = .string(createdAt) + var bootstrap: [String: JSONValue] = ["createThread": .object(create)] + if let worktreePreparation { + var prepareWorktree: [String: JSONValue] = [ + "projectCwd": .string(worktreePreparation.projectCwd), + "baseBranch": .string(worktreePreparation.baseBranch), + "branch": .string(worktreePreparation.branch), + ] + if worktreePreparation.startFromOrigin { + prepareWorktree["startFromOrigin"] = .bool(true) + } + bootstrap["prepareWorktree"] = .object(prepareWorktree) + bootstrap["runSetupScript"] = .bool(true) + } + return .object([ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object([ + "messageId": .string(messageID), + "role": .string("user"), + "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ]), + "modelSelection": try .encode(model), + "titleSeed": .string(title), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "bootstrap": .object(bootstrap), + "createdAt": .string(createdAt), + ]) + } + + public static func archive( + threadID: String, + archived: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + basic( + type: archived ? "thread.archive" : "thread.unarchive", + threadID: threadID, + commandID: commandID + ) + } + + public static func deleteThread( + threadID: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + basic(type: "thread.delete", threadID: threadID, commandID: commandID) + } + + public static func rename( + threadID: String, + title: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + .object([ + "type": .string("thread.meta.update"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "title": .string(title), + ]) + } + + public static func regenerateTitle( + threadID: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + .object([ + "type": .string("thread.meta.update"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "regenerateTitle": .bool(true), + ]) + } + + public static func interrupt( + threadID: String, + turnID: String?, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("thread.turn.interrupt"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "createdAt": .string(createdAt), + ] + if let turnID { value["turnId"] = .string(turnID) } + return .object(value) + } + + public static func respondToApproval( + threadID: String, + requestID: String, + decision: String, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.approval.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "decision": .string(decision), + "createdAt": .string(createdAt), + ]) + } + + public static func respondToUserInput( + threadID: String, + requestID: String, + answers: [String: JSONValue], + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.user-input.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "answers": .object(answers), + "createdAt": .string(createdAt), + ]) + } + + public static func settle( + threadID: String, + settled: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + var value = basic( + type: settled ? "thread.settle" : "thread.unsettle", + threadID: threadID, + commandID: commandID + ) + if !settled, case var .object(object) = value { + object["reason"] = .string("user") + value = .object(object) + } + return value + } + + public static func snooze( + threadID: String, + until: Date?, + commandID: String = UUID().uuidString + ) -> JSONValue { + if let until { + return .object([ + "type": .string("thread.snooze"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "snoozedUntil": .string(iso8601.format(until)), + ]) + } + return .object([ + "type": .string("thread.unsnooze"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "reason": .string("user"), + ]) + } + + public static func pin( + threadID: String, + pinned: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + basic( + type: pinned ? "thread.pin" : "thread.unpin", + threadID: threadID, + commandID: commandID + ) + } + + public static func setRuntimeMode( + threadID: String, + mode: RuntimeMode, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.runtime-mode.set"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "runtimeMode": .string(mode.rawValue), + "createdAt": .string(createdAt), + ]) + } + + public static func setInteractionMode( + threadID: String, + mode: InteractionMode, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.interaction-mode.set"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "interactionMode": .string(mode.rawValue), + "createdAt": .string(createdAt), + ]) + } + + private static func basic(type: String, threadID: String, commandID: String) -> JSONValue { + .object([ + "type": .string(type), + "commandId": .string(commandID), + "threadId": .string(threadID), + ]) + } + + public static func now() -> String { + iso8601.format(Date()) + } + + /// Commands are built on several actors, so their shared formatter must be Sendable. + private static let iso8601 = Date.ISO8601FormatStyle() +} diff --git a/apps/swift-ios/Core/ToolActivityPresentation.swift b/apps/swift-ios/Core/ToolActivityPresentation.swift new file mode 100644 index 000000000000..bec25a674f02 --- /dev/null +++ b/apps/swift-ios/Core/ToolActivityPresentation.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct ToolNativeAppReference: Codable, Hashable, Sendable { + public let _tag: String + public let appId: String? + public let displayName: String? +} + +public struct ToolActivityPresentation: Codable, Hashable, Sendable { + public let surface: String? + public let sourceName: String? + public let lightURL: URL? + public let darkURL: URL? + public let nativeApp: ToolNativeAppReference? + + init?(payload: JSONValue) { + let source = payload["toolSource"] + surface = payload["toolSurface"]?.stringValue ?? source?["kind"]?.stringValue + sourceName = source?["name"]?.stringValue + let icon = payload["toolIcon"] ?? source?["icon"] + let kind = icon?["_tag"]?.stringValue + lightURL = Self.imageURL(icon?[kind == "website" ? "faviconUrl" : "logoUrl"]?.stringValue) + darkURL = Self.imageURL(icon?[kind == "website" ? "faviconUrlDark" : "logoUrlDark"]?.stringValue) + if kind == "native-app", let app = icon?["app"] { + if app["_tag"]?.stringValue == "app-id", let id = app["appId"]?.stringValue, + !id.isEmpty, id.count <= 512, id.range(of: #"^[A-Za-z0-9._-]+$"#, options: .regularExpression) != nil { + nativeApp = ToolNativeAppReference(_tag: "app-id", appId: id, displayName: nil) + } else if app["_tag"]?.stringValue == "display-name", let name = app["displayName"]?.stringValue, + !name.isEmpty, name.count <= 160 { + nativeApp = ToolNativeAppReference(_tag: "display-name", appId: nil, displayName: name) + } else { nativeApp = nil } + } else { nativeApp = nil } + if surface == nil, sourceName == nil, lightURL == nil, darkURL == nil, nativeApp == nil { return nil } + } + + private static func imageURL(_ raw: String?) -> URL? { + guard let raw, raw.count <= 4_096, let url = URL(string: raw), + ["https", "http", "data"].contains(url.scheme?.lowercased() ?? "") else { return nil } + return url + } +} diff --git a/apps/swift-ios/Core/UsageLimitsModels.swift b/apps/swift-ios/Core/UsageLimitsModels.swift new file mode 100644 index 000000000000..03b3300d7e40 --- /dev/null +++ b/apps/swift-ios/Core/UsageLimitsModels.swift @@ -0,0 +1,156 @@ +import Foundation + +public struct ServerProviderUsageWindow: Codable, Identifiable, Equatable, Sendable { + public enum Kind: String, Codable, CaseIterable, Sendable { + case session + case weekly + case monthly + case other + } + + public let id: String + public let kind: Kind + public let label: String + public let usedPercent: Double + public let resetsAt: String? + public let windowDurationMins: Int? + + public init( + id: String, + kind: Kind, + label: String, + usedPercent: Double, + resetsAt: String? = nil, + windowDurationMins: Int? = nil + ) { + self.id = id + self.kind = kind + self.label = label + self.usedPercent = usedPercent + self.resetsAt = resetsAt + self.windowDurationMins = windowDurationMins + } +} + +public struct ServerProviderResetCredits: Codable, Equatable, Sendable { + public let availableCount: Int + public let nextExpiresAt: String? + + public init(availableCount: Int, nextExpiresAt: String? = nil) { + self.availableCount = availableCount + self.nextExpiresAt = nextExpiresAt + } +} + +public struct ServerProviderUsageLimits: Codable, Equatable, Sendable { + public struct Unavailable: Codable, Equatable, Sendable { + public enum Reason: String, Codable, CaseIterable, Sendable { + case unsupported + case probeFailed + } + + public let reason: Reason + public let message: String? + + public init(reason: Reason, message: String? = nil) { + self.reason = reason + self.message = message + } + } + + public let checkedAt: String + @ForwardCompatibleArray public var windows: [ServerProviderUsageWindow] + public let resetCredits: ServerProviderResetCredits? + public let unavailable: Unavailable? + + public init( + checkedAt: String, + windows: [ServerProviderUsageWindow], + resetCredits: ServerProviderResetCredits? = nil, + unavailable: Unavailable? = nil + ) { + self.checkedAt = checkedAt + self.windows = windows + self.resetCredits = resetCredits + self.unavailable = unavailable + } + + private enum CodingKeys: String, CodingKey { + case checkedAt, windows, resetCredits, unavailable + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + checkedAt = try container.decode(String.self, forKey: .checkedAt) + _windows = try container.decode(ForwardCompatibleArray.self, forKey: .windows) + resetCredits = try container.decodeIfPresent(ServerProviderResetCredits.self, forKey: .resetCredits) + // A new limits notice must not remove the provider from the config. + unavailable = try? container.decodeIfPresent(Unavailable.self, forKey: .unavailable) + } +} + +public struct UsageLimitSourceAccount: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let driver: String + public let email: String? + public let plan: String? + public let usageLimits: ServerProviderUsageLimits + + public init( + id: String, + driver: String, + email: String? = nil, + plan: String? = nil, + usageLimits: ServerProviderUsageLimits + ) { + self.id = id + self.driver = driver + self.email = email + self.plan = plan + self.usageLimits = usageLimits + } +} + +public struct UsageLimitSourceSnapshot: Codable, Identifiable, Equatable, Sendable { + public enum Kind: String, Codable, CaseIterable, Sendable { + case cliproxy + } + + public let id: String + public let kind: Kind + public let label: String + public let checkedAt: String + @ForwardCompatibleArray public var accounts: [UsageLimitSourceAccount] + public let error: String? + + public init( + id: String, + kind: Kind = .cliproxy, + label: String, + checkedAt: String, + accounts: [UsageLimitSourceAccount], + error: String? = nil + ) { + self.id = id + self.kind = kind + self.label = label + self.checkedAt = checkedAt + self.accounts = accounts + self.error = error + } +} + +public enum ProviderConsumeResetCreditOutcome: String, Codable, CaseIterable, Sendable { + case reset + case nothingToReset + case noCredit + case alreadyRedeemed +} + +public struct ProviderConsumeResetCreditResult: Codable, Equatable, Sendable { + public let outcome: ProviderConsumeResetCreditOutcome + + public init(outcome: ProviderConsumeResetCreditOutcome) { + self.outcome = outcome + } +} diff --git a/apps/swift-ios/Core/UsageWireModels.swift b/apps/swift-ios/Core/UsageWireModels.swift new file mode 100644 index 000000000000..2fec4ddd5d15 --- /dev/null +++ b/apps/swift-ios/Core/UsageWireModels.swift @@ -0,0 +1,160 @@ +import Foundation + +public let usageContractVersion = 5 +public let minimumCompatibleUsageContractVersion = 3 + +/// Version 3 supports daily totals. Hourly windows require version 4 or later. +public func isCompatibleUsageContractVersion( + _ version: Int, + resolution: UsageResolution? = nil +) -> Bool { + let minimum = resolution == .hour ? 4 : minimumCompatibleUsageContractVersion + return (minimum ... usageContractVersion).contains(version) +} + +public enum UsageProviderKind: String, Codable, CaseIterable, Sendable { + case codex + case claude + case grok + + public var displayName: String { + switch self { + case .codex: "Codex" + case .claude: "Claude Code" + case .grok: "Grok Build" + } + } +} + +public enum UsageCostSource: String, Codable, Sendable { + case providerReported + case modelPriced + case unpriced +} + +public enum UsageResolution: String, Codable, Equatable, Sendable { + case day + case hour +} + +public struct UsageSummaryInput: Codable, Equatable, Sendable { + public let sinceDay: String + public let untilDay: String + public let timeZone: String + public let resolution: UsageResolution? + public let sinceTime: String? + public let untilTime: String? + + public init( + sinceDay: String, + untilDay: String, + timeZone: String, + resolution: UsageResolution? = nil, + sinceTime: String? = nil, + untilTime: String? = nil + ) { + self.sinceDay = sinceDay + self.untilDay = untilDay + self.timeZone = timeZone + self.resolution = resolution + self.sinceTime = sinceTime + self.untilTime = untilTime + } +} + +public struct UsageTokenTotals: Codable, Equatable, Sendable { + public let uncachedInputTokens: Int + public let cachedInputTokens: Int + public let cacheCreationTokens: Int + public let outputTokens: Int + public let reasoningTokens: Int +} + +public struct UsageBucket: Codable, Equatable, Sendable { + public let day: String + public let hourStart: String? + public let provider: UsageProviderKind + public let model: String + public let totals: UsageTokenTotals + public let costUsd: Double + public let cacheSavingsUsd: Double + public let costSource: UsageCostSource + public let records: Int + public let unpricedRecords: Int + public let sessions: Int + + public init( + day: String, + hourStart: String? = nil, + provider: UsageProviderKind, + model: String, + totals: UsageTokenTotals, + costUsd: Double, + cacheSavingsUsd: Double, + costSource: UsageCostSource, + records: Int, + unpricedRecords: Int, + sessions: Int + ) { + self.day = day + self.hourStart = hourStart + self.provider = provider + self.model = model + self.totals = totals + self.costUsd = costUsd + self.cacheSavingsUsd = cacheSavingsUsd + self.costSource = costSource + self.records = records + self.unpricedRecords = unpricedRecords + self.sessions = sessions + } +} + +public struct UsageSourceFingerprint: Codable, Equatable, Hashable, Sendable { + public let hostId: String + public let provider: UsageProviderKind + public let resolvedHomePath: String + public let volumeId: String +} + +public enum UsageSourceStatus: String, Codable, Sendable { + case ok + case missing + case partial + case failed +} + +public struct UsageSource: Codable, Equatable, Sendable { + public let fingerprint: UsageSourceFingerprint + public let status: UsageSourceStatus + public let scannedFiles: Int + public let skippedFiles: Int + public let malformedRecords: Int + public let distinctSessions: Int + public let message: String? +} + +public enum UsagePricingStatus: String, Codable, Sendable { + case fresh + case cached + case unavailable +} + +public struct UsagePricing: Codable, Equatable, Sendable { + public let status: UsagePricingStatus + public let source: String + public let fetchedAt: String? + public let knownModels: Int +} + +public struct UsageSummary: Codable, Equatable, Sendable { + public let contractVersion: Int + public let readAt: String + public let timeZone: String + public let sinceDay: String + public let untilDay: String + public let buckets: [UsageBucket] + public let sources: [UsageSource] + public let pricing: UsagePricing + public let scanDurationMs: Int +} diff --git a/apps/swift-ios/Core/WebSocketRPC.swift b/apps/swift-ios/Core/WebSocketRPC.swift new file mode 100644 index 000000000000..39aff95ac2c7 --- /dev/null +++ b/apps/swift-ios/Core/WebSocketRPC.swift @@ -0,0 +1,942 @@ +import Foundation +import OSLog + +public protocol WebSocketConnection: Sendable { + func send(_ data: Data) async throws + func receive() async throws -> Data + func close() async +} + +public protocol WebSocketConnecting: Sendable { + func connect(to url: URL) async throws -> any WebSocketConnection +} + +public enum WebSocketHandshakeRequest { + public static let perMessageDeflateOffer = "permessage-deflate; client_max_window_bits" + + /// URLSessionWebSocketTask accepts a URLRequest for its opening handshake. + /// It does not expose the 101 response headers or negotiated extensions, + /// so callers can know that compression was offered, not prove that a + /// particular connection accepted it. + public static func make( + url: URL, + offersPerMessageDeflate: Bool = true + ) -> URLRequest { + var request = URLRequest(url: url) + if offersPerMessageDeflate { + request.setValue( + perMessageDeflateOffer, + forHTTPHeaderField: "Sec-WebSocket-Extensions" + ) + } + return request + } +} + +public struct URLSessionWebSocketConnector: WebSocketConnecting { + private let session: URLSession + private let offersPerMessageDeflate: Bool + + public init( + session: URLSession = .shared, + offersPerMessageDeflate: Bool = true + ) { + self.session = session + self.offersPerMessageDeflate = offersPerMessageDeflate + } + + public func connect(to url: URL) async throws -> any WebSocketConnection { + let request = WebSocketHandshakeRequest.make( + url: url, + offersPerMessageDeflate: offersPerMessageDeflate + ) + let connection = URLSessionWebSocketConnection(session: session, request: request) + await connection.open() + return connection + } +} + +private actor URLSessionWebSocketConnection: WebSocketConnection { + private let task: URLSessionWebSocketTask + + init(session: URLSession, request: URLRequest) { + task = session.webSocketTask(with: request) + } + + func open() { + task.resume() + } + + func send(_ data: Data) async throws { + try await task.send(.data(data)) + } + + func receive() async throws -> Data { + switch try await task.receive() { + case let .data(data): + return data + case let .string(string): + guard let data = string.data(using: .utf8) else { + throw RPCError.protocolViolation("WebSocket text was not UTF-8.") + } + return data + @unknown default: + throw RPCError.protocolViolation("Unknown WebSocket message.") + } + } + + func close() { + task.cancel(with: .goingAway, reason: nil) + } +} + +public enum RPCError: LocalizedError, Sendable { + case connectionUnavailable + case disconnected + case responseTimedOut + case remote(String) + case protocolViolation(String) + + public var errorDescription: String? { + switch self { + case .connectionUnavailable: + "The live command connection is unavailable." + case .disconnected: "The environment disconnected." + case .responseTimedOut: "The environment did not answer the command in time." + case let .remote(message): message + case let .protocolViolation(message): message + } + } +} + +private struct RPCRequestEnvelope: Encodable, Sendable { + let _tag = "Request" + let id: Int + let tag: String + let payload: JSONValue + let headers: [[String]] +} + +private struct RPCControlEnvelope: Encodable, Sendable { + let _tag: String + let requestId: Int? + + init(_ tag: String, requestID: Int? = nil) { + _tag = tag + requestId = requestID + } +} + +private struct RPCResponseEnvelope: Decodable, Sendable { + struct Exit: Decodable, Sendable { + struct Cause: Decodable, Sendable { + let _tag: String + let error: JSONValue? + let defect: JSONValue? + } + + let _tag: String + let value: JSONValue? + let cause: [Cause]? + } + + let _tag: String + let requestId: Int? + let values: [JSONValue]? + let exit: Exit? + let defect: JSONValue? +} + +/// Implements Effect RPC's JSON socket framing. Subscriptions survive +/// reconnects; unary calls that crossed a broken connection fail rather than +/// being replayed, because replaying a command could duplicate side effects. +public actor WebSocketRPCClient { + public typealias EndpointProvider = @Sendable () async throws -> URL + + private static let logger = Logger( + subsystem: "com.t3tools.t3code", + category: "WebSocketRPC" + ) + + private struct UnaryRequest { + let envelope: RPCRequestEnvelope + var sent: Bool + var connectionWaitTask: Task? + var sendDeadlineTask: Task? + var responseDeadlineTask: Task? + let resume: @Sendable (Result) -> Void + } + + private enum SubscriptionYieldResult: Sendable { + case enqueued + case dropped + case terminated + } + + private struct Subscription { + let tag: String + let payload: JSONValue + let reconnect: Bool + var requestID: Int? + /// The connection that assigned `requestID`. Request IDs are reissued + /// after reconnects, so an Interrupt is only valid on this connection. + var requestConnectionID: UUID? + let yield: @Sendable (JSONValue) -> SubscriptionYieldResult + let finish: @Sendable (Error?) -> Void + } + + private struct ConnectionAttempt: Sendable { + let connector: any WebSocketConnecting + let endpointProvider: EndpointProvider + } + + /// Long-lived tasks retain this box, never the client. Each actor hop + /// briefly promotes the weak reference, then releases it before the next + /// socket receive or reconnect wait. + private final class WeakOwner: @unchecked Sendable { + weak var value: WebSocketRPCClient? + + init(_ value: WebSocketRPCClient) { + self.value = value + } + + func connectionAttempt(loopID: UUID) async -> ConnectionAttempt? { + guard let value else { return nil } + return await value.connectionAttempt(loopID: loopID) + } + + func isCurrentConnectionLoop(_ loopID: UUID) async -> Bool { + guard let value else { return false } + return await value.isCurrentConnectionLoop(loopID) + } + + func installConnection( + _ connection: any WebSocketConnection, + loopID: UUID + ) async -> UUID? { + guard let value else { return nil } + return await value.installConnection(connection, loopID: loopID) + } + + func ownsConnection(loopID: UUID, connectionID: UUID) async -> Bool { + guard let value else { return false } + return await value.ownsConnection(loopID: loopID, connectionID: connectionID) + } + + func handle(_ data: Data, connectionID: UUID) async throws -> Bool { + guard let value else { return false } + return try await value.handle(data, expectedConnectionID: connectionID) + } + + func disconnected(connectionID: UUID, subscriptionError: RPCError? = nil) async -> Bool { + guard let value else { return false } + return await value.disconnected( + expectedConnectionID: connectionID, + subscriptionError: subscriptionError ?? .disconnected + ) + } + + func finishConnectionLoop(_ loopID: UUID) async { + guard let value else { return } + await value.finishConnectionLoop(loopID) + } + + func sendKeepalive(connectionID: UUID) async -> Bool { + guard let value else { return false } + return await value.sendKeepalive(expectedConnectionID: connectionID) + } + + func reconnectDelay(failureCount: Int, loopID: UUID) async -> Duration? { + guard let value else { return nil } + return await value.reconnectDelay(failureCount: failureCount, loopID: loopID) + } + } + + private let connector: any WebSocketConnecting + private let endpointProvider: EndpointProvider + private let connectionWaitTimeout: Duration + private let responseTimeout: Duration + private let keepaliveInterval: Duration + private let subscriptionBufferLimit: Int + private let reconnectBackoff: @Sendable (Int) -> Duration + private var connection: (any WebSocketConnection)? + private var connectionID: UUID? + private var loopTask: Task? + private var loopID: UUID? + private var keepaliveTask: Task? + private var desired = false + private var nextRequestID = 1 + private var unary: [Int: UnaryRequest] = [:] + private var subscriptions: [UUID: Subscription] = [:] + private var subscriptionByRequestID: [Int: UUID] = [:] + private var awaitingKeepaliveResponse = false + private var connectionWaiters: [UUID: (previous: UUID?, continuation: CheckedContinuation)] = [:] + + public init( + connector: any WebSocketConnecting = URLSessionWebSocketConnector(), + connectionWaitTimeout: Duration = .seconds(4), + responseTimeout: Duration = .seconds(30), + keepaliveInterval: Duration = .seconds(5), + subscriptionBufferLimit: Int = 128, + reconnectBackoff: @escaping @Sendable (Int) -> Duration = { failureCount in + // Jitter desynchronizes reconnects across environments so a + // server restart doesn't trigger simultaneous ticket mints. + let backoff = min(5.0, 0.35 * pow(1.7, Double(failureCount - 1))) + return .seconds(backoff * Double.random(in: 0.5...1.0)) + }, + endpointProvider: @escaping EndpointProvider + ) { + self.connector = connector + self.connectionWaitTimeout = connectionWaitTimeout + self.responseTimeout = responseTimeout + self.keepaliveInterval = keepaliveInterval > .zero ? keepaliveInterval : .seconds(5) + self.subscriptionBufferLimit = max(1, subscriptionBufferLimit) + self.reconnectBackoff = reconnectBackoff + self.endpointProvider = endpointProvider + } + + deinit { + loopTask?.cancel() + keepaliveTask?.cancel() + } + + public func start() { + desired = true + guard loopTask == nil else { return } + let id = UUID() + loopID = id + let owner = WeakOwner(self) + loopTask = Task { + await Self.connectionLoop(owner: owner, id: id) + } + } + + public func isConnected() -> Bool { + connection != nil + } + + public func currentConnectionID() -> UUID? { + connectionID + } + + /// Waits without polling after a subscription cannot use its current socket. + public func waitForConnection(after previous: UUID?) async throws -> UUID { + try Task.checkCancellation() + if let connectionID, connectionID != previous { return connectionID } + guard desired else { throw RPCError.disconnected } + let waiterID = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + connectionWaiters[waiterID] = (previous, continuation) + } + } + } onCancel: { + Task { await self.cancelConnectionWaiter(waiterID) } + } + } + + private func cancelConnectionWaiter(_ id: UUID) { + connectionWaiters.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError()) + } + + /// Replaces a socket after suspension without replaying sent commands. + /// Resumable subscriptions survive; one-shot owners choose their next cursor. + public func reconnect() async { + guard desired else { return } + loopID = nil + loopTask?.cancel() + loopTask = nil + await disconnected() + start() + } + + public func stop() async { + let closingConnection = connection + desired = false + loopID = nil + loopTask?.cancel() + loopTask = nil + keepaliveTask?.cancel() + keepaliveTask = nil + awaitingKeepaliveResponse = false + connection = nil + connectionID = nil + let waiting = connectionWaiters.values + connectionWaiters.removeAll() + waiting.forEach { $0.continuation.resume(throwing: RPCError.disconnected) } + failUnary(RPCError.disconnected, includingUnsent: true) + let active = Array(subscriptions.values) + subscriptions.removeAll() + subscriptionByRequestID.removeAll() + active.forEach { $0.finish(RPCError.disconnected) } + // Publish the stopped state before suspension. A new start while the + // old socket closes owns independent state and must survive this call. + await closingConnection?.close() + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]), + as type: Result.Type + ) async throws -> Result { + let raw = try await requestRaw(tag, payload: payload) + return try raw.decode(type) + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]) + ) async throws { + _ = try await requestRaw(tag, payload: payload) + } + + public func subscribe( + _ tag: String, + payload: JSONValue = .object([:]), + reconnect: Bool = true, + as type: Value.Type + ) -> AsyncThrowingStream { + let subscriptionID = UUID() + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(subscriptionBufferLimit)) { + continuation in + subscriptions[subscriptionID] = Subscription( + tag: tag, + payload: payload, + reconnect: reconnect, + requestID: nil, + yield: { value in + do { + switch continuation.yield(try value.decode(type)) { + case .enqueued: + return .enqueued + case .dropped: + return .dropped + case .terminated: + return .terminated + @unknown default: + return .dropped + } + } catch { + continuation.finish(throwing: error) + return .terminated + } + }, + finish: { error in + if let error { + continuation.finish(throwing: error) + } else { + continuation.finish() + } + } + ) + continuation.onTermination = { @Sendable _ in + Task { await self.removeSubscription(subscriptionID) } + } + if connection != nil { + Task { await self.sendSubscription(subscriptionID) } + } + start() + } + } + + /// Registers the stream and its socket identity in one actor turn. A cold + /// subscriber must not mistake its first failed socket for a replacement. + public func subscribeOnCurrentConnection( + _ tag: String, + payload: JSONValue = .object([:]), + as type: Value.Type + ) async throws -> (events: AsyncThrowingStream, connectionID: UUID) { + try Task.checkCancellation() + start() + while true { + try Task.checkCancellation() + if let id = connectionID { + return ( + subscribe(tag, payload: payload, reconnect: false, as: type), + id + ) + } + _ = try await waitForConnection(after: nil) + } + } + + private func requestRaw(_ tag: String, payload: JSONValue) async throws -> JSONValue { + start() + let id = allocateRequestID() + let envelope = RPCRequestEnvelope( + id: id, + tag: tag, + payload: payload, + headers: [] + ) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + // If cancellation won the race before onCancel could observe + // an installed request, complete locally and never send it. + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + unary[id] = UnaryRequest( + envelope: envelope, + sent: false, + connectionWaitTask: nil, + sendDeadlineTask: nil, + responseDeadlineTask: nil, + resume: { continuation.resume(with: $0) } + ) + installUnaryDeadlines(id) + if connection != nil { + Task { await self.sendUnary(id) } + } + } + } onCancel: { + Task { await self.cancelUnary(id) } + } + } + + private static func connectionLoop(owner: WeakOwner, id loopID: UUID) async { + var retry = 0 + while await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled { + var openedID: UUID? + var openedConnection: (any WebSocketConnection)? + do { + guard let attempt = await owner.connectionAttempt(loopID: loopID) else { break } + let url = try await attempt.endpointProvider() + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { break } + let opened = try await attempt.connector.connect(to: url) + openedConnection = opened + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { + await opened.close() + break + } + guard let id = await owner.installConnection(opened, loopID: loopID) else { + await opened.close() + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { + break + } + throw RPCError.disconnected + } + openedID = id + logger.info("WebSocket connection installed") + try await withTaskCancellationHandler { + while await owner.ownsConnection(loopID: loopID, connectionID: id), + !Task.isCancelled { + let data = try await opened.receive() + guard try await owner.handle(data, connectionID: id) else { + throw RPCError.disconnected + } + // URLSession's `resume()` does not expose a completed + // WebSocket handshake. A valid inbound frame is the + // first proof that the connection is actually usable. + retry = 0 + } + } onCancel: { + Task { await opened.close() } + } + if !(await owner.disconnected(connectionID: id)) { + await opened.close() + } + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { break } + } catch { + logger.warning( + "WebSocket connection failed: \(String(describing: error), privacy: .private)" + ) + if let openedID { + let protocolError: RPCError? + if error is DecodingError { + protocolError = .protocolViolation("The server sent an invalid live response.") + } else if case let RPCError.protocolViolation(message) = error { + protocolError = .protocolViolation(message) + } else { + protocolError = nil + } + if !(await owner.disconnected(connectionID: openedID, subscriptionError: protocolError)) { + await openedConnection?.close() + } + } else { + await openedConnection?.close() + } + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { break } + retry += 1 + guard let delay = await owner.reconnectDelay( + failureCount: retry, + loopID: loopID + ) else { break } + try? await Task.sleep(for: delay) + } + } + await owner.finishConnectionLoop(loopID) + } + + private func connectionAttempt(loopID: UUID) -> ConnectionAttempt? { + guard isCurrentConnectionLoop(loopID) else { return nil } + return ConnectionAttempt(connector: connector, endpointProvider: endpointProvider) + } + + private func reconnectDelay(failureCount: Int, loopID: UUID) -> Duration? { + guard isCurrentConnectionLoop(loopID) else { return nil } + return reconnectBackoff(failureCount) + } + + private func installConnection( + _ opened: any WebSocketConnection, + loopID: UUID + ) async -> UUID? { + guard isCurrentConnectionLoop(loopID), !Task.isCancelled else { return nil } + let id = UUID() + connection = opened + connectionID = id + await connected() + // Sending queued work during setup is actor-reentrant. A send failure + // can discard this socket before setup completes. + guard isCurrentConnectionLoop(loopID), connectionID == id else { return nil } + let ready = connectionWaiters.filter { $0.value.previous != id } + for (waiterID, waiter) in ready { + connectionWaiters.removeValue(forKey: waiterID) + waiter.continuation.resume(returning: id) + } + return id + } + + private func ownsConnection(loopID: UUID, connectionID: UUID) -> Bool { + isCurrentConnectionLoop(loopID) && self.connectionID == connectionID + } + + private func finishConnectionLoop(_ loopID: UUID) { + guard self.loopID == loopID else { return } + self.loopID = nil + loopTask = nil + } + + private func isCurrentConnectionLoop(_ id: UUID) -> Bool { + desired && loopID == id + } + + private func connected() async { + keepaliveTask?.cancel() + if let connectionID { + let owner = WeakOwner(self) + let interval = keepaliveInterval + keepaliveTask = Task { + await Self.keepaliveLoop(owner: owner, connectionID: connectionID, interval: interval) + } + } + // Snapshot the keys: the sends suspend, and reentrant completions or + // failures mutate these dictionaries mid-iteration. + for id in Array(unary.keys) { + await sendUnary(id) + } + subscriptionByRequestID.removeAll() + for id in Array(subscriptions.keys) { + await sendSubscription(id) + } + } + + @discardableResult + private func disconnected( + expectedConnectionID: UUID? = nil, + subscriptionError: RPCError = .disconnected + ) async -> Bool { + if let expectedConnectionID, connectionID != expectedConnectionID { + return false + } + let closingConnection = connection + keepaliveTask?.cancel() + keepaliveTask = nil + awaitingKeepaliveResponse = false + connection = nil + connectionID = nil + Self.logger.info("WebSocket connection closed") + failUnary(RPCError.disconnected, includingUnsent: false) + subscriptionByRequestID.removeAll() + let oneShotSubscriptions = subscriptions.filter { !$0.value.reconnect } + for (id, subscription) in oneShotSubscriptions { + subscriptions.removeValue(forKey: id) + subscription.finish(subscriptionError) + } + for id in Array(subscriptions.keys) { + subscriptions[id]?.requestID = nil + subscriptions[id]?.requestConnectionID = nil + } + await closingConnection?.close() + return true + } + + private func handle(_ data: Data, expectedConnectionID: UUID) async throws -> Bool { + guard connectionID == expectedConnectionID else { return false } + let response = try JSONDecoder.t3.decode(RPCResponseEnvelope.self, from: data) + awaitingKeepaliveResponse = false + try await handle(response) + return connectionID == expectedConnectionID + } + + private func handle(_ response: RPCResponseEnvelope) async throws { + switch response._tag { + case "Pong": + return + case "Chunk": + guard let requestID = response.requestId, + let subscriptionID = subscriptionByRequestID[requestID], + let subscription = subscriptions[subscriptionID] + else { return } + for value in response.values ?? [] { + switch subscription.yield(value) { + case .enqueued: + continue + case .dropped: + let error = RPCError.protocolViolation( + "The live stream exceeded its buffered event limit." + ) + if !subscription.reconnect { + subscriptionByRequestID.removeValue(forKey: requestID) + subscriptions.removeValue(forKey: subscriptionID) + subscription.finish(error) + } + throw error + case .terminated: + await removeSubscription(subscriptionID) + return + } + } + try await sendControl("Ack", requestID: requestID) + case "Exit": + guard let requestID = response.requestId, let exit = response.exit else { return } + if unary[requestID] != nil { + if exit._tag == "Success" { + completeUnary(requestID, with: .success(exit.value ?? .null)) + } else { + completeUnary(requestID, with: .failure(remoteError(exit))) + } + return + } + guard let subscriptionID = subscriptionByRequestID.removeValue(forKey: requestID), + let subscription = subscriptions.removeValue(forKey: subscriptionID) + else { return } + if exit.cause?.contains(where: { $0._tag == "Die" }) == true { + subscription.finish(RPCError.protocolViolation("The server could not complete the live request.")) + } else { + subscription.finish(exit._tag == "Success" ? nil : remoteError(exit)) + } + case "Defect", "ClientProtocolError": + throw RPCError.protocolViolation("The server reported an RPC protocol error.") + default: + throw RPCError.protocolViolation("Unknown RPC response \(response._tag).") + } + } + + private func sendUnary(_ id: Int) async { + guard let connection, + let connectionID, + var request = unary[id], + !request.sent else { return } + // Actor methods are reentrant at the send below. Record that this + // command crossed the socket boundary first so a concurrent + // disconnect fails it instead of replaying an ambiguous mutation. + request.sent = true + unary[id] = request + startUnarySendDeadline(id, connectionID: connectionID) + do { + try await connection.send(JSONEncoder.t3.encode(request.envelope)) + startUnaryResponseDeadline(id) + } catch { + // A response, disconnect, or stop may have completed the request + // while send was suspended. Only its current owner may resume it. + completeUnary(id, with: .failure(RPCError.disconnected)) + await disconnected(expectedConnectionID: connectionID) + } + } + + private func sendSubscription(_ subscriptionID: UUID) async { + guard let connection, + let connectionID, + var subscription = subscriptions[subscriptionID], + subscription.requestID == nil else { return } + let requestID = allocateRequestID() + let envelope = RPCRequestEnvelope( + id: requestID, + tag: subscription.tag, + payload: subscription.payload, + headers: [] + ) + // Install ownership before suspending in send. A very fast response can + // otherwise arrive before the request is routable, while termination + // during the send must be able to remove the exact in-flight mapping. + subscription.requestID = requestID + subscription.requestConnectionID = connectionID + subscriptions[subscriptionID] = subscription + subscriptionByRequestID[requestID] = subscriptionID + do { + try await connection.send(JSONEncoder.t3.encode(envelope)) + } catch { + // Retain the subscription for the next socket, but make the send + // failure visible to the connection loop by closing this socket. + await disconnected(expectedConnectionID: connectionID) + } + } + + private func removeSubscription(_ id: UUID) async { + guard let subscription = subscriptions.removeValue(forKey: id) else { return } + if let requestID = subscription.requestID { + subscriptionByRequestID.removeValue(forKey: requestID) + // A termination racing a reconnect must not interrupt whichever + // subscription now owns this request ID on the new connection. + if subscription.requestConnectionID == connectionID { + try? await sendControl("Interrupt", requestID: requestID) + } + } + } + + private static func keepaliveLoop( + owner: WeakOwner, + connectionID: UUID, + interval: Duration + ) async { + while !Task.isCancelled { + try? await Task.sleep(for: interval) + guard !Task.isCancelled, + await owner.sendKeepalive(connectionID: connectionID) else { return } + } + } + + private func sendKeepalive(expectedConnectionID: UUID) async -> Bool { + guard desired, connectionID == expectedConnectionID, connection != nil else { + return false + } + if awaitingKeepaliveResponse { + await disconnected(expectedConnectionID: expectedConnectionID) + return false + } + do { + awaitingKeepaliveResponse = true + try await sendControl("Ping", requestID: nil) + return connectionID == expectedConnectionID + } catch { + return false + } + } + + private func sendControl(_ tag: String, requestID: Int?) async throws { + guard let connection, let connectionID else { throw RPCError.disconnected } + do { + try await connection.send( + JSONEncoder.t3.encode(RPCControlEnvelope(tag, requestID: requestID)) + ) + } catch { + await disconnected(expectedConnectionID: connectionID) + throw RPCError.disconnected + } + } + + private func failUnary(_ error: Error, includingUnsent: Bool) { + let failedIDs = unary.compactMap { id, request in + includingUnsent || request.sent ? id : nil + } + for id in failedIDs { + completeUnary(id, with: .failure(error)) + } + } + + private func failUnaryIfUnsent(_ id: Int) { + guard let request = unary[id], !request.sent else { return } + completeUnary(id, with: .failure(RPCError.connectionUnavailable)) + } + + private func installUnaryDeadlines(_ id: Int) { + guard var request = unary[id] else { return } + let connectionWaitTimeout = connectionWaitTimeout + request.connectionWaitTask = Task { [weak self] in + do { + try await Task.sleep(for: connectionWaitTimeout) + } catch { + return + } + await self?.failUnaryIfUnsent(id) + } + unary[id] = request + } + + private func startUnaryResponseDeadline(_ id: Int) { + guard var request = unary[id], request.sent else { return } + request.connectionWaitTask?.cancel() + request.connectionWaitTask = nil + request.sendDeadlineTask?.cancel() + request.sendDeadlineTask = nil + let responseTimeout = responseTimeout + request.responseDeadlineTask = Task { [weak self] in + do { + try await Task.sleep(for: responseTimeout) + } catch { + return + } + await self?.failUnaryOnResponseDeadline(id) + } + unary[id] = request + } + + private func startUnarySendDeadline(_ id: Int, connectionID: UUID) { + guard var request = unary[id], request.sent else { return } + request.connectionWaitTask?.cancel() + request.connectionWaitTask = nil + let sendTimeout = responseTimeout + request.sendDeadlineTask = Task { [weak self] in + do { + try await Task.sleep(for: sendTimeout) + } catch { + return + } + await self?.failUnaryOnSendDeadline(id, connectionID: connectionID) + } + unary[id] = request + } + + private func failUnaryOnSendDeadline(_ id: Int, connectionID: UUID) async { + guard let request = unary[id], + request.sent, + request.responseDeadlineTask == nil else { return } + completeUnary(id, with: .failure(RPCError.responseTimedOut)) + await disconnected(expectedConnectionID: connectionID) + } + + private func failUnaryOnResponseDeadline(_ id: Int) async { + guard let request = unary[id] else { return } + let sent = request.sent + completeUnary(id, with: .failure(RPCError.responseTimedOut)) + if sent { + try? await sendControl("Interrupt", requestID: id) + } + } + + private func cancelUnary(_ id: Int) async { + guard let request = unary[id] else { return } + let sent = request.sent + completeUnary(id, with: .failure(CancellationError())) + if sent { + try? await sendControl("Interrupt", requestID: id) + } + } + + private func completeUnary(_ id: Int, with result: Result) { + guard let request = unary.removeValue(forKey: id) else { return } + request.connectionWaitTask?.cancel() + request.sendDeadlineTask?.cancel() + request.responseDeadlineTask?.cancel() + request.resume(result) + } + + private func remoteError(_ exit: RPCResponseEnvelope.Exit) -> RPCError { + let value = exit.cause?.first?.error + let message = value?["message"]?.stringValue + ?? value?["detail"]?.stringValue + ?? "The environment rejected the RPC request." + return .remote(message) + } + + private func allocateRequestID() -> Int { + defer { nextRequestID += 1 } + return nextRequestID + } +} diff --git a/apps/swift-ios/Core/WorkspaceModels.swift b/apps/swift-ios/Core/WorkspaceModels.swift new file mode 100644 index 000000000000..1d4eaeb5d3bf --- /dev/null +++ b/apps/swift-ios/Core/WorkspaceModels.swift @@ -0,0 +1,576 @@ +import Foundation + +// MARK: - Project files + +public enum ProjectEntryKind: String, Codable, Sendable { + case file + case directory +} + +public struct ProjectEntry: Codable, Equatable, Sendable { + public let path: String + public let kind: ProjectEntryKind +} + +public struct ProjectEntriesResult: Codable, Equatable, Sendable { + public let entries: [ProjectEntry] + public let truncated: Bool +} + +public struct ProjectReadFileResult: Codable, Equatable, Sendable { + public let relativePath: String + public let contents: String + public let byteLength: Int + public let truncated: Bool +} + +public struct ProjectWriteFileResult: Codable, Equatable, Sendable { + public let relativePath: String +} + +public struct ThreadWorktreePreparation: Equatable, Sendable { + public let projectCwd: String + public let baseBranch: String + public let branch: String + public let startFromOrigin: Bool + + public init( + projectCwd: String, + baseBranch: String, + branch: String, + startFromOrigin: Bool + ) { + self.projectCwd = projectCwd + self.baseBranch = baseBranch + self.branch = branch + self.startFromOrigin = startFromOrigin + } +} + +public struct FilesystemBrowseEntry: Codable, Equatable, Sendable { + public let name: String + public let fullPath: String +} + +public struct FilesystemBrowseResult: Codable, Equatable, Sendable { + public let parentPath: String + public let entries: [FilesystemBrowseEntry] +} + +// MARK: - Source control and VCS + +public enum SourceControlProviderKind: String, Codable, CaseIterable, Sendable { + case github + case gitlab + case azureDevOps = "azure-devops" + case bitbucket + case unknown +} + +public struct SourceControlProviderInfo: Codable, Equatable, Sendable { + public let kind: SourceControlProviderKind + public let name: String + public let baseUrl: String +} + +public struct SourceControlRepositoryInfo: Codable, Equatable, Sendable { + public let provider: SourceControlProviderKind + public let nameWithOwner: String + public let url: String + public let sshUrl: String +} + +public struct SourceControlCloneResult: Codable, Equatable, Sendable { + public let cwd: String + public let remoteUrl: String + public let repository: SourceControlRepositoryInfo? +} + +public struct SourceControlPublishResult: Codable, Equatable, Sendable { + public let repository: SourceControlRepositoryInfo + public let remoteName: String + public let remoteUrl: String + public let branch: String + public let upstreamBranch: String? + public let status: String +} + +public enum SourceControlDiscoveryStatus: String, Codable, Sendable { + case available + case missing +} + +public enum SourceControlProviderAuthStatus: String, Codable, Sendable { + case authenticated + case unauthenticated + case unknown +} + +public struct SourceControlProviderAuth: Decodable, Equatable, Sendable { + public let status: SourceControlProviderAuthStatus + public let account: String? + public let host: String? + public let detail: String? + + private enum CodingKeys: String, CodingKey { + case status + case account + case host + case detail + } + + public init( + status: SourceControlProviderAuthStatus, + account: String? = nil, + host: String? = nil, + detail: String? = nil + ) { + self.status = status + self.account = account + self.host = host + self.detail = detail + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + status = try container.decode(SourceControlProviderAuthStatus.self, forKey: .status) + account = try container.decodeEffectOptionalString(forKey: .account) + host = try container.decodeEffectOptionalString(forKey: .host) + detail = try container.decodeEffectOptionalString(forKey: .detail) + } +} + +public struct SourceControlVCSDiscoveryItem: Decodable, Equatable, Sendable { + public let kind: String + public let label: String + public let executable: String? + public let implemented: Bool + public let status: SourceControlDiscoveryStatus + public let version: String? + public let installHint: String + public let detail: String? + + private enum CodingKeys: String, CodingKey { + case kind + case label + case executable + case implemented + case status + case version + case installHint + case detail + } + + public init( + kind: String, + label: String, + executable: String? = nil, + implemented: Bool, + status: SourceControlDiscoveryStatus, + version: String? = nil, + installHint: String, + detail: String? = nil + ) { + self.kind = kind + self.label = label + self.executable = executable + self.implemented = implemented + self.status = status + self.version = version + self.installHint = installHint + self.detail = detail + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + label = try container.decode(String.self, forKey: .label) + executable = try container.decodeIfPresent(String.self, forKey: .executable) + implemented = try container.decode(Bool.self, forKey: .implemented) + status = try container.decode(SourceControlDiscoveryStatus.self, forKey: .status) + version = try container.decodeEffectOptionalString(forKey: .version) + installHint = try container.decode(String.self, forKey: .installHint) + detail = try container.decodeEffectOptionalString(forKey: .detail) + } +} + +public struct SourceControlProviderDiscoveryItem: Decodable, Equatable, Sendable { + public let kind: SourceControlProviderKind + public let label: String + public let executable: String? + public let status: SourceControlDiscoveryStatus + public let version: String? + public let installHint: String + public let detail: String? + public let auth: SourceControlProviderAuth + + private enum CodingKeys: String, CodingKey { + case kind + case label + case executable + case status + case version + case installHint + case detail + case auth + } + + public init( + kind: SourceControlProviderKind, + label: String, + executable: String? = nil, + status: SourceControlDiscoveryStatus, + version: String? = nil, + installHint: String, + detail: String? = nil, + auth: SourceControlProviderAuth + ) { + self.kind = kind + self.label = label + self.executable = executable + self.status = status + self.version = version + self.installHint = installHint + self.detail = detail + self.auth = auth + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(SourceControlProviderKind.self, forKey: .kind) + label = try container.decode(String.self, forKey: .label) + executable = try container.decodeIfPresent(String.self, forKey: .executable) + status = try container.decode(SourceControlDiscoveryStatus.self, forKey: .status) + version = try container.decodeEffectOptionalString(forKey: .version) + installHint = try container.decode(String.self, forKey: .installHint) + detail = try container.decodeEffectOptionalString(forKey: .detail) + auth = try container.decode(SourceControlProviderAuth.self, forKey: .auth) + } +} + +public struct SourceControlDiscoveryResult: Decodable, Equatable, Sendable { + public let versionControlSystems: [SourceControlVCSDiscoveryItem] + public let sourceControlProviders: [SourceControlProviderDiscoveryItem] + + public init( + versionControlSystems: [SourceControlVCSDiscoveryItem], + sourceControlProviders: [SourceControlProviderDiscoveryItem] + ) { + self.versionControlSystems = versionControlSystems + self.sourceControlProviders = sourceControlProviders + } +} + +private struct EffectOptionalString: Decodable { + let value: String? + + private enum CodingKeys: String, CodingKey { + case _tag + case value + } + + init(from decoder: any Decoder) throws { + let singleValue = try decoder.singleValueContainer() + if singleValue.decodeNil() { + value = nil + return + } + if let direct = try? singleValue.decode(String.self) { + value = direct + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: ._tag) { + case "Some": + value = try container.decode(String.self, forKey: .value) + case "None": + value = nil + case let tag: + throw DecodingError.dataCorruptedError( + forKey: ._tag, + in: container, + debugDescription: "Unknown Effect Option tag \(tag)" + ) + } + } +} + +private extension KeyedDecodingContainer { + func decodeEffectOptionalString(forKey key: Key) throws -> String? { + guard contains(key), try !decodeNil(forKey: key) else { return nil } + return try decode(EffectOptionalString.self, forKey: key).value + } +} + +public struct VCSWorkingTreeFile: Codable, Equatable, Sendable { + public let path: String + public let insertions: Int + public let deletions: Int +} + +public struct VCSWorkingTree: Codable, Equatable, Sendable { + public let files: [VCSWorkingTreeFile] + public let insertions: Int + public let deletions: Int +} + +public struct VCSChangeRequest: Codable, Equatable, Sendable { + public let number: Int + public let title: String + public let url: String + public let baseRef: String + public let headRef: String + public let state: String + public var updatedAt: String? = nil +} + +public struct VCSLocalStatus: Codable, Equatable, Sendable { + public let isRepo: Bool + public let sourceControlProvider: SourceControlProviderInfo? + public let hasPrimaryRemote: Bool + public let isDefaultRef: Bool + public let refName: String? + public let hasWorkingTreeChanges: Bool + public let workingTree: VCSWorkingTree +} + +public struct VCSRemoteStatus: Codable, Equatable, Sendable { + public let hasUpstream: Bool + public let aheadCount: Int + public let behindCount: Int + public let aheadOfDefaultCount: Int? + public let pr: VCSChangeRequest? +} + +public struct VCSStatus: Codable, Equatable, Sendable { + public let isRepo: Bool + public let sourceControlProvider: SourceControlProviderInfo? + public let hasPrimaryRemote: Bool + public let isDefaultRef: Bool + public let refName: String? + public let hasWorkingTreeChanges: Bool + public let workingTree: VCSWorkingTree + public let hasUpstream: Bool + public let aheadCount: Int + public let behindCount: Int + public let aheadOfDefaultCount: Int? + public let pr: VCSChangeRequest? +} + +public enum VCSStatusEvent: Decodable, Sendable { + case snapshot(local: VCSLocalStatus, remote: VCSRemoteStatus?) + case localUpdated(VCSLocalStatus) + case remoteUpdated(VCSRemoteStatus?) + + private enum CodingKeys: String, CodingKey { case _tag, local, remote } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let tag = try container.decode(String.self, forKey: ._tag) + switch tag { + case "snapshot": + self = .snapshot( + local: try container.decode(VCSLocalStatus.self, forKey: .local), + remote: try container.decodeIfPresent(VCSRemoteStatus.self, forKey: .remote) + ) + case "localUpdated": + self = .localUpdated(try container.decode(VCSLocalStatus.self, forKey: .local)) + case "remoteUpdated": + self = .remoteUpdated( + try container.decodeIfPresent(VCSRemoteStatus.self, forKey: .remote) + ) + default: + throw DecodingError.dataCorruptedError( + forKey: ._tag, + in: container, + debugDescription: "Unknown VCS status event \(tag)" + ) + } + } +} + +public struct VCSRef: Codable, Equatable, Sendable { + public let name: String + public let isRemote: Bool? + public let remoteName: String? + public let current: Bool + public let isDefault: Bool + public let worktreePath: String? +} + +public struct VCSRefsResult: Codable, Equatable, Sendable { + public let refs: [VCSRef] + public let isRepo: Bool + public let hasPrimaryRemote: Bool + public let nextCursor: Int? + public let totalCount: Int +} + +public struct VCSPullResult: Codable, Equatable, Sendable { + public let status: String + public let refName: String + public let upstreamRef: String? +} + +public struct VCSCreateRefResult: Codable, Equatable, Sendable { + public let refName: String +} + +public struct VCSSwitchRefResult: Codable, Equatable, Sendable { + public let refName: String? +} + +public struct VCSWorktree: Codable, Equatable, Sendable { + public let path: String + public let refName: String +} + +public struct VCSCreateWorktreeResult: Codable, Equatable, Sendable { + public let worktree: VCSWorktree +} + +public enum GitStackedAction: String, Codable, CaseIterable, Sendable { + case commit + case push + case createPullRequest = "create_pr" + case commitAndPush = "commit_push" + case commitPushAndPullRequest = "commit_push_pr" +} + +public struct GitActionResult: Codable, Equatable, Sendable { + public struct Branch: Codable, Equatable, Sendable { + public let status: String + public let name: String? + } + + public struct Commit: Codable, Equatable, Sendable { + public let status: String + public let commitSha: String? + public let subject: String? + } + + public struct Push: Codable, Equatable, Sendable { + public let status: String + public let branch: String? + public let upstreamBranch: String? + public let setUpstream: Bool? + } + + public struct PullRequest: Codable, Equatable, Sendable { + public let status: String + public let url: String? + public let number: Int? + public let baseBranch: String? + public let headBranch: String? + public let title: String? + } + + public let action: GitStackedAction + public let branch: Branch + public let commit: Commit + public let push: Push + public let pr: PullRequest + public let toast: JSONValue +} + +public struct GitActionProgressEvent: Codable, Equatable, Sendable { + public let actionId: String + public let cwd: String + public let action: GitStackedAction + public let kind: String + public let phases: [String]? + public let phase: String? + public let label: String? + public let hookName: String? + public let stream: String? + public let text: String? + public let exitCode: Int? + public let durationMs: Int? + public let result: GitActionResult? + public let message: String? +} + +// MARK: - Review + +public struct ReviewDiffSource: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let kind: String + public let title: String + public let baseRef: String? + public let headRef: String? + public let diff: String + public let diffHash: String + public let truncated: Bool +} + +public struct ReviewDiffPreview: Codable, Equatable, Sendable { + public let cwd: String + public let generatedAt: String + public let sources: [ReviewDiffSource] +} + +public struct ReviewDiffFileContents: Codable, Equatable, Sendable { + public let oldContents: String + public let newContents: String +} + +// MARK: - Terminal + +public enum TerminalSessionStatus: String, Codable, Sendable { + case starting + case running + case exited + case error +} + +public struct TerminalSessionSnapshot: Codable, Equatable, Sendable { + public let threadId: String + public let terminalId: String + public let cwd: String + public let worktreePath: String? + public let status: TerminalSessionStatus + public let pid: Int? + public let history: String + public let exitCode: Int? + public let exitSignal: Int? + public let label: String + public let updatedAt: String + public let sequence: Int? +} + +public struct TerminalSummary: Codable, Equatable, Sendable { + public let threadId: String + public let terminalId: String + public let cwd: String + public let worktreePath: String? + public let status: TerminalSessionStatus + public let pid: Int? + public let exitCode: Int? + public let exitSignal: Int? + public let hasRunningSubprocess: Bool + public let label: String + public let updatedAt: String +} + +public struct TerminalEvent: Codable, Equatable, Sendable { + public let type: String + public let threadId: String? + public let terminalId: String? + public let sequence: Int? + public let snapshot: TerminalSessionSnapshot? + public let data: String? + public let exitCode: Int? + public let exitSignal: Int? + public let message: String? + public let hasRunningSubprocess: Bool? + public let label: String? +} + +public struct TerminalMetadataEvent: Codable, Equatable, Sendable { + public let type: String + public let terminals: [TerminalSummary]? + public let terminal: TerminalSummary? + public let threadId: String? + public let terminalId: String? +} diff --git a/apps/swift-ios/DesignSystem/ProjectIconPresentation.swift b/apps/swift-ios/DesignSystem/ProjectIconPresentation.swift new file mode 100644 index 000000000000..556ddac9db51 --- /dev/null +++ b/apps/swift-ios/DesignSystem/ProjectIconPresentation.swift @@ -0,0 +1,46 @@ +import SwiftUI + +enum ProjectIconPresentation { + static func symbol(_ name: String?) -> String { + switch name { + case "code", "code-xml", "code-2": "chevron.left.forwardslash.chevron.right" + case "terminal", "square-terminal": "terminal" + case "globe": "globe" + case "smartphone": "iphone" + case "monitor": "desktopcomputer" + case "server": "server.rack" + case "database": "externaldrive" + case "cloud": "cloud" + case "rocket": "paperplane" + case "box", "package": "shippingbox" + case "bug": "ladybug" + case "book", "book-open": "book" + case "heart": "heart" + case "star": "star" + case "zap": "bolt" + case "music": "music.note" + case "image": "photo" + case "gamepad", "gamepad-2": "gamecontroller" + case "cpu": "cpu" + case "wrench": "wrench" + case "git-branch": "arrow.triangle.branch" + default: "folder" + } + } + + static func color(_ name: String?) -> Color { + switch name { + case "red", "rose": .red + case "orange", "amber": .orange + case "yellow": .yellow + case "lime", "green", "emerald": .green + case "teal": .teal + case "cyan", "sky": .cyan + case "blue": .blue + case "indigo": .indigo + case "violet", "purple": .purple + case "fuchsia", "pink": .pink + default: T3Colors.textSecondary + } + } +} diff --git a/apps/swift-ios/DesignSystem/ProviderIcon.swift b/apps/swift-ios/DesignSystem/ProviderIcon.swift new file mode 100644 index 000000000000..0de4dd3570ec --- /dev/null +++ b/apps/swift-ios/DesignSystem/ProviderIcon.swift @@ -0,0 +1,88 @@ +import SwiftUI + +enum ProviderBrand: String { + case openAI = "ProviderOpenAI" + case claude = "ProviderClaude" + case cursor = "ProviderCursor" + case grok = "ProviderGrok" + case openCode = "ProviderOpenCode" + case antigravity = "ProviderAntigravity" + + static func resolve( + driver: String, + providerID: String, + providerName: String = "" + ) -> ProviderBrand? { + for value in [driver, providerID, providerName] { + let normalized = value + .lowercased() + .filter(\.isLetter) + switch normalized { + case "codex", "codexcli", "openai", "openaicodex": + return .openAI + case "anthropic", "anthropicclaude", "claudeagent", "claude", "claudecode": + return .claude + case "cursor", "cursoragent": + return .cursor + case "grok", "xai", "xaigrok": + return .grok + case "opencode": + return .openCode + case "antigravity", "googleantigravity": + return .antigravity + default: + continue + } + } + return nil + } + + var usesTemplateRendering: Bool { + switch self { + case .openAI, .cursor, .grok: true + case .claude, .openCode, .antigravity: false + } + } +} + +/// Displays the same provider artwork used by the web and marketing surfaces. +/// Unknown provider instances retain a compact initial so custom adapters remain legible. +struct ProviderIcon: View { + let driver: String + let providerID: String + let fallbackName: String + let size: CGFloat + + var body: some View { + Group { + if let brand = ProviderBrand.resolve( + driver: driver, + providerID: providerID, + providerName: fallbackName + ) { + Image(brand.rawValue) + .resizable() + .renderingMode(brand.usesTemplateRendering ? .template : .original) + .foregroundStyle(T3Colors.textSecondary) + .scaledToFit() + } else { + Text(fallbackInitial) + .font(.system(size: max(9, size * 0.44), weight: .bold)) + .foregroundStyle(T3Colors.textPrimary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + T3Colors.surfaceRaised, + in: RoundedRectangle(cornerRadius: max(4, size * 0.22)) + ) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + private var fallbackInitial: String { + fallbackName.trimmingCharacters(in: .whitespacesAndNewlines) + .first + .map { String($0).uppercased() } ?? "?" + } +} diff --git a/apps/swift-ios/DesignSystem/T3TextScale.swift b/apps/swift-ios/DesignSystem/T3TextScale.swift new file mode 100644 index 000000000000..e5ff3d886734 --- /dev/null +++ b/apps/swift-ios/DesignSystem/T3TextScale.swift @@ -0,0 +1,110 @@ +import SwiftUI +import UIKit + +enum T3TextSizing { + private static let categories: [UIContentSizeCategory] = [ + .extraSmall, + .small, + .medium, + .large, + .extraLarge, + .extraExtraLarge, + .extraExtraExtraLarge, + .accessibilityMedium, + .accessibilityLarge, + .accessibilityExtraLarge, + .accessibilityExtraExtraLarge, + .accessibilityExtraExtraExtraLarge, + ] + + static func contentSizeCategory( + system: UIContentSizeCategory, + steps: Int + ) -> UIContentSizeCategory { + guard steps != 0, let index = categories.firstIndex(of: system) else { return system } + return categories[min(categories.count - 1, max(0, index + steps))] + } +} + +extension DynamicTypeSize { + func t3Shifted(by steps: Int) -> DynamicTypeSize { + guard steps != 0 else { return self } + let sizes = DynamicTypeSize.allCases + guard let index = sizes.firstIndex(of: self) else { return self } + return sizes[min(sizes.count - 1, max(0, index + steps))] + } +} + +private struct T3CodeSizeStepsKey: EnvironmentKey { + static let defaultValue = 0 +} + +extension EnvironmentValues { + var t3CodeSizeSteps: Int { + get { self[T3CodeSizeStepsKey.self] } + set { self[T3CodeSizeStepsKey.self] = newValue } + } +} + +extension View { + func t3AppTextSize(steps: Int) -> some View { + modifier(T3AppTextSize(steps: steps)) + } + + func t3CodeSizing(steps: Int) -> some View { + environment(\.t3CodeSizeSteps, steps) + } + + func t3CodeTextSize(_ isEnabled: Bool = true) -> some View { + modifier(T3CodeTextSize(isEnabled: isEnabled)) + } +} + +/// Uses the window trait so the preference reaches sheets and UIKit-hosted cells. +/// Removing the override at zero preserves the reader's system Dynamic Type setting. +private struct T3AppTextSize: ViewModifier { + let steps: Int + @State private var systemCategory = UIApplication.shared.preferredContentSizeCategory + + func body(content: Content) -> some View { + content + .onAppear { apply() } + .onChange(of: steps) { _, _ in apply() } + .task { + for await change in NotificationCenter.default.notifications( + named: UIContentSizeCategory.didChangeNotification + ) { + systemCategory = change.userInfo?[ + UIContentSizeCategory.newValueUserInfoKey + ] as? UIContentSizeCategory + ?? UIApplication.shared.preferredContentSizeCategory + apply() + } + } + } + + @MainActor + private func apply() { + let category = T3TextSizing.contentSizeCategory(system: systemCategory, steps: steps) + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + for window in windows { + if steps == 0 { + window.traitOverrides.remove(UITraitPreferredContentSizeCategory.self) + } else { + window.traitOverrides.preferredContentSizeCategory = category + } + } + } +} + +private struct T3CodeTextSize: ViewModifier { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.t3CodeSizeSteps) private var codeSteps + let isEnabled: Bool + + func body(content: Content) -> some View { + content.dynamicTypeSize(dynamicTypeSize.t3Shifted(by: isEnabled ? codeSteps : 0)) + } +} diff --git a/apps/swift-ios/DesignSystem/T3Theme.swift b/apps/swift-ios/DesignSystem/T3Theme.swift new file mode 100644 index 000000000000..68871b03e55c --- /dev/null +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -0,0 +1,108 @@ +import SwiftUI +import UIKit + +enum T3Colors { + // UIKit variants let recycled collection and terminal surfaces participate + // in the same system appearance changes as SwiftUI views. + static let uiBackground = adaptive(light: rgb(0xF2F2F7), dark: rgb(0x000000)) + static let uiTextPrimary = adaptive(light: rgb(0x262626), dark: rgb(0xF5F5F5)) + static let uiTextSecondary = adaptive(light: rgb(0x525252), dark: rgb(0xA3A3A3)) + static let uiSurfaceRaised = adaptive(light: rgb(0xF5F5F5), dark: rgb(0x1C1C1C)) + static let uiAccent = adaptive(light: rgb(0x007AFF), dark: rgb(0x0A84FF)) + + static let background = Color(uiColor: uiBackground) + static let sheet = color(light: rgb(0xF2F2F7, alpha: 0.98), dark: rgb(0x000000, alpha: 0.98)) + static let surface = color(light: rgb(0xFFFFFF), dark: rgb(0x171717)) + static let surfaceRaised = Color(uiColor: uiSurfaceRaised) + static let input = color(light: rgb(0xFFFFFF), dark: rgb(0x141414)) + static let border = color(light: rgb(0x000000, alpha: 0.08), dark: rgb(0xFFFFFF, alpha: 0.06)) + static let inputBorder = color( + light: rgb(0x000000, alpha: 0.10), dark: rgb(0xFFFFFF, alpha: 0.08)) + static let separator = color( + light: rgb(0x000000, alpha: 0.04), dark: rgb(0xFFFFFF, alpha: 0.03)) + static let subtle = color(light: rgb(0x000000, alpha: 0.04), dark: rgb(0xFFFFFF, alpha: 0.04)) + static let subtleStrong = color( + light: rgb(0x000000, alpha: 0.08), dark: rgb(0xFFFFFF, alpha: 0.08)) + static let shadow = color(light: rgb(0x000000, alpha: 0.18), dark: rgb(0x000000, alpha: 0.32)) + static let ledgerSurface = surface + static let ledgerSelected = surfaceRaised + + static let textPrimary = Color(uiColor: uiTextPrimary) + static let textSecondary = Color(uiColor: uiTextSecondary) + static let textTertiary = color(light: rgb(0x737373), dark: rgb(0x8E8E93)) + static let placeholder = color(light: rgb(0xA3A3A3), dark: rgb(0x8E8E93)) + + static let primaryAction = color(light: rgb(0x262626), dark: rgb(0xF5F5F5)) + static let primaryActionForeground = color(light: rgb(0xFFFFFF), dark: rgb(0x000000)) + static let accent = Color(uiColor: uiAccent) + static let statusRunning = color(light: rgb(0x0284C7), dark: rgb(0x22D3EE)) + static let statusInput = color(light: rgb(0x4F46E5), dark: rgb(0xA5B4FC)) + static let success = color(light: rgb(0x16A34A), dark: rgb(0x30D158)) + static let warning = color(light: rgb(0xD97706), dark: rgb(0xFF9F0A)) + static let danger = color(light: rgb(0xDC2626), dark: rgb(0xFF453A)) + + static let syntaxKeyword = color(light: rgb(0x7C3AED), dark: rgb(0xC78EFF)) + static let syntaxLiteral = color(light: rgb(0x2563EB), dark: rgb(0x8CC7FF)) + static let syntaxNumber = color(light: rgb(0xB45309), dark: rgb(0xEBAA6B)) + static let syntaxProperty = color(light: rgb(0x0F766E), dark: rgb(0x6BD1C2)) + + private static func color(light: UIColor, dark: UIColor) -> Color { + Color(uiColor: adaptive(light: light, dark: dark)) + } + + private static func adaptive(light: UIColor, dark: UIColor) -> UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + } + } + + private static func rgb(_ hex: UInt32, alpha: CGFloat = 1) -> UIColor { + UIColor( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: alpha + ) + } +} + +/// The native client uses semantic fonts so every surface follows Dynamic Type. +/// Keep roles here instead of introducing one-off point sizes in feature views. +enum T3Typography { + static let homeTitle = Font.system(.body, design: .default, weight: .semibold) + static let homeMetadata = Font.system(.footnote, design: .default) + + static let navigationTitle = Font.system(.headline, design: .default, weight: .semibold) + static let navigationMetadata = Font.system(.footnote, design: .default) + static let status = Font.system(.footnote, design: .default, weight: .semibold) + + static let threadBody = Font.system(.body, design: .default) + static let threadHeading1 = Font.system(.title2, design: .default, weight: .bold) + static let threadHeading2 = Font.system(.title3, design: .default, weight: .bold) + static let threadHeading3 = Font.system(.headline, design: .default, weight: .bold) + static let threadHeading4 = Font.system(.body, design: .default, weight: .semibold) + static let code = Font.system(.callout, design: .monospaced) + static let tool = Font.system(.footnote, design: .monospaced) + + static let composer = Font.system(.body, design: .default) + static let control = Font.system(.callout, design: .default, weight: .medium) + static let supporting = Font.system(.footnote, design: .default) + static let supportingStrong = Font.system(.footnote, design: .default, weight: .semibold) + static let eyebrow = Font.system(.footnote, design: .default, weight: .bold) +} + +enum T3Metrics { + static let minimumTapTarget: CGFloat = 44 + static let maximumToolFailureMessageHeight: CGFloat = 144 + static let sidebarWidth: CGFloat = 320 + static let minimumSidebarWidth: CGFloat = 280 + static let maximumSidebarWidth: CGFloat = 380 + static let readingWidth: CGFloat = 760 +} + +extension View { + func t3NavigationChrome() -> some View { + toolbarBackground(T3Colors.sheet, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } +} diff --git a/apps/swift-ios/Extensions/Share/Info.plist b/apps/swift-ios/Extensions/Share/Info.plist new file mode 100644 index 000000000000..b7d47b414f30 --- /dev/null +++ b/apps/swift-ios/Extensions/Share/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDisplayName + $(T3CODE_SHARE_DISPLAY_NAME) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationDictionaryVersion + 2 + NSExtensionActivationSupportsImageWithMaxCount + 8 + NSExtensionActivationSupportsMovieWithMaxCount + 8 + NSExtensionActivationSupportsFileWithMaxCount + 8 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).T3ShareViewController + + + diff --git a/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift b/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift new file mode 100644 index 000000000000..eece28decf1e --- /dev/null +++ b/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift @@ -0,0 +1,306 @@ +import Foundation +import UniformTypeIdentifiers + +struct T3LoadedSharePayload: Sendable { + var textFragments: [String] + var images: [T3PendingShareImage] + var files: [T3PendingShareFile] + var warnings: [String] +} + +enum T3SharePayloadLoader { + @MainActor + static func load(from inputItems: [Any]) async -> T3LoadedSharePayload { + var textFragments: [String] = [] + var images: [T3PendingShareImage] = [] + var files: [T3PendingShareFile] = [] + var skippedOversizedImage = false + var skippedOversizedFile = false + var skippedExcessAttachment = false + + for case let item as NSExtensionItem in inputItems { + if let attributedText = item.attributedContentText?.string { + textFragments.append(attributedText) + } + + for provider in item.attachments ?? [] { + if let imageType = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .image) == true + }) { + guard images.count + files.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessAttachment = true + continue + } + do { + let staged = try await loadStagedImage( + from: provider, + typeIdentifier: imageType + ) + images.append( + T3PendingShareImage( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: provider.suggestedName, + typeIdentifier: imageType + ) + ) + } catch T3SharePayloadLoaderError.imageTooLarge { + skippedOversizedImage = true + } catch { + // An image provider is terminal even if it also vends a + // URL or text representation. Falling through would + // silently turn a rejected attachment into other input. + } + continue + } + + if let fileType = provider.registeredTypeIdentifiers.first(where: { + guard let type = UTType($0) else { return false } + return (type.conforms(to: .movie) || type.conforms(to: .data)) + && !type.conforms(to: .url) + && !type.conforms(to: .text) + }) { + guard images.count + files.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessAttachment = true + continue + } + do { + let staged = try await loadStagedFile( + from: provider, + typeIdentifier: fileType, + maximumBytes: T3IncomingShareStore.maximumFileBytes + ) + files.append(T3PendingShareFile( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: provider.suggestedName, + mimeType: UTType(fileType)?.preferredMIMEType ?? "application/octet-stream" + )) + } catch T3SharePayloadLoaderError.fileTooLarge { + skippedOversizedFile = true + } catch { + // A file provider is terminal. Do not turn a rejected + // attachment into its URL or text representation. + } + continue + } + + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + let urlValue = try? await loadURLItem( + from: provider, + typeIdentifier: UTType.url.identifier + ) + { + if urlValue.isFileURL { + guard images.count + files.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessAttachment = true + continue + } + do { + let staged = try stageFile( + from: urlValue, + maximumBytes: T3IncomingShareStore.maximumFileBytes + ) + let type = UTType(filenameExtension: urlValue.pathExtension) + files.append(T3PendingShareFile( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: urlValue.lastPathComponent, + mimeType: type?.preferredMIMEType ?? "application/octet-stream" + )) + } catch T3SharePayloadLoaderError.fileTooLarge { + skippedOversizedFile = true + } catch {} + } else { + textFragments.append(urlValue.absoluteString) + } + continue + } + + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier), + let text = try? await loadItemString( + from: provider, + typeIdentifier: UTType.plainText.identifier + ) + { + textFragments.append(text) + } + } + } + + var warnings: [String] = [] + if skippedOversizedImage { + warnings.append("One shared image exceeded the 10 MB attachment limit.") + } + if skippedOversizedFile { + warnings.append("One shared file exceeded the 50 MB attachment limit.") + } + if skippedExcessAttachment { + warnings.append( + "Only the first \(T3IncomingShareStore.maximumAttachmentCount) shared files were attached." + ) + } + return T3LoadedSharePayload( + textFragments: textFragments, + images: images, + files: files, + warnings: warnings + ) + } + + @MainActor + private static func loadStagedImage( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> (url: URL, byteCount: Int) { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + do { + guard let url else { + throw error ?? CocoaError(.fileReadUnknown) + } + continuation.resume(returning: try stageFile( + from: url, + maximumBytes: T3IncomingShareStore.maximumImageBytes, + oversizedError: .imageTooLarge + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// The provider-owned URL expires when its callback returns. Stream it to + /// an extension-owned temporary file while enforcing the byte limit, so a + /// malicious or enormous provider never has to be materialized in memory. + @MainActor + private static func loadStagedFile( + from provider: NSItemProvider, + typeIdentifier: String, + maximumBytes: Int + ) async throws -> (url: URL, byteCount: Int) { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + do { + guard let url else { throw error ?? CocoaError(.fileReadUnknown) } + continuation.resume(returning: try stageFile( + from: url, + maximumBytes: maximumBytes + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func stageFile( + from sourceURL: URL, + maximumBytes: Int, + oversizedError: T3SharePayloadLoaderError = .fileTooLarge + ) throws -> (url: URL, byteCount: Int) { + let values = try sourceURL.resourceValues(forKeys: [.isRegularFileKey]) + guard sourceURL.isFileURL, values.isRegularFile == true else { + throw CocoaError(.fileReadUnsupportedScheme) + } + let fileManager = FileManager.default + let stagingDirectory = fileManager.temporaryDirectory.appending( + path: "T3CodeShareStaging", + directoryHint: .isDirectory + ) + try fileManager.createDirectory( + at: stagingDirectory, + withIntermediateDirectories: true + ) + let stagedURL = stagingDirectory.appending( + path: UUID().uuidString.lowercased(), + directoryHint: .notDirectory + ) + guard fileManager.createFile(atPath: stagedURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + + do { + let source = try FileHandle(forReadingFrom: sourceURL) + let destination = try FileHandle(forWritingTo: stagedURL) + defer { + try? source.close() + try? destination.close() + } + + var byteCount = 0 + while let chunk = try source.read(upToCount: 64 * 1_024), !chunk.isEmpty { + try Task.checkCancellation() + byteCount += chunk.count + guard byteCount <= maximumBytes else { + throw oversizedError + } + try destination.write(contentsOf: chunk) + } + guard byteCount > 0 else { throw CocoaError(.fileReadCorruptFile) } + return (stagedURL, byteCount) + } catch { + try? fileManager.removeItem(at: stagedURL) + throw error + } + } + + @MainActor + private static func loadURLItem( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier) { value, error in + if let value, let url = url(from: value) { + continuation.resume(returning: url) + } else { + continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + } + } + } + + @MainActor + private static func loadItemString( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier) { value, error in + if let value, + let text = textString(from: value) { + continuation.resume(returning: text) + } else { + continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + } + } + } + + private static func url(from value: NSSecureCoding) -> URL? { + if let url = value as? URL { + return url + } + if let text = value as? String, let url = URL(string: text) { + return url + } + return nil + } + + private static func textString(from value: NSSecureCoding) -> String? { + if let text = value as? String { + return text + } + if let attributedText = value as? NSAttributedString { + return attributedText.string + } + return nil + } +} + +private enum T3SharePayloadLoaderError: Error { + case imageTooLarge + case fileTooLarge +} diff --git a/apps/swift-ios/Extensions/Share/ShareViewController.swift b/apps/swift-ios/Extensions/Share/ShareViewController.swift new file mode 100644 index 000000000000..014ffdead158 --- /dev/null +++ b/apps/swift-ios/Extensions/Share/ShareViewController.swift @@ -0,0 +1,190 @@ +import SwiftUI +import UIKit + +final class T3ShareViewController: UIViewController { + private var hostingController: UIHostingController? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let content = T3ShareExtensionView( + save: { [weak self] in + let inputItems = self?.extensionContext?.inputItems ?? [] + let payload = await T3SharePayloadLoader.load(from: inputItems) + return try await Task.detached { + try T3IncomingShareStore.write( + textFragments: payload.textFragments, + images: payload.images, + files: payload.files, + warnings: payload.warnings + ) + }.value + }, + cancel: { [weak self] in + self?.extensionContext?.cancelRequest(withError: CocoaError(.userCancelled)) + }, + complete: { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + } + ) + let hostingController = UIHostingController(rootView: content) + hostingController.view.backgroundColor = .systemBackground + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hostingController.didMove(toParent: self) + self.hostingController = hostingController + } +} + +struct T3ShareExtensionView: View { + enum Phase: Equatable { + case ready + case saving + case saved(imageCount: Int) + case failed(message: String) + } + + let save: () async throws -> T3IncomingShareEnvelope + let cancel: () -> Void + let complete: () -> Void + + @State private var phase = Phase.ready + + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Cancel", action: cancel) + .foregroundStyle(.secondary) + .disabled(isSaving) + Spacer() + Text("T3 Code") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Color.clear.frame(width: 52, height: 1) + } + .padding(.horizontal, 18) + .padding(.vertical, 15) + + Divider() + + VStack(spacing: 14) { + Image(systemName: phaseSymbol) + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(phaseTint) + .accessibilityHidden(true) + Text(title) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + Text(message) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 28) + .padding(.vertical, 24) + + Button(action: primaryAction) { + Text(primaryTitle) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(uiColor: .systemBackground)) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color(uiColor: .label), in: RoundedRectangle(cornerRadius: 13)) + } + .buttonStyle(.plain) + .disabled(isSaving) + .opacity(isSaving ? 0.55 : 1) + .padding(.horizontal, 18) + .padding(.bottom, 18) + } + .background(Color(uiColor: .systemBackground).ignoresSafeArea()) + } + + private var isSaving: Bool { + phase == .saving + } + + private var title: String { + switch phase { + case .ready: "Add to a new task" + case .saving: "Saving shared content" + case .saved: "Ready in T3 Code" + case .failed: "Could not add this" + } + } + + private var message: String { + switch phase { + case .ready: + "Text, links, and up to eight files will be waiting in the native composer." + case .saving: + "Keeping a durable copy so nothing gets lost." + case let .saved(imageCount): + imageCount == 0 + ? "Open T3 Code to choose a project and send it." + : "Saved \(imageCount) image\(imageCount == 1 ? "" : "s"). Open T3 Code to choose a project." + case let .failed(message): + message + } + } + + private var phaseSymbol: String { + switch phase { + case .ready: "square.and.arrow.up" + case .saving: "arrow.down.doc" + case .saved: "checkmark.circle.fill" + case .failed: "exclamationmark.triangle.fill" + } + } + + private var phaseTint: Color { + switch phase { + case .saved: Color(uiColor: .systemGreen) + case .failed: Color(uiColor: .systemRed) + default: Color(uiColor: .label) + } + } + + private var primaryTitle: String { + switch phase { + case .ready: "Add to T3 Code" + case .saving: "Saving…" + case .saved: "Done" + case .failed: "Try again" + } + } + + private func primaryAction() { + switch phase { + case .ready, .failed: + phase = .saving + Task { + do { + let envelope = try await save() + phase = .saved(imageCount: envelope.images.count + envelope.files.count) + } catch { + phase = .failed( + message: (error as? LocalizedError)?.errorDescription + ?? "The shared content could not be saved." + ) + } + } + case .saved: + complete() + case .saving: + break + } + } +} diff --git a/apps/swift-ios/Extensions/Share/T3CodeShare.entitlements b/apps/swift-ios/Extensions/Share/T3CodeShare.entitlements new file mode 100644 index 000000000000..87c87298c6ae --- /dev/null +++ b/apps/swift-ios/Extensions/Share/T3CodeShare.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(T3CODE_APP_GROUP_IDENTIFIER) + + + diff --git a/apps/swift-ios/Extensions/Shared/AgentActivityAttributes.swift b/apps/swift-ios/Extensions/Shared/AgentActivityAttributes.swift new file mode 100644 index 000000000000..c79d515534ba --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/AgentActivityAttributes.swift @@ -0,0 +1,119 @@ +import ActivityKit +import Foundation + +enum T3AgentActivityPhase: String, Codable, Hashable, Sendable { + case starting + case running + case waitingForApproval = "waiting_for_approval" + case waitingForInput = "waiting_for_input" + case completed + case failed + case stale + + var systemImage: String { + switch self { + case .starting: + "circle.dotted" + case .running: + "arrow.trianglehead.2.clockwise.rotate.90" + case .waitingForApproval: + "exclamationmark.circle.fill" + case .waitingForInput: + "questionmark.circle.fill" + case .completed: + "checkmark.circle.fill" + case .failed: + "xmark.octagon.fill" + case .stale: + "clock.arrow.circlepath" + } + } +} + +/// Mirrors `RelayAgentActivityAggregateRow` in packages/contracts/src/relay.ts. +struct T3RelayAgentActivityAggregateRow: Codable, Hashable, Identifiable, Sendable { + var environmentId: String + var threadId: String + var projectTitle: String + var threadTitle: String + var modelTitle: String + var phase: T3AgentActivityPhase + var status: String + var updatedAt: String + var deepLink: String + + var id: String { "\(environmentId):\(threadId)" } + + /// Generate the native query route rather than trusting a web-shaped path. + var nativeDeepLinkURL: URL? { + var components = URLComponents() + components.scheme = T3SharedContainer.urlScheme + components.host = "threads" + components.queryItems = [ + URLQueryItem(name: "environment", value: environmentId), + URLQueryItem(name: "thread", value: threadId), + ] + return components.url + } +} + +/// Mirrors `RelayAgentActivityAggregateState` in packages/contracts/src/relay.ts. +struct T3RelayAgentActivityAggregateState: Codable, Hashable, Sendable { + var title: String + var subtitle: String + var activeCount: Int + var updatedAt: String + var activities: [T3RelayAgentActivityAggregateRow] + + var attentionFirstActivities: [T3RelayAgentActivityAggregateRow] { + activities.sorted { left, right in + let leftPriority = left.phase.presentationPriority + let rightPriority = right.phase.presentationPriority + return leftPriority == rightPriority + ? left.updatedAt > right.updatedAt + : leftPriority < rightPriority + } + } +} + +/// This exact type and state envelope are part of the relay/APNs protocol. +/// The relay sends `attributes-type: LiveActivityAttributes`, empty attributes, +/// and `{ name: "AgentActivity", props: "" }` as content state. +struct LiveActivityAttributes: ActivityAttributes, Hashable { + struct ContentState: Codable, Hashable, Sendable { + var name: String + var props: String + + var aggregate: T3RelayAgentActivityAggregateState? { + guard name == LiveActivityAttributes.activityName, + let data = props.data(using: .utf8) + else { + return nil + } + return try? JSONDecoder().decode(T3RelayAgentActivityAggregateState.self, from: data) + } + + init(name: String, props: String) { + self.name = name + self.props = props + } + + init(aggregate: T3RelayAgentActivityAggregateState) throws { + name = LiveActivityAttributes.activityName + props = String(decoding: try JSONEncoder().encode(aggregate), as: UTF8.self) + } + } + + static let activityName = "AgentActivity" +} + +extension T3AgentActivityPhase { + fileprivate var presentationPriority: Int { + switch self { + case .waitingForApproval, .waitingForInput: 0 + case .failed: 1 + case .starting, .running: 2 + case .completed, .stale: 3 + } + } +} diff --git a/apps/swift-ios/Extensions/Shared/ShareInbox.swift b/apps/swift-ios/Extensions/Shared/ShareInbox.swift new file mode 100644 index 000000000000..75fa3d7b6031 --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/ShareInbox.swift @@ -0,0 +1,349 @@ +import Foundation + +struct T3IncomingShareImage: Codable, Hashable, Identifiable, Sendable { + var id: String + var fileName: String + var typeIdentifier: String + var relativePath: String + var byteCount: Int +} + +struct T3IncomingShareFile: Codable, Hashable, Identifiable, Sendable { + var id: String + var fileName: String + var mimeType: String + var relativePath: String + var byteCount: Int +} + +struct T3IncomingShareEnvelope: Codable, Hashable, Identifiable, Sendable { + static let schemaVersion = 2 + + var schemaVersion: Int + var id: String + var createdAt: Date + var text: String + var images: [T3IncomingShareImage] + var files: [T3IncomingShareFile] + var warnings: [String] + + init( + schemaVersion: Int, + id: String, + createdAt: Date, + text: String, + images: [T3IncomingShareImage], + files: [T3IncomingShareFile] = [], + warnings: [String] + ) { + self.schemaVersion = schemaVersion + self.id = id + self.createdAt = createdAt + self.text = text + self.images = images + self.files = files + self.warnings = warnings + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, id, createdAt, text, images, files, warnings + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decode(Int.self, forKey: .schemaVersion) + id = try values.decode(String.self, forKey: .id) + createdAt = try values.decode(Date.self, forKey: .createdAt) + text = try values.decode(String.self, forKey: .text) + images = try values.decodeIfPresent([T3IncomingShareImage].self, forKey: .images) ?? [] + files = try values.decodeIfPresent([T3IncomingShareFile].self, forKey: .files) ?? [] + warnings = try values.decodeIfPresent([String].self, forKey: .warnings) ?? [] + } +} + +struct T3PendingShareImage: Sendable { + var stagedFileURL: URL + var byteCount: Int + var suggestedName: String? + var typeIdentifier: String +} + +struct T3PendingShareFile: Sendable { + var stagedFileURL: URL + var byteCount: Int + var suggestedName: String? + var mimeType: String +} + +enum T3IncomingShareStoreError: LocalizedError { + case appGroupUnavailable + case noSupportedContent + + var errorDescription: String? { + switch self { + case .appGroupUnavailable: + "T3 Code could not access its shared inbox." + case .noSupportedContent: + "This app did not provide text, a URL, or a supported file." + } + } +} + +/// A crash-safe handoff from the short-lived share extension to the host app. +/// Each share gets its own UUID directory and an atomically-written manifest. +enum T3IncomingShareStore { + static let inboxRelativePath = "Library/Application Support/T3Code/IncomingShares" + static let manifestFileName = "manifest.json" + static let maximumImageCount = 8 + static let maximumImageBytes = 10 * 1_024 * 1_024 + static let maximumAttachmentCount = 8 + static let maximumFileBytes = 50 * 1_024 * 1_024 + + static func write( + textFragments: [String], + images: [T3PendingShareImage], + files: [T3PendingShareFile] = [], + warnings initialWarnings: [String] = [], + now: Date = Date(), + id: String = UUID().uuidString.lowercased() + ) throws -> T3IncomingShareEnvelope { + guard let containerURL = T3SharedContainer.rootURL else { + throw T3IncomingShareStoreError.appGroupUnavailable + } + defer { + for url in images.map(\.stagedFileURL) + files.map(\.stagedFileURL) { + try? FileManager.default.removeItem(at: url) + } + } + + let normalizedText = deduplicatedText(textFragments) + let itemDirectory = containerURL + .appending(path: inboxRelativePath, directoryHint: .isDirectory) + .appending(path: id, directoryHint: .isDirectory) + var warnings = initialWarnings + var savedImages: [T3IncomingShareImage] = [] + var savedFiles: [T3IncomingShareFile] = [] + var validOverflowCount = 0 + + do { + try FileManager.default.createDirectory( + at: itemDirectory, + withIntermediateDirectories: true + ) + + for image in images { + let values = try? image.stagedFileURL.resourceValues(forKeys: [ + .fileSizeKey, + .isRegularFileKey, + ]) + guard values?.isRegularFile == true, + let byteCount = values?.fileSize, + byteCount > 0, + byteCount <= maximumImageBytes, + byteCount == image.byteCount else { + warnings.append("One shared image exceeded the 10 MB attachment limit.") + continue + } + guard savedImages.count + savedFiles.count < maximumAttachmentCount else { + validOverflowCount += 1 + continue + } + + let attachmentID = UUID().uuidString.lowercased() + let fileName = safeFileName( + image.suggestedName, + fallback: "shared-image-\(savedImages.count + 1).\(fileExtension(for: image.typeIdentifier))" + ) + let storedName = "\(attachmentID)-\(fileName)" + let fileURL = itemDirectory.appending(path: storedName, directoryHint: .notDirectory) + try FileManager.default.copyItem(at: image.stagedFileURL, to: fileURL) + savedImages.append( + T3IncomingShareImage( + id: attachmentID, + fileName: fileName, + typeIdentifier: image.typeIdentifier, + relativePath: "\(inboxRelativePath)/\(id)/\(storedName)", + byteCount: byteCount + ) + ) + } + + for file in files { + let values = try? file.stagedFileURL.resourceValues(forKeys: [ + .fileSizeKey, .isRegularFileKey, + ]) + guard values?.isRegularFile == true, + let byteCount = values?.fileSize, + byteCount > 0, + byteCount <= maximumFileBytes, + byteCount == file.byteCount else { + warnings.append("One shared file exceeded the 50 MB attachment limit.") + continue + } + guard savedImages.count + savedFiles.count < maximumAttachmentCount else { + validOverflowCount += 1 + continue + } + let attachmentID = UUID().uuidString.lowercased() + let fileName = safeFileName(file.suggestedName, fallback: "shared-file-\(savedFiles.count + 1)") + let storedName = "\(attachmentID)-\(fileName)" + let fileURL = itemDirectory.appending(path: storedName, directoryHint: .notDirectory) + try FileManager.default.copyItem(at: file.stagedFileURL, to: fileURL) + savedFiles.append(T3IncomingShareFile( + id: attachmentID, + fileName: fileName, + mimeType: safeMIMEType(file.mimeType), + relativePath: "\(inboxRelativePath)/\(id)/\(storedName)", + byteCount: byteCount + )) + } + + if validOverflowCount > 0 { + warnings.append("Only the first \(maximumAttachmentCount) shared files were attached.") + } + + guard !normalizedText.isEmpty || !savedImages.isEmpty || !savedFiles.isEmpty else { + throw T3IncomingShareStoreError.noSupportedContent + } + + let envelope = T3IncomingShareEnvelope( + schemaVersion: T3IncomingShareEnvelope.schemaVersion, + id: id, + createdAt: now, + text: normalizedText, + images: savedImages, + files: savedFiles, + warnings: warnings + ) + let manifestURL = itemDirectory.appending( + path: manifestFileName, + directoryHint: .notDirectory + ) + try encoder.encode(envelope).write(to: manifestURL, options: .atomic) + return envelope + } catch { + try? FileManager.default.removeItem(at: itemDirectory) + throw error + } + } + + static func loadAll() -> [T3IncomingShareEnvelope] { + guard let containerURL = T3SharedContainer.rootURL else { return [] } + let inboxURL = containerURL.appending(path: inboxRelativePath, directoryHint: .isDirectory) + guard let directories = try? FileManager.default.contentsOfDirectory( + at: inboxURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return directories.compactMap { directory in + let manifestURL = directory.appending(path: manifestFileName, directoryHint: .notDirectory) + guard let data = try? Data(contentsOf: manifestURL) else { return nil } + return try? decoder.decode(T3IncomingShareEnvelope.self, from: data) + } + .filter { $0.schemaVersion == 1 || $0.schemaVersion == T3IncomingShareEnvelope.schemaVersion } + .sorted { $0.createdAt < $1.createdAt } + } + + static func remove(id: String) throws { + guard let containerURL = T3SharedContainer.rootURL else { + throw T3IncomingShareStoreError.appGroupUnavailable + } + guard UUID(uuidString: id) != nil else { + throw T3IncomingShareStoreError.noSupportedContent + } + let inboxURL = containerURL + .appending(path: inboxRelativePath, directoryHint: .isDirectory) + .standardizedFileURL + let itemURL = inboxURL + .appending(path: id, directoryHint: .isDirectory) + .standardizedFileURL + guard itemURL.deletingLastPathComponent() == inboxURL else { + throw T3IncomingShareStoreError.noSupportedContent + } + guard FileManager.default.fileExists(atPath: itemURL.path) else { return } + try FileManager.default.removeItem(at: itemURL) + } + + static func fileURL(for image: T3IncomingShareImage) -> URL? { + fileURL(relativePath: image.relativePath) + } + + static func fileURL(for file: T3IncomingShareFile) -> URL? { + fileURL(relativePath: file.relativePath) + } + + private static func fileURL(relativePath: String) -> URL? { + guard let root = T3SharedContainer.rootURL else { return nil } + return fileURL(relativePath: relativePath, rootURL: root) + } + + static func fileURL(relativePath: String, rootURL: URL) -> URL? { + let root = rootURL.standardizedFileURL.resolvingSymlinksInPath() + let inbox = root.appending(path: inboxRelativePath, directoryHint: .isDirectory) + .standardizedFileURL.resolvingSymlinksInPath() + let url = root.appending(path: relativePath, directoryHint: .notDirectory) + .standardizedFileURL.resolvingSymlinksInPath() + guard url.path.hasPrefix(inbox.path + "/") else { return nil } + return url + } + + private static func safeMIMEType(_ proposed: String) -> String { + let value = proposed.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "!#$&^_.+-/")) + guard value.count <= 100, + value.filter({ $0 == "/" }).count == 1, + value.unicodeScalars.allSatisfy(allowed.contains) else { + return "application/octet-stream" + } + return value + } + + private static func deduplicatedText(_ fragments: [String]) -> String { + var seen: Set = [] + return fragments.compactMap { fragment in + let value = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, seen.insert(value).inserted else { return nil } + return value + }.joined(separator: "\n\n") + } + + private static func safeFileName(_ proposed: String?, fallback: String) -> String { + let candidate = proposed?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lastPathComponent = URL(fileURLWithPath: candidate).lastPathComponent + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_ ")) + let sanitized = String(lastPathComponent.unicodeScalars.filter(allowed.contains)).prefix(96) + guard !sanitized.isEmpty else { return fallback } + let value = String(sanitized) + let pathExtension = URL(fileURLWithPath: value).pathExtension + guard pathExtension.count <= 16 else { + return URL(fileURLWithPath: value).deletingPathExtension().lastPathComponent + } + return value + } + + private static func fileExtension(for typeIdentifier: String) -> String { + switch typeIdentifier.lowercased() { + case "public.jpeg", "public.jpg", "image/jpeg": "jpg" + case "public.heic", "image/heic": "heic" + case "public.webp", "image/webp": "webp" + case "com.compuserve.gif", "image/gif": "gif" + default: "png" + } + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} diff --git a/apps/swift-ios/Extensions/Shared/SharedContainer.swift b/apps/swift-ios/Extensions/Shared/SharedContainer.swift new file mode 100644 index 000000000000..54f6fbaaf473 --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/SharedContainer.swift @@ -0,0 +1,17 @@ +import Foundation + +enum T3SharedContainer { + #if DEBUG + static let appGroupID = "group.com.t3tools.t3code.swiftui.debug" + static let urlScheme = "t3code-swiftui-dev" + #else + static let appGroupID = "group.com.t3tools.t3code.swiftui" + static let urlScheme = "t3code-swiftui" + #endif + + static var rootURL: URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) + } +} diff --git a/apps/swift-ios/Extensions/Shared/T3Code.entitlements b/apps/swift-ios/Extensions/Shared/T3Code.entitlements new file mode 100644 index 000000000000..703abe40ace0 --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/T3Code.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + $(APS_ENVIRONMENT) + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + webcredentials:clerk.t3.codes + + com.apple.security.application-groups + + $(T3CODE_APP_GROUP_IDENTIFIER) + + + diff --git a/apps/swift-ios/Extensions/Shared/TaskWidgetSnapshot.swift b/apps/swift-ios/Extensions/Shared/TaskWidgetSnapshot.swift new file mode 100644 index 000000000000..22f9dd30443b --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/TaskWidgetSnapshot.swift @@ -0,0 +1,47 @@ +import Foundation + +struct T3TaskWidgetSnapshot: Codable, Hashable, Sendable { + static let empty = T3TaskWidgetSnapshot(updatedAt: "", tasks: []) + + var updatedAt: String + var tasks: [T3RelayAgentActivityAggregateRow] +} + +/// The host writes one small snapshot after task-state changes; the widget only +/// performs a bounded file read when WidgetKit requests a timeline. +enum T3TaskWidgetSnapshotStore { + static let fileName = "task-widget-snapshot.json" + + static func load() -> T3TaskWidgetSnapshot { + guard let url = fileURL(), + let data = try? Data(contentsOf: url), + let snapshot = try? JSONDecoder().decode(T3TaskWidgetSnapshot.self, from: data) + else { + return .empty + } + return snapshot + } + + static func save(_ snapshot: T3TaskWidgetSnapshot) throws { + guard let url = fileURL() else { + throw CocoaError(.fileNoSuchFile) + } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(snapshot).write(to: url, options: .atomic) + } + + private static func fileURL() -> URL? { + T3SharedContainer.rootURL? + .appending(path: "Library/Application Support/T3Code", directoryHint: .isDirectory) + .appending(path: fileName, directoryHint: .notDirectory) + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() +} diff --git a/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift b/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift new file mode 100644 index 000000000000..3e531463fc6f --- /dev/null +++ b/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import T3Code + +final class ExtensionContractTests: XCTestCase { + func testLiveActivityDecodesTheRelayAPNSEnvelope() throws { + let props = #"{"title":"T3 Code","subtitle":"2 active agents, 1 needs attention","activeCount":2,"updatedAt":"2026-08-01T12:00:00.000Z","activities":[{"environmentId":"env-1","threadId":"thread-working","projectTitle":"t3code","threadTitle":"Build the native app","modelTitle":"GPT-5.6 Sol","phase":"running","status":"Working","updatedAt":"2026-08-01T12:00:00.000Z","deepLink":"/env-1/thread-working"},{"environmentId":"env-2","threadId":"thread-approval","projectTitle":"uploadthing","threadTitle":"Ship upload recovery","modelTitle":"Claude Opus 5","phase":"waiting_for_approval","status":"Approval","updatedAt":"2026-08-01T11:59:00.000Z","deepLink":"/env-2/thread-approval"}]}"# + let state = LiveActivityAttributes.ContentState( + name: "AgentActivity", + props: props + ) + + let aggregate = try XCTUnwrap(state.aggregate) + XCTAssertEqual(aggregate.activeCount, 2) + XCTAssertEqual(aggregate.activities.count, 2) + XCTAssertEqual(aggregate.attentionFirstActivities.first?.threadId, "thread-approval") + XCTAssertEqual( + aggregate.attentionFirstActivities.first?.nativeDeepLinkURL?.absoluteString, + "\(T3SharedContainer.urlScheme)://threads?environment=env-2&thread=thread-approval" + ) + } + + func testLocalLiveActivityStatePreservesTheExactNameAndPropsKeys() throws { + let aggregate = T3RelayAgentActivityAggregateState( + title: "T3 Code", + subtitle: "1 active agent", + activeCount: 1, + updatedAt: "2026-08-01T12:00:00.000Z", + activities: [] + ) + let state = try LiveActivityAttributes.ContentState(aggregate: aggregate) + let encoded = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(state)) as? [String: Any] + ) + + XCTAssertEqual(Set(encoded.keys), Set(["name", "props"])) + XCTAssertEqual(encoded["name"] as? String, "AgentActivity") + XCTAssertEqual(state.aggregate, aggregate) + } + + func testUnexpectedActivityNamesNeverDecodeAsAgentState() { + let state = LiveActivityAttributes.ContentState(name: "Other", props: "{}") + XCTAssertNil(state.aggregate) + } +} diff --git a/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift b/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift new file mode 100644 index 000000000000..fe077760ee91 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift @@ -0,0 +1,180 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct T3TaskLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LiveActivityAttributes.self) { context in + T3LiveActivityLockScreenView(context: context) + .activityBackgroundTint(Color(uiColor: .systemBackground)) + .activitySystemActionForegroundColor(Color(uiColor: .label)) + .widgetURL(T3ActivityPresentation(state: context.state).deepLinkURL) + } dynamicIsland: { context in + let presentation = T3ActivityPresentation(state: context.state) + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text("T3") + .font(.system(size: 14, weight: .black, design: .rounded)) + .foregroundStyle(presentation.tint) + .padding(.leading, 4) + } + + DynamicIslandExpandedRegion(.trailing) { + Label(presentation.shortStatus, systemImage: presentation.phase.systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(presentation.tint) + .lineLimit(1) + .padding(.trailing, 4) + } + + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 5) { + ForEach(presentation.rows.prefix(3)) { row in + T3LiveActivityRow(row: row) + } + if presentation.rows.isEmpty { + Text(presentation.subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.bottom, 2) + } + } compactLeading: { + Text("T3") + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(presentation.tint) + } compactTrailing: { + Image(systemName: presentation.phase.systemImage) + .foregroundStyle(presentation.tint) + } minimal: { + Image(systemName: presentation.phase.systemImage) + .foregroundStyle(presentation.tint) + } + .widgetURL(presentation.deepLinkURL) + .keylineTint(presentation.tint) + } + } +} + +private struct T3LiveActivityLockScreenView: View { + let context: ActivityViewContext + + private var presentation: T3ActivityPresentation { + T3ActivityPresentation(state: context.state) + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + Text("T3 Code") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.primary) + Spacer(minLength: 8) + Label(presentation.shortStatus, systemImage: presentation.phase.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(presentation.tint) + .lineLimit(1) + } + + if presentation.rows.isEmpty { + Text(presentation.subtitle) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + ForEach(presentation.rows.prefix(4)) { row in + T3LiveActivityRow(row: row) + } + } + } + .padding(15) + } +} + +private struct T3LiveActivityRow: View { + let row: T3RelayAgentActivityAggregateRow + + var body: some View { + HStack(spacing: 7) { + Image(systemName: row.phase.systemImage) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(row.phase.tint) + .frame(width: 14) + Text(row.threadTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(row.projectTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 6) + Text(row.status) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(row.phase.tint) + .lineLimit(1) + } + } +} + +private struct T3ActivityPresentation { + let aggregate: T3RelayAgentActivityAggregateState? + + init(state: LiveActivityAttributes.ContentState) { + aggregate = state.aggregate + } + + var rows: [T3RelayAgentActivityAggregateRow] { + aggregate?.attentionFirstActivities ?? [] + } + + var phase: T3AgentActivityPhase { + rows.first?.phase ?? .stale + } + + var tint: Color { phase.tint } + + var shortStatus: String { + if let row = rows.first(where: { + $0.phase == .waitingForApproval || $0.phase == .waitingForInput + }) { + return row.phase == .waitingForApproval ? "Approval" : "Input" + } + guard let aggregate else { return "Updating" } + if aggregate.activeCount > 0 { + return "\(aggregate.activeCount) active" + } + return rows.contains(where: { $0.phase == .failed }) ? "Failed" : "Done" + } + + var subtitle: String { + aggregate?.subtitle ?? "Waiting for the latest task status." + } + + var deepLinkURL: URL? { + rows.first?.nativeDeepLinkURL + } +} + +extension T3AgentActivityPhase { + var tint: Color { + switch self { + case .starting, .running: + Color(uiColor: .systemBlue) + case .waitingForApproval: + Color(uiColor: .systemOrange) + case .waitingForInput: + Color(uiColor: .systemIndigo) + case .completed: + Color(uiColor: .systemGreen) + case .failed: + Color(uiColor: .systemRed) + case .stale: + Color.secondary + } + } +} diff --git a/apps/swift-ios/Extensions/Widgets/Info.plist b/apps/swift-ios/Extensions/Widgets/Info.plist new file mode 100644 index 000000000000..d31f30c5f683 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/Info.plist @@ -0,0 +1,13 @@ + + + + + CFBundleDisplayName + $(T3CODE_WIDGET_DISPLAY_NAME) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift b/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift new file mode 100644 index 000000000000..c1ced6e9ca7c --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift @@ -0,0 +1,224 @@ +import SwiftUI +import WidgetKit + +private struct T3TaskWidgetEntry: TimelineEntry { + var date: Date + var snapshot: T3TaskWidgetSnapshot +} + +private struct T3TaskWidgetProvider: TimelineProvider { + func placeholder(in _: Context) -> T3TaskWidgetEntry { + T3TaskWidgetEntry(date: Date(), snapshot: .preview) + } + + func getSnapshot(in context: Context, completion: @escaping (T3TaskWidgetEntry) -> Void) { + let snapshot = context.isPreview ? T3TaskWidgetSnapshot.preview : T3TaskWidgetSnapshotStore.load() + completion(T3TaskWidgetEntry(date: Date(), snapshot: snapshot)) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let entry = T3TaskWidgetEntry(date: now, snapshot: T3TaskWidgetSnapshotStore.load()) + completion(Timeline(entries: [entry], policy: .after(now.addingTimeInterval(15 * 60)))) + } +} + +struct T3RecentTasksWidget: Widget { + private let kind = "T3RecentTasksWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: T3TaskWidgetProvider()) { entry in + T3TaskWidgetView(entry: entry) + .containerBackground(Color(uiColor: .systemBackground), for: .widget) + } + .configurationDisplayName("T3 Code Tasks") + .description("See active and recent T3 Code tasks at a glance.") + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) + } +} + +private struct T3TaskWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: T3TaskWidgetEntry + + var body: some View { + switch family { + case .systemMedium: + mediumView + case .accessoryRectangular: + accessoryView + default: + smallView + } + } + + private var orderedTasks: [T3RelayAgentActivityAggregateRow] { + entry.snapshot.tasks.sorted { left, right in + let leftPriority = left.phase.widgetPriority + let rightPriority = right.phase.widgetPriority + return leftPriority == rightPriority + ? left.updatedAt > right.updatedAt + : leftPriority < rightPriority + } + } + + private var smallView: some View { + VStack(alignment: .leading, spacing: 8) { + header + Spacer(minLength: 0) + if let task = orderedTasks.first { + Link(destination: task.nativeDeepLinkURL ?? T3WidgetURLs.newTask) { + VStack(alignment: .leading, spacing: 5) { + Label(task.status, systemImage: task.phase.systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(task.phase.tint) + .lineLimit(1) + Text(task.threadTitle) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(2) + Text(task.projectTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } else { + emptyState + } + } + .widgetURL(orderedTasks.first?.nativeDeepLinkURL ?? T3WidgetURLs.newTask) + } + + private var mediumView: some View { + VStack(alignment: .leading, spacing: 8) { + header + if orderedTasks.isEmpty { + Spacer(minLength: 0) + emptyState + Spacer(minLength: 0) + } else { + ForEach(Array(orderedTasks.prefix(3).enumerated()), id: \.element.id) { index, task in + Link(destination: task.nativeDeepLinkURL ?? T3WidgetURLs.newTask) { + HStack(spacing: 8) { + Image(systemName: task.phase.systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(task.phase.tint) + .frame(width: 15) + VStack(alignment: .leading, spacing: 1) { + Text(task.threadTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(task.projectTitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Text(task.status) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(task.phase.tint) + .lineLimit(1) + } + } + if index < min(orderedTasks.count, 3) - 1 { + Divider() + } + } + } + } + } + + private var accessoryView: some View { + Group { + if let task = orderedTasks.first { + VStack(alignment: .leading, spacing: 2) { + Label(task.status, systemImage: task.phase.systemImage) + .font(.caption.weight(.semibold)) + Text(task.threadTitle) + .font(.caption2.weight(.medium)) + .lineLimit(1) + } + } else { + Label("New task", systemImage: "square.and.pencil") + .font(.caption.weight(.semibold)) + } + } + .widgetURL(orderedTasks.first?.nativeDeepLinkURL ?? T3WidgetURLs.newTask) + } + + private var header: some View { + HStack(spacing: 6) { + Text("T3") + .font(.system(size: 14, weight: .black, design: .rounded)) + .foregroundStyle(.primary) + Text("Code") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + Link(destination: T3WidgetURLs.newTask) { + Image(systemName: "square.and.pencil") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + } + .accessibilityLabel("New task") + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Ready for a task") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + Text("Tap to start in T3 Code") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + } +} + +private enum T3WidgetURLs { + static let newTask = URL(string: "\(T3SharedContainer.urlScheme)://new-task")! +} + +private extension T3AgentActivityPhase { + var widgetPriority: Int { + switch self { + case .waitingForApproval, .waitingForInput: 0 + case .failed: 1 + case .starting, .running: 2 + case .completed, .stale: 3 + } + } +} + +private extension T3TaskWidgetSnapshot { + static let preview = T3TaskWidgetSnapshot( + updatedAt: "2026-08-01T12:00:00.000Z", + tasks: [ + T3RelayAgentActivityAggregateRow( + environmentId: "preview", + threadId: "one", + projectTitle: "t3code", + threadTitle: "Polish native task list", + modelTitle: "GPT-5.6 Sol", + phase: .running, + status: "Working", + updatedAt: "2026-08-01T12:00:00.000Z", + deepLink: "/preview/one" + ), + T3RelayAgentActivityAggregateRow( + environmentId: "preview", + threadId: "two", + projectTitle: "uploadthing", + threadTitle: "Review multipart recovery", + modelTitle: "Claude Opus 5", + phase: .waitingForApproval, + status: "Approval", + updatedAt: "2026-08-01T11:59:00.000Z", + deepLink: "/preview/two" + ), + ] + ) +} diff --git a/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.entitlements b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.entitlements new file mode 100644 index 000000000000..87c87298c6ae --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(T3CODE_APP_GROUP_IDENTIFIER) + + + diff --git a/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift new file mode 100644 index 000000000000..4b4564bf9341 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WidgetKit + +@main +struct T3CodeWidgetBundle: WidgetBundle { + var body: some Widget { + T3TaskLiveActivity() + T3RecentTasksWidget() + } +} diff --git a/apps/swift-ios/Features/Chat/AppleVoiceInputAdapter.swift b/apps/swift-ios/Features/Chat/AppleVoiceInputAdapter.swift new file mode 100644 index 000000000000..f310e231cfa3 --- /dev/null +++ b/apps/swift-ios/Features/Chat/AppleVoiceInputAdapter.swift @@ -0,0 +1,192 @@ +import AVFoundation +import Foundation +import Speech + +@MainActor +enum FeatureVoiceInputAdapterFactory { + static func make() -> any FeatureVoiceInputAdapter { + if #available(iOS 26.0, *) { + return AppleVoiceInputAdapter() + } + return UnsupportedVoiceInputAdapter() + } +} + +@MainActor +private final class UnsupportedVoiceInputAdapter: FeatureVoiceInputAdapter { + let isSupported = false + let localeIdentifier = Locale.current.identifier + + func prepare() async throws {} + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission { .denied } + func startRecording(maximumDuration: TimeInterval) throws {} + func stopRecording() async throws -> URL { throw AppleVoiceInputError.unavailable } + func transcribe(recordingURL: URL) async throws -> String { + throw AppleVoiceInputError.unavailable + } + func cancelTranscription() async {} + func cleanup() async {} +} + +private enum AppleVoiceInputError: Error { + case unavailable + case unsupportedLocale + case recordingFailed +} + +private struct AppleVoiceAudioSessionConfiguration { + let category: AVAudioSession.Category + let mode: AVAudioSession.Mode + let options: AVAudioSession.CategoryOptions +} + +@available(iOS 26.0, *) +@MainActor +private final class AppleVoiceInputAdapter: FeatureVoiceInputAdapter { + private let audioSession = AVAudioSession.sharedInstance() + private let fileManager = FileManager.default + private var transcriber: SpeechTranscriber? + private var analyzer: SpeechAnalyzer? + private var recorder: AVAudioRecorder? + private var recordingURL: URL? + private var ownedRecordingURLs = Set() + private var previousAudioSessionConfiguration: AppleVoiceAudioSessionConfiguration? + private var audioSessionWasConfigured = false + + var isSupported: Bool { SpeechTranscriber.isAvailable } + private(set) var localeIdentifier = Locale.current.identifier + + func prepare() async throws { + guard SpeechTranscriber.isAvailable else { throw AppleVoiceInputError.unavailable } + guard let locale = await SpeechTranscriber.supportedLocale( + equivalentTo: Locale.current + ) else { + throw AppleVoiceInputError.unsupportedLocale + } + + let transcriber = SpeechTranscriber(locale: locale, preset: .transcription) + if let request = try await AssetInventory.assetInstallationRequest( + supporting: [transcriber] + ) { + try await request.downloadAndInstall() + } + self.transcriber = transcriber + localeIdentifier = locale.identifier + } + + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission { + let granted = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + return granted ? .granted : .denied + } + + func startRecording(maximumDuration: TimeInterval) throws { + let url = fileManager.temporaryDirectory + .appendingPathComponent("t3-voice-\(UUID().uuidString)") + .appendingPathExtension("m4a") + ownedRecordingURLs.insert(url) + recordingURL = url + + previousAudioSessionConfiguration = AppleVoiceAudioSessionConfiguration( + category: audioSession.category, + mode: audioSession.mode, + options: audioSession.categoryOptions + ) + do { + try audioSession.setCategory(.record, mode: .measurement) + audioSessionWasConfigured = true + try audioSession.setActive(true) + let recorder = try AVAudioRecorder(url: url, settings: [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, + ]) + recorder.prepareToRecord() + guard recorder.record(forDuration: maximumDuration) else { + throw AppleVoiceInputError.recordingFailed + } + self.recorder = recorder + } catch { + restoreAudioSession() + throw error + } + } + + func stopRecording() async throws -> URL { + guard let recordingURL else { throw AppleVoiceInputError.recordingFailed } + recorder?.stop() + recorder = nil + restoreAudioSession() + return recordingURL + } + + func transcribe(recordingURL: URL) async throws -> String { + guard let transcriber else { throw AppleVoiceInputError.unavailable } + let audioFile = try AVAudioFile(forReading: recordingURL) + let analyzer = SpeechAnalyzer(modules: [transcriber]) + self.analyzer = analyzer + + let collector = Task { () throws -> [String] in + var segments: [String] = [] + for try await result in transcriber.results where result.isFinal { + segments.append(String(result.text.characters)) + } + return segments + } + + do { + try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true) + let segments = try await collector.value + if self.analyzer === analyzer { self.analyzer = nil } + return segments.joined(separator: " ").trimmingCharacters( + in: .whitespacesAndNewlines + ) + } catch { + collector.cancel() + await analyzer.cancelAndFinishNow() + _ = try? await collector.value + if self.analyzer === analyzer { self.analyzer = nil } + throw error + } + } + + func cancelTranscription() async { + await analyzer?.cancelAndFinishNow() + } + + func cleanup() async { + recorder?.stop() + recorder = nil + restoreAudioSession() + if let analyzer { + await analyzer.cancelAndFinishNow() + self.analyzer = nil + } + for url in ownedRecordingURLs { + try? fileManager.removeItem(at: url) + } + ownedRecordingURLs.removeAll() + recordingURL = nil + transcriber = nil + } + + private func restoreAudioSession() { + guard let previousAudioSessionConfiguration else { return } + guard audioSessionWasConfigured else { + self.previousAudioSessionConfiguration = nil + return + } + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + try? audioSession.setCategory( + previousAudioSessionConfiguration.category, + mode: previousAudioSessionConfiguration.mode, + options: previousAudioSessionConfiguration.options + ) + self.previousAudioSessionConfiguration = nil + audioSessionWasConfigured = false + } +} diff --git a/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift b/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift new file mode 100644 index 000000000000..943faef1dc53 --- /dev/null +++ b/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift @@ -0,0 +1,332 @@ +import Foundation + +enum CodexArtifactTemplateKind: String, Equatable, Sendable { + case document, presentation, spreadsheet, site + case googleDocs = "google-docs" + case googleSlides = "google-slides" + case googleSheets = "google-sheets" + case image, email, slack + + var label: String { + switch self { + case .document: "Document template" + case .presentation: "Presentation template" + case .spreadsheet: "Spreadsheet template" + case .site: "Site template" + case .googleDocs: "Google Doc template" + case .googleSlides: "Google Slides template" + case .googleSheets: "Google Sheet template" + case .image: "Image template" + case .email: "Email template" + case .slack: "Slack template" + } + } + + func usePrompt(skillName: String) -> String { + let skill = "$\(skillName)" + return switch self { + case .document: "Create a document using this \(skill) about…" + case .presentation: "Create a presentation using the \(skill) template about…" + case .spreadsheet: "Create a spreadsheet using this \(skill) about…" + case .site: "Create a Site using this \(skill) about…" + case .googleDocs: "Create a Google Doc using this \(skill) about…" + case .googleSlides: "Create a Google Slides presentation using this \(skill) about…" + case .googleSheets: "Create a Google Sheet using this \(skill) about…" + case .image: "Create an image using this \(skill) of…" + case .email: "Draft an email using this \(skill) about…" + case .slack: "Draft a Slack message using this \(skill) about…" + } + } +} + +struct CodexArtifactTemplate: Equatable, Sendable { + let kind: CodexArtifactTemplateKind + let displayName: String + let skillDirectory: String + let skillName: String + let galleryKind: String? + + var usePrompt: String { kind.usePrompt(skillName: skillName) } + + var useURL: URL? { + var components = URLComponents() + components.scheme = "t3code" + components.host = "codex-artifact-template" + components.path = "/use" + components.queryItems = [URLQueryItem(name: "prompt", value: usePrompt)] + return components.url + } +} + +enum CodexMarkdownDirectives { + private static let artifactPrefix = "::artifact-template{" + private static let fileCitationPrefix = ":codex-file-citation{" + private static let fileCitationCharacters = Array(fileCitationPrefix) + + static func artifactTemplate(from line: String) -> CodexArtifactTemplate? { + guard line.prefix(while: { $0 == " " }).count < 4, line.first != "\t" else { + return nil + } + let source = line.trimmingCharacters(in: .whitespaces) + guard source.hasPrefix(artifactPrefix), source.hasSuffix("}"), + let attributes = attributes( + in: String(source.dropFirst(artifactPrefix.count).dropLast()) + ), + let kindValue = attributes["artifact_kind"], + let kind = CodexArtifactTemplateKind(rawValue: kindValue), + let displayName = attributes["display_name"]?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !displayName.isEmpty, + let directory = attributes["skill_directory"], isAbsolutePath(directory), + let skillName = attributes["skill_name"], + skillName.hasPrefix("artifact-template-") else { return nil } + + let gallery = attributes["gallery_kind"] + guard gallery == nil || gallery == "imagegen" || gallery == "product-design" else { + return nil + } + return CodexArtifactTemplate( + kind: kind, + displayName: displayName, + skillDirectory: directory, + skillName: skillName, + galleryKind: gallery + ) + } + + static func replacingFileCitations(in source: String) -> String { + // Most messages have no citations. Keep them out of the character-by-character + // link and code scanner, including incomplete Markdown arriving in a stream. + guard source.contains(fileCitationPrefix) else { return source } + let lines = source.components(separatedBy: "\n") + var fence: Character? + var fenceCount = 0 + return lines.map { line in + let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) + if let marker = trimmed.first, marker == "`" || marker == "~" { + let count = trimmed.prefix(while: { $0 == marker }).count + if count >= 3 { + if fence == nil { fence = marker; fenceCount = count } + else if fence == marker, count >= fenceCount { fence = nil } + return line + } + } + let leadingSpaces = line.prefix(while: { $0 == " " }).count + guard fence == nil, leadingSpaces < 4, line.first != "\t", + line.contains(fileCitationPrefix) else { + return line + } + return replacingCitationsInInlineMarkdown(line) + }.joined(separator: "\n") + } + + private static func replacingCitationsInInlineMarkdown(_ line: String) -> String { + let characters = Array(line) + var result = "" + var cursor = 0 + + while cursor < characters.count { + if characters[cursor] == "[", !isEscaped(at: cursor, in: characters), + let end = markdownLinkEnd(in: characters, from: cursor) { + result += String(characters[cursor.. String? { + let prefix = fileCitationPrefix + guard directive.hasPrefix(prefix), directive.hasSuffix("}"), + let values = attributes(in: String(directive.dropFirst(prefix.count).dropLast())), + let rawPath = values["path"] else { return nil } + let path = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + let normalizedPath = path.replacingOccurrences(of: "\\", with: "/") + .replacingOccurrences(of: #"/+$"#, with: "", options: .regularExpression) + let label = normalizedPath.split(separator: "/").last.map(String.init) + ?? (normalizedPath.isEmpty ? "File" : normalizedPath) + var destination = path + .replacingOccurrences(of: "%", with: "%25") + .replacingOccurrences(of: "#", with: "%23") + .replacingOccurrences(of: "?", with: "%3F") + .replacingOccurrences(of: "<", with: "%3C") + .replacingOccurrences(of: ">", with: "%3E") + .replacingOccurrences(of: "\r", with: "%0D") + .replacingOccurrences(of: "\n", with: "%0A") + if let line = values["line_range_start"], + let value = Int(line.trimmingCharacters(in: .whitespacesAndNewlines)), value > 0 { + destination += "#L\(value)" + } + return "[\(escapedMarkdownLabel(label))](<\(destination)>)" + } + + private static func attributes(in source: String) -> [String: String]? { + let chars = Array(source) + var values: [String: String] = [:] + var cursor = 0 + while cursor < chars.count { + while cursor < chars.count, chars[cursor].isWhitespace { cursor += 1 } + guard cursor < chars.count else { break } + let keyStart = cursor + while cursor < chars.count, chars[cursor].isLetter || chars[cursor].isNumber + || chars[cursor] == "_" { cursor += 1 } + guard cursor > keyStart else { return nil } + let key = String(chars[keyStart.. Int? { + guard let labelEnd = closingBracket(in: chars, from: start) else { return nil } + let suffix = labelEnd + 1 + guard suffix < chars.count else { return nil } + if chars[suffix] == "(" { + return closingDelimiter(")", in: chars, after: suffix) + } + if chars[suffix] == "[" { + return closingBracket(in: chars, from: suffix).map { $0 + 1 } + } + return nil + } + + private static func closingBracket(in chars: [Character], from start: Int) -> Int? { + var depth = 1 + var cursor = start + 1 + while cursor < chars.count { + if !isEscaped(at: cursor, in: chars) { + if chars[cursor] == "[" { depth += 1 } + if chars[cursor] == "]" { + depth -= 1 + if depth == 0 { return cursor } + } + } + cursor += 1 + } + return nil + } + + private static func closingDelimiter( + _ delimiter: Character, + in chars: [Character], + after start: Int + ) -> Int? { + var cursor = start + 1 + while cursor < chars.count { + if chars[cursor] == delimiter, !isEscaped(at: cursor, in: chars) { return cursor + 1 } + cursor += 1 + } + return nil + } + + private static func closingBacktickRun( + ofLength length: Int, + after start: Int, + in chars: [Character] + ) -> Int? { + var cursor = start + while cursor < chars.count { + guard chars[cursor] == "`", !isEscaped(at: cursor, in: chars) else { + cursor += 1 + continue + } + let end = chars[cursor...].prefix(while: { $0 == "`" }).count + cursor + if end - cursor == length { return end } + cursor = end + } + return nil + } + + private static func directiveEnd(in chars: [Character], from start: Int) -> Int? { + var cursor = start + fileCitationCharacters.count + var quote: Character? + while cursor < chars.count { + let character = chars[cursor] + if let activeQuote = quote { + if character == activeQuote, !isEscaped(at: cursor, in: chars) { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "}" { + return cursor + } + cursor += 1 + } + return nil + } + + private static func isEscaped(at index: Int, in chars: [Character]) -> Bool { + guard index > 0 else { return false } + var backslashes = 0 + var cursor = index - 1 + while chars[cursor] == "\\" { + backslashes += 1 + guard cursor > 0 else { break } + cursor -= 1 + } + return backslashes.isMultiple(of: 2) == false + } + + private static func escapedMarkdownLabel(_ value: String) -> String { + let escaped = CharacterSet(charactersIn: "\\[]*_`<&") + return value.unicodeScalars.reduce(into: "") { result, scalar in + if escaped.contains(scalar) { result.append("\\") } + result.unicodeScalars.append(scalar) + } + } + + private static func isAbsolutePath(_ path: String) -> Bool { + if path.hasPrefix("/"), !path.hasPrefix("//") { return true } + if path.range(of: #"^[A-Za-z]:[\\/]"#, options: .regularExpression) != nil { return true } + return path.range(of: #"^(?:\\\\[^\\]+\\[^\\]+|//[^/]+/[^/]+)"#, + options: .regularExpression) != nil + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerCommandPopover.swift b/apps/swift-ios/Features/Chat/FeatureComposerCommandPopover.swift new file mode 100644 index 000000000000..f70844ef22d6 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerCommandPopover.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FeatureComposerCommandPopover: View { + let triggerKind: FeatureComposerTriggerKind + let items: [FeatureComposerMenuItem] + let isLoading: Bool + let errorMessage: String? + let pathSearchAvailable: Bool + let onSelect: (FeatureComposerMenuItem) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if items.isEmpty { + Text(emptyMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + Button { + onSelect(item) + } label: { + FeatureComposerCommandRow(item: item, triggerKind: triggerKind) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel(for: item)) + .accessibilityIdentifier("composer-suggestion-\(item.id)") + + if index < items.count - 1 { + Divider() + .overlay(T3Colors.separator) + .padding(.leading, 40) + } + } + } + } + .scrollIndicators(.hidden) + } + } + .frame(height: menuHeight, alignment: .top) + .background(T3Colors.surfaceRaised.opacity(0.98)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + .accessibilityLabel(groupLabel) + .accessibilityIdentifier("composer-command-menu") + } + + private var groupLabel: String { + switch triggerKind { + case .slashCommand: return "Commands" + case .model: return "Models" + case .skill: return "Skills" + case .path: return "Files" + } + } + + private var emptyMessage: String { + if isLoading { return "Searching files…" } + if let errorMessage, !errorMessage.isEmpty { return errorMessage } + switch triggerKind { + case .slashCommand: return "No matching commands" + case .model: return "No matching models" + case .skill: return "No matching skills" + case .path where !pathSearchAvailable: return "File search unavailable" + case .path: return "Type a file name" + } + } + + private func accessibilityLabel(for item: FeatureComposerMenuItem) -> String { + item.description.isEmpty ? item.label : "\(item.label), \(item.description)" + } + + private var menuHeight: CGFloat { + Self.height(forItemCount: items.count) + } + + /// The menu's height is deterministic so the composer can position the + /// menu fully above its own surface without measuring it. + static func height(forItemCount count: Int) -> CGFloat { + guard count > 0 else { return 48 } + let rowHeight: CGFloat = 47 + return min(CGFloat(count) * rowHeight, 188) + } +} + +private struct FeatureComposerCommandRow: View { + let item: FeatureComposerMenuItem + let triggerKind: FeatureComposerTriggerKind + + var body: some View { + HStack(spacing: 10) { + Image(systemName: iconName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 17) + + Text(displayLabel) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .layoutPriority(1) + + if !item.description.isEmpty { + Text(item.description) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Spacer(minLength: 0) + } + } + .padding(.horizontal, 14) + .frame(minHeight: 46) + .contentShape(Rectangle()) + } + + private var iconName: String { + switch item { + case .modelCommand, .model: return "cpu" + case .providerCommand: return "terminal" + case let .skill(skill): return skill.source.systemImage + case let .path(entry): return entry.kind == .directory ? "folder" : "doc" + } + } + + private var displayLabel: String { + if triggerKind == .slashCommand, case let .skill(skill) = item { + return "/skill:\(skill.displayName ?? skill.name)" + } + return item.label + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerImageDrop.swift b/apps/swift-ios/Features/Chat/FeatureComposerImageDrop.swift new file mode 100644 index 000000000000..0dca4fa768f7 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerImageDrop.swift @@ -0,0 +1,104 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// How many of an incoming batch of images the composer can take, separated +/// from the SwiftUI plumbing so the cap and the overflow accounting can be +/// tested without a live drag session or pasteboard. +/// +/// Paste and drop are two doors into the same room: both produce item +/// providers, both land in the attachment strip, and both must respect the +/// attachment cap while earlier images are still being prepared. +struct FeatureComposerImageIntakePlan: Equatable { + let acceptedCount: Int + let firstOrdinal: Int + let droppedCount: Int + + /// Returns nil when nothing can be accepted, either because the batch is + /// empty or the cap is already spent by attached and in-flight images. + static func forProviders( + providerCount: Int, + attachmentCount: Int, + pendingCount: Int, + maximumCount: Int = FeatureImageAttachmentLimits.maximumCount + ) -> FeatureComposerImageIntakePlan? { + guard providerCount > 0 else { return nil } + let remaining = max(0, maximumCount - attachmentCount - pendingCount) + let accepted = min(providerCount, remaining) + guard accepted > 0 else { return nil } + + return FeatureComposerImageIntakePlan( + acceptedCount: accepted, + firstOrdinal: attachmentCount + pendingCount + 1, + droppedCount: providerCount - accepted + ) + } +} + +/// Accepts images dragged onto the composer from another app. +/// +/// Registering only `.image` lets the drag session itself reject anything +/// else, so a dropped PDF never lands and there is no failure to explain +/// afterwards. +struct FeatureComposerImageDrop: ViewModifier { + let isEnabled: Bool + let shape: RoundedRectangle + let onDropImages: ([NSItemProvider]) -> Bool + + @State private var isTargeted = false + + func body(content: Content) -> some View { + content + .overlay { + if isTargeted { + shape + .fill(T3Colors.accent.opacity(0.1)) + .overlay { + shape.strokeBorder(T3Colors.accent, lineWidth: 2) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .animation(.easeOut(duration: 0.12), value: isTargeted) + .onDrop( + of: [.image], + delegate: FeatureComposerImageDropDelegate( + isEnabled: isEnabled, + isTargeted: $isTargeted, + onDropImages: onDropImages + ) + ) + } +} + +/// Tracks targeting through explicit enter and exit callbacks so the highlight +/// cannot outlive the session, and states the drop operation outright. +private struct FeatureComposerImageDropDelegate: DropDelegate { + let isEnabled: Bool + @Binding var isTargeted: Bool + let onDropImages: ([NSItemProvider]) -> Bool + + func validateDrop(info: DropInfo) -> Bool { + isEnabled && info.hasItemsConforming(to: [.image]) + } + + func dropEntered(info: DropInfo) { + isTargeted = true + } + + // A system-sourced drag (the screenshot thumbnail, Photos) is refused + // under SwiftUI's default proposal and the session dies mid-air with the + // highlight still lit. Asking for a copy explicitly is what lets it land. + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .copy) + } + + func dropExited(info: DropInfo) { + isTargeted = false + } + + func performDrop(info: DropInfo) -> Bool { + isTargeted = false + return onDropImages(info.itemProviders(for: [.image])) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift new file mode 100644 index 000000000000..0c113252ff92 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift @@ -0,0 +1,474 @@ +import Foundation + +/// Provider- and project-scoped data used by the composer command menu. +/// The feature layer supplies these values because the composer should not +/// know how a particular environment fetches provider or workspace data. +struct FeatureComposerPowerFeatures { + typealias PathSearch = (_ query: String) async throws -> [FeatureComposerPathEntry] + + var slashCommands: [FeatureProviderSlashCommand] + var skills: [FeatureProviderSkill] + var canCompactContext: Bool + var pathSearchScopeID: String + var searchPaths: PathSearch? + + init( + slashCommands: [FeatureProviderSlashCommand] = [], + skills: [FeatureProviderSkill] = [], + canCompactContext: Bool = false, + pathSearchScopeID: String = "", + searchPaths: PathSearch? = nil + ) { + self.slashCommands = slashCommands + self.skills = skills + self.canCompactContext = canCompactContext + self.pathSearchScopeID = pathSearchScopeID + self.searchPaths = searchPaths + } + + static var disabled: FeatureComposerPowerFeatures { FeatureComposerPowerFeatures() } + + var enabledSkills: [FeatureProviderSkill] { + skills.filter(\.isEnabled) + } +} + +enum FeatureContextCompaction { + static func isCommand(_ text: String, hasAttachments: Bool) -> Bool { + !hasAttachments + && text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/compact" + } + + static func canStart(in detail: FeatureThreadDetail?, isBusy: Bool) -> Bool { + guard let detail, !isBusy, detail.isCompacting != true, + detail.approvals.isEmpty, detail.userInputs.isEmpty else { return false } + switch detail.thread.state { + case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: + return false + case .idle, .completed, .failed: + break + } + + return detail.messages.contains { message in + guard message.role == .user, message.state != .queued else { return false } + return !message.attachments.isEmpty + || (!message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isCommand(message.text, hasAttachments: false)) + } || (detail.page?.hasMore == true && detail.thread.settlementFacts?.latestUserMessageAt != nil) + } +} + +public struct FeatureProviderSlashCommand: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { name } + public let name: String + public let description: String? + public let inputHint: String? + + public init( + name: String, + description: String? = nil, + inputHint: String? = nil + ) { + self.name = name + self.description = description + self.inputHint = inputHint + } +} + +public struct FeatureProviderSkill: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { name } + public let name: String + public let displayName: String? + public let description: String? + public let shortDescription: String? + public let path: String + public let scope: String? + public let isEnabled: Bool + public var userInvocationOnly: Bool? = nil + public var userInvocable: Bool? = nil + + var invocation: String { "\(userInvocationOnly == true ? "/" : "$")\(name) " } + + public init( + name: String, + displayName: String? = nil, + description: String? = nil, + shortDescription: String? = nil, + path: String = "", + scope: String? = nil, + isEnabled: Bool = true + ) { + self.name = name + self.displayName = displayName + self.description = description + self.shortDescription = shortDescription + self.path = path + self.scope = scope + self.isEnabled = isEnabled + } + + var source: FeatureProviderSkillSource { + let normalizedPath = path.replacingOccurrences(of: "\\", with: "/") + if normalizedPath.contains("/.codex/plugins/") + || normalizedPath.contains("/.agents/plugins/") { + return .app + } + switch scope?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "repo", "repository": return .repository + case "project", "workspace", "local": return .project + case "user", "personal": return .personal + case "system": return .system + default: return .other + } + } +} + +enum FeatureProviderSkillSource: String, Sendable, Equatable { + case app + case repository + case project + case personal + case system + case other + + var systemImage: String { + switch self { + case .app: "square.grid.2x2" + case .repository, .project: "folder" + case .personal: "person.crop.circle" + case .system: "gearshape" + case .other: "shippingbox" + } + } +} + +struct FeatureComposerPathEntry: Identifiable, Sendable, Equatable, Hashable { + enum Kind: String, Sendable, Equatable, Hashable { + case file + case directory + } + + var id: String { path } + let path: String + let kind: Kind + + init(path: String, kind: Kind) { + self.path = path + self.kind = kind + } + + var name: String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + return normalized.split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? path + } + + var parentPath: String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + let parts = normalized.split(separator: "/", omittingEmptySubsequences: true) + return parts.dropLast().joined(separator: "/") + } +} + +enum FeatureComposerTriggerKind: Sendable, Equatable { + case slashCommand + case model + case skill + case path +} + +struct FeatureComposerTrigger: Sendable, Equatable { + let kind: FeatureComposerTriggerKind + let query: String + let range: Range +} + +struct FeatureCodexFeedbackCommand: Sendable, Equatable { + private static let expression = try? NSRegularExpression( + pattern: #"^/feedback(?:\s+([\s\S]*))?$"#, + options: [.caseInsensitive] + ) + + let reason: String? + + static func parse(_ text: String) -> Self? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("/feedback"), + let expression, + let match = expression.firstMatch( + in: trimmed, + range: NSRange(trimmed.startIndex..., in: trimmed) + ) else { + return nil + } + guard match.range(at: 1).location != NSNotFound, + let reasonRange = Range(match.range(at: 1), in: trimmed) else { + return Self(reason: nil) + } + let reason = trimmed[reasonRange].trimmingCharacters(in: .whitespacesAndNewlines) + return Self(reason: reason.isEmpty ? nil : reason) + } +} + +/// Mirrors the shared web/mobile trigger grammar while keeping this target +/// independent of the TypeScript runtime. +enum FeatureComposerTriggerParser { + static func detect(in text: String, cursorOffset: Int? = nil) -> FeatureComposerTrigger? { + let cursor = min(max(cursorOffset ?? text.count, 0), text.count) + let cursorIndex = text.index(text.startIndex, offsetBy: cursor) + let prefix = text[.. text.startIndex { + let previous = text.index(before: tokenStartIndex) + if text[previous].isWhitespace { break } + tokenStartIndex = previous + } + let token = String(text[tokenStartIndex.., + in text: String, + with replacement: String + ) -> String { + let lower = min(max(range.lowerBound, 0), text.count) + let upper = min(max(range.upperBound, lower), text.count) + let start = text.index(text.startIndex, offsetBy: lower) + let end = text.index(text.startIndex, offsetBy: upper) + return String(text[.. String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + let basename = normalized.split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? path + let label = basename + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "[", with: "\\[") + .replacingOccurrences(of: "]", with: "\\]") + return "[\(label)](\(encodeDestination(path)))" + } + + private static func encodeDestination(_ path: String) -> String { + let unescaped = Set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789;,/:@&=+$-_.!~*'" + ) + return path.utf8.map { byte -> String in + guard byte < 128, + let scalar = UnicodeScalar(Int(byte)), + unescaped.contains(Character(String(scalar))) else { + return String(format: "%%%02X", byte) + } + return String(scalar) + }.joined() + } +} + +enum FeatureComposerMenuItem: Identifiable, Sendable, Equatable { + case modelCommand + case model(selection: FeatureSelection, label: String, description: String) + case providerCommand(FeatureProviderSlashCommand) + case skill(FeatureProviderSkill) + case path(FeatureComposerPathEntry) + + var id: String { + switch self { + case .modelCommand: "command:model" + case let .model(selection, _, _): "model:\(selection.providerID):\(selection.modelID)" + case let .providerCommand(command): "command:\(command.id)" + case let .skill(skill): "skill:\(skill.id)" + case let .path(entry): "path:\(entry.path)" + } + } + + var label: String { + switch self { + case .modelCommand: "/model" + case let .model(_, label, _): label + case let .providerCommand(command): "/\(command.name)" + case let .skill(skill): skill.displayName ?? skill.name + case let .path(entry): entry.name + } + } + + var description: String { + switch self { + case .modelCommand: "Switch model" + case let .model(_, _, description): description + case let .providerCommand(command): + command.description ?? command.inputHint ?? "" + case let .skill(skill): + skill.shortDescription ?? skill.description ?? skill.scope ?? "" + case let .path(entry): entry.parentPath + } + } +} + +enum FeatureComposerMenuBuilder { + private static func normalizedName(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func enabledSkills( + in skills: [FeatureProviderSkill] + ) -> [FeatureProviderSkill] { + var seenNames = Set() + return skills.filter { skill in + guard skill.isEnabled else { return false } + return seenNames.insert(normalizedName(skill.name)).inserted + } + } + + static func items( + trigger: FeatureComposerTrigger, + providers: [FeatureProvider], + currentSelection: FeatureSelection?, + threadSelection: FeatureSelection?, + powerFeatures: FeatureComposerPowerFeatures, + pathEntries: [FeatureComposerPathEntry] + ) -> [FeatureComposerMenuItem] { + switch trigger.kind { + case .slashCommand: + let query = trigger.query.lowercased() + let normalizedSkillQuery = query.hasPrefix("skill:") + ? String(query.dropFirst("skill:".count)) + : query + var items: [FeatureComposerMenuItem] = [] + if query.isEmpty || "model".contains(query) { + items.append(.modelCommand) + } + let enabledSkills = enabledSkills(in: powerFeatures.skills) + let skills = enabledSkills + .filter { $0.userInvocable != false } + .filter { skill in + guard !normalizedSkillQuery.isEmpty else { return true } + return [skill.name, skill.displayName, skill.shortDescription, skill.description] + .compactMap { $0 } + .contains { $0.localizedCaseInsensitiveContains(normalizedSkillQuery) } + } + .sorted { + ($0.displayName ?? $0.name).localizedStandardCompare($1.displayName ?? $1.name) + == .orderedAscending + } + let enabledSkillNames = Set(enabledSkills.map { normalizedName($0.name) }) + let excludedCommandNames = Set(["model", "plan", "default"].map(normalizedName)) + let commands = powerFeatures.slashCommands + .filter { !excludedCommandNames.contains(normalizedName($0.name)) } + .filter { normalizedName($0.name) != "compact" || powerFeatures.canCompactContext } + .filter { !enabledSkillNames.contains(normalizedName($0.name)) } + .filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + items.append(contentsOf: commands.map(FeatureComposerMenuItem.providerCommand)) + items.append(contentsOf: skills.map(FeatureComposerMenuItem.skill)) + return Array(items.prefix(20)) + + case .model: + let query = trigger.query.trimmingCharacters(in: .whitespacesAndNewlines) + return providers + .filter(\.isAvailable) + .filter { provider in + threadSelection == nil || provider.id == threadSelection?.providerID + } + .flatMap { provider in + provider.models + .filter { model in + guard provider.requiresNewThreadForModelChange, + let threadSelection else { return true } + return model.id == threadSelection.modelID + } + .map { model in + ( + item: FeatureComposerMenuItem.model( + selection: FeatureSelection( + providerID: provider.id, + modelID: model.id, + options: currentSelection?.providerID == provider.id + && currentSelection?.modelID == model.id + ? currentSelection?.options ?? [] + : DailyUXModelOptions.defaults(for: model) + ), + label: model.name, + description: provider.name + ), + searchText: "\(provider.name) \(model.name) \(model.id)" + ) + } + } + .filter { query.isEmpty || $0.searchText.localizedCaseInsensitiveContains(query) } + .prefix(20) + .map(\.item) + + case .skill: + let query = trigger.query.trimmingCharacters(in: .whitespacesAndNewlines) + return enabledSkills(in: powerFeatures.skills) + .filter { skill in + guard !query.isEmpty else { return true } + return [skill.name, skill.displayName, skill.shortDescription, skill.description] + .compactMap { $0 } + .contains { $0.localizedCaseInsensitiveContains(query) } + } + .sorted { + ($0.displayName ?? $0.name).localizedStandardCompare($1.displayName ?? $1.name) + == .orderedAscending + } + .prefix(20) + .map(FeatureComposerMenuItem.skill) + + case .path: + return pathEntries + .uniquedByPath() + .prefix(20) + .map(FeatureComposerMenuItem.path) + } + } +} + +private extension Array where Element == FeatureComposerPathEntry { + func uniquedByPath() -> [Element] { + var seen = Set() + return filter { seen.insert($0.path).inserted } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift new file mode 100644 index 000000000000..5427118fafd6 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -0,0 +1,506 @@ +import SwiftUI + +struct FeatureComposerApprovalPanel: View { + let approval: FeatureApproval + let position: Int + let total: Int + let isResponding: Bool + let onDecision: (FeatureApprovalDecision) -> Void + let onCancelTurn: () -> Void + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text("Pending approval") + .font(T3Typography.eyebrow) + .tracking(1.3) + .textCase(.uppercase) + .foregroundStyle(T3Colors.warning) + + Spacer() + + if total > 1 { + Text("\(position)/\(total)") + .font(T3Typography.supportingStrong.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + } + + Text(approval.appName ?? approval.title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 5) { + Text(detailLabel) + .font(T3Typography.supportingStrong) + .tracking(0.7) + .textCase(.uppercase) + .foregroundStyle(T3Colors.textTertiary) + + Text(approval.detail) + .font( + approval.kind == .command + ? T3Typography.code + : T3Typography.threadBody + ) + .foregroundStyle(T3Colors.textPrimary.opacity(0.92)) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .t3CodeTextSize(approval.kind == .command) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(T3Colors.border, lineWidth: 1) + } + .padding(.top, 9) + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background(T3Colors.subtle) + + Divider().overlay(T3Colors.separator) + + VStack(spacing: 9) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 7) { + ForEach(positiveOptions) { option in + approvalButton( + option.label, + background: option.decision == .allowOnce ? T3Colors.accent : .clear, + border: option.decision == .allowOnce ? .clear : T3Colors.border, + foreground: option.decision == .allowOnce ? .white : T3Colors.textPrimary, + action: { onDecision(option.decision) } + ) + } + } + + HStack(spacing: 26) { + ForEach(negativeOptions) { option in + Button(option.label, role: .destructive) { + onDecision(option.decision) + } + .foregroundStyle(T3Colors.danger) + } + + Button("Cancel turn", action: onCancelTurn) + .foregroundStyle(T3Colors.textTertiary) + } + .font(T3Typography.supportingStrong) + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 11) + } + .disabled(isResponding) + .opacity(isResponding ? 0.56 : 1) + .accessibilityElement(children: .contain) + } + + private func approvalButton( + _ title: String, + background: Color, + border: Color = .clear, + foreground: Color = .white, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(T3Typography.control.weight(.semibold)) + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .frame(height: T3Metrics.minimumTapTarget) + .background(background, in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(border, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + private var detailLabel: String { + switch approval.kind { + case .command: "Command" + case .fileRead: "File access" + case .fileChange: "File change" + case .mcpElicitation: "App access" + case .patch: "Patch" + case .other: "Details" + } + } + + private var options: [FeatureApprovalOption] { + approval.options ?? [ + FeatureApprovalOption(decision: .allowOnce, label: "Approve once"), + FeatureApprovalOption(decision: .allowForSession, label: "Allow session"), + FeatureApprovalOption(decision: .deny, label: "Decline"), + ] + } + + private var positiveOptions: [FeatureApprovalOption] { + options.filter { $0.decision != .deny && $0.decision != .cancel } + } + + private var negativeOptions: [FeatureApprovalOption] { + options.filter { $0.decision == .deny || $0.decision == .cancel } + } +} + +struct FeatureComposerUserInputPanel: View { + let input: FeatureUserInput + let isResponding: Bool + let onSubmit: ([String: FeatureInputAnswer]) -> Void + + @State private var answers: [String: FeatureInputAnswer] = [:] + @State private var questionIndex = 0 + + var body: some View { + Group { + if let question = activeQuestion { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(question.header) + .font(T3Typography.eyebrow) + .tracking(1.3) + .textCase(.uppercase) + .foregroundStyle(T3Colors.accent) + + Spacer() + + if input.questions.count > 1 { + Text("\(questionIndex + 1)/\(input.questions.count)") + .font(T3Typography.supportingStrong.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + } + + Text(question.question) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 5) + + if question.allowsMultiple { + Text("Select one or more options") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .padding(.top, 4) + } + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.subtle) + + Divider().overlay(T3Colors.separator) + + ScrollView { + VStack(spacing: 6) { + ForEach( + Array(question.options.enumerated()), + id: \.element.label + ) { index, option in + optionButton(option, number: index + 1, question: question) + } + } + .padding(.horizontal, 10) + .padding(.top, 10) + } + .frame(maxHeight: 320) + .scrollIndicators(.hidden) + + HStack(spacing: 8) { + Image(systemName: "pencil") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + + TextField( + "Write custom answer", + text: answerBinding(for: question), + axis: .vertical + ) + .font(T3Typography.composer) + .lineLimit(1...4) + .submitLabel(.return) + } + .padding(.horizontal, 12) + .frame(minHeight: T3Metrics.minimumTapTarget) + .background( + T3Colors.input, + in: RoundedRectangle(cornerRadius: 11) + ) + .overlay { + RoundedRectangle(cornerRadius: 11) + .stroke(T3Colors.inputBorder, lineWidth: 1) + } + .padding(.horizontal, 10) + .padding(.top, 7) + + HStack(spacing: 8) { + if questionIndex > 0 { + Button("Back") { + questionIndex -= 1 + } + .font(T3Typography.control.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame( + minWidth: T3Metrics.minimumTapTarget, + minHeight: T3Metrics.minimumTapTarget + ) + } + + Spacer() + + Button(action: advanceOrSubmit) { + Text(isLastQuestion ? "Submit" : "Next question") + .font(T3Typography.control.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .frame(height: T3Metrics.minimumTapTarget) + .background(T3Colors.accent, in: Capsule()) + } + .buttonStyle(.plain) + .disabled(!canAdvance) + .opacity(canAdvance ? 1 : 0.3) + } + .padding(.horizontal, 10) + .padding(.top, 9) + .padding(.bottom, 11) + } + .disabled(isResponding) + .opacity(isResponding ? 0.56 : 1) + } + } + .onChange(of: input.id) { + answers = [:] + questionIndex = 0 + } + .onChange(of: questionIDs) { previousIDs, currentIDs in + questionIndex = FeatureComposerQuestionReconciliation.index( + current: questionIndex, + previousQuestionIDs: previousIDs, + currentQuestionIDs: currentIDs + ) + answers = FeatureComposerQuestionReconciliation.answers( + answers, + currentQuestionIDs: currentIDs + ) + } + } + + private var activeQuestion: FeatureInputQuestion? { + guard input.questions.indices.contains(questionIndex) else { return nil } + return input.questions[questionIndex] + } + + private var questionIDs: [String] { + input.questions.map(\.id) + } + + private var isLastQuestion: Bool { + questionIndex >= input.questions.count - 1 + } + + private var canAdvance: Bool { + guard let activeQuestion else { return false } + return normalizedAnswer(for: activeQuestion.id) != nil + } + + private var normalizedAnswers: [String: FeatureInputAnswer]? { + var result: [String: FeatureInputAnswer] = [:] + for question in input.questions { + guard let answer = normalizedAnswer(for: question.id) else { return nil } + result[question.id] = answer + } + return result + } + + private func optionButton( + _ option: FeatureInputOption, + number: Int, + question: FeatureInputQuestion + ) -> some View { + let isSelected = isOptionSelected(option.label, for: question) + + return Button { + select(option.label, for: question) + } label: { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(option.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + + if !option.detail.isEmpty, option.detail != option.label { + Text(option.detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 8) + + if isSelected { + Image(systemName: "checkmark") + .font(T3Typography.supporting.weight(.bold)) + .foregroundStyle(T3Colors.accent) + } else if number <= 9 { + Text("\(number)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 20, height: 20) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(T3Colors.border, lineWidth: 1) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, minHeight: T3Metrics.minimumTapTarget, alignment: .leading) + .background( + isSelected ? T3Colors.accent.opacity(0.12) : T3Colors.subtle, + in: RoundedRectangle(cornerRadius: 10) + ) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke( + isSelected ? T3Colors.accent.opacity(0.46) : Color.clear, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + } + + private func answerBinding(for question: FeatureInputQuestion) -> Binding { + Binding( + get: { + FeatureComposerCustomAnswer.text( + in: answers[question.id], + for: question + ) + }, + set: { + answers[question.id] = FeatureComposerCustomAnswer.replacingText( + in: answers[question.id], + with: $0, + for: question + ) + } + ) + } + + private func select(_ label: String, for question: FeatureInputQuestion) { + answers[question.id] = (answers[question.id] ?? .selections([])) + .togglingOption(label, allowsMultiple: question.allowsMultiple) + if question.allowsMultiple { + return + } + guard !isLastQuestion else { return } + let selectedQuestionID = question.id + Task { @MainActor in + await Task.yield() + guard activeQuestion?.id == selectedQuestionID, + !isLastQuestion else { + return + } + questionIndex += 1 + } + } + + private func advanceOrSubmit() { + guard canAdvance else { return } + if !isLastQuestion { + questionIndex += 1 + } else if let normalizedAnswers { + onSubmit(normalizedAnswers) + } else if let unanswered = input.questions.firstIndex(where: { + normalizedAnswer(for: $0.id) == nil + }) { + questionIndex = unanswered + } + } + + private func normalizedAnswer(for questionID: String) -> FeatureInputAnswer? { + answers[questionID]?.normalized + } + + private func isOptionSelected(_ label: String, for question: FeatureInputQuestion) -> Bool { + switch answers[question.id] { + case let .text(value): + return !question.allowsMultiple && value == label + case let .selections(values): + return values.contains(label) + case nil: + return false + } + } +} + +enum FeatureComposerCustomAnswer { + static func text( + in answer: FeatureInputAnswer?, + for question: FeatureInputQuestion + ) -> String { + let optionLabels = Set(question.options.map(\.label)) + switch answer { + case let .text(value): + return optionLabels.contains(value) ? "" : value + case let .selections(values): + return values.first(where: { !optionLabels.contains($0) }) ?? "" + case nil: + return "" + } + } + + static func replacingText( + in answer: FeatureInputAnswer?, + with text: String, + for question: FeatureInputQuestion + ) -> FeatureInputAnswer { + guard question.allowsMultiple else { return .text(text) } + let optionLabels = Set(question.options.map(\.label)) + let selectedOptions: [String] + if case let .selections(values) = answer { + selectedOptions = values.filter(optionLabels.contains) + } else { + selectedOptions = [] + } + return .selections(text.isEmpty ? selectedOptions : selectedOptions + [text]) + } +} + +enum FeatureComposerQuestionReconciliation { + static func index( + current: Int, + previousQuestionIDs: [String], + currentQuestionIDs: [String] + ) -> Int { + guard !currentQuestionIDs.isEmpty else { return 0 } + if previousQuestionIDs.indices.contains(current), + let retained = currentQuestionIDs.firstIndex( + of: previousQuestionIDs[current] + ) { + return retained + } + return min(max(0, current), currentQuestionIDs.count - 1) + } + + static func answers( + _ answers: [String: FeatureInputAnswer], + currentQuestionIDs: [String] + ) -> [String: FeatureInputAnswer] { + let liveIDs = Set(currentQuestionIDs) + return answers.filter { liveIDs.contains($0.key) } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift new file mode 100644 index 000000000000..a7c67360d44a --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift @@ -0,0 +1,704 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers +import Observation + +/// The composer's text entry is a UIKit text view because SwiftUI's text +/// inputs expose no paste hook on iOS: the long-press Paste menu can never +/// offer an image. Bridging `UITextView` buys the native paste menu, image +/// paste, and internal scrolling once the draft outgrows its viewport cap. +struct FeatureComposerTextInput: UIViewRepresentable { + @Binding var text: String + // A plain binding, not `FocusState`: SwiftUI ignores writes to a + // `FocusState` that no `.focused()` view registers with, and a + // representable cannot register. The UIKit responder state is the source + // of truth and this binding mirrors it for the hosts. + @Binding var focused: Bool + let placeholder: String + let acceptsImages: Bool + let isReadOnly: Bool + let skills: [FeatureProviderSkill] + let selectionRequest: FeatureComposerTextSelectionRequest? + let onSelectionChange: (NSRange) -> Void + let onPasteImages: ([NSItemProvider]) -> Void + let onDismissKeyboard: (() -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> FeatureComposerUITextView { + let textView = FeatureComposerUITextView() + textView.delegate = context.coordinator + textView.acceptsImages = acceptsImages + textView.isReadOnly = isReadOnly + textView.onPasteImages = onPasteImages + textView.onDismissKeyboard = onDismissKeyboard + if onDismissKeyboard != nil { + textView.installDismissPanRecognizer() + } + textView.backgroundColor = .clear + textView.textColor = T3Colors.uiTextPrimary + textView.tintColor = T3Colors.uiAccent + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.adjustsFontForContentSizeCategory = true + textView.smartQuotesType = .no + textView.smartDashesType = .no + // Outer padding belongs to SwiftUI. The bottom inset keeps the final + // insertion point above the composer controls. + textView.configureComposerViewport() + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = true + // Deliberately not `keyboardDismissMode = .interactive`: the capped + // input sits directly above the keyboard, so scrolling up through a + // long draft drags into the keyboard's frame and yanks it around. + // Dismissal belongs to the pan recognizer below, which only fires + // for a drag that begins with the draft at its top. + textView.accessibilityIdentifier = "message-composer" + updateAccessibility(textView) + return textView + } + + func updateUIView(_ textView: FeatureComposerUITextView, context: Context) { + context.coordinator.parent = self + textView.acceptsImages = acceptsImages + textView.onPasteImages = onPasteImages + textView.onDismissKeyboard = onDismissKeyboard + textView.isReadOnly = isReadOnly + + let previousAttributedText = textView.attributedText ?? NSAttributedString() + let previousText = FeatureInlineSkillProjection.plainText(from: previousAttributedText) + let previousSelection = FeatureInlineSkillProjection.plainRange( + for: textView.selectedRange, + in: previousAttributedText + ) + let shouldApplySelection = selectionRequest.map { + context.coordinator.lastAppliedSelectionRequestID != $0.id + } ?? false + context.coordinator.isApplyingProgrammaticUpdate = true + defer { + context.coordinator.isApplyingProgrammaticUpdate = false + onSelectionChange(FeatureInlineSkillProjection.plainRange( + for: textView.selectedRange, + in: textView.attributedText + )) + } + let targetSelection: NSRange + if shouldApplySelection, let selectionRequest { + targetSelection = NSRange( + location: min(selectionRequest.location, text.utf16.count), + length: 0 + ) + } else if previousText != text { + let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate( + previousText: previousText, + newText: text, + selectedLocation: previousSelection.location + ) + let length = previousText.isEmpty + ? 0 + : min(previousSelection.length, text.utf16.count - location) + targetSelection = NSRange(location: location, length: length) + } else { + targetSelection = previousSelection + } + + let rebuiltText = context.coordinator.synchronizeInlineSkills( + in: textView, + source: text, + selection: targetSelection + ) + if shouldApplySelection, let selectionRequest { + textView.selectedRange = FeatureInlineSkillProjection.displayRange( + for: targetSelection, + in: textView.attributedText + ) + textView.scrollSelectionIntoView() + context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id + } else if rebuiltText { + textView.scrollSelectionIntoView() + } + updateAccessibility(textView) + + if context.coordinator.lastAppliedFocus != focused { + context.coordinator.lastAppliedFocus = focused + if focused, !textView.isFirstResponder { + textView.becomeFirstResponderWhenAttached() + } else if !focused { + textView.cancelPendingFirstResponder() + if textView.isFirstResponder { + textView.resignFirstResponder() + } + } + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: FeatureComposerUITextView, + context: Context + ) -> CGSize? { + guard let width = proposal.width, width.isFinite else { return nil } + let fittingSize = uiView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + return CGSize( + width: width, + height: FeatureComposerTextInputSizing.height( + fittingHeight: fittingSize.height, + lineHeight: uiView.font?.lineHeight ?? 22, + availableHeight: proposal.height + ) + ) + } + + private func updateAccessibility(_ textView: FeatureComposerUITextView) { + textView.accessibilityLabel = "Message agent" + textView.accessibilityHint = acceptsImages + ? "Enter a message or paste images" + : "Enter a message" + textView.accessibilityValue = text.isEmpty ? placeholder : text + } + + final class Coordinator: NSObject, UITextViewDelegate { + private struct UndoSnapshot: Equatable { + let source: String + let selection: NSRange + let trailingSkill: FeatureInlineSkillDescriptor? + } + + var parent: FeatureComposerTextInput + var lastAppliedFocus: Bool? + var lastAppliedSelectionRequestID: UUID? + var isApplyingProgrammaticUpdate = false + private var isSynchronizingInlineSkills = false + private var pendingUndoSnapshot: UndoSnapshot? + private weak var disabledUndoManager: UndoManager? + + init(_ parent: FeatureComposerTextInput) { + self.parent = parent + } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard !parent.isReadOnly else { return false } + guard !isApplyingProgrammaticUpdate, !isSynchronizingInlineSkills else { + return true + } + if pendingUndoSnapshot == nil { + pendingUndoSnapshot = undoSnapshot(in: textView) + } + if disabledUndoManager == nil, + let undoManager = textView.undoManager, + undoManager.isUndoRegistrationEnabled { + undoManager.disableUndoRegistration() + disabledUndoManager = undoManager + } + return true + } + + func textViewDidChange(_ textView: UITextView) { + restoreUndoRegistration() + guard !isApplyingProgrammaticUpdate else { return } + guard !isSynchronizingInlineSkills else { return } + let source = FeatureInlineSkillProjection.plainText(from: textView.attributedText) + if parent.text != source { + parent.text = source + } + guard textView.markedTextRange == nil, + let composerTextView = textView as? FeatureComposerUITextView else { + return + } + let selection = FeatureInlineSkillProjection.plainRange( + for: textView.selectedRange, + in: textView.attributedText + ) + _ = synchronizeInlineSkills( + in: composerTextView, + source: source, + selection: selection + ) + let updatedSnapshot = undoSnapshot(in: textView) + if let pendingUndoSnapshot, pendingUndoSnapshot != updatedSnapshot { + registerUndo( + restoring: pendingUndoSnapshot, + inverse: updatedSnapshot, + in: composerTextView + ) + } + pendingUndoSnapshot = nil + composerTextView.scrollSelectionIntoView() + } + + @discardableResult + func synchronizeInlineSkills( + in textView: FeatureComposerUITextView, + source: String, + selection: NSRange, + preservingTrailing restoredTrailingSkill: FeatureInlineSkillDescriptor? = nil + ) -> Bool { + // Replacing attributed text would commit or discard active IME composition. + guard textView.markedTextRange == nil else { return false } + let currentText = textView.attributedText ?? NSAttributedString() + let currentSource = FeatureInlineSkillProjection.plainText(from: currentText) + let currentSignatures = FeatureInlineSkillProjection.signatures(in: currentText) + let preservedTrailing = restoredTrailingSkill ?? ( + currentSource == source ? currentSignatures.last?.descriptor : nil + ) + let descriptors = FeatureInlineSkillParser.descriptors( + in: source, + skills: parent.skills, + allowsEndBoundary: false, + preservingTrailing: preservedTrailing + ) + let font = textView.font ?? UIFont.preferredFont(forTextStyle: .body) + let desiredSignatures = FeatureInlineSkillPillRenderer.signatures( + for: descriptors, + font: font, + traits: textView.traitCollection + ) + guard currentSource != source || currentSignatures != desiredSignatures else { + return false + } + + let baseAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: T3Colors.uiTextPrimary, + ] + let attributedText = FeatureInlineSkillPillRenderer.attributedText( + source: source, + descriptors: descriptors, + baseAttributes: baseAttributes, + font: font, + traits: textView.traitCollection + ) + isSynchronizingInlineSkills = true + textView.attributedText = attributedText + textView.selectedRange = FeatureInlineSkillProjection.displayRange( + for: selection, + in: attributedText + ) + textView.typingAttributes = baseAttributes + isSynchronizingInlineSkills = false + return true + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingProgrammaticUpdate, !isSynchronizingInlineSkills else { return } + let selection = FeatureInlineSkillProjection.plainRange( + for: textView.selectedRange, + in: textView.attributedText + ) + parent.onSelectionChange(selection) + } + + func textViewDidBeginEditing(_ textView: UITextView) { + lastAppliedFocus = true + if !parent.focused { + parent.focused = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + restoreUndoRegistration() + pendingUndoSnapshot = nil + lastAppliedFocus = false + if parent.focused { + parent.focused = false + } + } + + private func undoSnapshot(in textView: UITextView) -> UndoSnapshot { + let source = FeatureInlineSkillProjection.plainText(from: textView.attributedText) + let trailingSkill = FeatureInlineSkillProjection.signatures(in: textView.attributedText) + .last?.descriptor + return UndoSnapshot( + source: source, + selection: FeatureInlineSkillProjection.plainRange( + for: textView.selectedRange, + in: textView.attributedText + ), + trailingSkill: trailingSkill.flatMap { + NSMaxRange($0.range) == source.utf16.count ? $0 : nil + } + ) + } + + private func restoreUndoRegistration() { + if let disabledUndoManager, + !disabledUndoManager.isUndoRegistrationEnabled { + disabledUndoManager.enableUndoRegistration() + } + disabledUndoManager = nil + } + + private func registerUndo( + restoring snapshot: UndoSnapshot, + inverse: UndoSnapshot, + in textView: FeatureComposerUITextView + ) { + guard let undoManager = textView.undoManager else { return } + let opensUndoGroup = undoManager.groupingLevel == 0 + if opensUndoGroup { + undoManager.beginUndoGrouping() + } + undoManager.registerUndo(withTarget: self) { [weak textView] coordinator in + guard let textView else { return } + coordinator.restore( + snapshot, + inverse: inverse, + in: textView + ) + } + undoManager.setActionName("Typing") + if opensUndoGroup { + undoManager.endUndoGrouping() + } + } + + private func restore( + _ snapshot: UndoSnapshot, + inverse: UndoSnapshot, + in textView: FeatureComposerUITextView + ) { + registerUndo(restoring: inverse, inverse: snapshot, in: textView) + isApplyingProgrammaticUpdate = true + _ = synchronizeInlineSkills( + in: textView, + source: snapshot.source, + selection: snapshot.selection, + preservingTrailing: snapshot.trailingSkill + ) + parent.text = snapshot.source + parent.onSelectionChange(snapshot.selection) + isApplyingProgrammaticUpdate = false + textView.scrollSelectionIntoView() + } + } +} + +/// Advertises image support to the paste menu and routes image pastes out to +/// the attachment pipeline. Text-only pastes fall through to UIKit untouched. +final class FeatureComposerUITextView: FeatureInlineSkillTextView { + private static let bottomEditingInset: CGFloat = 10 + private var lastLaidOutBoundsSize = CGSize.zero + + // Changing isEditable can dismiss an open keyboard. During voice input, + // keep the responder and reject user edits without changing isEditable. + var isReadOnly = false + + override var canBecomeFirstResponder: Bool { + (!isReadOnly || isFirstResponder) && super.canBecomeFirstResponder + } + + override func insertText(_ text: String) { + guard !isReadOnly else { return } + super.insertText(text) + } + + override func deleteBackward() { + guard !isReadOnly else { return } + super.deleteBackward() + } + + override func cut(_ sender: Any?) { + guard !isReadOnly else { return } + super.cut(sender) + } + + func configureComposerViewport() { + clipsToBounds = true + textContainerInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: Self.bottomEditingInset, + right: 0 + ) + } + + func scrollSelectionIntoView() { + guard bounds.width > 0, bounds.height > 0 else { return } + scrollRangeToVisible(selectedRange) + guard let selection = selectedTextRange else { return } + + let caret = caretRect(for: selection.end) + let visibleBottom = contentOffset.y + bounds.height - Self.bottomEditingInset + guard caret.maxY > visibleBottom else { return } + + let maximumOffset = max( + -adjustedContentInset.top, + contentSize.height + adjustedContentInset.bottom - bounds.height + ) + let requestedOffset = caret.maxY + Self.bottomEditingInset - bounds.height + let pixelScale = traitCollection.displayScale > 0 ? traitCollection.displayScale : 1 + let alignedOffset = ceil(requestedOffset * pixelScale) / pixelScale + setContentOffset( + CGPoint(x: contentOffset.x, y: min(maximumOffset, alignedOffset)), + animated: false + ) + } + + var acceptsImages = false { + didSet { + guard oldValue != acceptsImages else { return } + pasteConfiguration = acceptsImages + ? UIPasteConfiguration( + acceptableTypeIdentifiers: [ + UTType.image.identifier, + UTType.text.identifier, + ] + ) + : nil + } + } + var onPasteImages: (([NSItemProvider]) -> Void)? + var onDismissKeyboard: (() -> Void)? + private var wantsFirstResponderOnAttach = false + + /// Programmatic focus can arrive before the view joins a window (a host + /// refocusing right as the composer expands); retry once attached. The + /// pending request is cancelled if focus clears again before the view + /// attaches, so a stale request can never raise the keyboard. + func becomeFirstResponderWhenAttached() { + if window != nil { + becomeFirstResponder() + } else { + wantsFirstResponderOnAttach = true + } + } + + func cancelPendingFirstResponder() { + wantsFirstResponderOnAttach = false + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil, wantsFirstResponderOnAttach { + wantsFirstResponderOnAttach = false + becomeFirstResponder() + } + } + + // A SwiftUI drag gesture on the composer never sees drags that start + // inside this view: the text interaction's own recognizers claim them at + // the UIKit level. This observing pan reproduces the host's + // drag-to-dismiss there: it recognizes alongside everything, cancels + // nothing, and dismisses a scrollable draft only when the drag begins at + // its top. + private let dismissPanDelegate = FeatureComposerDismissPanDelegate() + + func installDismissPanRecognizer() { + let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan)) + pan.cancelsTouchesInView = false + pan.delegate = dismissPanDelegate + addGestureRecognizer(pan) + } + + private var dismissPanBeganAtTop = false + + @objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) { + // A fast flick can jump straight from .began to .ended without a + // .changed in between, so the end state is evaluated too. The at-top + // check is latched at .began: a drag that merely reaches the top + // mid-scroll only rubber-bands, instead of yanking the keyboard away + // the moment the offset crosses zero. + switch recognizer.state { + case .began: + dismissPanBeganAtTop = contentOffset.y <= 0 + return + case .changed, .ended: break + default: return + } + guard isFirstResponder else { return } + let translation = recognizer.translation(in: self) + guard FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: translation.x, + translationY: translation.y, + isScrollable: contentOverflows, + isAtTop: dismissPanBeganAtTop + ) else { return } + onDismissKeyboard?() + } + + // Scrolling stays enabled at every size: toggling `isScrollEnabled` off + // stops UITextView from maintaining `contentSize` on some OS versions, + // which left long drafts unscrollable on device. Overflow is computed + // fresh wherever it matters instead. A stale offset from a mid-resize + // selection change is still reset; with nothing to scroll, any offset + // clips the first line under the padding. + var contentOverflows: Bool { + contentSize.height > bounds.height + 0.5 + } + + override func layoutSubviews() { + let viewportChanged = lastLaidOutBoundsSize != bounds.size + lastLaidOutBoundsSize = bounds.size + super.layoutSubviews() + if !contentOverflows, contentOffset.y != 0 { + contentOffset.y = 0 + } else if viewportChanged, isFirstResponder { + // `sizeThatFits` receives a proposal. The final UIKit viewport can + // still differ after the footer and attachments take their space. + // Recheck the caret against these actual bounds once per resize. + scrollSelectionIntoView() + } + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if isReadOnly, action == #selector(paste(_:)) || action == #selector(cut(_:)) { + return false + } + if action == #selector(paste(_:)), + acceptsImages, + FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) { + return true + } + return super.canPerformAction(action, withSender: sender) + } + + // Drops are the other client of the paste configuration: while editing, + // UIKit offers the text view any drag it says it can paste. Declining + // image drags leaves them to the composer surface, so one target owns + // the session and the highlight; when the text view wins instead, the + // image vanishes into UITextView's text-only default and the surface's + // highlight never hears that the session ended. + override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { + guard !isReadOnly else { return false } + let holdsImage = itemProviders.contains { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + return holdsImage ? false : super.canPaste(itemProviders) + } + + // When the pasteboard holds images, only the images attach. Any text + // riding along (a copied web image usually brings its URL) is dropped on + // purpose: Slack and X do the same, and inserting a stray URL next to an + // attached screenshot reads as a bug. + override func paste(_ sender: Any?) { + guard !isReadOnly else { return } + guard acceptsImages else { + super.paste(sender) + return + } + let imageProviders = UIPasteboard.general.itemProviders.filter { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + guard !imageProviders.isEmpty else { + super.paste(sender) + return + } + onPasteImages?(imageProviders) + } +} + +/// A standalone delegate (rather than the text view itself, whose scroll-view +/// superclass already takes part in gesture delegation) so the observing pan +/// reliably recognizes alongside the text interaction's own recognizers +/// instead of being cancelled by them. +private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } +} + +/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly +/// vertical downward drag. While the draft is scrollable, scrolling back +/// through it never drops the keyboard mid-read, but a drag that *begins* +/// with the draft at its top only rubber-bands, which is unambiguous +/// dismissal intent (and the composer's only escape hatch once it and the +/// keyboard cover the transcript). `isAtTop` is the position at drag start, +/// so a scroll that reaches the top never dismisses mid-gesture. +enum FeatureComposerDragDismissPolicy { + static func shouldDismiss( + translationX: CGFloat, + translationY: CGFloat, + isScrollable: Bool, + isAtTop: Bool + ) -> Bool { + (!isScrollable || isAtTop) + && translationY > 8 + && translationY > abs(translationX) + } +} + +enum FeatureComposerPasteboardPolicy { + /// `UIPasteboard.hasImages` misses formats that merely conform to image + /// (HEIC screenshots among them), so detection goes through UTType + /// conformance instead. + static func containsImage(in pasteboard: UIPasteboard) -> Bool { + pasteboard.itemProviders.contains { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + } +} + +/// A one-shot caret placement, applied by the text input exactly once per +/// request `id`. Command completion issues one so the caret lands after the +/// inserted text instead of wherever UIKit leaves it after a programmatic +/// text replacement. +struct FeatureComposerTextSelectionRequest: Equatable { + let id = UUID() + let location: Int +} + +/// Selection changes come from `updateUIView` and UIKit delegate callbacks. +/// Keeping this value outside Observation avoids synchronous SwiftUI state +/// writes while the representable is updating. +@MainActor +@Observable +final class FeatureComposerTextObservation { + @ObservationIgnored var selection = NSRange(location: 0, length: 0) +} + +enum FeatureComposerTextSelectionPolicy { + /// UTF-16 caret location after `range` (character indices, as produced by + /// the trigger parser) is replaced with `replacement`. + static func cursorLocation( + afterReplacing range: Range, + in text: String, + with replacement: String + ) -> Int { + let lower = min(max(range.lowerBound, 0), text.count) + let lowerIndex = text.index(text.startIndex, offsetBy: lower) + return text[.. Int { + previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count) + } +} + +/// The editor grows with its content, then scrolls when it reaches the line +/// cap or the space above the composer controls. A finite SwiftUI proposal is +/// a hard bound. Returning a larger minimum makes the parent clip the editor +/// under its fixed footer. +enum FeatureComposerTextInputSizing { + static let maximumLines: CGFloat = 12 + + static func height( + fittingHeight: CGFloat, + lineHeight: CGFloat, + availableHeight: CGFloat? = nil + ) -> CGFloat { + let maximumHeight = max(0, lineHeight * maximumLines) + let contentHeight = max(0, fittingHeight) + guard let availableHeight, availableHeight.isFinite else { + return min(contentHeight, maximumHeight) + } + return min(contentHeight, maximumHeight, max(0, availableHeight)) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerTraitsControl.swift b/apps/swift-ios/Features/Chat/FeatureComposerTraitsControl.swift new file mode 100644 index 000000000000..53c63963d96b --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerTraitsControl.swift @@ -0,0 +1,219 @@ +/// The composer traits menu is derived from the selected model's option +/// descriptors. It deliberately knows nothing about which providers expose +/// which sections: select descriptors use their advertised choices, boolean +/// descriptors use On/Off, and descriptors without a usable control disappear. +struct FeatureComposerTraitsControl: Equatable { + struct Choice: Identifiable, Equatable { + let id: String + let label: String + let detail: String? + let isDefault: Bool + let value: FeatureModelOptionValue + } + + struct Section: Identifiable, Equatable { + let id: String + let label: String + let choices: [Choice] + let currentChoiceID: String? + } + + let sections: [Section] + let triggerLabel: String + let showsFastModeIcon: Bool + private let resolvedSelection: FeatureSelection + + static func resolve( + explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider], + materializesDefaultSelection: Bool + ) -> FeatureComposerTraitsControl? { + let providers = ProviderModelCatalogNormalizer.normalized(providers) + let selection = if materializesDefaultSelection { + ProviderModelSelectionResolver.materialized(explicit, in: providers) + } else { + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: explicit, + inherited: inherited, + providers: providers + ) + } + guard let selection, + let provider = providers.first(where: { $0.id == selection.providerID }), + let model = provider.models.first(where: { $0.id == selection.modelID }) else { + return nil + } + + let sections = model.options.compactMap { + section(for: $0, selections: selection.options) + } + guard !sections.isEmpty else { return nil } + + let trigger = triggerDisplay( + sections: sections, + descriptors: model.options, + providerDriver: provider.driver + ) + return FeatureComposerTraitsControl( + sections: sections, + triggerLabel: trigger.label, + showsFastModeIcon: trigger.showsFastModeIcon, + resolvedSelection: selection + ) + } + + /// The shared model-selection policy adds declared defaults. A traits choice + /// changes only its own option, preserving saved values and unset options. + func selection(choosing choiceID: String, in descriptorID: String) -> FeatureSelection { + guard let section = sections.first(where: { $0.id == descriptorID }), + let choice = section.choices.first(where: { $0.id == choiceID }) else { + return resolvedSelection + } + + var next = resolvedSelection + next.options = DailyUXModelOptions.updating( + next.options, + id: descriptorID, + value: choice.value + ) + return next + } + + private static func section( + for descriptor: FeatureModelOptionDescriptor, + selections: [FeatureModelOptionSelection] + ) -> Section? { + switch descriptor.kind { + case .select: + let supportedChoices = descriptor.choices.filter { + !(descriptor.promptInjectedValues ?? []).contains($0.id) + } + guard !supportedChoices.isEmpty else { return nil } + return Section( + id: descriptor.id, + label: descriptor.label, + choices: supportedChoices.map { + Choice( + id: $0.id, + label: $0.label, + detail: $0.detail, + isDefault: $0.isDefault, + value: .string($0.id) + ) + }, + currentChoiceID: currentSelectChoiceID( + for: descriptor, + selections: selections + ) + ) + case .boolean: + let current = currentBooleanValue(for: descriptor, selections: selections) + return Section( + id: descriptor.id, + label: descriptor.label, + choices: [ + Choice( + id: "on", + label: "On", + detail: nil, + isDefault: false, + value: .boolean(true) + ), + Choice( + id: "off", + label: "Off", + detail: nil, + isDefault: false, + value: .boolean(false) + ), + ], + currentChoiceID: current.map { $0 ? "on" : "off" } + ) + } + } + + private static func currentSelectChoiceID( + for descriptor: FeatureModelOptionDescriptor, + selections: [FeatureModelOptionSelection] + ) -> String? { + if case .string(let selected)? = DailyUXModelOptions.value( + for: descriptor, + in: selections + ) { + return selected + } + return nil + } + + private static func currentBooleanValue( + for descriptor: FeatureModelOptionDescriptor, + selections: [FeatureModelOptionSelection] + ) -> Bool? { + if case .boolean(let selected)? = DailyUXModelOptions.value( + for: descriptor, + in: selections + ) { + return selected + } + return nil + } + + /// Mirrors Electron's compact TraitsPicker display. Fast mode is a bolt when + /// another trait supplies readable text; when it is the only trait its state + /// remains text so the trigger never becomes an unexplained icon. + private static func triggerDisplay( + sections: [Section], + descriptors: [FeatureModelOptionDescriptor], + providerDriver: String + ) -> (label: String, showsFastModeIcon: Bool) { + var fastModeFallbackLabel: String? + var fastModeEnabled = false + var labels: [String] = [] + + for descriptor in descriptors { + guard let section = sections.first(where: { $0.id == descriptor.id }) else { + continue + } + let current = section.choices.first(where: { + $0.id == section.currentChoiceID + }) + + if descriptor.id == "fastMode", descriptor.kind == .boolean { + guard let current else { continue } + fastModeEnabled = current.value == .boolean(true) + fastModeFallbackLabel = fastModeEnabled ? "Fast" : "Normal" + continue + } + + if providerDriver == "codex", + descriptor.id == "serviceTier", + descriptor.kind == .select, + let fastChoice = section.choices.first(where: { $0.label == "Fast" }), + section.currentChoiceID == "default" + || section.currentChoiceID == fastChoice.id { + fastModeEnabled = section.currentChoiceID == fastChoice.id + fastModeFallbackLabel = current?.label + continue + } + + switch descriptor.kind { + case .select: + let label = current?.label ?? descriptor.choices.first(where: { + $0.id == section.currentChoiceID + })?.label ?? section.currentChoiceID + if let label { + labels.append(label) + } + case .boolean: + guard case .boolean(let value)? = current?.value else { continue } + labels.append("\(descriptor.label) \(value ? "On" : "Off")") + } + } + + if labels.isEmpty, let fastModeFallbackLabel { + return (fastModeFallbackLabel, false) + } + return (labels.isEmpty ? "Provider default" : labels.joined(separator: " · "), fastModeEnabled) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift new file mode 100644 index 000000000000..20a7201c85b3 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -0,0 +1,1203 @@ +import AVFoundation +import SwiftUI +import UIKit + +struct FeatureModelRefreshError: LocalizedError { + var errorDescription: String? { "Couldn’t refresh models." } +} + +struct FeatureComposerUploadStatus { + var preparingCount = 0 + var uploadingCount = 0 + var failures: [(UUID, String)] = [] + + init(states: [(UUID, FeatureAttachmentUploadState?)]) { + for (id, state) in states { + switch state { + case .some(.ready): break + case let .some(.failed(message)): failures.append((id, message)) + case .some(.uploading): uploadingCount += 1 + case .some(.queued), .none: preparingCount += 1 + } + } + } + + var blocksSend: Bool { + preparingCount > 0 || uploadingCount > 0 || !failures.isEmpty + } +} + +struct FeatureComposerView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @State private var isManuallyExpanded = false + @State private var isAttachmentFlowActive = false + @State private var isModelPickerPresented = false + @State private var isTraitsPickerPresented = false + @State private var restoresFocusAfterModelPickerDismissal = false + @State private var attachmentPreparation = FeatureAttachmentPreparationState() + @State private var pathEntries: [FeatureComposerPathEntry] = [] + @State private var isPathSearchLoading = false + @State private var pathSearchError: String? + @State private var textSelectionRequest: FeatureComposerTextSelectionRequest? + @State private var imageIntakeErrorMessage: String? + @State private var textRevision: UInt64 = 0 + @State private var textObservation = FeatureComposerTextObservation() + @State private var voiceInputController = FeatureVoiceInputController() + @Binding private var text: String + @Binding private var selection: FeatureSelection? + @Binding private var attachments: [FeatureDraftAttachment] + + private let providers: [FeatureProvider] + private let draftOwnerID: String + private let environmentID: String? + private let draftStorageKey: String? + private let environmentIsConnected: Bool + private let attachmentUploads: FeatureAttachmentUploadCoordinator + private let attachmentPreferences: FeatureEnvironmentPreferences + private let onRefreshModels: (() async throws -> Void)? + private let draftSaveError: String? + private let onRetryDraftSave: (() -> Void)? + private let threadSelection: FeatureSelection? + private let materializesDefaultSelection: Bool + private let isSending: Bool + private let isWorking: Bool + @Binding private var focused: Bool + private let contextUsage: Double? + private let forceExpanded: Bool + private let pendingApprovals: [FeatureApproval] + private let pendingUserInputs: [FeatureUserInput] + private let isResolvingRequest: Bool + private let powerFeatures: FeatureComposerPowerFeatures + private let onSend: () -> Void + private let onStop: () -> Void + private let showsKeyboardDismissControl: Bool + private let onDismissKeyboard: (() -> Void)? + private let onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? + private let onUserInputSubmit: ((String, [String: FeatureInputAnswer]) -> Void)? + + init( + text: Binding, + selection: Binding, + attachments: Binding<[FeatureDraftAttachment]>, + draftOwnerID: String, + environmentID: String?, + draftStorageKey: String?, + environmentIsConnected: Bool, + attachmentUploads: FeatureAttachmentUploadCoordinator, + attachmentPreferences: FeatureEnvironmentPreferences, + providers: [FeatureProvider], + threadSelection: FeatureSelection?, + materializesDefaultSelection: Bool = true, + isSending: Bool, + isWorking: Bool, + focused: Binding, + onSend: @escaping () -> Void, + onStop: @escaping () -> Void, + contextUsage: Double? = nil, + forceExpanded: Bool = false, + pendingApprovals: [FeatureApproval] = [], + pendingUserInputs: [FeatureUserInput] = [], + isResolvingRequest: Bool = false, + powerFeatures: FeatureComposerPowerFeatures = .disabled, + showsKeyboardDismissControl: Bool = false, + onDismissKeyboard: (() -> Void)? = nil, + onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? = nil, + onUserInputSubmit: ((String, [String: FeatureInputAnswer]) -> Void)? = nil, + onRefreshModels: (() async throws -> Void)? = nil, + draftSaveError: String? = nil, + onRetryDraftSave: (() -> Void)? = nil + ) { + _text = text + _selection = selection + _attachments = attachments + self.draftOwnerID = draftOwnerID + self.environmentID = environmentID + self.draftStorageKey = draftStorageKey + self.environmentIsConnected = environmentIsConnected + self.attachmentUploads = attachmentUploads + self.attachmentPreferences = attachmentPreferences + self.onRefreshModels = onRefreshModels + self.draftSaveError = draftSaveError + self.onRetryDraftSave = onRetryDraftSave + self.providers = providers + self.threadSelection = threadSelection + self.materializesDefaultSelection = materializesDefaultSelection + self.isSending = isSending + self.isWorking = isWorking + _focused = focused + self.onSend = onSend + self.onStop = onStop + self.contextUsage = contextUsage + self.forceExpanded = forceExpanded + self.pendingApprovals = pendingApprovals + self.pendingUserInputs = pendingUserInputs + self.isResolvingRequest = isResolvingRequest + self.powerFeatures = powerFeatures + self.showsKeyboardDismissControl = showsKeyboardDismissControl + self.onDismissKeyboard = onDismissKeyboard + self.onApprovalDecision = onApprovalDecision + self.onUserInputSubmit = onUserInputSubmit + } + + var body: some View { + composerSurface + .overlay(alignment: .top) { + if showsCommandMenu, let trigger = composerTrigger { + // Offset by the menu's deterministic height so it sits + // fully above the composer and the active `$`/`@`/`/` + // token stays readable while typing. An alignment-guide + // override here never actually moved the menu, which + // left it covering the text entry. + FeatureComposerCommandPopover( + triggerKind: trigger.kind, + items: commandMenuItems, + isLoading: isPathSearchLoading, + errorMessage: pathSearchError, + pathSearchAvailable: powerFeatures.searchPaths != nil, + onSelect: selectCommandItem + ) + .offset( + y: -(FeatureComposerCommandPopover.height( + forItemCount: commandMenuItems.count + ) + 12) + ) + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 10) + .background { + LinearGradient( + colors: [ + .clear, + T3Colors.background.opacity(0.94), + T3Colors.background, + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + } + .onChange(of: focused) { + if FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: focused, + textIsEmpty: textIsEmpty, + attachmentsAreEmpty: attachments.isEmpty, + isAttachmentFlowActive: isAttachmentFlowActive + || isModelPickerPresented + || isTraitsPickerPresented, + isPreparingAttachments: attachmentPreparation.isPreparing + ) { + isManuallyExpanded = false + } + } + .task(id: pathSearchRequest) { + await updatePathSearch() + } + .onAppear { + synchronizeVoiceDraft(ownerChanged: false) + } + .onDisappear { + voiceInputController.cancel() + } + .onChange(of: text) { + textRevision &+= 1 + synchronizeVoiceDraft(ownerChanged: false) + } + .onChange(of: draftOwnerID) { + synchronizeVoiceDraft(ownerChanged: true) + } + .onChange(of: voiceInputController.pendingCommit?.id) { + applyPendingVoiceCommit() + } + .onChange(of: scenePhase) { _, phase in + if phase == .background { + voiceInputController.appMovedToBackground() + } + } + .onReceive(NotificationCenter.default.publisher( + for: AVAudioSession.interruptionNotification + )) { notification in + guard let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] + as? UInt, + AVAudioSession.InterruptionType(rawValue: rawType) == .began else { return } + voiceInputController.recordingWasInterrupted() + } + .alert( + "Couldn’t add image", + isPresented: Binding( + get: { imageIntakeErrorMessage != nil }, + set: { if !$0 { imageIntakeErrorMessage = nil } } + ) + ) { + Button("OK") { imageIntakeErrorMessage = nil } + } message: { + Text(imageIntakeErrorMessage ?? "") + } + } + + private var composerSurface: some View { + VStack(spacing: 0) { + if let approval = pendingApprovals.first, let onApprovalDecision { + FeatureComposerApprovalPanel( + approval: approval, + position: 1, + total: pendingApprovals.count, + isResponding: isResolvingRequest, + onDecision: { decision in + onApprovalDecision(approval.id, decision) + }, + onCancelTurn: onStop + ) + } else if let input = pendingUserInputs.first, let onUserInputSubmit { + FeatureComposerUserInputPanel( + input: input, + isResponding: isResolvingRequest, + onSubmit: { answers in + onUserInputSubmit(input.id, answers) + } + ) + } else if isExpanded { + expandedComposer + } else { + collapsedComposer + } + } + .background(T3Colors.input.opacity(0.98), in: composerShape) + .overlay { + composerShape + .stroke(T3Colors.inputBorder, lineWidth: 1) + } + .clipShape(composerShape) + .modifier( + FeatureComposerImageDrop( + isEnabled: imagesAllowed && !voiceInputController.isBusy, + shape: composerShape, + onDropImages: attachDroppedImages + ) + ) + } + + private var collapsedComposer: some View { + HStack(spacing: 4) { + Button { + isManuallyExpanded = true + Task { @MainActor in + await Task.yield() + focused = true + } + } label: { + Text(composerPlaceholder) + .font(T3Typography.composer) + .foregroundStyle(T3Colors.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Message agent") + .accessibilityHint("Opens the message editor") + + submitButton + .padding(.trailing, 7) + + if voiceInputController.isSupported { + voiceInputButton + .padding(.trailing, 3) + } + } + .padding(.leading, 14) + .padding(.vertical, 7) + } + + private var expandedComposer: some View { + VStack(spacing: 0) { + if !attachments.isEmpty { + FeatureAttachmentStrip(attachments: $attachments) + .padding(.horizontal, 12) + .padding(.top, 3) + .padding(.bottom, 8) + .fixedSize(horizontal: false, vertical: true) + + Divider() + .overlay(T3Colors.separator) + .padding(.horizontal, 13) + } + + // Return is always editing input. Sending is deliberately + // button-only, which is UITextView's native return behavior. + ZStack(alignment: .topLeading) { + FeatureComposerTextInput( + text: $text, + focused: $focused, + placeholder: composerPlaceholder, + acceptsImages: imagesAllowed, + isReadOnly: voiceInputController.isBusy, + skills: powerFeatures.enabledSkills, + selectionRequest: textSelectionRequest, + onSelectionChange: handleTextSelectionChange, + onPasteImages: attachImageProviders, + onDismissKeyboard: onDismissKeyboard + ) + .padding(.horizontal, 16) + .padding(.top, 14) + + if text.isEmpty { + Text(composerPlaceholder) + .font(T3Typography.composer) + .foregroundStyle(T3Colors.textTertiary) + .padding(.horizontal, 16) + .padding(.top, 14) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .padding(.bottom, 7) + .frame(minHeight: 62, alignment: .top) + .layoutPriority(1) + .clipped() + + if let attachmentBlocker { + Label(attachmentBlocker, systemImage: "exclamationmark.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.bottom, 4) + } + + if attachmentPreparation.isPreparing { + Label(attachmentPreparation.statusLabel, systemImage: "hourglass") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.bottom, 4) + .accessibilityIdentifier("attachment-preparing") + } + + if let draftSaveError { + HStack(spacing: 8) { + Text(draftSaveError).lineLimit(3) + Spacer(minLength: 0) + if let onRetryDraftSave { + Button("Retry", action: onRetryDraftSave) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .padding(.horizontal, 15) + .padding(.bottom, 4) + .accessibilityIdentifier("composer-draft-save-error") + } else if uploadStatus.blocksSend { + uploadStatusView(uploadStatus) + } + + if voiceInputController.phase == .error { + voiceInputError + } + + composerFooter + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } + } + + private var composerFooter: some View { + Group { + if voiceInputController.isBusy { + voiceInputFooter + } else { + standardComposerFooter + } + } + } + + private var standardComposerFooter: some View { + HStack(spacing: 2) { + if FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: focused, + isEnabled: showsKeyboardDismissControl, + canDismiss: onDismissKeyboard != nil + ) { + dismissKeyboardButton + } + + FeatureImageAttachmentPicker( + attachments: $attachments, + preparationState: $attachmentPreparation, + isFlowActive: $isAttachmentFlowActive, + draftOwnerID: draftOwnerID, + environmentID: environmentID, + imagesAllowed: imagesAllowed, + maximumFileBytes: attachmentPreferences.maxFileAttachmentBytes + ) + + ProviderModelPicker( + providers: providers, + selection: $selection, + style: .compact, + threadSelection: threadSelection, + materializesDefaultSelection: materializesDefaultSelection, + onRefresh: onRefreshModels, + onPresentationChange: handleModelPickerPresentation + ) + .frame(maxWidth: 220, alignment: .leading) + .layoutPriority(1) + + if let traitsControl { + traitsPicker(traitsControl) + .frame(minWidth: 28, maxWidth: 148, alignment: .trailing) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + } + + Spacer(minLength: 0) + + if voiceInputController.isSupported { + voiceInputButton + } + + if let contextUsage { + FeatureContextMeter(usage: contextUsage) + } + + submitButton + .padding(.leading, 4) + } + .padding(.horizontal, 7) + .padding(.top, 2) + .padding(.bottom, 8) + } + + private var dismissKeyboardButton: some View { + Button("Hide keyboard", systemImage: "keyboard.chevron.compact.down", action: dismissKeyboard) + .labelStyle(.iconOnly) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityHint("Keeps your draft and shows the thread") + .accessibilityIdentifier("composer-dismiss-keyboard") + } + + private func dismissKeyboard() { + onDismissKeyboard?() + } + + private var voiceInputButton: some View { + Button(action: startVoiceInput) { + Image(systemName: "mic") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Start voice input") + .accessibilityIdentifier("voice-input-start") + } + + private var voiceInputFooter: some View { + HStack(spacing: 8) { + voiceInputStatus + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + Spacer(minLength: 0) + + Button("Cancel") { + voiceInputController.cancel() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: T3Metrics.minimumTapTarget) + + if voiceInputController.phase == .recording { + Button("Stop") { + voiceInputController.stop() + } + .font(T3Typography.supporting.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .frame(minHeight: 34) + .background(T3Colors.accent, in: Capsule()) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Stop recording and transcribe") + } + } + .padding(.horizontal, 12) + .padding(.top, 2) + .padding(.bottom, 8) + } + + @ViewBuilder + private var voiceInputStatus: some View { + switch voiceInputController.phase { + case .preparing: + Text("Preparing") + case .recording: + TimelineView(.periodic(from: .now, by: 1)) { context in + Text("Recording \(voiceRecordingDuration(at: context.date))") + .monospacedDigit() + } + case .transcribing: + Text("Transcribing") + case .idle, .error: + EmptyView() + } + } + + private var voiceInputError: some View { + HStack(spacing: 8) { + Text(voiceInputController.errorMessage ?? "Voice input failed.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .frame(maxWidth: .infinity, alignment: .leading) + + if let action = voiceInputController.errorAction { + Button(action == .settings ? "Settings" : "Retry") { + if action == .settings, + let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } else { + startVoiceInput() + } + } + .font(T3Typography.supporting.weight(.semibold)) + } + + Button("Dismiss") { + voiceInputController.cancel() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(.horizontal, 15) + .padding(.bottom, 4) + } + + /// The popover keeps all descriptor sections together and preserves their + /// catalog order, including option descriptions that a system Menu would + /// flatten away. + private func traitsPicker(_ control: FeatureComposerTraitsControl) -> some View { + Button { + isTraitsPickerPresented.toggle() + } label: { + traitsPickerLabel(control) + .frame( + minWidth: T3Metrics.minimumTapTarget, + minHeight: T3Metrics.minimumTapTarget + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .popover( + isPresented: $isTraitsPickerPresented, + attachmentAnchor: .rect(.bounds), + arrowEdge: .bottom + ) { + FeatureComposerTraitsMenu(control: control) { descriptorID, choiceID in + selection = control.selection(choosing: choiceID, in: descriptorID) + isTraitsPickerPresented = false + } + .presentationCompactAdaptation(.popover) + } + .accessibilityLabel("Model traits") + .accessibilityValue(control.triggerLabel) + .accessibilityIdentifier("composer-traits-picker") + } + + private func traitsPickerLabel(_ control: FeatureComposerTraitsControl) -> some View { + HStack(spacing: 3) { + if control.showsFastModeIcon { + Image(systemName: "bolt.fill") + .font(.system(size: 10, weight: .semibold)) + .accessibilityHidden(true) + } + Text(control.triggerLabel) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + .fixedSize() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .contentShape(Rectangle()) + } + + private var submitButton: some View { + Button(action: performPrimaryAction) { + Image(systemName: submitSymbol) + .font(.system(size: showsStop ? 11 : 14, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(showsStop ? T3Colors.danger : T3Colors.accent, in: Circle()) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(submitDisabled) + .opacity(submitDisabled ? 0.3 : 1) + .accessibilityLabel(submitAccessibilityLabel) + .accessibilityIdentifier(showsStop ? "thread-stop" : "message-send") + } + + private var composerPlaceholder: String { + isWorking ? "Queue a message…" : "Ask anything…" + } + + private var submitSymbol: String { + if isSending { return "ellipsis" } + return showsStop ? "stop.fill" : "arrow.up" + } + + private var submitAccessibilityLabel: String { + if isSending { return "Sending message" } + if showsStop { return "Stop agent" } + return isWorking ? "Queue message" : "Send message" + } + + private var composerShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 22, style: .continuous) + } + + private var isExpanded: Bool { + forceExpanded + || isManuallyExpanded + || focused + || !textIsEmpty + || !attachments.isEmpty + || attachmentPreparation.isPreparing + || voiceInputController.isBusy + || voiceInputController.phase == .error + } + + private var showsStop: Bool { + isWorking && textIsEmpty && attachments.isEmpty + } + + private var submitDisabled: Bool { + isSending || (!showsStop && !canSend) + } + + private var textIsEmpty: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var canSend: Bool { + guard composerTrigger?.kind != .model else { return false } + return FeatureComposerSubmissionEligibility.canSend( + text: text, + attachmentCount: attachments.count, + imagesAllowed: imagesAllowed, + filesAllowed: attachmentPreferences.maxFileAttachmentBytes != nil, + containsImages: attachments.contains { $0.mimeType.hasPrefix("image/") }, + containsFiles: attachments.contains { !$0.mimeType.hasPrefix("image/") }, + isSending: isSending, + preparationState: attachmentPreparation + ) && !uploadStatus.blocksSend && draftSaveError == nil + } + + private var imagesAllowed: Bool { + DailyUXModelOptions.supportsImages( + selection: selection ?? threadSelection, + providers: providers + ) + } + + private var attachmentBlocker: String? { + if attachments.contains(where: { !$0.mimeType.hasPrefix("image/") }), + attachmentPreferences.maxFileAttachmentBytes == nil { + return "This environment does not accept file attachments" + } + if attachments.contains(where: { $0.mimeType.hasPrefix("image/") }), !imagesAllowed { + return "Choose a model that accepts images" + } + return nil + } + + private var applicableUploadStates: [(UUID, FeatureAttachmentUploadState?)] { + guard environmentIsConnected, let environmentID, draftStorageKey != nil else { return [] } + return attachments.compactMap { attachment in + let isImage = attachment.mimeType.hasPrefix("image/") + let uploadsHere = isImage + ? attachmentPreferences.supportsImageUploads + : attachmentPreferences.maxFileAttachmentBytes != nil + guard uploadsHere else { return nil } + return ( + attachment.id, + attachmentUploads.state( + environmentID: environmentID, + attachmentID: attachment.id + ) + ) + } + } + + private var uploadStatus: FeatureComposerUploadStatus { + FeatureComposerUploadStatus(states: applicableUploadStates) + } + + private func uploadStatusView(_ status: FeatureComposerUploadStatus) -> some View { + VStack(alignment: .leading, spacing: 4) { + if status.preparingCount > 0 { + Text("Preparing \(status.preparingCount) attachment\(status.preparingCount == 1 ? "" : "s")") + } + if status.uploadingCount > 0 { + Text("Uploading \(status.uploadingCount) attachment\(status.uploadingCount == 1 ? "" : "s")") + } + ForEach(status.failures, id: \.0) { failure in + HStack(spacing: 8) { + Text(failure.1).lineLimit(2) + Spacer(minLength: 0) + Button("Retry") { + guard let environmentID else { return } + attachmentUploads.retry( + environmentID: environmentID, + attachmentID: failure.0 + ) + } + } + } + } + .font(T3Typography.supporting) + .foregroundStyle(status.failures.isEmpty ? T3Colors.textSecondary : T3Colors.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.bottom, 4) + .accessibilityIdentifier("attachment-upload-status") + } + + private var traitsControl: FeatureComposerTraitsControl? { + FeatureComposerTraitsControl.resolve( + explicit: selection, + inherited: threadSelection, + providers: providers, + materializesDefaultSelection: materializesDefaultSelection + ) + } + + /// Trigger detection walks the whole draft with character indices and is + /// read from several computed properties per body evaluation, so one parse + /// per keystroke is memoized instead of four. + private final class TriggerMemo { + var text: String? + var trigger: FeatureComposerTrigger? + } + + @State private var triggerMemo = TriggerMemo() + + private var composerTrigger: FeatureComposerTrigger? { + if triggerMemo.text == text { return triggerMemo.trigger } + let trigger = FeatureComposerTriggerParser.detect(in: text) + triggerMemo.text = text + triggerMemo.trigger = trigger + return trigger + } + + private var commandMenuItems: [FeatureComposerMenuItem] { + guard let composerTrigger else { return [] } + return FeatureComposerMenuBuilder.items( + trigger: composerTrigger, + providers: providers, + currentSelection: selection, + threadSelection: threadSelection, + powerFeatures: powerFeatures, + pathEntries: pathEntries + ) + } + + private var showsCommandMenu: Bool { + isExpanded + && !voiceInputController.isBusy + && pendingApprovals.isEmpty + && pendingUserInputs.isEmpty + && composerTrigger != nil + } + + private var pathSearchRequest: FeatureComposerPathSearchRequest? { + guard let trigger = composerTrigger, + trigger.kind == .path, + powerFeatures.searchPaths != nil else { + return nil + } + let query = trigger.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return nil } + return FeatureComposerPathSearchRequest( + scopeID: powerFeatures.pathSearchScopeID, + query: query + ) + } + + @MainActor + private func updatePathSearch() async { + guard let request = pathSearchRequest, let searchPaths = powerFeatures.searchPaths else { + pathEntries = [] + isPathSearchLoading = false + pathSearchError = nil + return + } + + pathEntries = [] + pathSearchError = nil + isPathSearchLoading = true + do { + try await Task.sleep(for: .milliseconds(140)) + let result = try await searchPaths(request.query) + guard !Task.isCancelled else { return } + pathEntries = result + isPathSearchLoading = false + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + pathSearchError = "Couldn’t search files." + isPathSearchLoading = false + } + } + + private func selectCommandItem(_ item: FeatureComposerMenuItem) { + guard let trigger = composerTrigger else { return } + let replacement: String + switch item { + case .modelCommand: + replacement = "/model " + case let .model(nextSelection, _, _): + selection = nextSelection + replacement = "" + case let .providerCommand(command): + replacement = "/\(command.name) " + case let .skill(skill): + replacement = skill.invocation + case let .path(entry): + replacement = FeatureComposerFileLinkSerializer.markdownLink(for: entry.path) + " " + } + let nextCursorLocation = FeatureComposerTextSelectionPolicy.cursorLocation( + afterReplacing: trigger.range, + in: text, + with: replacement + ) + text = FeatureComposerTriggerParser.replacing( + trigger.range, + in: text, + with: replacement + ) + // Publish the text first so the representable cannot consume and clamp + // this request against the pre-replacement draft. + textSelectionRequest = FeatureComposerTextSelectionRequest( + location: nextCursorLocation + ) + pathEntries = [] + pathSearchError = nil + Task { @MainActor in + await Task.yield() + focused = true + } + } + + private func performPrimaryAction() { + if showsStop { + onStop() + } else if FeatureComposerSubmissionPolicy.allowsSend(for: .explicitButton), + canSend { + onSend() + } + } + + private func startVoiceInput() { + synchronizeVoiceDraft(ownerChanged: false) + voiceInputController.start() + } + + private func handleTextSelectionChange(_ selection: NSRange) { + textObservation.selection = selection + voiceInputController.updateSelection(selection) + } + + private func synchronizeVoiceDraft(ownerChanged: Bool) { + let snapshot = FeatureVoiceDraftSnapshot( + ownerID: draftOwnerID, + text: text, + revision: textRevision, + selection: textObservation.selection + ) + if ownerChanged { + voiceInputController.ownerChanged(to: snapshot) + } else { + voiceInputController.updateDraft(snapshot) + } + } + + private func applyPendingVoiceCommit() { + guard let commit = voiceInputController.pendingCommit else { return } + textSelectionRequest = FeatureComposerTextSelectionRequest( + location: commit.caretLocation + ) + text = commit.text + voiceInputController.consumePendingCommit() + } + + private func voiceRecordingDuration(at date: Date) -> String { + let seconds = max(0, Int(date.timeIntervalSince( + voiceInputController.recordingStartedAt ?? date + ))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + private func handleModelPickerPresentation(_ isPresented: Bool) { + if isPresented { + restoresFocusAfterModelPickerDismissal = focused + isManuallyExpanded = true + isModelPickerPresented = true + return + } + + isModelPickerPresented = false + guard restoresFocusAfterModelPickerDismissal else { return } + restoresFocusAfterModelPickerDismissal = false + Task { @MainActor in + await Task.yield() + focused = true + } + } + + /// Attaches images arriving from the text view's paste menu or a drag + /// from another app through the same preparation pipeline the attachment + /// picker uses, so sending stays blocked until every image is processed. + private func attachImageProviders(_ providers: [NSItemProvider]) { + guard imagesAllowed, !providers.isEmpty else { return } + + guard let plan = FeatureComposerImageIntakePlan.forProviders( + providerCount: providers.count, + attachmentCount: attachments.count, + pendingCount: attachmentPreparation.pendingItemCount + ) else { + imageIntakeErrorMessage = "You can attach up to eight images." + return + } + if plan.droppedCount > 0 { + imageIntakeErrorMessage = + "Some images were not attached because the eight-image limit was reached." + } + + let accepted = Array(providers.prefix(plan.acceptedCount)) + // Begin every provider request while the paste or drop callback still + // owns access to its item providers. Image processing can finish + // asynchronously after the callback returns. + let loads = accepted.map { provider in + Result { try FeatureImageItemProviderLoader.start(from: provider) } + } + let operation = attachmentPreparation.begin(itemCount: accepted.count) + Task { @MainActor in + defer { attachmentPreparation.finish(operation) } + for (offset, load) in loads.enumerated() { + do { + let data = try await load.get().data() + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment( + from: data, + ordinal: plan.firstOrdinal + offset + ) + }.value + attachments.append(attachment) + } catch { + imageIntakeErrorMessage = error.localizedDescription + } + } + } + } + + /// A drop is refused outright when images are not accepted, so the drag + /// session shows the system's "not allowed" badge instead of a dead drop. + private func attachDroppedImages(_ providers: [NSItemProvider]) -> Bool { + guard imagesAllowed, !providers.isEmpty else { return false } + attachImageProviders(providers) + return true + } +} + +enum FeatureComposerKeyboardDismissPolicy { + static func showsDismissControl(isFocused: Bool, isEnabled: Bool, canDismiss: Bool) -> Bool { + isFocused && isEnabled && canDismiss + } +} + +private struct FeatureComposerTraitsMenu: View { + let control: FeatureComposerTraitsControl + let onSelect: (String, String) -> Void + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(control.sections.enumerated()), id: \.element.id) { index, section in + if index > 0 { + Divider() + .overlay(T3Colors.separator) + .padding(.vertical, 5) + } + traitSection(section) + } + } + .padding(6) + } + .scrollIndicators(.hidden) + .frame(width: 292) + .frame(maxHeight: 520) + .background(T3Colors.surface) + .accessibilityIdentifier("composer-traits-menu") + } + + private func traitSection(_ section: FeatureComposerTraitsControl.Section) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(section.label) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 10) + .padding(.top, 5) + .padding(.bottom, 3) + + ForEach(section.choices) { choice in + let isCurrent = choice.id == section.currentChoiceID + Button { + onSelect(section.id, choice.id) + } label: { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(choice.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + if choice.isDefault { + Text("Default") + .font(.caption2.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(T3Colors.subtleStrong, in: Capsule()) + } + } + if let detail = choice.detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 4) + if isCurrent { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(T3Colors.accent) + .padding(.top, 3) + } + } + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, choice.detail == nil ? 1 : 4) + .background( + isCurrent ? T3Colors.accent.opacity(0.12) : .clear, + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(choice.label) + .accessibilityValue( + [choice.isDefault ? "Default" : nil, isCurrent ? "Current" : nil] + .compactMap { $0 } + .joined(separator: ", ") + ) + .accessibilityIdentifier( + "composer-trait-\(section.id)-choice-\(choice.id)" + ) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(section.label) + .accessibilityIdentifier("composer-trait-section-\(section.id)") + } +} + +enum FeatureComposerCollapsePolicy { + static func shouldCollapse( + isFocused: Bool, + textIsEmpty: Bool, + attachmentsAreEmpty: Bool, + isAttachmentFlowActive: Bool, + isPreparingAttachments: Bool + ) -> Bool { + !isFocused + && textIsEmpty + && attachmentsAreEmpty + && !isAttachmentFlowActive + && !isPreparingAttachments + } +} + +private struct FeatureComposerPathSearchRequest: Hashable { + let scopeID: String + let query: String +} + +enum FeatureComposerSubmissionEligibility { + static func canSend( + text: String, + attachmentCount: Int, + imagesAllowed: Bool, + filesAllowed: Bool = false, + containsImages: Bool = true, + containsFiles: Bool = false, + isSending: Bool, + preparationState: FeatureAttachmentPreparationState + ) -> Bool { + let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasAttachments = attachmentCount > 0 + return !isSending + && !preparationState.isPreparing + && (hasText || hasAttachments) + && (!hasAttachments || !containsImages || imagesAllowed) + && (!hasAttachments || !containsFiles || filesAllowed) + } +} + +enum FeatureComposerSubmissionIntent: Equatable { + case explicitButton + case returnKey +} + +enum FeatureComposerSubmissionPolicy { + static func allowsSend(for intent: FeatureComposerSubmissionIntent) -> Bool { + intent == .explicitButton + } +} + +private struct FeatureContextMeter: View { + let usage: Double + + var body: some View { + ZStack { + Circle() + .stroke(T3Colors.border, lineWidth: 2) + Circle() + .trim(from: 0, to: clampedUsage) + .stroke( + T3Colors.textSecondary, + style: StrokeStyle(lineWidth: 2, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + } + .frame(width: 18, height: 18) + .frame(width: 30, height: T3Metrics.minimumTapTarget) + .accessibilityElement() + .accessibilityLabel("Context used") + .accessibilityValue("\(Int((clampedUsage * 100).rounded())) percent") + } + + private var clampedUsage: Double { + min(max(usage, 0), 1) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift new file mode 100644 index 000000000000..8be5b706f698 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift @@ -0,0 +1,410 @@ +import SwiftUI +import UIKit + +struct FeatureInlineSkillDescriptor: Equatable { + let rawText: String + let displayName: String + let range: NSRange +} + +extension FeatureProviderSkill { + var invocationDisplayName: String { + if let displayName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines), + !displayName.isEmpty { + return displayName + } + + return name + .split { $0.isWhitespace || $0 == ":" || $0 == "_" || $0 == "-" } + .map { word in + word.prefix(1).uppercased() + word.dropFirst() + } + .joined(separator: " ") + } +} + +enum FeatureInlineSkillParser { + private static let tokenExpression = try! NSRegularExpression( + pattern: #"(? [FeatureInlineSkillDescriptor] { + guard !text.isEmpty, !skills.isEmpty else { return [] } + + let skillsByName = Dictionary(skills.map { ($0.name, $0) }) { first, _ in first } + let source = text as NSString + return tokenExpression.matches( + in: text, + range: NSRange(location: 0, length: source.length) + ).compactMap { match in + let range = match.range(at: 0) + let hasEndBoundary = NSMaxRange(range) == source.length + let preservesThisTrailingToken = hasEndBoundary + && preserved?.range == range + && preserved?.rawText == source.substring(with: range) + guard !hasEndBoundary || allowsEndBoundary || preservesThisTrailingToken else { + return nil + } + + let name = source.substring(with: match.range(at: 1)) + guard let skill = skillsByName[name] else { return nil } + return FeatureInlineSkillDescriptor( + rawText: source.substring(with: range), + displayName: skill.invocationDisplayName, + range: range + ) + } + } +} + +struct FeatureInlineSkillAttachmentSignature: Equatable { + let descriptor: FeatureInlineSkillDescriptor + let styleKey: String +} + +extension NSAttributedString.Key { + static let featureInlineSkillRawText = NSAttributedString.Key("t3.inline-skill.raw-text") + static let featureInlineSkillDisplayName = NSAttributedString.Key("t3.inline-skill.display-name") + static let featureInlineSkillStyleKey = NSAttributedString.Key("t3.inline-skill.style-key") +} + +enum FeatureInlineSkillProjection { + private struct Run { + let displayRange: NSRange + let plainRange: NSRange + let rawText: String? + let displayName: String? + let styleKey: String? + } + + static func plainText(from attributedText: NSAttributedString) -> String { + let result = NSMutableString() + let source = attributedText.string as NSString + for run in runs(in: attributedText) { + result.append(run.rawText ?? source.substring(with: run.displayRange)) + } + return result as String + } + + static func signatures( + in attributedText: NSAttributedString + ) -> [FeatureInlineSkillAttachmentSignature] { + runs(in: attributedText).compactMap { run in + guard let rawText = run.rawText, + let displayName = run.displayName, + let styleKey = run.styleKey else { return nil } + return FeatureInlineSkillAttachmentSignature( + descriptor: FeatureInlineSkillDescriptor( + rawText: rawText, + displayName: displayName, + range: run.plainRange + ), + styleKey: styleKey + ) + } + } + + static func plainRange( + for displayRange: NSRange, + in attributedText: NSAttributedString + ) -> NSRange { + guard displayRange.location != NSNotFound else { return NSRange(location: 0, length: 0) } + let runs = runs(in: attributedText) + let lower = plainOffset(for: displayRange.location, runs: runs, displayLength: attributedText.length) + let upper = plainOffset(for: NSMaxRange(displayRange), runs: runs, displayLength: attributedText.length) + return NSRange(location: lower, length: max(0, upper - lower)) + } + + static func displayRange( + for plainRange: NSRange, + in attributedText: NSAttributedString + ) -> NSRange { + guard plainRange.location != NSNotFound else { return NSRange(location: 0, length: 0) } + let runs = runs(in: attributedText) + let plainLength = runs.last.map { NSMaxRange($0.plainRange) } ?? 0 + let lower = displayOffset(for: plainRange.location, runs: runs, plainLength: plainLength) + let upper = displayOffset(for: NSMaxRange(plainRange), runs: runs, plainLength: plainLength) + return NSRange(location: lower, length: max(0, upper - lower)) + } + + private static func plainOffset( + for displayOffset: Int, + runs: [Run], + displayLength: Int + ) -> Int { + let target = min(max(displayOffset, 0), displayLength) + var lengthDelta = 0 + for run in runs where run.rawText != nil { + guard target > run.displayRange.location else { break } + if target <= NSMaxRange(run.displayRange) { + return NSMaxRange(run.plainRange) + } + lengthDelta += run.plainRange.length - run.displayRange.length + } + return target + lengthDelta + } + + private static func displayOffset( + for plainOffset: Int, + runs: [Run], + plainLength: Int + ) -> Int { + let target = min(max(plainOffset, 0), plainLength) + var lengthDelta = 0 + for run in runs where run.rawText != nil { + guard target > run.plainRange.location else { break } + if target <= NSMaxRange(run.plainRange) { + return NSMaxRange(run.displayRange) + } + lengthDelta += run.plainRange.length - run.displayRange.length + } + return target - lengthDelta + } + + private static func runs(in attributedText: NSAttributedString) -> [Run] { + var result: [Run] = [] + var plainOffset = 0 + let source = attributedText.string as NSString + attributedText.enumerateAttributes( + in: NSRange(location: 0, length: attributedText.length) + ) { attributes, displayRange, _ in + let isSkillAttachment = displayRange.length == 1 + && source.character(at: displayRange.location) == 0xFFFC + && attributes[.attachment] is NSTextAttachment + let rawText = isSkillAttachment + ? attributes[.featureInlineSkillRawText] as? String + : nil + let plainLength = rawText.map { ($0 as NSString).length } ?? displayRange.length + result.append( + Run( + displayRange: displayRange, + plainRange: NSRange(location: plainOffset, length: plainLength), + rawText: rawText, + displayName: rawText == nil + ? nil + : attributes[.featureInlineSkillDisplayName] as? String, + styleKey: rawText == nil + ? nil + : attributes[.featureInlineSkillStyleKey] as? String + ) + ) + plainOffset += plainLength + } + return result + } +} + +@MainActor +enum FeatureInlineSkillPillRenderer { + private static let imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + + static func attributedText( + source: String, + descriptors: [FeatureInlineSkillDescriptor], + baseAttributes: [NSAttributedString.Key: Any], + font: UIFont, + traits: UITraitCollection + ) -> NSAttributedString { + guard !descriptors.isEmpty else { + return NSAttributedString(string: source, attributes: baseAttributes) + } + let result = NSMutableAttributedString() + let sourceText = source as NSString + let styleKey = styleKey(font: font, traits: traits) + var cursor = 0 + + for descriptor in descriptors where descriptor.range.location >= cursor { + if descriptor.range.location > cursor { + result.append( + NSAttributedString( + string: sourceText.substring( + with: NSRange(location: cursor, length: descriptor.range.location - cursor) + ), + attributes: baseAttributes + ) + ) + } + + let attachment = NSTextAttachment() + let renderedPill = image( + label: descriptor.displayName, + font: font, + traits: traits + ) + attachment.image = renderedPill + attachment.bounds = CGRect( + x: 0, + y: (font.capHeight - renderedPill.size.height) / 2, + width: renderedPill.size.width, + height: renderedPill.size.height + ) + let attachmentText = NSMutableAttributedString(attachment: attachment) + attachmentText.addAttributes( + baseAttributes.merging( + [ + .featureInlineSkillRawText: descriptor.rawText, + .featureInlineSkillDisplayName: descriptor.displayName, + .featureInlineSkillStyleKey: styleKey, + ], + uniquingKeysWith: { _, replacement in replacement } + ), + range: NSRange(location: 0, length: attachmentText.length) + ) + result.append(attachmentText) + cursor = NSMaxRange(descriptor.range) + } + + if cursor < sourceText.length { + result.append( + NSAttributedString( + string: sourceText.substring(from: cursor), + attributes: baseAttributes + ) + ) + } + return result + } + + static func signatures( + for descriptors: [FeatureInlineSkillDescriptor], + font: UIFont, + traits: UITraitCollection + ) -> [FeatureInlineSkillAttachmentSignature] { + let styleKey = styleKey(font: font, traits: traits) + return descriptors.map { + FeatureInlineSkillAttachmentSignature(descriptor: $0, styleKey: styleKey) + } + } + + private static func styleKey(font: UIFont, traits: UITraitCollection) -> String { + [ + String(format: "%.3f", font.pointSize), + String(traits.userInterfaceStyle.rawValue), + traits.preferredContentSizeCategory.rawValue, + String(format: "%.2f", traits.displayScale), + ].joined(separator: ":") + } + + private static func image( + label: String, + font: UIFont, + traits: UITraitCollection + ) -> UIImage { + let cacheKey = "\(styleKey(font: font, traits: traits))\u{0}\(label)" as NSString + if let cached = imageCache.object(forKey: cacheKey) { + return cached + } + let labelFont = UIFont.systemFont(ofSize: max(11, font.pointSize * 0.86), weight: .medium) + let pillHeight = max(18, font.pointSize * 1.41) + let iconSize = max(12, labelFont.pointSize * 1.08) + let horizontalPadding = max(6, font.pointSize * 0.5) + let gap = max(4, font.pointSize * 0.28) + let maximumLabelWidth: CGFloat = 190 + let measuredLabelWidth = (label as NSString).size(withAttributes: [.font: labelFont]).width + let labelWidth = min(maximumLabelWidth, ceil(measuredLabelWidth)) + let size = CGSize( + width: ceil(horizontalPadding * 2 + iconSize + gap + labelWidth), + height: ceil(pillHeight) + ) + + let format = UIGraphicsImageRendererFormat() + format.scale = traits.displayScale > 0 ? traits.displayScale : UIScreen.main.scale + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: size, format: format) + let rendered = renderer.image { _ in + let foreground = UIColor { currentTraits in + currentTraits.userInterfaceStyle == .dark + ? UIColor(red: 240 / 255, green: 171 / 255, blue: 252 / 255, alpha: 1) + : UIColor(red: 162 / 255, green: 28 / 255, blue: 175 / 255, alpha: 1) + }.resolvedColor(with: traits) + let fuchsia = UIColor(red: 217 / 255, green: 70 / 255, blue: 239 / 255, alpha: 1) + let bounds = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5) + let path = UIBezierPath( + roundedRect: bounds, + cornerRadius: labelFont.pointSize * 0.5 + ) + fuchsia.withAlphaComponent(0.12).setFill() + path.fill() + fuchsia.withAlphaComponent(0.25).setStroke() + path.lineWidth = 1 + path.stroke() + + let iconOrigin = CGPoint( + x: horizontalPadding, + y: (size.height - iconSize) / 2 + ) + if let icon = UIImage( + systemName: "shippingbox", + withConfiguration: UIImage.SymbolConfiguration( + pointSize: iconSize, + weight: .regular + ) + )?.withTintColor(foreground, renderingMode: .alwaysOriginal) { + icon.draw(in: CGRect(origin: iconOrigin, size: CGSize(width: iconSize, height: iconSize))) + } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byTruncatingTail + let labelRect = CGRect( + x: horizontalPadding + iconSize + gap, + y: (size.height - labelFont.lineHeight) / 2, + width: labelWidth, + height: labelFont.lineHeight + ) + (label as NSString).draw( + with: labelRect, + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + attributes: [ + .font: labelFont, + .foregroundColor: foreground, + .paragraphStyle: paragraphStyle, + ], + context: nil + ) + } + imageCache.setObject(rendered, forKey: cacheKey) + return rendered + } +} + +/// Makes selected pill text portable. UIKit otherwise copies an attachment as +/// rich image data or the object-replacement character instead of `$skill`. +class FeatureInlineSkillTextView: UITextView { + override func copy(_ sender: Any?) { + guard let selectedPlainText else { + super.copy(sender) + return + } + UIPasteboard.general.string = selectedPlainText + } + + override func cut(_ sender: Any?) { + guard let selectedPlainText else { + super.cut(sender) + return + } + super.cut(sender) + UIPasteboard.general.string = selectedPlainText + } + + private var selectedPlainText: String? { + let range = selectedRange + guard range.location != NSNotFound, + range.length > 0, + NSMaxRange(range) <= attributedText.length else { + return nil + } + let selected = attributedText.attributedSubstring(from: range) + guard !FeatureInlineSkillProjection.signatures(in: selected).isEmpty else { return nil } + return FeatureInlineSkillProjection.plainText(from: selected) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureToolActivityIcon.swift b/apps/swift-ios/Features/Chat/FeatureToolActivityIcon.swift new file mode 100644 index 000000000000..0f8b2acd8412 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureToolActivityIcon.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct FeatureToolActivityIcon: View { + let presentation: ToolActivityPresentation? + let context: MarkdownImageContext? + @SwiftUI.Environment(\.colorScheme) private var colorScheme + @State private var nativeURL: URL? + + private var url: URL? { + nativeURL ?? (colorScheme == .dark ? presentation?.darkURL ?? presentation?.lightURL : presentation?.lightURL) + } + + private var fallback: String { + switch presentation?.surface { + case "browser": "globe" + case "computer": "desktopcomputer" + default: "terminal" + } + } + + var body: some View { + AsyncImage(url: url) { image in + image.resizable().scaledToFit() + } placeholder: { + Image(systemName: fallback) + } + .frame(width: 16, height: 16) + .accessibilityHidden(true) + .task(id: presentation) { + nativeURL = nil + guard let context, let app = presentation?.nativeApp else { return } + let url = try? await context.resolver.nativeAppIconURL(threadID: context.threadID, app: app) + guard !Task.isCancelled else { return } + nativeURL = url + } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift b/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift new file mode 100644 index 000000000000..22599e65e273 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift @@ -0,0 +1,417 @@ +import Foundation +import Observation + +enum FeatureVoiceInputPhase: Equatable { + case idle + case preparing + case recording + case transcribing + case error +} + +enum FeatureVoiceInputErrorAction: Equatable { + case retry + case settings +} + +struct FeatureVoiceDraftSnapshot: Equatable { + let ownerID: String + let text: String + let revision: UInt64 + let selection: NSRange +} + +struct FeatureVoiceTranscriptCommit: Equatable, Identifiable { + let id = UUID() + let text: String + let caretLocation: Int +} + +enum FeatureVoiceTranscriptCommitResult: Equatable { + case commit(FeatureVoiceTranscriptCommit) + case empty + case stale +} + +enum FeatureVoiceTranscriptResolver { + static func resolve( + captured: FeatureVoiceDraftSnapshot, + current: FeatureVoiceDraftSnapshot?, + transcript: String, + localeIdentifier: String + ) -> FeatureVoiceTranscriptCommitResult { + guard let current, + current.ownerID == captured.ownerID, + current.text == captured.text, + current.revision == captured.revision, + captured.selection.location >= 0, + captured.selection.length >= 0, + captured.selection.location <= captured.text.utf16.count, + captured.selection.length <= captured.text.utf16.count + - captured.selection.location, + Range(captured.selection, in: captured.text) != nil else { + return .stale + } + + let replacement = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !replacement.isEmpty else { return .empty } + + var insertion = replacement + let normalizedLocale = localeIdentifier + .replacingOccurrences(of: "_", with: "-") + .lowercased() + if captured.selection.length == 0, + normalizedLocale == "en" || normalizedLocale.hasPrefix("en-") { + let text = captured.text as NSString + let location = captured.selection.location + let left = location > 0 ? text.character(at: location - 1) : nil + let right = location < text.length ? text.character(at: location) : nil + let leftNeedsSpace = left.map(Self.leftBoundaryCharacters.contains) == true + && (right == nil || right.map(Self.isWhitespace) == true) + let rightNeedsSpace = right.map(Self.rightBoundaryCharacters.contains) == true + && (left == nil || left.map(Self.isWhitespace) == true) + insertion = "\(leftNeedsSpace ? " " : "")\(replacement)\(rightNeedsSpace ? " " : "")" + } + + let nextText = (captured.text as NSString).replacingCharacters( + in: captured.selection, + with: insertion + ) + return .commit(FeatureVoiceTranscriptCommit( + text: nextText, + caretLocation: captured.selection.location + insertion.utf16.count + )) + } + + private static let leftBoundaryCharacters = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.!?,:;)]}'\"" + .utf16 + ) + private static let rightBoundaryCharacters = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789([{'\"" + .utf16 + ) + + private static func isWhitespace(_ codeUnit: unichar) -> Bool { + guard let scalar = UnicodeScalar(codeUnit) else { return false } + return CharacterSet.whitespacesAndNewlines.contains(scalar) + } +} + +enum FeatureVoiceMicrophonePermission: Equatable { + case granted + case denied +} + +@MainActor +protocol FeatureVoiceInputAdapter: AnyObject { + var isSupported: Bool { get } + var localeIdentifier: String { get } + + func prepare() async throws + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission + func startRecording(maximumDuration: TimeInterval) throws + func stopRecording() async throws -> URL + func transcribe(recordingURL: URL) async throws -> String + func cancelTranscription() async + func cleanup() async +} + +@MainActor +private enum FeatureVoiceInputOperationGate { + static var owner: UUID? + + static func acquire(_ candidate: UUID) -> Bool { + guard owner == nil else { return false } + owner = candidate + return true + } + + static func release(_ candidate: UUID) { + if owner == candidate { owner = nil } + } +} + +@MainActor +@Observable +final class FeatureVoiceInputController { + static let maximumRecordingDuration: TimeInterval = 5 * 60 + + private(set) var phase: FeatureVoiceInputPhase = .idle + private(set) var errorMessage: String? + private(set) var errorAction: FeatureVoiceInputErrorAction? + private(set) var recordingStartedAt: Date? + private(set) var pendingCommit: FeatureVoiceTranscriptCommit? + + @ObservationIgnored private let adapter: any FeatureVoiceInputAdapter + @ObservationIgnored private var currentDraft: FeatureVoiceDraftSnapshot? + @ObservationIgnored private var capturedDraft: FeatureVoiceDraftSnapshot? + @ObservationIgnored private var operationID: UUID? + @ObservationIgnored private var operationTask: Task? + @ObservationIgnored private var recordingLimitTask: Task? + + init(adapter: any FeatureVoiceInputAdapter = FeatureVoiceInputAdapterFactory.make()) { + self.adapter = adapter + } + + var isSupported: Bool { adapter.isSupported } + + var isBusy: Bool { + phase == .preparing || phase == .recording || phase == .transcribing + } + + func updateDraft(_ snapshot: FeatureVoiceDraftSnapshot) { + currentDraft = snapshot + } + + func updateSelection(_ selection: NSRange) { + guard let currentDraft else { return } + self.currentDraft = FeatureVoiceDraftSnapshot( + ownerID: currentDraft.ownerID, + text: currentDraft.text, + revision: currentDraft.revision, + selection: selection + ) + } + + func start() { + guard phase == .idle || phase == .error else { return } + guard adapter.isSupported else { + setError("Voice transcription requires a supported device with iOS 26 or later.", nil) + return + } + guard let currentDraft else { + setError("This draft is no longer available.", .retry) + return + } + + let id = UUID() + guard FeatureVoiceInputOperationGate.acquire(id) else { + setError("Another voice recording is still finishing.", .retry) + return + } + + operationID = id + capturedDraft = currentDraft + pendingCommit = nil + setPhase(.preparing) + operationTask = Task { [weak self] in + await self?.prepareAndStartRecording(id: id) + } + } + + func stop() { + guard phase == .recording, let id = operationID else { return } + recordingLimitTask?.cancel() + recordingLimitTask = nil + recordingStartedAt = nil + setPhase(.transcribing) + operationTask = Task { [weak self] in + await self?.stopAndTranscribe(id: id) + } + } + + func cancel() { + switch phase { + case .idle: + return + case .error: + clearError() + case .preparing: + invalidateOperation() + setPhase(.idle) + case .recording: + guard let id = operationID else { + setPhase(.idle) + return + } + invalidateOperation() + setPhase(.idle) + operationTask = Task { [weak self] in + await self?.cleanupAndRelease(id: id) + } + case .transcribing: + invalidateOperation() + setPhase(.idle) + Task { [weak self] in + await self?.adapter.cancelTranscription() + } + } + } + + func ownerChanged(to snapshot: FeatureVoiceDraftSnapshot) { + if currentDraft?.ownerID != snapshot.ownerID { + pendingCommit = nil + if phase != .idle { cancel() } + } + currentDraft = snapshot + } + + func appMovedToBackground() { + if isBusy { cancel() } + } + + func recordingWasInterrupted() { + guard phase == .recording, let id = operationID else { return } + invalidateOperation() + setError("Voice recording was interrupted.", .retry) + operationTask = Task { [weak self] in + await self?.cleanupAndRelease(id: id) + } + } + + func consumePendingCommit() { + pendingCommit = nil + } + + func waitForCurrentOperation() async { + await operationTask?.value + } + + private func prepareAndStartRecording(id: UUID) async { + do { + // Locale resolution and asset installation happen before the app + // asks for microphone access. A permission prompt must not hide a + // long asset download. + try await adapter.prepare() + guard isCurrent(id) else { + await cleanupAndRelease(id: id) + return + } + + guard await adapter.requestMicrophonePermission() == .granted else { + await cleanupAndRelease(id: id) + if operationID == id { + setError("Microphone access is required for voice input.", .settings) + operationID = nil + } + return + } + guard isCurrent(id), draftContentMatches(capturedDraft, currentDraft) else { + await cleanupAndRelease(id: id) + if operationID == id { + setError("This draft is no longer available.", .retry) + operationID = nil + } + return + } + + try adapter.startRecording(maximumDuration: Self.maximumRecordingDuration) + guard isCurrent(id) else { + await cleanupAndRelease(id: id) + return + } + recordingStartedAt = .now + setPhase(.recording) + scheduleRecordingLimit(for: id) + } catch { + await cleanupAndRelease(id: id) + if operationID == id { + operationID = nil + setError("Could not prepare voice input.", .retry) + } + } + } + + private func stopAndTranscribe(id: UUID) async { + do { + let recordingURL = try await adapter.stopRecording() + guard isCurrent(id), let capturedDraft else { + await cleanupAndRelease(id: id) + return + } + + let transcript = try await adapter.transcribe(recordingURL: recordingURL) + guard isCurrent(id) else { + await cleanupAndRelease(id: id) + return + } + + let result = FeatureVoiceTranscriptResolver.resolve( + captured: capturedDraft, + current: currentDraft, + transcript: transcript, + localeIdentifier: adapter.localeIdentifier + ) + await cleanupAndRelease(id: id) + guard operationID == id else { return } + operationID = nil + self.capturedDraft = nil + switch result { + case let .commit(commit): + pendingCommit = commit + setPhase(.idle) + case .empty: + setError("No speech was detected.", .retry) + case .stale: + setError( + "The draft changed while voice input was running. The transcript was not added.", + .retry + ) + } + } catch { + await cleanupAndRelease(id: id) + if operationID == id { + operationID = nil + setError("Could not transcribe this recording.", .retry) + } + } + } + + private func scheduleRecordingLimit(for id: UUID) { + recordingLimitTask?.cancel() + recordingLimitTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.maximumRecordingDuration)) + guard !Task.isCancelled, let self, self.isCurrent(id) else { return } + self.stop() + } + } + + private func invalidateOperation() { + operationID = nil + capturedDraft = nil + recordingStartedAt = nil + recordingLimitTask?.cancel() + recordingLimitTask = nil + } + + private func cleanupAndRelease(id: UUID) async { + await adapter.cleanup() + FeatureVoiceInputOperationGate.release(id) + } + + private func isCurrent(_ id: UUID) -> Bool { + operationID == id + } + + private func draftContentMatches( + _ captured: FeatureVoiceDraftSnapshot?, + _ current: FeatureVoiceDraftSnapshot? + ) -> Bool { + guard let captured, let current else { return false } + return captured.ownerID == current.ownerID + && captured.text == current.text + && captured.revision == current.revision + } + + private func setPhase(_ phase: FeatureVoiceInputPhase) { + self.phase = phase + if phase != .error { + errorMessage = nil + errorAction = nil + } + } + + private func setError(_ message: String, _ action: FeatureVoiceInputErrorAction?) { + phase = .error + errorMessage = message + errorAction = action + recordingStartedAt = nil + } + + private func clearError() { + errorMessage = nil + errorAction = nil + setPhase(.idle) + } +} diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift new file mode 100644 index 000000000000..66d405f643f5 --- /dev/null +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -0,0 +1,798 @@ +import ImageIO +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +enum FeatureImageAttachmentLimits { + /// Shared by every attachment entry point (picker, camera, files, and + /// paste), so their in-flight reservations count against the same cap. + static let maximumCount = 8 +} + +struct FeatureAttachmentPreparationState: Equatable { + struct Operation: Hashable { + fileprivate let id: UUID + } + + private var pendingItemsByOperation: [Operation: Int] = [:] + + var isPreparing: Bool { + !pendingItemsByOperation.isEmpty + } + + var pendingItemCount: Int { + pendingItemsByOperation.values.reduce(0, +) + } + + var statusLabel: String { + pendingItemCount == 1 + ? "Preparing attachment…" + : "Preparing \(pendingItemCount) attachments…" + } + + @discardableResult + mutating func begin(itemCount: Int, id: UUID = UUID()) -> Operation { + let operation = Operation(id: id) + pendingItemsByOperation[operation] = max(1, itemCount) + return operation + } + + mutating func finish(_ operation: Operation) { + pendingItemsByOperation.removeValue(forKey: operation) + } +} + +struct FeatureAttachmentOperationIdentity: Equatable { + let ownerID: String + let environmentID: String? + let generation: UUID + + func matches(ownerID: String, environmentID: String?, generation: UUID) -> Bool { + self.ownerID == ownerID + && self.environmentID == environmentID + && self.generation == generation + } +} + +struct FeatureImageAttachmentPicker: View { + private enum Source { + case photoLibrary + case camera + case files + } + + @Binding var attachments: [FeatureDraftAttachment] + @Binding var preparationState: FeatureAttachmentPreparationState + @Binding var isFlowActive: Bool + let maximumCount: Int + let draftOwnerID: String + let environmentID: String? + let imagesAllowed: Bool + let maximumFileBytes: Int? + + @State private var isAttachmentSourcePresented = false + @State private var isPhotoLibraryPresented = false + @State private var pendingPhotoLibraryItems: [FeaturePhotoLibraryItem] = [] + @State private var isCameraPresented = false + @State private var isFileImporterPresented = false + @State private var sourcePresentationTask: Task? + @State private var errorMessage: String? + @State private var generation = UUID() + @State private var flowIdentity: FeatureAttachmentOperationIdentity? + + init( + attachments: Binding<[FeatureDraftAttachment]>, + preparationState: Binding, + isFlowActive: Binding, + draftOwnerID: String, + environmentID: String?, + imagesAllowed: Bool, + maximumFileBytes: Int?, + maximumCount: Int = FeatureImageAttachmentLimits.maximumCount + ) { + _attachments = attachments + _preparationState = preparationState + _isFlowActive = isFlowActive + self.maximumCount = maximumCount + self.draftOwnerID = draftOwnerID + self.environmentID = environmentID + self.imagesAllowed = imagesAllowed + self.maximumFileBytes = maximumFileBytes + } + + var body: some View { + Button { + flowIdentity = FeatureAttachmentOperationIdentity( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) + isFlowActive = true + isAttachmentSourcePresented = true + } label: { + Image(systemName: preparationState.isPreparing ? "hourglass" : "paperclip") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .opacity(canAdd ? 1 : 0.3) + .accessibilityLabel(attachmentAccessibilityLabel) + .accessibilityIdentifier("image-attachment-picker") + .accessibilityHint(attachmentAccessibilityHint) + .confirmationDialog("Add attachment", isPresented: $isAttachmentSourcePresented) { + Button("Photo Library") { present(.photoLibrary) } + .disabled(!imagesAllowed && maximumFileBytes == nil) + Button("Camera") { present(.camera) } + .disabled(!imagesAllowed || !UIImagePickerController.isSourceTypeAvailable(.camera)) + Button("Files") { present(.files) } + Button("Cancel", role: .cancel) { + isFlowActive = false + } + } + .fullScreenCover( + isPresented: $isPhotoLibraryPresented, + onDismiss: finishPhotoLibrarySelection + ) { + FeaturePhotoLibraryPicker( + maximumCount: max(1, remainingCount), + imagesAllowed: imagesAllowed, + videosAllowed: maximumFileBytes != nil, + onFinish: { items in + pendingPhotoLibraryItems = items + isPhotoLibraryPresented = false + } + ) + .ignoresSafeArea() + } + .fullScreenCover(isPresented: $isCameraPresented) { + FeatureCameraPicker( + onCapture: loadCapturedImage, + onCancel: { + isCameraPresented = false + isFlowActive = false + } + ) + .ignoresSafeArea() + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: maximumFileBytes == nil ? [.image] : [.item], + allowsMultipleSelection: true, + onCompletion: loadFiles + ) + .alert( + "Couldn’t add attachment", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .onDisappear { + sourcePresentationTask?.cancel() + if !isFlowActive { + generation = UUID() + flowIdentity = nil + } + } + .onChange(of: draftOwnerID) { + generation = UUID() + flowIdentity = nil + pendingPhotoLibraryItems = [] + } + .onChange(of: environmentID) { + generation = UUID() + flowIdentity = nil + pendingPhotoLibraryItems = [] + } + } + + private var remainingCount: Int { + max(0, maximumCount - attachments.count) + } + + private var canAdd: Bool { + (imagesAllowed || maximumFileBytes != nil) + && !preparationState.isPreparing && remainingCount > 0 + } + + private var attachmentAccessibilityLabel: String { + if preparationState.isPreparing { return preparationState.statusLabel } + if remainingCount == 0 { return "Attachment limit reached" } + return "Add attachment" + } + + private var attachmentAccessibilityHint: String { + if !imagesAllowed && maximumFileBytes == nil { return "Attachments are not supported" } + if remainingCount == 0 { return "Remove an attachment before adding another" } + return maximumFileBytes == nil + ? "Choose a photo, take a photo, or browse image files" + : "Choose a photo, video, or file" + } + + private func present(_ source: Source) { + sourcePresentationTask?.cancel() + isAttachmentSourcePresented = false + sourcePresentationTask = Task { @MainActor in + // A confirmation dialog is still the active presenter while its action + // runs. Wait for its dismissal animation before presenting another + // controller or UIKit can reject (or race) the new presentation. + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, canAdd else { + isFlowActive = false + return + } + switch source { + case .photoLibrary: + isPhotoLibraryPresented = true + case .camera: + isCameraPresented = true + case .files: + isFileImporterPresented = true + } + } + } + + private func finishPhotoLibrarySelection() { + guard let identity = flowIdentity else { + pendingPhotoLibraryItems = [] + isFlowActive = false + return + } + Task { @MainActor in + // Keep PhotosUI presentation and asset materialization in separate turns. + // Some OS versions become stuck or dismiss mid-selection when the picker + // and its selection are driven by the same SwiftUI binding transaction. + await Task.yield() + guard !isPhotoLibraryPresented, !pendingPhotoLibraryItems.isEmpty, canAdd else { + pendingPhotoLibraryItems = [] + isFlowActive = false + return + } + + let selected = Array(pendingPhotoLibraryItems.prefix(remainingCount)) + pendingPhotoLibraryItems = [] + let firstOrdinal = attachments.count + preparationState.pendingItemCount + 1 + let operation = preparationState.begin(itemCount: selected.count) + + defer { + preparationState.finish(operation) + isFlowActive = false + } + + for (offset, item) in selected.enumerated() { + do { + let attachment = try await item.loadAttachment( + ordinal: firstOrdinal + offset, + maximumFileBytes: maximumFileBytes + ) + guard identity.matches( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) else { + discardOwnedFile(for: attachment) + return + } + if attachment.mimeType.hasPrefix("image/"), !imagesAllowed { + throw FeatureAttachmentIntakeError.imagesUnsupported + } + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + } + } + } + } + + private func loadCapturedImage(_ image: UIImage) { + isCameraPresented = false + guard canAdd else { + isFlowActive = false + return + } + guard let identity = flowIdentity else { + isFlowActive = false + return + } + let operation = preparationState.begin(itemCount: 1) + + Task { + defer { + preparationState.finish(operation) + isFlowActive = false + } + do { + let ordinal = attachments.count + 1 + let data = try await Task.detached(priority: .userInitiated) { + guard let data = image.jpegData(compressionQuality: 0.94) else { + throw FeatureImageAttachmentError.encodingFailed + } + return data + }.value + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + guard identity.matches( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) else { return } + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func loadFiles(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + errorMessage = error.localizedDescription + isFlowActive = false + case .success(let urls): + guard !urls.isEmpty, canAdd, let identity = flowIdentity else { + isFlowActive = false + return + } + let operation = preparationState.begin(itemCount: min(urls.count, remainingCount)) + + Task { + defer { + preparationState.finish(operation) + isFlowActive = false + } + for url in urls.prefix(remainingCount) { + do { + let attachment = try await prepareFile(url) + guard identity.matches( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) else { + discardOwnedFile(for: attachment) + return + } + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + break + } + } + } + } + } + + private func prepareFile(_ url: URL) async throws -> FeatureDraftAttachment { + let type = UTType(filenameExtension: url.pathExtension) + if type?.conforms(to: .image) == true { + guard imagesAllowed else { throw FeatureAttachmentIntakeError.imagesUnsupported } + let ordinal = attachments.count + 1 + let data = try await Task.detached(priority: .userInitiated) { + let hasAccess = url.startAccessingSecurityScopedResource() + defer { if hasAccess { url.stopAccessingSecurityScopedResource() } } + return try Data(contentsOf: url, options: .mappedIfSafe) + }.value + return try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + } + guard let maximumFileBytes else { throw FeatureAttachmentIntakeError.filesUnsupported } + let id = UUID() + let owned = try await Task.detached(priority: .userInitiated) { + try ManagedAttachmentFileStore().copyOwnedFile( + from: url, + attachmentID: id, + originalFileName: url.lastPathComponent, + maximumBytes: maximumFileBytes + ) + }.value + return FeatureDraftAttachment( + id: id, + ownedFile: owned, + filename: url.lastPathComponent, + mimeType: type?.preferredMIMEType ?? "application/octet-stream" + ) + } + + private func appendImage(_ data: Data, ordinal: Int? = nil) async throws { + let ordinal = ordinal ?? attachments.count + 1 + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + attachments.append(attachment) + } + + private func discardOwnedFile(for attachment: FeatureDraftAttachment) { + guard let fileName = attachment.ownedFile?.fileName else { return } + try? ManagedAttachmentFileStore().removeOwnedFile(fileName: fileName) + } +} + +private struct FeaturePhotoLibraryItem: @unchecked Sendable { + let provider: NSItemProvider + + @MainActor + func loadAttachment( + ordinal: Int, + maximumFileBytes: Int? + ) async throws -> FeatureDraftAttachment { + if provider.registeredTypeIdentifiers.contains(where: { + UTType($0)?.conforms(to: .image) == true + }) { + let data = try await FeatureImageItemProviderLoader.data(from: provider) + return try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + } + guard let maximumFileBytes else { throw FeatureAttachmentIntakeError.filesUnsupported } + return try await FeatureFileItemProviderLoader.attachment( + from: provider, + maximumBytes: maximumFileBytes + ) + } +} + +enum FeatureFileItemProviderLoader { + @MainActor + static func attachment( + from provider: NSItemProvider, + maximumBytes: Int + ) async throws -> FeatureDraftAttachment { + guard let identifier = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .movie) == true + || UTType($0)?.conforms(to: .item) == true + }) else { throw FeatureAttachmentIntakeError.invalidFile } + let type = UTType(identifier) + let id = UUID() + let preferredExtension = type?.preferredFilenameExtension + let suggestedName = provider.suggestedName ?? "Attachment" + let suggestedURL = URL(fileURLWithPath: suggestedName) + let fileName = suggestedURL.pathExtension.isEmpty + ? preferredExtension.map { "\(suggestedName).\($0)" } ?? suggestedName + : suggestedName + + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: identifier) { url, error in + do { + guard let url else { + throw error ?? FeatureAttachmentIntakeError.invalidFile + } + // The provider deletes this URL when the callback returns. + let owned = try ManagedAttachmentFileStore().copyOwnedFile( + from: url, + attachmentID: id, + originalFileName: fileName, + maximumBytes: maximumBytes + ) + continuation.resume(returning: FeatureDraftAttachment( + id: id, + ownedFile: owned, + filename: fileName, + mimeType: type?.preferredMIMEType ?? "application/octet-stream" + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} + +/// Loads raw image bytes from an `NSItemProvider`, shared by the photo +/// library picker and the composer's paste path. Main-actor isolated because +/// providers arrive from main-actor UI callbacks and are not `Sendable`; the +/// provider does its own work off-thread. +enum FeatureImageItemProviderLoader { + struct Load { + fileprivate let values: AsyncThrowingStream + + @MainActor + func data() async throws -> Data { + for try await data in values { + return data + } + throw FeatureImageAttachmentError.encodingFailed + } + } + + /// Starts the provider request before returning. Drop callers use this + /// form so access begins within `performDrop`, while the provider grant is + /// active. + @MainActor + static func start(from provider: NSItemProvider) throws -> Load { + guard let typeIdentifier = provider.registeredTypeIdentifiers.first(where: { identifier in + UTType(identifier)?.conforms(to: .image) == true + }) else { + throw FeatureImageAttachmentError.invalidImage + } + + let values = AsyncThrowingStream { continuation in + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + if let data { + continuation.yield(data) + continuation.finish() + } else { + continuation.finish( + throwing: error ?? FeatureImageAttachmentError.encodingFailed + ) + } + } + } + return Load(values: values) + } + + @MainActor + static func data(from provider: NSItemProvider) async throws -> Data { + try await start(from: provider).data() + } +} + +private struct FeaturePhotoLibraryPicker: UIViewControllerRepresentable { + let maximumCount: Int + let imagesAllowed: Bool + let videosAllowed: Bool + let onFinish: @MainActor ([FeaturePhotoLibraryItem]) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration() + configuration.filter = if imagesAllowed && videosAllowed { + .any(of: [.images, .videos]) + } else if videosAllowed { + .videos + } else { + .images + } + configuration.selectionLimit = maximumCount + configuration.selection = .ordered + configuration.preferredAssetRepresentationMode = .compatible + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ picker: PHPickerViewController, context: Context) {} + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + private let onFinish: @MainActor ([FeaturePhotoLibraryItem]) -> Void + private var didFinish = false + + init(onFinish: @escaping @MainActor ([FeaturePhotoLibraryItem]) -> Void) { + self.onFinish = onFinish + } + + func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !didFinish else { return } + didFinish = true + + let items = results.map { FeaturePhotoLibraryItem(provider: $0.itemProvider) } + Task { @MainActor in + onFinish(items) + } + } + } +} + +struct FeatureAttachmentStrip: View { + @Binding var attachments: [FeatureDraftAttachment] + + var body: some View { + if !attachments.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + FeatureAttachmentThumbnail(attachment: attachment) { + attachments.removeAll { $0.id == attachment.id } + } + } + } + .padding(.horizontal, 1) + } + .scrollIndicators(.hidden) + .accessibilityLabel("\(attachments.count) attachments") + } + } +} + +private struct FeatureAttachmentThumbnail: View { + let attachment: FeatureDraftAttachment + let onRemove: () -> Void + @State private var image: UIImage? + + var body: some View { + ZStack(alignment: .topTrailing) { + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if attachment.mimeType.hasPrefix("image/") { + Image(systemName: "photo") + .foregroundStyle(T3Colors.textSecondary) + } else { + VStack(spacing: 3) { + Image(systemName: "doc") + Text(attachment.filename) + .font(.caption2) + .lineLimit(1) + Text(ByteCountFormatter.string( + fromByteCount: Int64(attachment.byteCount), + countStyle: .file + )) + .font(.caption2) + } + .foregroundStyle(T3Colors.textSecondary) + } + } + .frame(width: 58, height: 58) + .background(T3Colors.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(.black.opacity(0.78), in: Circle()) + .frame( + width: T3Metrics.minimumTapTarget, + height: T3Metrics.minimumTapTarget + ) + .contentShape(Rectangle()) + } + .offset(x: 11, y: -11) + .accessibilityLabel("Remove \(attachment.filename)") + } + .padding(.top, 11) + .padding(.trailing, 11) + .task(id: attachment.id) { + guard attachment.mimeType.hasPrefix("image/") else { return } + let data = attachment.thumbnailData ?? attachment.data + image = await Task.detached(priority: .utility) { + UIImage(data: data) + }.value + } + } +} + +private struct FeatureCameraPicker: UIViewControllerRepresentable { + let onCapture: (UIImage) -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCapture: onCapture, onCancel: onCancel) + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let controller = UIImagePickerController() + controller.sourceType = .camera + controller.cameraCaptureMode = .photo + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ controller: UIImagePickerController, context: Context) {} + + final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + private let onCapture: (UIImage) -> Void + private let onCancel: () -> Void + + init(onCapture: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { + self.onCapture = onCapture + self.onCancel = onCancel + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + guard let image = info[.originalImage] as? UIImage else { + onCancel() + return + } + onCapture(image) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + onCancel() + } + } +} + +enum FeatureImageProcessor { + private static let maximumDimension: CGFloat = 2_048 + private static let maximumEncodedBytes = 10 * 1_024 * 1_024 + + static func attachment( + from sourceData: Data, + ordinal: Int + ) throws -> FeatureDraftAttachment { + guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumDimension, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary + ) else { + throw FeatureImageAttachmentError.invalidImage + } + + let preparedImage = UIImage(cgImage: image) + guard let data = preparedImage.jpegData(compressionQuality: 0.82), + let thumbnailData = thumbnail(from: preparedImage) else { + throw FeatureImageAttachmentError.encodingFailed + } + guard data.count <= maximumEncodedBytes else { + throw FeatureImageAttachmentError.tooLarge + } + + return FeatureDraftAttachment( + data: data, + thumbnailData: thumbnailData, + filename: "Image \(ordinal).jpg", + mimeType: "image/jpeg" + ) + } + + private static func thumbnail(from image: UIImage) -> Data? { + let longestSide = max(image.size.width, image.size.height) + let scale = min(1, 160 / longestSide) + let size = CGSize( + width: max(1, image.size.width * scale), + height: max(1, image.size.height * scale) + ) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + }.jpegData(compressionQuality: 0.72) + } +} + +enum FeatureImageAttachmentError: LocalizedError { + case invalidImage + case encodingFailed + case tooLarge + + var errorDescription: String? { + switch self { + case .invalidImage: + "That photo could not be read." + case .encodingFailed: + "That photo could not be prepared." + case .tooLarge: + "Images must be smaller than 10 MB." + } + } +} + +enum FeatureAttachmentIntakeError: LocalizedError { + case invalidFile + case filesUnsupported + case imagesUnsupported + + var errorDescription: String? { + switch self { + case .invalidFile: "That file could not be read." + case .filesUnsupported: "This environment does not accept file attachments." + case .imagesUnsupported: "The selected model does not accept images." + } + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownDocument.swift b/apps/swift-ios/Features/Chat/MarkdownDocument.swift new file mode 100644 index 000000000000..56243207de75 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownDocument.swift @@ -0,0 +1,813 @@ +import Foundation + +/// A small block-level Markdown model used by chat messages. +/// +/// Foundation already provides a strong inline Markdown parser. This layer only +/// separates the block structures that `Text` otherwise flattens, keeping chat +/// rendering native and dependency-free. +struct MarkdownDocument: Equatable, Sendable { + let blocks: [MarkdownBlock] + + init(parsing source: String) { + var parser = MarkdownBlockParser( + source: CodexMarkdownDirectives.replacingFileCitations(in: source) + ) + blocks = parser.parse() + } + + fileprivate init(blocks: [MarkdownBlock]) { + self.blocks = blocks + } +} + +indirect enum MarkdownBlock: Equatable, Sendable { + case paragraph(String) + case image(MarkdownImage) + case heading(level: Int, text: String) + case unorderedList([MarkdownListItem]) + case orderedList(start: Int, items: [MarkdownListItem]) + case blockquote(MarkdownDocument) + case table(MarkdownTable) + case codeBlock(language: String?, code: String) + case thematicBreak + case artifactTemplate(CodexArtifactTemplate) +} + +struct MarkdownImage: Equatable, Sendable { + let source: String + let alternativeText: String +} + +enum MarkdownImageSource: Equatable, Sendable { + case direct(URL) + case workspaceFile(String) + case blocked + + static func classify(_ rawSource: String, workspaceRoot: String? = nil) -> Self { + var source = rawSource.trimmingCharacters(in: .whitespacesAndNewlines) + if source.hasPrefix("<"), source.hasSuffix(">") { + source = String(source.dropFirst().dropLast()) + } + guard !source.isEmpty, !source.hasPrefix("#"), !source.hasPrefix("?") else { + return .blocked + } + + let lowercased = source.lowercased() + if lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://") + || lowercased.hasPrefix("data:") || lowercased.hasPrefix("blob:") { + return URL(string: source).map(Self.direct) ?? .blocked + } + if source.hasPrefix("//") { + return URL(string: "https:\(source)").map(Self.direct) ?? .blocked + } + if lowercased.hasPrefix("file:") { + guard let components = URLComponents(string: source), + components.scheme?.lowercased() == "file" else { + return .blocked + } + let decodedPath = components.percentEncodedPath.removingPercentEncoding + ?? components.percentEncodedPath + guard !decodedPath.isEmpty else { return .blocked } + if let host = components.host, !host.isEmpty, host.lowercased() != "localhost" { + return .workspaceFile( + "\\\\\(host)\(decodedPath.replacingOccurrences(of: "/", with: "\\"))" + ) + } + return .workspaceFile(normalizeWindowsDrivePath(decodedPath)) + } + + let pathEnd = source.firstIndex(where: { $0 == "?" || $0 == "#" }) ?? source.endIndex + let decodedPath = String(source[.. String { + guard value.count >= 4, value.first == "/", isWindowsDrivePath(String(value.dropFirst())) + else { return value } + return String(value.dropFirst()) + } + + private static func isWindowsDrivePath(_ value: String) -> Bool { + value.range(of: #"^[A-Za-z]:[\\/]"#, options: .regularExpression) != nil + } + + private static func hasURIScheme(_ value: String) -> Bool { + value.range(of: #"^[A-Za-z][A-Za-z0-9+.-]*:"#, options: .regularExpression) != nil + } +} + +enum MarkdownWorkspaceFileLink { + static func relativePath(for url: URL, workspaceRoot: String) -> String? { + let raw = url.absoluteString + let isWindowsPath = raw.range(of: #"^[A-Za-z]:[/\\]"#, options: .regularExpression) != nil + if url.scheme != nil, !url.isFileURL, !isWindowsPath { + return nil + } + + var path: String + if url.isFileURL { + // URL.path is decoded and already excludes a real query or fragment. + path = url.path + } else { + let pathEnd = raw.firstIndex(where: { $0 == "#" || $0 == "?" }) ?? raw.endIndex + let encodedPath = String(raw[..]+>|[^\s)]+)(?:\s+[\"'][^\"']*[\"'])?\s*\)"# + ) + + private let lines: [String] + private var index = 0 + + init(source: String) { + let normalized = source + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + lines = normalized.components(separatedBy: "\n") + } + + mutating func parse() -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + + while index < lines.count { + if lines[index].isMarkdownBlank { + index += 1 + continue + } + + if let fence = fenceMarker(in: lines[index]) { + blocks.append(parseCodeBlock(opening: fence)) + continue + } + + if let template = CodexMarkdownDirectives.artifactTemplate(from: lines[index]) { + blocks.append(.artifactTemplate(template)) + index += 1 + continue + } + + if let heading = atxHeading(in: lines[index]) { + blocks.append(.heading(level: heading.level, text: heading.text)) + index += 1 + continue + } + + if let table = tableOpening(at: index) { + blocks.append(parseTable(opening: table)) + continue + } + + if let level = setextHeadingLevel(after: index) { + blocks.append(.heading(level: level, text: lines[index].markdownTrimmed)) + index += 2 + continue + } + + if blockquoteContent(in: lines[index]) != nil { + blocks.append(parseBlockquote()) + continue + } + + if let marker = listMarker(in: lines[index]) { + blocks.append(parseList(opening: marker)) + continue + } + + if isThematicBreak(lines[index]) { + blocks.append(.thematicBreak) + index += 1 + continue + } + + blocks.append(contentsOf: parseParagraph()) + } + + return blocks + } + + private mutating func parseCodeBlock(opening: FenceMarker) -> MarkdownBlock { + index += 1 + var codeLines: [String] = [] + + while index < lines.count { + let line = lines[index] + if isClosingFence(line, matching: opening) { + index += 1 + break + } + codeLines.append(line) + index += 1 + } + + return .codeBlock(language: opening.language, code: codeLines.joined(separator: "\n")) + } + + private mutating func parseBlockquote() -> MarkdownBlock { + var quotedLines: [String] = [] + + while index < lines.count, let content = blockquoteContent(in: lines[index]) { + quotedLines.append(content) + index += 1 + } + + var parser = MarkdownBlockParser(source: quotedLines.joined(separator: "\n")) + return .blockquote(MarkdownDocument(blocks: parser.parse())) + } + + private mutating func parseTable(opening: TableOpening) -> MarkdownBlock { + index += 2 + var rows: [[String]] = [] + + while index < lines.count, + !lines[index].isMarkdownBlank, + let cells = tableCells(in: lines[index]) { + var normalized = Array(cells.prefix(opening.header.count)) + if normalized.count < opening.header.count { + normalized.append( + contentsOf: repeatElement( + "", + count: opening.header.count - normalized.count + ) + ) + } + rows.append(normalized) + index += 1 + } + + return .table( + MarkdownTable( + header: opening.header, + alignments: opening.alignments, + rows: rows + ) + ) + } + + private mutating func parseList(opening: ListMarker) -> MarkdownBlock { + var items: [MarkdownListItem] = [] + let ordered = opening.number != nil + + while index < lines.count, + let marker = listMarker(in: lines[index]), + marker.indent == opening.indent, + (marker.number != nil) == ordered { + index += 1 + var itemLines = [marker.content] + + while index < lines.count { + let line = lines[index] + + if line.isMarkdownBlank { + let next = nextNonblankLine(after: index) + guard let next else { + index = lines.count + break + } + + if let nextMarker = listMarker(in: lines[next]), + nextMarker.indent == opening.indent, + (nextMarker.number != nil) == ordered { + index = next + break + } + + if lines[next].markdownLeadingSpaces > opening.indent { + itemLines.append("") + index += 1 + continue + } + + index = next + break + } + + if let nextMarker = listMarker(in: line), nextMarker.indent == opening.indent { + break + } + + let leadingSpaces = line.markdownLeadingSpaces + if leadingSpaces > opening.indent { + let continuationIndent = min( + leadingSpaces, + max(opening.indent + 2, marker.contentIndent) + ) + itemLines.append(line.droppingLeadingSpaces(continuationIndent)) + index += 1 + continue + } + + if isBlockStarter(line) { + break + } + + // CommonMark permits a paragraph continuation without indentation. + itemLines.append(line) + index += 1 + } + + let task = taskState(in: itemLines.first ?? "") + if task != nil, !itemLines.isEmpty { + itemLines[0] = removingTaskMarker(from: itemLines[0]) + } + var itemParser = MarkdownBlockParser(source: itemLines.joined(separator: "\n")) + items.append(MarkdownListItem(task: task, blocks: itemParser.parse())) + + guard index < lines.count, + let nextMarker = listMarker(in: lines[index]), + nextMarker.indent == opening.indent, + (nextMarker.number != nil) == ordered else { + break + } + } + + if let start = opening.number { + return .orderedList(start: start, items: items) + } + return .unorderedList(items) + } + + private mutating func parseParagraph() -> [MarkdownBlock] { + var paragraphLines: [String] = [] + + while index < lines.count, !lines[index].isMarkdownBlank { + if !paragraphLines.isEmpty, + (isBlockStarter(lines[index]) || tableOpening(at: index) != nil) { + break + } + paragraphLines.append(lines[index].markdownTrimmedTrailing) + index += 1 + } + + return imageBlocks(in: paragraphLines.joined(separator: "\n")) + } + + private func imageBlocks(in paragraph: String) -> [MarkdownBlock] { + guard paragraph.contains("!["), let expression = Self.imageExpression else { + return [.paragraph(paragraph)] + } + let source = paragraph as NSString + let matches = expression.matches( + in: paragraph, + range: NSRange(location: 0, length: source.length) + ) + guard !matches.isEmpty else { return [.paragraph(paragraph)] } + + var blocks: [MarkdownBlock] = [] + var cursor = 0 + for match in matches { + let preceding = source.substring(with: NSRange( + location: cursor, + length: match.range.location - cursor + )).trimmingCharacters(in: .whitespacesAndNewlines) + if !preceding.isEmpty { + blocks.append(.paragraph(preceding)) + } + blocks.append(.image(MarkdownImage( + source: source.substring(with: match.range(at: 2)), + alternativeText: source.substring(with: match.range(at: 1)) + ))) + cursor = match.range.location + match.range.length + } + let trailing = source.substring(from: cursor) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !trailing.isEmpty { + blocks.append(.paragraph(trailing)) + } + return blocks + } + + private func tableOpening(at position: Int) -> TableOpening? { + guard position + 1 < lines.count, + let header = tableCells(in: lines[position]), + let delimiters = tableCells(in: lines[position + 1]), + header.count == delimiters.count, + !header.isEmpty else { + return nil + } + + let alignments = delimiters.compactMap(tableAlignment(in:)) + guard alignments.count == delimiters.count else { return nil } + return TableOpening(header: header, alignments: alignments) + } + + private func tableAlignment(in source: String) -> MarkdownTableAlignment? { + var marker = source.markdownTrimmed + let hasLeadingColon = marker.first == ":" + let hasTrailingColon = marker.last == ":" + if hasLeadingColon { marker.removeFirst() } + if hasTrailingColon, !marker.isEmpty { marker.removeLast() } + guard marker.count >= 3, marker.allSatisfy({ $0 == "-" }) else { return nil } + return switch (hasLeadingColon, hasTrailingColon) { + case (true, true): .center + case (true, false): .leading + case (false, true): .trailing + case (false, false): .natural + } + } + + /// Splits a GFM table row while preserving escapes for Foundation's inline parser. + /// Pipes inside code spans or escaped with a backslash remain cell content. + private func tableCells(in line: String) -> [String]? { + guard line.contains("|") else { return nil } + let source = line.markdownTrimmed + guard !source.isEmpty else { return nil } + + var cells = [String]() + var cell = "" + var codeFenceLength: Int? + var foundSeparator = false + + let characters = Array(source) + var cursor = 0 + while cursor < characters.count { + let character = characters[cursor] + if character == "\\", cursor + 1 < characters.count { + cell.append(character) + cell.append(characters[cursor + 1]) + cursor += 2 + continue + } + if character == "`" { + var runEnd = cursor + while runEnd < characters.count, characters[runEnd] == "`" { + runEnd += 1 + } + let runLength = runEnd - cursor + for _ in cursor.. Bool { + if CodexMarkdownDirectives.artifactTemplate(from: line) != nil { return true } + return fenceMarker(in: line) != nil + || atxHeading(in: line) != nil + || blockquoteContent(in: line) != nil + || listMarker(in: line) != nil + || isThematicBreak(line) + } + + private func nextNonblankLine(after position: Int) -> Int? { + var candidate = position + 1 + while candidate < lines.count { + if !lines[candidate].isMarkdownBlank { + return candidate + } + candidate += 1 + } + return nil + } + + private func atxHeading(in line: String) -> (level: Int, text: String)? { + let indent = line.markdownLeadingSpaces + guard indent <= 3, line.dropFirst(indent).first == "#" else { + return nil + } + let characters = Array(line) + + var cursor = indent + while cursor < characters.count, characters[cursor] == "#" { + cursor += 1 + } + let level = cursor - indent + guard level <= 6, + cursor == characters.count || characters[cursor].isMarkdownWhitespace else { + return nil + } + + while cursor < characters.count, characters[cursor].isMarkdownWhitespace { + cursor += 1 + } + var content = String(characters[cursor.. Int? { + guard position + 1 < lines.count, !lines[position].isMarkdownBlank else { + return nil + } + let underline = lines[position + 1].markdownTrimmed + guard !underline.isEmpty else { return nil } + if underline.allSatisfy({ $0 == "=" }) { + return 1 + } + if underline.allSatisfy({ $0 == "-" }) { + return 2 + } + return nil + } + + private func blockquoteContent(in line: String) -> String? { + let indent = line.markdownLeadingSpaces + guard indent <= 3, line.dropFirst(indent).first == ">" else { + return nil + } + let characters = Array(line) + var cursor = indent + 1 + if cursor < characters.count, characters[cursor].isMarkdownWhitespace { + cursor += 1 + } + return cursor < characters.count ? String(characters[cursor...]) : "" + } + + private func listMarker(in line: String) -> ListMarker? { + let indent = line.markdownLeadingSpaces + guard indent <= 3, let marker = line.dropFirst(indent).first, + marker == "-" || marker == "+" || marker == "*" || marker.isNumber else { + return nil + } + let characters = Array(line) + + var cursor = indent + var number: Int? + + if ["-", "+", "*"].contains(characters[cursor]) { + cursor += 1 + } else if characters[cursor].isNumber { + let numberStart = cursor + while cursor < characters.count, + characters[cursor].isNumber, + cursor - numberStart < 9 { + cursor += 1 + } + guard cursor > numberStart, + cursor < characters.count, + characters[cursor] == "." || characters[cursor] == ")", + let parsedNumber = Int(String(characters[numberStart.. MarkdownTaskState? { + guard line.first == "[" else { return nil } + let characters = Array(line) + guard characters.count >= 3, + characters[0] == "[", + characters[2] == "]", + [" ", "x", "X"].contains(characters[1]), + characters.count == 3 || characters[3].isMarkdownWhitespace else { + return nil + } + return characters[1] == " " ? .incomplete : .complete + } + + private func removingTaskMarker(from line: String) -> String { + let characters = Array(line) + var cursor = min(3, characters.count) + while cursor < characters.count, characters[cursor].isMarkdownWhitespace { + cursor += 1 + } + return cursor < characters.count ? String(characters[cursor...]) : "" + } + + private func isThematicBreak(_ line: String) -> Bool { + let trimmed = line.markdownTrimmed + guard let marker = trimmed.first, ["-", "_", "*"].contains(marker) else { + return false + } + let visible = trimmed.filter { !$0.isMarkdownWhitespace } + return visible.count >= 3 && visible.allSatisfy { $0 == marker } + } + + private func fenceMarker(in line: String) -> FenceMarker? { + let indent = line.markdownLeadingSpaces + guard indent <= 3, let marker = line.dropFirst(indent).first, + marker == "`" || marker == "~" else { + return nil + } + let characters = Array(line) + + let character = characters[indent] + var cursor = indent + while cursor < characters.count, characters[cursor] == character { + cursor += 1 + } + let length = cursor - indent + guard length >= 3 else { return nil } + + let info = cursor < characters.count + ? String(characters[cursor...]).markdownTrimmed + : "" + guard character != "`" || !info.contains("`") else { return nil } + return FenceMarker( + character: character, + length: length, + language: info.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) + ) + } + + private func isClosingFence(_ line: String, matching opening: FenceMarker) -> Bool { + let indent = line.markdownLeadingSpaces + guard indent <= 3, line.dropFirst(indent).first == opening.character else { + return false + } + let characters = Array(line) + + var cursor = indent + while cursor < characters.count, characters[cursor] == opening.character { + cursor += 1 + } + guard cursor - indent >= opening.length else { return false } + return characters[cursor.. String { + String(dropFirst(Swift.min(count, markdownLeadingSpaces))) + } +} + +private extension Character { + var isMarkdownWhitespace: Bool { + self == " " || self == "\t" + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownImageRendering.swift b/apps/swift-ios/Features/Chat/MarkdownImageRendering.swift new file mode 100644 index 000000000000..9bf578ab92c1 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownImageRendering.swift @@ -0,0 +1,75 @@ +import ImageIO +import SwiftUI +import UIKit + +enum MarkdownImageGeometry { + static func sourceSize(_ dimensions: AssetImageDimensions?) -> CGSize? { + guard let dimensions, dimensions.width > 0, dimensions.height > 0 else { return nil } + return CGSize(width: dimensions.width, height: dimensions.height) + } + + static func displaySize(sourceSize: CGSize?, availableWidth: CGFloat) -> CGSize { + let width = availableWidth.isFinite ? max(0, availableWidth) : 320 + guard let sourceSize, + sourceSize.width.isFinite, sourceSize.height.isFinite, + sourceSize.width > 0, sourceSize.height > 0 else { + return CGSize(width: width, height: 140) + } + let scale = min(width / sourceSize.width, 480 / sourceSize.height) + return CGSize(width: sourceSize.width * scale, height: sourceSize.height * scale) + } +} + +/// Uses the parent's width on the first layout pass, including list and quote indents. +/// Metadata and the decoded image share this layout so downloading bytes does not resize the row. +struct MarkdownImageLayout: Layout { + let sourceSize: CGSize? + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + MarkdownImageGeometry.displaySize(sourceSize: sourceSize, availableWidth: proposal.width ?? 320) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + for subview in subviews { + subview.place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(bounds.size)) + } + } +} + +struct MarkdownDecodedImage: @unchecked Sendable { + let image: UIImage + let sourceSize: CGSize +} + +enum MarkdownImageDecoder { + static func decode(_ data: Data, maximumPixelSize: Int) throws -> MarkdownDecodedImage { + guard let source = CGImageSourceCreateWithData( + data as CFData, + [kCGImageSourceShouldCache: false] as CFDictionary + ), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber else { + throw MarkdownImageLoadingError.invalidImage + } + let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1 + let swapsAxes = (5...8).contains(orientation) + let sourceSize = CGSize( + width: swapsAxes ? height.doubleValue : width.doubleValue, + height: swapsAxes ? width.doubleValue : height.doubleValue + ) + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: max(1, maximumPixelSize), + ] as CFDictionary) else { + throw MarkdownImageLoadingError.invalidImage + } + return MarkdownDecodedImage(image: UIImage(cgImage: thumbnail), sourceSize: sourceSize) + } +} + +enum MarkdownImageLoadingError: Error { + case invalidImage + case invalidResponse +} diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift new file mode 100644 index 000000000000..481b8308d788 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -0,0 +1,1194 @@ +import SwiftUI +import UIKit + +struct MarkdownImageContext: Equatable, @unchecked Sendable { + let threadID: String + let workspaceRoot: String + let resolver: any FeatureWorkspaceAssetResolving + var sourceFilePath: String? = nil + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.threadID == rhs.threadID + && lhs.workspaceRoot == rhs.workspaceRoot + && lhs.sourceFilePath == rhs.sourceFilePath + && ObjectIdentifier(lhs.resolver) == ObjectIdentifier(rhs.resolver) + } +} + +/// Native chat Markdown with block-aware layout and Foundation inline parsing. +struct MarkdownMessageView: View { + private struct RenderRequest: Hashable { + let revision: MarkdownContentRevision + let isStreaming: Bool + } + + private let source: String + private let revision: MarkdownContentRevision + private let isStreaming: Bool + private let copyActionTitle: String + private let imageContext: MarkdownImageContext? + private let skills: [FeatureProviderSkill] + @State private var selectionSource: MarkdownSelectionSource + @State private var renderedDocument: MarkdownRenderedDocument? + @State private var streamingRenderer = StreamingMarkdownRenderer() + + init( + _ source: String, + isStreaming: Bool = false, + copyActionTitle: String = "Copy message", + imageContext: MarkdownImageContext? = nil, + skills: [FeatureProviderSkill] = [] + ) { + self.source = source + self.isStreaming = isStreaming + self.copyActionTitle = copyActionTitle + self.imageContext = imageContext + self.skills = skills + _selectionSource = State(initialValue: MarkdownSelectionSource(source)) + let revision = MarkdownContentRevision(source) + self.revision = revision + let initialDocument = if isStreaming { + MarkdownRenderCache.shared.cachedDocument(for: revision) + } else { + MarkdownRenderCache.shared.documentImmediately(for: revision) + } + _renderedDocument = State( + initialValue: initialDocument + ) + } + + var body: some View { + let selectionContext = selectionContext + Group { + if let displayDocument { + MarkdownBlocksView( + blocks: displayDocument.blocks, + selectionContext: selectionContext, + imageContext: imageContext + ) + } else { + // Parsing waits briefly so token-by-token streaming cancels stale revisions + // instead of scheduling work for content the user will never see. + Text(verbatim: source) + .font(T3Typography.threadBody) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityAction(named: copyActionTitle) { + UIPasteboard.general.string = source + } + .task(id: RenderRequest(revision: revision, isStreaming: isStreaming)) { + if !isStreaming { + streamingRenderer.cancel() + // Streaming -> complete usually keeps the final text; promote + // the last streamed render instead of reparsing synchronously. + if let renderedDocument, renderedDocument.revision == revision { + MarkdownRenderCache.shared.promote(renderedDocument) + return + } + renderedDocument = MarkdownRenderCache.shared.documentImmediately(for: revision) + return + } + + if let cached = MarkdownRenderCache.shared.cachedDocument(for: revision) { + renderedDocument = cached + return + } + + // Hand the revision to a renderer that outlives this task. The + // task modifier cancels on every revision, so rendering inside it + // starves as soon as parsing is slower than the publish cadence; + // the renderer instead keeps one render running and always picks + // up the newest revision when it finishes (latest wins). + streamingRenderer.submit(revision) { renderedDocument = $0 } + } + .onDisappear { + streamingRenderer.cancel() + } + } + + private var displayDocument: MarkdownRenderedDocument? { + if let renderedDocument, renderedDocument.revision == revision { + return renderedDocument + } + // While streaming, a slightly stale document is better than flashing + // back to plain text between renders. Streamed content only appends, + // so require the stale document to be a prefix of the current source: + // that accepts earlier snapshots of this message and rejects leftovers + // from a recycled cell showing a different message. + if isStreaming { + if let renderedDocument, + renderedDocument.revision.utf8Count <= revision.utf8Count, + source.utf8.starts(with: renderedDocument.revision.source.utf8) { + return renderedDocument + } + return nil + } + return MarkdownRenderCache.shared.documentImmediately(for: revision) + } + + private var selectionContext: MarkdownSelectionContext { + selectionSource.text = source + return MarkdownSelectionContext( + source: selectionSource, + copyActionTitle: copyActionTitle, + skills: skills + ) + } +} + +/// Renders streaming revisions outside SwiftUI's task lifecycle so a render +/// in progress is never cancelled by the next revision arriving. One render +/// runs at a time; newer revisions replace the pending slot (latest wins) and +/// a 150ms throttle bounds the render cadence. +@MainActor +private final class StreamingMarkdownRenderer { + private let throttle: Duration = .milliseconds(150) + private var pending: MarkdownContentRevision? + private var deliver: ((MarkdownRenderedDocument) -> Void)? + private var renderTask: Task? + private var generation = 0 + private var lastRenderAt: Date? + + func submit( + _ revision: MarkdownContentRevision, + deliver: @escaping (MarkdownRenderedDocument) -> Void + ) { + pending = revision + self.deliver = deliver + guard renderTask == nil else { return } + generation += 1 + let generation = generation + renderTask = Task { [weak self] in + await self?.drain(generation: generation) + } + } + + func cancel() { + generation += 1 + renderTask?.cancel() + renderTask = nil + pending = nil + deliver = nil + } + + private func drain(generation: Int) async { + // A cancelled drain can unwind after a replacement was already + // started; only the current generation may clear the shared slot or + // deliver, so two drains can never race or regress the document. + defer { + if self.generation == generation { renderTask = nil } + } + while self.generation == generation, let revision = pending { + pending = nil + if let lastRenderAt { + let elapsed = Duration.seconds(-lastRenderAt.timeIntervalSinceNow) + if elapsed < throttle { + try? await Task.sleep(for: throttle - elapsed) + } + } + guard !Task.isCancelled else { return } + // Render the newest revision available after the throttle wait. + let target = pending ?? revision + pending = nil + guard let document = await MarkdownRenderCache.shared.document( + for: target, + isIntermediate: true + ) else { continue } + guard !Task.isCancelled, self.generation == generation else { return } + lastRenderAt = .now + deliver?(document) + } + } +} + +private final class MarkdownSelectionSource: @unchecked Sendable { + var text: String + + init(_ text: String) { + self.text = text + } +} + +private struct MarkdownSelectionContext: Equatable, Sendable { + let source: MarkdownSelectionSource + let copyActionTitle: String + let skills: [FeatureProviderSkill] + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.source === rhs.source + && lhs.copyActionTitle == rhs.copyActionTitle + && lhs.skills == rhs.skills + } +} + +private enum MarkdownTextColor: Equatable, Sendable { + case primary + case secondary + + var uiColor: UIColor { + switch self { + case .primary: T3Colors.uiTextPrimary + case .secondary: T3Colors.uiTextSecondary + } + } +} + +private struct MarkdownBlocksView: View { + let blocks: [MarkdownRenderedBlock] + let selectionContext: MarkdownSelectionContext + let imageContext: MarkdownImageContext? + var spacing: CGFloat = 12 + var textColor: MarkdownTextColor = .primary + + var body: some View { + VStack(alignment: .leading, spacing: spacing) { + ForEach(blocks.indices, id: \.self) { index in + // Unchanged blocks share inline runs by reference across + // streaming revisions, so equatable comparison skips their + // body and layout entirely; only the changed tail re-renders. + MarkdownBlockView( + block: blocks[index], + selectionContext: selectionContext, + imageContext: imageContext, + textColor: textColor + ) + .equatable() + } + } + } +} + +private struct MarkdownBlockView: View, Equatable { + let block: MarkdownRenderedBlock + let selectionContext: MarkdownSelectionContext + let imageContext: MarkdownImageContext? + let textColor: MarkdownTextColor + + @ViewBuilder + var body: some View { + switch block { + case let .paragraph(inline): + MarkdownInlineText( + inline, + selectionContext: selectionContext, + lineSpacing: 4, + textColor: textColor + ) + + case let .image(image): + MarkdownImageView(image: image, context: imageContext) + + case let .heading(level, inline): + MarkdownInlineText( + inline, + selectionContext: selectionContext, + textColor: textColor + ) + .padding(.top, level <= 2 ? 3 : 1) + + case let .unorderedList(items): + MarkdownListView( + items: items, + start: nil, + selectionContext: selectionContext, + imageContext: imageContext, + textColor: textColor + ) + + case let .orderedList(start, items): + MarkdownListView( + items: items, + start: start, + selectionContext: selectionContext, + imageContext: imageContext, + textColor: textColor + ) + + case let .blockquote(blocks): + MarkdownBlocksView( + blocks: blocks, + selectionContext: selectionContext, + imageContext: imageContext, + spacing: 9, + textColor: .secondary + ) + .foregroundStyle(T3Colors.textSecondary) + .padding(.leading, 14) + .overlay(alignment: .leading) { + Rectangle() + .fill(T3Colors.textTertiary) + .frame(width: 2) + } + + case let .table(table): + MarkdownTableView( + table: table, + selectionContext: selectionContext, + textColor: textColor + ) + + case let .codeBlock(language, code, renderedCode): + MarkdownCodeBlockView( + language: language, + code: code, + renderedCode: renderedCode, + selectionContext: selectionContext + ) + + case let .artifactTemplate(template): + CodexArtifactTemplateView(template: template) + + case .thematicBreak: + Rectangle() + .fill(T3Colors.separator) + .frame(height: 1) + .padding(.vertical, 2) + .accessibilityHidden(true) + } + } +} + +private struct MarkdownTableView: View { + let table: MarkdownRenderedTable + let selectionContext: MarkdownSelectionContext + let textColor: MarkdownTextColor + + private var columnWidths: [CGFloat] { table.columnWidths } + + var body: some View { + ScrollView(.horizontal) { + Grid(horizontalSpacing: 0, verticalSpacing: 0) { + tableRow(table.header, isHeader: true) + ForEach(table.rows.indices, id: \.self) { rowIndex in + tableRow(table.rows[rowIndex], isHeader: false) + } + } + // A horizontal ScrollView still proposes the viewport width to its child. + // Preserve the grid's measured column widths so it overflows and scrolls + // instead of compressing prose columns into unreadable slivers. + .fixedSize(horizontal: true, vertical: true) + .background(T3Colors.surface) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + } + .scrollIndicators(.visible) + .accessibilityElement(children: .contain) + .accessibilityLabel("Table with \(table.header.count) columns and \(table.rows.count) rows") + } + + private func tableRow( + _ cells: [MarkdownRenderedInline], + isHeader: Bool + ) -> some View { + GridRow(alignment: .top) { + ForEach(cells.indices, id: \.self) { columnIndex in + MarkdownInlineText( + cells[columnIndex], + selectionContext: selectionContext, + lineSpacing: 3, + textColor: textColor + ) + .frame( + width: columnWidths[columnIndex], + alignment: alignment(for: columnIndex) + ) + .frame( + minHeight: 44, + maxHeight: .infinity, + alignment: alignment(for: columnIndex) + ) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .overlay(alignment: .trailing) { + if columnIndex < cells.count - 1 { + Rectangle() + .fill(T3Colors.separator) + .frame(width: 1) + } + } + } + } + .background(isHeader ? T3Colors.surfaceRaised : T3Colors.surface) + .overlay(alignment: .bottom) { + Rectangle() + .fill(T3Colors.separator) + .frame(height: 1) + } + } + + private func alignment(for columnIndex: Int) -> Alignment { + guard table.alignments.indices.contains(columnIndex) else { return .leading } + return switch table.alignments[columnIndex] { + case .natural, .leading: .leading + case .center: .center + case .trailing: .trailing + } + } + +} + +private struct MarkdownListView: View { + let items: [MarkdownRenderedListItem] + let start: Int? + let selectionContext: MarkdownSelectionContext + let imageContext: MarkdownImageContext? + let textColor: MarkdownTextColor + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(items.indices, id: \.self) { offset in + let item = items[offset] + HStack(alignment: .top, spacing: 8) { + marker(for: item, offset: offset) + .frame(width: 24, height: 24, alignment: .trailing) + MarkdownBlocksView( + blocks: item.blocks, + selectionContext: selectionContext, + imageContext: imageContext, + spacing: 7, + textColor: textColor + ) + } + .accessibilityElement(children: .contain) + } + } + } + + @ViewBuilder + private func marker(for item: MarkdownRenderedListItem, offset: Int) -> some View { + if let task = item.task { + Image(systemName: task == .complete ? "checkmark.square.fill" : "square") + .font(T3Typography.control) + .foregroundStyle( + task == .complete ? T3Colors.success : T3Colors.textSecondary + ) + .accessibilityLabel(task == .complete ? "Completed" : "Not completed") + } else if let start { + Text("\(start + offset).") + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Item \(start + offset)") + } else { + Text("•") + .font(T3Typography.threadBody.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityHidden(true) + } + } +} + +private struct MarkdownImageView: View { + private struct Request: Equatable { + let image: MarkdownImage + let context: MarkdownImageContext? + let maximumPixelSize: Int + } + + let image: MarkdownImage + let context: MarkdownImageContext? + + @SwiftUI.Environment(\.openURL) private var openURL + @SwiftUI.Environment(\.displayScale) private var displayScale + @State private var loadedImage: MarkdownDecodedImage? + @State private var activeRequest: Request? + @State private var knownSize: CGSize? + @State private var previewURL: URL? + @State private var failed = false + + private var request: Request { + Request(image: image, context: context, maximumPixelSize: min(2_048, max(512, Int(ceil(480 * displayScale))))) + } + + private var classifiedSource: MarkdownImageSource { + let basePath = context?.sourceFilePath.map { + let isWindows = $0.contains("\\") + let normalized = $0.replacingOccurrences(of: "\\", with: "/") + let parent = (normalized as NSString).deletingLastPathComponent + return isWindows ? parent.replacingOccurrences(of: "/", with: "\\") : parent + } ?? context?.workspaceRoot + return MarkdownImageSource.classify(image.source, workspaceRoot: basePath) + } + + var body: some View { + if classifiedSource != .blocked { + let currentRequest = activeRequest == request + let decoded = currentRequest ? loadedImage : nil + MarkdownImageLayout(sourceSize: decoded?.sourceSize ?? (currentRequest ? knownSize : nil)) { + if let decoded { + Image(uiImage: decoded.image) + .resizable() + .scaledToFit() + } else { + Image(systemName: currentRequest && failed ? "exclamationmark.triangle" : "photo") + .font(.title2) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.surfaceRaised) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityLabel(image.alternativeText.isEmpty ? "Image" : image.alternativeText) + .accessibilityAddTraits(.isButton) + .contentShape(Rectangle()) + .onTapGesture { + if currentRequest, let previewURL { openURL(previewURL) } + } + .task(id: request) { + await loadImage() + } + } + } + + @MainActor + private func loadImage() async { + let loadingRequest = request + activeRequest = loadingRequest + loadedImage = nil + knownSize = nil + failed = false + previewURL = nil + do { + let url: URL + switch classifiedSource { + case let .direct(directURL): + url = directURL + if directURL.scheme == "http" || directURL.scheme == "https" { + previewURL = directURL + } + case let .workspaceFile(path): + guard let context else { return } + var components = URLComponents() + components.scheme = "t3code" + components.host = "media-preview" + components.path = "/open" + components.queryItems = [ + URLQueryItem(name: "path", value: path), + URLQueryItem(name: "kind", value: "image"), + ] + previewURL = components.url + let asset = try await context.resolver.mediaAsset( + threadID: context.threadID, + path: path + ) + try Task.checkCancellation() + knownSize = MarkdownImageGeometry.sourceSize(asset.imageDimensions) + url = asset.url + case .blocked: + return + } + let decoded = try await MarkdownImageLoader.load(url, maximumPixelSize: loadingRequest.maximumPixelSize) + try Task.checkCancellation() + loadedImage = decoded + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + failed = true + } + } +} + +private struct CodexArtifactTemplateView: View { + let template: CodexArtifactTemplate + @SwiftUI.Environment(\.openURL) private var openURL + + var body: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(template.displayName) + .font(T3Typography.threadBody.weight(.medium)) + .foregroundStyle(T3Colors.textPrimary) + Text(template.kind.label) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer(minLength: 8) + Button("Use") { + if let url = template.useURL { openURL(url) } + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } +} + +@MainActor +private enum MarkdownImageLoader { + private final class CachedImage { + let decoded: MarkdownDecodedImage + init(_ decoded: MarkdownDecodedImage) { self.decoded = decoded } + } + + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 64 + cache.totalCostLimit = 32 * 1_024 * 1_024 + return cache + }() + + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + return URLSession(configuration: configuration) + }() + + static func load(_ url: URL, maximumPixelSize: Int) async throws -> MarkdownDecodedImage { + let cacheKey = "\(url.absoluteString)#\(maximumPixelSize)" as NSString + if let cached = cache.object(forKey: cacheKey) { + return cached.decoded + } + + let data: Data + if url.scheme?.lowercased() == "data" { + guard let comma = url.absoluteString.firstIndex(of: ","), + url.absoluteString[.. = [ + "markdown", + "md", + "plain", + "plaintext", + "text", + "text/plain", + "txt", + ] + + static func wrapsByDefault(language: String?) -> Bool { + guard let language else { return false } + return proseLanguages.contains(language.lowercased()) + } +} + +private struct MarkdownInlineText: UIViewRepresentable { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.openURL) private var openURL + + let rendered: MarkdownRenderedInline + let selectionContext: MarkdownSelectionContext + let lineSpacing: CGFloat + let textColor: MarkdownTextColor + let wrapsLines: Bool + + init( + _ rendered: MarkdownRenderedInline, + selectionContext: MarkdownSelectionContext, + lineSpacing: CGFloat = 0, + textColor: MarkdownTextColor = .primary, + wrapsLines: Bool = true + ) { + self.rendered = rendered + self.selectionContext = selectionContext + self.lineSpacing = lineSpacing + self.textColor = textColor + self.wrapsLines = wrapsLines + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UITextView { + let textView = FeatureInlineSkillTextView() + textView.backgroundColor = .clear + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.showsHorizontalScrollIndicator = false + textView.showsVerticalScrollIndicator = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.widthTracksTextView = true + textView.textContainer.lineBreakMode = wrapsLines ? .byWordWrapping : .byClipping + textView.adjustsFontForContentSizeCategory = true + textView.linkTextAttributes = [ + .foregroundColor: T3Colors.uiAccent, + .underlineStyle: 0, + ] + textView.accessibilityTraits = .staticText + textView.delegate = context.coordinator + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + let attributedText = context.coordinator.attributedText( + from: rendered, + lineSpacing: lineSpacing, + textColor: textColor, + dynamicTypeSize: dynamicTypeSize, + wrapsLines: wrapsLines, + skills: selectionContext.skills, + traits: textView.traitCollection + ) + if context.coordinator.shouldApply(attributedText) { + let previousText = context.coordinator.lastAppliedText + let previousSelection = textView.selectedRange + textView.attributedText = attributedText + textView.selectedRange = MarkdownSelectionRestoration.range( + previousText: previousText, + previousRange: previousSelection, + newText: attributedText.string + ) + context.coordinator.didApply(attributedText) + } + context.coordinator.selectionContext = selectionContext + context.coordinator.onOpenURL = { url in + openURL(url) + } + textView.accessibilityCustomActions = context.coordinator.accessibilityActions( + title: selectionContext.copyActionTitle + ) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: UITextView, + context: Context + ) -> CGSize? { + guard let proposedWidth = proposal.width, + proposedWidth.isFinite, + proposedWidth > 0 + else { + return wrapsLines ? nil : context.coordinator.unwrappedSize(for: uiView) + } + return context.coordinator.size( + for: uiView, + proposedWidth: proposedWidth, + wrapsLines: wrapsLines + ) + } + + final class Coordinator: NSObject, UITextViewDelegate { + private struct CacheKey: Equatable { + let lineSpacing: CGFloat + let textColor: MarkdownTextColor + let dynamicTypeSize: DynamicTypeSize + let wrapsLines: Bool + let skills: [FeatureProviderSkill] + let userInterfaceStyle: UIUserInterfaceStyle + } + + private struct SizeKey: Hashable { + let proposedWidth: CGFloat + let wrapsLines: Bool + } + + var selectionContext = MarkdownSelectionContext( + source: MarkdownSelectionSource(""), + copyActionTitle: "Copy message", + skills: [] + ) + var onOpenURL: ((URL) -> Void)? + private var cacheKey: CacheKey? + private var cachedRendered: MarkdownRenderedInline? + private var cachedAttributedText: NSAttributedString? + private var cachedSizes: [SizeKey: CGSize] = [:] + private var lastAppliedAttributedText: NSAttributedString? + private var cachedAccessibilityTitle: String? + private var cachedAccessibilityActions: [UIAccessibilityCustomAction] = [] + + func attributedText( + from rendered: MarkdownRenderedInline, + lineSpacing: CGFloat, + textColor: MarkdownTextColor, + dynamicTypeSize: DynamicTypeSize, + wrapsLines: Bool, + skills: [FeatureProviderSkill], + traits: UITraitCollection + ) -> NSAttributedString { + let key = CacheKey( + lineSpacing: lineSpacing, + textColor: textColor, + dynamicTypeSize: dynamicTypeSize, + wrapsLines: wrapsLines, + skills: skills, + userInterfaceStyle: traits.userInterfaceStyle + ) + if cachedRendered === rendered, key == cacheKey, let cachedAttributedText { + return cachedAttributedText + } + let attributedText = MarkdownSelectableTextAttributes.make( + from: rendered, + lineSpacing: lineSpacing, + foregroundColor: textColor.uiColor, + dynamicTypeSize: dynamicTypeSize, + wrapsLines: wrapsLines, + skills: skills, + traits: traits + ) + cacheKey = key + cachedRendered = rendered + cachedAttributedText = attributedText + cachedSizes.removeAll(keepingCapacity: true) + return attributedText + } + + var lastAppliedText: String { + lastAppliedAttributedText?.string ?? "" + } + + func shouldApply(_ attributedText: NSAttributedString) -> Bool { + lastAppliedAttributedText !== attributedText + } + + func didApply(_ attributedText: NSAttributedString) { + lastAppliedAttributedText = attributedText + } + + func size( + for textView: UITextView, + proposedWidth: CGFloat, + wrapsLines: Bool + ) -> CGSize { + let key = SizeKey(proposedWidth: proposedWidth, wrapsLines: wrapsLines) + if let cached = cachedSizes[key] { + return cached + } + let bounds = textView.attributedText.boundingRect( + with: CGSize(width: proposedWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + let width = min(proposedWidth, max(1, ceil(bounds.width))) + let fittingSize = textView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + let size = CGSize(width: width, height: max(1, ceil(fittingSize.height))) + cachedSizes[key] = size + return size + } + + func unwrappedSize(for textView: UITextView) -> CGSize { + let key = SizeKey(proposedWidth: .infinity, wrapsLines: false) + if let cached = cachedSizes[key] { + return cached + } + let longestLineLength = textView.attributedText.string + .split(separator: "\n", omittingEmptySubsequences: false) + .map(\.utf16.count) + .max() ?? 0 + var largestFontPointSize: CGFloat = 0 + textView.attributedText.enumerateAttribute( + .font, + in: NSRange(location: 0, length: textView.attributedText.length) + ) { value, _, _ in + largestFontPointSize = max( + largestFontPointSize, + (value as? UIFont)?.pointSize ?? 0 + ) + } + let perCharacterWidth = max(16, largestFontPointSize * 1.5) + let maximumWidth = max(2_048, CGFloat(longestLineLength) * perCharacterWidth) + let bounds = textView.attributedText.boundingRect( + with: CGSize(width: maximumWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + let fittingSize = textView.sizeThatFits( + CGSize(width: max(1, ceil(bounds.width)), height: .greatestFiniteMagnitude) + ) + let size = CGSize( + width: max(1, ceil(bounds.width)), + height: max(1, ceil(fittingSize.height)) + ) + cachedSizes[key] = size + return size + } + + func accessibilityActions(title: String) -> [UIAccessibilityCustomAction] { + if cachedAccessibilityTitle == title { + return cachedAccessibilityActions + } + cachedAccessibilityTitle = title + cachedAccessibilityActions = [ + UIAccessibilityCustomAction( + name: title + ) { [weak self] _ in + self?.copyMessage() + return true + }, + ] + return cachedAccessibilityActions + } + + func textView( + _ textView: UITextView, + editMenuForTextIn range: NSRange, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + let copyMessage = UIAction( + title: selectionContext.copyActionTitle, + image: UIImage(systemName: "doc.on.doc") + ) { [weak self] _ in + self?.copyMessage() + } + return UIMenu(children: suggestedActions + [copyMessage]) + } + + func textView( + _ textView: UITextView, + primaryActionFor textItem: UITextItem, + defaultAction: UIAction + ) -> UIAction? { + guard case let .link(url) = textItem.content else { return defaultAction } + return UIAction { [weak self] _ in + self?.onOpenURL?(url) + } + } + + private func copyMessage() { + UIPasteboard.general.string = selectionContext.source.text + } + } +} + +enum MarkdownSelectableTextAttributes { + @MainActor + static func make( + from rendered: MarkdownRenderedInline, + lineSpacing: CGFloat, + foregroundColor: UIColor = T3Colors.uiTextPrimary, + dynamicTypeSize: DynamicTypeSize = .large, + wrapsLines: Bool = true, + skills: [FeatureProviderSkill] = [], + traits: UITraitCollection = .current + ) -> NSAttributedString { + let result = NSMutableAttributedString() + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + paragraphStyle.lineBreakMode = wrapsLines ? .byWordWrapping : .byClipping + + for run in rendered.attributedText.runs { + let intent = run.inlinePresentationIntent + var attributes: [NSAttributedString.Key: Any] = [ + .font: font( + for: rendered.style, + intent: intent, + dynamicTypeSize: dynamicTypeSize + ), + .foregroundColor: foregroundColor, + .paragraphStyle: paragraphStyle, + ] + if intent?.contains(.code) == true { + attributes[.backgroundColor] = T3Colors.uiSurfaceRaised + } + if intent?.contains(.strikethrough) == true { + attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + if let link = run.link { + attributes[.link] = link + } + let runText = String(rendered.attributedText[run.range].characters) + let hasLeadingBoundary = run.range.lowerBound == rendered.attributedText.startIndex + || rendered.attributedText.characters[ + rendered.attributedText.characters.index(before: run.range.lowerBound) + ].isWhitespace + let hasTrailingBoundary = run.range.upperBound == rendered.attributedText.endIndex + || rendered.attributedText.characters[run.range.upperBound].isWhitespace + let runLength = (runText as NSString).length + let descriptors = rendered.style == .code + || intent?.contains(.code) == true + || run.link != nil + ? [] + : FeatureInlineSkillParser.descriptors( + in: runText, + skills: skills, + allowsEndBoundary: true + ).filter { descriptor in + (descriptor.range.location > 0 || hasLeadingBoundary) + && (NSMaxRange(descriptor.range) < runLength || hasTrailingBoundary) + } + result.append( + FeatureInlineSkillPillRenderer.attributedText( + source: runText, + descriptors: descriptors, + baseAttributes: attributes, + font: attributes[.font] as? UIFont + ?? rendered.style.uiFont(dynamicTypeSize: dynamicTypeSize), + traits: traits + ) + ) + } + + return result + } + + @MainActor + private static func font( + for style: MarkdownInlineStyle, + intent: InlinePresentationIntent?, + dynamicTypeSize: DynamicTypeSize + ) -> UIFont { + var font = style.uiFont(dynamicTypeSize: dynamicTypeSize) + if intent?.contains(.code) == true, style != .code { + font = UIFont.monospacedSystemFont( + ofSize: font.pointSize, + weight: .regular + ) + } + + let addsBold = intent?.contains(.stronglyEmphasized) == true + let addsItalic = intent?.contains(.emphasized) == true + guard addsBold || addsItalic else { return font } + + var traits = font.fontDescriptor.symbolicTraits + if addsBold { + traits.insert(.traitBold) + } + if addsItalic { + traits.insert(.traitItalic) + } + if let descriptor = font.fontDescriptor.withSymbolicTraits(traits) { + font = UIFont(descriptor: descriptor, size: 0) + } + return font + } +} + +enum MarkdownSelectionRestoration { + static func range( + previousText: String, + previousRange: NSRange, + newText: String + ) -> NSRange { + guard newText.utf16.starts(with: previousText.utf16), + NSMaxRange(previousRange) <= (newText as NSString).length + else { + return NSRange(location: 0, length: 0) + } + return previousRange + } +} + +enum MarkdownInlineFormatter { + static func format(_ source: String) -> AttributedString { + ( + try? AttributedString( + markdown: source, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace, + failurePolicy: .returnPartiallyParsedIfPossible + ) + ) + ) ?? AttributedString(source) + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift new file mode 100644 index 000000000000..6114c369d33d --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift @@ -0,0 +1,477 @@ +import Foundation +import SwiftUI +import UIKit + +/// An exact content revision with a cheap, deterministic hash for SwiftUI task identity. +/// Source equality remains the final check, so a fingerprint collision cannot return stale text. +struct MarkdownContentRevision: Hashable, Sendable { + let source: String + let fingerprint: UInt64 + let utf8Count: Int + + init(_ source: String) { + self.source = source + utf8Count = source.utf8.count + + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in source.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + fingerprint = hash + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.fingerprint == rhs.fingerprint + && lhs.utf8Count == rhs.utf8Count + && lhs.source == rhs.source + } + + func hash(into hasher: inout Hasher) { + hasher.combine(fingerprint) + hasher.combine(utf8Count) + } +} + +enum MarkdownInlineStyle: String, Hashable, Sendable { + case body + case heading1 + case heading2 + case heading3 + case heading4 + case tableHeader + case tableCell + case code + + @MainActor + func uiFont(dynamicTypeSize: DynamicTypeSize) -> UIFont { + let textStyle: UIFont.TextStyle + let weight: UIFont.Weight + switch self { + case .body, .tableCell: + textStyle = .body + weight = .regular + case .heading1: + textStyle = .title2 + weight = .bold + case .heading2: + textStyle = .title3 + weight = .bold + case .heading3: + textStyle = .headline + weight = .bold + case .heading4, .tableHeader: + textStyle = .body + weight = .semibold + case .code: + textStyle = .callout + weight = .regular + } + + let traits = UITraitCollection( + preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize) + ) + let preferred = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: traits) + if self == .code { + return UIFont.monospacedSystemFont(ofSize: preferred.pointSize, weight: weight) + } + return UIFont.systemFont(ofSize: preferred.pointSize, weight: weight) + } + + static func heading(level: Int) -> Self { + switch level { + case 1: .heading1 + case 2: .heading2 + case 3: .heading3 + default: .heading4 + } + } +} + +/// Reference semantics let consecutive streaming revisions share unchanged inline runs. +final class MarkdownRenderedInline: @unchecked Sendable { + let attributedText: AttributedString + let style: MarkdownInlineStyle + + init(attributedText: AttributedString, style: MarkdownInlineStyle) { + self.attributedText = attributedText + self.style = style + } +} + +extension MarkdownRenderedInline: Equatable { + /// Streaming revisions share unchanged runs by reference (see the inline + /// cache), so identity comparison is both cheap and effective: unchanged + /// paragraphs compare equal without touching their attributed text. + static func == (lhs: MarkdownRenderedInline, rhs: MarkdownRenderedInline) -> Bool { + lhs === rhs + } +} + +struct MarkdownRenderedListItem: Equatable, @unchecked Sendable { + let task: MarkdownTaskState? + let blocks: [MarkdownRenderedBlock] +} + +struct MarkdownRenderedTable: Equatable, @unchecked Sendable { + let header: [MarkdownRenderedInline] + let alignments: [MarkdownTableAlignment] + let rows: [[MarkdownRenderedInline]] + /// Estimated per-column widths, computed once on the render task so the + /// table view never measures cell text on the main thread. + let columnWidths: [CGFloat] + + static func estimatedColumnWidths( + header: [MarkdownRenderedInline], + rows: [[MarkdownRenderedInline]] + ) -> [CGFloat] { + let cells = [header] + rows + return header.indices.map { columnIndex in + let longestLine = cells + .compactMap { row -> Int? in + guard row.indices.contains(columnIndex) else { return nil } + return String(row[columnIndex].attributedText.characters) + .split(separator: "\n", omittingEmptySubsequences: false) + .map(\.count) + .max() + } + .max() ?? 0 + + // Deliberately an estimate rather than text measurement. Exact + // widths would require laying every cell out twice. + return min(300, max(140, CGFloat(longestLine) * 8.25)) + } + } +} + +indirect enum MarkdownRenderedBlock: Equatable, @unchecked Sendable { + case paragraph(MarkdownRenderedInline) + case image(MarkdownImage) + case heading(level: Int, inline: MarkdownRenderedInline) + case unorderedList([MarkdownRenderedListItem]) + case orderedList(start: Int, items: [MarkdownRenderedListItem]) + case blockquote([MarkdownRenderedBlock]) + case table(MarkdownRenderedTable) + case codeBlock(language: String?, code: String, inline: MarkdownRenderedInline) + case thematicBreak + case artifactTemplate(CodexArtifactTemplate) +} + +/// Immutable render plans are safe to reuse every time SwiftUI reconstructs a message row. +final class MarkdownRenderedDocument: @unchecked Sendable { + let revision: MarkdownContentRevision + let blocks: [MarkdownRenderedBlock] + + init(revision: MarkdownContentRevision, blocks: [MarkdownRenderedBlock]) { + self.revision = revision + self.blocks = blocks + } +} + +private final class MarkdownRenderedInlineBox: NSObject { + let value: MarkdownRenderedInline + + init(_ value: MarkdownRenderedInline) { + self.value = value + } +} + +/// Bounded, process-local caches keep history navigation and SwiftUI diffing from reparsing +/// unchanged messages. Cache misses are rendered by a detached task and duplicate requests +/// for the same revision share one in-flight render. +final class MarkdownRenderCache: @unchecked Sendable { + static let shared: MarkdownRenderCache = { + let cache = MarkdownRenderCache() + // NSCache only sheds objects; in-flight renders and their waiters are + // ours to drop when the system is under pressure. + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { _ in + cache.removeAll() + } + return cache + }() + + private let documents = NSCache() + private let inlineRuns = NSCache() + private let inFlightQueue = DispatchQueue(label: "codes.t3.native.markdown-render-cache") + private struct InFlightRender { + let task: Task + var waiters: Set + } + private var inFlight: [MarkdownContentRevision: InFlightRender] = [:] + + init( + documentCountLimit: Int = 512, + documentCostLimit: Int = 12 * 1_024 * 1_024, + inlineCountLimit: Int = 2_048, + inlineCostLimit: Int = 8 * 1_024 * 1_024 + ) { + documents.countLimit = documentCountLimit + documents.totalCostLimit = documentCostLimit + inlineRuns.countLimit = inlineCountLimit + inlineRuns.totalCostLimit = inlineCostLimit + } + + /// Keys use the precomputed fingerprint instead of the full source so each + /// lookup avoids bridging and hashing the entire message body. The stored + /// document's revision equality check below makes collisions safe. + private func cacheKey(for revision: MarkdownContentRevision) -> NSString { + "\(revision.fingerprint):\(revision.utf8Count)" as NSString + } + + func cachedDocument(for revision: MarkdownContentRevision) -> MarkdownRenderedDocument? { + let document = documents.object(forKey: cacheKey(for: revision)) + return document?.revision == revision ? document : nil + } + + /// Completed transcript rows must have their final geometry on first display. + /// Prefetching normally makes this a cache hit; the synchronous fallback prevents + /// a visible plain-text-to-Markdown layout swap when UIKit misses a prefetch window. + func documentImmediately( + for revision: MarkdownContentRevision + ) -> MarkdownRenderedDocument? { + if let cached = cachedDocument(for: revision) { + return cached + } + guard let document = renderDocument(revision) else { return nil } + documents.setObject( + document, + forKey: cacheKey(for: revision), + cost: documentCost(document) + ) + return document + } + + /// Set `isIntermediate` for in-progress streaming revisions: they are + /// superseded within milliseconds, and inserting each one would churn + /// completed messages out of the bounded document cache. Unchanged inline + /// runs are still shared through the inline cache either way. + func document( + for revision: MarkdownContentRevision, + isIntermediate: Bool = false + ) async -> MarkdownRenderedDocument? { + guard !Task.isCancelled else { return nil } + if let cached = cachedDocument(for: revision) { + return cached + } + + let waiterID = UUID() + let task = inFlightTask(for: revision, waiterID: waiterID) + let document = await withTaskCancellationHandler { + let document = await task.value + releaseWaiter(for: revision, waiterID: waiterID, cancelIfLast: false) + return document + } onCancel: { [self] in + releaseWaiter(for: revision, waiterID: waiterID, cancelIfLast: true) + } + guard !Task.isCancelled, let document else { return nil } + if !isIntermediate { + documents.setObject( + document, + forKey: cacheKey(for: revision), + cost: documentCost(document) + ) + } + return document + } + + /// Adopts a document that was rendered as a streaming intermediate. Called + /// when its message completes so the final revision becomes a durable + /// cache entry without reparsing on the main thread. + func promote(_ document: MarkdownRenderedDocument) { + guard cachedDocument(for: document.revision) == nil else { return } + documents.setObject( + document, + forKey: cacheKey(for: document.revision), + cost: documentCost(document) + ) + } + + func removeAll() { + documents.removeAllObjects() + inlineRuns.removeAllObjects() + let tasks = inFlightQueue.sync { + let tasks = inFlight.values.map(\.task) + inFlight.removeAll(keepingCapacity: true) + return tasks + } + tasks.forEach { $0.cancel() } + } + + private func inFlightTask( + for revision: MarkdownContentRevision, + waiterID: UUID + ) -> Task { + inFlightQueue.sync { + if var existing = inFlight[revision] { + existing.waiters.insert(waiterID) + inFlight[revision] = existing + return existing.task + } + + let task = Task.detached(priority: .userInitiated) { [self] in + renderDocument(revision) + } + inFlight[revision] = InFlightRender(task: task, waiters: [waiterID]) + return task + } + } + + private func releaseWaiter( + for revision: MarkdownContentRevision, + waiterID: UUID, + cancelIfLast: Bool + ) { + let taskToCancel: Task? = inFlightQueue.sync { + guard var render = inFlight[revision], + render.waiters.remove(waiterID) != nil else { + return nil + } + guard render.waiters.isEmpty else { + inFlight[revision] = render + return nil + } + inFlight.removeValue(forKey: revision) + return cancelIfLast ? render.task : nil + } + taskToCancel?.cancel() + } + + private func renderDocument(_ revision: MarkdownContentRevision) -> MarkdownRenderedDocument? { + guard !Task.isCancelled else { return nil } + let document = MarkdownDocument(parsing: revision.source) + guard !Task.isCancelled, let blocks = renderBlocks(document.blocks) else { return nil } + return MarkdownRenderedDocument( + revision: revision, + blocks: blocks + ) + } + + private func renderBlocks(_ blocks: [MarkdownBlock]) -> [MarkdownRenderedBlock]? { + var renderedBlocks: [MarkdownRenderedBlock] = [] + renderedBlocks.reserveCapacity(blocks.count) + for block in blocks { + guard !Task.isCancelled else { return nil } + let rendered: MarkdownRenderedBlock + switch block { + case let .paragraph(source): + guard let inline = renderInline(source, style: .body) else { return nil } + rendered = .paragraph(inline) + + case let .image(image): + rendered = .image(image) + + case let .heading(level, source): + guard let inline = renderInline(source, style: .heading(level: level)) else { + return nil + } + rendered = .heading( + level: level, + inline: inline + ) + + case let .unorderedList(items): + guard let items = renderItems(items) else { return nil } + rendered = .unorderedList(items) + + case let .orderedList(start, items): + guard let items = renderItems(items) else { return nil } + rendered = .orderedList(start: start, items: items) + + case let .blockquote(document): + guard let blocks = renderBlocks(document.blocks) else { return nil } + rendered = .blockquote(blocks) + + case let .table(table): + guard let table = renderTable(table) else { return nil } + rendered = .table(table) + + case let .codeBlock(language, code): + guard let inline = renderInline(code, style: .code) else { return nil } + rendered = .codeBlock(language: language, code: code, inline: inline) + + case let .artifactTemplate(template): + rendered = .artifactTemplate(template) + + case .thematicBreak: + rendered = .thematicBreak + } + renderedBlocks.append(rendered) + } + return renderedBlocks + } + + private func renderTable(_ table: MarkdownTable) -> MarkdownRenderedTable? { + var header: [MarkdownRenderedInline] = [] + header.reserveCapacity(table.header.count) + for cell in table.header { + guard let inline = renderInline(cell, style: .tableHeader) else { return nil } + header.append(inline) + } + + var rows: [[MarkdownRenderedInline]] = [] + rows.reserveCapacity(table.rows.count) + for sourceRow in table.rows { + guard !Task.isCancelled else { return nil } + var row: [MarkdownRenderedInline] = [] + row.reserveCapacity(sourceRow.count) + for cell in sourceRow { + guard let inline = renderInline(cell, style: .tableCell) else { return nil } + row.append(inline) + } + rows.append(row) + } + + return MarkdownRenderedTable( + header: header, + alignments: table.alignments, + rows: rows, + columnWidths: MarkdownRenderedTable.estimatedColumnWidths( + header: header, + rows: rows + ) + ) + } + + private func renderItems(_ items: [MarkdownListItem]) -> [MarkdownRenderedListItem]? { + var renderedItems: [MarkdownRenderedListItem] = [] + renderedItems.reserveCapacity(items.count) + for item in items { + guard !Task.isCancelled, let blocks = renderBlocks(item.blocks) else { return nil } + renderedItems.append(MarkdownRenderedListItem(task: item.task, blocks: blocks)) + } + return renderedItems + } + + private func renderInline( + _ source: String, + style: MarkdownInlineStyle + ) -> MarkdownRenderedInline? { + guard !Task.isCancelled else { return nil } + let key = "\(style.rawValue)\u{0}\(source)" as NSString + if let cached = inlineRuns.object(forKey: key) { + return cached.value + } + + let attributedText = if style == .code { + AttributedString(source) + } else { + MarkdownInlineFormatter.format(source) + } + let inline = MarkdownRenderedInline(attributedText: attributedText, style: style) + guard !Task.isCancelled else { return nil } + inlineRuns.setObject( + MarkdownRenderedInlineBox(inline), + forKey: key, + cost: max(64, source.utf8.count * 2) + ) + return inline + } + + private func documentCost(_ document: MarkdownRenderedDocument) -> Int { + max(256, document.revision.utf8Count * 3) + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift new file mode 100644 index 000000000000..4878a38b2399 --- /dev/null +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -0,0 +1,2864 @@ +import ImageIO +import SwiftUI +import UIKit + +public struct ThreadDetailView: View { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.t3CodeSizeSteps) private var codeSizeSteps + @SwiftUI.Environment(\.horizontalSizeClass) private var horizontalSizeClass + @SwiftUI.Environment(\.openURL) private var parentOpenURL + @SwiftUI.Environment(\.scenePhase) private var scenePhase + + @Bindable var model: FeatureRootModel + let thread: FeatureThread + let submitMessage: (FeatureMessageSubmission) async -> Bool + let onNavigateBack: () -> Void + private let draftStore: FeatureComposerDraftStore + + @State private var draft = "" + @State private var selection: FeatureSelection? + @State private var attachments: [FeatureDraftAttachment] = [] + @State private var isSending = false + @State private var submittingCompaction = false + @State private var isLoading = true + @State private var sendFailed = false + @State private var feedbackMessages: [FeatureMessage] = [] + @State private var feedbackRevision: UInt64 = 0 + @State private var feedbackAlertMessage: String? + @State private var feedbackIdentifier: String? + @State private var didRestoreDraft = false + @State private var draftSaveTask: Task? + @State private var draftSaveError: String? + @State private var toolSurface: FeatureThreadToolSurface? + @State private var branchPullRequest: FeaturePullRequest? + @State private var linkedMediaPreview: FeatureLinkedMediaPreview? + @State private var linkedMediaPreviewError: String? + // Plain state, not `FocusState`: the composer's UIKit text view owns + // focus and mirrors it through this binding, because SwiftUI drops + // writes to a `FocusState` no `.focused()` view registers with. + @State private var composerFocused = false + + public init( + model: FeatureRootModel, + thread: FeatureThread, + submitMessage: @escaping (FeatureMessageSubmission) async -> Bool, + onNavigateBack: @escaping () -> Void = {}, + draftStore: FeatureComposerDraftStore = .shared + ) { + self.model = model + self.thread = thread + self.submitMessage = submitMessage + self.onNavigateBack = onNavigateBack + self.draftStore = draftStore + } + + public var body: some View { + Group { + if let detail { + timeline(detail) + } else if isLoading { + FeatureThreadOpeningView() + } else { + ContentUnavailableView { + Label("Thread unavailable", systemImage: "exclamationmark.bubble") + } description: { + Text("The thread could not be loaded.") + } actions: { + Button("Retry", action: reloadThread) + } + } + } + .background(T3Colors.background) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(false) + .t3NavigationChrome() + .toolbar { + ToolbarItem(placement: .principal) { + threadHeaderTitle + } + ToolbarItem(placement: .primaryAction) { + threadActionsMenu + } + } + .task(id: thread.id) { + isLoading = true + _ = await model.detail(for: thread.id, force: true) + isLoading = false + } + .task(id: thread.id) { + // A cached thread can already show its composer while the server + // is catching up. Local drafts must not wait for that request. + guard !didRestoreDraft else { return } + await restoreDraft(from: composerDraft, key: draftKey) + } + .task(id: pullRequestObservationID) { + await observeThreadPullRequest() + } + .task(id: workspaceCatalogID) { + if let environmentID = currentThread.environmentID, let cwd = workspaceCatalogPath, + let instanceID = selection?.providerID ?? currentSelection?.providerID { + await model.refreshWorkspaceProviders(environmentID: environmentID, cwd: cwd, instanceID: instanceID) + } + } + .environment(\.providerSetupContext, currentThread.environmentID.map { + ProviderSetupContext(model: model, environmentID: $0) + }) + .onChange(of: draft) { scheduleDraftSave() } + .onChange(of: selection) { scheduleDraftSave() } + .onChange(of: threadConnectionState) { _, state in + if state == .connected, + case .failed = model.detailLoadStates[thread.id], + !isLoading { + reloadThread() + } + } + .onChange(of: scenePhase) { _, phase in + if phase != .active { + persistDraftBeforeLeaving() + } + } + .onDisappear { + model.releaseThread(thread.id) + persistDraftBeforeLeaving() + } + .sheet(item: $toolSurface) { surface in + NavigationStack { + Group { + switch surface { + case .files: + FeatureFilesView( + client: model.client, + threadID: thread.id, + workspaceRoot: markdownImageContext?.workspaceRoot + ) + case let .file(path): + FeatureFilesView( + client: model.client, + threadID: thread.id, + initialPath: path, + workspaceRoot: markdownImageContext?.workspaceRoot + ) + case .review: + FeatureReviewView(client: model.client, threadID: thread.id) + case .sourceControl: + FeatureSourceControlView(client: model.client, threadID: thread.id) + case .terminal: + FeatureTerminalView(client: model.client, threadID: thread.id) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + toolSurface = nil + } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .t3CodeSizing(steps: codeSizeSteps) + } + .alert("Message not sent", isPresented: $sendFailed) { + // Refocusing happens here rather than when the send fails: the + // alert takes first responder from the composer, so a refocus + // issued before it presents is lost by the time it dismisses. + Button("OK") { composerFocused = true } + } message: { + Text("Your draft is still here. Check your connection and try again.") + } + .alert( + feedbackIdentifier == nil ? "Could not send feedback" : "Feedback sent to OpenAI", + isPresented: Binding( + get: { feedbackAlertMessage != nil }, + set: { if !$0 { feedbackAlertMessage = nil; feedbackIdentifier = nil } } + ) + ) { + if let feedbackIdentifier { + Button("Copy ID") { + UIPasteboard.general.string = feedbackIdentifier + } + } + Button("OK", role: .cancel) {} + } message: { + Text(feedbackAlertMessage ?? "") + } + .background { + ThreadBackSwipeGestureView( + isEnabled: horizontalSizeClass == .compact, + onNavigateBack: onNavigateBack + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .environment(\.openURL, OpenURLAction { url in + if handleArtifactTemplateURL(url) { return .handled } + if handleTypedMediaPreviewURL(url) { return .handled } + if case let .workspaceFile(hostPath) = MarkdownImageSource.classify( + url.absoluteString, workspaceRoot: markdownImageContext?.workspaceRoot + ) { + let kind = FeatureFilePreviewKind.infer(path: hostPath) + let suffix = URL(fileURLWithPath: hostPath).pathExtension.lowercased() + if kind == .image || kind == .video || kind == .pdf || ["html", "htm"].contains(suffix) { + resolveHostMedia(path: hostPath, kind: kind) + return .handled + } + } + guard let workspaceRoot = markdownImageContext?.workspaceRoot, + let path = MarkdownWorkspaceFileLink.relativePath( + for: url, + workspaceRoot: workspaceRoot + ) else { + if url.scheme?.lowercased() == "http" || url.scheme?.lowercased() == "https", + let kind = FeatureLinkedMediaPreview.previewKind(for: url) { + linkedMediaPreview = FeatureLinkedMediaPreview( + source: url.isFileURL ? .file(url) : .remote(url), + kind: kind, + fileName: url.lastPathComponent + ) + return .handled + } + if url.isFileURL { + let path = url.path + let kind = FeatureFilePreviewKind.infer(path: path) + if kind == .image || kind == .video { + resolveHostMedia(path: path, kind: kind) + return .handled + } + } + if url.scheme?.lowercased() == "t3code" { return .discarded } + parentOpenURL(url) + return .handled + } + let kind = FeatureFilePreviewKind.infer(path: path) + if kind == .image || kind == .video { + resolveHostMedia(path: path, kind: kind) + return .handled + } + toolSurface = .file(path) + return .handled + }) + .fullScreenCover(item: $linkedMediaPreview) { preview in + NavigationStack { + FeatureNativeMediaPreviewView( + source: preview.source, + kind: preview.kind, + fileName: preview.fileName + ) + .navigationTitle(preview.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { linkedMediaPreview = nil } + } + } + } + .preferredColorScheme(.dark) + } + .alert( + "Preview unavailable", + isPresented: Binding( + get: { linkedMediaPreviewError != nil }, + set: { if !$0 { linkedMediaPreviewError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(linkedMediaPreviewError ?? "The file could not be opened.") + } + } + + private var detail: FeatureThreadDetail? { + model.details[thread.id] + } + + private var currentThread: FeatureThread { + detail?.thread ?? thread + } + + private var isCompacting: Bool { + submittingCompaction || detail?.isCompacting == true + } + + private var currentSelection: FeatureSelection? { + guard let providerID = detail?.thread.providerID ?? thread.providerID, + let modelID = detail?.thread.modelID ?? thread.modelID else { return nil } + let provider = threadProviders.first { $0.id == providerID } + let featureModel = provider?.models.first { $0.id == modelID } + let savedOptions = detail?.thread.modelOptions ?? thread.modelOptions + return FeatureSelection( + providerID: providerID, + modelID: modelID, + options: savedOptions.isEmpty + ? featureModel.map(DailyUXModelOptions.defaults) ?? [] + : savedOptions + ) + } + + private var threadHeaderTitle: some View { + VStack(alignment: .leading, spacing: 1) { + Text(currentThread.title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + HStack(spacing: 5) { + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + Text(headerBranch) + .lineLimit(1) + if let environmentName = currentThread.homeEnvironmentLabel(in: model.snapshot) { + Text("·") + Text(environmentName) + .lineLimit(1) + } + } + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 6) + + // Cached work state is not proof that the agent is still + // running. Only current, working threads need a live timer. + Group { + if refreshPresentation != nil { + EmptyView() + } else if currentThread.homeStatus == .working, !isCompacting { + TimelineView(.periodic(from: .now, by: 1)) { context in + headerStatus(at: context.date) + } + } else { + headerStatus(at: .now) + } + } + .fixedSize(horizontal: true, vertical: false) + } + .font(T3Typography.navigationMetadata) + .foregroundStyle(T3Colors.textTertiary) + } + // Leave compact-width clearance for the trailing thread menu. + .padding(.trailing, horizontalSizeClass == .compact ? 10 : 0) + .frame(maxWidth: horizontalSizeClass == .compact ? 260 : 460, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityAddTraits( + refreshPresentation == nil && !isCompacting && currentThread.hasLiveWorkingDuration + ? .updatesFrequently : [] + ) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } + + @ViewBuilder + private func headerStatus(at now: Date) -> some View { + let duration = currentThread.homeWorkingDuration(at: now) + if isCompacting { + Label("Compacting", systemImage: "arrow.down.right.and.arrow.up.left") + .font(T3Typography.status) + .foregroundStyle(T3Colors.statusRunning) + .lineLimit(1) + } else if let label = duration ?? currentThread.detailHeaderStatusLabel { + HStack(spacing: 5) { + if let icon = currentThread.detailHeaderStatusIcon { + Image(systemName: icon) + } + headerStatusText(label, isDuration: duration != nil) + } + .font(T3Typography.status) + .foregroundStyle(headerStatusColor) + .lineLimit(1) + .accessibilityElement(children: .combine) + .accessibilityLabel(currentThread.homeStatusAccessibilityLabel(at: now)) + } + } + + @ViewBuilder + private func headerStatusText(_ label: String, isDuration: Bool) -> some View { + if isDuration { + Text(label) + .monospaced() + .monospacedDigit() + } else { + Text(label) + } + } + + private var threadActionsMenu: some View { + Menu { + Section("Thread") { + if let pullRequest = currentPullRequest { + Button { + parentOpenURL(pullRequest.url) + } label: { + Label("Open pull request #\(pullRequest.number)", systemImage: "arrow.triangle.pull") + } + } + if currentThread.supportsTitleRegeneration == true { + Button { + Task { await model.regenerateThreadTitle(thread.id) } + } label: { + Label("Regenerate title", systemImage: "sparkles") + } + } + Menu { + if !FeatureRuntimeMode.allCases.contains(currentThread.runtimeMode) { + Section("Current") { + Button {} label: { + Label( + runtimeModeLabel(currentThread.runtimeMode), + systemImage: "checkmark" + ) + } + .disabled(true) + } + } + ForEach(FeatureRuntimeMode.allCases, id: \.self) { mode in + Button { + guard currentThread.runtimeMode != mode else { + return + } + Task { await model.setRuntimeMode(thread.id, mode: mode) } + } label: { + if currentThread.runtimeMode == mode { + Label(runtimeModeLabel(mode), systemImage: "checkmark") + } else { + Text(runtimeModeLabel(mode)) + } + } + } + } label: { + Label("Permissions", systemImage: "checkmark.shield") + } + .disabled(model.isPerformingAction) + if currentThread.canTogglePin, !currentThread.isArchived { + Button { + Task { + await model.setPinned( + thread.id, + pinned: currentThread.pinnedAt == nil + ) + } + } label: { + Label( + currentThread.pinnedAt == nil ? "Pin" : "Unpin", + systemImage: currentThread.pinnedAt == nil ? "pin" : "pin.slash" + ) + } + } + let isSettled = model.isEffectivelySettled(currentThread) + if (isSettled || currentThread.canSettleNow()), !currentThread.isArchived { + Button { + Task { await model.setSettled(thread.id, settled: !isSettled) } + } label: { + Label( + isSettled ? "Reopen" : "Settle", + systemImage: isSettled ? "arrow.counterclockwise" : "checkmark" + ) + } + } + Button(action: reloadThread) { + Label("Reload", systemImage: "arrow.clockwise") + } + } + Section("Workspace") { + Button { toolSurface = .files } label: { + Label("Files", systemImage: "folder") + } + Button { toolSurface = .review } label: { + Label("Review changes", systemImage: "doc.text.magnifyingglass") + } + Button { toolSurface = .sourceControl } label: { + Label("Source control", systemImage: "arrow.triangle.branch") + } + Button { toolSurface = .terminal } label: { + Label("Terminal", systemImage: "terminal") + } + } + Section { + Button { + Task { + await model.setArchived(thread.id, archived: !currentThread.isArchived) + } + } label: { + Label( + currentThread.isArchived ? "Restore" : "Archive", + systemImage: currentThread.isArchived + ? "arrow.uturn.backward" + : "archivebox" + ) + } + } + } label: { + Image(systemName: "ellipsis") + .font(.body.weight(.semibold)) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Thread actions") + .accessibilityHint("Shows thread actions and workspace tools") + .accessibilityIdentifier("thread-actions-menu") + } + + private func runtimeModeLabel(_ mode: FeatureRuntimeMode) -> String { + switch mode { + case .approvalRequired: "Supervised" + case .autoAcceptEdits: "Auto-accept edits" + case .automatic: "Automatic" + case .fullAccess: "Full access" + } + } + + private var currentPullRequest: ThreadPullRequestDestination? { + return ThreadPullRequestDestination.resolve( + thread: currentThread, + branchPullRequest: branchPullRequest + ) + } + + private var pullRequestObservationID: String? { + currentThread.pullRequestObservationIdentity + } + + @MainActor + private func observeThreadPullRequest() async { + branchPullRequest = nil + guard let observationIdentity = pullRequestObservationID else { + branchPullRequest = nil + return + } + + if let linked = currentThread.effectivePullRequest, + let environmentID = currentThread.environmentID { + let target = FeaturePullRequestTarget( + environmentID: environmentID, + environmentName: currentThread.environmentName ?? environmentID, + reference: PullRequestRef( + projectId: linked.projectId, + repository: linked.repository, + number: linked.number + ) + ) + while !Task.isCancelled { + if let detail = try? await model.client.pullRequestDetail(target), + let presentation = HomeThreadPullRequestPresentation.resolve( + linkedPullRequest: linked, + detail: detail + ) { + model.updatePullRequest( + presentation, + threadID: currentThread.id, + observationIdentity: observationIdentity + ) + } + do { + try await Task.sleep(for: .seconds(30)) + } catch { + return + } + } + return + } + + for await status in model.client.sourceControlStatusEvents(threadID: thread.id) { + guard !Task.isCancelled else { return } + let next = status.branch == currentThread.branch ? status.pullRequest : nil + if next != branchPullRequest { + branchPullRequest = next + } + model.updatePullRequest( + HomeThreadPullRequestPresentation.resolve(thread: currentThread, status: status), + threadID: currentThread.id, + observationIdentity: observationIdentity + ) + } + } + + private func reloadThread() { + isLoading = true + Task { + _ = await model.detail(for: thread.id, force: true, fresh: true) + isLoading = false + } + } + + private var threadConnectionState: FeatureConnection.State? { + guard let environmentID = currentThread.environmentID else { return nil } + return model.snapshot.environments.first { $0.id == environmentID }?.connectionState + } + + private var refreshPresentation: ThreadRefreshPresentation? { + ThreadRefreshPresentation.resolve( + loadState: model.detailLoadStates[thread.id], + connectionState: threadConnectionState, + isOpening: isLoading, + syncState: model.threadSyncStates[thread.id] + ) + } + + @ViewBuilder + private var refreshStatus: some View { + if let refreshPresentation { + HStack(spacing: 8) { + Label(refreshPresentation.title, systemImage: refreshPresentation.systemImage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 4) + if refreshPresentation.canRetry { + Button(action: reloadThread) { + Label("Retry", systemImage: "arrow.clockwise") + .font(T3Typography.control) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.accent) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("thread-refresh-retry") + } + } + .padding(.horizontal, 18) + .padding(.top, 8) + .accessibilityIdentifier("thread-refresh-status") + } + } + + private var headerBranch: String { + if let branch = currentThread.branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty { + return branch + } + if let path = currentThread.worktreePath, + !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: path).lastPathComponent + } + return "workspace" + } + + private var headerStatusColor: Color { + switch currentThread.homeStatus { + case .working: T3Colors.statusRunning + case .monitoring: T3Colors.statusRunning + case .approval: T3Colors.warning + case .input: T3Colors.statusInput + case .failed: T3Colors.danger + case .done: T3Colors.success + case .ready: T3Colors.textTertiary + } + } + + private func timeline(_ detail: FeatureThreadDetail) -> some View { + let hasActiveWork = detail.thread.state == .working + || detail.thread.state == .queued + || detail.thread.state == .monitoring + || isCompacting + let isWorking = hasActiveWork && refreshPresentation == nil + return Group { + if detail.messages.isEmpty, !hasActiveWork { + if refreshPresentation == nil { + ContentUnavailableView( + "Ready for a task", + systemImage: "sparkles", + description: Text("Tell the agent what you want to build.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + FeatureTranscriptCollectionView( + threadID: thread.id, + messages: timelineMessages(detail.messages), + imageContext: markdownImageContext, + attachmentContext: (model.client as? any FeatureAttachmentAssetResolving).map { + FeatureAttachmentContext(threadID: thread.id, resolver: $0) + }, + skills: threadProviderSkills, + renderUpdate: timelineRenderUpdate, + dynamicTypeSize: dynamicTypeSize, + codeSizeSteps: codeSizeSteps, + isWorking: isWorking, + isCompacting: isCompacting, + activeSubagentCount: detail.activeSubagentCount, + backgroundWorkIsActive: detail.backgroundWorkIsActive, + isMonitoring: detail.thread.state == .monitoring, + canLoadEarlier: detail.page?.hasMore == true, + isLoadingEarlier: detail.page?.isLoading == true, + onLoadEarlier: { + Task { await model.loadEarlierTurns(for: thread.id) } + }, + onDismissKeyboard: dismissKeyboard + ) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + refreshStatus + FeatureComposerView( + text: $draft, + selection: $selection, + attachments: attachmentBinding, + draftOwnerID: "thread:\(currentThread.id)", + environmentID: currentThread.environmentID, + draftStorageKey: draftKey, + environmentIsConnected: threadConnectionState == .connected, + attachmentUploads: model.attachmentUploads, + attachmentPreferences: currentThread.environmentID.flatMap { + model.snapshot.preferencesByEnvironment?[$0] + } ?? FeatureEnvironmentPreferences(), + providers: threadProviders, + threadSelection: currentSelection, + materializesDefaultSelection: false, + isSending: isSending, + isWorking: detail.thread.state == .working || detail.thread.state == .queued + || isCompacting, + focused: $composerFocused, + onSend: send, + onStop: { + Task { await model.cancelTurn(threadID: thread.id) } + }, + pendingApprovals: detail.approvals, + pendingUserInputs: detail.userInputs, + isResolvingRequest: model.isPerformingAction, + powerFeatures: composerPowerFeatures, + showsKeyboardDismissControl: true, + onDismissKeyboard: dismissKeyboard, + onApprovalDecision: { id, decision in + Task { await model.resolveApproval(id, decision: decision) } + }, + onUserInputSubmit: { id, answers in + Task { await model.resolveUserInput(id, answers: answers) } + }, + onRefreshModels: refreshThreadEnvironmentModels, + draftSaveError: draftSaveError, + onRetryDraftSave: persistDraftImmediately + ) + } + .background(T3Colors.background) + } + } + + private var composerPowerFeatures: FeatureComposerPowerFeatures { + let selectedProviderID = selection?.providerID ?? currentSelection?.providerID + let provider = threadProviders.first { $0.id == selectedProviderID } + return FeatureComposerPowerFeatures( + slashCommands: provider?.workspaceCatalog(cwd: workspaceCatalogPath).slashCommands ?? [], + skills: provider?.workspaceCatalog(cwd: workspaceCatalogPath).skills ?? [], + canCompactContext: FeatureContextCompaction.canStart( + in: detail, + isBusy: isSending || refreshPresentation != nil + ), + pathSearchScopeID: currentThread.id, + searchPaths: { query in + try await model.client.searchThreadFiles( + threadID: currentThread.id, + query: query, + limit: 20 + ).map { entry in + FeatureComposerPathEntry( + path: entry.path, + kind: entry.kind == .directory ? .directory : .file + ) + } + } + ) + } + + private var threadProviders: [FeatureProvider] { + ThreadComposerProviderCatalog.providers( + for: currentThread, + in: model.snapshot + ) + } + + private var workspaceCatalogPath: String? { + currentThread.worktreePath ?? model.snapshot.projects.first { $0.id == currentThread.projectID }?.path + } + + private var workspaceCatalogID: String { + "\(currentThread.environmentID ?? ""):\(workspaceCatalogPath ?? ""):\(selection?.providerID ?? currentSelection?.providerID ?? "")" + } + + private func refreshThreadEnvironmentModels() async throws { + guard let environmentID = currentThread.environmentID else { return } + guard await model.refreshProviders(environmentID: environmentID) else { + throw FeatureModelRefreshError() + } + } + + var threadProviderSkills: [FeatureProviderSkill] { + guard let selectedProviderID = currentSelection?.providerID else { return [] } + return threadProviders.first { $0.id == selectedProviderID }? + .workspaceCatalog(cwd: workspaceCatalogPath).skills ?? [] + } + + private var timelineRenderUpdate: FeatureDetailRenderUpdate? { + guard !feedbackMessages.isEmpty else { + return model.detailRenderUpdates[thread.id] + } + let revision = model.detailRevisions[thread.id] ?? 0 + return FeatureDetailRenderUpdate( + baseRevision: revision, + revision: (UInt64.max / 2) &+ revision &+ feedbackRevision, + change: .full + ) + } + + private func timelineMessages(_ messages: [FeatureMessage]) -> [FeatureMessage] { + guard !feedbackMessages.isEmpty else { return messages } + return (messages + feedbackMessages).sorted { + if $0.createdAt == $1.createdAt { + return $0.id < $1.id + } + return $0.createdAt < $1.createdAt + } + } + + private var markdownImageContext: MarkdownImageContext? { + guard let resolver = model.client as? any FeatureWorkspaceAssetResolving, + let project = model.snapshot.projects.first(where: { + $0.id == currentThread.projectID + }) else { + return nil + } + return MarkdownImageContext( + threadID: currentThread.id, + workspaceRoot: currentThread.worktreePath ?? project.path, + resolver: resolver + ) + } + + private func handleArtifactTemplateURL(_ url: URL) -> Bool { + guard url.scheme?.lowercased() == "t3code", + url.host?.lowercased() == "codex-artifact-template", + url.path == "/use", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.queryItems?.count == 1, + components.queryItems?.first?.name == "prompt", + let prompt = components.queryItems?.first?.value? + .trimmingCharacters(in: .whitespacesAndNewlines), + !prompt.isEmpty, prompt.count <= 4_096 else { return false } + if draft == prompt || draft.hasSuffix(" \(prompt)") || draft.hasSuffix("\n\(prompt)") { + composerFocused = true + return true + } + draft = draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? prompt + : draft + (draft.last?.isWhitespace == true ? "" : " ") + prompt + composerFocused = true + return true + } + + private func handleTypedMediaPreviewURL(_ url: URL) -> Bool { + guard let route = FeatureTypedMediaPreviewRoute.parse(url) else { return false } + resolveHostMedia(path: route.path, kind: route.kind) + return true + } + + private func resolveHostMedia(path: String, kind: FeatureFilePreviewKind) { + guard let resolver = model.client as? any FeatureWorkspaceAssetResolving else { + linkedMediaPreviewError = "This environment cannot resolve media files." + return + } + let requestedThreadID = currentThread.id + Task { + do { + let resolved = try await resolver.mediaAssetURL( + threadID: requestedThreadID, + path: path + ) + guard !Task.isCancelled, currentThread.id == requestedThreadID else { return } + linkedMediaPreview = FeatureLinkedMediaPreview( + source: .remote(resolved), + kind: kind, + fileName: URL(fileURLWithPath: path).lastPathComponent + ) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, currentThread.id == requestedThreadID else { return } + linkedMediaPreviewError = error.localizedDescription + } + } + } + + private func dismissKeyboard() { + guard composerFocused else { return } + composerFocused = false + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + + private func send() { + let message = draft + let pendingAttachments = currentThread.environmentID.map { + model.attachmentUploads.attachmentsForSend( + draftKey: draftKey, + environmentID: $0, + attachments: attachments + ) + } ?? attachments + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !pendingAttachments.isEmpty else { + return + } + if pendingAttachments.isEmpty, + let command = FeatureCodexFeedbackCommand.parse(message), + let providerID = currentThread.providerID, + threadProviders.first(where: { $0.id == providerID })?.driver == "codex" + || currentThread.providerName?.lowercased() == "codex", + let submitter = model.client as? any FeatureFeedbackSubmitting { + sendFeedback(command, message: message, submitter: submitter) + return + } + let pendingDraftSave = draftSaveTask + pendingDraftSave?.cancel() + draftSaveTask = nil + isSending = true + submittingCompaction = FeatureContextCompaction.isCommand( + message, + hasAttachments: !pendingAttachments.isEmpty + ) + draft = "" + attachments = [] + composerFocused = false + Task { + await pendingDraftSave?.value + let sent = await submitMessage( + FeatureMessageSubmission( + threadID: thread.id, + text: message, + selection: selection, + attachments: pendingAttachments + ) + ) + if sent { + let trailingSave = draftSaveTask + trailingSave?.cancel() + draftSaveTask = nil + await trailingSave?.value + let followUpDraft = composerDraft + if followUpDraft.text.isEmpty && followUpDraft.attachments.isEmpty { + try? await draftStore.removeDraft(for: draftKey) + } else { + try? await draftStore.setDraft(followUpDraft, for: draftKey) + } + } else { + let currentDraft = draft + let restoredMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + if currentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft = message + } else if !restoredMessage.isEmpty { + draft = "\(message)\n\(currentDraft)" + } + let pendingIDs = Set(pendingAttachments.map(\.id)) + attachments = pendingAttachments + attachments.filter { + !pendingIDs.contains($0.id) + } + sendFailed = true + } + submittingCompaction = false + isSending = false + if !sent || !draft.isEmpty || !attachments.isEmpty { + persistDraftImmediately() + } + } + } + + private func sendFeedback( + _ command: FeatureCodexFeedbackCommand, + message: String, + submitter: any FeatureFeedbackSubmitting + ) { + guard detail?.messages.isEmpty == false else { + feedbackAlertMessage = "Send a message before you submit feedback." + return + } + + let identifier = UUID().uuidString + let createdAt = Date() + let assistantID = "\(identifier):feedback" + feedbackMessages.append(FeatureMessage( + id: identifier, + role: .user, + text: message, + createdAt: createdAt + )) + feedbackMessages.append(FeatureMessage( + id: assistantID, + role: .assistant, + text: "Sending feedback to OpenAI...", + createdAt: createdAt.addingTimeInterval(0.001) + )) + feedbackRevision &+= 1 + draftSaveTask?.cancel() + draft = "" + composerFocused = false + isSending = true + + Task { + defer { + isSending = false + if !draft.isEmpty || !attachments.isEmpty { + persistDraftImmediately() + } + } + do { + let identifier = try await submitter.submitCodexFeedback( + threadID: thread.id, + reason: command.reason + ) + updateFeedbackMessage( + id: assistantID, + text: "Feedback sent to OpenAI.\n\nThread ID: `\(identifier)`" + ) + feedbackIdentifier = identifier + feedbackAlertMessage = "Thread ID: \(identifier)" + try? await draftStore.removeDraft(for: draftKey) + } catch { + let detail = error.localizedDescription + updateFeedbackMessage( + id: assistantID, + text: "Could not send feedback to OpenAI.\n\n\(detail)" + ) + feedbackIdentifier = nil + feedbackAlertMessage = detail + } + } + } + + private func updateFeedbackMessage(id: String, text: String) { + guard let index = feedbackMessages.firstIndex(where: { $0.id == id }) else { return } + feedbackMessages[index].text = text + feedbackRevision &+= 1 + } + + private var draftKey: String { + FeatureComposerDraftStore.threadKey(currentThread) + } + + private var attachmentBinding: Binding<[FeatureDraftAttachment]> { + Binding( + get: { attachments }, + set: { value in + attachments = value + // Photo results arrive while a full-screen cover is closing. + // Save at the handoff, not through a parent view observer. + persistDraftImmediately() + } + ) + } + + @MainActor + private func restoreDraft(from baseline: FeatureComposerDraft, key: String) async { + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled else { return } + + let liveDraft = composerDraft + var restored = FeatureComposerDraftRestoration.merge( + saved: saved, + baseline: baseline, + current: liveDraft + ) + restored.selection = ThreadComposerModelSelectionPolicy.explicitSelection( + restored.selection, + inherited: currentSelection, + providers: threadProviders + ) + draft = restored.text + attachments = restored.attachments + selection = restored.selection + didRestoreDraft = true + + // Changes made while the file read or thread refresh was in flight did + // not pass the didRestoreDraft gate, so enqueue their first save now. + if liveDraft != baseline { + scheduleDraftSave() + } else if saved != nil, let environmentID = currentThread.environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: restored.attachments + ) + } + } + + private func scheduleDraftSave() { + guard didRestoreDraft, !isSending else { return } + let previousSave = draftSaveTask + previousSave?.cancel() + let snapshot = composerDraft + let key = draftKey + let environmentID = currentThread.environmentID + draftSaveTask = Task { + await previousSave?.value + do { + try await Task.sleep(for: .milliseconds(220)) + try Task.checkCancellation() + try await draftStore.setDraft(snapshot, for: key) + guard !Task.isCancelled else { return } + draftSaveError = nil + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + draftSaveError = "Could not save draft. \(error.localizedDescription)" + } + } + } + + private func persistDraftImmediately() { + guard didRestoreDraft, !isSending else { return } + let previousSave = draftSaveTask + previousSave?.cancel() + let snapshot = composerDraft + let key = draftKey + let environmentID = currentThread.environmentID + draftSaveTask = Task { + do { + await previousSave?.value + try Task.checkCancellation() + try await draftStore.setDraft(snapshot, for: key) + guard !Task.isCancelled else { return } + draftSaveError = nil + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch { + guard !Task.isCancelled else { return } + draftSaveError = "Could not save draft. \(error.localizedDescription)" + } + } + } + + private func persistDraftBeforeLeaving() { + guard didRestoreDraft, !isSending else { return } + persistDraftImmediately() + } + + private var composerDraft: FeatureComposerDraft { + FeatureComposerDraft( + text: draft, + attachments: attachments, + selection: selection + ) + } + +} + +enum ThreadRefreshPresentation: Equatable { + case loading + case catchingUp + case reconnecting + case offline + case failed + + var title: String { + switch self { + case .loading: "Updating thread..." + case .catchingUp: "Catching up..." + case .reconnecting: "Reconnecting..." + case .offline: "Computer offline" + case .failed: "Could not update thread" + } + } + + var systemImage: String { + switch self { + case .loading, .catchingUp: "hourglass" + case .reconnecting: "wifi" + case .offline, .failed: "wifi.exclamationmark" + } + } + + var canRetry: Bool { self == .offline || self == .failed } + + static func resolve( + loadState: FeatureThreadLoadState?, + connectionState: FeatureConnection.State?, + isOpening: Bool, + syncState: FeatureThreadSyncState? = nil + ) -> Self? { + switch syncState { + case .catchingUp: return .catchingUp + case .reconnecting: return .reconnecting + case .failed: return .failed + case .live, nil: break + } + // A synchronized subscription outranks local loading flags and an + // environment's periodic shell probe. Socket loss has its own state. + if syncState == .live { return nil } + if isOpening || loadState == .loading { return .loading } + if case .failed = loadState { return .failed } + switch connectionState { + case .connecting, .reconnecting: return .reconnecting + case .disconnected: return .offline + case .connected, nil: return nil + } + } +} + +private struct FeatureThreadOpeningView: View { + var body: some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.regular) + Text("Loading thread…") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("thread-opening-state") + } +} + +private enum FeatureThreadToolSurface: Identifiable { + case files + case file(String) + case review + case sourceControl + case terminal + + var id: String { + switch self { + case .files: "files" + case let .file(path): "file:\(path)" + case .review: "review" + case .sourceControl: "sourceControl" + case .terminal: "terminal" + } + } +} + +struct ThreadPullRequestDestination: Equatable { + let number: Int + let url: URL + + static func resolve( + thread: FeatureThread, + branchPullRequest: FeaturePullRequest? + ) -> Self? { + if let linked = thread.effectivePullRequest, + let url = URL(string: linked.url) { + return Self(number: linked.number, url: url) + } + + guard let pullRequest = branchPullRequest, + let url = pullRequest.url else { return nil } + return Self(number: pullRequest.number, url: url) + } +} + +/// Merges a stored draft with edits made while that draft was loading. Each +/// field is restored only if its live value still matches the value captured +/// before the asynchronous read began. +enum FeatureComposerDraftRestoration { + static func merge( + saved: FeatureComposerDraft?, + baseline: FeatureComposerDraft, + current: FeatureComposerDraft, + fallbackSelection: FeatureSelection? = nil, + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil + ) -> FeatureComposerDraft { + FeatureComposerDraft( + text: current.text == baseline.text + ? saved?.text ?? "" + : current.text, + attachments: current.attachments == baseline.attachments + ? saved?.attachments ?? [] + : current.attachments, + selection: current.selection == baseline.selection + ? saved?.selection ?? fallbackSelection + : current.selection, + workspace: mergeWorkspace( + saved: saved?.workspace ?? fallbackWorkspace, + baseline: baseline.workspace, + current: current.workspace + ) + ) + } + + private static func mergeWorkspace( + saved: FeatureComposerWorkspaceDraft?, + baseline: FeatureComposerWorkspaceDraft?, + current: FeatureComposerWorkspaceDraft? + ) -> FeatureComposerWorkspaceDraft? { + guard let saved else { + return current == baseline ? nil : current + } + guard let baseline, let current else { + return current == baseline ? saved : current + } + return FeatureComposerWorkspaceDraft( + mode: current.mode == baseline.mode ? saved.mode : current.mode, + branch: current.branch == baseline.branch ? saved.branch : current.branch, + worktreePath: current.worktreePath == baseline.worktreePath + ? saved.worktreePath + : current.worktreePath, + startFromOrigin: current.startFromOrigin == baseline.startFromOrigin + ? saved.startFromOrigin + : current.startFromOrigin + ) + } +} + +/// A recycled transcript surface. SwiftUI still owns each message's rendering, +/// while UIKit keeps offscreen messages out of the active view hierarchy. +private struct FeatureTranscriptCollectionView: UIViewRepresentable { + private static let workingIndicatorID = "__t3-working-indicator__" + private static let loadEarlierID = "__t3-load-earlier__" + + private enum Section: Hashable { + case transcript + } + + let threadID: String + let messages: [FeatureMessage] + let imageContext: MarkdownImageContext? + let attachmentContext: FeatureAttachmentContext? + let skills: [FeatureProviderSkill] + let renderUpdate: FeatureDetailRenderUpdate? + let dynamicTypeSize: DynamicTypeSize + let codeSizeSteps: Int + let isWorking: Bool + let isCompacting: Bool + let activeSubagentCount: Int + let backgroundWorkIsActive: Bool + let isMonitoring: Bool + let canLoadEarlier: Bool + let isLoadingEarlier: Bool + let onLoadEarlier: () -> Void + let onDismissKeyboard: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UICollectionView { + let collectionView = BottomAnchoredTranscriptCollectionView( + frame: .zero, + collectionViewLayout: Self.makeLayout() + ) + collectionView.backgroundColor = T3Colors.uiBackground + collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .onDrag + collectionView.delaysContentTouches = false + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.isPrefetchingEnabled = true + collectionView.accessibilityIdentifier = "thread-transcript" + context.coordinator.connect(to: collectionView) + return collectionView + } + + func updateUIView(_ collectionView: UICollectionView, context: Context) { + context.coordinator.update( + threadID: threadID, + messages: messages, + imageContext: imageContext, + attachmentContext: attachmentContext, + skills: skills, + renderUpdate: renderUpdate, + dynamicTypeSize: dynamicTypeSize, + codeSizeSteps: codeSizeSteps, + isWorking: isWorking, + isCompacting: isCompacting, + activeSubagentCount: activeSubagentCount, + backgroundWorkIsActive: backgroundWorkIsActive, + isMonitoring: isMonitoring, + canLoadEarlier: canLoadEarlier, + isLoadingEarlier: isLoadingEarlier, + onLoadEarlier: onLoadEarlier, + onDismissKeyboard: onDismissKeyboard, + in: collectionView + ) + } + + private static func makeLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout { _, environment in + let width = environment.container.effectiveContentSize.width + let sideInset = max(18, (width - T3Metrics.readingWidth) / 2) + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(120) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: itemSize, + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 22 + section.contentInsets = NSDirectionalEdgeInsets( + top: 18, + leading: sideInset, + bottom: 14, + trailing: sideInset + ) + return section + } + } + + @MainActor + final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, UICollectionViewDelegate { + private struct MarkdownPrefetch { + let revision: MarkdownContentRevision + let task: Task + } + + private var dataSource: UICollectionViewDiffableDataSource? + private var messagesByID: [String: FeatureMessage] = [:] + private var orderedIDs: [String] = [] + private var currentThreadID: String? + private var currentImageContext: MarkdownImageContext? + private var currentAttachmentContext: FeatureAttachmentContext? + private var currentSkills: [FeatureProviderSkill] = [] + private var currentDetailRevision: UInt64? + private var currentDynamicTypeSize: DynamicTypeSize? + private var currentCodeSizeSteps = 0 + private var currentIsWorking = false + private var currentIsCompacting = false + private var currentActiveSubagentCount = 0 + private var currentBackgroundWorkIsActive = false + private var currentIsMonitoring = false + private var currentCanLoadEarlier = false + private var currentIsLoadingEarlier = false + private var markdownPrefetches: [String: MarkdownPrefetch] = [:] + private var onLoadEarlier: (() -> Void)? + private var onDismissKeyboard: (() -> Void)? + + deinit { + markdownPrefetches.values.forEach { $0.task.cancel() } + } + + func connect(to collectionView: UICollectionView) { + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, messageID in + if messageID == FeatureTranscriptCollectionView.loadEarlierID { + cell.contentConfiguration = UIHostingConfiguration { + FeatureLoadEarlierTurnsButton( + isLoading: self?.currentIsLoadingEarlier == true, + onLoad: { self?.onLoadEarlier?() } + ) + } + .margins(.all, 0) + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessibilityIdentifier = "load-earlier-turns" + return + } + if messageID == FeatureTranscriptCollectionView.workingIndicatorID { + cell.contentConfiguration = UIHostingConfiguration { + FeatureThreadWorkingIndicator( + isCompacting: self?.currentIsCompacting == true, + activeSubagentCount: self?.currentActiveSubagentCount ?? 0, + backgroundWorkIsActive: self?.currentBackgroundWorkIsActive == true, + isMonitoring: self?.currentIsMonitoring == true + ) + } + .margins(.all, 0) + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessibilityIdentifier = "thread-working-indicator" + return + } + guard let message = self?.messagesByID[messageID] else { + cell.contentConfiguration = nil + return + } + + cell.contentConfiguration = UIHostingConfiguration { + FeatureMessageView( + message: message, + imageContext: self?.currentImageContext, + attachmentContext: self?.currentAttachmentContext, + skills: self?.currentSkills ?? [] + ) + .frame(maxWidth: .infinity, alignment: .leading) + .environment(\.t3CodeSizeSteps, self?.currentCodeSizeSteps ?? 0) + } + .margins(.all, 0) + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessibilityIdentifier = "message-cell-\(messageID)" + } + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, messageID in + collectionView.dequeueConfiguredReusableCell( + using: registration, + for: indexPath, + item: messageID + ) + } + collectionView.prefetchDataSource = self + collectionView.delegate = self + } + + func update( + threadID: String, + messages: [FeatureMessage], + imageContext: MarkdownImageContext?, + attachmentContext: FeatureAttachmentContext?, + skills: [FeatureProviderSkill], + renderUpdate: FeatureDetailRenderUpdate?, + dynamicTypeSize: DynamicTypeSize, + codeSizeSteps: Int, + isWorking: Bool, + isCompacting: Bool, + activeSubagentCount: Int, + backgroundWorkIsActive: Bool, + isMonitoring: Bool, + canLoadEarlier: Bool, + isLoadingEarlier: Bool, + onLoadEarlier: @escaping () -> Void, + onDismissKeyboard: @escaping () -> Void, + in collectionView: UICollectionView + ) { + guard let dataSource else { return } + self.onLoadEarlier = onLoadEarlier + self.onDismissKeyboard = onDismissKeyboard + + let threadChanged = currentThreadID != threadID + let imageContextChanged = currentImageContext != imageContext + || currentAttachmentContext != attachmentContext + let skillsChanged = currentSkills != skills + let typeSizeChanged = currentDynamicTypeSize != dynamicTypeSize + || currentCodeSizeSteps != codeSizeSteps + let revisionChanged = currentDetailRevision != renderUpdate?.revision + let workingChanged = currentIsWorking != isWorking + let workingDetailChanged = currentIsCompacting != isCompacting + || currentActiveSubagentCount != activeSubagentCount + || currentBackgroundWorkIsActive != backgroundWorkIsActive + || currentIsMonitoring != isMonitoring + let loadEarlierChanged = currentCanLoadEarlier != canLoadEarlier + || currentIsLoadingEarlier != isLoadingEarlier + guard threadChanged || imageContextChanged || skillsChanged || typeSizeChanged + || revisionChanged || workingChanged + || workingDetailChanged || loadEarlierChanged else { return } + + let incremental = !threadChanged + ? incrementalState(messages: messages, renderUpdate: renderUpdate) + : nil + let state = incremental ?? fullState(messages: messages) + let newIDs = state.ids + let idsChanged = state.idsChanged + let changedIDs = typeSizeChanged || imageContextChanged || skillsChanged + ? newIDs + : state.changedIDs + + currentImageContext = imageContext + currentAttachmentContext = attachmentContext + currentSkills = skills + currentDetailRevision = renderUpdate?.revision + currentDynamicTypeSize = dynamicTypeSize + currentCodeSizeSteps = codeSizeSteps + currentIsWorking = isWorking + currentIsCompacting = isCompacting + currentActiveSubagentCount = activeSubagentCount + currentBackgroundWorkIsActive = backgroundWorkIsActive + currentIsMonitoring = isMonitoring + currentCanLoadEarlier = canLoadEarlier + currentIsLoadingEarlier = isLoadingEarlier + guard threadChanged || idsChanged || !changedIDs.isEmpty || workingChanged + || workingDetailChanged || loadEarlierChanged else { return } + + if threadChanged { + cancelAllMarkdownPrefetches() + } else { + var invalidatedIDs = Set(changedIDs) + if idsChanged, !state.isAppendOnly { + invalidatedIDs.formUnion(Set(orderedIDs).subtracting(newIDs)) + } + cancelMarkdownPrefetches(for: invalidatedIDs) + } + + let wasNearBottom = isNearBottom(collectionView) + let lastIDChanged = orderedIDs.last != newIDs.last || workingChanged + let isInitialLoad = currentThreadID == nil || threadChanged + let previousIDs = orderedIDs + let prependedMessages = !threadChanged + && newIDs.count > previousIDs.count + && Array(newIDs.suffix(previousIDs.count)) == previousIDs + let shouldFollowBottom = isInitialLoad || wasNearBottom + let prependAnchor = !shouldFollowBottom + && (prependedMessages || (loadEarlierChanged && !canLoadEarlier)) + ? visibleAnchor(in: collectionView, dataSource: dataSource) + : nil + + currentThreadID = threadID + if let replacementMessagesByID = state.replacementMessagesByID { + messagesByID = replacementMessagesByID + } + orderedIDs = newIDs + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = + isInitialLoad || wasNearBottom + + var snapshot: NSDiffableDataSourceSnapshot + if threadChanged || loadEarlierChanged { + snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.transcript]) + if canLoadEarlier { + snapshot.appendItems( + [FeatureTranscriptCollectionView.loadEarlierID], + toSection: .transcript + ) + } + snapshot.appendItems(newIDs, toSection: .transcript) + } else if !idsChanged { + snapshot = dataSource.snapshot() + } else if state.isAppendOnly { + snapshot = dataSource.snapshot() + snapshot.appendItems(state.appendedIDs, toSection: .transcript) + } else if newIDs.starts(with: previousIDs) { + snapshot = dataSource.snapshot() + snapshot.appendItems(Array(newIDs.dropFirst(previousIDs.count)), toSection: .transcript) + } else { + snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.transcript]) + if canLoadEarlier { + snapshot.appendItems( + [FeatureTranscriptCollectionView.loadEarlierID], + toSection: .transcript + ) + } + snapshot.appendItems(newIDs, toSection: .transcript) + } + if snapshot.indexOfItem(FeatureTranscriptCollectionView.workingIndicatorID) != nil { + snapshot.deleteItems([FeatureTranscriptCollectionView.workingIndicatorID]) + } + if isWorking { + snapshot.appendItems( + [FeatureTranscriptCollectionView.workingIndicatorID], + toSection: .transcript + ) + } + let appendedIDSet = Set(state.appendedIDs) + var reconfiguredIDs = changedIDs.filter { !appendedIDSet.contains($0) } + if loadEarlierChanged, + snapshot.indexOfItem(FeatureTranscriptCollectionView.loadEarlierID) != nil { + reconfiguredIDs.append(FeatureTranscriptCollectionView.loadEarlierID) + } + if workingDetailChanged, + snapshot.indexOfItem(FeatureTranscriptCollectionView.workingIndicatorID) != nil { + reconfiguredIDs.append(FeatureTranscriptCollectionView.workingIndicatorID) + } + if !reconfiguredIDs.isEmpty { + snapshot.reconfigureItems(reconfiguredIDs) + } + + dataSource.apply(snapshot, animatingDifferences: false) { + [weak self, weak collectionView] in + guard let self, let collectionView else { return } + DispatchQueue.main.async { + if shouldFollowBottom { + self.scrollToBottom( + collectionView, + animated: !isInitialLoad && lastIDChanged + ) + } else if let prependAnchor { + self.restore(prependAnchor, in: collectionView, dataSource: dataSource) + } + } + } + } + + private struct VisibleAnchor { + let id: String + let offsetFromViewportTop: CGFloat + } + + private func visibleAnchor( + in collectionView: UICollectionView, + dataSource: UICollectionViewDiffableDataSource + ) -> VisibleAnchor? { + for indexPath in collectionView.indexPathsForVisibleItems.sorted() { + guard let id = dataSource.itemIdentifier(for: indexPath), + id != FeatureTranscriptCollectionView.loadEarlierID, + id != FeatureTranscriptCollectionView.workingIndicatorID, + let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + continue + } + return VisibleAnchor( + id: id, + offsetFromViewportTop: attributes.frame.minY - collectionView.contentOffset.y + ) + } + return nil + } + + private func restore( + _ anchor: VisibleAnchor, + in collectionView: UICollectionView, + dataSource: UICollectionViewDiffableDataSource + ) { + collectionView.layoutIfNeeded() + guard let indexPath = dataSource.indexPath(for: anchor.id), + let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + return + } + let minimumY = -collectionView.adjustedContentInset.top + let maximumY = max( + minimumY, + collectionView.contentSize.height + - collectionView.bounds.height + + collectionView.adjustedContentInset.bottom + ) + let targetY = min( + maximumY, + max(minimumY, attributes.frame.minY - anchor.offsetFromViewportTop) + ) + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false + collectionView.setContentOffset( + CGPoint(x: collectionView.contentOffset.x, y: targetY), + animated: false + ) + } + + private struct MessageState { + let ids: [String] + let replacementMessagesByID: [String: FeatureMessage]? + let changedIDs: [String] + let appendedIDs: [String] + let idsChanged: Bool + let isAppendOnly: Bool + } + + private func incrementalState( + messages: [FeatureMessage], + renderUpdate: FeatureDetailRenderUpdate? + ) -> MessageState? { + guard let currentDetailRevision, + let renderUpdate, + renderUpdate.baseRevision == currentDetailRevision, + case let .delta(delta) = renderUpdate.change, + messages.count == orderedIDs.count + delta.appendedMessageIDs.count else { + return nil + } + + let appendedIDs = delta.appendedMessageIDs + guard Set(appendedIDs).count == appendedIDs.count, + appendedIDs.allSatisfy({ messagesByID[$0] == nil }) else { + return nil + } + + let appendedIDSet = Set(appendedIDs) + let changedMessageIDs = Set(delta.changedMessages.map(\.id)) + guard appendedIDs.allSatisfy(changedMessageIDs.contains), + delta.changedMessages.allSatisfy({ + messagesByID[$0.id] != nil || appendedIDSet.contains($0.id) + }) else { + return nil + } + + var changedIDs: [String] = [] + changedIDs.reserveCapacity(delta.changedMessages.count) + for message in delta.changedMessages { + if messagesByID[message.id] != message { + changedIDs.append(message.id) + } + messagesByID[message.id] = message + } + + return MessageState( + ids: appendedIDs.isEmpty ? orderedIDs : orderedIDs + appendedIDs, + replacementMessagesByID: nil, + changedIDs: changedIDs, + appendedIDs: appendedIDs, + idsChanged: !appendedIDs.isEmpty, + isAppendOnly: !appendedIDs.isEmpty + ) + } + + private func fullState(messages: [FeatureMessage]) -> MessageState { + var seenMessageIDs = Set() + let uniqueMessages = Array(messages.reversed().filter { + seenMessageIDs.insert($0.id).inserted + }.reversed()) + let ids = uniqueMessages.map(\.id) + let updatedMessages = uniqueMessages.reduce(into: [String: FeatureMessage]()) { + $0[$1.id] = $1 + } + return MessageState( + ids: ids, + replacementMessagesByID: updatedMessages, + changedIDs: ids.filter { messagesByID[$0] != updatedMessages[$0] }, + appendedIDs: [], + idsChanged: orderedIDs != ids, + isAppendOnly: false + ) + } + + func collectionView( + _ collectionView: UICollectionView, + prefetchItemsAt indexPaths: [IndexPath] + ) { + for indexPath in indexPaths where orderedIDs.indices.contains(indexPath.item) { + let messageID = orderedIDs[indexPath.item] + guard markdownPrefetches[messageID] == nil, + let message = messagesByID[messageID], + !message.text.isEmpty, + message.state != .streaming, + message.role == .user || message.role == .assistant else { + continue + } + + let revision = MarkdownContentRevision(message.text) + guard MarkdownRenderCache.shared.cachedDocument(for: revision) == nil else { + continue + } + + let task = Task { [weak self] in + guard !Task.isCancelled else { return } + _ = await MarkdownRenderCache.shared.document(for: revision) + guard !Task.isCancelled else { return } + self?.finishMarkdownPrefetch(messageID: messageID, revision: revision) + } + markdownPrefetches[messageID] = MarkdownPrefetch( + revision: revision, + task: task + ) + } + } + + func collectionView( + _ collectionView: UICollectionView, + cancelPrefetchingForItemsAt indexPaths: [IndexPath] + ) { + let messageIDs = indexPaths.compactMap { indexPath in + orderedIDs.indices.contains(indexPath.item) ? orderedIDs[indexPath.item] : nil + } + cancelMarkdownPrefetches(for: Set(messageIDs)) + } + + private func finishMarkdownPrefetch( + messageID: String, + revision: MarkdownContentRevision + ) { + guard markdownPrefetches[messageID]?.revision == revision else { return } + markdownPrefetches.removeValue(forKey: messageID) + } + + private func cancelMarkdownPrefetches(for messageIDs: Set) { + for messageID in messageIDs { + markdownPrefetches.removeValue(forKey: messageID)?.task.cancel() + } + } + + private func cancelAllMarkdownPrefetches() { + markdownPrefetches.values.forEach { $0.task.cancel() } + markdownPrefetches.removeAll(keepingCapacity: true) + } + + private func isNearBottom(_ collectionView: UICollectionView) -> Bool { + let visibleBottom = collectionView.contentOffset.y + + collectionView.bounds.height + - collectionView.adjustedContentInset.bottom + return collectionView.contentSize.height - visibleBottom < 120 + } + + private func scrollToBottom( + _ collectionView: UICollectionView, + animated: Bool + ) { + collectionView.layoutIfNeeded() + let geometry = TranscriptViewportGeometry( + contentHeight: collectionView.contentSize.height, + viewportHeight: collectionView.bounds.height, + topInset: collectionView.adjustedContentInset.top, + bottomInset: collectionView.adjustedContentInset.bottom + ) + let target = CGPoint(x: collectionView.contentOffset.x, y: geometry.bottomOffset) + collectionView.setContentOffset(target, animated: animated) + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = true + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + (scrollView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false + onDismissKeyboard?() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard !decelerate else { return } + updateBottomAnchor(for: scrollView) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateBottomAnchor(for: scrollView) + } + + private func updateBottomAnchor(for scrollView: UIScrollView) { + guard let collectionView = scrollView as? BottomAnchoredTranscriptCollectionView else { + return + } + collectionView.maintainsBottomAnchor = isNearBottom(collectionView) + } + } +} + +private struct FeatureLoadEarlierTurnsButton: View { + let isLoading: Bool + let onLoad: () -> Void + + var body: some View { + Button(action: onLoad) { + HStack(spacing: 7) { + if isLoading { + Image(systemName: "ellipsis") + .font(T3Typography.supporting.weight(.semibold)) + } + Text(isLoading ? "Loading earlier turns…" : "Load earlier turns") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity) + .frame(minHeight: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .disabled(isLoading) + .accessibilityLabel(isLoading ? "Loading earlier turns" : "Load earlier turns") + } +} + +private struct FeatureThreadWorkingIndicator: View { + let isCompacting: Bool + let activeSubagentCount: Int + let backgroundWorkIsActive: Bool + let isMonitoring: Bool + + private var title: String { + if isCompacting { + return "Compacting context" + } + if isMonitoring { + return "Monitoring in the background" + } + if activeSubagentCount == 1 { + return "1 subagent is working" + } + if activeSubagentCount > 1 { + return "\(activeSubagentCount) subagents are working" + } + return backgroundWorkIsActive ? "Background work is running" : "Agent is working" + } + + private var detail: String? { + isCompacting || backgroundWorkIsActive || isMonitoring ? nil : "New output will appear here" + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: isCompacting ? "arrow.down.right.and.arrow.up.left" : "circle.dotted") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(T3Colors.statusRunning) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.statusRunning) + if let detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(detail.map { "\(title). \($0)." } ?? "\(title).") + } +} + +struct TranscriptViewportGeometry: Equatable { + let contentHeight: CGFloat + let viewportHeight: CGFloat + let topInset: CGFloat + let bottomInset: CGFloat + + var bottomOffset: CGFloat { + max(-topInset, contentHeight - viewportHeight + bottomInset) + } + + func restoredBottomOffset( + after previous: Self?, + maintainsBottomAnchor: Bool, + isInteracting: Bool + ) -> CGFloat? { + guard maintainsBottomAnchor, !isInteracting else { + return nil + } + + guard let previous, + previous.contentHeight > 0, + previous.viewportHeight > 0 else { + return contentHeight > 0 && viewportHeight > 0 ? bottomOffset : nil + } + + let contentChanged = abs(contentHeight - previous.contentHeight) > 0.5 + let viewportChanged = abs(viewportHeight - previous.viewportHeight) > 0.5 + || abs(bottomInset - previous.bottomInset) > 0.5 + guard contentChanged || viewportChanged else { return nil } + + return bottomOffset + } +} + +/// The detail surface uses a native pan recognizer instead of a SwiftUI +/// `DragGesture`. SwiftUI's broad drag recognizer can begin before it knows +/// whether a gesture is vertical, which competes with the transcript's native +/// collection-view scrolling. This recognizer fails for vertical motion at +/// gesture-begin time and remains simultaneous with the collection view for +/// horizontal motion. +enum ThreadBackSwipeGesture { + static let minimumTranslation: CGFloat = 72 + static let horizontalToVerticalRatio: CGFloat = 1.4 + private static let scrollExtentEpsilon: CGFloat = 1 + + static func shouldBegin(with velocity: CGPoint) -> Bool { + shouldBegin(with: velocity, translation: .zero) + } + + static func shouldBegin(with velocity: CGPoint, translation: CGPoint) -> Bool { + let direction = hypot(translation.x, translation.y) >= 8 ? translation : velocity + return direction.x > 0 + && direction.x >= abs(direction.y) * horizontalToVerticalRatio + } + + static func shouldNavigateBack(with translation: CGPoint) -> Bool { + translation.x >= minimumTranslation + && translation.x >= abs(translation.y) * horizontalToVerticalRatio + } + + @MainActor + static func shouldAllowSimultaneousRecognition(with scrollView: UIScrollView) -> Bool { + let hasHorizontalContent = scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width + > scrollView.bounds.width + scrollExtentEpsilon + guard hasHorizontalContent else { + return scrollView.alwaysBounceVertical + || scrollView.contentSize.height + > scrollView.bounds.height + scrollExtentEpsilon + } + return isAtLeadingEdge(scrollView) + } + + @MainActor + static func shouldReceiveTouch(in view: UIView?, host: UIView) -> Bool { + var currentView = view + while let current = currentView { + // Editable text and an active transcript selection need to own + // horizontal drags for caret and selection-handle movement. Plain + // rendered message text still participates in the full-surface pan. + if current is UITextField { + return false + } + if let textView = current as? UITextView, + textView.isEditable || textView.isFirstResponder { + return false + } + if let scrollView = current as? UIScrollView, + scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width + > scrollView.bounds.width + scrollExtentEpsilon { + guard isAtLeadingEdge(scrollView) else { return false } + } + if current === host { return true } + currentView = current.superview + } + return false + } + + @MainActor + private static func isAtLeadingEdge(_ scrollView: UIScrollView) -> Bool { + scrollView.contentOffset.x + <= -scrollView.adjustedContentInset.left + scrollExtentEpsilon + } + + @MainActor + static func shouldReceiveTouch( + _ touch: UITouch, + surface: UIView, + host: UIView + ) -> Bool { + guard surface.window === host.window, + surface.bounds.contains(touch.location(in: surface)), + shouldReceiveTouch(in: touch.view, host: host), + surface.window?.rootViewController?.presentedViewController == nil else { + return false + } + return true + } +} + +private struct ThreadBackSwipeGestureView: UIViewRepresentable { + let isEnabled: Bool + let onNavigateBack: () -> Void + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.update(isEnabled: isEnabled, onNavigateBack: onNavigateBack) + return view + } + + func updateUIView(_ view: InstallerView, context: Context) { + view.update(isEnabled: isEnabled, onNavigateBack: onNavigateBack) + } + + static func dismantleUIView(_ view: InstallerView, coordinator: ()) { + view.uninstallGesture() + } + + final class InstallerView: UIView { + private var isEnabled = false + private var onNavigateBack: (() -> Void)? + private weak var gestureHost: UIView? + private var panGesture: UIPanGestureRecognizer? + private var gestureDelegate: GestureDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + uninstallGesture() + } else { + installGestureIfPossible() + } + } + + func update(isEnabled: Bool, onNavigateBack: @escaping () -> Void) { + self.isEnabled = isEnabled + self.onNavigateBack = onNavigateBack + installGestureIfPossible() + } + + func uninstallGesture() { + if let panGesture, let gestureHost { + gestureHost.removeGestureRecognizer(panGesture) + } + panGesture = nil + gestureDelegate = nil + gestureHost = nil + } + + private func installGestureIfPossible() { + // SwiftUI hosts a background UIViewRepresentable beside, rather than + // above, the transcript and composer. Install on their shared root + // view and use the representable's frame to scope received touches. + guard isEnabled, let window, let host = window.rootViewController?.view else { + if !isEnabled { uninstallGesture() } + return + } + guard gestureHost !== host else { return } + + uninstallGesture() + let panGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handlePan(_:)) + ) + let gestureDelegate = GestureDelegate(owner: self) + panGesture.delegate = gestureDelegate + panGesture.cancelsTouchesInView = false + panGesture.delaysTouchesBegan = false + panGesture.maximumNumberOfTouches = 1 + host.addGestureRecognizer(panGesture) + gestureHost = host + self.panGesture = panGesture + self.gestureDelegate = gestureDelegate + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard isEnabled, + gesture.state == .ended, + ThreadBackSwipeGesture.shouldNavigateBack( + with: gesture.translation(in: gesture.view) + ) else { + return + } + onNavigateBack?() + } + + private final class GestureDelegate: NSObject, UIGestureRecognizerDelegate { + weak var owner: InstallerView? + + init(owner: InstallerView) { + self.owner = owner + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let owner, + owner.isEnabled, + let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { + return false + } + return ThreadBackSwipeGesture.shouldBegin( + with: panGesture.velocity(in: panGesture.view), + translation: panGesture.translation(in: panGesture.view) + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard let owner, + let gestureHost = owner.gestureHost, + ThreadBackSwipeGesture.shouldReceiveTouch( + touch, + surface: owner, + host: gestureHost + ) + else { return false } + return true + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + if otherGestureRecognizer is UIScreenEdgePanGestureRecognizer { + return true + } + guard let scrollView = otherGestureRecognizer.view as? UIScrollView else { + return false + } + return ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition( + with: scrollView + ) + } + } + } +} + +/// Self-sizing hosted Markdown can change the transcript height after a snapshot finishes, +/// while presenting the keyboard changes the viewport without changing the content at all. +/// Preserve the visual bottom only while the reader is already following the latest turn. +private final class BottomAnchoredTranscriptCollectionView: UICollectionView { + var maintainsBottomAnchor = false + + private var lastLaidOutGeometry: TranscriptViewportGeometry? + private var isRestoringBottomAnchor = false + + override func layoutSubviews() { + super.layoutSubviews() + + let geometry = TranscriptViewportGeometry( + contentHeight: contentSize.height, + viewportHeight: bounds.height, + topInset: adjustedContentInset.top, + bottomInset: adjustedContentInset.bottom + ) + defer { lastLaidOutGeometry = geometry } + + guard let bottomY = geometry.restoredBottomOffset( + after: lastLaidOutGeometry, + maintainsBottomAnchor: maintainsBottomAnchor, + isInteracting: isDragging || isDecelerating || isRestoringBottomAnchor + ) else { + return + } + guard abs(contentOffset.y - bottomY) > 0.5 else { return } + + isRestoringBottomAnchor = true + contentOffset = CGPoint(x: contentOffset.x, y: bottomY) + isRestoringBottomAnchor = false + } +} + +private struct FeatureRemoteAttachmentThumbnail: View { + private struct Request: Hashable { + let url: URL + let maximumPixelSize: Int + } + + @SwiftUI.Environment(\.displayScale) private var displayScale + @State private var image: UIImage? + @State private var loadedRequest: Request? + @State private var failedRequest: Request? + + let url: URL + + var body: some View { + Group { + if loadedRequest == request, let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else if failedRequest == request { + placeholder(systemImage: "exclamationmark.triangle") + } else { + placeholder(systemImage: "photo") + } + } + .accessibilityHidden(true) + .task(id: request) { + let activeRequest = request + do { + let image = try await FeatureAttachmentThumbnailLoader.image( + for: activeRequest.url, + maximumPixelSize: activeRequest.maximumPixelSize + ) + try Task.checkCancellation() + self.image = image + loadedRequest = activeRequest + failedRequest = nil + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + image = nil + loadedRequest = nil + failedRequest = activeRequest + } + } + } + + private var request: Request { + Request( + url: url, + maximumPixelSize: min(768, max(190, Int(ceil(190 * displayScale)))) + ) + } + + private func placeholder(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Local preview bytes routed through the shared thumbnail cache so streaming +/// reconfigures of a message with attachments never re-allocate UIImages in +/// body. Decode happens once, off the main thread. +private struct FeatureLocalAttachmentThumbnail: View { + let attachmentID: String + let previewData: Data + + @State private var image: UIImage? + @State private var failed = false + + private var cacheKey: NSString { "local:\(attachmentID)" as NSString } + + var body: some View { + Group { + if let image = image ?? FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else if failed { + placeholder(systemImage: "exclamationmark.triangle") + } else { + placeholder(systemImage: "photo") + } + } + .accessibilityHidden(true) + .task(id: attachmentID) { + guard FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) == nil else { return } + let data = previewData + let decoded = await Task.detached(priority: .utility) { + UIImage(data: data) + }.value + guard !Task.isCancelled else { return } + if let decoded { + FeatureAttachmentThumbnailCache.shared.insert(decoded, for: cacheKey) + image = decoded + } else { + failed = true + } + } + } + + private func placeholder(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private enum FeatureAttachmentThumbnailLoader { + static func image(for url: URL, maximumPixelSize: Int) async throws -> UIImage { + let cacheKey = "\(url.absoluteString)#\(maximumPixelSize)" as NSString + if let cached = FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) { + return cached + } + + let (data, response) = try await URLSession.shared.data(from: url) + try Task.checkCancellation() + if let response = response as? HTTPURLResponse, + !(200...299).contains(response.statusCode) { + throw FeatureAttachmentThumbnailError.invalidResponse + } + + let image = try await Task.detached(priority: .utility) { + try downsample(data: data, maximumPixelSize: maximumPixelSize) + }.value + try Task.checkCancellation() + FeatureAttachmentThumbnailCache.shared.insert(image, for: cacheKey) + return image + } + + private static func downsample(data: Data, maximumPixelSize: Int) throws -> UIImage { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + throw FeatureAttachmentThumbnailError.decodingFailed + } + + let thumbnailOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + thumbnailOptions + ) else { + throw FeatureAttachmentThumbnailError.decodingFailed + } + return UIImage(cgImage: thumbnail) + } +} + +private final class FeatureAttachmentThumbnailCache: @unchecked Sendable { + static let shared = FeatureAttachmentThumbnailCache() + + private let images = NSCache() + + private init() { + images.countLimit = 96 + images.totalCostLimit = 32 * 1_024 * 1_024 + } + + func image(for key: NSString) -> UIImage? { + images.object(forKey: key) + } + + func insert(_ image: UIImage, for key: NSString) { + let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0 + images.setObject(image, forKey: key, cost: cost) + } +} + +private enum FeatureAttachmentThumbnailError: Error { + case invalidResponse + case decodingFailed +} + +struct FeatureMessageView: View { + let message: FeatureMessage + var imageContext: MarkdownImageContext? = nil + var attachmentContext: FeatureAttachmentContext? = nil + var skills: [FeatureProviderSkill] = [] + + var body: some View { + switch message.role { + case .user: + HStack { + Spacer(minLength: 44) + VStack(alignment: .leading, spacing: 10) { + FeatureMessageAttachmentsView(attachments: message.attachments, context: attachmentContext) + if !message.text.isEmpty { + MarkdownMessageView( + message.text, + isStreaming: message.state == .streaming, + imageContext: imageContext, + skills: skills + ) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: T3Metrics.readingWidth * 0.88, alignment: .leading) + .background( + T3Colors.subtleStrong, + in: UnevenRoundedRectangle( + topLeadingRadius: 16, + bottomLeadingRadius: 16, + bottomTrailingRadius: 4, + topTrailingRadius: 16 + ) + ) + } + .accessibilityLabel("You") + .accessibilityValue(accessibilityValue) + .accessibilityIdentifier("message-\(message.id)") + case .assistant: + VStack(alignment: .leading, spacing: 10) { + if message.state == .streaming { + HStack(spacing: 6) { + Image(systemName: "circle.dotted") + Text("Working") + } + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.statusRunning) + } + FeatureMessageAttachmentsView(attachments: message.attachments, context: attachmentContext) + if !message.text.isEmpty { + MarkdownMessageView( + message.text, + isStreaming: message.state == .streaming, + imageContext: imageContext, + skills: skills + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("message-\(message.id)") + case .tool: + FeatureWorkLogView(message: message, imageContext: imageContext) + .id(message.id) + case .system: + systemMessage + .accessibilityIdentifier("message-\(message.id)") + } + } + + @ViewBuilder + private var systemMessage: some View { + if message.toolName == "runtime.warning" { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(T3Colors.warning) + VStack(alignment: .leading, spacing: 5) { + Text(message.text) + .foregroundStyle(T3Colors.textPrimary) + .textSelection(.enabled) + Text(message.createdAt, format: .dateTime.month(.abbreviated).day().hour().minute()) + .foregroundStyle(T3Colors.textSecondary) + } + } + .font(T3Typography.supporting) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } else if message.toolName == "context-compaction" { + Label(message.text, systemImage: "arrow.down.right.and.arrow.up.left") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) + } else { + Text(message.text) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private var accessibilityValue: String { + let attachmentSummary = message.attachments.isEmpty + ? "" + : "\(message.attachments.count) image attachment" + + (message.attachments.count == 1 ? "" : "s") + return [message.text, attachmentSummary] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } +} + +private struct FeatureWorkLogView: View { + let message: FeatureMessage + let imageContext: MarkdownImageContext? + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + FeatureToolActivityIcon(presentation: message.toolPresentation, context: imageContext) + Text(message.toolName ?? "Tool output") + if let source = message.toolPresentation?.sourceName { + Text(source).lineLimit(1) + } + } + if let activeWorkLabel = message.activeWorkLabel { + Text(activeWorkLabel) + .lineLimit(1) + .foregroundStyle(T3Colors.statusRunning) + } + } + Spacer(minLength: 8) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + } + .font(T3Typography.tool.weight(.medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + .accessibilityIdentifier("work-log-toggle-\(message.id)") + + if isExpanded { + Text(message.text) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + .lineSpacing(3) + .textSelection(.enabled) + .padding(.top, 8) + .t3CodeTextSize() + .transition(.identity) + if FeatureWorkLogMedia.shouldRenderImages( + isExpanded: isExpanded, + paths: message.workLogImagePaths ?? [] + ) { + MarkdownMessageView( + FeatureWorkLogMedia.markdownSource( + for: message.workLogImagePaths ?? [] + ), + imageContext: imageContext + ) + .padding(.top, 8) + } + } + } + .padding(.vertical, 6) + .accessibilityIdentifier("message-\(message.id)") + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } +} + +enum FeatureWorkLogMedia { + static func shouldRenderImages(isExpanded: Bool, paths: [String]) -> Bool { + isExpanded && !paths.isEmpty + } + + static func markdownSource(for paths: [String]) -> String { + paths.prefix(8).compactMap { path in + guard let escaped = path.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed.subtracting( + CharacterSet(charactersIn: "()<>[]!\\\"' #%?\n\r") + ) + ) else { return nil } + return "![](\(escaped))" + }.joined(separator: "\n\n") + } +} + +private struct FeatureMessageAttachmentsView: View { + let attachments: [FeatureMessageAttachment] + let context: FeatureAttachmentContext? + @State private var previewedAttachment: FeatureMessageAttachment? + + var body: some View { + if !attachments.isEmpty { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 118, maximum: 190), spacing: 7)], + alignment: .leading, + spacing: 7 + ) { + ForEach(attachments) { attachment in + FeatureMessageAttachmentView(attachment: attachment, context: context) { + previewedAttachment = $0 + } + .id("\(context?.threadID ?? ""):attachment:\(attachment.id)") + } + } + .fullScreenCover(item: $previewedAttachment) { attachment in + FeatureAttachmentPreview(attachment: attachment) + } + } + } +} + +private struct FeatureMessageAttachmentView: View { + let attachment: FeatureMessageAttachment + let context: FeatureAttachmentContext? + let onPreview: (FeatureMessageAttachment) -> Void + @State private var resolvedURL: URL? + @State private var failed = false + @State private var isOpening = false + + private var isImage: Bool { attachment.mimeType.hasPrefix("image/") } + private var currentURL: URL? { resolvedURL ?? attachment.url } + private var hasLocalPreview: Bool { isImage && attachment.previewData != nil } + private var showsStatus: Bool { failed || isOpening || (currentURL == nil && !hasLocalPreview) } + private var statusText: String { failed ? "Couldn’t load. Tap to retry." : "Loading attachment…" } + private var canPreview: Bool { hasLocalPreview || currentURL != nil || context != nil } + private var sizeText: String { + ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if isImage { thumbnail } + HStack(spacing: 9) { + Image(systemName: isImage ? "photo" : "doc") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 30, height: 30) + .background(T3Colors.surfaceRaised, in: RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 1) { + Text(attachment.name) + .font(T3Typography.control) + .lineLimit(1) + Text(showsStatus ? statusText : sizeText) + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(7) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(T3Colors.border, lineWidth: 1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(isImage ? "Image attachment" : "File attachment") + .accessibilityValue("\(attachment.name), \(showsStatus ? statusText : sizeText)") + .accessibilityIdentifier("attachment-\(attachment.id)") + .accessibilityAddTraits(canPreview ? .isButton : []) + .accessibilityHint(canPreview ? "Opens full-screen preview" : "") + .accessibilityAction { openPreview() } + .contentShape(Rectangle()) + .onTapGesture { openPreview() } + .task { await resolveURL() } + .task(id: isOpening) { + guard isOpening else { return } + defer { isOpening = false } + // A row can stay mounted beyond a signed URL's expiry. + await resolveURL() + guard !Task.isCancelled, !failed, let currentURL else { return } + var preview = attachment + preview.url = currentURL + onPreview(preview) + } + } + + private var thumbnail: some View { + Group { + if let previewData = attachment.previewData { + FeatureLocalAttachmentThumbnail(attachmentID: attachment.id, previewData: previewData) + } else if let currentURL { + FeatureRemoteAttachmentThumbnail(url: currentURL) + } else { + Image(systemName: failed ? "exclamationmark.triangle" : "photo") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(height: 160) + .frame(maxWidth: .infinity) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func openPreview() { + if hasLocalPreview { + onPreview(attachment) + } else if canPreview { + isOpening = true + } + } + + private func resolveURL() async { + guard let context else { + failed = currentURL == nil && !hasLocalPreview + return + } + failed = false + do { + let url = try await context.resolver.attachmentAssetURL( + threadID: context.threadID, attachment: attachment + ) + try Task.checkCancellation() + resolvedURL = url + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + failed = true + } + } +} + +private struct FeatureAttachmentPreview: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let attachment: FeatureMessageAttachment + + var body: some View { + NavigationStack { + FeatureNativeMediaPreviewView( + source: attachment.previewData.map(FeatureMediaPreviewSource.localImage) + ?? attachment.url.map(FeatureMediaPreviewSource.remote) + ?? .localImage(Data()), + kind: FeatureLinkedMediaPreview.previewKind( + fileName: attachment.name, + mimeType: attachment.mimeType + ), + fileName: attachment.name + ) + .navigationTitle(attachment.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .t3NavigationChrome() + } + .preferredColorScheme(.dark) + } +} + +private struct FeatureLinkedMediaPreview: Identifiable { + let id = UUID() + let source: FeatureMediaPreviewSource + let kind: FeatureFilePreviewKind + let fileName: String + + static func previewKind(for url: URL) -> FeatureFilePreviewKind? { + let kind = FeatureFilePreviewKind.infer(path: url.path) + return switch kind { + case .image, .pdf, .video, .document: kind + case .markdown, .source, .plainText: nil + } + } + + static func previewKind(fileName: String, mimeType: String) -> FeatureFilePreviewKind { + if mimeType.hasPrefix("image/") { return .image } + if mimeType.hasPrefix("video/") { return .video } + if mimeType == "application/pdf" { return .pdf } + let inferred = FeatureFilePreviewKind.infer(path: fileName) + return inferred == .plainText ? .document : inferred + } +} diff --git a/apps/swift-ios/Features/Connection/ConnectionDetails.swift b/apps/swift-ios/Features/Connection/ConnectionDetails.swift new file mode 100644 index 000000000000..be783aafb219 --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionDetails.swift @@ -0,0 +1,282 @@ +import Foundation + +struct ConnectionDetails: Equatable, Sendable { + var endpoint: String + var pairingCode: String? +} + +enum ConnectionDetailsError: LocalizedError, Equatable { + case empty + case invalidAddress + case unsupportedScheme + + var errorDescription: String? { + switch self { + case .empty: + "Paste a T3 pairing link or enter your server address." + case .invalidAddress: + "That connection link does not include a valid server address." + case .unsupportedScheme: + "Use an HTTP or HTTPS T3 server address." + } + } +} + +/// Parses the pairing links emitted by local, shared, and hosted T3 environments. +enum ConnectionDetailsParser { + private static let tokenNames = [ + "token", + "pairing_token", + "pairingToken", + "pairing_code", + "pairingCode", + "code", + ] + private static let endpointNames = ["host", "endpoint", "server", "url"] + private static let wrappedPairingURLNames = ["pairingUrl", "pairing_url"] + + static func parse(_ input: String) throws -> ConnectionDetails { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ConnectionDetailsError.empty } + + let extracted = trimmed.lowercased().hasPrefix("t3code:") + ? trimmed + : (firstURL(in: trimmed) ?? trimmed) + let candidate = trimmingTrailingProsePunctuation(extracted) + let loweredCandidate = candidate.lowercased() + if candidate.contains("://") + || loweredCandidate.hasPrefix("t3code:") + || loweredCandidate.hasPrefix("t3:") { + return try parseURL(candidate) + } + + let pieces = candidate + .split(whereSeparator: \.isWhitespace) + .map(String.init) + guard let address = pieces.first else { throw ConnectionDetailsError.empty } + let code = pieces.dropFirst().first(where: { !$0.isEmpty }) + return ConnectionDetails( + endpoint: try normalizedEndpoint(address), + pairingCode: normalizedCode(code) + ) + } + + static func normalizedEndpoint(_ input: String) throws -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ConnectionDetailsError.empty } + + let value: String + if trimmed.contains("://") { + value = trimmed + } else { + let address = bracketBareIPv6(trimmed) + value = "\(EndpointNetworkScope.isLocalHost(trimmed) ? "http" : "https")://\(address)" + } + + guard var components = URLComponents(string: value), + let scheme = components.scheme?.lowercased() + else { + throw ConnectionDetailsError.invalidAddress + } + + switch scheme { + case "http", "https": + break + case "ws": + components.scheme = "http" + case "wss": + components.scheme = "https" + default: + throw ConnectionDetailsError.unsupportedScheme + } + + guard let host = components.host, !host.isEmpty else { + throw ConnectionDetailsError.invalidAddress + } + + components.path = "" + components.query = nil + components.fragment = nil + guard var normalized = components.url?.absoluteString else { + throw ConnectionDetailsError.invalidAddress + } + while normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } + + private static func parseURL(_ input: String) throws -> ConnectionDetails { + guard let components = URLComponents(string: input), + let scheme = components.scheme?.lowercased() + else { + throw ConnectionDetailsError.invalidAddress + } + + let fragmentItems = URLComponents(string: "?\(components.fragment ?? "")")?.queryItems ?? [] + let queryItems = components.queryItems ?? [] + let allItems = queryItems + fragmentItems + let token = firstValue(named: tokenNames, in: allItems) + + if ["t3", "t3code", "t3code-swiftui", "t3code-swiftui-dev"].contains(scheme) { + if let wrappedPairingURL = firstValue(named: wrappedPairingURLNames, in: allItems) { + var wrapped = try parse(wrappedPairingURL) + if wrapped.pairingCode == nil { + wrapped.pairingCode = normalizedCode(token) + } + return wrapped + } + guard let target = firstValue(named: endpointNames, in: allItems) else { + throw ConnectionDetailsError.invalidAddress + } + return ConnectionDetails( + endpoint: try normalizedEndpoint(target), + pairingCode: normalizedCode(token) + ) + } + + guard ["http", "https", "ws", "wss"].contains(scheme) else { + throw ConnectionDetailsError.unsupportedScheme + } + + let advertisedHost = firstValue(named: endpointNames, in: queryItems) + return ConnectionDetails( + endpoint: try normalizedEndpoint(advertisedHost ?? input), + pairingCode: normalizedCode(token) + ) + } + + private static func firstValue(named names: [String], in items: [URLQueryItem]) -> String? { + items.first { item in + names.contains { $0.caseInsensitiveCompare(item.name) == .orderedSame } + }?.value + } + + private static func normalizedCode(_ input: String?) -> String? { + let value = input?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } + + private static func firstURL(in input: String) -> String? { + guard let expression = try? NSRegularExpression( + pattern: #"(?i)(?:(?:https?|wss?|t3)://|t3code(?:-swiftui(?:-dev)?)?:(?://)?)[^\s<>"']+"# + ) else { + return nil + } + let range = NSRange(input.startIndex..., in: input) + guard let match = expression.firstMatch(in: input, range: range), + let swiftRange = Range(match.range, in: input) + else { + return nil + } + return trimmingTrailingProsePunctuation(String(input[swiftRange])) + } + + /// Pairing links are commonly copied from sentences and terminal output. + /// Remove punctuation belonging to that prose while retaining balanced URL + /// delimiters such as the closing bracket around an IPv6 host. + static func trimmingTrailingProsePunctuation(_ input: String) -> String { + var value = input + while let last = value.last { + let shouldTrim = switch last { + case ".", ",", ";", "!": + true + case "?": + value.dropLast().contains("?") + case ")": + value.filter { $0 == ")" }.count > value.filter { $0 == "(" }.count + case "]": + value.filter { $0 == "]" }.count > value.filter { $0 == "[" }.count + case "}": + value.filter { $0 == "}" }.count > value.filter { $0 == "{" }.count + default: + false + } + guard shouldTrim else { break } + value.removeLast() + } + return value + } + + private static func bracketBareIPv6(_ value: String) -> String { + guard value.contains(":"), + !value.hasPrefix("["), + value.filter({ $0 == ":" }).count > 1 + else { + return value + } + return "[\(value)]" + } +} + +enum EndpointNetworkScope { + static func isLocal(_ endpoint: String) -> Bool { + guard let host = URLComponents(string: endpoint)?.host else { return false } + return isLocalHost(host) + } + + static func isLocalHost(_ rawHost: String) -> Bool { + let host = hostWithoutPort(rawHost).lowercased() + + if host == "localhost" || host.hasSuffix(".local") || host == "::1" { + return true + } + + let octets = host.split(separator: ".").compactMap { Int($0) } + if octets.count == 4, octets.allSatisfy({ 0 ... 255 ~= $0 }) { + return octets[0] == 10 + || octets[0] == 127 + || (octets[0] == 169 && octets[1] == 254) + || (octets[0] == 172 && 16 ... 31 ~= octets[1]) + || (octets[0] == 192 && octets[1] == 168) + } + + guard host.contains(":"), + let firstHextetText = host.split(separator: ":", omittingEmptySubsequences: true).first, + let firstHextet = UInt16(firstHextetText, radix: 16) + else { + return false + } + return firstHextet & 0xffc0 == 0xfe80 + || firstHextet & 0xfe00 == 0xfc00 + } + + private static func hostWithoutPort(_ rawHost: String) -> String { + let value = rawHost.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("["), + let closingBracket = value.firstIndex(of: "]") { + return String(value[value.index(after: value.startIndex) ..< closingBracket]) + } + + if value.filter({ $0 == ":" }).count == 1, + let colon = value.lastIndex(of: ":"), + value[value.index(after: colon)...].allSatisfy(\.isNumber) { + return String(value[.. String { + let message = rawMessage? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + + if message.isEmpty || message == "cancelled" || message == "canceled" { + return "The connection stopped before it finished. Make sure T3 Code is running, then try again." + } + if message.contains("expired") || message.contains("invalid_credential") + || message.contains("invalid credential") || message.contains("401") { + return "That pairing code is invalid or has expired. Create a new code on the host and try again." + } + if message.contains("timed out") || message.contains("timeout") { + return "The server took too long to respond. Check the address and that both devices are online." + } + if message.contains("offline") || message.contains("network") + || message.contains("could not connect") || message.contains("not connected") { + return "This iPhone could not reach the server. Check the address and network, then try again." + } + return "T3 Code could not complete pairing. Check the server address and use a fresh pairing code." + } +} diff --git a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift new file mode 100644 index 000000000000..46f0bcf60bf7 --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -0,0 +1,717 @@ +import SwiftUI +import UIKit + +public struct ConnectionOnboardingView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @Bindable private var model: FeatureRootModel + + private let readinessChecker: any ConnectionReadinessChecking + private let onConnected: @MainActor () -> Void + private let onCancel: (@MainActor () -> Void)? + private let showsT3ConnectOption: Bool + + @State private var stage = ConnectionStage.welcome + @State private var endpoint = "" + @State private var pairingCode = "" + @State private var errorMessage: String? + @State private var showsPermissionAction = false + @State private var showingScanner = false + @State private var entryHeading = "Connect manually" + @State private var connectionReturnStage = ConnectionStage.details + @State private var connectionTask: Task? + @State private var connectionAttemptID: UUID? + @FocusState private var focusedField: ConnectionField? + + public init( + model: FeatureRootModel, + showsT3ConnectOption: Bool = true, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + readinessChecker = LocalNetworkAccessChecker() + self.showsT3ConnectOption = showsT3ConnectOption + self.onConnected = onConnected + self.onCancel = onCancel + } + + init( + model: FeatureRootModel, + readinessChecker: any ConnectionReadinessChecking, + showsT3ConnectOption: Bool = true, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + self.readinessChecker = readinessChecker + self.showsT3ConnectOption = showsT3ConnectOption + self.onConnected = onConnected + self.onCancel = onCancel + } + + public var body: some View { + NavigationStack { + Group { + switch stage { + case .welcome: + welcomeView + case .details: + detailsView + case .checking, .connecting: + progressView + case .success: + successView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background.ignoresSafeArea()) + .animation(.snappy(duration: 0.24), value: stage) + .toolbarBackground(T3Colors.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + if stage == .welcome, let onCancel { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: onCancel) + .accessibilityIdentifier("connection-onboarding-close") + } + } else if stage == .checking || stage == .connecting { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + cancelConnectionAttempt() + model.errorMessage = nil + stage = connectionReturnStage + } + .accessibilityIdentifier("connection-onboarding-cancel") + } + } + } + } + .fullScreenCover(isPresented: $showingScanner) { + QRCodeScannerView( + onScan: { value in + showingScanner = false + applyConnectionString( + value, + heading: "Confirm connection", + connectAutomatically: true + ) + }, + onCancel: { + showingScanner = false + }, + onPaste: { + showingScanner = false + pasteConnectionLink() + } + ) + } + .onOpenURL { url in + applyConnectionString(url.absoluteString, heading: "Confirm connection") + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active, showsPermissionAction { + showsPermissionAction = false + errorMessage = nil + } + } + .interactiveDismissDisabled(stage == .checking || stage == .connecting) + .onDisappear { + cancelConnectionAttempt() + } + } + + private var welcomeView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 36) + + Text("T3") + .font(.system(size: 34, weight: .black, design: .rounded)) + .foregroundStyle(Color(red: 0.02, green: 0.74, blue: 0.5)) + .accessibilityLabel("T3 Code") + + Text("Connect an environment") + .font(.largeTitle.bold()) + .foregroundStyle(T3Colors.textPrimary) + .padding(.top, 20) + + Text("Choose how to connect to T3 Code.") + .font(.body) + .foregroundStyle(T3Colors.textSecondary) + .padding(.top, 8) + + if let errorMessage { + connectionError(message: errorMessage) + .padding(.top, 20) + } + + if showsT3ConnectOption, + let capability = model.client as? any T3ConnectCapable, + capability.t3ConnectController.unavailableReason == nil { + NavigationLink { + T3ConnectView(capability: capability, model: model) { + await model.reloadAfterConnection() + onConnected() + } + } label: { + Label("T3 Connect", systemImage: "cloud") + .frame(maxWidth: .infinity) + } + .buttonStyle(ConnectionPrimaryButtonStyle()) + .padding(.top, 32) + .accessibilityHint("Sign in to connect a linked environment") + .accessibilityIdentifier("connection-onboarding-t3-connect") + } + + VStack(spacing: 0) { + connectionAction( + title: "Scan QR code", + subtitle: "Use the code on your computer", + systemImage: "qrcode.viewfinder" + ) { + errorMessage = nil + showsPermissionAction = false + showingScanner = true + } + + Divider().overlay(T3Colors.border) + + connectionAction( + title: "Paste connection link", + subtitle: "Use a link from your computer", + systemImage: "doc.on.clipboard" + ) { + pasteConnectionLink() + } + + Divider().overlay(T3Colors.border) + + connectionAction( + title: "Enter details", + subtitle: "Use an address and pairing code", + systemImage: "keyboard" + ) { + entryHeading = "Connect manually" + errorMessage = nil + showsPermissionAction = false + stage = .details + } + } + .padding(.top, showsT3ConnectOption ? 20 : 28) + + knownEnvironments + } + .padding(.horizontal, 24) + .padding(.bottom, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + } + + @ViewBuilder + private var knownEnvironments: some View { + if !knownEnvironmentValues.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text("Saved environments") + .font(.headline) + .foregroundStyle(T3Colors.textPrimary) + .padding(.top, 36) + .padding(.bottom, 8) + + ForEach(knownEnvironmentValues) { environment in + Button { + connect( + .activate( + id: environment.id, + endpoint: environment.endpoint + ) + ) + } label: { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .font(.body) + .foregroundStyle(.secondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .font(T3Typography.threadBody) + .foregroundStyle(.primary) + Text(environment.endpoint) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + Spacer() + Image(systemName: "arrow.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .accessibilityLabel(environment.name) + .accessibilityValue(environment.endpoint) + .accessibilityHint("Connects to this saved environment") + + if environment.id != knownEnvironmentValues.last?.id { + Divider().overlay(T3Colors.border) + } + } + } + } + } + + private var knownEnvironmentValues: [FeatureEnvironment] { + guard showsT3ConnectOption else { return [] } + return model.snapshot.environments + } + + private var detailsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(entryHeading) + .font(.largeTitle.bold()) + .foregroundStyle(T3Colors.textPrimary) + Text("Find these details in T3 Code on your computer.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Server address") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + + TextField( + "Server address", + text: $endpoint, + prompt: Text("http://192.168.1.5:3773") + .foregroundStyle(T3Colors.placeholder) + ) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + .focused($focusedField, equals: .endpoint) + .connectionInput() + .accessibilityLabel("Server address") + .accessibilityIdentifier("connection-onboarding-address") + .submitLabel(.next) + .onSubmit { focusedField = .pairingCode } + .onChange(of: endpoint) { _, value in + autofillIfPairingLink(value) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Pairing code") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + + TextField( + "Pairing code", + text: $pairingCode, + prompt: Text("Enter pairing code") + .foregroundStyle(T3Colors.placeholder) + ) + .textInputAutocapitalization(.never) + .textContentType(.oneTimeCode) + .autocorrectionDisabled() + .focused($focusedField, equals: .pairingCode) + .connectionInput() + .privacySensitive() + .accessibilityLabel("Pairing code") + .accessibilityIdentifier("connection-onboarding-pairing-code") + .submitLabel(.go) + .onSubmit { + if canSubmit { submitDetails() } + } + } + + Button { + pasteConnectionLink() + } label: { + Label("Paste connection link", systemImage: "doc.on.clipboard") + .font(T3Typography.control.weight(.semibold)) + } + .buttonStyle(.plain) + .frame(minHeight: T3Metrics.minimumTapTarget, alignment: .leading) + .accessibilityIdentifier("connection-onboarding-paste-link") + + if let errorMessage { + connectionError(message: errorMessage) + } + + Button { + submitDetails() + } label: { + Text("Connect") + .frame(maxWidth: .infinity) + } + .buttonStyle(ConnectionPrimaryButtonStyle()) + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.45) + .accessibilityIdentifier("connection-onboarding-submit") + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Add environment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + errorMessage = nil + showsPermissionAction = false + focusedField = nil + stage = .welcome + } label: { + Label("Back", systemImage: "chevron.left") + } + } + } + } + + private var progressView: some View { + VStack(alignment: .leading, spacing: 34) { + Spacer() + + Text(stage == .checking ? "Checking connection" : "Connecting") + .font(.largeTitle.bold()) + .foregroundStyle(T3Colors.textPrimary) + + VStack(alignment: .leading, spacing: 20) { + progressRow( + title: "Server address", + state: .complete + ) + progressRow( + title: EndpointNetworkScope.isLocal(endpoint) + ? "Local network access" + : "Network access", + state: stage == .checking ? .active : .complete + ) + progressRow( + title: "Pairing", + state: stage == .connecting ? .active : .waiting + ) + } + + Spacer() + + Text(endpoint) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + .padding(.horizontal, 32) + .padding(.vertical, 28) + .frame(maxWidth: 520) + .accessibilityElement(children: .contain) + } + + private var successView: some View { + VStack(spacing: 18) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 54)) + .foregroundStyle(.green) + Text("Connected") + .font(.title.bold()) + Text("Loading your projects.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + } + .accessibilityElement(children: .combine) + } + + private func connectionAction( + title: String, + subtitle: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .medium)) + .frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.body.weight(.semibold)) + Text(subtitle) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .contentShape(Rectangle()) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityElement(children: .combine) + } + + private func connectionError(message: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(message, systemImage: showsPermissionAction ? "network.slash" : "exclamationmark.circle") + .font(T3Typography.control) + .foregroundStyle(Color(red: 1, green: 0.58, blue: 0.2)) + + if showsPermissionAction { + Button("Open Settings") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + .font(T3Typography.control.weight(.semibold)) + } + } + .accessibilityElement(children: .contain) + } + + private func progressRow(title: String, state: ProgressRowState) -> some View { + HStack(spacing: 14) { + Group { + switch state { + case .complete: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .active: + ProgressView() + .controlSize(.small) + case .waiting: + Image(systemName: "circle") + .foregroundStyle(.tertiary) + } + } + .frame(width: 22) + + Text(title) + .font(.body.weight(state == .active ? .semibold : .regular)) + .foregroundStyle(state == .waiting ? .secondary : .primary) + } + } + + private var canSubmit: Bool { + !endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !pairingCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + private func submitDetails() { + do { + let normalized = try ConnectionDetailsParser.normalizedEndpoint(endpoint) + endpoint = normalized + errorMessage = nil + showsPermissionAction = false + focusedField = nil + connect( + .pair( + endpoint: normalized, + code: pairingCode.trimmingCharacters(in: .whitespacesAndNewlines) + ) + ) + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + private func pasteConnectionLink() { + guard let value = UIPasteboard.general.string, !value.isEmpty else { + entryHeading = "Connect manually" + stage = .details + errorMessage = "Copy a T3 pairing link first, or enter the details below." + focusedField = .endpoint + return + } + applyConnectionString(value, heading: "Confirm connection") + } + + @MainActor + private func applyConnectionString( + _ value: String, + heading: String, + connectAutomatically: Bool = false + ) { + cancelConnectionAttempt() + do { + let details = try ConnectionDetailsParser.parse(value) + endpoint = details.endpoint + pairingCode = details.pairingCode ?? "" + entryHeading = heading + errorMessage = details.pairingCode == nil + ? "The link did not include a pairing code. Enter it below." + : nil + showsPermissionAction = false + if connectAutomatically, let code = details.pairingCode { + focusedField = nil + connect(.pair(endpoint: details.endpoint, code: code)) + } else { + stage = .details + focusedField = details.pairingCode == nil ? .pairingCode : nil + } + } catch { + entryHeading = "Connect manually" + errorMessage = error.localizedDescription + stage = .details + focusedField = .endpoint + } + } + + @MainActor + private func autofillIfPairingLink(_ value: String) { + guard let details = try? ConnectionDetailsParser.parse(value), + let code = details.pairingCode + else { + return + } + endpoint = details.endpoint + pairingCode = code + errorMessage = nil + focusedField = nil + } + + @MainActor + private func connect(_ action: ConnectionAction) { + cancelConnectionAttempt() + let attemptID = UUID() + connectionAttemptID = attemptID + switch action { + case .pair: + connectionReturnStage = .details + case .activate: + connectionReturnStage = .welcome + } + endpoint = action.endpoint + errorMessage = nil + showsPermissionAction = false + stage = .checking + + connectionTask = Task { + let readiness = await readinessChecker.check(endpoint: action.endpoint) + guard !Task.isCancelled, connectionAttemptID == attemptID else { return } + switch readiness { + case .ready: + stage = .connecting + case .localNetworkDenied: + errorMessage = "Allow Local Network access to connect to this environment." + showsPermissionAction = true + connectionAttemptID = nil + connectionTask = nil + stage = connectionReturnStage + return + case .unreachable: + errorMessage = "Cannot reach this environment. Check the address and network connection." + connectionAttemptID = nil + connectionTask = nil + stage = connectionReturnStage + return + } + + model.errorMessage = nil + let didConnect: Bool + switch action { + case let .pair(endpoint, code): + didConnect = await model.pair(endpoint: endpoint, token: code) + case let .activate(id, _): + didConnect = await model.setEnvironmentEnabled(id, enabled: true) + } + guard !Task.isCancelled, connectionAttemptID == attemptID else { return } + + if didConnect { + connectionAttemptID = nil + connectionTask = nil + stage = .success + onConnected() + } else { + let rawError = model.errorMessage + model.errorMessage = nil + errorMessage = ConnectionErrorCopy.message(for: rawError) + connectionAttemptID = nil + connectionTask = nil + stage = connectionReturnStage + } + } + } + + @MainActor + private func cancelConnectionAttempt() { + connectionTask?.cancel() + connectionTask = nil + connectionAttemptID = nil + } +} + +private enum ConnectionStage: Equatable { + case welcome + case details + case checking + case connecting + case success +} + +private enum ConnectionField: Hashable { + case endpoint + case pairingCode +} + +private enum ProgressRowState { + case complete + case active + case waiting +} + +private enum ConnectionAction { + case pair(endpoint: String, code: String) + case activate(id: String, endpoint: String) + + var endpoint: String { + switch self { + case let .pair(endpoint, _), let .activate(_, endpoint): + endpoint + } + } +} + +private extension View { + func connectionInput() -> some View { + self + .font(.body.monospaced()) + .foregroundStyle(T3Colors.textPrimary) + .padding(.horizontal, 14) + .frame(minHeight: 50) + .background(T3Colors.input) + .overlay(alignment: .bottom) { + Rectangle() + .fill(T3Colors.inputBorder) + .frame(height: 1) + } + } +} + +private struct ConnectionPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.body.weight(.semibold)) + .foregroundStyle(T3Colors.primaryActionForeground) + .padding(.horizontal, 16) + .frame(minHeight: 52) + .background( + configuration.isPressed + ? T3Colors.primaryAction.opacity(0.76) + : T3Colors.primaryAction + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift b/apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift new file mode 100644 index 000000000000..2adcf72b2f22 --- /dev/null +++ b/apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift @@ -0,0 +1,112 @@ +import Foundation +import Network + +enum ConnectionReadiness: Equatable, Sendable { + case ready + case localNetworkDenied + case unreachable +} + +protocol ConnectionReadinessChecking: Sendable { + func check(endpoint: String) async -> ConnectionReadiness +} + +struct LocalNetworkAccessChecker: ConnectionReadinessChecking { + func check(endpoint: String) async -> ConnectionReadiness { + guard EndpointNetworkScope.isLocal(endpoint) else { return .ready } + guard let components = URLComponents(string: endpoint), + let hostname = components.host + else { + return .unreachable + } + let portNumber = components.port + ?? (components.scheme?.lowercased() == "https" ? 443 : 80) + guard + let rawPort = UInt16(exactly: portNumber), + let port = NWEndpoint.Port(rawValue: rawPort) + else { + return .unreachable + } + + return await withCheckedContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(hostname), + port: port, + using: .tcp + ) + let completion = ConnectionProbeCompletion( + connection: connection, + continuation: continuation + ) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + completion.finish(.ready) + case .waiting(let error), .failed(let error): + if connection.currentPath?.unsatisfiedReason == .localNetworkDenied + || error.indicatesPermissionDenied { + completion.finish(.localNetworkDenied) + } else if case .failed = state { + completion.finish(.unreachable) + } + case .cancelled: + completion.finish(.unreachable) + default: + break + } + } + + connection.start(queue: .global(qos: .userInitiated)) + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 6) { + if connection.currentPath?.unsatisfiedReason == .localNetworkDenied { + completion.finish(.localNetworkDenied) + } else { + completion.finish(.unreachable) + } + } + } + } +} + +private final class ConnectionProbeCompletion: @unchecked Sendable { + private let lock = NSLock() + private var didFinish = false + private let connection: NWConnection + private var continuation: CheckedContinuation? + + init( + connection: NWConnection, + continuation: CheckedContinuation + ) { + self.connection = connection + self.continuation = continuation + } + + func finish(_ result: ConnectionReadiness) { + lock.lock() + guard !didFinish else { + lock.unlock() + return + } + didFinish = true + let continuation = continuation + self.continuation = nil + lock.unlock() + + connection.stateUpdateHandler = nil + connection.cancel() + continuation?.resume(returning: result) + } +} + +private extension NWError { + var indicatesPermissionDenied: Bool { + switch self { + case .posix(.EACCES), .posix(.EPERM): + true + default: + false + } + } +} diff --git a/apps/swift-ios/Features/Connection/QRCodeScannerView.swift b/apps/swift-ios/Features/Connection/QRCodeScannerView.swift new file mode 100644 index 000000000000..b00720b88c5d --- /dev/null +++ b/apps/swift-ios/Features/Connection/QRCodeScannerView.swift @@ -0,0 +1,332 @@ +@preconcurrency import AVFoundation +import SwiftUI +import UIKit + +struct QRCodeScannerView: View { + let onScan: (String) -> Void + let onCancel: () -> Void + let onPaste: () -> Void + + @State private var availability: QRScannerAvailability = .checking + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + QRScannerCameraView( + availability: $availability, + onScan: onScan + ) + .ignoresSafeArea() + + LinearGradient( + colors: [.black.opacity(0.7), .clear, .black.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .allowsHitTesting(false) + + VStack(spacing: 0) { + HStack { + Button("Cancel", action: onCancel) + .font(.body.weight(.semibold)) + Spacer() + Text("Scan QR code") + .font(.headline) + Spacer() + Button("Cancel", action: onCancel) + .hidden() + } + .padding(.horizontal, 20) + .frame(height: 56) + + Spacer() + + if availability == .ready { + scannerFrame + Text("Point your camera at the QR code shown by T3 Code.") + .font(T3Typography.threadBody) + .foregroundStyle(.white.opacity(0.78)) + .multilineTextAlignment(.center) + .padding(.top, 28) + } else { + unavailableContent + } + + Spacer() + + Button { + onPaste() + } label: { + Label("Paste connection link", systemImage: "doc.on.clipboard") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + } + .buttonStyle(.bordered) + .tint(.white) + .padding(.horizontal, 28) + .padding(.bottom, 24) + } + } + .preferredColorScheme(.dark) + } + + private var scannerFrame: some View { + RoundedRectangle(cornerRadius: 24) + .stroke(Color.white.opacity(0.9), lineWidth: 3) + .frame(width: 252, height: 252) + .overlay(alignment: .topLeading) { + Image(systemName: "viewfinder") + .resizable() + .frame(width: 282, height: 282) + .offset(x: -15, y: -15) + .foregroundStyle(.white) + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var unavailableContent: some View { + switch availability { + case .checking: + ProgressView() + .controlSize(.large) + .accessibilityLabel("Checking camera") + case .ready: + EmptyView() + case .denied: + VStack(spacing: 14) { + Image(systemName: "camera.fill") + .font(.system(size: 34)) + Text("Camera access is off") + .font(.title3.bold()) + Text("Allow camera access in Settings to scan a pairing code.") + .font(T3Typography.threadBody) + .foregroundStyle(.white.opacity(0.78)) + .multilineTextAlignment(.center) + Button("Open Settings") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(.black) + } + .padding(.horizontal, 36) + case .unavailable: + VStack(spacing: 14) { + Image(systemName: "camera.slash.fill") + .font(.system(size: 34)) + Text("Camera unavailable") + .font(.title3.bold()) + Text("Paste the connection link instead.") + .font(T3Typography.threadBody) + .foregroundStyle(.white.opacity(0.78)) + } + } + } +} + +private enum QRScannerAvailability: Equatable { + case checking + case ready + case denied + case unavailable +} + +private struct QRScannerCameraView: UIViewControllerRepresentable { + @Binding var availability: QRScannerAvailability + let onScan: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(availability: $availability, onScan: onScan) + } + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + context.coordinator.attach(to: controller) + return controller + } + + func updateUIViewController(_ controller: QRScannerViewController, context: Context) {} + + static func dismantleUIViewController( + _ controller: QRScannerViewController, + coordinator: Coordinator + ) { + controller.stop() + } + + @MainActor + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var availability: Binding + private let onScan: (String) -> Void + private weak var controller: QRScannerViewController? + private var didScan = false + + init( + availability: Binding, + onScan: @escaping (String) -> Void + ) { + self.availability = availability + self.onScan = onScan + } + + func attach(to controller: QRScannerViewController) { + self.controller = controller + controller.prepare(delegate: self) { [weak self] nextAvailability in + self?.availability.wrappedValue = nextAvailability + } + } + + nonisolated func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)? + .stringValue + else { + return + } + Task { @MainActor [weak self] in + guard let self, !didScan else { return } + didScan = true + controller?.stop() + UINotificationFeedbackGenerator().notificationOccurred(.success) + onScan(value) + } + } + } +} + +@MainActor +private final class QRScannerViewController: UIViewController { + private let captureSession = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "codes.t3.swift-ios.qr-scanner") + private var previewLayer: AVCaptureVideoPreviewLayer? + private var metadataDelegate: AVCaptureMetadataOutputObjectsDelegate? + private var availabilityChanged: (@MainActor (QRScannerAvailability) -> Void)? + private var isConfiguring = false + private var isRequestingAuthorization = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + } + + // SwiftUI does not reliably dismantle a representable the moment its + // fullScreenCover dismisses, and backgrounding never dismantles it, so + // stop the camera on disappear and resume it when the view returns. + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + stop() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard previewLayer != nil else { + refreshAuthorization() + return + } + let session = captureSession + sessionQueue.async { + if !session.isRunning { + session.startRunning() + } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + func prepare( + delegate: AVCaptureMetadataOutputObjectsDelegate, + availabilityChanged: @escaping @MainActor (QRScannerAvailability) -> Void + ) { + metadataDelegate = delegate + self.availabilityChanged = availabilityChanged + refreshAuthorization() + } + + private func refreshAuthorization() { + guard let metadataDelegate, let availabilityChanged else { return } + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configure(delegate: metadataDelegate, availabilityChanged: availabilityChanged) + case .notDetermined: + guard !isRequestingAuthorization else { return } + isRequestingAuthorization = true + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + Task { @MainActor in + guard let self else { return } + self.isRequestingAuthorization = false + guard granted else { + self.availabilityChanged?(.denied) + return + } + self.refreshAuthorization() + } + } + case .denied, .restricted: + availabilityChanged(.denied) + @unknown default: + availabilityChanged(.unavailable) + } + } + + func stop() { + let session = captureSession + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } + } + + private func configure( + delegate: AVCaptureMetadataOutputObjectsDelegate, + availabilityChanged: @escaping @MainActor (QRScannerAvailability) -> Void + ) { + guard previewLayer == nil, !isConfiguring else { return } + isConfiguring = true + defer { isConfiguring = false } + guard let camera = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: camera), + captureSession.canAddInput(input) + else { + availabilityChanged(.unavailable) + return + } + + let output = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(output) else { + availabilityChanged(.unavailable) + return + } + + captureSession.beginConfiguration() + captureSession.sessionPreset = .high + captureSession.addInput(input) + captureSession.addOutput(output) + output.setMetadataObjectsDelegate(delegate, queue: .main) + output.metadataObjectTypes = [.qr] + captureSession.commitConfiguration() + + let preview = AVCaptureVideoPreviewLayer(session: captureSession) + preview.videoGravity = .resizeAspectFill + preview.frame = view.bounds + view.layer.insertSublayer(preview, at: 0) + previewLayer = preview + availabilityChanged(.ready) + + let session = captureSession + sessionQueue.async { + session.startRunning() + } + } +} diff --git a/apps/swift-ios/Features/Connection/T3ConnectView.swift b/apps/swift-ios/Features/Connection/T3ConnectView.swift new file mode 100644 index 000000000000..d4c28399b285 --- /dev/null +++ b/apps/swift-ios/Features/Connection/T3ConnectView.swift @@ -0,0 +1,668 @@ +import ClerkKit +import ClerkKitUI +import SwiftUI + +public struct T3ConnectView: View { + public enum Purpose: Sendable { + case connect + case manage + } + + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable private var controller: T3ConnectController + @State private var isAuthPresented = false + @State private var didFinishInitialRefresh = false + @State private var connectingEnvironmentID: String? + @State private var isSigningOut = false + private let connectEnvironment: + @MainActor (T3ConnectManagedEnvironmentCredential) async throws -> Void + private let signOut: @MainActor () async -> Void + private let onConnected: @MainActor () async -> Void + private let onUnlinked: @MainActor (String) async -> Void + private let purpose: Purpose + + public init( + capability: any T3ConnectCapable, + model: FeatureRootModel? = nil, + purpose: Purpose = .connect, + onConnected: @escaping @MainActor () async -> Void = {}, + onUnlinked: @escaping @MainActor (String) async -> Void = { _ in } + ) { + controller = capability.t3ConnectController + connectEnvironment = capability.connectT3Environment + signOut = if let model { + model.signOutT3Connect + } else { + capability.signOutT3Connect + } + self.purpose = purpose + self.onConnected = onConnected + self.onUnlinked = onUnlinked + } + + public var body: some View { + content + .navigationTitle("T3 Connect") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(T3Colors.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .refreshable { + await controller.refresh() + } + .task { + await controller.refresh() + guard !Task.isCancelled else { return } + didFinishInitialRefresh = true + presentAuthenticationIfNeeded() + } + .onChange(of: controller.account?.id) { _, accountID in + guard didFinishInitialRefresh, + !isSigningOut, + accountID == nil, + controller.unavailableReason == nil else { return } + isAuthPresented = true + } + .fullScreenCover( + isPresented: $isAuthPresented, + onDismiss: handleAuthenticationDismissal + ) { + authenticationView + } + .alert( + "T3 Connect", + isPresented: Binding( + get: { controller.errorMessage != nil }, + set: { if !$0 { controller.errorMessage = nil } } + ) + ) { + Button("OK") { controller.errorMessage = nil } + } message: { + Text(controller.errorMessage ?? "Something went wrong.") + } + } + + @ViewBuilder + private var content: some View { + if let reason = controller.unavailableReason { + connectList { + unavailableSection(reason) + } + } else if let account = controller.account { + connectList { + environmentSection + accountSection(account) + } + } else if isSigningOut { + loadingView("Signing out") + } else if didFinishInitialRefresh { + signedOutView + } else { + loadingView("Checking account") + } + } + + private func connectList( + @ViewBuilder content: () -> Content + ) -> some View { + List { + content() + } + .listStyle(.plain) + .listSectionSpacing(28) + .scrollContentBackground(.hidden) + .background(T3Colors.background.ignoresSafeArea()) + } + + private var signedOutView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Sign in to T3 Connect") + .font(.title2.bold()) + .foregroundStyle(T3Colors.textPrimary) + + Text("Access environments linked to your account.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + + Button("Sign in") { + isAuthPresented = true + } + .font(T3Typography.control.weight(.semibold)) + .frame(minHeight: T3Metrics.minimumTapTarget) + .padding(.top, 4) + .accessibilityIdentifier("t3-connect-sign-in") + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(24) + .background(T3Colors.background.ignoresSafeArea()) + } + + private func loadingView(_ message: String) -> some View { + VStack(spacing: 12) { + ProgressView() + .tint(T3Colors.textPrimary) + Text(message) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background.ignoresSafeArea()) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var authenticationView: some View { + if let clerk = controller.clerk { + T3ConnectAuthenticationView { + await controller.refreshAfterAuthentication() + if controller.account != nil { + isAuthPresented = false + return true + } + return false + } + .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) + .environment(clerk) + } else { + loadingView("Loading sign-in") + } + } + + private func presentAuthenticationIfNeeded() { + guard controller.unavailableReason == nil, + controller.account == nil else { return } + isAuthPresented = true + } + + private func handleAuthenticationDismissal() { + Task { + await controller.refresh() + if controller.account == nil { + dismiss() + } + } + } + + private func unavailableSection(_ reason: String) -> some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Label("T3 Connect unavailable", systemImage: "cloud.slash") + .font(T3Typography.homeTitle) + Text(reason) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + Text("You can still connect directly.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.vertical, 8) + .listRowBackground(T3Colors.background) + } + } + + private func accountSection(_ account: T3ConnectAccount) -> some View { + Section("Account") { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.fill") + .font(.title2) + .foregroundStyle(T3Colors.textSecondary) + VStack(alignment: .leading, spacing: 2) { + Text(account.email ?? "T3 account") + .font(T3Typography.homeTitle) + Text("Signed in") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .padding(.vertical, 3) + .listRowBackground(T3Colors.background) + .accessibilityElement(children: .combine) + + Button(role: .destructive) { + handleSignOut() + } label: { + HStack { + Text("Sign out") + Spacer() + if isSigningOut { + ProgressView() + .controlSize(.small) + } + } + .frame(minHeight: T3Metrics.minimumTapTarget) + } + .disabled(controller.isRefreshing || isSigningOut || connectingEnvironmentID != nil) + .listRowBackground(T3Colors.background) + .accessibilityIdentifier("t3-connect-sign-out") + } + } + + private var environmentSection: some View { + Section("Environments") { + if controller.environments.isEmpty, !controller.isRefreshing { + VStack(alignment: .leading, spacing: 8) { + Text("No linked environments") + .font(T3Typography.homeTitle) + Text("Link an environment in T3 Code on your computer.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + Button("Refresh") { + Task { await controller.refresh() } + } + .font(T3Typography.control) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("t3-connect-refresh") + } + .padding(.top, 8) + .listRowBackground(T3Colors.background) + } + + ForEach(controller.environments) { item in + environmentRow(item) + .listRowBackground(T3Colors.background) + .swipeActions { + Button(role: .destructive) { + Task { + if await controller.unlink(item.environment) { + await onUnlinked(item.id) + } + } + } label: { + Label("Unlink", systemImage: "link.badge.minus") + } + } + } + + if controller.isRefreshing { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking environments") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .listRowBackground(T3Colors.background) + .accessibilityElement(children: .combine) + } + } + } + + private func environmentRow(_ item: T3ConnectCloudEnvironment) -> some View { + HStack(spacing: 12) { + Circle() + .fill(statusColor(item)) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text(item.environment.label) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Text(statusText(item)) + .font(T3Typography.supporting) + .foregroundStyle( + item.statusError == nil ? T3Colors.textSecondary : T3Colors.danger + ) + .lineLimit(2) + } + .accessibilityElement(children: .combine) + + Spacer(minLength: 8) + + if purpose == .connect { + Button { + Task { await handleConnect(item.environment) } + } label: { + if controller.busyEnvironmentID == item.id + || connectingEnvironmentID == item.id { + ProgressView() + .frame(width: 54) + .accessibilityLabel("Connecting to \(item.environment.label)") + } else { + Text("Connect") + .font(T3Typography.supportingStrong) + } + } + .buttonStyle(.borderless) + .frame(minHeight: T3Metrics.minimumTapTarget) + .disabled( + controller.busyEnvironmentID != nil + || connectingEnvironmentID != nil + || item.status?.status == .offline + ) + .accessibilityLabel("Connect to \(item.environment.label)") + .accessibilityHint(item.status?.status == .offline ? "Environment is offline" : "") + .accessibilityIdentifier("t3-connect-environment-\(item.id)") + } + } + .padding(.vertical, 5) + } + + private func handleSignOut() { + guard !isSigningOut else { return } + isSigningOut = true + Task { + await signOut() + isSigningOut = false + if controller.account == nil { + dismiss() + } + } + } + + private func handleConnect(_ environment: T3ConnectRelayEnvironment) async { + guard connectingEnvironmentID == nil else { return } + connectingEnvironmentID = environment.environmentId + defer { + if connectingEnvironmentID == environment.environmentId { + connectingEnvironmentID = nil + } + } + do { + let credential = try await controller.credential(for: environment) + try await connectEnvironment(credential) + await onConnected() + } catch { + controller.errorMessage = error.localizedDescription + } + } + + private func statusText(_ item: T3ConnectCloudEnvironment) -> String { + if let error = item.statusError { return error } + switch item.status?.status { + case .online: return "Online" + case .offline: return item.status?.error ?? "Offline" + case nil: return "Checking" + } + } + + private func statusColor(_ item: T3ConnectCloudEnvironment) -> Color { + if item.statusError != nil { + return T3Colors.danger + } + return switch item.status?.status { + case .online: T3Colors.success + case .offline: T3Colors.danger + case nil: T3Colors.textTertiary + } + } +} + +@MainActor +private struct T3ConnectAuthenticationView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @SwiftUI.Environment(Clerk.self) private var clerk + @State private var activeProvider: OAuthProvider? + @State private var errorMessage: String? + @State private var isEmailPresented = false + + private let onAuthenticationChanged: @MainActor () async -> Bool + private let preferredProviders: [OAuthProvider] = [ + .apple, + .github, + .google, + .microsoft, + ] + + init( + onAuthenticationChanged: @escaping @MainActor () async -> Bool + ) { + self.onAuthenticationChanged = onAuthenticationChanged + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + brand + .padding(.bottom, 38) + + Text("Sign in to T3 Connect") + .font(.system(.largeTitle, design: .default, weight: .bold)) + .foregroundStyle(T3Colors.textPrimary) + .padding(.bottom, 10) + + Text("Access your linked environments.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 34) + + providerButtons + + emailButton + .padding(.top, 24) + } + .frame(maxWidth: 440, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 34) + .padding(.bottom, 40) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .background(T3Colors.background.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + .accessibilityIdentifier("t3-connect-auth-close") + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + .task { + if clerk.environment == nil { + _ = try? await clerk.refreshEnvironment() + } + } + .sheet(isPresented: $isEmailPresented, onDismiss: authenticationDidFinish) { + AuthView(mode: .signInOrUp) + .prefetchClerkImages() + .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) + .environment(clerk) + } + .alert( + "Couldn’t sign in", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "Please try again.") + } + } + + private var brand: some View { + HStack(spacing: 10) { + Text("T3") + .font(.system(size: 14, weight: .heavy, design: .rounded)) + .foregroundStyle(T3Colors.primaryActionForeground) + .frame(width: 32, height: 32) + .background(T3Colors.primaryAction) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Text("T3 Connect") + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + } + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var providerButtons: some View { + if clerk.environment == nil { + HStack(spacing: 10) { + ProgressView() + .tint(T3Colors.textPrimary) + Text("Loading sign-in options") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, minHeight: 56) + } else { + VStack(spacing: 12) { + ForEach(availableProviders) { provider in + providerButton(provider) + } + } + } + } + + private func providerButton(_ provider: OAuthProvider) -> some View { + Button { + Task { await signIn(with: provider) } + } label: { + HStack(spacing: 12) { + T3ConnectAuthProviderIcon(provider: provider) + .frame(width: 22, height: 22) + + Text("Continue with \(provider.name)") + .font(.system(.body, design: .default, weight: .semibold)) + .foregroundStyle(T3Colors.textPrimary) + + Spacer(minLength: 8) + + if activeProvider == provider { + ProgressView() + .tint(T3Colors.textPrimary) + } + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, minHeight: 56) + .background(T3Colors.surfaceRaised) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(activeProvider != nil) + .opacity(activeProvider == nil || activeProvider == provider ? 1 : 0.55) + .accessibilityIdentifier("t3-connect-auth-\(provider.strategy)") + } + + private var emailButton: some View { + Button { + isEmailPresented = true + } label: { + HStack(spacing: 10) { + Image(systemName: "envelope") + .font(.system(size: 15, weight: .medium)) + Text(availableProviders.isEmpty ? "Continue with email" : "Use email") + .font(T3Typography.control) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(activeProvider != nil) + .accessibilityIdentifier("t3-connect-auth-email") + } + + private var availableProviders: [OAuthProvider] { + guard let environment = clerk.environment else { return [] } + let enabledStrategies = Set( + environment.userSettings.social.values + .filter { $0.enabled && $0.authenticatable } + .map(\.strategy) + ) + return preferredProviders.filter { enabledStrategies.contains($0.strategy) } + } + + private func signIn(with provider: OAuthProvider) async { + activeProvider = provider + defer { activeProvider = nil } + + do { + if provider == .apple { + try await clerk.auth.signInWithApple() + } else { + try await clerk.auth.signInWithOAuth(provider: provider) + } + + if !(await onAuthenticationChanged()) { + isEmailPresented = true + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func authenticationDidFinish() { + Task { _ = await onAuthenticationChanged() } + } +} + +private struct T3ConnectAuthProviderIcon: View { + let provider: OAuthProvider + + @ViewBuilder + var body: some View { + switch provider { + case .apple: + Image(systemName: "apple.logo") + .resizable() + .scaledToFit() + .foregroundStyle(T3Colors.textPrimary) + case .github: + Image("AuthGitHub") + .resizable() + .scaledToFit() + case .google: + Image("AuthGoogle") + .resizable() + .scaledToFit() + case .microsoft: + Image("AuthMicrosoft") + .resizable() + .scaledToFit() + default: + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + .foregroundStyle(T3Colors.textPrimary) + } + } +} + +@MainActor +private enum T3ConnectClerkAppearance { + static let theme = ClerkTheme( + colors: .init( + primary: T3Colors.primaryAction, + background: T3Colors.background, + input: T3Colors.input, + danger: T3Colors.danger, + success: T3Colors.success, + warning: T3Colors.warning, + foreground: T3Colors.textPrimary, + mutedForeground: T3Colors.textSecondary, + primaryForeground: T3Colors.primaryActionForeground, + inputForeground: T3Colors.textPrimary, + neutral: T3Colors.textPrimary, + ring: T3Colors.textPrimary, + muted: T3Colors.surfaceRaised, + shadow: T3Colors.border, + border: T3Colors.border + ), + design: .init(borderRadius: 12) + ) +} diff --git a/apps/swift-ios/Features/Devices/DevicesView.swift b/apps/swift-ios/Features/Devices/DevicesView.swift new file mode 100644 index 000000000000..59a2a80a612e --- /dev/null +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +public struct DevicesView: View { + private let manager: any FeatureDeviceManaging + + @State private var sessions: [FeatureDeviceSession] = [] + @State private var isLoading = true + @State private var isRevoking = false + @State private var errorMessage: String? + @State private var revokeTarget: FeatureDeviceSession? + @State private var showingRevokeOthers = false + + public init(manager: any FeatureDeviceManaging) { + self.manager = manager + } + + public var body: some View { + Group { + if isLoading, sessions.isEmpty { + VStack(spacing: 12) { + ProgressView() + Text("Loading devices") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } else if let errorMessage, sessions.isEmpty { + ContentUnavailableView { + Label("Couldn’t load devices", systemImage: "exclamationmark.circle") + } description: { + Text(errorMessage) + } actions: { + Button("Try again") { + Task { await reload() } + } + .buttonStyle(.borderedProminent) + } + } else if sessions.isEmpty { + ContentUnavailableView { + Label("No devices found", systemImage: "laptopcomputer.and.iphone") + } description: { + Text("Device sessions will appear here when this server supports access management.") + } + } else { + deviceList + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + .navigationTitle("Devices") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !otherSessions.isEmpty { + ToolbarItem(placement: .primaryAction) { + Menu { + Button(role: .destructive) { + showingRevokeOthers = true + } label: { + Label("Remove all other devices", systemImage: "rectangle.stack.badge.minus") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(isRevoking) + .accessibilityLabel("Device actions") + } + } + } + .task { + await reload() + } + .alert( + "Remove this device?", + isPresented: Binding( + get: { revokeTarget != nil }, + set: { if !$0 { revokeTarget = nil } } + ), + presenting: revokeTarget + ) { device in + Button(manager.managesServerSessions ? "Remove access" : "Remove device", role: .destructive) { + Task { await revoke(device) } + } + Button("Cancel", role: .cancel) {} + } message: { device in + Text( + manager.managesServerSessions + ? "\(device.displayName) will need a new pairing code to reconnect." + : "\(device.displayName) will stop receiving T3 Connect notifications." + ) + } + .confirmationDialog( + "Remove all other devices?", + isPresented: $showingRevokeOthers, + titleVisibility: .visible + ) { + Button("Remove \(otherSessions.count) devices", role: .destructive) { + Task { await revokeOthers() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + manager.managesServerSessions + ? "Every other phone, tablet, browser, and desktop will be signed out." + : "Other registered devices will stop receiving T3 Connect notifications." + ) + } + } + + private var deviceList: some View { + List { + if let currentSession { + Section("THIS DEVICE") { + DeviceSessionRow(session: currentSession) + } + } + + if !otherSessions.isEmpty { + Section("OTHER DEVICES") { + ForEach(otherSessions) { session in + DeviceSessionRow(session: session) + .contentShape(Rectangle()) + .swipeActions { + Button("Remove", role: .destructive) { + revokeTarget = session + } + } + .contextMenu { + Button(role: .destructive) { + revokeTarget = session + } label: { + Label("Remove access", systemImage: "trash") + } + } + } + } + } + + if let errorMessage { + Section { + VStack(alignment: .leading, spacing: 10) { + Label(errorMessage, systemImage: "exclamationmark.circle") + .font(T3Typography.control) + .foregroundStyle(.orange) + Button("Try again") { + Task { await reload() } + } + .font(T3Typography.control.weight(.semibold)) + } + .padding(.vertical, 4) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .refreshable { + await reload() + } + .overlay(alignment: .top) { + if isRevoking { + ProgressView() + .padding(.top, 12) + .accessibilityLabel("Updating device access") + } + } + } + + private var currentSession: FeatureDeviceSession? { + sessions.first(where: \.isCurrent) + } + + private var otherSessions: [FeatureDeviceSession] { + sessions.filter { !$0.isCurrent } + } + + @MainActor + private func reload() async { + isLoading = true + defer { isLoading = false } + do { + sessions = FeatureDeviceSession.sortedForDisplay( + try await manager.loadDeviceSessions() + ) + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } + + @MainActor + private func revoke(_ session: FeatureDeviceSession) async { + isRevoking = true + defer { + isRevoking = false + revokeTarget = nil + } + do { + try await manager.revokeDeviceSession(id: session.id) + sessions.removeAll { $0.id == session.id } + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } + + @MainActor + private func revokeOthers() async { + isRevoking = true + defer { isRevoking = false } + do { + try await manager.revokeOtherDeviceSessions() + sessions.removeAll { !$0.isCurrent } + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } +} + +private struct DeviceSessionRow: View { + let session: FeatureDeviceSession + + var body: some View { + HStack(alignment: .top, spacing: 13) { + Image(systemName: session.deviceType.systemImage) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(session.isCurrent ? .green : .secondary) + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(session.displayName) + .font(T3Typography.homeTitle) + if session.isCurrent { + Text("Current") + .font(T3Typography.supportingStrong) + .foregroundStyle(.green) + } else if session.isConnected { + Text("Online") + .font(T3Typography.supportingStrong) + .foregroundStyle(.green) + } + } + + if !session.platformDescription.isEmpty { + Text(session.platformDescription) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + + Text(lastSeenDescription) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + if let ipAddress = session.ipAddress, !ipAddress.isEmpty { + Text(ipAddress) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer(minLength: 8) + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + private var lastSeenDescription: String { + if session.isConnected { + return "Active now" + } + return "Last seen \(session.lastSeenAt.formatted(.relative(presentation: .named)))" + } +} diff --git a/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift b/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift new file mode 100644 index 000000000000..0117c37c2018 --- /dev/null +++ b/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift @@ -0,0 +1,147 @@ +import Foundation + +public enum FeatureDeviceType: String, Sendable, Equatable, Codable { + case desktop + case mobile + case tablet + case bot + case unknown + + var displayName: String { + switch self { + case .desktop: "Desktop" + case .mobile: "Phone" + case .tablet: "Tablet" + case .bot: "Automation" + case .unknown: "Device" + } + } + + var systemImage: String { + switch self { + case .desktop: "desktopcomputer" + case .mobile: "iphone" + case .tablet: "ipad" + case .bot: "gearshape.2" + case .unknown: "network" + } + } +} + +public struct FeatureDeviceSession: Identifiable, Sendable, Equatable, Codable { + public var id: String { sessionID } + + public let sessionID: String + public var label: String? + public var deviceType: FeatureDeviceType + public var operatingSystem: String? + public var browser: String? + public var ipAddress: String? + public var issuedAt: Date + public var expiresAt: Date + public var lastConnectedAt: Date? + public var isConnected: Bool + public var isCurrent: Bool + + public init( + sessionID: String, + label: String? = nil, + deviceType: FeatureDeviceType = .unknown, + operatingSystem: String? = nil, + browser: String? = nil, + ipAddress: String? = nil, + issuedAt: Date, + expiresAt: Date, + lastConnectedAt: Date? = nil, + isConnected: Bool = false, + isCurrent: Bool = false + ) { + self.sessionID = sessionID + self.label = label + self.deviceType = deviceType + self.operatingSystem = operatingSystem + self.browser = browser + self.ipAddress = ipAddress + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.lastConnectedAt = lastConnectedAt + self.isConnected = isConnected + self.isCurrent = isCurrent + } + + var displayName: String { + let value = label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !value.isEmpty { + return value + } + return isCurrent ? "This device" : deviceType.displayName + } + + var platformDescription: String { + [operatingSystem, browser] + .compactMap { value in + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + .joined(separator: " · ") + } + + var lastSeenAt: Date { + lastConnectedAt ?? issuedAt + } + + static func sortedForDisplay(_ sessions: [Self]) -> [Self] { + sessions.sorted { left, right in + if left.isCurrent != right.isCurrent { + return left.isCurrent + } + if left.isConnected != right.isConnected { + return left.isConnected + } + return left.lastSeenAt > right.lastSeenAt + } + } +} + +/// The app adapter can opt into access management without making it a requirement +/// for environments that do not grant the access:read/access:write scopes. +@MainActor +public protocol FeatureDeviceManaging: AnyObject { + var managesServerSessions: Bool { get } + func loadDeviceSessions() async throws -> [FeatureDeviceSession] + func revokeDeviceSession(id: String) async throws + func revokeOtherDeviceSessions() async throws +} + +extension FeatureDeviceManaging { + var managesServerSessions: Bool { true } +} + +@MainActor +final class EmptyFeatureDeviceManager: FeatureDeviceManaging { + static let shared = EmptyFeatureDeviceManager() + + private init() {} + + func loadDeviceSessions() async throws -> [FeatureDeviceSession] { + [] + } + + func revokeDeviceSession(id: String) async throws {} + func revokeOtherDeviceSessions() async throws {} +} + +enum DeviceManagementErrorCopy { + static func message(for error: Error) -> String { + let value = error.localizedDescription.lowercased() + if value.contains("scope") || value.contains("403") || value.contains("forbidden") + || value.contains("permission") { + return "This connection does not have permission to manage devices." + } + if value.contains("offline") || value.contains("network") + || value.contains("not connected") || value.contains("timed out") { + return "Device access could not be updated. Check your connection and try again." + } + return "Device access could not be updated. Try again in a moment." + } +} diff --git a/apps/swift-ios/Features/Files/FeatureFilesView.swift b/apps/swift-ios/Features/Files/FeatureFilesView.swift new file mode 100644 index 000000000000..66803a68e394 --- /dev/null +++ b/apps/swift-ios/Features/Files/FeatureFilesView.swift @@ -0,0 +1,503 @@ +import ImageIO +import SwiftUI +import UIKit + +public struct FeatureFilesView: View { + let client: any FeatureClient + let threadID: String + let initialPath: String? + let workspaceRoot: String? + + public init( + client: any FeatureClient, + threadID: String, + initialPath: String? = nil, + workspaceRoot: String? = nil + ) { + self.client = client + self.threadID = threadID + self.initialPath = initialPath + self.workspaceRoot = workspaceRoot + } + + public var body: some View { + Group { + if let initialPath { + FeatureFilePreviewView( + client: client, + threadID: threadID, + entry: FeatureFileEntry( + path: initialPath, + name: URL(fileURLWithPath: initialPath).lastPathComponent, + kind: .file + ), + workspaceRoot: workspaceRoot + ) + } else { + FeatureFileDirectoryView( + client: client, + threadID: threadID, + path: nil, + title: "Files", + workspaceRoot: workspaceRoot + ) + } + } + .background(T3Colors.background) + } +} + +private struct FeatureFileDirectoryView: View { + let client: any FeatureClient + let threadID: String + let path: String? + let title: String + let workspaceRoot: String? + + @State private var entries: [FeatureFileEntry] = [] + @State private var searchText = "" + @State private var includesHidden = false + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + Group { + if isLoading, entries.isEmpty { + ProgressView("Loading files…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage, entries.isEmpty { + ContentUnavailableView( + "Files unavailable", + systemImage: "folder.badge.questionmark", + description: Text(errorMessage) + ) + } else if filteredEntries.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Empty folder" : "No matches", + systemImage: "folder", + description: Text(searchText.isEmpty ? "This folder has no visible files." : "Try another search.") + ) + } else { + List(filteredEntries) { entry in + NavigationLink { + destination(for: entry) + } label: { + FeatureFileRow(entry: entry) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await load() } + } + } + .background(T3Colors.background) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Filter files") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle("Show hidden files", isOn: $includesHidden) + Button { + Task { await load() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("File browser options") + } + } + .task(id: path) { await load() } + } + + @ViewBuilder + private func destination(for entry: FeatureFileEntry) -> some View { + if entry.kind == .directory { + FeatureFileDirectoryView( + client: client, + threadID: threadID, + path: entry.path, + title: entry.name, + workspaceRoot: workspaceRoot + ) + } else { + FeatureFilePreviewView( + client: client, + threadID: threadID, + entry: entry, + workspaceRoot: workspaceRoot + ) + } + } + + private var filteredEntries: [FeatureFileEntry] { + entries.featureFiltered(by: searchText, includesHidden: includesHidden) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + entries = try await client.listFiles(threadID: threadID, path: path) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureFileRow: View { + let entry: FeatureFileEntry + + var body: some View { + HStack(spacing: 11) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundStyle(entry.kind == .directory ? .blue : .secondary) + .frame(width: 20) + Text(entry.name) + .font(T3Typography.threadBody) + .lineLimit(1) + Spacer() + if let size = entry.sizeBytes, entry.kind != .directory { + Text(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) + .font(T3Typography.tool.monospacedDigit()) + .foregroundStyle(T3Colors.textSecondary) + } + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + } + + private var icon: String { + switch entry.kind { + case .directory: "folder.fill" + case .symbolicLink: "link" + case .file: + switch FeatureFilePreviewKind.infer(path: entry.path) { + case .image: "photo" + case .pdf: "doc.richtext" + case .video: "video" + case .document: "doc" + case .markdown: "doc.richtext" + case .source: entry.name.hasSuffix(".swift") ? "swift" : "chevron.left.forwardslash.chevron.right" + case .plainText: "doc.text" + } + } + } +} + +private struct FeatureFilePreviewView: View { + let client: any FeatureClient + let threadID: String + let entry: FeatureFileEntry + let workspaceRoot: String? + + @State private var content: FeatureFileContent? + @State private var sourceLines: [FeatureSourceLine] = [] + @State private var assetURL: URL? + @State private var errorMessage: String? + @State private var isLoading = true + + private var previewKind: FeatureFilePreviewKind { + FeatureFilePreviewKind.infer(path: entry.path, language: content?.language) + } + + var body: some View { + Group { + if isLoading, content == nil, assetURL == nil { + ProgressView("Loading file…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let assetURL { + FeatureNativeMediaPreviewView( + source: .remote(assetURL), + kind: previewKind, + fileName: entry.name + ) + } else if let content { + VStack(spacing: 0) { + if content.isTruncated { + Label("Partial preview", systemImage: "exclamationmark.triangle") + .font(T3Typography.supportingStrong) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.09)) + } + switch previewKind { + case .markdown: + ScrollView { + MarkdownMessageView( + content.text, + copyActionTitle: "Copy file contents", + imageContext: markdownImageContext + ) + .frame(maxWidth: T3Metrics.readingWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 16) + } + .scrollDismissesKeyboard(.interactively) + case .source, .plainText: + FeatureSourceTextView(lines: sourceLines) + case .image, .pdf, .video, .document: + EmptyView() + } + } + } else { + ContentUnavailableView( + previewKind == .image ? "Image unavailable" : "File unavailable", + systemImage: previewKind == .image ? "photo.badge.exclamationmark" : "doc.badge.ellipsis", + description: Text(errorMessage ?? "The file could not be read.") + ) + } + } + .background(T3Colors.background) + .navigationTitle(entry.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let content { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: content.text) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share file contents") + } + } + } + .task { await load() } + } + + private var markdownImageContext: MarkdownImageContext? { + guard let workspaceRoot, + let resolver = client as? any FeatureWorkspaceAssetResolving else { return nil } + return MarkdownImageContext( + threadID: threadID, + workspaceRoot: workspaceRoot, + resolver: resolver, + sourceFilePath: entry.path + ) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + if [.image, .pdf, .video, .document].contains(previewKind) { + guard let resolver = client as? any FeatureWorkspaceAssetResolving else { + throw FeatureCapabilityUnavailable("Native file previews") + } + let resolvedURL = if previewKind == .image || previewKind == .video || previewKind == .pdf + || ["html", "htm"].contains(URL(fileURLWithPath: entry.path).pathExtension.lowercased()) { + try await resolver.mediaAssetURL(threadID: threadID, path: entry.path) + } else { + try await resolver.workspaceAssetURL(threadID: threadID, path: entry.path) + } + guard !Task.isCancelled else { return } + assetURL = resolvedURL + content = nil + sourceLines = [] + } else { + let loaded = try await client.readFile(threadID: threadID, path: entry.path) + let loadedKind = FeatureFilePreviewKind.infer( + path: entry.path, + language: loaded.language + ) + let lines: [FeatureSourceLine] + switch loadedKind { + case .source: + lines = await Task.detached(priority: .userInitiated) { + FeatureSourceHighlighter.lines( + text: loaded.text, + language: loaded.language + ) + }.value + case .plainText: + lines = await Task.detached(priority: .userInitiated) { + FeatureSourceHighlighter.lines(text: loaded.text, language: "plain") + }.value + case .markdown: + _ = await MarkdownRenderCache.shared.document( + for: MarkdownContentRevision(loaded.text) + ) + lines = [] + case .image, .pdf, .video, .document: + lines = [] + } + guard !Task.isCancelled else { return } + content = loaded + sourceLines = lines + assetURL = nil + } + errorMessage = nil + } catch { + guard !Task.isCancelled else { return } + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureSourceTextView: View { + let lines: [FeatureSourceLine] + + var body: some View { + GeometryReader { proxy in + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines) { line in + HStack(alignment: .top, spacing: 10) { + Text("\(line.number)") + .foregroundStyle(.tertiary) + .frame(width: 44, alignment: .trailing) + .accessibilityHidden(true) + FeatureHighlightedSourceLine(line: line) + } + .font(T3Typography.code) + .t3CodeTextSize() + .fixedSize(horizontal: true, vertical: false) + .frame( + minWidth: proxy.size.width, + minHeight: 22, + alignment: .leading + ) + } + } + .frame(minWidth: proxy.size.width, alignment: .leading) + .padding(.vertical, 10) + .padding(.trailing, 14) + .textSelection(.enabled) + } + } + .background(T3Colors.background) + .accessibilityLabel("Source file") + } +} + +private struct FeatureHighlightedSourceLine: View { + let line: FeatureSourceLine + + var body: some View { + renderedText + .fixedSize(horizontal: true, vertical: false) + } + + private var renderedText: Text { + guard !line.spans.isEmpty else { return Text(" ") } + return line.spans.reduce(Text("")) { output, span in + output + Text(verbatim: span.text).foregroundColor(color(for: span.kind)) + } + } + + private func color(for kind: FeatureSourceTokenKind) -> Color { + switch kind { + case .plain: T3Colors.textPrimary.opacity(0.92) + case .comment: T3Colors.textTertiary + case .keyword: T3Colors.syntaxKeyword + case .literal: T3Colors.syntaxLiteral + case .number: T3Colors.syntaxNumber + case .property: T3Colors.syntaxProperty + } + } +} + +private struct FeatureZoomableImageView: UIViewRepresentable { + let image: UIImage + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.backgroundColor = .black + scrollView.delegate = context.coordinator + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = 6 + scrollView.bouncesZoom = true + scrollView.decelerationRate = .fast + + let imageView = context.coordinator.imageView + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = "Image preview" + scrollView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.toggleZoom(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + context.coordinator.scrollView = scrollView + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + if context.coordinator.imageView.image !== image { + context.coordinator.imageView.image = image + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false) + } + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + let imageView = UIImageView() + weak var scrollView: UIScrollView? + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + @objc func toggleZoom(_ recognizer: UITapGestureRecognizer) { + guard let scrollView else { return } + let scale = scrollView.zoomScale > scrollView.minimumZoomScale + ? scrollView.minimumZoomScale + : min(2.5, scrollView.maximumZoomScale) + scrollView.setZoomScale(scale, animated: true) + } + } +} + +private enum FeatureImageDecoder { + static func downsample(_ data: Data, maxPixelSize: CGFloat) -> UIImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + return nil + } + let options = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] as CFDictionary + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { + return nil + } + return UIImage(cgImage: image) + } +} + +private enum FeatureImagePreviewError: LocalizedError { + case httpStatus(Int) + case invalidImage + case tooLarge + + var errorDescription: String? { + switch self { + case let .httpStatus(status): "The image server returned HTTP \(status)." + case .invalidImage: "The file is not a supported image." + case .tooLarge: "The image is larger than the 64 MB preview limit." + } + } +} diff --git a/apps/swift-ios/Features/PullRequests/PullRequestsView.swift b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift new file mode 100644 index 000000000000..3326c1a8ddf4 --- /dev/null +++ b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift @@ -0,0 +1,1644 @@ +import Observation +import SwiftUI + +struct FeaturePullRequestRow: Identifiable, Equatable { + let environmentID: String + let environmentName: String + let entry: PullRequestListEntry + + var id: String { "\(environmentID):\(entry.id)" } + var target: FeaturePullRequestTarget { + FeaturePullRequestTarget( + environmentID: environmentID, + environmentName: environmentName, + reference: PullRequestRef( + projectId: entry.projectId, + repository: entry.repository, + number: entry.number + ) + ) + } +} + +@MainActor +@Observable +final class PullRequestsModel { + var rows: [FeaturePullRequestRow] = [] + private var allRows: [FeaturePullRequestRow] = [] + var environments: [FeaturePullRequestEnvironmentList] = [] + var state: PullRequestListState = .open + var involvement: PullRequestInvolvement = .all + var query = "" + var draftFilter: String? + var reviewFilter: String? + var checksFilter: String? + var environmentFilter: String? + var hostFilter: String? + var projectFilter: String? + var isLoading = false + var isLoadingMore = false + var errorMessage: String? + + private let client: any FeatureClient + private var loadGeneration: UInt64 = 0 + private var loadedInput: PullRequestListInput? + + init(client: any FeatureClient) { + self.client = client + } + + func load(invalidate: Bool = false) async { + loadGeneration &+= 1 + let generation = loadGeneration + isLoading = true + isLoadingMore = false + errorMessage = nil + defer { + if loadGeneration == generation { + isLoading = false + } + } + do { + if invalidate { try await client.invalidatePullRequests(nil) } + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let filters = PullRequestListFilters( + draft: draftFilter, + review: reviewFilter, + checks: checksFilter + ) + let input = PullRequestListInput( + state: state, + involvement: involvement, + filters: filters == PullRequestListFilters() ? nil : filters, + query: trimmedQuery.isEmpty ? nil : trimmedQuery + ) + let result = try await client.pullRequestLists(input) + guard !Task.isCancelled, loadGeneration == generation else { return } + loadedInput = input + environments = result + updateRows() + } catch { + guard loadGeneration == generation, !(error is CancellationError) else { return } + errorMessage = error.localizedDescription + } + } + + var hasMorePages: Bool { + environments.contains { environment in + (environmentFilter == nil || environment.environmentID == environmentFilter) + && environment.result?.nextCursors.isEmpty == false + } + } + + func loadMore() async { + guard !isLoading, !isLoadingMore, let loadedInput else { return } + + let pending = environments.compactMap { environment -> (String, [String: String])? in + guard environmentFilter == nil || environment.environmentID == environmentFilter, + let cursors = environment.result?.nextCursors, + !cursors.isEmpty else { + return nil + } + return (environment.environmentID, cursors) + } + guard !pending.isEmpty else { return } + + let generation = loadGeneration + isLoadingMore = true + defer { + if loadGeneration == generation { + isLoadingMore = false + } + } + + for (environmentID, cursors) in pending { + guard !Task.isCancelled, loadGeneration == generation else { return } + + let input = PullRequestListInput( + state: loadedInput.state, + involvement: loadedInput.involvement, + filters: loadedInput.filters, + projectId: loadedInput.projectId, + projectIds: loadedInput.projectIds, + host: loadedInput.host, + limit: loadedInput.limit, + cursors: cursors, + query: loadedInput.query + ) + + do { + let pages = try await client.pullRequestLists( + input, + environmentID: environmentID + ) + guard !Task.isCancelled, loadGeneration == generation else { return } + guard let page = pages.first(where: { $0.environmentID == environmentID }), + let index = environments.firstIndex(where: { + $0.environmentID == environmentID + }) else { + continue + } + + let previous = environments[index] + let result: PullRequestListResult? = if let pageResult = page.result { + previous.result?.appending(pageResult) ?? pageResult + } else { + previous.result + } + environments[index] = FeaturePullRequestEnvironmentList( + environmentID: environmentID, + environmentName: page.environmentName, + result: result, + errorMessage: page.errorMessage + ) + updateRows() + } catch { + guard loadGeneration == generation, + !(error is CancellationError), + let index = environments.firstIndex(where: { + $0.environmentID == environmentID + }) else { + return + } + let previous = environments[index] + environments[index] = FeaturePullRequestEnvironmentList( + environmentID: environmentID, + environmentName: previous.environmentName, + result: previous.result, + errorMessage: error.localizedDescription + ) + } + } + } + + func applyLocalFilters() { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + rows = allRows.filter { row in + (environmentFilter == nil || row.environmentID == environmentFilter) + && (hostFilter == nil || row.entry.host == hostFilter) + && (projectFilter == nil + || "\(row.environmentID):\(row.entry.projectId)" == projectFilter) + && (needle.isEmpty + || row.entry.title.lowercased().contains(needle) + || row.entry.repository.lowercased().contains(needle) + || row.entry.author?.login.lowercased().contains(needle) == true + || String(row.entry.number) == needle) + } + } + + var environmentOptions: [(String, String)] { + Dictionary(uniqueKeysWithValues: environments.map { ($0.environmentID, $0.environmentName) }) + .sorted { $0.value < $1.value } + } + + var hostOptions: [String] { Array(Set(allRows.map(\.entry.host))).sorted() } + + var projectOptions: [(String, String)] { + let values = allRows.reduce(into: [String: String]()) { result, row in + result["\(row.environmentID):\(row.entry.projectId)"] = row.entry.projectTitle + } + return values.sorted { $0.value < $1.value } + } + + private func updateRows() { + var seenRowIDs = Set() + allRows = environments.flatMap { environment in + (environment.result?.entries ?? []).map { + FeaturePullRequestRow( + environmentID: environment.environmentID, + environmentName: environment.environmentName, + entry: $0 + ) + } + } + .filter { seenRowIDs.insert($0.id).inserted } + .sorted { $0.entry.updatedAt > $1.entry.updatedAt } + applyLocalFilters() + } +} + +public struct PullRequestsView: View { + @Bindable private var rootModel: FeatureRootModel + @State private var model: PullRequestsModel + @State private var searchTask: Task? + + public init(model: FeatureRootModel) { + rootModel = model + _model = State(initialValue: PullRequestsModel(client: model.client)) + } + + public var body: some View { + VStack(spacing: 0) { + filters + Divider().overlay(T3Colors.separator) + content + } + .background(T3Colors.background) + .navigationTitle("Pull Requests") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await model.load(invalidate: true) } } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(model.isLoading) + } + } + .t3NavigationChrome() + .task { await model.load() } + .onChange(of: model.state) { reload() } + .onChange(of: model.involvement) { reload() } + .onChange(of: model.draftFilter) { reload() } + .onChange(of: model.reviewFilter) { reload() } + .onChange(of: model.checksFilter) { reload() } + .onChange(of: model.environmentFilter) { model.applyLocalFilters() } + .onChange(of: model.hostFilter) { model.applyLocalFilters() } + .onChange(of: model.projectFilter) { model.applyLocalFilters() } + .onChange(of: model.query) { + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await model.load() + } + } + } + + private var filters: some View { + VStack(spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(T3Colors.textTertiary) + TextField("Search pull requests", text: $model.query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + filterMenu + } + .padding(.horizontal, 14) + .frame(minHeight: 44) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + + HStack(spacing: 12) { + Picker("State", selection: $model.state) { + ForEach(PullRequestListState.allCases, id: \.self) { + Text($0.label).tag($0) + } + } + .pickerStyle(.segmented) + + Menu { + Picker("Involvement", selection: $model.involvement) { + ForEach(PullRequestInvolvement.allCases, id: \.self) { + Text($0.label).tag($0) + } + } + } label: { + Label(model.involvement.label, systemImage: "person.2") + .font(T3Typography.control) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var filterMenu: some View { + Menu { + Menu("Drafts") { + filterButton("Any", value: nil, selection: $model.draftFilter) + filterButton("Only drafts", value: "only", selection: $model.draftFilter) + filterButton("Hide drafts", value: "hide", selection: $model.draftFilter) + } + Menu("Review") { + filterButton("Any", value: nil, selection: $model.reviewFilter) + filterButton("Approved", value: "approved", selection: $model.reviewFilter) + filterButton( + "Changes requested", + value: "changes-requested", + selection: $model.reviewFilter + ) + filterButton( + "Review required", + value: "review-required", + selection: $model.reviewFilter + ) + filterButton("No review", value: "none", selection: $model.reviewFilter) + } + Menu("Checks") { + filterButton("Any", value: nil, selection: $model.checksFilter) + filterButton("Passing", value: "passing", selection: $model.checksFilter) + filterButton("Failing", value: "failing", selection: $model.checksFilter) + } + Menu("Computer") { + filterButton("All computers", value: nil, selection: $model.environmentFilter) + ForEach(model.environmentOptions, id: \.0) { id, name in + filterButton(name, value: id, selection: $model.environmentFilter) + } + } + Menu("Host") { + filterButton("All hosts", value: nil, selection: $model.hostFilter) + ForEach(model.hostOptions, id: \.self) { host in + filterButton(host, value: host, selection: $model.hostFilter) + } + } + Menu("Project") { + filterButton("All projects", value: nil, selection: $model.projectFilter) + ForEach(model.projectOptions, id: \.0) { id, name in + filterButton(name, value: id, selection: $model.projectFilter) + } + } + if hasExtraFilters { + Divider() + Button("Clear filters") { + model.draftFilter = nil + model.reviewFilter = nil + model.checksFilter = nil + model.environmentFilter = nil + model.hostFilter = nil + model.projectFilter = nil + } + } + } label: { + Image(systemName: hasExtraFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .font(.system(size: 18)) + .foregroundStyle(hasExtraFilters ? T3Colors.accent : T3Colors.textSecondary) + } + } + + private func filterButton( + _ title: String, + value: String?, + selection: Binding + ) -> some View { + Button { + selection.wrappedValue = value + } label: { + if selection.wrappedValue == value { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + } + + private var hasExtraFilters: Bool { + model.draftFilter != nil || model.reviewFilter != nil || model.checksFilter != nil + || model.environmentFilter != nil || model.hostFilter != nil + || model.projectFilter != nil + } + + @ViewBuilder + private var content: some View { + if model.isLoading, model.rows.isEmpty { + ProgressView("Loading pull requests…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = model.errorMessage, model.rows.isEmpty { + ContentUnavailableView("Couldn’t load pull requests", systemImage: "exclamationmark.triangle", description: Text(error)) + } else { + List { + ForEach(model.environments.filter { $0.errorMessage != nil }) { environment in + Label { + VStack(alignment: .leading, spacing: 2) { + Text(environment.environmentName) + .font(T3Typography.supportingStrong) + Text("Unavailable. Other computers are still shown.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } icon: { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(T3Colors.warning) + } + } + + ForEach(model.rows) { row in + NavigationLink { + PullRequestDetailView(rootModel: rootModel, row: row) + } label: { + PullRequestRowView(row: row) + } + } + + if model.rows.isEmpty { + ContentUnavailableView( + "No pull requests", + systemImage: "arrow.triangle.pull", + description: Text("Try another state, involvement, or search.") + ) + .listRowBackground(Color.clear) + } + + if model.hasMorePages { + Button { + Task { await model.loadMore() } + } label: { + HStack { + Spacer() + if model.isLoadingMore { + ProgressView() + } + Text(model.isLoadingMore ? "Loading more..." : "Load more") + Spacer() + } + } + .disabled(model.isLoading || model.isLoadingMore) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .refreshable { await model.load(invalidate: true) } + } + } + + private func reload() { + Task { await model.load() } + } +} + +private struct PullRequestRowView: View { + let row: FeaturePullRequestRow + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: row.entry.state.systemImage) + .foregroundStyle(row.entry.state.color) + Text("\(row.entry.repository) #\(row.entry.number)") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 8) + Text(row.environmentName) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + Text(row.entry.title) + .font(T3Typography.threadBody.weight(.semibold)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(2) + HStack(spacing: 10) { + if let author = row.entry.author { Text(author.login) } + Text("\(row.entry.headBranch) → \(row.entry.baseBranch)") + .lineLimit(1) + Spacer(minLength: 4) + if row.entry.additions > 0 || row.entry.deletions > 0 { + Text("+\(row.entry.additions)").foregroundStyle(T3Colors.success) + Text("−\(row.entry.deletions)").foregroundStyle(T3Colors.danger) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(.vertical, 5) + } +} + +@MainActor +@Observable +private final class PullRequestDetailModel { + var detail: PullRequestDetail? + var activity: PullRequestActivity? + var diffFiles: [PullRequestDiffFile] = [] + var isDiffIncomplete = false + var isLoading = true + var isLoadingDiff = false + var isActing = false + var errorMessage: String? + var reviewDrafts: [PullRequestReviewCommentDraft] = [] + var reviewerCandidates: [PullRequestReviewerCandidate] = [] + var isLoadingReviewers = false + + private let client: any FeatureClient + let target: FeaturePullRequestTarget + + init(client: any FeatureClient, target: FeaturePullRequestTarget) { + self.client = client + self.target = target + } + + func load(invalidate: Bool = false) async { + isLoading = true + errorMessage = nil + do { + if invalidate { try await client.invalidatePullRequests(target) } + detail = try await client.pullRequestDetail(target) + activity = try? await client.pullRequestActivity(target) + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func loadDiff() async { + guard detail?.capabilities.diff == true, diffFiles.isEmpty, !isLoadingDiff else { return } + isLoadingDiff = true + do { + var cursor: String? + var pagination = PullRequestDiffPagination() + repeat { + let page = try await client.pullRequestDiff(target, cursor: cursor) + cursor = pagination.append(page) + } while cursor != nil + diffFiles = PullRequestDiffParser.parse(pagination.patch) + isDiffIncomplete = pagination.isIncomplete + } catch { + errorMessage = error.localizedDescription + } + isLoadingDiff = false + } + + func run( + _ action: PullRequestAction, + mergeMethod: PullRequestMergeMethod? = nil, + updateMethod: PullRequestUpdateMethod? = nil + ) async { + await mutate { + try await client.runPullRequestAction( + target, + action: action, + mergeMethod: mergeMethod, + updateMethod: updateMethod + ) + } + } + + func update(title: String? = nil, body: String? = nil) async { + await mutate { try await client.updatePullRequest(target, title: title, body: body) } + } + + func comment(_ body: String) async { + await mutate { try await client.commentOnPullRequest(target, body: body) } + } + + func review(verdict: PullRequestReviewVerdict, body: String) async -> Bool { + var submitted = false + await mutate { + try await client.submitPullRequestReview( + target, + verdict: verdict, + body: body, + comments: reviewDrafts + ) + reviewDrafts = [] + submitted = true + } + return submitted + } + + func reply(threadID: String, body: String) async { + await mutate { + try await client.replyToPullRequestThread(target, threadID: threadID, body: body) + } + } + + func resolve(thread: PullRequestReviewThread) async { + await mutate { + try await client.setPullRequestThreadResolved( + target, + threadID: thread.id, + resolved: !thread.isResolved + ) + } + } + + func react(subjectID: String?, reaction: PullRequestReactionContent, reacted: Bool) async { + await mutate { + try await client.setPullRequestReaction( + target, + subjectID: subjectID, + content: reaction, + reacted: reacted + ) + } + } + + func loadReviewers() async { + guard !isLoadingReviewers else { return } + isLoadingReviewers = true + do { + reviewerCandidates = try await client.pullRequestReviewerCandidates(target).candidates + } catch { + errorMessage = error.localizedDescription + } + isLoadingReviewers = false + } + + func toggleReviewer(_ reviewer: PullRequestReviewerCandidate) async { + await mutate { + try await client.requestPullRequestReviewers( + target, + reviewers: [reviewer], + requested: !reviewer.isRequested + ) + } + await loadReviewers() + } + + private func mutate(_ operation: () async throws -> Void) async { + isActing = true + do { + try await operation() + try await client.invalidatePullRequests(target) + await load() + } catch { + errorMessage = error.localizedDescription + } + isActing = false + } +} + +private enum PullRequestDetailTab: String, CaseIterable { + case summary = "Summary" + case conversation = "Activity" + case files = "Files" +} + +struct PullRequestDetailView: View { + private struct PendingAction: Identifiable { + let id = UUID() + let action: PullRequestAction + var mergeMethod: PullRequestMergeMethod? + var updateMethod: PullRequestUpdateMethod? + } + + @Bindable var rootModel: FeatureRootModel + let target: FeaturePullRequestTarget + @State private var model: PullRequestDetailModel + @State private var tab: PullRequestDetailTab = .summary + @State private var editor: PullRequestEditor? + @State private var reviewSheet = false + @State private var reviewerSheet = false + @State private var notice: String? + @State private var pendingAction: PendingAction? + + init(rootModel: FeatureRootModel, row: FeaturePullRequestRow) { + self.init(rootModel: rootModel, target: row.target) + } + + init(rootModel: FeatureRootModel, target: FeaturePullRequestTarget) { + self.rootModel = rootModel + self.target = target + _model = State(initialValue: PullRequestDetailModel(client: rootModel.client, target: target)) + } + + var body: some View { + Group { + if model.isLoading, model.detail == nil { + ProgressView("Loading pull request…") + } else if let detail = model.detail { + VStack(spacing: 0) { + detailHeader(detail) + Picker("Section", selection: $tab) { + ForEach(PullRequestDetailTab.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 10) + + Divider().overlay(T3Colors.separator) + tabContent(detail) + } + } else { + ContentUnavailableView( + "Couldn’t load pull request", + systemImage: "exclamationmark.triangle", + description: Text(model.errorMessage ?? "Try again.") + ) + } + } + .background(T3Colors.background) + .navigationTitle("#\(target.reference.number)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { actionMenu } + } + .t3NavigationChrome() + .task { await model.load() } + .onChange(of: tab) { _, value in + if value == .files { Task { await model.loadDiff() } } + } + .sheet(item: $editor) { editor in + PullRequestEditSheet(editor: editor) { value in + Task { + switch editor.kind { + case .title: await model.update(title: value) + case .body: await model.update(body: value) + case .comment: await model.comment(value) + } + } + } + } + .sheet(isPresented: $reviewSheet) { + PullRequestReviewSheet(model: model) + } + .sheet(isPresented: $reviewerSheet) { + PullRequestReviewerSheet(model: model) + } + .alert("Pull request", isPresented: Binding( + get: { notice != nil }, + set: { if !$0 { notice = nil } } + )) { Button("OK") {} } message: { Text(notice ?? "") } + .alert("Action failed", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { Button("OK") {} } message: { Text(model.errorMessage ?? "") } + .alert( + "Confirm pull request action", + isPresented: Binding( + get: { pendingAction != nil }, + set: { if !$0 { pendingAction = nil } } + ), + presenting: pendingAction + ) { pending in + Button(pending.action.label, role: .destructive) { + pendingAction = nil + Task { + await model.run( + pending.action, + mergeMethod: pending.mergeMethod, + updateMethod: pending.updateMethod + ) + } + } + Button("Cancel", role: .cancel) { pendingAction = nil } + } message: { pending in + Text("This action will \(pending.action.label.lowercased()).") + } + } + + private func detailHeader(_ detail: PullRequestDetail) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(detail.title) + .font(T3Typography.threadHeading3) + .foregroundStyle(T3Colors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 7) { + Label(detail.state.label, systemImage: detail.state.systemImage) + .foregroundStyle(detail.state.color) + Text("\(detail.repository) · \(target.environmentName)") + Spacer() + Text("+\(detail.additions)").foregroundStyle(T3Colors.success) + Text("−\(detail.deletions)").foregroundStyle(T3Colors.danger) + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(16) + } + + @ViewBuilder + private func tabContent(_ detail: PullRequestDetail) -> some View { + switch tab { + case .summary: + PullRequestSummaryView( + detail: detail, + activity: model.activity, + model: model, + onUpdateBranch: { method in + pendingAction = PendingAction(action: .updateBranch, updateMethod: method) + } + ) + case .conversation: + PullRequestActivityView(activity: model.activity, model: model) + case .files: + PullRequestFilesView( + files: model.diffFiles, + isLoading: model.isLoadingDiff, + isIncomplete: model.isDiffIncomplete, + drafts: $model.reviewDrafts, + canComment: detail.capabilities.review.inlineComment + && detail.viewerPermissions.comment, + sendToAgent: sendToAgent + ) + } + } + + @ViewBuilder + private var actionMenu: some View { + if let detail = model.detail { + Menu { + if detail.capabilities.edit?.changeRequest == true { + Button("Edit title", systemImage: "pencil") { + editor = PullRequestEditor(kind: .title, value: detail.title) + } + Button("Edit description", systemImage: "doc.text") { + editor = PullRequestEditor(kind: .body, value: detail.body) + } + } + if detail.capabilities.comment, detail.viewerPermissions.comment { + Button("Add comment", systemImage: "text.bubble") { + editor = PullRequestEditor(kind: .comment, value: "") + } + } + if !detail.viewerPermissions.verdicts.isEmpty { + Button("Review changes", systemImage: "checkmark.bubble") { reviewSheet = true } + } + if detail.capabilities.reviewers.request, + detail.capabilities.reviewers.listCandidates, + detail.viewerPermissions.requestReviewers { + Button("Manage reviewers", systemImage: "person.badge.plus") { + reviewerSheet = true + } + } + Divider() + ForEach(detail.viewerPermissions.actions, id: \.self) { action in + if detail.capabilities.actions.contains(action), + action != .merge, + action != .updateBranch { + Button(action.label, systemImage: action.systemImage) { + if action == .close || action == .enableAutoMerge { + pendingAction = PendingAction(action: action) + } else { + Task { await model.run(action) } + } + } + } + } + if detail.capabilities.actions.contains(.merge), + detail.viewerPermissions.actions.contains(.merge) { + Menu("Merge pull request") { + ForEach(availableMergeMethods(detail), id: \.self) { method in + Button(method.label) { + pendingAction = PendingAction(action: .merge, mergeMethod: method) + } + } + } + } + } label: { + if model.isActing { ProgressView() } else { Image(systemName: "ellipsis.circle") } + } + .disabled(model.isActing) + } + } + + private func availableMergeMethods(_ detail: PullRequestDetail) -> [PullRequestMergeMethod] { + detail.capabilities.mergeMethods.filter { + switch $0 { + case .merge: detail.mergeCapabilities.merge + case .squash: detail.mergeCapabilities.squash + case .rebase: detail.mergeCapabilities.rebase + } + } + } + + private func sendToAgent(_ line: PullRequestDiffLine, file: PullRequestDiffFile) { + guard let project = rootModel.snapshot.projects.first(where: { + $0.environmentID == target.environmentID + && ($0.wireID ?? $0.id) == target.reference.projectId + }) else { + notice = "The project for this pull request is not available on this computer." + return + } + guard let selection = DailyUXCreationContext.initialSelection( + for: project, + in: rootModel.snapshot + ) else { + notice = "Choose a default model for this project first." + return + } + let prompt = """ + Please inspect and address this line from pull request #\(target.reference.number) in \(target.reference.repository). + + File: \(file.path) + Line: \(line.displayLineNumber) + + ```diff + \(line.text) + ``` + """ + Task { + let thread = await rootModel.startTask( + NewTaskRequest( + projectID: project.id, + prompt: prompt, + selection: selection, + runtimeMode: .fullAccess, + interactionMode: .standard + ) + ) + notice = thread == nil ? "The task could not be started." : "Sent to a new agent thread." + } + } +} + +private struct PullRequestSummaryView: View { + let detail: PullRequestDetail + let activity: PullRequestActivity? + @Bindable var model: PullRequestDetailModel + let onUpdateBranch: (PullRequestUpdateMethod) -> Void + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + if detail.baseComparison == .behind, + detail.capabilities.actions.contains(.updateBranch), + detail.viewerPermissions.actions.contains(.updateBranch) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Branch is behind").font(T3Typography.supportingStrong) + Text(detail.behindBy.map { "\($0) commits behind \(detail.baseBranch)" } ?? "Update from \(detail.baseBranch)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + Menu("Update") { + ForEach(detail.viewerPermissions.updateMethods ?? [], id: \.self) { method in + Button(method.rawValue.capitalized) { + onUpdateBranch(method) + } + } + } + .buttonStyle(.borderedProminent) + } + .padding(14) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } + + if !detail.body.isEmpty { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Description") + MarkdownMessageView(detail.body, copyActionTitle: "Copy description") + } + } + + if !detail.checks.isEmpty { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Checks") + ForEach(detail.checks) { check in + HStack(spacing: 9) { + Image(systemName: check.status.systemImage) + .foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).font(T3Typography.control) + if let description = check.description { + Text(description) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer() + } + } + } + } + + if !detail.reviewers.isEmpty { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Reviewers") + ForEach(detail.reviewers, id: \.login) { reviewer in + Label(reviewer.name ?? reviewer.login, systemImage: "person.crop.circle") + .font(T3Typography.control) + } + } + } + + if detail.capabilities.reactions == true { + PullRequestReactionsView( + reactions: activity?.reactions ?? [], + onToggle: { reaction, reacted in + Task { await model.react(subjectID: nil, reaction: reaction, reacted: reacted) } + } + ) + } + } + .padding(16) + } + } + + private func sectionTitle(_ value: String) -> some View { + Text(value.uppercased()) + .font(T3Typography.eyebrow) + .foregroundStyle(T3Colors.textSecondary) + } +} + +private struct PullRequestActivityView: View { + let activity: PullRequestActivity? + @Bindable var model: PullRequestDetailModel + @State private var replyThread: PullRequestReviewThread? + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if let activity { + ForEach(activity.comments) { comment in + PullRequestCommentView(comment: comment, model: model) + } + ForEach(activity.reviewThreads) { thread in + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("\(thread.path):\(thread.line.map(String.init) ?? "file")", systemImage: "text.bubble") + .font(T3Typography.supportingStrong) + Spacer() + if model.detail?.capabilities.review.resolve == true, + model.detail?.viewerPermissions.resolve == true { + Button(thread.isResolved ? "Reopen" : "Resolve") { + Task { await model.resolve(thread: thread) } + } + .font(T3Typography.supportingStrong) + } + } + ForEach(thread.comments) { comment in + VStack(alignment: .leading, spacing: 5) { + Text(comment.author?.login ?? "Unknown") + .font(T3Typography.supportingStrong) + MarkdownMessageView(comment.body, copyActionTitle: "Copy comment") + if model.detail?.capabilities.reactions == true { + PullRequestReactionsView( + reactions: comment.reactions ?? [] + ) { reaction, reacted in + Task { + await model.react( + subjectID: comment.id, + reaction: reaction, + reacted: reacted + ) + } + } + } + } + } + if model.detail?.capabilities.review.reply == true, + model.detail?.viewerPermissions.comment == true { + Button("Reply") { replyThread = thread } + .font(T3Typography.control) + } + } + .padding(14) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } + ForEach(activity.commits) { commit in + HStack(spacing: 10) { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(commit.messageHeadline).lineLimit(2) + Spacer() + Text(String(commit.oid.prefix(7))).monospaced() + } + .font(T3Typography.supporting) + } + if activity.comments.isEmpty && activity.reviewThreads.isEmpty && activity.commits.isEmpty { + ContentUnavailableView("No activity", systemImage: "text.bubble") + } + } else { + ProgressView("Loading activity…") + } + } + .padding(16) + } + .sheet(item: $replyThread) { thread in + PullRequestTextSheet(title: "Reply", initialValue: "") { body in + Task { await model.reply(threadID: thread.id, body: body) } + } + } + } +} + +private struct PullRequestCommentView: View { + let comment: PullRequestComment + @Bindable var model: PullRequestDetailModel + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text(comment.author?.name ?? comment.author?.login ?? "Unknown") + .font(T3Typography.supportingStrong) + if let state = comment.reviewState { + Text(state.replacingOccurrences(of: "_", with: " ").lowercased()) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + } + if !comment.body.isEmpty { + MarkdownMessageView(comment.body, copyActionTitle: "Copy comment") + } + if model.detail?.capabilities.reactions == true { + PullRequestReactionsView(reactions: comment.reactions ?? []) { reaction, reacted in + Task { + await model.react( + subjectID: comment.id, + reaction: reaction, + reacted: reacted + ) + } + } + } + } + .padding(14) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } +} + +private struct PullRequestReactionsView: View { + let reactions: [PullRequestReaction] + let onToggle: (PullRequestReactionContent, Bool) -> Void + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 7) { + ForEach(reactions) { reaction in + Button { + onToggle(reaction.content, !reaction.viewerHasReacted) + } label: { + Text("\(reaction.content.emoji) \(reaction.count)") + } + .buttonStyle(.bordered) + .tint(reaction.viewerHasReacted ? T3Colors.accent : T3Colors.textSecondary) + } + Menu { + ForEach(PullRequestReactionContent.allCases, id: \.self) { reaction in + Button("\(reaction.emoji) \(reaction.label)") { onToggle(reaction, true) } + } + } label: { + Image(systemName: "face.smiling") + } + .buttonStyle(.bordered) + } + } + .scrollIndicators(.hidden) + } +} + +private struct PullRequestFilesView: View { + let files: [PullRequestDiffFile] + let isLoading: Bool + let isIncomplete: Bool + @Binding var drafts: [PullRequestReviewCommentDraft] + let canComment: Bool + let sendToAgent: (PullRequestDiffLine, PullRequestDiffFile) -> Void + @State private var commentingLine: PullRequestDiffSelection? + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if isIncomplete { + Label( + "Some changes are missing from this diff.", + systemImage: "exclamationmark.triangle" + ) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.warning) + } + if isLoading { + ProgressView("Loading diff…") + .frame(maxWidth: .infinity) + .padding(.top, 50) + } else if files.isEmpty { + ContentUnavailableView("No diff available", systemImage: "doc.text.magnifyingglass") + } else { + ForEach(files) { file in + VStack(alignment: .leading, spacing: 0) { + Text(file.path) + .font(T3Typography.supportingStrong.monospaced()) + .padding(12) + Divider().overlay(T3Colors.separator) + ScrollView(.horizontal) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(file.lines) { line in + HStack(spacing: 0) { + Text(line.oldLine.map(String.init) ?? "") + .frame(width: 42, alignment: .trailing) + Text(line.newLine.map(String.init) ?? "") + .frame(width: 42, alignment: .trailing) + Text(line.text) + .padding(.leading, 10) + .frame(minWidth: 500, alignment: .leading) + } + .font(T3Typography.code) + .t3CodeTextSize() + .foregroundStyle(line.foreground) + .padding(.vertical, 2) + .background(line.background) + .contentShape(Rectangle()) + .contextMenu { + if canComment, line.position != nil { + Button("Add review comment", systemImage: "text.bubble") { + commentingLine = PullRequestDiffSelection(file: file, line: line) + } + } + Button("Send line to agent", systemImage: "paperplane") { + sendToAgent(line, file) + } + } + } + } + } + } + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border)) + } + } + } + .padding(16) + } + .sheet(item: $commentingLine) { selection in + PullRequestTextSheet(title: "Review comment", initialValue: "") { body in + guard let position = selection.line.position else { return } + drafts.append( + PullRequestReviewCommentDraft( + path: selection.file.path, + oldPath: selection.file.oldPath, + position: position, + body: body + ) + ) + } + } + } +} + +private struct PullRequestReviewSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: PullRequestDetailModel + @State private var verdict: PullRequestReviewVerdict = .comment + @State private var reviewBody = "" + + var body: some View { + NavigationStack { + Form { + Picker("Verdict", selection: $verdict) { + ForEach(model.detail?.viewerPermissions.verdicts ?? [], id: \.self) { + Text($0.label).tag($0) + } + } + Section("Summary") { + TextEditor(text: $reviewBody).frame(minHeight: 130) + } + if !model.reviewDrafts.isEmpty { + Section("Inline comments") { + ForEach(model.reviewDrafts) { draft in + VStack(alignment: .leading, spacing: 4) { + Text(draft.path).font(T3Typography.supportingStrong.monospaced()) + Text(draft.body).font(T3Typography.threadBody) + } + } + .onDelete { model.reviewDrafts.remove(atOffsets: $0) } + } + } + } + .navigationTitle("Submit Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Submit") { + Task { + if await model.review(verdict: verdict, body: reviewBody) { + dismiss() + } + } + } + .disabled(model.isActing) + } + } + .onAppear { + verdict = model.detail?.viewerPermissions.verdicts.first ?? .comment + } + } + } +} + +private struct PullRequestReviewerSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: PullRequestDetailModel + + var body: some View { + NavigationStack { + List { + if model.isLoadingReviewers, model.reviewerCandidates.isEmpty { + ProgressView("Loading reviewers…") + } + ForEach(model.reviewerCandidates) { reviewer in + Button { + Task { await model.toggleReviewer(reviewer) } + } label: { + HStack { + Image(systemName: reviewer.kind == "team" ? "person.3" : "person.crop.circle") + VStack(alignment: .leading, spacing: 2) { + Text(reviewer.name ?? reviewer.login) + .foregroundStyle(T3Colors.textPrimary) + if reviewer.name != nil { + Text(reviewer.login) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer() + if reviewer.isRequested { Image(systemName: "checkmark") } + } + } + } + } + .navigationTitle("Reviewers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + .task { await model.loadReviewers() } + } + } +} + +private struct PullRequestEditor: Identifiable { + enum Kind { case title, body, comment } + let id = UUID() + let kind: Kind + let value: String +} + +private struct PullRequestEditSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let editor: PullRequestEditor + let save: (String) -> Void + @State private var value: String + + init(editor: PullRequestEditor, save: @escaping (String) -> Void) { + self.editor = editor + self.save = save + _value = State(initialValue: editor.value) + } + + var body: some View { + PullRequestTextSheet(title: editor.kind.title, initialValue: editor.value, save: save) + } +} + +private struct PullRequestTextSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let title: String + let save: (String) -> Void + @State private var value: String + + init(title: String, initialValue: String, save: @escaping (String) -> Void) { + self.title = title + self.save = save + _value = State(initialValue: initialValue) + } + + var body: some View { + NavigationStack { + TextEditor(text: $value) + .font(T3Typography.threadBody) + .padding(12) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save(value); dismiss() } + .disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +struct PullRequestDiffFile: Identifiable, Equatable { + let id: String + let path: String + let oldPath: String? + let lines: [PullRequestDiffLine] +} + +struct PullRequestDiffLine: Identifiable, Equatable { + enum Kind { case context, added, deleted, header } + let id: Int + let kind: Kind + let text: String + let oldLine: Int? + let newLine: Int? + + var displayLineNumber: Int { newLine ?? oldLine ?? 0 } + var position: PullRequestReviewPosition? { + switch kind { + case .added: newLine.map(PullRequestReviewPosition.added) + case .deleted: oldLine.map(PullRequestReviewPosition.deleted) + case .context: + if let oldLine, let newLine { + PullRequestReviewPosition.context(old: oldLine, new: newLine, side: .right) + } else { nil } + case .header: nil + } + } + var foreground: Color { + switch kind { + case .added: T3Colors.success + case .deleted: T3Colors.danger + case .header: T3Colors.accent + case .context: T3Colors.textPrimary + } + } + var background: Color { + switch kind { + case .added: T3Colors.success.opacity(0.08) + case .deleted: T3Colors.danger.opacity(0.08) + default: .clear + } + } +} + +private struct PullRequestDiffSelection: Identifiable { + var id: String { "\(file.id):\(line.id)" } + let file: PullRequestDiffFile + let line: PullRequestDiffLine +} + +struct PullRequestDiffPagination { + private(set) var patch = "" + private(set) var isIncomplete = false + private var seenCursors = Set() + + mutating func append(_ page: PullRequestDiffResult) -> String? { + patch += page.patch + isIncomplete = isIncomplete || page.truncated + || !(page.omittedFileStats ?? []).isEmpty + + guard let cursor = page.nextCursor, !cursor.isEmpty else { return nil } + guard seenCursors.insert(cursor).inserted else { + isIncomplete = true + return nil + } + return cursor + } +} + +enum PullRequestDiffParser { + static func parse(_ patch: String) -> [PullRequestDiffFile] { + var files: [PullRequestDiffFile] = [] + var path = "" + var oldPath: String? + var lines: [PullRequestDiffLine] = [] + var oldLine = 0 + var newLine = 0 + var lineID = 0 + + func finish() { + guard !path.isEmpty else { return } + files.append(.init(id: "\(files.count):\(path)", path: path, oldPath: oldPath, lines: lines)) + lines = [] + oldPath = nil + } + + for raw in patch.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { + if raw == "\\ No newline at end of file" { + continue + } + if raw.hasPrefix("diff --git ") { + finish() + let parts = raw.split(separator: " ") + path = parts.count > 3 ? String(parts[3]).replacingOccurrences(of: "b/", with: "", options: .anchored) : "Changed file" + continue + } + if raw.hasPrefix("--- ") { + let value = String(raw.dropFirst(4)) + oldPath = value == "/dev/null" ? nil : value.replacingOccurrences(of: "a/", with: "", options: .anchored) + continue + } + if raw.hasPrefix("+++ ") { + let value = String(raw.dropFirst(4)) + if value != "/dev/null" { path = value.replacingOccurrences(of: "b/", with: "", options: .anchored) } + continue + } + if raw.hasPrefix("@@") { + let parts = raw.split(separator: " ") + oldLine = parts.count > 1 ? startLine(String(parts[1])) : 0 + newLine = parts.count > 2 ? startLine(String(parts[2])) : 0 + lines.append(.init(id: lineID, kind: .header, text: raw, oldLine: nil, newLine: nil)) + } else if raw.hasPrefix("+") { + lines.append(.init(id: lineID, kind: .added, text: raw, oldLine: nil, newLine: newLine)) + newLine += 1 + } else if raw.hasPrefix("-") { + lines.append(.init(id: lineID, kind: .deleted, text: raw, oldLine: oldLine, newLine: nil)) + oldLine += 1 + } else { + lines.append(.init(id: lineID, kind: .context, text: raw, oldLine: oldLine, newLine: newLine)) + oldLine += 1 + newLine += 1 + } + lineID += 1 + } + finish() + return files + } + + private static func startLine(_ range: String) -> Int { + Int(range.dropFirst().split(separator: ",").first ?? "0") ?? 0 + } +} + +private extension PullRequestListState { + var label: String { rawValue.capitalized } +} + +private extension PullRequestInvolvement { + var label: String { rawValue.capitalized } +} + +private extension PullRequestState { + var label: String { rawValue.capitalized } + var systemImage: String { + switch self { + case .open: "arrow.triangle.pull" + case .closed: "xmark.circle" + case .merged: "arrow.triangle.merge" + } + } + var color: Color { + switch self { + case .open: T3Colors.success + case .closed: T3Colors.danger + case .merged: T3Colors.syntaxKeyword + } + } +} + +private extension PullRequestCheckStatus { + var systemImage: String { + switch self { + case .success: "checkmark.circle.fill" + case .failure: "xmark.circle.fill" + case .pending: "clock" + case .skipped, .neutral, .cancelled: "minus.circle" + } + } + var color: Color { + switch self { + case .success: T3Colors.success + case .failure: T3Colors.danger + case .pending: T3Colors.warning + case .skipped, .neutral, .cancelled: T3Colors.textTertiary + } + } +} + +private extension PullRequestAction { + var label: String { + switch self { + case .merge: "Merge pull request" + case .ready: "Mark ready for review" + case .draft: "Convert to draft" + case .close: "Close pull request" + case .reopen: "Reopen pull request" + case .updateBranch: "Update branch" + case .enableAutoMerge: "Enable auto-merge" + case .disableAutoMerge: "Disable auto-merge" + } + } + var systemImage: String { + switch self { + case .merge: "arrow.triangle.merge" + case .ready: "checkmark.circle" + case .draft: "pencil.circle" + case .close: "xmark.circle" + case .reopen: "arrow.uturn.backward.circle" + case .updateBranch: "arrow.clockwise" + case .enableAutoMerge: "bolt.circle" + case .disableAutoMerge: "bolt.slash.circle" + } + } +} + +private extension PullRequestReviewVerdict { + var label: String { + switch self { + case .comment: "Comment" + case .approve: "Approve" + case .requestChanges: "Request changes" + } + } +} + +private extension PullRequestMergeMethod { + var label: String { + switch self { + case .merge: "Create merge commit" + case .squash: "Squash and merge" + case .rebase: "Rebase and merge" + } + } +} + +private extension PullRequestReactionContent { + var emoji: String { + switch self { + case .thumbsUp: "👍" + case .thumbsDown: "👎" + case .laugh: "😄" + case .hooray: "🎉" + case .confused: "😕" + case .heart: "❤️" + case .rocket: "🚀" + case .eyes: "👀" + } + } + var label: String { rawValue.replacingOccurrences(of: "-", with: " ").capitalized } +} + +private extension PullRequestEditor.Kind { + var title: String { + switch self { + case .title: "Edit Title" + case .body: "Edit Description" + case .comment: "Add Comment" + } + } +} diff --git a/apps/swift-ios/Features/Review/FeatureReviewView.swift b/apps/swift-ios/Features/Review/FeatureReviewView.swift new file mode 100644 index 000000000000..28e4267dec18 --- /dev/null +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -0,0 +1,527 @@ +import SwiftUI +import UIKit + +public struct FeatureReviewView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + let client: any FeatureClient + let threadID: String + + @State private var review: FeatureReview? + @State private var isLoading = true + @State private var errorMessage: String? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + Group { + if isLoading, review == nil { + ProgressView("Loading changes…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let review { + reviewList(review) + } else { + ContentUnavailableView( + "Review unavailable", + systemImage: "doc.text.magnifyingglass", + description: Text(errorMessage ?? "Changes could not be loaded.") + ) + } + } + .background(T3Colors.background) + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("Reload changes") + } + } + .task { await load() } + .onChange(of: scenePhase) { _, phase in + guard phase == .active, review != nil, !isLoading else { return } + Task { await load() } + } + } + + private func reviewList(_ review: FeatureReview) -> some View { + List { + Section { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(review.title) + .font(T3Typography.navigationTitle) + if let base = review.baseReference { + Text(base) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer() + FeatureDiffStatsLabel(additions: review.additions, deletions: review.deletions) + } + .padding(.vertical, 3) + + if review.isTruncated { + Label("Large diff, showing a partial result", systemImage: "exclamationmark.triangle") + .font(T3Typography.supporting) + .foregroundStyle(.orange) + } + } + + Section("\(review.files.count) changed \(review.files.count == 1 ? "file" : "files")") { + if review.files.isEmpty { + ContentUnavailableView( + "No changes", + systemImage: "checkmark.circle", + description: Text("The working tree is clean.") + ) + .listRowBackground(Color.clear) + } + ForEach(review.files) { file in + NavigationLink { + FeatureDiffView(client: client, threadID: threadID, file: file) + } label: { + FeatureReviewFileRow(file: file) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .refreshable { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + review = try await client.loadReview(threadID: threadID) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureReviewFileRow: View { + let file: FeatureReviewFile + + var body: some View { + HStack(spacing: 10) { + Text(changeLabel) + .font(.caption2.monospaced().weight(.bold)) + .foregroundStyle(changeColor) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(fileName) + .font(T3Typography.homeTitle) + .lineLimit(1) + if !directory.isEmpty { + Text(directory) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + } + Spacer() + FeatureDiffStatsLabel(additions: file.additions, deletions: file.deletions) + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + } + + private var fileName: String { + file.path.split(separator: "/").last.map(String.init) ?? file.path + } + + private var directory: String { + let components = file.path.split(separator: "/") + return components.dropLast().joined(separator: "/") + } + + private var changeLabel: String { + switch file.change { + case .added: "A" + case .modified: "M" + case .deleted: "D" + case .renamed: "R" + case .binary: "B" + } + } + + private var changeColor: Color { + switch file.change { + case .added: .green + case .deleted: .red + case .renamed: .blue + case .modified, .binary: .orange + } + } +} + +struct FeatureDiffStatsLabel: View { + let additions: Int + let deletions: Int + + var body: some View { + HStack(spacing: 5) { + if additions > 0 { + Text("+\(additions)").foregroundStyle(.green) + } + if deletions > 0 { + Text("−\(deletions)").foregroundStyle(.red) + } + } + .font(T3Typography.tool.monospacedDigit().weight(.medium)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(additions) additions, \(deletions) deletions") + } +} + +private struct FeatureDiffView: View { + let client: any FeatureClient + let threadID: String + let file: FeatureReviewFile + + @State private var renderedLines: [FeatureDiffLine] + @State private var isHydrating = false + @State private var selectedLine: FeatureReviewLineSelection? + @State private var isCommenting = false + @State private var comment = "" + @State private var isSending = false + @State private var commentError: String? + @FocusState private var isCommentFocused: Bool + + init(client: any FeatureClient, threadID: String, file: FeatureReviewFile) { + self.client = client + self.threadID = threadID + self.file = file + _renderedLines = State(initialValue: file.lines) + } + + var body: some View { + Group { + if renderedLines.isEmpty, isHydrating { + ProgressView("Loading full diff…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if renderedLines.isEmpty { + ContentUnavailableView( + file.change == .binary ? "Binary file" : "Diff unavailable", + systemImage: file.change == .binary ? "doc.richtext" : "doc.text.magnifyingglass", + description: Text("No line-level preview is available.") + ) + } else { + GeometryReader { proxy in + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(renderedLines) { line in + FeatureDiffLineRow( + line: line, + isSelected: selection(for: line) == selectedLine, + minimumWidth: proxy.size.width + ) { + guard let selection = selection(for: line) else { return } + selectedLine = selection + openCommentComposer() + } + } + } + .frame(minWidth: proxy.size.width, alignment: .leading) + .padding(.vertical, 8) + } + } + } + } + .background(T3Colors.background) + .navigationTitle(file.path.split(separator: "/").last.map(String.init) ?? file.path) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + selectedLine = nil + openCommentComposer() + } label: { + Image(systemName: "text.bubble") + } + .accessibilityLabel("Add file review comment") + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if isCommenting { + commentComposer + } + } + .task(id: file.id) { await hydrate() } + } + + private var commentComposer: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("REVIEW COMMENT") + .font(T3Typography.eyebrow) + .foregroundStyle(T3Colors.textTertiary) + Text(commentLocation) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button { + isCommenting = false + isCommentFocused = false + commentError = nil + } label: { + Image(systemName: "xmark") + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Close review comment") + } + + TextField( + "What should change?", + text: $comment, + axis: .vertical + ) + .font(T3Typography.composer) + .lineLimit(2 ... 6) + .focused($isCommentFocused) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(T3Colors.input) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(T3Colors.border, lineWidth: 1) + } + + if let commentError { + Text(commentError) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + } + + HStack(spacing: 10) { + Button { + UIPasteboard.general.string = reviewDraft.prompt + } label: { + Label("Copy prompt", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity, minHeight: 42) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .disabled(trimmedComment.isEmpty) + + Button { + sendComment() + } label: { + HStack(spacing: 7) { + if isSending { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.up") + } + Text("Send to agent") + } + .frame(maxWidth: .infinity, minHeight: 42) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(T3Colors.accent) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .disabled(trimmedComment.isEmpty || isSending) + } + .font(T3Typography.control) + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 8) + .background(T3Colors.surface) + .overlay(alignment: .top) { + Rectangle() + .fill(T3Colors.separator) + .frame(height: 1) + } + } + + private var trimmedComment: String { + comment.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var reviewDraft: FeatureReviewCommentDraft { + FeatureReviewCommentDraft(filePath: file.path, line: selectedLine, body: comment) + } + + private var commentLocation: String { + guard let selectedLine else { return file.path } + return "\(file.path) · \(selectedLine.side.rawValue) line \(selectedLine.line)" + } + + private func selection(for line: FeatureDiffLine) -> FeatureReviewLineSelection? { + if let newLine = line.newLine { + return FeatureReviewLineSelection(side: .new, line: newLine) + } + if let oldLine = line.oldLine { + return FeatureReviewLineSelection(side: .old, line: oldLine) + } + return nil + } + + private func openCommentComposer() { + isCommenting = true + commentError = nil + Task { @MainActor in + await Task.yield() + isCommentFocused = true + } + } + + private func hydrate() async { + isHydrating = true + defer { isHydrating = false } + guard let contents = try? await client.loadReviewFileContents( + threadID: threadID, + file: file + ) else { + return + } + renderedLines = FeatureFullDiffHydrator.lines(for: file, contents: contents) + } + + private func sendComment() { + guard !trimmedComment.isEmpty, !isSending else { return } + let prompt = reviewDraft.prompt + isSending = true + commentError = nil + Task { + do { + try await client.sendMessage(threadID: threadID, text: prompt, selection: nil) + comment = "" + selectedLine = nil + isCommenting = false + isCommentFocused = false + } catch { + commentError = error.localizedDescription + } + isSending = false + } + } +} + +private struct FeatureDiffLineRow: View { + let line: FeatureDiffLine + let isSelected: Bool + let minimumWidth: CGFloat + let select: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if line.kind == .hunk { + Text(line.text) + .foregroundStyle(.blue) + .padding(.horizontal, 10) + .fixedSize(horizontal: true, vertical: false) + } else { + lineNumber(line.oldLine) + lineNumber(line.newLine) + Text(prefix) + .foregroundStyle(prefixColor) + .frame(width: 18) + diffText + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + .padding(.trailing, 12) + } + } + .font(T3Typography.code) + .t3CodeTextSize() + .fixedSize(horizontal: true, vertical: false) + .frame( + minWidth: minimumWidth, + minHeight: line.kind == .hunk ? 30 : 22, + alignment: .leading + ) + .background(isSelected ? T3Colors.accent.opacity(0.14) : background) + .overlay(alignment: .leading) { + if isSelected { + Rectangle() + .fill(T3Colors.accent) + .frame(width: 2) + } + } + .contentShape(Rectangle()) + .onTapGesture(perform: select) + .accessibilityAction(named: "Add review comment", select) + } + + private func lineNumber(_ value: Int?) -> some View { + Text(value.map(String.init) ?? "") + .foregroundStyle(.tertiary) + .frame(width: 48, alignment: .trailing) + .padding(.trailing, 7) + .accessibilityHidden(true) + } + + private var prefix: String { + switch line.kind { + case .addition: "+" + case .deletion: "−" + case .context, .hunk: " " + } + } + + private var prefixColor: Color { + switch line.kind { + case .addition: .green + case .deletion: .red + case .context, .hunk: .secondary + } + } + + @ViewBuilder + private var diffText: some View { + if let spans = line.spans, !spans.isEmpty { + HStack(spacing: 0) { + ForEach(spans.indices, id: \.self) { index in + let span = spans[index] + Text(verbatim: span.text.isEmpty ? " " : span.text) + .foregroundStyle(.primary) + .fontWeight(span.kind == .changed ? .semibold : .regular) + .background(span.kind == .changed ? changedSpanBackground : Color.clear) + } + } + } else { + Text(line.text.isEmpty ? " " : line.text) + .foregroundStyle(.primary) + } + } + + private var changedSpanBackground: Color { + switch line.kind { + case .addition: Color.green.opacity(0.28) + case .deletion: Color.red.opacity(0.28) + case .context, .hunk: Color.clear + } + } + + private var background: Color { + switch line.kind { + case .addition: Color.green.opacity(0.11) + case .deletion: Color.red.opacity(0.11) + case .hunk: Color.blue.opacity(0.08) + case .context: Color.clear + } + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift new file mode 100644 index 000000000000..1403b9e86715 --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -0,0 +1,1824 @@ +import Foundation +import Observation + +private struct FeatureConnectionUnavailableError: LocalizedError { + var errorDescription: String? { + "Could not connect to the selected computer." + } +} + +enum FeatureDetailRenderChange: Equatable { + case full + case delta(FeatureDetailDelta) +} + +struct FeatureDetailRenderUpdate: Equatable { + let baseRevision: UInt64 + let revision: UInt64 + let change: FeatureDetailRenderChange +} + +enum FeatureThreadLoadState: Equatable { + case loading + case failed(String) +} + +@MainActor +@Observable +public final class FeatureRootModel { + private static let maximumRetainedThreadDetails = 6 + + private struct PendingSettlementMutation { + let id: UUID + let settled: Bool + let settledAt: Date? + let unsettledAt: Date? + + func apply(to thread: inout FeatureThread) { + thread.isSettled = settled + thread.keepsActive = !settled + thread.settlementFacts?.settlementOverride = settled ? .settled : .active + thread.settledAt = settledAt + thread.unsettledAt = unsettledAt + if settled { + thread.pinnedAt = nil + } + } + } + + public private(set) var snapshot = FeatureSnapshot() + private(set) var pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + private var pullRequestObservationIdentities: [String: String] = [:] + public private(set) var details: [String: FeatureThreadDetail] = [:] + private(set) var detailLoadStates: [String: FeatureThreadLoadState] = [:] + private(set) var threadSyncStates: [String: FeatureThreadSyncState] = [:] + private var backgroundedAt: Date? + /// Advances whenever a Home presentation input changes. + public private(set) var homePresentationRevision: UInt64 = 0 + /// Advances when a Home-visible thread is inserted, removed, or changed. + public private(set) var threadCollectionRevision: UInt64 = 0 + /// Advances for any selected-thread metadata, message, approval, or input change. + public private(set) var detailRevision: UInt64 = 0 + /// The latest detail revision for each loaded thread. + public private(set) var detailRevisions: [String: UInt64] = [:] + private(set) var detailRenderUpdates: [String: FeatureDetailRenderUpdate] = [:] + public private(set) var isLoading = true + public private(set) var isPerformingAction = false + public private(set) var isManagingConnections = false + private(set) var isSigningOutT3Connect = false + public var errorMessage: String? + + let client: any FeatureClient + private let outboxStore: FeatureOutboxStore + private let draftStore: FeatureComposerDraftStore + @ObservationIgnored + public private(set) lazy var attachmentUploads = FeatureAttachmentUploadCoordinator( + client: client, + draftStore: draftStore + ) + private var pendingSubmissionsByID: [String: FeatureQueuedSubmission] = [:] + private var pendingThreadsByID: [String: FeatureThread] = [:] + private var pendingSettlementMutations: [String: PendingSettlementMutation] = [:] + private var pendingCompletionSubmissionIDs: Set = [] + private var pendingDiscardSubmissionIDs: Set = [] + private var detailRecency: [String] = [] + private var detailLoadGeneration: UInt64 = 0 + private var detailLoadRevisions: [String: UInt64] = [:] + private var detailLoadRequestRevision: UInt64 = 0 + private var activeDetailLoadRequests: [String: UInt64] = [:] + private var storedDetailLoadRequestRevisions: [String: UInt64] = [:] + private var detailMetadataRevisions: [String: UInt64] = [:] + private var outboxDrainTask: Task? + private var outboxRetryAttempt = 0 + private var outboxGeneration: UInt64 = 0 + private var lastPersistedSettings = FeatureSettings() + private var settingsWriteTask: Task? + private var settingsWriteGeneration: UInt64 = 0 + private var settingsChangeRevision: UInt64 = 0 + + public init( + client: any FeatureClient, + outboxStore: FeatureOutboxStore = .shared, + draftStore: FeatureComposerDraftStore = .shared + ) { + self.client = client + self.outboxStore = outboxStore + self.draftStore = draftStore + } + + public func start() async { + do { + install(try await client.initialSnapshot()) + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + } + await restoreOutbox() + isLoading = false + scheduleOutboxDrain() + + for await event in client.events() { + apply(event) + } + } + + public func reload() async { + do { + install(try await client.initialSnapshot()) + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + } + } + + func applicationDidEnterBackground(at date: Date = .now) { + backgroundedAt = date + } + + func applicationDidBecomeActive(at date: Date = .now) async { + guard let backgroundedAt else { return } + self.backgroundedAt = nil + await client.resumeAfterBackground( + reconnect: date.timeIntervalSince(backgroundedAt) >= 10 + ) + } + + /// Background refresh is deliberately separate from `reload()`: native + /// clients must not mount WebSocket streams or timers for a bounded BG task. + public func refreshInBackground() async -> Bool { + do { + install(try await client.backgroundSnapshot()) + return !Task.isCancelled + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + @discardableResult + public func refreshProviders(environmentID: String) async -> Bool { + await perform { + let providers = try await client.refreshProviders(environmentID: environmentID) + var byEnvironment = snapshot.providersByEnvironment ?? [:] + byEnvironment[environmentID] = providers + snapshot.providersByEnvironment = byEnvironment + } + } + + public func reloadAfterConnection() async { + clearDetails() + await reload() + } + + func refreshWorkspaceProviders(environmentID: String, cwd: String, instanceID: String) async { + do { + let providers = try await client.refreshWorkspaceProviders(environmentID: environmentID, cwd: cwd, instanceID: instanceID) + try Task.checkCancellation() + var byEnvironment = snapshot.providersByEnvironment ?? [:] + byEnvironment[environmentID] = providers + snapshot.providersByEnvironment = byEnvironment + } catch { + // Older or offline servers retain their last catalog. Do not block composing. + } + } + + public func pair(endpoint: String, token: String?) async -> Bool { + await perform { + try await client.pair(endpoint: endpoint, token: token) + let next = try await client.initialSnapshot() + clearDetails() + install(next) + guard next.connection.state != .disconnected else { + throw FeatureConnectionUnavailableError() + } + } + } + + public func removeEnvironment(_ id: String) async { + var logicalProjectIDs = Set(snapshot.projects.compactMap { project in + guard project.environmentID == id, project.repositoryIdentity != nil else { + return nil + } + return DailyUXCreationContext.logicalProjectID(for: project, in: snapshot) + }) + let remainingLogicalProjectIDs = Set(snapshot.projects.compactMap { project in + guard project.environmentID != id, project.repositoryIdentity != nil else { + return nil + } + return DailyUXCreationContext.logicalProjectID(for: project, in: snapshot) + }) + logicalProjectIDs.subtract(remainingLogicalProjectIDs) + await stopOutboxDrain() + await perform { + try await client.removeEnvironment(id: id) + var cleanupError: (any Error)? + do { + try await outboxStore.removeAll(environmentID: id) + removePendingSubmissions(environmentID: id) + } catch { + markPendingSubmissionsForDiscard(environmentID: id) + cleanupError = error + } + do { + try await draftStore.removeDrafts( + environmentID: id, + logicalProjectIDs: logicalProjectIDs + ) + } catch { + cleanupError = cleanupError ?? error + } + if let cleanupError { + errorMessage = "Environment removed, but its queued messages or drafts could not be cleared: \(cleanupError.localizedDescription)" + } + install(try await client.initialSnapshot()) + clearDetails() + } + scheduleOutboxDrain() + } + + public func signOutT3Connect() async { + guard let capability = client as? any T3ConnectCapable else { return } + isSigningOutT3Connect = true + defer { isSigningOutT3Connect = false } + let removedEnvironmentIDs = snapshot.environments + .filter { $0.source == .t3Connect } + .map(\.id) + let removedEnvironmentIDSet = Set(removedEnvironmentIDs) + let groupedProjects = Dictionary( + grouping: snapshot.projects.filter { $0.repositoryIdentity != nil }, + by: \.environmentID + ) + let retainedLogicalProjectIDs = Set(snapshot.projects.compactMap { project in + guard project.repositoryIdentity != nil, + !removedEnvironmentIDSet.contains(project.environmentID) else { + return nil + } + return DailyUXCreationContext.logicalProjectID(for: project, in: snapshot) + }) + let logicalProjectIDs = removedEnvironmentIDs.reduce(into: [String: Set]()) { + result, environmentID in + let projectIDs = Set((groupedProjects[environmentID] ?? []).map { + DailyUXCreationContext.logicalProjectID(for: $0, in: snapshot) + }) + result[environmentID] = projectIDs.subtracting(retainedLogicalProjectIDs) + } + + await stopOutboxDrain() + await capability.signOutT3Connect() + for environmentID in removedEnvironmentIDs { + var cleanupError: (any Error)? + do { + try await outboxStore.removeAll(environmentID: environmentID) + } catch { + cleanupError = error + } + removePendingSubmissions(environmentID: environmentID) + do { + try await draftStore.removeDrafts( + environmentID: environmentID, + logicalProjectIDs: logicalProjectIDs[environmentID] ?? [] + ) + } catch { + cleanupError = cleanupError ?? error + } + if let cleanupError { + errorMessage = "Could not clear saved T3 Connect data: \(cleanupError.localizedDescription)" + } + } + clearDetails() + await reload() + scheduleOutboxDrain() + } + + func removeManagedEnvironmentsAfterAccountChange() async { + let managedIDs = snapshot.environments + .filter { $0.source == .t3Connect } + .map(\.id) + for id in managedIDs { + await removeEnvironment(id) + } + } + + @discardableResult + public func setEnvironmentEnabled(_ id: String, enabled: Bool) async -> Bool { + await stopOutboxDrain() + let succeeded = await perform { + try await client.setEnvironmentEnabled(id: id, enabled: enabled) + install(try await client.initialSnapshot()) + if !enabled { clearDetails() } + } + scheduleOutboxDrain() + return succeeded + } + + public func disconnect() async { + await stopOutboxDrain() + isManagingConnections = false + await client.disconnect() + let disconnectedEnvironments = snapshot.environments.map { environment in + var environment = environment + environment.connectionState = .disconnected + environment.connectionDetail = nil + return environment + } + install(FeatureSnapshot( + environments: disconnectedEnvironments, + settings: snapshot.settings + )) + clearDetails() + } + + public func setConnectionManagementPresented(_ isPresented: Bool) { + isManagingConnections = isPresented + } + + public func addProject(path: String) async -> Bool { + await perform { + try await client.addProject(path: path) + install(try await client.initialSnapshot()) + } + } + + public func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async -> FeatureThread? { + let environment = currentEnvironmentIdentity + var created: FeatureThread? + let succeeded = await perform { + let thread = try await client.createThread( + projectID: projectID, + title: title, + selection: selection + ) + guard currentEnvironmentIdentity == environment else { + throw CancellationError() + } + upsert(thread) + created = thread + } + return succeeded ? created : nil + } + + public func startTask(_ request: NewTaskRequest) async -> FeatureThread? { + let prompt = request.trimmedPrompt + guard !prompt.isEmpty || !request.attachments.isEmpty else { return nil } + guard request.workspaceMode != .worktree || request.branch != nil else { return nil } + + guard let project = snapshot.projects.first(where: { $0.id == request.projectID }) else { + errorMessage = "That project is no longer available." + return nil + } + let identity = FeatureSubmissionIdentity() + let threadID = FeatureScopedID.thread( + environmentID: project.environmentID, + wireID: identity.threadID + ) + let uploads = request.attachments.map(\.upload) + let queued = FeatureQueuedSubmission( + environmentID: project.environmentID, + identity: identity, + threadID: threadID, + text: prompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode, + attachments: uploads, + creation: FeatureQueuedCreation( + projectID: request.projectID, + projectName: project.name, + workspaceMode: request.workspaceMode, + branch: request.branch, + worktreePath: request.worktreePath, + startFromOrigin: request.startFromOrigin + ) + ) + guard await enqueue(queued) else { return nil } + installPendingCreation(queued, project: project) + + isPerformingAction = true + defer { isPerformingAction = false } + do { + let thread = try await client.createThreadAndSend( + projectID: request.projectID, + prompt: prompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode.mobileNormalized, + workspaceMode: request.workspaceMode, + branch: request.branch, + worktreePath: request.worktreePath, + startFromOrigin: request.startFromOrigin, + attachments: uploads, + identity: identity + ) + if !(await completeQueuedSubmission(queued)) { + scheduleOutboxRetry() + } + if thread.id != queued.threadID { + removeThread(id: queued.threadID) + removeDetail(id: queued.threadID) + } + upsert(thread) + return thread + } catch { + if Self.shouldQueue(error, environmentID: project.environmentID, snapshot: snapshot) { + if isEnvironmentConnected(project.environmentID) { + scheduleOutboxRetry() + } + return snapshot.threads.first { $0.id == threadID } + ?? pendingThreadsByID[threadID] + } + let discarded = await discardQueuedSubmission(queued) + if !discarded { + scheduleOutboxRetry() + } + if discarded, !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return nil + } + } + + public func workspaceBranches( + projectID: String, + refresh: Bool = false + ) async throws -> [FeatureWorkspaceBranch] { + try await client.listWorkspaceBranches(projectID: projectID, refresh: refresh) + } + + public func renameThread(_ id: String, title: String) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.renameThread(id: id, title: title) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { $0.title = title } + } + } + + public func regenerateThreadTitle(_ id: String) async { + await perform { + try await client.regenerateThreadTitle(id: id) + } + } + + public func setArchived(_ id: String, archived: Bool) async { + if archived, + let thread = snapshot.threads.first(where: { $0.id == id }), + [.queued, .working, .monitoring, .waitingForApproval, .waitingForInput] + .contains(thread.state) { + errorMessage = "This thread is still active. Stop it before archiving." + return + } + let environment = currentEnvironmentIdentity + await perform { + try await client.setThreadArchived(id: id, archived: archived) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { $0.isArchived = archived } + } + } + + @discardableResult + public func setSettled(_ id: String, settled: Bool) async -> Bool { + guard let previous = snapshot.threads.first(where: { $0.id == id }) else { + return false + } + if settled, !previous.canSettleNow() { + errorMessage = "This thread still needs attention. Resolve or stop it first." + return false + } + + let environment = currentEnvironmentIdentity + let now = Date.now + let mutation = PendingSettlementMutation( + id: UUID(), + settled: settled, + settledAt: settled ? now : nil, + unsettledAt: settled ? nil : now + ) + pendingSettlementMutations[id] = mutation + mutateThread(id: id) { mutation.apply(to: &$0) } + + let succeeded = await perform { + try await client.setThreadSettled(id: id, settled: settled) + } + + guard pendingSettlementMutations[id]?.id == mutation.id else { return false } + pendingSettlementMutations.removeValue(forKey: id) + guard !succeeded else { return true } + guard currentEnvironmentIdentity == environment else { return false } + + mutateThread(id: id) { + guard $0.isSettled == settled, $0.settledAt == mutation.settledAt else { return } + $0.isSettled = previous.isSettled + $0.keepsActive = previous.keepsActive + $0.settlementFacts?.settlementOverride = previous.settlementFacts?.settlementOverride + $0.settledAt = previous.settledAt + $0.unsettledAt = previous.unsettledAt + $0.pinnedAt = previous.pinnedAt + } + return false + } + + public func setSnoozed(_ id: String, until: Date?) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.setThreadSnoozed(id: id, until: until) + guard currentEnvironmentIdentity == environment else { return } + let snoozedAt = until.map { _ in Date.now } + mutateThread(id: id) { + $0.snoozedUntil = until + $0.snoozedAt = snoozedAt + } + } + } + + public func setPinned(_ id: String, pinned: Bool) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.setThreadPinned(id: id, pinned: pinned) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { + $0.pinnedAt = pinned ? Date.now : nil + if pinned { + $0.snoozedUntil = nil + $0.snoozedAt = nil + } + } + } + } + + func updatePullRequest( + _ pullRequest: HomeThreadPullRequestPresentation?, + threadID: String, + observationIdentity: String + ) { + guard snapshot.threads.first(where: { $0.id == threadID })? + .pullRequestObservationIdentity == observationIdentity else { + return + } + if pullRequest == nil, pullRequestsByThreadID[threadID] == nil { return } + if pullRequestsByThreadID[threadID] == pullRequest, + pullRequestObservationIdentities[threadID] == observationIdentity { + return + } + if let pullRequest { + pullRequestsByThreadID[threadID] = pullRequest + pullRequestObservationIdentities[threadID] = observationIdentity + } else { + pullRequestsByThreadID.removeValue(forKey: threadID) + pullRequestObservationIdentities.removeValue(forKey: threadID) + } + homePresentationRevision &+= 1 + } + + func isEffectivelySettled(_ thread: FeatureThread) -> Bool { + thread.isEffectivelySettled() + } + + public func setRuntimeMode(_ id: String, mode: FeatureRuntimeMode) async { + guard let environmentID = snapshot.threads.first(where: { $0.id == id })?.environmentID else { + return + } + await perform { + try await client.setRuntimeMode(id: id, mode: mode) + guard snapshot.threads.first(where: { $0.id == id })?.environmentID == environmentID else { + return + } + mutateThread(id: id) { $0.runtimeMode = mode } + } + } + + public func setInteractionMode(_ id: String, mode: FeatureInteractionMode) async { + let mode = mode.mobileNormalized + let environment = currentEnvironmentIdentity + await perform { + try await client.setInteractionMode(id: id, mode: mode) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { $0.interactionMode = mode } + } + } + + public func deleteThread(_ id: String) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.deleteThread(id: id) + guard currentEnvironmentIdentity == environment else { return } + removeThread(id: id) + removeDetail(id: id) + } + } + + public func detail(for id: String, force: Bool = false, fresh: Bool = false) async -> FeatureThreadDetail? { + if !force, let cached = details[id] { + return cached + } + let environment = currentEnvironmentIdentity + let loadGenerationBeforeLoad = detailLoadGeneration + let loadRevisionBeforeLoad = detailLoadRevisions[id] + let metadataRevisionBeforeLoad = detailMetadataRevisions[id] + let threadBeforeLoad = snapshot.threads.first { $0.id == id } + detailLoadRequestRevision &+= 1 + let loadRequestRevision = detailLoadRequestRevision + activeDetailLoadRequests[id] = loadRequestRevision + detailLoadStates[id] = .loading + defer { + if activeDetailLoadRequests[id] == loadRequestRevision { + activeDetailLoadRequests[id] = nil + if detailLoadStates[id] == .loading { + detailLoadStates[id] = nil + } + } + } + do { + var detail = try await client.loadThread(id: id, fresh: fresh) + guard currentEnvironmentIdentity == environment else { + return details[id] + } + if detailLoadGeneration != loadGenerationBeforeLoad + || detailLoadRevisions[id] != loadRevisionBeforeLoad { + return details[id] + } + if let storedLoadRequestRevision = storedDetailLoadRequestRevisions[id], + loadRequestRevision < storedLoadRequestRevision { + return details[id] + } + let currentThread = snapshot.threads.first { $0.id == id } + if detailMetadataRevisions[id] != metadataRevisionBeforeLoad { + if let currentThread = details[id]?.thread ?? currentThread { + detail.thread = currentThread + } + } else if let currentThread, currentThread != threadBeforeLoad { + detail.thread = currentThread + } + store(detail, invalidatesInFlightLoad: false) + storedDetailLoadRequestRevisions[id] = loadRequestRevision + upsert(detail.thread) + return detail + } catch { + if !Self.isBenignCancellation(error), + activeDetailLoadRequests[id] == loadRequestRevision, + detailLoadGeneration == loadGenerationBeforeLoad, + detailLoadRevisions[id] == loadRevisionBeforeLoad, + currentEnvironmentIdentity == environment { + detailLoadStates[id] = .failed(error.localizedDescription) + if details[id] == nil { + errorMessage = error.localizedDescription + } + } + return details[id] + } + } + + public func loadEarlierTurns(for id: String) async { + guard details[id]?.page?.hasMore == true, + details[id]?.page?.isLoading != true else { return } + let environment = currentEnvironmentIdentity + do { + guard let detail = try await client.loadEarlierThreadTurns(id: id), + currentEnvironmentIdentity == environment else { return } + store(detail, invalidatesInFlightLoad: false) + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + } + } + + /// Ends any selected-thread transport work when its detail view closes. + public func releaseThread(_ id: String) { + client.releaseThread(id: id) + markDetailRecentlyUsed(id) + evictOldThreadDetailsIfNeeded() + } + + public func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async -> Bool { + await sendMessage( + FeatureMessageSubmission( + threadID: threadID, + text: text, + selection: selection + ) + ) + } + + public func sendMessage(_ submission: FeatureMessageSubmission) async -> Bool { + let trimmed = submission.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !submission.attachments.isEmpty else { return false } + + guard let thread = snapshot.threads.first(where: { $0.id == submission.threadID }), + let environmentID = thread.environmentID else { + return false + } + let identity = FeatureSubmissionIdentity(threadID: thread.wireID ?? thread.id) + let uploads = submission.attachments.map(\.upload) + let queued = FeatureQueuedSubmission( + environmentID: environmentID, + identity: identity, + threadID: submission.threadID, + text: trimmed, + selection: submission.selection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + attachments: uploads + ) + guard await enqueue(queued) else { return false } + + let optimistic = FeatureMessage( + id: identity.messageID, + role: .user, + text: trimmed, + createdAt: identity.createdAt, + state: .queued, + attachments: submission.attachments.map { + FeatureMessageAttachment( + id: $0.id.uuidString, + name: $0.filename, + mimeType: $0.mimeType, + sizeBytes: $0.byteCount, + previewData: $0.thumbnailData + ) + } + ) + mutateDetail( + id: submission.threadID, + change: .delta(FeatureDetailDelta( + changedMessages: [optimistic], + appendedMessageIDs: [optimistic.id] + )) + ) { + $0.messages.append(optimistic) + } + + isPerformingAction = true + defer { isPerformingAction = false } + do { + try await client.sendMessage( + threadID: submission.threadID, + text: trimmed, + selection: submission.selection, + runtimeMode: queued.runtimeMode, + attachments: uploads, + identity: identity + ) + if !(await completeQueuedSubmission(queued)) { + scheduleOutboxRetry() + } + return true + } catch { + if Self.shouldQueue(error, environmentID: environmentID, snapshot: snapshot) { + if isEnvironmentConnected(environmentID) { + scheduleOutboxRetry() + } + return true + } + let discarded = await discardQueuedSubmission(queued) + if !discarded { + scheduleOutboxRetry() + } + if discarded, !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + public func cancelTurn(threadID: String) async { + if pendingSubmissionsByID.values.contains(where: { + $0.threadID == threadID && $0.creation != nil + }) { + await stopOutboxDrain() + let queued = pendingSubmissionsByID.values.filter { $0.threadID == threadID } + for submission in queued { + if !(await discardQueuedSubmission(submission)) { + scheduleOutboxRetry() + } + } + if pendingThreadsByID[threadID] == nil, + snapshot.threads.contains(where: { $0.id == threadID }) { + await perform { + try await client.cancelTurn(threadID: threadID) + } + } + scheduleOutboxDrain() + return + } + await perform { + try await client.cancelTurn(threadID: threadID) + } + } + + public func resolveApproval(_ id: String, decision: FeatureApprovalDecision) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.resolveApproval(id: id, decision: decision) + guard currentEnvironmentIdentity == environment else { return } + // Only touch details that actually hold the request; mutateDetail + // deep-compares each mutated detail and the cache never shrinks. + for key in Array(details.keys) + where details[key]?.approvals.contains(where: { $0.id == id }) == true { + mutateDetail( + id: key, + change: .delta(FeatureDetailDelta(changedMessages: [])) + ) { + $0.approvals.removeAll { $0.id == id } + } + } + } + } + + public func resolveUserInput(_ id: String, answers: [String: FeatureInputAnswer]) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.resolveUserInput(id: id, answers: answers) + guard currentEnvironmentIdentity == environment else { return } + for key in Array(details.keys) + where details[key]?.userInputs.contains(where: { $0.id == id }) == true { + mutateDetail( + id: key, + change: .delta(FeatureDetailDelta(changedMessages: [])) + ) { + $0.userInputs.removeAll { $0.id == id } + } + } + } + } + + /// Convenience for callers that only submit free-form or single-select text. + public func resolveUserInput(_ id: String, answers: [String: String]) async { + await resolveUserInput( + id, + answers: answers.mapValues(FeatureInputAnswer.text) + ) + } + + @discardableResult + public func saveSettings(_ settings: FeatureSettings) async -> Bool { + snapshot.settings = settings + settingsChangeRevision &+= 1 + let revision = settingsChangeRevision + let saved = await perform { + try await enqueueSettingsWrite(settings) + } + if !saved, settingsChangeRevision == revision { + snapshot.settings = lastPersistedSettings + } + return saved + } + + @discardableResult + public func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async -> Bool { + await perform { + let updated = try await client.updateAutomaticSettlement( + environmentID: environmentID, + change: change + ) + guard var preferences = snapshot.preferencesByEnvironment?[environmentID], + preferences.automaticSettlement != nil else { + return + } + preferences.automaticSettlement = updated + snapshot.preferencesByEnvironment?[environmentID] = preferences + } + } + + /// Applies one preference immediately and queues it with any other pending changes. + @discardableResult + public func savePreference( + _ keyPath: WritableKeyPath, + value: Value + ) async -> Bool { + await saveSettingsChange { $0[keyPath: keyPath] = value } + } + + @discardableResult + public func saveAppearance(_ appearance: FeatureAppearance) async -> Bool { + await savePreference(\.appearance, value: appearance) + } + + @discardableResult + public func saveTextSizes( + textSize: FeatureTextSizeAdjustment, + codeSize: FeatureTextSizeAdjustment + ) async -> Bool { + await saveSettingsChange { + $0.textSize = textSize + $0.codeSize = codeSize + } + } + + private func saveSettingsChange( + _ change: (inout FeatureSettings) -> Void + ) async -> Bool { + let previous = snapshot.settings + var updated = previous + change(&updated) + guard updated != previous else { return true } + snapshot.settings = updated + settingsChangeRevision &+= 1 + let revision = settingsChangeRevision + + do { + try await enqueueSettingsWrite(updated) + return true + } catch { + guard settingsChangeRevision == revision else { return false } + snapshot.settings = lastPersistedSettings + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + /// Serializes full-snapshot writes. Only a successful write advances the + /// rollback point, so a failed optimistic predecessor is never restored. + private func enqueueSettingsWrite(_ settings: FeatureSettings) async throws { + let predecessor = settingsWriteTask + let write = Task { @MainActor [client] in + if let predecessor { + _ = await predecessor.result + } + try await client.saveSettings(settings) + lastPersistedSettings = settings + } + settingsWriteGeneration &+= 1 + let generation = settingsWriteGeneration + settingsWriteTask = write + defer { + if settingsWriteGeneration == generation { + settingsWriteTask = nil + } + } + try await write.value + } + + @discardableResult + private func perform( + reportError: Bool = true, + _ operation: () async throws -> Void + ) async -> Bool { + isPerformingAction = true + defer { isPerformingAction = false } + do { + try await operation() + return true + } catch { + if reportError, !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + private static func isBenignCancellation(_ error: any Error) -> Bool { + if error is CancellationError { return true } + let message = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return message == "cancelled" || message == "canceled" + } + + private var currentEnvironmentIdentity: String { + snapshot.environments + .sorted { $0.id < $1.id } + .map { "\($0.id)|\($0.endpoint)|\($0.isEnabled)" } + .joined(separator: ";") + } + + private func apply(_ event: FeatureEvent) { + switch event { + case let .snapshot(value): + install(value) + case let .connection(value): + guard snapshot.connection != value else { return } + snapshot.connection = value + homePresentationRevision &+= 1 + if value.state == .connected { + scheduleOutboxDrain() + } + case let .thread(value): + pendingThreadsByID.removeValue(forKey: value.id) + upsert(value) + case let .threadRemoved(id): + removeThread(id: id) + removeDetail(id: id) + case let .detail(value): + pendingThreadsByID.removeValue(forKey: value.thread.id) + store(value) + upsert(value.thread) + case let .detailDelta(value, delta): + pendingThreadsByID.removeValue(forKey: value.thread.id) + store(value, delta: delta) + upsert(value.thread) + case let .threadSync(id, state): + if threadSyncStates[id] != state { + threadSyncStates[id] = state + } + if state == .live, case .failed = detailLoadStates[id] { + detailLoadStates[id] = nil + } + case let .failure(message): + errorMessage = message + } + } + + private func upsert(_ thread: FeatureThread) { + let thread = retainingPendingSettlement(in: thread) + discardStalePullRequest(for: thread) + var metadataChanged = false + if let index = snapshot.threads.firstIndex(where: { $0.id == thread.id }) { + let previous = snapshot.threads[index] + if previous != thread { + snapshot.threads[index] = thread + metadataChanged = true + if previous.projectID != thread.projectID { + adjustProjectCount(id: previous.projectID, by: -1) + adjustProjectCount(id: thread.projectID, by: 1) + } + } + } else { + snapshot.threads.append(thread) + adjustProjectCount(id: thread.projectID, by: 1) + metadataChanged = true + } + if metadataChanged { + threadCollectionRevision &+= 1 + homePresentationRevision &+= 1 + } + let detailChanged = mutateDetail( + id: thread.id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + $0.thread = thread + } + if metadataChanged || detailChanged { + bumpDetailMetadataRevision(id: thread.id) + } + } + + private func removeThread(id: String) { + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + let projectID = snapshot.threads[index].projectID + snapshot.threads.remove(at: index) + pullRequestsByThreadID.removeValue(forKey: id) + pullRequestObservationIdentities.removeValue(forKey: id) + adjustProjectCount(id: projectID, by: -1) + threadCollectionRevision &+= 1 + homePresentationRevision &+= 1 + } + + private func adjustProjectCount(id: String, by delta: Int) { + guard let index = snapshot.projects.firstIndex(where: { $0.id == id }) else { return } + snapshot.projects[index].threadCount = max(0, snapshot.projects[index].threadCount + delta) + } + + private func install(_ value: FeatureSnapshot) { + var value = value + if settingsWriteTask != nil { + // A shell refresh can still contain the settings from before a + // queued write. Keep both the visible choice and its rollback point. + value.settings = snapshot.settings + } else { + lastPersistedSettings = value.settings + } + for index in value.threads.indices { + value.threads[index] = retainingPendingSettlement(in: value.threads[index]) + } + let authoritativeThreadIDs = Set(value.threads.map(\.id)) + for id in authoritativeThreadIDs { + pendingThreadsByID.removeValue(forKey: id) + } + for pending in pendingThreadsByID.values where !authoritativeThreadIDs.contains(pending.id) { + value.threads.append(pending) + if let index = value.projects.firstIndex(where: { $0.id == pending.projectID }) { + value.projects[index].threadCount += 1 + } + } + + let previousThreads = snapshot.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + let nextThreads = value.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + for thread in value.threads { + discardStalePullRequest(for: thread) + } + for id in Array(pullRequestsByThreadID.keys) where nextThreads[id] == nil { + pullRequestsByThreadID.removeValue(forKey: id) + pullRequestObservationIdentities.removeValue(forKey: id) + } + for id in previousThreads.keys where nextThreads[id] == nil { + removeDetail(id: id) + } + for thread in value.threads where previousThreads[thread.id] != thread { + mutateDetail( + id: thread.id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + $0.thread = thread + } + bumpDetailMetadataRevision(id: thread.id) + } + + if snapshot.connection != value.connection + || snapshot.environments != value.environments + || snapshot.projects != value.projects + || snapshot.providers != value.providers + || snapshot.providersByEnvironment != value.providersByEnvironment + || snapshot.preferencesByEnvironment != value.preferencesByEnvironment + || snapshot.threads != value.threads { + homePresentationRevision &+= 1 + } + if snapshot.threads != value.threads { + threadCollectionRevision &+= 1 + } + snapshot = value + if value.connection.state == .connected + || value.environments.contains(where: { $0.connectionState == .connected }) { + scheduleOutboxDrain() + } + } + + private func discardStalePullRequest(for thread: FeatureThread) { + guard let cachedIdentity = pullRequestObservationIdentities[thread.id], + cachedIdentity != thread.pullRequestObservationIdentity else { + return + } + pullRequestsByThreadID.removeValue(forKey: thread.id) + pullRequestObservationIdentities.removeValue(forKey: thread.id) + } + + private func mutateThread( + id: String, + _ mutation: (inout FeatureThread) -> Void + ) { + var metadataChanged = false + if let index = snapshot.threads.firstIndex(where: { $0.id == id }) { + let previous = snapshot.threads[index] + mutation(&snapshot.threads[index]) + if snapshot.threads[index] != previous { + metadataChanged = true + threadCollectionRevision &+= 1 + homePresentationRevision &+= 1 + } + } + let detailChanged = mutateDetail( + id: id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + mutation(&$0.thread) + } + if metadataChanged || detailChanged { + bumpDetailMetadataRevision(id: id) + } + } + + private func store( + _ incoming: FeatureThreadDetail, + invalidatesInFlightLoad: Bool = true + ) { + var incoming = retainingLocalAttachmentPreviews(in: incoming) + incoming.thread = retainingPendingSettlement(in: incoming.thread) + let id = incoming.thread.id + acknowledgeDeliveredMessages(incoming.messages) + let prepared = addingPendingMessages(to: incoming) + let next = details[id].map { current in + FeatureThreadDetail( + thread: prepared.thread, + messages: replacingChangedSuffix(current.messages, with: prepared.messages), + approvals: replacingChangedSuffix(current.approvals, with: prepared.approvals), + userInputs: replacingChangedSuffix(current.userInputs, with: prepared.userInputs), + page: prepared.page, + activeSubagentCount: prepared.activeSubagentCount, + backgroundWorkIsActive: prepared.backgroundWorkIsActive, + isCompacting: prepared.isCompacting == true + ) + } ?? prepared + guard details[id] != next else { return } + details[id] = next + markDetailRecentlyUsed(id) + if invalidatesInFlightLoad { + bumpDetailLoadRevision(id: id) + } + bumpDetailRevision(id: id, change: .full) + } + + private func store(_ incoming: FeatureThreadDetail, delta: FeatureDetailDelta) { + var incoming = retainingLocalAttachmentPreviews(in: incoming) + incoming.thread = retainingPendingSettlement(in: incoming.thread) + let id = incoming.thread.id + acknowledgeDeliveredMessages(incoming.messages) + let next = addingPendingMessages(to: incoming) + details[id] = next + markDetailRecentlyUsed(id) + bumpDetailLoadRevision(id: id) + let appended = next.messages.dropFirst(incoming.messages.count).map(\.id) + let pendingDelta = FeatureDetailDelta( + changedMessages: delta.changedMessages + next.messages.dropFirst(incoming.messages.count), + appendedMessageIDs: delta.appendedMessageIDs + appended + ) + bumpDetailRevision(id: id, change: .delta(pendingDelta)) + } + + private func retainingPendingSettlement(in thread: FeatureThread) -> FeatureThread { + guard let mutation = pendingSettlementMutations[thread.id] else { return thread } + var thread = thread + mutation.apply(to: &thread) + return thread + } + + @discardableResult + private func mutateDetail( + id: String, + change: FeatureDetailRenderChange = .full, + invalidatesInFlightLoad: Bool = true, + _ mutation: (inout FeatureThreadDetail) -> Void + ) -> Bool { + guard var detail = details[id] else { return false } + let previous = detail + mutation(&detail) + guard detail != previous else { return false } + details[id] = detail + markDetailRecentlyUsed(id) + if invalidatesInFlightLoad { + bumpDetailLoadRevision(id: id) + } + bumpDetailRevision(id: id, change: change) + return true + } + + private func removeDetail(id: String) { + if details.removeValue(forKey: id) != nil { + detailRecency.removeAll { $0 == id } + } + storedDetailLoadRequestRevisions.removeValue(forKey: id) + activeDetailLoadRequests.removeValue(forKey: id) + detailLoadStates.removeValue(forKey: id) + threadSyncStates.removeValue(forKey: id) + bumpDetailLoadRevision(id: id) + bumpDetailRevision(id: id, change: .full) + } + + private func clearDetails() { + detailLoadGeneration &+= 1 + detailLoadRevisions.removeAll() + storedDetailLoadRequestRevisions.removeAll() + activeDetailLoadRequests.removeAll() + detailLoadStates.removeAll() + threadSyncStates.removeAll() + detailMetadataRevisions.removeAll() + let hadDetails = !details.isEmpty + details.removeAll() + detailRecency.removeAll() + if hadDetails { + detailRevision &+= 1 + } + detailRevisions.removeAll() + detailRenderUpdates.removeAll() + } + + private func bumpDetailLoadRevision(id: String) { + detailLoadRevisions[id] = (detailLoadRevisions[id] ?? 0) &+ 1 + } + + private func bumpDetailMetadataRevision(id: String) { + detailMetadataRevisions[id] = (detailMetadataRevisions[id] ?? 0) &+ 1 + } + + private func markDetailRecentlyUsed(_ id: String) { + detailRecency.removeAll { $0 == id } + detailRecency.append(id) + } + + private func evictOldThreadDetailsIfNeeded() { + let protected = Set(pendingSubmissionsByID.values.map(\.threadID)) + while details.count > Self.maximumRetainedThreadDetails, + let candidate = detailRecency.first(where: { !protected.contains($0) }) { + detailRecency.removeAll { $0 == candidate } + removeDetail(id: candidate) + } + } + + private func bumpDetailRevision(id: String, change: FeatureDetailRenderChange) { + let baseRevision = detailRevisions[id] ?? 0 + detailRevision &+= 1 + detailRevisions[id] = detailRevision + detailRenderUpdates[id] = FeatureDetailRenderUpdate( + baseRevision: baseRevision, + revision: detailRevision, + change: change + ) + } + + private func replacingChangedSuffix( + _ current: [Element], + with incoming: [Element] + ) -> [Element] { + guard current != incoming else { return current } + let prefixCount = zip(current, incoming).prefix { pair in + pair.0 == pair.1 + }.count + var result = current + result.replaceSubrange(prefixCount..., with: incoming.dropFirst(prefixCount)) + return result + } + + private func restoreOutbox() async { + let submissions: [FeatureQueuedSubmission] + do { + submissions = try await outboxStore.submissions() + } catch { + errorMessage = "Could not restore queued messages: \(error.localizedDescription)" + return + } + + for submission in submissions { + setAttachmentOutboxOwnership(true, for: submission) + if let creation = submission.creation { + if snapshot.threads.contains(where: { $0.id == submission.threadID }) { + pendingSubmissionsByID[submission.id] = submission + if let detail = details[submission.threadID] { + store(addingPendingMessages(to: detail)) + } + continue + } + guard let project = snapshot.projects.first(where: { + $0.id == creation.projectID && $0.environmentID == submission.environmentID + }) else { + if isEnvironmentConnected(submission.environmentID) { + await discardRestoredSubmission(submission) + } else { + pendingSubmissionsByID[submission.id] = submission + } + continue + } + pendingSubmissionsByID[submission.id] = submission + installPendingCreation(submission, project: project) + continue + } + + guard snapshot.threads.contains(where: { $0.id == submission.threadID }) else { + if pendingThreadsByID[submission.threadID] != nil { + pendingSubmissionsByID[submission.id] = submission + } else if isEnvironmentConnected(submission.environmentID) { + await discardRestoredSubmission(submission) + } else { + pendingSubmissionsByID[submission.id] = submission + } + continue + } + pendingSubmissionsByID[submission.id] = submission + if let detail = details[submission.threadID] { + store(addingPendingMessages(to: detail)) + } + } + } + + private func discardRestoredSubmission(_ submission: FeatureQueuedSubmission) async { + pendingSubmissionsByID[submission.id] = submission + await discardQueuedSubmission(submission) + } + + private func enqueue(_ submission: FeatureQueuedSubmission) async -> Bool { + do { + try await outboxStore.enqueue(submission) + pendingSubmissionsByID[submission.id] = submission + setAttachmentOutboxOwnership(true, for: submission) + return true + } catch { + errorMessage = "Could not safely queue this message: \(error.localizedDescription)" + return false + } + } + + private func installPendingCreation( + _ submission: FeatureQueuedSubmission, + project: FeatureProject + ) { + guard let creation = submission.creation else { return } + let provider = provider( + id: submission.selection?.providerID, + environmentID: submission.environmentID + ) + let environmentName = snapshot.environments.first { + $0.id == submission.environmentID + }?.name + let title = submission.text + .split(whereSeparator: \.isNewline) + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let thread = FeatureThread( + id: submission.threadID, + wireID: submission.identity.threadID, + projectID: project.id, + environmentID: submission.environmentID, + environmentName: environmentName, + title: title?.isEmpty == false ? title! : "New task", + preview: submission.text, + branch: creation.branch, + worktreePath: creation.worktreePath, + createdAt: submission.identity.createdAt, + updatedAt: submission.identity.createdAt, + state: .queued, + providerID: submission.selection?.providerID, + providerName: provider?.name, + modelID: submission.selection?.modelID, + runtimeMode: submission.runtimeMode, + interactionMode: submission.interactionMode + ) + pendingThreadsByID[thread.id] = thread + upsert(thread) + store(FeatureThreadDetail( + thread: thread, + messages: [queuedMessage(for: submission)] + )) + } + + private func provider(id: String?, environmentID: String) -> FeatureProvider? { + guard let id else { return nil } + let providers = snapshot.providersByEnvironment?[environmentID] ?? [] + return providers.first { $0.id == id } + } + + private func queuedMessage(for submission: FeatureQueuedSubmission) -> FeatureMessage { + FeatureMessage( + id: submission.identity.messageID, + role: .user, + text: submission.text, + createdAt: submission.identity.createdAt, + state: .queued, + attachments: submission.attachments.enumerated().map { index, attachment in + FeatureMessageAttachment( + id: "\(submission.id)-attachment-\(index)", + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.byteCount ?? attachment.data?.count ?? 0 + ) + } + ) + } + + private func addingPendingMessages(to incoming: FeatureThreadDetail) -> FeatureThreadDetail { + let queued = pendingSubmissionsByID.values + .filter { $0.threadID == incoming.thread.id } + .sorted { $0.identity.createdAt < $1.identity.createdAt } + guard !queued.isEmpty else { return incoming } + var result = incoming + let existing = Set(result.messages.map(\.id)) + result.messages.append(contentsOf: queued.lazy + .filter { !existing.contains($0.identity.messageID) } + .map(queuedMessage(for:))) + return result + } + + private func retainingLocalAttachmentPreviews( + in incoming: FeatureThreadDetail + ) -> FeatureThreadDetail { + guard let current = details[incoming.thread.id] else { return incoming } + let currentMessages = current.messages.reduce(into: [String: FeatureMessage]()) { + $0[$1.id] = $1 + } + var result = incoming + result.messages = incoming.messages.map { message in + guard let local = currentMessages[message.id], !message.attachments.isEmpty else { + return message + } + var message = message + message.attachments = message.attachments.enumerated().map { index, attachment in + guard attachment.previewData == nil else { return attachment } + let matching = local.attachments.first { candidate in + candidate.id == attachment.id + } ?? ( + local.attachments.indices.contains(index) + ? local.attachments[index] + : nil + ) + guard let previewData = matching?.previewData else { return attachment } + var attachment = attachment + attachment.previewData = previewData + return attachment + } + return message + } + return result + } + + private func acknowledgeDeliveredMessages(_ messages: [FeatureMessage]) { + // Runs on every detail publish; skip the full message-ID scan in the + // common case where nothing is waiting in the outbox. + guard !pendingSubmissionsByID.isEmpty else { return } + // Local optimistic rows reuse the final message ID but are not proof + // that the server accepted the turn. Only authoritative, non-queued + // rows can retire a durable outbox entry. + let messageIDs = Set(messages.lazy + .filter { $0.state != .queued } + .map(\.id)) + let delivered = pendingSubmissionsByID.values.filter { + messageIDs.contains($0.identity.messageID) + } + for submission in delivered { + scheduleQueuedSubmissionCompletion(submission) + } + } + + private func scheduleQueuedSubmissionCompletion(_ submission: FeatureQueuedSubmission) { + guard pendingCompletionSubmissionIDs.insert(submission.id).inserted else { return } + pendingDiscardSubmissionIDs.remove(submission.id) + Task { @MainActor [weak self] in + guard let self else { return } + if !(await self.completeQueuedSubmission(submission)) { + self.scheduleOutboxRetry() + } + } + } + + @discardableResult + private func completeQueuedSubmission(_ submission: FeatureQueuedSubmission) async -> Bool { + pendingCompletionSubmissionIDs.insert(submission.id) + pendingDiscardSubmissionIDs.remove(submission.id) + do { + try await outboxStore.remove(id: submission.id) + } catch { + errorMessage = "The message was delivered, but its queued copy could not be cleared: \(error.localizedDescription)" + return false + } + pendingCompletionSubmissionIDs.remove(submission.id) + pendingSubmissionsByID.removeValue(forKey: submission.id) + setAttachmentOutboxOwnership(false, for: submission) + pendingThreadsByID.removeValue(forKey: submission.threadID) + markQueuedMessageDelivered(submission) + outboxRetryAttempt = 0 + return true + } + + private func markQueuedMessageDelivered(_ submission: FeatureQueuedSubmission) { + mutateDetail( + id: submission.threadID, + change: .delta(FeatureDetailDelta(changedMessages: [])) + ) { detail in + guard let index = detail.messages.firstIndex(where: { + $0.id == submission.identity.messageID + }) else { return } + detail.messages[index].state = .complete + } + } + + @discardableResult + private func discardQueuedSubmission(_ submission: FeatureQueuedSubmission) async -> Bool { + pendingCompletionSubmissionIDs.remove(submission.id) + pendingDiscardSubmissionIDs.insert(submission.id) + do { + try await outboxStore.remove(id: submission.id) + } catch { + errorMessage = "Could not remove the queued message: \(error.localizedDescription)" + return false + } + pendingDiscardSubmissionIDs.remove(submission.id) + pendingSubmissionsByID.removeValue(forKey: submission.id) + setAttachmentOutboxOwnership(false, for: submission) + let wasPendingCreation = pendingThreadsByID.removeValue(forKey: submission.threadID) != nil + if wasPendingCreation { + removeThread(id: submission.threadID) + removeDetail(id: submission.threadID) + } else { + mutateDetail(id: submission.threadID) { + $0.messages.removeAll { $0.id == submission.identity.messageID } + } + } + return true + } + + private func setAttachmentOutboxOwnership( + _ owned: Bool, + for submission: FeatureQueuedSubmission + ) { + if owned { + attachmentUploads.syncOutboxOwner( + ownerID: submission.id, + environmentID: submission.environmentID, + attachmentIDs: submission.attachments.map(\.id) + ) + } else { + attachmentUploads.removeOutboxOwner(ownerID: submission.id) + } + } + + private func removePendingSubmissions(environmentID: String) { + let removed = pendingSubmissionsByID.values.filter { + $0.environmentID == environmentID + } + for submission in removed { + pendingCompletionSubmissionIDs.remove(submission.id) + pendingDiscardSubmissionIDs.remove(submission.id) + pendingSubmissionsByID.removeValue(forKey: submission.id) + setAttachmentOutboxOwnership(false, for: submission) + if pendingThreadsByID.removeValue(forKey: submission.threadID) != nil { + removeThread(id: submission.threadID) + removeDetail(id: submission.threadID) + } else { + mutateDetail(id: submission.threadID) { + $0.messages.removeAll { $0.id == submission.identity.messageID } + } + } + } + } + + private func markPendingSubmissionsForDiscard(environmentID: String) { + for submission in pendingSubmissionsByID.values where submission.environmentID == environmentID { + pendingCompletionSubmissionIDs.remove(submission.id) + pendingDiscardSubmissionIDs.insert(submission.id) + } + } + + private func scheduleOutboxDrain(after delay: Duration = .zero) { + guard outboxDrainTask == nil, !pendingSubmissionsByID.isEmpty else { return } + let generation = outboxGeneration + outboxDrainTask = Task { @MainActor [weak self] in + if delay > .zero { + try? await Task.sleep(for: delay) + } + guard !Task.isCancelled, + let self, + self.outboxGeneration == generation else { return } + let needsRetry = await self.drainOutbox(generation: generation) + self.outboxDrainTask = nil + if needsRetry, + !Task.isCancelled, + self.outboxGeneration == generation { + self.scheduleOutboxRetry() + } + } + } + + private func stopOutboxDrain() async { + outboxGeneration &+= 1 + guard let task = outboxDrainTask else { return } + task.cancel() + await task.value + outboxDrainTask = nil + } + + private func scheduleOutboxRetry() { + guard outboxDrainTask == nil else { return } + let seconds = min(16, 1 << min(outboxRetryAttempt, 4)) + outboxRetryAttempt += 1 + scheduleOutboxDrain(after: .seconds(seconds)) + } + + private func drainOutbox(generation: UInt64) async -> Bool { + let submissions = pendingSubmissionsByID.values.sorted { + $0.identity.createdAt < $1.identity.createdAt + } + var needsRetry = false + for submission in submissions where pendingSubmissionsByID[submission.id] != nil { + guard !Task.isCancelled, outboxGeneration == generation else { return false } + if pendingCompletionSubmissionIDs.contains(submission.id) { + if !(await completeQueuedSubmission(submission)) { + needsRetry = true + } + continue + } + if pendingDiscardSubmissionIDs.contains(submission.id) { + if !(await discardQueuedSubmission(submission)) { + needsRetry = true + } + continue + } + var policySnapshot = snapshot + if pendingThreadsByID[submission.threadID] != nil { + policySnapshot.threads.removeAll { $0.id == submission.threadID } + } + switch FeatureOutboxPolicy.decision( + for: submission, + snapshot: policySnapshot, + pendingCreationThreadIDs: Set( + pendingSubmissionsByID.values.compactMap { + $0.creation == nil ? nil : $0.threadID + } + ) + ) { + case .discard: + if !(await discardQueuedSubmission(submission)) { + needsRetry = true + } + case .wait: + // Connectivity and snapshot events wake the drain immediately. + // Avoid a permanent timer while the owning device is offline. + continue + case .send: + do { + guard pendingSubmissionsByID[submission.id] != nil, + snapshot.environments.contains(where: { + $0.id == submission.environmentID + }) else { + continue + } + if let creation = submission.creation { + let thread = try await client.createThreadAndSend( + projectID: creation.projectID, + prompt: submission.text, + selection: submission.selection, + runtimeMode: submission.runtimeMode, + interactionMode: submission.interactionMode, + workspaceMode: creation.workspaceMode, + branch: creation.branch, + worktreePath: creation.worktreePath, + startFromOrigin: creation.startFromOrigin, + attachments: submission.uploads, + identity: submission.identity + ) + guard !Task.isCancelled, + outboxGeneration == generation else { return false } + if !(await completeQueuedSubmission(submission)) { + needsRetry = true + } + if thread.id != submission.threadID { + removeThread(id: submission.threadID) + removeDetail(id: submission.threadID) + } + upsert(thread) + } else { + try await client.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection, + runtimeMode: submission.runtimeMode, + attachments: submission.uploads, + identity: submission.identity + ) + guard !Task.isCancelled, + outboxGeneration == generation else { return false } + if !(await completeQueuedSubmission(submission)) { + needsRetry = true + } + } + } catch { + if Self.shouldQueue( + error, + environmentID: submission.environmentID, + snapshot: snapshot + ) { + needsRetry = true + } else { + if !(await discardQueuedSubmission(submission)) { + needsRetry = true + } else { + errorMessage = error.localizedDescription + } + } + } + } + } + return needsRetry + } + + private func isEnvironmentConnected(_ environmentID: String) -> Bool { + guard let environment = snapshot.environments.first(where: { $0.id == environmentID }) else { + return false + } + return environment.isEnabled && environment.connectionState == .connected + } + + static func shouldQueue( + _ error: any Error, + environmentID: String, + snapshot: FeatureSnapshot + ) -> Bool { + if error is CancellationError || error is URLError { return true } + if let rpcError = error as? RPCError, + case .responseTimedOut = rpcError { + return true + } + if let environment = snapshot.environments.first(where: { $0.id == environmentID }) { + let disconnected = !environment.isEnabled + || environment.connectionState != .connected + if disconnected { return true } + } + let message = error.localizedDescription.lowercased() + return [ + "cancelled", "canceled", "connection", "network", "offline", + "socket", "timed out", "timeout", "transport", "not connected", + "request deadline", + ].contains { message.contains($0) } + } +} + +private extension FeatureDraftAttachment { + var upload: FeatureUploadAttachment { + FeatureUploadAttachment(self) + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift new file mode 100644 index 000000000000..1f0ddd41f7dd --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +public struct FeatureRootView: View { + @State private var model: FeatureRootModel + private let navigationRequest: FeatureWorkspaceNavigationRequest? + private let onNavigationRequestConsumed: @MainActor (UUID) -> Void + + public init(client: any FeatureClient) { + _model = State(initialValue: FeatureRootModel(client: client)) + navigationRequest = nil + onNavigationRequestConsumed = { _ in } + } + + init( + model: FeatureRootModel, + navigationRequest: FeatureWorkspaceNavigationRequest? = nil, + onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void = { _ in } + ) { + _model = State(initialValue: model) + self.navigationRequest = navigationRequest + self.onNavigationRequestConsumed = onNavigationRequestConsumed + } + + public var body: some View { + Group { + if model.isLoading { + FeatureLoadingView() + } else if shouldShowWorkspace { + WorkspaceView( + model: model, + navigationRequest: navigationRequest, + onNavigationRequestConsumed: onNavigationRequestConsumed, + submitNewTask: { request in + await model.startTask(request) + }, + submitMessage: { submission in + await model.sendMessage(submission) + } + ) + } else { + ConnectionOnboardingView(model: model) + } + } + .preferredColorScheme(preferredColorScheme) + .t3AppTextSize(steps: model.snapshot.settings.textSize.steps) + .t3CodeSizing(steps: model.snapshot.settings.codeSize.steps) + .tint(T3Colors.accent) + .background(T3Colors.background.ignoresSafeArea()) + .task { await model.start() } + .alert( + "Something went wrong", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + ), + actions: { + Button("OK") { model.errorMessage = nil } + }, + message: { + Text(model.errorMessage ?? "Unknown error") + } + ) + } + + /// Keep the last-known workspace visible through a degraded connection. + /// Connection management also stays mounted while saved servers are being + /// removed, so a disconnected fallback cannot destroy its own Settings sheet. + private var shouldShowWorkspace: Bool { + FeatureRootPresentation.showsWorkspace( + snapshot: model.snapshot, + isManagingConnections: model.isManagingConnections + ) + } + + private var preferredColorScheme: ColorScheme? { + switch model.snapshot.settings.appearance { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +enum FeatureRootPresentation { + static func showsWorkspace( + snapshot: FeatureSnapshot, + isManagingConnections: Bool + ) -> Bool { + isManagingConnections + || !snapshot.environments.isEmpty + || !snapshot.projects.isEmpty + || !snapshot.threads.isEmpty + } +} + +private struct FeatureLoadingView: View { + var body: some View { + VStack(spacing: 14) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 30, weight: .semibold)) + ProgressView() + .controlSize(.small) + Text("Connecting to T3 Code") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + .accessibilityElement(children: .combine) + } +} diff --git a/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift b/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift new file mode 100644 index 000000000000..08e2a266d5cb --- /dev/null +++ b/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift @@ -0,0 +1,149 @@ +import Foundation + +enum ConnectionHubStatus: Equatable { + case disabled + case checking + case connecting + case offline + case online + + var title: String { + switch self { + case .disabled: "Off" + case .checking: "Checking" + case .connecting: "Connecting" + case .offline: "Offline" + case .online: "Online" + } + } +} + +struct T3ConnectEnvironmentPresentation: Identifiable, Equatable { + let linkedEnvironment: T3ConnectCloudEnvironment? + let savedEnvironment: FeatureEnvironment? + + var id: String { + linkedEnvironment?.id ?? savedEnvironment?.id ?? "" + } + + var name: String { + linkedEnvironment?.environment.label ?? savedEnvironment?.name ?? "T3 environment" + } + + var isEnabled: Bool { + savedEnvironment?.isEnabled ?? false + } + + var endpoint: String? { + linkedEnvironment?.environment.endpoint.httpBaseUrl ?? savedEnvironment?.endpoint + } + + var isOnline: Bool { + status == .online + } + + var status: ConnectionHubStatus { + connectionStatus() + } + + func connectionStatus( + pendingEnabled: Bool? = nil, + isConnecting: Bool = false + ) -> ConnectionHubStatus { + if isConnecting || (pendingEnabled == true && savedEnvironment == nil) { + return .connecting + } + + if let savedEnvironment { + return ConnectionHubPresentation.status( + for: savedEnvironment, + pendingEnabled: pendingEnabled + ) + } + + return switch linkedEnvironment?.status?.status { + case .online: .online + case .offline: .offline + case nil: linkedEnvironment?.statusError == nil ? .checking : .offline + } + } +} + +enum ConnectionHubPresentation { + static func status( + for environment: FeatureEnvironment, + pendingEnabled: Bool? = nil + ) -> ConnectionHubStatus { + guard pendingEnabled ?? environment.isEnabled else { return .disabled } + + if pendingEnabled == true && !environment.isEnabled { + return .connecting + } + + return switch environment.connectionState { + case .connected: .online + case .connecting, .reconnecting: .connecting + case .disconnected: .offline + case nil: .checking + } + } + + static func disambiguatingEndpoint( + _ endpoint: String, + for name: String, + among names: [String] + ) -> String? { + let matchingNames = names.filter { + $0.localizedCaseInsensitiveCompare(name) == .orderedSame + } + guard matchingNames.count > 1, + let components = URLComponents(string: endpoint), + let host = components.host else { return nil } + + return components.port.map { "\(host):\($0)" } ?? host + } + + static func directEnvironments( + in environments: [FeatureEnvironment] + ) -> [FeatureEnvironment] { + environments.enumerated() + .filter { $0.element.source == .direct } + .sorted { first, second in + if first.element.isEnabled != second.element.isEnabled { + return first.element.isEnabled + } + return first.offset < second.offset + } + .map(\.element) + } + + /// T3 Connect owns the account catalog while Core owns the environments + /// already saved on this iPhone. Join them by server identity so the hub + /// has one row and one switch for each machine. + static func t3ConnectEnvironments( + saved: [FeatureEnvironment], + linked: [T3ConnectCloudEnvironment] + ) -> [T3ConnectEnvironmentPresentation] { + let savedByID = Dictionary( + uniqueKeysWithValues: saved + .filter { $0.source == .t3Connect } + .map { ($0.id, $0) } + ) + let linkedIDs = Set(linked.map(\.id)) + let linkedRows = linked.map { + T3ConnectEnvironmentPresentation( + linkedEnvironment: $0, + savedEnvironment: savedByID[$0.id] + ) + } + let savedOnlyRows: [T3ConnectEnvironmentPresentation] = saved.compactMap { environment in + guard environment.source == .t3Connect, + !linkedIDs.contains(environment.id) else { return nil } + return T3ConnectEnvironmentPresentation( + linkedEnvironment: nil, + savedEnvironment: environment + ) + } + return linkedRows + savedOnlyRows + } +} diff --git a/apps/swift-ios/Features/Settings/ConnectionsView.swift b/apps/swift-ios/Features/Settings/ConnectionsView.swift new file mode 100644 index 000000000000..fb2bd76fbda2 --- /dev/null +++ b/apps/swift-ios/Features/Settings/ConnectionsView.swift @@ -0,0 +1,689 @@ +import SwiftUI + +struct ConnectionsView: View { + @Bindable var model: FeatureRootModel + + @State private var pendingEnabledValues: [String: Bool] = [:] + @State private var showingAddConnection = false + @State private var showingDevices = false + @State private var showingT3Connect = false + @State private var detailEnvironmentID: String? + @State private var removalTarget: FeatureEnvironment? + @State private var connectingEnvironmentID: String? + @State private var connectionErrorMessage: String? + + var body: some View { + VStack(spacing: 0) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + directConnectionsSection + t3ConnectSection + accessSection + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + } + .scrollDismissesKeyboard(.interactively) + } + .background(T3Colors.background) + .navigationTitle("Environments") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .navigationBar) + .t3NavigationChrome() + .task { + await t3ConnectController?.refresh() + } + .sheet(isPresented: $showingAddConnection) { + ConnectionOnboardingView( + model: model, + showsT3ConnectOption: false, + onConnected: { + showingAddConnection = false + Task { await model.reloadAfterConnection() } + }, + onCancel: { showingAddConnection = false } + ) + } + .sheet(isPresented: $showingDevices) { + NavigationStack { + DevicesView(manager: deviceManager) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showingDevices = false } + } + } + } + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showingT3Connect) { + if let capability = model.client as? any T3ConnectCapable { + NavigationStack { + T3ConnectView( + capability: capability, + model: model, + purpose: .manage, + onUnlinked: { id in + await model.removeEnvironment(id) + } + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showingT3Connect = false } + } + } + } + .presentationDragIndicator(.visible) + } + } + .sheet( + isPresented: Binding( + get: { detailEnvironmentID != nil }, + set: { if !$0 { detailEnvironmentID = nil } } + ) + ) { + if let id = detailEnvironmentID { + NavigationStack { + ConnectionDetailView( + model: model, + environmentID: id, + pendingEnabledValues: $pendingEnabledValues, + onRemove: { + detailEnvironmentID = nil + Task { await model.removeEnvironment(id) } + } + ) + } + .presentationDragIndicator(.visible) + } + } + .alert( + "Remove connection?", + isPresented: Binding( + get: { removalTarget != nil }, + set: { if !$0 { removalTarget = nil } } + ), + presenting: removalTarget + ) { environment in + Button("Remove", role: .destructive) { + Task { + await model.removeEnvironment(environment.id) + removalTarget = nil + } + } + Button("Cancel", role: .cancel) {} + } message: { environment in + Text(removalMessage(for: environment)) + } + .alert( + "T3 Connect", + isPresented: Binding( + get: { connectionErrorMessage != nil }, + set: { if !$0 { connectionErrorMessage = nil } } + ) + ) { + Button("OK") { connectionErrorMessage = nil } + } message: { + Text(connectionErrorMessage ?? "Something went wrong.") + } + } + + private var directConnectionsSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text("Direct") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 0) + Button("Add") { showingAddConnection = true } + .font(T3Typography.control) + .accessibilityIdentifier("connections-add-button") + } + + if directEnvironments.isEmpty { + emptyRow("No paired environments") + } else { + VStack(spacing: 0) { + ForEach(directEnvironments) { environment in + directConnectionRow(environment) + } + } + } + } + } + + private func directConnectionRow(_ environment: FeatureEnvironment) -> some View { + HStack(spacing: 12) { + Button { + detailEnvironmentID = environment.id + } label: { + HStack(spacing: 12) { + Image(systemName: environment.systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 25) + .accessibilityHidden(true) + + environmentLabel( + name: environment.name, + status: ConnectionHubPresentation.status( + for: environment, + pendingEnabled: pendingEnabledValues[environment.id] + ), + endpoint: directEndpointLabel(for: environment) + ) + + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("Shows connection details") + + Toggle("Enabled", isOn: enabledBinding(for: environment)) + .labelsHidden() + .tint(T3Colors.success) + .disabled(pendingEnabledValues[environment.id] != nil) + .accessibilityLabel( + toggleAccessibilityLabel( + name: environment.name, + endpoint: directEndpointLabel(for: environment) + ) + ) + } + .frame(minHeight: 70) + .contextMenu { + Button(role: .destructive) { + removalTarget = environment + } label: { + Label("Remove connection", systemImage: "trash") + } + } + } + + private var t3ConnectSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text("T3 Connect") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 0) + Button(t3ConnectController?.account == nil ? "Sign in" : "Manage") { + showingT3Connect = true + } + .font(T3Typography.control) + .disabled(t3ConnectCapability == nil) + .accessibilityIdentifier("connections-manage-t3-connect-button") + } + + if t3ConnectRows.isEmpty { + if t3ConnectController?.isRefreshing == true { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking T3 Connect") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(minHeight: 44) + .frame(maxWidth: .infinity, alignment: .leading) + } else if t3ConnectCapability == nil { + emptyRow("T3 Connect unavailable") + } else if t3ConnectController?.account == nil { + emptyRow("Sign in to see your environments") + } else { + emptyRow("No linked environments") + } + } else { + VStack(spacing: 0) { + ForEach(t3ConnectRows) { item in + t3ConnectConnectionRow(item) + } + } + } + } + } + + private func t3ConnectConnectionRow( + _ item: T3ConnectEnvironmentPresentation + ) -> some View { + HStack(spacing: 12) { + Group { + if let environment = item.savedEnvironment { + Button { + detailEnvironmentID = environment.id + } label: { + t3ConnectEnvironmentLabel(item) + } + .buttonStyle(.plain) + .accessibilityHint("Shows connection details") + } else { + t3ConnectEnvironmentLabel(item) + } + } + + Toggle("Enabled", isOn: t3ConnectEnabledBinding(for: item)) + .labelsHidden() + .tint(T3Colors.success) + .disabled(isT3ConnectToggleDisabled(item)) + .accessibilityHint( + item.savedEnvironment == nil && item.status == .offline + ? "Environment is offline" + : "" + ) + .accessibilityLabel( + toggleAccessibilityLabel( + name: item.name, + endpoint: t3ConnectEndpointLabel(for: item) + ) + ) + } + .frame(minHeight: 70) + } + + private func t3ConnectEnvironmentLabel( + _ item: T3ConnectEnvironmentPresentation + ) -> some View { + HStack(spacing: 12) { + Image(systemName: item.savedEnvironment?.systemImage ?? "cloud") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 25) + .accessibilityHidden(true) + + environmentLabel( + name: item.name, + status: item.connectionStatus( + pendingEnabled: pendingEnabledValues[item.id], + isConnecting: connectingEnvironmentID == item.id + || t3ConnectController?.busyEnvironmentID == item.id + ), + endpoint: t3ConnectEndpointLabel(for: item) + ) + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + + private func environmentLabel( + name: String, + status: ConnectionHubStatus, + endpoint: String? + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + + HStack(spacing: 7) { + Circle() + .fill(status.color) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + + Text(status.title) + .fixedSize(horizontal: true, vertical: false) + + if let endpoint { + Text(endpoint) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .accessibilityElement(children: .combine) + } + + private func emptyRow(_ message: String) -> some View { + Text(message) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 38) + } + + private var accessSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Access") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Button { + showingDevices = true + } label: { + HStack(spacing: 12) { + Image(systemName: "laptopcomputer.and.iphone") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(T3Colors.accent) + .frame(width: 25) + .accessibilityHidden(true) + Text("Devices and sessions") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityHidden(true) + } + .frame(minHeight: 54) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + private func directEndpointLabel(for environment: FeatureEnvironment) -> String? { + ConnectionHubPresentation.disambiguatingEndpoint( + environment.endpoint, + for: environment.name, + among: directEnvironments.map(\.name) + ) + } + + private func t3ConnectEndpointLabel( + for item: T3ConnectEnvironmentPresentation + ) -> String? { + guard let endpoint = item.endpoint else { return nil } + return ConnectionHubPresentation.disambiguatingEndpoint( + endpoint, + for: item.name, + among: t3ConnectRows.map(\.name) + ) + } + + private func toggleAccessibilityLabel(name: String, endpoint: String?) -> String { + guard let endpoint else { return "Enable \(name)" } + return "Enable \(name), \(endpoint)" + } + + private func enabledBinding(for environment: FeatureEnvironment) -> Binding { + Binding( + get: { pendingEnabledValues[environment.id] ?? environment.isEnabled }, + set: { enabled in + pendingEnabledValues[environment.id] = enabled + Task { + _ = await model.setEnvironmentEnabled(environment.id, enabled: enabled) + pendingEnabledValues[environment.id] = nil + } + } + ) + } + + private func t3ConnectEnabledBinding( + for item: T3ConnectEnvironmentPresentation + ) -> Binding { + Binding( + get: { pendingEnabledValues[item.id] ?? item.isEnabled }, + set: { enabled in + pendingEnabledValues[item.id] = enabled + Task { await setT3ConnectEnvironment(item, enabled: enabled) } + } + ) + } + + private func setT3ConnectEnvironment( + _ item: T3ConnectEnvironmentPresentation, + enabled: Bool + ) async { + defer { + pendingEnabledValues[item.id] = nil + if connectingEnvironmentID == item.id { + connectingEnvironmentID = nil + } + } + + if let savedEnvironment = item.savedEnvironment { + _ = await model.setEnvironmentEnabled(savedEnvironment.id, enabled: enabled) + return + } + + guard enabled, + let linkedEnvironment = item.linkedEnvironment, + let capability = t3ConnectCapability else { return } + + connectingEnvironmentID = item.id + do { + let credential = try await capability.t3ConnectController.credential( + for: linkedEnvironment.environment + ) + try await capability.connectT3Environment(credential) + await model.reloadAfterConnection() + } catch { + connectionErrorMessage = error.localizedDescription + } + } + + private func isT3ConnectToggleDisabled( + _ item: T3ConnectEnvironmentPresentation + ) -> Bool { + pendingEnabledValues[item.id] != nil + || (connectingEnvironmentID != nil && connectingEnvironmentID != item.id) + || t3ConnectController?.busyEnvironmentID != nil + || (item.savedEnvironment == nil && item.status == .offline) + } + + private var directEnvironments: [FeatureEnvironment] { + ConnectionHubPresentation.directEnvironments(in: model.snapshot.environments) + } + + private var t3ConnectRows: [T3ConnectEnvironmentPresentation] { + ConnectionHubPresentation.t3ConnectEnvironments( + saved: model.snapshot.environments, + linked: t3ConnectController?.environments ?? [] + ) + } + + private var t3ConnectCapability: (any T3ConnectCapable)? { + model.client as? any T3ConnectCapable + } + + private var t3ConnectController: T3ConnectController? { + t3ConnectCapability?.t3ConnectController + } + + private func removalMessage(for environment: FeatureEnvironment) -> String { + switch environment.source { + case .direct: + "\(environment.name) will need a new pairing code to be added again." + case .t3Connect: + "\(environment.name) will remain linked to your T3 Connect account." + } + } + + private var deviceManager: any FeatureDeviceManaging { + (model.client as? any FeatureDeviceManaging) ?? EmptyFeatureDeviceManager.shared + } +} + +private struct ConnectionDetailView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + let environmentID: String + @Binding var pendingEnabledValues: [String: Bool] + let onRemove: () -> Void + + @State private var showingRemoval = false + @State private var isUpdatingAutomaticSettlement = false + + var body: some View { + List { + if let environment { + Section { + Toggle("Enabled", isOn: enabledBinding(for: environment)) + .tint(T3Colors.success) + .disabled(pendingEnabledValues[environment.id] != nil) + LabeledContent( + "Status", + value: ConnectionHubPresentation.status( + for: environment, + pendingEnabled: pendingEnabledValues[environment.id] + ).title + ) + LabeledContent("Connection", value: environment.source.title) + } + + Section("Server") { + Text(environment.endpoint) + .textSelection(.enabled) + LabeledContent("Projects", value: "\(projectCount)") + if environment.canCustomizeIcon == true || automaticSettlement != nil { + NavigationLink("Preferences") { + EnvironmentPreferencesView(model: model, environmentID: environmentID) + } + } + } + + if let automaticSettlement { + Section("Automatic settlement") { + Toggle("When a pull request merges", isOn: mergeBinding) + .tint(T3Colors.success) + Toggle("After inactivity", isOn: inactivityEnabledBinding) + .tint(T3Colors.success) + if automaticSettlement.afterDays != nil { + Stepper( + value: inactivityDaysBinding, + in: 1...90, + step: 1 + ) { + LabeledContent( + "Days", + value: formattedDays(automaticSettlement.afterDays ?? 3) + ) + } + } + } + .disabled(automaticSettlementControlsDisabled) + } + + Section { + Button("Remove connection", role: .destructive) { + showingRemoval = true + } + } + } else { + ContentUnavailableView("Connection removed", systemImage: "network.slash") + } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle(environment?.name ?? "Connection") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .confirmationDialog( + "Remove this connection?", + isPresented: $showingRemoval, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive, action: onRemove) + Button("Cancel", role: .cancel) {} + } message: { + Text(removalMessage) + } + } + + private var environment: FeatureEnvironment? { + model.snapshot.environments.first { $0.id == environmentID } + } + + private var projectCount: Int { + model.snapshot.projects.count { $0.environmentID == environmentID } + } + + private var automaticSettlement: FeatureAutomaticSettlementSettings? { + model.snapshot.preferencesByEnvironment?[environmentID]?.automaticSettlement + } + + private var automaticSettlementControlsDisabled: Bool { + isUpdatingAutomaticSettlement + || pendingEnabledValues[environmentID] != nil + || environment?.isEnabled != true + || environment?.connectionState != .connected + } + + private var mergeBinding: Binding { + Binding( + get: { automaticSettlement?.onMerge ?? false }, + set: { updateAutomaticSettlement(.onMerge($0)) } + ) + } + + private var inactivityEnabledBinding: Binding { + Binding( + get: { automaticSettlement?.afterDays != nil }, + set: { enabled in + updateAutomaticSettlement(.afterDays(enabled ? 3 : nil)) + } + ) + } + + private var inactivityDaysBinding: Binding { + Binding( + get: { automaticSettlement?.afterDays ?? 3 }, + set: { updateAutomaticSettlement(.afterDays($0)) } + ) + } + + private func updateAutomaticSettlement(_ change: FeatureAutomaticSettlementChange) { + guard !automaticSettlementControlsDisabled else { return } + isUpdatingAutomaticSettlement = true + Task { + _ = await model.updateAutomaticSettlement( + environmentID: environmentID, + change: change + ) + isUpdatingAutomaticSettlement = false + } + } + + private func formattedDays(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...2))) + } + + private var removalMessage: String { + guard environment?.source == .t3Connect else { + return "A new pairing code will be required to add it again." + } + return "It will remain linked to your T3 Connect account." + } + + private func enabledBinding(for environment: FeatureEnvironment) -> Binding { + Binding( + get: { pendingEnabledValues[environment.id] ?? environment.isEnabled }, + set: { enabled in + pendingEnabledValues[environment.id] = enabled + Task { + _ = await model.setEnvironmentEnabled(environment.id, enabled: enabled) + pendingEnabledValues[environment.id] = nil + } + } + ) + } +} + +private extension FeatureEnvironment.Source { + var title: String { + switch self { + case .direct: "Direct" + case .t3Connect: "T3 Connect" + } + } + +} + +private extension ConnectionHubStatus { + var color: Color { + switch self { + case .disabled, .checking: T3Colors.textTertiary + case .connecting: T3Colors.warning + case .offline: T3Colors.danger + case .online: T3Colors.success + } + } +} diff --git a/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift b/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift new file mode 100644 index 000000000000..5e9253653d87 --- /dev/null +++ b/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct EnvironmentPreferencesView: View { + @Bindable var model: FeatureRootModel + let environmentID: String + @State private var settings: ServerSettingsSnapshot? + @State private var busy = false + @State private var errorMessage: String? + @State private var mismatches: [String] = [] + + private var environment: FeatureEnvironment? { + model.snapshot.environments.first { $0.id == environmentID } + } + + private var supportsRestartContinuation: Bool { + model.snapshot.preferencesByEnvironment?[environmentID]?.continueThreadsAfterServerUpdate != nil + } + + var body: some View { + Form { + if let settings { + if environment?.canCustomizeIcon == true { + Section("Environment") { + Picker("Icon", selection: Binding( + get: { settings.environmentIcon ?? "" }, + set: { save(.environmentIcon($0.isEmpty ? nil : $0)) } + )) { + Text("Detected").tag("") + Text("Server").tag("server") + Text("Cloud").tag("cloud") + Text("Desktop").tag("desktop") + Text("Laptop").tag("laptop") + Text("Mac mini").tag("mac-mini") + Text("Mac Studio").tag("mac-studio") + } + } + } + if model.snapshot.preferencesByEnvironment?[environmentID]?.automaticSettlement != nil { + Section { + Picker("New threads", selection: Binding( + get: { settings.defaultThreadEnvMode.rawValue }, + set: { save(.defaultThreadEnvMode($0 == "worktree" ? .worktree : .local)) } + )) { + Text("Local workspace").tag("local") + Text("New worktree").tag("worktree") + } + Toggle("Start worktrees from origin", isOn: Binding( + get: { settings.newWorktreesStartFromOrigin }, + set: { save(.newWorktreesStartFromOrigin($0)) } + )) + if supportsRestartContinuation { + Toggle("Continue threads after restarts", isOn: Binding( + get: { settings.continueThreadsAfterServerUpdate }, + set: { save(.continueThreadsAfterServerUpdate($0)) } + )) + } + } footer: { + Text("These preferences and automatic settlement apply to connected environments that support them. Projects, models and providers remain separate.") + } + if !mismatches.isEmpty { + Section("Different preferences") { + ForEach(mismatches, id: \.self) { Text($0) } + Button("Use this environment’s preferences") { + save(.sharedPreferences(settings.sharedPatch( + supportsRestartContinuation: supportsRestartContinuation + ))) + } + } + } + } + } else if errorMessage == nil { + Text("Loading preferences…") + } + if let errorMessage { + Section { + Text(errorMessage) + Button("Try again") { Task { await load() } } + } + } + } + .disabled(busy) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle("Preferences") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + do { + settings = try await model.client.serverPreferences(environmentID: environmentID) + mismatches = model.client.sharedPreferenceMismatches(environmentID: environmentID) + errorMessage = nil + } catch { errorMessage = "Could not load preferences. Check this connection." } + } + + private func save(_ change: ServerSettingsChange) { + busy = true + Task { + defer { busy = false } + do { + try await model.client.updateServerPreferences(environmentID: environmentID, change: change) + await load() + } catch { errorMessage = "Could not save preferences. Check this connection and try again." } + } + } +} diff --git a/apps/swift-ios/Features/Settings/ProviderSetupView.swift b/apps/swift-ios/Features/Settings/ProviderSetupView.swift new file mode 100644 index 000000000000..d5183504793b --- /dev/null +++ b/apps/swift-ios/Features/Settings/ProviderSetupView.swift @@ -0,0 +1,188 @@ +import SwiftUI + +struct ProviderSetupContext { + let model: FeatureRootModel + let environmentID: String +} + +private struct ProviderSetupContextKey: EnvironmentKey { + static let defaultValue: ProviderSetupContext? = nil +} + +extension EnvironmentValues { + var providerSetupContext: ProviderSetupContext? { + get { self[ProviderSetupContextKey.self] } + set { self[ProviderSetupContextKey.self] = newValue } + } +} + +struct ProvidersSettingsView: View { + @Bindable var model: FeatureRootModel + var environmentID: String? + + var body: some View { + List { + ForEach(model.snapshot.environments.filter { environmentID == nil || $0.id == environmentID }) { environment in + Section(environment.name) { + let providers = model.snapshot.providersByEnvironment?[environment.id] ?? [] + if providers.isEmpty { Text("Connect this environment to load providers.") } + ForEach(providers) { provider in + NavigationLink { + ProviderSetupView(model: model, environmentID: environment.id, instanceID: provider.id) + } label: { + HStack(spacing: 12) { + ProviderIcon(driver: provider.driver, providerID: provider.id, fallbackName: provider.name, size: 24) + VStack(alignment: .leading) { + Text(provider.name) + Text(provider.isAvailable ? "Ready" : provider.statusMessage ?? "Setup needed") + .font(.caption).foregroundStyle(T3Colors.textSecondary) + } + } + } + } + } + } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle("Providers") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ProviderSetupView: View { + @SwiftUI.Environment(\.openURL) private var openURL + @Bindable var model: FeatureRootModel + let environmentID: String + let instanceID: String + @State private var auth: ProviderAuthState? + @State private var install: ProviderInstallState? + @State private var callbackURL = "" + @State private var busy = false + @State private var errorMessage: String? + @State private var confirmSignOut = false + @State private var confirmRemove = false + + private var provider: FeatureProvider? { + model.snapshot.providersByEnvironment?[environmentID]?.first { $0.id == instanceID } + } + + var body: some View { + Form { + if let provider { + Section { + Text(provider.statusMessage ?? (provider.isAvailable ? "Ready" : "Setup needed")) + if provider.driver == "antigravity" { + Toggle("Enabled", isOn: Binding( + get: { provider.isEnabled == true }, + set: { enabled in + Task { + busy = true + defer { busy = false } + do { + try await model.client.setProviderEnabled(environmentID: environmentID, instanceID: instanceID, enabled: enabled) + _ = await model.refreshProviders(environmentID: environmentID) + } catch { errorMessage = "Could not change provider settings." } + } + } + )) + } + } + if provider.setup?.canInstall == true { + Section("Runtime") { + if let install, install.isActive { + Text(install.phase.capitalized) + if let total = install.totalBytes, total > 0 { + ProgressView(value: Double(install.downloadedBytes), total: Double(total)) + } + if let operationID = install.operationId { + Button("Cancel installation") { run(.cancelInstall(operationID: operationID)) } + } + } else { + Button(provider.isInstalled == true ? "Reinstall runtime" : "Install runtime") { run(.install) } + if install?.canRemove == true { + Button("Remove runtime", role: .destructive) { confirmRemove = true } + } + } + if let message = install?.message { Text(message).font(.footnote) } + } + } + if provider.setup?.canAuthenticate == true { + Section("Account") { + if provider.authStatus == "authenticated" + || (provider.isEnabled == false && provider.authStatus == "unknown") { + if provider.authStatus == "authenticated" { Text("Signed in") } + Button("Sign out", role: .destructive) { confirmSignOut = true } + } else if let auth, auth.isActive { + if let rawURL = auth.authorizationUrl, let url = URL(string: rawURL), url.scheme == "https" { + Button("Open sign-in page") { openURL(url) } + } + if let flowID = auth.flowId { + TextField("Paste the return URL", text: $callbackURL, axis: .vertical) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .privacySensitive() + Button("Finish sign-in") { + let url = callbackURL.trimmingCharacters(in: .whitespacesAndNewlines) + callbackURL = "" + run(.completeSignIn(flowID: flowID, callbackURL: url)) + }.disabled(callbackURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel sign-in") { callbackURL = ""; run(.cancelSignIn(flowID: flowID)) } + } + } else { + Button("Sign in") { run(.signIn) } + .disabled(provider.isEnabled == false || provider.isInstalled == false) + } + if auth?.phase != "succeeded", let message = auth?.message { Text(message).font(.footnote) } + } + } + if provider.setup == nil { + Section { Text("Configure this provider on its computer.") } + } + Section { Button("Refresh models") { Task { _ = await model.refreshProviders(environmentID: environmentID) } } } + Section { Text("Runtime and credentials stay on this environment.").font(.footnote) } + } + if let errorMessage { Section { Text(errorMessage).foregroundStyle(T3Colors.textSecondary) } } + } + .disabled(busy) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle(provider?.name ?? "Provider") + .navigationBarTitleDisplayMode(.inline) + .tint(T3Colors.accent) + .task(id: instanceID) { + do { + for try await event in model.client.providerSetupEvents(environmentID: environmentID, instanceID: instanceID) { + receive(event) + } + } catch is CancellationError {} catch { errorMessage = "Could not load provider setup. Check this connection and its permissions." } + } + .onDisappear { callbackURL = "" } + .confirmationDialog("Sign out on this environment?", isPresented: $confirmSignOut) { + Button("Sign out", role: .destructive) { run(.signOut) } + } + .confirmationDialog("Remove the runtime from this environment?", isPresented: $confirmRemove) { + Button("Remove runtime", role: .destructive) { run(.remove) } + } + } + + private func receive(_ event: ProviderSetupEvent) { + switch event { + case let .auth(state): auth = state + case let .install(state): install = state + } + } + + private func run(_ action: ProviderSetupAction) { + Task { + busy = true + errorMessage = nil + defer { busy = false } + do { + receive(try await model.client.providerSetup(environmentID: environmentID, instanceID: instanceID, action: action)) + } catch { + // Provider errors can contain callback URLs. Do not display or persist them. + errorMessage = "Provider setup failed. Check the connection and try again." + } + } + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift new file mode 100644 index 000000000000..d5687b02f417 --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -0,0 +1,554 @@ +import SwiftUI + +public struct SettingsView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable private var model: FeatureRootModel + @State private var saveErrorMessage: String? + @State private var isPresented = false + + public init(model: FeatureRootModel) { + self.model = model + } + + public var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + workspaceSection + appSection + activitySection + aboutSection + } + .padding(.vertical, 20) + } + .scrollDismissesKeyboard(.interactively) + .background(T3Colors.background) + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .accessibilityIdentifier("settings-done") + } + } + } + .alert( + "Couldn't save settings", + isPresented: Binding( + get: { saveErrorMessage != nil }, + set: { if !$0 { saveErrorMessage = nil } } + ) + ) { + Button("OK") { saveErrorMessage = nil } + } message: { + Text(saveErrorMessage ?? "Try changing the setting again.") + } + .onAppear { + isPresented = true + model.setConnectionManagementPresented(true) + } + .onDisappear { + isPresented = false + model.setConnectionManagementPresented(false) + } + .presentationBackground(T3Colors.background) + .presentationDragIndicator(.visible) + .t3CodeSizing(steps: model.snapshot.settings.codeSize.steps) + } + + private var workspaceSection: some View { + SettingsSection(title: "Workspace") { + VStack(spacing: 0) { + NavigationLink { + ConnectionsView(model: model) + } label: { + SettingsNavigationRow( + title: "Environments", + value: environmentCountLabel, + subtitle: environmentSummary.text, + systemImage: "server.rack", + statusColor: environmentSummary.color + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Environments") + .accessibilityValue(environmentAccessibilityValue) + .accessibilityHint("Manage saved environments") + settingsDivider + NavigationLink { + ProvidersSettingsView(model: model) + } label: { + SettingsNavigationRow(title: "Providers", systemImage: "cpu") + } + .buttonStyle(.plain) + } + } + } + + private var activitySection: some View { + SettingsSection(title: "Activity") { + VStack(spacing: 0) { + NavigationLink { + UsageView(client: model.client) + } label: { + SettingsNavigationRow(title: "Usage", systemImage: "chart.bar.xaxis") + } + .buttonStyle(.plain) + .accessibilityHint("Shows provider usage") + settingsDivider + NavigationLink { + PullRequestsView(model: model) + } label: { + SettingsNavigationRow( + title: "Pull requests", + systemImage: "arrow.triangle.pull" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Shows pull requests") + } + } + } + + private var appSection: some View { + SettingsSection(title: "App") { + VStack(spacing: 0) { + NavigationLink { + SettingsAppearanceView( + appearance: preference(\.appearance), + textSize: preference(\.textSize), + codeSize: preference(\.codeSize) + ) + } label: { + SettingsNavigationRow( + title: "Appearance", + systemImage: "circle.lefthalf.filled" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Theme, text size, and code size") + .accessibilityIdentifier("settings-appearance") + settingsDivider + NavigationLink { + SettingsNotificationsView( + notificationsEnabled: preference(\.notificationsEnabled), + liveActivitiesEnabled: preference(\.liveActivitiesEnabled) + ) + } label: { + SettingsNavigationRow(title: "Notifications", systemImage: "bell") + } + .buttonStyle(.plain) + .accessibilityHint("Notifications and Live Activities") + .accessibilityIdentifier("settings-notifications") + settingsDivider + SettingsToggleRow( + title: "Haptics", + systemImage: "iphone.radiowaves.left.and.right", + isOn: preference(\.hapticsEnabled) + ) + .accessibilityIdentifier("settings-haptics") + } + } + } + + private var aboutSection: some View { + SettingsSection(title: "About", footer: "Version \(appVersionLabel)") { + Link(destination: URL(string: "https://github.com/pingdotgg/t3code")!) { + SettingsNavigationRow( + title: "Source code", + systemImage: "chevron.left.forwardslash.chevron.right", + trailingSystemImage: "arrow.up.right" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Opens GitHub in your browser") + } + } + + private var settingsDivider: some View { + Divider() + .overlay(T3Colors.separator) + .padding(.leading, 54) + .padding(.trailing, 20) + } + + private var environmentSummary: (text: String, color: Color) { + let environments = model.snapshot.environments + guard !environments.isEmpty else { + return ("Add an environment", T3Colors.textTertiary) + } + + let connected = connectedEnvironments + if connected.count == 1, let environment = connected.first { + return ("\(environment.name) online", T3Colors.success) + } + if connected.count > 1 { + return ("\(connected.count) online", T3Colors.success) + } + + let enabled = environments.filter(\.isEnabled) + guard !enabled.isEmpty else { + let text = environments.count == 1 ? "Off" : "All off" + return (text, T3Colors.textTertiary) + } + + if let connecting = enabled.first(where: { + $0.connectionState == .connecting || $0.connectionState == .reconnecting + }) { + let state = connecting.connectionState == .reconnecting + ? "reconnecting" + : "connecting" + return ("\(connecting.name) \(state)", T3Colors.warning) + } + + if let checking = enabled.first(where: { $0.connectionState == nil }) { + let text = enabled.count == 1 + ? "\(checking.name) checking" + : "Checking environments" + return (text, T3Colors.textTertiary) + } + + let text = enabled.count == 1 ? "\(enabled[0].name) offline" : "All offline" + return (text, T3Colors.danger) + } + + private var connectedEnvironments: [FeatureEnvironment] { + model.snapshot.environments.filter { + ConnectionHubPresentation.status(for: $0) == .online + } + } + + private var environmentCountLabel: String? { + let environments = model.snapshot.environments + guard !environments.isEmpty else { return nil } + return "\(connectedEnvironments.count)/\(environments.count)" + } + + private var environmentAccessibilityValue: String { + let environmentCount = model.snapshot.environments.count + guard environmentCount > 0 else { + return environmentSummary.text + } + + let connectedCount = connectedEnvironments.count + return "\(environmentSummary.text), \(connectedCount) of \(environmentCount) online" + } + + private var appVersionLabel: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "?" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ?? "?" + return "\(version) (\(build))" + } + + private func preference( + _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { model.snapshot.settings[keyPath: keyPath] }, + set: { value in + // The model owns queued writes, including after this sheet closes. + Task { + let didSave = await model.savePreference(keyPath, value: value) + if !didSave, isPresented, let message = model.errorMessage { + saveErrorMessage = message + model.errorMessage = nil + } + } + } + ) + } +} + +private struct SettingsAppearanceView: View { + @Binding var appearance: FeatureAppearance + @Binding var textSize: FeatureTextSizeAdjustment + @Binding var codeSize: FeatureTextSizeAdjustment + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + SettingsSection(title: "Theme") { + Picker("Theme", selection: $appearance) { + Text("System").tag(FeatureAppearance.system) + Text("Light").tag(FeatureAppearance.light) + Text("Dark").tag(FeatureAppearance.dark) + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .accessibilityIdentifier("settings-theme") + } + SettingsSection( + title: "Text and code", + footer: "Sizes follow your iOS text size. Code size also applies to diffs, files, and tool output." + ) { + VStack(spacing: 12) { + SettingsTextSizePreview() + SettingsTextSizeRow( + title: "Text size", systemImage: "textformat.size", + adjustment: $textSize + ) + .accessibilityIdentifier("settings-text-size") + SettingsTextSizeRow( + title: "Code size", systemImage: "chevron.left.forwardslash.chevron.right", + adjustment: $codeSize + ) + .accessibilityIdentifier("settings-code-size") + } + } + } + .padding(.vertical, 20) + } + .background(T3Colors.background) + .navigationTitle("Appearance") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + } +} + +private struct SettingsNotificationsView: View { + @Binding var notificationsEnabled: Bool + @Binding var liveActivitiesEnabled: Bool + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + SettingsToggleRow( + title: "Notifications", systemImage: "bell", + isOn: $notificationsEnabled + ) + .accessibilityIdentifier("settings-notifications-enabled") + Divider() + .overlay(T3Colors.separator) + .padding(.leading, 54) + .padding(.trailing, 20) + SettingsToggleRow( + title: "Live Activities", systemImage: "waveform.path.ecg.rectangle", + isOn: $liveActivitiesEnabled + ) + .accessibilityIdentifier("settings-live-activities-enabled") + Text("Show thread progress on the Lock Screen and Dynamic Island.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .padding(.leading, 54) + .padding(.trailing, 20) + } + .padding(.vertical, 20) + } + .background(T3Colors.background) + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + } +} + +private struct SettingsTextSizePreview: View { + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Text("Rewrote the failing test and re-ran the suite.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Text(verbatim: "- expect(total).toBe(41)\n+ expect(total).toBe(42)") + .font(T3Typography.code) + .foregroundStyle(T3Colors.textSecondary) + .t3CodeTextSize() + .fixedSize(horizontal: false, vertical: true) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + T3Colors.surfaceRaised, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Preview of the selected text and code sizes") + } +} + +private struct SettingsTextSizeRow: View { + let title: String + let systemImage: String + @Binding var adjustment: FeatureTextSizeAdjustment + + private var steps: Binding { + Binding( + get: { Double(adjustment.steps) }, + set: { adjustment = FeatureTextSizeAdjustment(steps: Int($0.rounded())) } + ) + } + + private var valueLabel: String { + switch adjustment.steps { + case ...(-2): "Much smaller" + case -1: "Smaller" + case 0: "Default" + case 1: "Larger" + case 2: "Much larger" + default: "Largest" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 12) { + SettingsRowIcon(systemName: systemImage) + Text(title) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 12) + Text(valueLabel) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .accessibilityHidden(true) + HStack(spacing: 12) { + Image(systemName: "textformat.size.smaller") + .font(T3Typography.supporting) + Slider( + value: steps, + in: Double(FeatureTextSizeAdjustment.range.lowerBound) + ... Double(FeatureTextSizeAdjustment.range.upperBound), + step: 1 + ) { + Text(title) + } + .tint(T3Colors.accent) + .accessibilityValue(valueLabel) + Image(systemName: "textformat.size.larger") + .font(T3Typography.navigationTitle) + } + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .frame(minHeight: 52) + } +} + +private struct SettingsSection: View { + let title: String + let footer: String? + let content: Content + + init( + title: String, + footer: String? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.footer = footer + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .padding(.horizontal, 20) + .accessibilityAddTraits(.isHeader) + + content + + if let footer { + Text(footer) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .padding(.horizontal, 20) + } + } + } +} + +private struct SettingsRowIcon: View { + let systemName: String + var color: Color = T3Colors.textSecondary + + var body: some View { + Image(systemName: systemName) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(color) + .frame(width: 22, height: 22) + .accessibilityHidden(true) + } +} + +private struct SettingsNavigationRow: View { + let title: String + var value: String? = nil + var subtitle: String? = nil + let systemImage: String + var statusColor: Color? = nil + var trailingSystemImage = "chevron.right" + + var body: some View { + HStack(spacing: 12) { + SettingsRowIcon(systemName: systemImage) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + + if let subtitle { + HStack(spacing: 6) { + if let statusColor { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + } + + Text(subtitle) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + } + } + + Spacer(minLength: 8) + if let value { + Text(value) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + .layoutPriority(1) + } + Image(systemName: trailingSystemImage) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityHidden(true) + } + .padding(.horizontal, 20) + .frame(minHeight: subtitle == nil ? 56 : 68) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } +} + +private struct SettingsToggleRow: View { + let title: String + let systemImage: String + @Binding var isOn: Bool + + var body: some View { + Toggle(isOn: $isOn) { + HStack(spacing: 12) { + SettingsRowIcon(systemName: systemImage) + Text(title) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + } + } + .tint(T3Colors.accent) + .padding(.horizontal, 20) + .frame(minHeight: 56) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureActiveSubagentTracker.swift b/apps/swift-ios/Features/Shared/FeatureActiveSubagentTracker.swift new file mode 100644 index 000000000000..9cbe18e5a35f --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureActiveSubagentTracker.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Keeps only the small part of subagent state that mobile displays. The +/// server-provided `agentKind` is authoritative; legacy unmarked tasks remain +/// ordinary work-log entries. +struct FeatureActiveSubagentTracker { + private enum Status: String { + case pending + case running + case waiting + case idle + case completed + case failed + case cancelled + case interrupted + + var isActive: Bool { + self == .pending || self == .running || self == .waiting + } + + var isTerminal: Bool { + self == .completed || self == .failed || self == .cancelled || self == .interrupted + } + } + + private var statuses: [String: Status] = [:] + + var activeCount: Int { + statuses.values.count(where: \.isActive) + } + + mutating func reset(with activities: [OrchestrationActivity]) { + statuses.removeAll(keepingCapacity: true) + for activity in activities { + apply(activity) + } + } + + mutating func apply(_ activity: OrchestrationActivity) { + guard activity.kind == "task.started" + || activity.kind == "task.progress" + || activity.kind == "task.updated" + || activity.kind == "task.completed", + let taskID = activity.payload["taskId"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines), + !taskID.isEmpty else { + return + } + + let isKnownAgent = statuses[taskID] != nil + let isExplicitAgent = activity.payload["agentKind"]?.stringValue == "agent" + guard isKnownAgent || isExplicitAgent else { return } + + switch activity.kind { + case "task.started": + if let current = statuses[taskID], current.isTerminal { + return + } + statuses[taskID] = .running + + case "task.progress": + if let status = status(from: activity.payload["status"]) { + statuses[taskID] = status + } else if statuses[taskID] != .idle, + statuses[taskID]?.isTerminal != true { + statuses[taskID] = .running + } + + case "task.updated": + statuses[taskID] = status(from: activity.payload["status"]) + ?? statuses[taskID] + ?? .pending + + case "task.completed": + guard statuses[taskID]?.isTerminal != true else { return } + statuses[taskID] = switch activity.payload["status"]?.stringValue { + case "failed": .failed + case "stopped": .interrupted + default: .completed + } + + default: + break + } + } + + private func status(from value: JSONValue?) -> Status? { + guard let rawValue = value?.stringValue else { return nil } + return Status(rawValue: rawValue) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift b/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift new file mode 100644 index 000000000000..aab2cab89900 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Visible attachment rows resolve their own URLs. Opening a thread does not +/// request signed URLs for images that are still outside the viewport. +@MainActor +public protocol FeatureAttachmentAssetResolving: AnyObject { + func attachmentAssetURL( + threadID: String, + attachment: FeatureMessageAttachment + ) async throws -> URL +} + +struct FeatureAttachmentContext: Equatable { + let threadID: String + let resolver: any FeatureAttachmentAssetResolving + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.threadID == rhs.threadID && lhs.resolver === rhs.resolver + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureAttachmentUploadCoordinator.swift b/apps/swift-ios/Features/Shared/FeatureAttachmentUploadCoordinator.swift new file mode 100644 index 000000000000..a37d1d21bd41 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureAttachmentUploadCoordinator.swift @@ -0,0 +1,413 @@ +import Foundation +import Observation + +public struct FeatureAttachmentUploadKey: Hashable, Sendable { + public let environmentID: String + public let attachmentID: UUID + + public init(environmentID: String, attachmentID: UUID) { + self.environmentID = environmentID + self.attachmentID = attachmentID + } +} + +public enum FeatureAttachmentUploadState: Equatable, Sendable { + case queued + case uploading + case ready(FeatureUploadedAttachmentReference?) + case failed(String) +} + +@MainActor +@Observable +public final class FeatureAttachmentUploadCoordinator { + typealias Upload = @MainActor @Sendable (FeatureUploadAttachment, String) async throws + -> FeatureUploadedAttachmentReference? + typealias Persist = @MainActor @Sendable ( + FeatureUploadedAttachmentReference, + FeatureDraftAttachment, + String + ) async throws -> Bool + typealias WaitForTimeout = @MainActor @Sendable (Duration) async throws -> Void + + private struct Owner { + var environmentID: String + var attachments: [UUID: FeatureDraftAttachment] + } + + private struct Job { + let attachment: FeatureDraftAttachment + let token: UUID + var state: FeatureAttachmentUploadState + var task: Task? + var deadlineTask: Task? + } + + public private(set) var states: [FeatureAttachmentUploadKey: FeatureAttachmentUploadState] = [:] + private let upload: Upload + private let persist: Persist + private let waitForTimeout: WaitForTimeout + private let maximumConcurrentUploads: Int + private var owners: [String: Owner] = [:] + private var outboxOwners: [String: Set] = [:] + private var jobs: [FeatureAttachmentUploadKey: Job] = [:] + // Canceled transfers keep their slots until the upload function returns. + private var runningTokens: Set = [] + + public convenience init( + client: any FeatureClient, + draftStore: FeatureComposerDraftStore = .shared, + maximumConcurrentUploads: Int = 3 + ) { + self.init( + maximumConcurrentUploads: maximumConcurrentUploads, + upload: { attachment, environmentID in + try await client.preuploadAttachment(attachment, environmentID: environmentID) + }, + persist: { reference, attachment, draftKey in + try await draftStore.setUploadedReference( + reference, + attachment: attachment, + for: draftKey + ) + } + ) + } + + init( + maximumConcurrentUploads: Int = 3, + upload: @escaping Upload, + persist: @escaping Persist, + waitForTimeout: @escaping WaitForTimeout = { try await Task.sleep(for: $0) } + ) { + self.maximumConcurrentUploads = max(1, maximumConcurrentUploads) + self.upload = upload + self.persist = persist + self.waitForTimeout = waitForTimeout + } + + public func syncOwner( + draftKey: String, + environmentID: String, + attachments: [FeatureDraftAttachment] + ) { + let previous = owners[draftKey] + owners[draftKey] = Owner( + environmentID: environmentID, + attachments: Dictionary(uniqueKeysWithValues: attachments.map { ($0.id, $0) }) + ) + for attachment in attachments { + enqueueIfNeeded(attachment, environmentID: environmentID) + } + if let previous { + let oldKeys = Set(previous.attachments.keys.map { + FeatureAttachmentUploadKey( + environmentID: previous.environmentID, + attachmentID: $0 + ) + }) + let newKeys = Set(attachments.map { + FeatureAttachmentUploadKey(environmentID: environmentID, attachmentID: $0.id) + }) + cancelUnowned(oldKeys.subtracting(newKeys)) + } + startQueuedJobs() + } + + public func removeOwner(draftKey: String) { + guard let owner = owners.removeValue(forKey: draftKey) else { return } + cancelUnowned(Set(owner.attachments.keys.map { + FeatureAttachmentUploadKey(environmentID: owner.environmentID, attachmentID: $0) + })) + } + + public func syncOutboxOwner( + ownerID: String, + environmentID: String, + attachmentIDs: [UUID] + ) { + let previous = outboxOwners[ownerID] ?? [] + let current = Set(attachmentIDs.map { + FeatureAttachmentUploadKey(environmentID: environmentID, attachmentID: $0) + }) + outboxOwners[ownerID] = current + cancelUnowned(previous.subtracting(current)) + } + + public func removeOutboxOwner(ownerID: String) { + guard let previous = outboxOwners.removeValue(forKey: ownerID) else { return } + cancelUnowned(previous) + } + + public func retry(environmentID: String, attachmentID: UUID) { + let key = FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachmentID + ) + guard let job = jobs[key], case .failed = job.state, isOwned(key) else { return } + queue(key: key, attachment: job.attachment) + startQueuedJobs() + } + + public func state( + environmentID: String, + attachmentID: UUID + ) -> FeatureAttachmentUploadState? { + states[FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachmentID + )] + } + + public func attachmentsForSend( + draftKey: String, + environmentID: String, + attachments: [FeatureDraftAttachment] + ) -> [FeatureDraftAttachment] { + guard let owner = owners[draftKey], owner.environmentID == environmentID else { + return attachments + } + return attachments.map { attachment in + let key = FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachment.id + ) + guard let owned = owner.attachments[attachment.id], + Self.samePayload(owned, attachment), + let job = jobs[key], Self.samePayload(job.attachment, attachment), + case let .ready(reference) = job.state, + let reference, reference.environmentID == environmentID else { + return attachment + } + var enriched = attachment + enriched.uploadedReference = reference + return enriched + } + } + + private func enqueueIfNeeded(_ attachment: FeatureDraftAttachment, environmentID: String) { + let key = FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachment.id + ) + if let job = jobs[key] { + guard !Self.samePayload(job.attachment, attachment) else { return } + job.task?.cancel() + job.deadlineTask?.cancel() + jobs[key] = nil + states[key] = nil + } + queue(key: key, attachment: attachment) + } + + private func queue(key: FeatureAttachmentUploadKey, attachment: FeatureDraftAttachment) { + jobs[key] = Job( + attachment: attachment, + token: UUID(), + state: .queued, + task: nil, + deadlineTask: nil + ) + states[key] = .queued + } + + private func startQueuedJobs() { + while runningTokens.count < maximumConcurrentUploads, + let key = jobs.first(where: { $0.value.state == .queued && isOwned($0.key) })?.key, + var job = jobs[key] { + let token = job.token + let attachment = job.attachment + job.state = .uploading + runningTokens.insert(token) + states[key] = .uploading + job.deadlineTask = Task { [weak self, waitForTimeout] in + do { + // Include connection setup and draft persistence, not just + // the HTTP transfer. Files can be as large as 50 MiB. + let timeout: Duration = attachment.mimeType.hasPrefix("image/") + ? .seconds(120) : .seconds(600) + try await waitForTimeout(timeout) + try Task.checkCancellation() + } catch { + return + } + self?.uploadTimedOut(key: key, token: token) + } + job.task = Task { [weak self, upload] in + let result: Result + do { + result = .success(try await upload( + FeatureUploadAttachment(attachment), + key.environmentID + )) + } catch { + result = .failure(error) + } + await self?.transferReturned( + key: key, + token: token, + attachment: attachment, + result: result + ) + } + jobs[key] = job + } + } + + private func transferReturned( + key: FeatureAttachmentUploadKey, + token: UUID, + attachment: FeatureDraftAttachment, + result: Result + ) async { + runningTokens.remove(token) + guard jobs[key]?.token == token, jobs[key]?.state == .uploading else { + startQueuedJobs() + return + } + switch result { + case let .failure(error): + fail(key: key, token: token, error: error) + case let .success(reference): + await persistThenPublish( + key: key, + token: token, + attachment: attachment, + reference: reference + ) + } + startQueuedJobs() + } + + private func persistThenPublish( + key: FeatureAttachmentUploadKey, + token: UUID, + attachment: FeatureDraftAttachment, + reference: FeatureUploadedAttachmentReference? + ) async { + guard currentAndOwned(key: key, token: token, attachment: attachment) else { return } + if let reference { + guard reference.environmentID == key.environmentID else { + fail(key: key, token: token, error: CoordinatorError.wrongEnvironment) + return + } + let draftKeys = matchingDraftKeys(key: key, attachment: attachment) + do { + for draftKey in draftKeys { + let didPersist = try await persist(reference, attachment, draftKey) + guard currentAndOwned(key: key, token: token, attachment: attachment), + matchingDraftKeys(key: key, attachment: attachment).contains(draftKey) + else { return } + guard didPersist else { + fail(key: key, token: token, error: CoordinatorError.persistenceRejected) + return + } + } + } catch { + fail(key: key, token: token, error: error) + return + } + } + guard var job = jobs[key], job.token == token, + currentAndOwned(key: key, token: token, attachment: attachment) else { return } + job.state = .ready(reference) + job.task = nil + job.deadlineTask?.cancel() + job.deadlineTask = nil + jobs[key] = job + states[key] = job.state + } + + private func matchingDraftKeys( + key: FeatureAttachmentUploadKey, + attachment: FeatureDraftAttachment + ) -> [String] { + owners.compactMap { draftKey, owner in + owner.environmentID == key.environmentID + && owner.attachments[attachment.id].map { + Self.samePayload($0, attachment) + } == true ? draftKey : nil + } + } + + private func fail(key: FeatureAttachmentUploadKey, token: UUID, error: any Error) { + guard var job = jobs[key], job.token == token, job.state == .uploading else { return } + job.state = .failed( + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + ) + job.task = nil + job.deadlineTask?.cancel() + job.deadlineTask = nil + jobs[key] = job + states[key] = job.state + } + + private func uploadTimedOut(key: FeatureAttachmentUploadKey, token: UUID) { + guard let job = jobs[key], job.token == token, job.state == .uploading else { return } + job.task?.cancel() + // Publish the failure even if a canceled transfer has not returned. + // Its concurrency slot still belongs to it until transferReturned. + fail(key: key, token: token, error: CoordinatorError.timedOut) + startQueuedJobs() + } + + private func cancelUnowned(_ keys: Set) { + for key in keys where !isOwned(key) { + jobs[key]?.task?.cancel() + jobs[key]?.deadlineTask?.cancel() + jobs[key] = nil + states[key] = nil + } + startQueuedJobs() + } + + private func currentAndOwned( + key: FeatureAttachmentUploadKey, + token: UUID, + attachment: FeatureDraftAttachment + ) -> Bool { + guard let job = jobs[key], job.token == token, job.state == .uploading, + Self.samePayload(job.attachment, attachment) else { return false } + return outboxOwners.values.contains(where: { $0.contains(key) }) || !matchingDraftKeys( + key: key, + attachment: attachment + ).isEmpty + } + + private func isOwned(_ key: FeatureAttachmentUploadKey) -> Bool { + outboxOwners.values.contains(where: { $0.contains(key) }) || owners.values.contains { + $0.environmentID == key.environmentID && $0.attachments[key.attachmentID] != nil + } + } + + private static func samePayload( + _ lhs: FeatureDraftAttachment, + _ rhs: FeatureDraftAttachment + ) -> Bool { + guard lhs.id == rhs.id, + lhs.filename == rhs.filename, + lhs.mimeType == rhs.mimeType, + lhs.byteCount == rhs.byteCount else { return false } + if let file = lhs.ownedFile { + return file.fileName == rhs.ownedFile?.fileName + } + return rhs.ownedFile == nil && lhs.data == rhs.data + } +} + +private enum CoordinatorError: LocalizedError { + case wrongEnvironment + case persistenceRejected + case timedOut + + var errorDescription: String? { + switch self { + case .wrongEnvironment: + "The uploaded attachment belongs to a different environment." + case .persistenceRejected: + "The draft changed before the upload could be saved. Retry the upload." + case .timedOut: + "Upload timed out. Check the connection and retry." + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift new file mode 100644 index 000000000000..7ceb3414835b --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -0,0 +1,678 @@ +import Foundation + +/// The app-owned adapter between the native feature layer and T3's WebSocket/Core runtime. +/// Implementations are main-actor isolated so UI state never depends on locking. +@MainActor +public protocol FeatureClient: AnyObject { + func initialSnapshot() async throws -> FeatureSnapshot + /// Performs one bounded refresh without starting long-lived subscriptions. + /// Background tasks use this instead of the foreground bootstrap path. + func backgroundSnapshot() async throws -> FeatureSnapshot + func events() -> AsyncStream + func resumeAfterBackground(reconnect: Bool) async + + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? + + func pair(endpoint: String, token: String?) async throws + func setEnvironmentEnabled(id: String, enabled: Bool) async throws + func removeEnvironment(id: String) async throws + func disconnect() async + + func addProject(path: String) async throws + func createThread(projectID: String, title: String?, selection: FeatureSelection?) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws -> FeatureThread + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] + func renameThread(id: String, title: String) async throws + func regenerateThreadTitle(id: String) async throws + func setThreadArchived(id: String, archived: Bool) async throws + func setThreadSettled(id: String, settled: Bool) async throws + func setThreadSnoozed(id: String, until: Date?) async throws + func setThreadPinned(id: String, pinned: Bool) async throws + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws + func deleteThread(id: String) async throws + + func loadThread(id: String) async throws -> FeatureThreadDetail + func loadThread(id: String, fresh: Bool) async throws -> FeatureThreadDetail + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? + func releaseThread(id: String) + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws + func cancelTurn(threadID: String) async throws + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws + func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws + + func saveSettings(_ settings: FeatureSettings) async throws + func serverPreferences(environmentID: String) async throws -> ServerSettingsSnapshot + func updateServerPreferences(environmentID: String, change: ServerSettingsChange) async throws + func sharedPreferenceMismatches(environmentID: String) -> [String] + func refreshProviders(environmentID: String) async throws -> [FeatureProvider] + func refreshWorkspaceProviders(environmentID: String, cwd: String, instanceID: String) async throws -> [FeatureProvider] + func providerSetup(environmentID: String, instanceID: String, action: ProviderSetupAction) async throws -> ProviderSetupEvent + func providerSetupEvents(environmentID: String, instanceID: String) -> AsyncThrowingStream + func setProviderEnabled(environmentID: String, instanceID: String, enabled: Bool) async throws + func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings + + func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] + func usageSummaries(_ input: UsageSummaryInput, refreshPricing: Bool) async throws -> [FeatureEnvironmentUsage] + func usageSummaryUpdates( + _ input: UsageSummaryInput, + refreshPricing: Bool + ) -> AsyncThrowingStream<[FeatureEnvironmentUsage], Error> + func usageLimitsUpdates() -> AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error> + func refreshUsageLimits() async throws -> [FeatureEnvironmentUsageLimits] + func consumeResetCredit( + environmentID: String, + instanceID: String + ) async throws -> ProviderConsumeResetCreditResult + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws + + func cachedProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? + func refreshProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] + func searchProjectFiles( + projectID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] + func searchThreadFiles( + threadID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] + func readFile(threadID: String, path: String) async throws -> FeatureFileContent + func loadReview(threadID: String) async throws -> FeatureReview + func loadReviewFileContents( + threadID: String, + file: FeatureReviewFile + ) async throws -> FeatureReviewFileContents? + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus + func sourceControlStatuses( + threadID: String + ) async throws -> AsyncThrowingStream + func sourceControlStatusEvents(threadID: String) -> AsyncStream + /// Completes at the mutation boundary. Callers refresh status separately so a refresh + /// failure cannot make an already-completed non-idempotent action retryable. + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws + + func terminalSnapshot(threadID: String, terminalID: String) async throws -> FeatureTerminalSnapshot + func terminalHostOS(threadID: String) -> String? + func terminalEvents(threadID: String, terminalID: String) -> AsyncStream + func terminalSessions(threadID: String) -> AsyncStream<[FeatureTerminalSnapshot]> + func openTerminal(threadID: String, terminalID: String, columns: Int, rows: Int) async throws + func writeTerminal(threadID: String, terminalID: String, data: String) async throws + func resizeTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws + func clearTerminal(threadID: String, terminalID: String) async throws + func closeTerminal(threadID: String, terminalID: String) async throws +} + +public extension FeatureClient { + func serverPreferences(environmentID: String) async throws -> ServerSettingsSnapshot { + throw FeatureCapabilityUnavailable("Server preferences") + } + func updateServerPreferences(environmentID: String, change: ServerSettingsChange) async throws { + throw FeatureCapabilityUnavailable("Server preferences") + } + func sharedPreferenceMismatches(environmentID: String) -> [String] { [] } + + func loadThread(id: String, fresh: Bool) async throws -> FeatureThreadDetail { + try await loadThread(id: id) + } + + func usageSummaries(_ input: UsageSummaryInput, refreshPricing: Bool) async throws -> [FeatureEnvironmentUsage] { + try await usageSummaries(input) + } + + func usageSummaryUpdates( + _ input: UsageSummaryInput, + refreshPricing: Bool + ) -> AsyncThrowingStream<[FeatureEnvironmentUsage], Error> { + AsyncThrowingStream { continuation in + let task = Task { + do { + let result = try await usageSummaries(input, refreshPricing: refreshPricing) + try Task.checkCancellation() + continuation.yield(result) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func usageLimitsUpdates() -> AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error> { + AsyncThrowingStream { $0.finish() } + } + + func refreshUsageLimits() async throws -> [FeatureEnvironmentUsageLimits] { [] } + + func consumeResetCredit( + environmentID: String, + instanceID: String + ) async throws -> ProviderConsumeResetCreditResult { + throw FeatureCapabilityUnavailable("Usage reset credits") + } + + func terminalHostOS(threadID: String) -> String? { nil } + + func setProviderEnabled(environmentID: String, instanceID: String, enabled: Bool) async throws { + throw FeatureCapabilityUnavailable("Provider settings") + } + + func providerSetup(environmentID: String, instanceID: String, action: ProviderSetupAction) async throws -> ProviderSetupEvent { + throw FeatureCapabilityUnavailable("Provider setup") + } + + func providerSetupEvents(environmentID: String, instanceID: String) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + + func resumeAfterBackground(reconnect: Bool) async {} + + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? { + nil + } +} + +public extension FeatureClient { + func backgroundSnapshot() async throws -> FeatureSnapshot { + try await initialSnapshot() + } + + func regenerateThreadTitle(id _: String) async throws { + throw FeatureCapabilityUnavailable("Thread title regeneration") + } + + func loadEarlierThreadTurns(id _: String) async throws -> FeatureThreadDetail? { + nil + } +} + +public extension FeatureClient { + func events() -> AsyncStream { + AsyncStream { continuation in continuation.finish() } + } + + func setEnvironmentEnabled(id: String, enabled: Bool) async throws {} + func removeEnvironment(id: String) async throws {} + func disconnect() async {} + func refreshWorkspaceProviders(environmentID: String, cwd: String, instanceID: String) async throws -> [FeatureProvider] { + throw FeatureCapabilityUnavailable("Workspace provider catalog") + } + + func refreshProviders(environmentID _: String) async throws -> [FeatureProvider] { + throw FeatureCapabilityUnavailable("Provider refresh") + } + func updateAutomaticSettlement( + environmentID _: String, + change _: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings { + throw FeatureCapabilityUnavailable("Automatic settlement settings") + } + func addProject(path: String) async throws {} + func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { + [] + } + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + { + [] + } + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] { + throw FeatureCapabilityUnavailable("Environment-specific pull request pagination") + } + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail { + throw FeatureCapabilityUnavailable("Pull requests") + } + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + { + throw FeatureCapabilityUnavailable("Pull request activity") + } + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + { + throw FeatureCapabilityUnavailable("Pull request diffs") + } + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws { throw FeatureCapabilityUnavailable("Pull request actions") } + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws { throw FeatureCapabilityUnavailable("Pull request editing") } + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws { + throw FeatureCapabilityUnavailable("Pull request comments") + } + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { throw FeatureCapabilityUnavailable("Pull request reviews") } + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws { throw FeatureCapabilityUnavailable("Pull request replies") } + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws { throw FeatureCapabilityUnavailable("Pull request review threads") } + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { throw FeatureCapabilityUnavailable("Pull request reactions") } + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + { + throw FeatureCapabilityUnavailable("Pull request reviewers") + } + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { throw FeatureCapabilityUnavailable("Pull request reviewers") } + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws {} + func cachedProjectFavicon(environmentID: String, workspaceRoot: String) async -> Data? { + nil + } + func refreshProjectFavicon(environmentID: String, workspaceRoot: String) async -> Data? { + nil + } + func releaseThread(id: String) {} + func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws {} + + /// Keeps simple text-only callers source-compatible while the typed API + /// preserves multi-select answers as arrays. + func resolveUserInput(id: String, answers: [String: String]) async throws { + try await resolveUserInput( + id: id, + answers: answers.mapValues(FeatureInputAnswer.text) + ) + } + func setThreadSettled(id: String, settled: Bool) async throws {} + func setThreadSnoozed(id: String, until: Date?) async throws {} + func setThreadPinned(id: String, pinned: Bool) async throws {} + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws {} + func loadReviewFileContents( + threadID: String, + file: FeatureReviewFile + ) async throws -> FeatureReviewFileContents? { + nil + } + + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] { + [] + } + + /// Legacy clients still create in the current checkout. Native clients + /// override this overload to prepare worktrees atomically with the first turn. + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await createThreadAndSend( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + attachments: attachments + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + let thread = try await createThread( + projectID: projectID, + title: prompt, + selection: selection + ) + try await sendMessage( + threadID: thread.id, + text: prompt, + selection: selection, + attachments: attachments + ) + return thread + } + + /// Clients that understand stable command identities override this method. + /// The compatibility path remains functional but cannot guarantee + /// idempotence across a process death after an ambiguous network failure. + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws -> FeatureThread { + try await createThreadAndSend( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws { + guard attachments.isEmpty else { + throw FeatureCapabilityUnavailable("Image attachments") + } + try await sendMessage(threadID: threadID, text: text, selection: selection) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: attachments + ) + } + + /// Durable submissions carry the modes that were active when the user + /// sent them. Older clients can ignore them, while native retries preserve + /// the original permission instead of reading a later thread value. + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: attachments, + identity: identity + ) + } + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("Files") + } + + func searchProjectFiles( + projectID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("File search") + } + + func searchThreadFiles( + threadID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("File search") + } + + func readFile(threadID: String, path: String) async throws -> FeatureFileContent { + throw FeatureCapabilityUnavailable("File preview") + } + + func loadReview(threadID: String) async throws -> FeatureReview { + throw FeatureCapabilityUnavailable("Review") + } + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus { + throw FeatureCapabilityUnavailable("Source control") + } + + func sourceControlStatuses( + threadID: String + ) async throws -> AsyncThrowingStream { + let status = try await sourceControlStatus(threadID: threadID) + let (stream, continuation) = AsyncThrowingStream.makeStream( + of: FeatureSourceControlStatus.self + ) + continuation.yield(status) + continuation.finish() + return stream + } + + func sourceControlStatusEvents(threadID: String) -> AsyncStream { + AsyncStream { $0.finish() } + } + + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws { + throw FeatureCapabilityUnavailable("Source control actions") + } + + func terminalSnapshot(threadID: String, terminalID _: String) async throws -> FeatureTerminalSnapshot { + throw FeatureCapabilityUnavailable("Terminal") + } + + func terminalEvents(threadID: String, terminalID _: String) -> AsyncStream { + AsyncStream { $0.finish() } + } + + func terminalSessions(threadID _: String) -> AsyncStream<[FeatureTerminalSnapshot]> { + AsyncStream { $0.finish() } + } + + func openTerminal( + threadID: String, + terminalID _: String, + columns: Int, + rows: Int + ) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func writeTerminal(threadID: String, terminalID _: String, data: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func resizeTerminal( + threadID: String, + terminalID _: String, + columns: Int, + rows: Int + ) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func clearTerminal(threadID: String, terminalID _: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func closeTerminal(threadID: String, terminalID _: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift new file mode 100644 index 000000000000..96b211ed4b16 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift @@ -0,0 +1,387 @@ +import Foundation + +public struct FeatureComposerDraft: Sendable, Equatable { + public var text: String + public var attachments: [FeatureDraftAttachment] + public var selection: FeatureSelection? + public var workspace: FeatureComposerWorkspaceDraft? + + public init( + text: String = "", + attachments: [FeatureDraftAttachment] = [], + selection: FeatureSelection? = nil, + workspace: FeatureComposerWorkspaceDraft? = nil + ) { + self.text = text + self.attachments = attachments + self.selection = selection + self.workspace = workspace + } + + public var isEmpty: Bool { + text.isEmpty && attachments.isEmpty && selection == nil && workspace == nil + } +} + +public struct FeatureComposerWorkspaceDraft: Sendable, Equatable { + public var mode: FeatureWorkspaceMode + public var branch: String? + public var worktreePath: String? + public var startFromOrigin: Bool + + public init( + mode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool + ) { + self.mode = mode + self.branch = branch + self.worktreePath = worktreePath + self.startFromOrigin = startFromOrigin + } +} + +public enum FeatureComposerDraftImportError: LocalizedError, Equatable, Sendable { + case attachmentLimitExceeded(available: Int) + + public var errorDescription: String? { + switch self { + case let .attachmentLimitExceeded(available): + available == 0 + ? "This draft already has eight attachments. Remove one before importing the share." + : "This share needs more attachment slots. The current draft has room for \(available)." + } + } +} + +/// Persists composer state independently of view navigation. Draft writes are +/// atomic, and callers debounce high-frequency text changes before reaching +/// this actor so image data is not repeatedly encoded for every keystroke. +public actor FeatureComposerDraftStore { + public static let shared = FeatureComposerDraftStore() + private static let documentVersion = 2 + + private struct Document: Codable { + let version: Int + var drafts: [String: PersistedDraft] + } + + private struct PersistedDraft: Codable { + var text: String + var attachments: [PersistedAttachment] + var selection: FeatureSelection? + var workspace: PersistedWorkspace? + var importedShareIDs: [String]? + + init(_ draft: FeatureComposerDraft) { + text = draft.text + attachments = draft.attachments.map(PersistedAttachment.init) + selection = draft.selection + workspace = draft.workspace.map(PersistedWorkspace.init) + importedShareIDs = nil + } + + func featureValue(fileStore: ManagedAttachmentFileStore) -> FeatureComposerDraft { + FeatureComposerDraft( + text: text, + attachments: attachments.compactMap { $0.featureValue(fileStore: fileStore) }, + selection: selection, + workspace: workspace?.featureValue + ) + } + } + + private struct PersistedWorkspace: Codable { + var mode: FeatureWorkspaceMode + var branch: String? + var worktreePath: String? + var startFromOrigin: Bool + + init(_ workspace: FeatureComposerWorkspaceDraft) { + mode = workspace.mode + branch = workspace.branch + worktreePath = workspace.worktreePath + startFromOrigin = workspace.startFromOrigin + } + + var featureValue: FeatureComposerWorkspaceDraft { + FeatureComposerWorkspaceDraft( + mode: mode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin + ) + } + } + + private struct PersistedAttachment: Codable { + var id: UUID + var data: Data? + var ownedFileName: String? + var byteCount: Int? + var thumbnailData: Data? + var filename: String + var mimeType: String + var uploadedReference: FeatureUploadedAttachmentReference? + + init(_ attachment: FeatureDraftAttachment) { + id = attachment.id + data = attachment.ownedFile == nil ? attachment.data : nil + ownedFileName = attachment.ownedFile?.fileName + byteCount = attachment.byteCount + thumbnailData = attachment.thumbnailData + filename = attachment.filename + mimeType = attachment.mimeType + uploadedReference = attachment.uploadedReference + } + + func featureValue(fileStore: ManagedAttachmentFileStore) -> FeatureDraftAttachment? { + if let ownedFileName, + let ownedFile = try? fileStore.resolvedFile( + fileName: ownedFileName, + byteCount: byteCount ?? 0 + ) { + return FeatureDraftAttachment( + id: id, + ownedFile: ownedFile, + thumbnailData: thumbnailData, + filename: filename, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } + guard let data else { return nil } + return FeatureDraftAttachment( + id: id, + data: data, + thumbnailData: thumbnailData, + filename: filename, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } + + func hasSameContent(as attachment: FeatureDraftAttachment) -> Bool { + guard id == attachment.id, + filename == attachment.filename, + mimeType == attachment.mimeType, + (byteCount ?? data?.count ?? 0) == attachment.byteCount else { return false } + if let ownedFileName { + return ownedFileName == attachment.ownedFile?.fileName + } + return attachment.ownedFile == nil && data == attachment.data + } + } + + public let fileURL: URL + public let attachmentFileStore: ManagedAttachmentFileStore + private var loadedDrafts: [String: PersistedDraft]? + + public init(fileURL: URL? = nil, attachmentStorageRootURL: URL? = nil) { + attachmentFileStore = ManagedAttachmentFileStore(rootURL: attachmentStorageRootURL) + if let fileURL { + self.fileURL = fileURL + } else { + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.fileURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("composer-drafts.json", isDirectory: false) + } + } + + public func draft(for key: String) throws -> FeatureComposerDraft? { + guard let draft = try loadIfNeeded()[key]?.featureValue(fileStore: attachmentFileStore), + !draft.isEmpty else { return nil } + return draft + } + + public func setDraft(_ draft: FeatureComposerDraft, for key: String) throws { + var drafts = try loadIfNeeded() + let existingReferences = Dictionary( + uniqueKeysWithValues: (drafts[key]?.attachments ?? []).compactMap { attachment in + attachment.uploadedReference.map { (attachment.id, (attachment, $0)) } + } + ) + var mergedDraft = draft + for index in mergedDraft.attachments.indices + where mergedDraft.attachments[index].uploadedReference == nil { + let incoming = mergedDraft.attachments[index] + if let (persisted, reference) = existingReferences[incoming.id], + persisted.hasSameContent(as: incoming) { + mergedDraft.attachments[index].uploadedReference = reference + } + } + if mergedDraft.isEmpty { + if let importedShareIDs = drafts[key]?.importedShareIDs, + !importedShareIDs.isEmpty { + var persisted = PersistedDraft(mergedDraft) + persisted.importedShareIDs = importedShareIDs + drafts[key] = persisted + } else { + drafts.removeValue(forKey: key) + } + } else { + var persisted = PersistedDraft(mergedDraft) + // Preserve the crash-replay ledger while the composer performs its + // ordinary debounced saves after opening an imported share. + persisted.importedShareIDs = drafts[key]?.importedShareIDs + drafts[key] = persisted + } + try persist(drafts) + loadedDrafts = drafts + } + + /// Saves an upload result only if the attachment still exists with the + /// same immutable content. Text, selection, and workspace stay unchanged. + @discardableResult + public func setUploadedReference( + _ reference: FeatureUploadedAttachmentReference, + attachment: FeatureDraftAttachment, + for key: String + ) throws -> Bool { + var drafts = try loadIfNeeded() + guard var draft = drafts[key], + let index = draft.attachments.firstIndex(where: { $0.id == attachment.id }), + draft.attachments[index].hasSameContent(as: attachment) else { return false } + draft.attachments[index].uploadedReference = reference + drafts[key] = draft + try persist(drafts) + loadedDrafts = drafts + return true + } + + /// Atomically imports one share-extension envelope into the latest stored + /// draft. The share ID is committed with the content, so a host crash after + /// this write but before inbox cleanup cannot duplicate the import. + @discardableResult + public func importSharedContent( + shareID: String, + text: String, + attachments: [FeatureDraftAttachment], + for key: String, + maximumAttachmentCount: Int = 8 + ) throws -> FeatureComposerDraft { + var drafts = try loadIfNeeded() + var persisted = drafts[key] ?? PersistedDraft(FeatureComposerDraft()) + var importedIDs = persisted.importedShareIDs ?? [] + guard !importedIDs.contains(shareID) else { + return persisted.featureValue(fileStore: attachmentFileStore) + } + + let existingIDs = Set(persisted.attachments.map(\.id)) + let uniqueAttachments = attachments.filter { !existingIDs.contains($0.id) } + let availableCount = max(0, maximumAttachmentCount - persisted.attachments.count) + guard uniqueAttachments.count <= availableCount else { + throw FeatureComposerDraftImportError.attachmentLimitExceeded( + available: availableCount + ) + } + + let incomingText = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !incomingText.isEmpty { + persisted.text = persisted.text.trimmingCharacters(in: .whitespacesAndNewlines) + persisted.text = persisted.text.isEmpty + ? incomingText + : "\(persisted.text)\n\n\(incomingText)" + } + + persisted.attachments.append(contentsOf: uniqueAttachments.map(PersistedAttachment.init)) + importedIDs.append(shareID) + persisted.importedShareIDs = Array(importedIDs.suffix(32)) + drafts[key] = persisted + try persist(drafts) + loadedDrafts = drafts + return persisted.featureValue(fileStore: attachmentFileStore) + } + + public func removeDraft(for key: String) throws { + var drafts = try loadIfNeeded() + guard drafts.removeValue(forKey: key) != nil else { return } + try persist(drafts) + loadedDrafts = drafts + } + + public func removeDrafts( + environmentID: String, + logicalProjectIDs: Set = [] + ) throws { + var drafts = try loadIfNeeded() + let environmentPrefix = "environment:\(environmentID):" + let logicalKeys = Set(logicalProjectIDs.map(Self.newTaskKey(logicalProjectID:))) + drafts = drafts.filter { + !$0.key.hasPrefix(environmentPrefix) && !logicalKeys.contains($0.key) + } + try persist(drafts) + loadedDrafts = drafts + } + + public static func threadKey(_ thread: FeatureThread) -> String { + let environment = thread.environmentID ?? "active" + let threadID = thread.wireID ?? thread.id + return "environment:\(environment):thread:\(threadID)" + } + + public static func newTaskKey(project: FeatureProject) -> String { + let projectID = project.wireID ?? project.id + return "environment:\(project.environmentID):new-task:\(projectID)" + } + + static func newTaskKey(project: FeatureProject, in snapshot: FeatureSnapshot) -> String { + guard project.repositoryIdentity != nil else { + return newTaskKey(project: project) + } + return newTaskKey( + logicalProjectID: DailyUXCreationContext.logicalProjectID( + for: project, + in: snapshot + ) + ) + } + + public static func newTaskKey(logicalProjectID: String) -> String { + "logical-project:\(logicalProjectID):new-task" + } + + private func loadIfNeeded() throws -> [String: PersistedDraft] { + if let loadedDrafts { return loadedDrafts } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + let drafts: [String: PersistedDraft] = [:] + loadedDrafts = drafts + return drafts + } + let data = try Data(contentsOf: fileURL) + let document = try JSONDecoder.t3.decode(Document.self, from: data) + guard document.version == 1 || document.version == Self.documentVersion else { + throw CocoaError(.fileReadCorruptFile) + } + var drafts = document.drafts + if document.version == 1 { + // Version 1 wrote resolved project/environment defaults into every + // new-task draft. They were not necessarily user choices, so drop + // only those derived fields while preserving text and attachments. + for key in Array(drafts.keys) where key.contains(":new-task:") { + drafts[key]?.selection = nil + drafts[key]?.workspace = nil + } + drafts = drafts.filter { + !$0.value.featureValue(fileStore: attachmentFileStore).isEmpty + } + try persist(drafts) + } + loadedDrafts = drafts + return drafts + } + + private func persist(_ drafts: [String: PersistedDraft]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let document = Document(version: Self.documentVersion, drafts: drafts) + try JSONEncoder.t3.encode(document).write(to: fileURL, options: .atomic) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift new file mode 100644 index 000000000000..2778da0901db --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -0,0 +1,1286 @@ +import Foundation + +public struct FeatureConnection: Sendable, Equatable, Codable { + public enum State: String, Sendable, Hashable, Codable { + case disconnected + case connecting + case connected + case reconnecting + } + + public var state: State + public var environmentName: String? + public var endpoint: String? + public var detail: String? + + public init( + state: State = .disconnected, + environmentName: String? = nil, + endpoint: String? = nil, + detail: String? = nil + ) { + self.state = state + self.environmentName = environmentName + self.endpoint = endpoint + self.detail = detail + } +} + +public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, Codable { + public enum Source: String, Sendable, Equatable, Hashable, Codable { + case direct + case t3Connect + } + + public let id: String + public var name: String + public var endpoint: String + /// Internal stream-leader compatibility. Product routing must use the + /// project or thread environment instead. + public var isActive: Bool + public var isEnabled: Bool + public var source: Source + /// Reachability from the latest aggregate refresh. `nil` means the client + /// has not probed this saved environment yet. + public var connectionState: FeatureConnection.State? + public var connectionDetail: String? + public var machineIcon: String? = nil + public var canCustomizeIcon: Bool? = nil + + public var systemImage: String { + switch machineIcon { + case "cloud": "cloud" + case "desktop": "desktopcomputer" + case "laptop": "laptopcomputer" + case "mac-mini": "macmini" + case "mac-studio": "macstudio" + default: "server.rack" + } + } + + public init( + id: String, + name: String, + endpoint: String, + isActive: Bool = false, + isEnabled: Bool = true, + source: Source = .direct, + connectionState: FeatureConnection.State? = nil, + connectionDetail: String? = nil + ) { + self.id = id + self.name = name + self.endpoint = endpoint + self.isActive = isActive + self.isEnabled = isEnabled + self.source = source + self.connectionState = connectionState + self.connectionDetail = connectionDetail + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case endpoint + case isActive + case isEnabled + case source + case connectionState + case connectionDetail + case machineIcon + case canCustomizeIcon + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + endpoint = try container.decode(String.self, forKey: .endpoint) + isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? false + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + source = try container.decodeIfPresent(Source.self, forKey: .source) ?? .direct + connectionState = try container.decodeIfPresent( + FeatureConnection.State.self, + forKey: .connectionState + ) + connectionDetail = try container.decodeIfPresent(String.self, forKey: .connectionDetail) + machineIcon = try container.decodeIfPresent(String.self, forKey: .machineIcon) + canCustomizeIcon = try container.decodeIfPresent(Bool.self, forKey: .canCustomizeIcon) + } +} + +public struct FeatureRepositoryIdentity: Sendable, Equatable, Hashable, Codable { + public var canonicalKey: String + public var rootPath: String? + public var displayName: String? + public var name: String? + + public init( + canonicalKey: String, + rootPath: String? = nil, + displayName: String? = nil, + name: String? = nil + ) { + self.canonicalKey = canonicalKey + self.rootPath = rootPath + self.displayName = displayName + self.name = name + } +} + +public struct FeatureProject: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The environment-local identifier sent over the wire. Native aggregate + /// snapshots scope `id` by environment so cloned databases remain distinct. + public var wireID: String? + public var environmentID: String + public var name: String + public var path: String + public var threadCount: Int + public var defaultSelection: FeatureSelection? + public var repositoryIdentity: FeatureRepositoryIdentity? + public var createdAt: String? + public var updatedAt: String? + public var projectIcon: ProjectIconOverride? = nil + + public init( + id: String, + wireID: String? = nil, + environmentID: String, + name: String, + path: String, + threadCount: Int = 0, + defaultSelection: FeatureSelection? = nil, + repositoryIdentity: FeatureRepositoryIdentity? = nil, + createdAt: String? = nil, + updatedAt: String? = nil + ) { + self.id = id + self.wireID = wireID + self.environmentID = environmentID + self.name = name + self.path = path + self.threadCount = threadCount + self.defaultSelection = defaultSelection + self.repositoryIdentity = repositoryIdentity + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public enum FeatureThreadState: String, Sendable, Codable { + case idle + case queued + case working + case monitoring + case waitingForApproval + case waitingForInput + case failed + case completed +} + +public enum FeatureRuntimeMode: String, CaseIterable, Sendable, Codable { + case approvalRequired + case autoAcceptEdits + case automatic + case fullAccess + + /// Mobile offers the two current modes. Legacy modes remain distinct so + /// existing threads keep their exact server permission. + public static let allCases: [FeatureRuntimeMode] = [.automatic, .fullAccess] +} + +public enum FeatureInteractionMode: String, CaseIterable, Sendable, Codable { + case standard + case plan + + /// Plan remains decodable for existing server state, but is no longer a + /// mobile prompt choice. + public static let allCases: [FeatureInteractionMode] = [.standard] + + public var mobileNormalized: FeatureInteractionMode { .standard } +} + +public enum FeatureThreadSettlementOverride: String, Sendable, Equatable, Hashable, Codable { + case settled + case active +} + +public struct FeatureThreadSettlementFacts: Sendable, Equatable, Hashable, Codable { + public struct LatestTurn: Sendable, Equatable, Hashable, Codable { + public var requestedAt: Date? + public var startedAt: Date? + public var completedAt: Date? + public var requestedAtIsInvalid: Bool + public var startedAtIsInvalid: Bool + public var completedAtIsInvalid: Bool + + public init( + requestedAt: Date? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + requestedAtIsInvalid: Bool = false, + startedAtIsInvalid: Bool = false, + completedAtIsInvalid: Bool = false + ) { + self.requestedAt = requestedAt + self.startedAt = startedAt + self.completedAt = completedAt + self.requestedAtIsInvalid = requestedAtIsInvalid + self.startedAtIsInvalid = startedAtIsInvalid + self.completedAtIsInvalid = completedAtIsInvalid + } + } + + public var settlementOverride: FeatureThreadSettlementOverride? + public var sessionStatus: String? + public var hasPendingApprovals: Bool + public var hasPendingUserInput: Bool + public var latestUserMessageAt: Date? + public var latestTurn: LatestTurn? + + public init( + settlementOverride: FeatureThreadSettlementOverride? = nil, + sessionStatus: String? = nil, + hasPendingApprovals: Bool = false, + hasPendingUserInput: Bool = false, + latestUserMessageAt: Date? = nil, + latestTurn: LatestTurn? = nil + ) { + self.settlementOverride = settlementOverride + self.sessionStatus = sessionStatus + self.hasPendingApprovals = hasPendingApprovals + self.hasPendingUserInput = hasPendingUserInput + self.latestUserMessageAt = latestUserMessageAt + self.latestTurn = latestTurn + } +} + +public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The environment-local identifier sent over the wire. + public var wireID: String? + public var projectID: String + public var environmentID: String? + public var environmentName: String? + public var title: String + public var preview: String? + public var branch: String? + public var worktreePath: String? + public var linkedPullRequest: ThreadLinkedPullRequest? + public var branchPullRequest: ThreadLinkedPullRequest? + public var createdAt: Date + public var updatedAt: Date + public var state: FeatureThreadState + public var providerID: String? + public var providerName: String? + public var modelID: String? + public var modelOptions: [FeatureModelOptionSelection] + public var isArchived: Bool + public var isSettled: Bool + public var keepsActive: Bool + public var settledAt: Date? + public var unsettledAt: Date? + public var activeOrderKey: String? + public var lastActivityAt: Date? + public var snoozedUntil: Date? + public var snoozedAt: Date? + public var pinnedAt: Date? + public var supportsSettlement: Bool? + public var supportsSnooze: Bool? + public var supportsPinning: Bool? + public var supportsTitleRegeneration: Bool? + public var supportsPullRequestLinking: Bool? + public var attentionAt: Date? + public var workingStartedAt: Date? + public var latestTurnCompletedAt: Date? + public var settlementFacts: FeatureThreadSettlementFacts? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + + public init( + id: String, + wireID: String? = nil, + projectID: String, + environmentID: String? = nil, + environmentName: String? = nil, + title: String, + preview: String? = nil, + branch: String? = nil, + worktreePath: String? = nil, + linkedPullRequest: ThreadLinkedPullRequest? = nil, + branchPullRequest: ThreadLinkedPullRequest? = nil, + createdAt: Date = .now, + updatedAt: Date = .now, + state: FeatureThreadState = .idle, + providerID: String? = nil, + providerName: String? = nil, + modelID: String? = nil, + modelOptions: [FeatureModelOptionSelection] = [], + isArchived: Bool = false, + isSettled: Bool = false, + keepsActive: Bool = false, + settledAt: Date? = nil, + unsettledAt: Date? = nil, + activeOrderKey: String? = nil, + lastActivityAt: Date? = nil, + snoozedUntil: Date? = nil, + snoozedAt: Date? = nil, + pinnedAt: Date? = nil, + supportsSettlement: Bool? = nil, + supportsSnooze: Bool? = nil, + supportsPinning: Bool? = nil, + supportsTitleRegeneration: Bool? = nil, + supportsPullRequestLinking: Bool? = nil, + attentionAt: Date? = nil, + workingStartedAt: Date? = nil, + latestTurnCompletedAt: Date? = nil, + settlementFacts: FeatureThreadSettlementFacts? = nil, + runtimeMode: FeatureRuntimeMode = .fullAccess, + interactionMode: FeatureInteractionMode = .standard + ) { + self.id = id + self.wireID = wireID + self.projectID = projectID + self.environmentID = environmentID + self.environmentName = environmentName + self.title = title + self.preview = preview + self.branch = branch + self.worktreePath = worktreePath + self.linkedPullRequest = linkedPullRequest + self.branchPullRequest = branchPullRequest + self.createdAt = createdAt + self.updatedAt = updatedAt + self.state = state + self.providerID = providerID + self.providerName = providerName + self.modelID = modelID + self.modelOptions = modelOptions + self.isArchived = isArchived + self.isSettled = isSettled + self.keepsActive = keepsActive + self.settledAt = settledAt + self.unsettledAt = unsettledAt + self.activeOrderKey = activeOrderKey + self.lastActivityAt = lastActivityAt + self.snoozedUntil = snoozedUntil + self.snoozedAt = snoozedAt + self.pinnedAt = pinnedAt + self.supportsSettlement = supportsSettlement + self.supportsSnooze = supportsSnooze + self.supportsPinning = supportsPinning + self.supportsTitleRegeneration = supportsTitleRegeneration + self.supportsPullRequestLinking = supportsPullRequestLinking + self.attentionAt = attentionAt + self.workingStartedAt = workingStartedAt + self.latestTurnCompletedAt = latestTurnCompletedAt + self.settlementFacts = settlementFacts + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + } + + public var effectivePullRequest: ThreadLinkedPullRequest? { + linkedPullRequest ?? branchPullRequest + } + + /// Missing capabilities mean unsupported. Existing states remain reversible + /// so older cached snapshots cannot trap a thread in its current state. + public var canTogglePin: Bool { + pinnedAt != nil || supportsPinning == true + } + + public var canToggleSettlement: Bool { + isSettled || supportsSettlement == true + } + + public var canToggleSnooze: Bool { + snoozedUntil != nil || supportsSnooze == true + } + +} + +public enum FeatureMessageRole: String, Sendable, Codable { + case user + case assistant + case system + case tool +} + +public enum FeatureMessageState: String, Sendable, Codable { + case queued + case streaming + case complete + case failed +} + +public struct FeatureMessageAttachment: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var mimeType: String + public var sizeBytes: Int + public var url: URL? + /// Small local preview retained only while an optimistic message is replaced + /// by its server-backed attachment URL. + public var previewData: Data? + + public init( + id: String, + name: String, + mimeType: String, + sizeBytes: Int, + url: URL? = nil, + previewData: Data? = nil + ) { + self.id = id + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.url = url + self.previewData = previewData + } +} + +public struct FeatureUploadAttachment: Sendable, Equatable { + public let id: UUID + private var inlineData: Data? + public var ownedFile: FeatureOwnedAttachmentFile? + public var name: String + public var mimeType: String + public var uploadedReference: FeatureUploadedAttachmentReference? + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = data + ownedFile = nil + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + public init( + id: UUID = UUID(), + ownedFile: FeatureOwnedAttachmentFile, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = nil + self.ownedFile = ownedFile + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + public init(_ draft: FeatureDraftAttachment) { + id = draft.id + inlineData = draft.ownedFile == nil ? draft.data : nil + ownedFile = draft.ownedFile + name = draft.filename + mimeType = draft.mimeType + uploadedReference = draft.uploadedReference + } + + public var data: Data { + get { inlineData ?? Data() } + set { + inlineData = newValue + ownedFile = nil + } + } + + public var byteCount: Int { + inlineData?.count ?? ownedFile?.byteCount ?? 0 + } +} + +public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var role: FeatureMessageRole + public var text: String + public var createdAt: Date + public var state: FeatureMessageState + public var toolName: String? + public var attachments: [FeatureMessageAttachment] + public var workLogImagePaths: [String]? + public var activeWorkLabel: String? + public var toolPresentation: ToolActivityPresentation? = nil + + public init( + id: String, + role: FeatureMessageRole, + text: String, + createdAt: Date = .now, + state: FeatureMessageState = .complete, + toolName: String? = nil, + attachments: [FeatureMessageAttachment] = [], + workLogImagePaths: [String]? = nil, + activeWorkLabel: String? = nil + ) { + self.id = id + self.role = role + self.text = text + self.createdAt = createdAt + self.state = state + self.toolName = toolName + self.attachments = attachments + self.workLogImagePaths = workLogImagePaths + self.activeWorkLabel = activeWorkLabel + } +} + +public enum FeatureApprovalKind: String, Sendable, Codable { + case command + case fileRead + case fileChange + case mcpElicitation + case patch + case other +} + +public struct FeatureApprovalOption: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: FeatureApprovalDecision { decision } + public let decision: FeatureApprovalDecision + public let label: String + + public init(decision: FeatureApprovalDecision, label: String) { + self.decision = decision + self.label = label + } +} + +public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The provider request identifier sent over the wire. + public var wireID: String? + public var threadID: String + public var kind: FeatureApprovalKind + public var title: String + public var detail: String + public var appName: String? + public var options: [FeatureApprovalOption]? + + public init( + id: String, + wireID: String? = nil, + threadID: String, + kind: FeatureApprovalKind, + title: String, + detail: String, + appName: String? = nil, + options: [FeatureApprovalOption]? = nil + ) { + self.id = id + self.wireID = wireID + self.threadID = threadID + self.kind = kind + self.title = title + self.detail = detail + self.appName = appName + self.options = options + } +} + +public struct FeatureInputOption: Sendable, Equatable, Hashable, Codable { + public var label: String + public var detail: String + + public init(label: String, detail: String) { + self.label = label + self.detail = detail + } +} + +/// A provider answer is either free-form/single-select text or the selected +/// labels for a multi-select question. Its Codable shape intentionally matches +/// the provider wire contract: a JSON string or an array of JSON strings. +public enum FeatureInputAnswer: Sendable, Equatable, Hashable, Codable { + case text(String) + case selections([String]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .text(value) + } else { + self = try .selections(container.decode([String].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .text(value): + try container.encode(value) + case let .selections(values): + try container.encode(values) + } + } +} + +extension FeatureInputAnswer { + var normalized: FeatureInputAnswer? { + switch self { + case let .text(value): + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : .text(normalized) + case let .selections(values): + var seen: Set = [] + let normalized = values.compactMap { value -> String? in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } + return trimmed + } + return normalized.isEmpty ? nil : .selections(normalized) + } + } + + func togglingOption(_ label: String, allowsMultiple: Bool) -> FeatureInputAnswer { + guard allowsMultiple else { return .text(label) } + + let current: [String] + if case let .selections(values) = self { + current = values + } else { + current = [] + } + + if current.contains(label) { + return .selections(current.filter { $0 != label }) + } + return .selections(current + [label]) + } +} + +public struct FeatureInputQuestion: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var header: String + public var question: String + public var options: [FeatureInputOption] + public var allowsMultiple: Bool + + public init( + id: String, + header: String, + question: String, + options: [FeatureInputOption] = [], + allowsMultiple: Bool = false + ) { + self.id = id + self.header = header + self.question = question + self.options = options + self.allowsMultiple = allowsMultiple + } +} + +public struct FeatureUserInput: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The provider request identifier sent over the wire. + public var wireID: String? + public var threadID: String + public var questions: [FeatureInputQuestion] + + public init( + id: String, + wireID: String? = nil, + threadID: String, + questions: [FeatureInputQuestion] + ) { + self.id = id + self.wireID = wireID + self.threadID = threadID + self.questions = questions + } +} + +/// Stable UI identity for entities merged from independent environments. +/// Length-prefixing avoids separator collisions without requiring IDs to be parsed. +enum FeatureScopedID { + static func project(environmentID: String, wireID: String) -> String { + make(kind: "project", environmentID: environmentID, wireID: wireID) + } + + static func thread(environmentID: String, wireID: String) -> String { + make(kind: "thread", environmentID: environmentID, wireID: wireID) + } + + static func approval(environmentID: String, wireID: String) -> String { + make(kind: "approval", environmentID: environmentID, wireID: wireID) + } + + static func input(environmentID: String, wireID: String) -> String { + make(kind: "input", environmentID: environmentID, wireID: wireID) + } + + private static func make(kind: String, environmentID: String, wireID: String) -> String { + "\(kind):\(environmentID.utf8.count):\(environmentID)\(wireID)" + } +} + +public struct FeatureThreadDetail: Sendable, Equatable, Codable { + public var thread: FeatureThread + public var messages: [FeatureMessage] + public var approvals: [FeatureApproval] + public var userInputs: [FeatureUserInput] + public var page: FeatureThreadPage? + public var activeSubagentCount: Int + public var backgroundWorkIsActive: Bool + public var isCompacting: Bool? + + public init( + thread: FeatureThread, + messages: [FeatureMessage] = [], + approvals: [FeatureApproval] = [], + userInputs: [FeatureUserInput] = [], + page: FeatureThreadPage? = nil, + activeSubagentCount: Int = 0, + backgroundWorkIsActive: Bool = false, + isCompacting: Bool = false + ) { + self.thread = thread + self.messages = messages + self.approvals = approvals + self.userInputs = userInputs + self.page = page + self.activeSubagentCount = activeSubagentCount + self.backgroundWorkIsActive = backgroundWorkIsActive + self.isCompacting = isCompacting + } +} + +public struct FeatureThreadPage: Sendable, Equatable, Codable { + public var beforeCursor: String? + public var hasMore: Bool + public var isLoading: Bool + + public init(beforeCursor: String?, hasMore: Bool, isLoading: Bool = false) { + self.beforeCursor = beforeCursor + self.hasMore = hasMore + self.isLoading = isLoading + } +} + +/// The small rendered-message delta produced by the native thread stream. +/// Keeping this beside the authoritative detail lets recycled transcript rows +/// update in proportion to an event instead of rescanning the full history. +public struct FeatureDetailDelta: Sendable, Equatable { + public var changedMessages: [FeatureMessage] + public var appendedMessageIDs: [String] + + public init( + changedMessages: [FeatureMessage], + appendedMessageIDs: [String] = [] + ) { + self.changedMessages = changedMessages + self.appendedMessageIDs = appendedMessageIDs + } +} + +public struct FeatureModel: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var detail: String? + public var supportsImages: Bool + public var imageSupportIsUnknown: Bool? = nil + public var supportsReasoning: Bool + public var isDefault: Bool + public var isLegacy: Bool? + public var options: [FeatureModelOptionDescriptor] + + public init( + id: String, + name: String, + detail: String? = nil, + supportsImages: Bool = false, + supportsReasoning: Bool = false, + isDefault: Bool = false, + isLegacy: Bool? = nil, + options: [FeatureModelOptionDescriptor] = [] + ) { + self.id = id + self.name = name + self.detail = detail + self.supportsImages = supportsImages + self.supportsReasoning = supportsReasoning + self.isDefault = isDefault + self.isLegacy = isLegacy + self.options = options + } +} + +public enum FeatureModelOptionKind: String, Sendable, Equatable, Hashable, Codable { + case select + case boolean +} + +public struct FeatureModelOptionChoice: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var label: String + public var detail: String? + public var isDefault: Bool + + public init( + id: String, + label: String, + detail: String? = nil, + isDefault: Bool = false + ) { + self.id = id + self.label = label + self.detail = detail + self.isDefault = isDefault + } +} + +public struct FeatureModelOptionDescriptor: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var label: String + public var detail: String? + public var kind: FeatureModelOptionKind + public var choices: [FeatureModelOptionChoice] + public var defaultValue: FeatureModelOptionValue? + public var promptInjectedValues: [String]? + + public init( + id: String, + label: String, + detail: String? = nil, + kind: FeatureModelOptionKind, + choices: [FeatureModelOptionChoice] = [], + defaultValue: FeatureModelOptionValue? = nil, + promptInjectedValues: [String]? = nil + ) { + self.id = id + self.label = label + self.detail = detail + self.kind = kind + self.choices = choices + self.defaultValue = defaultValue + self.promptInjectedValues = promptInjectedValues + } +} + +public enum FeatureModelOptionValue: Sendable, Equatable, Hashable, Codable { + case string(String) + case boolean(Bool) + + private enum CodingKeys: String, CodingKey { + case type + case value + } + + private enum ValueType: String, Codable { + case string + case boolean + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(ValueType.self, forKey: .type) { + case .string: + self = try .string(container.decode(String.self, forKey: .value)) + case .boolean: + self = try .boolean(container.decode(Bool.self, forKey: .value)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .string(value): + try container.encode(ValueType.string, forKey: .type) + try container.encode(value, forKey: .value) + case let .boolean(value): + try container.encode(ValueType.boolean, forKey: .type) + try container.encode(value, forKey: .value) + } + } +} + +public struct FeatureModelOptionSelection: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var value: FeatureModelOptionValue + + public init(id: String, value: FeatureModelOptionValue) { + self.id = id + self.value = value + } +} + +public struct FeatureProviderWorkspace: Sendable, Equatable, Hashable, Codable { + public let cwd: String + public let slashCommands: [FeatureProviderSlashCommand] + public let skills: [FeatureProviderSkill] +} + +public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var isAvailable: Bool + public var driver: String + public var requiresNewThreadForModelChange: Bool + public var models: [FeatureModel] + public var slashCommands: [FeatureProviderSlashCommand]? + public var skills: [FeatureProviderSkill]? + public var workspaceSnapshots: [FeatureProviderWorkspace]? = nil + public var setup: ProviderSetupCapabilities? = nil + public var isEnabled: Bool? = nil + public var isInstalled: Bool? = nil + public var authStatus: String? = nil + public var statusMessage: String? = nil + + func workspaceCatalog(cwd: String?) -> FeatureProviderWorkspace { + if let cwd, let workspace = workspaceSnapshots?.first(where: { $0.cwd == cwd }) { + return workspace + } + // A catalog from another workspace must not leak into this composer. + return FeatureProviderWorkspace( + cwd: cwd ?? "", + slashCommands: workspaceSnapshots == nil ? slashCommands ?? [] : [], + skills: workspaceSnapshots == nil ? skills ?? [] : [] + ) + } + + public init( + id: String, + name: String, + isAvailable: Bool = true, + driver: String = "", + requiresNewThreadForModelChange: Bool = false, + models: [FeatureModel] = [], + slashCommands: [FeatureProviderSlashCommand] = [], + skills: [FeatureProviderSkill] = [] + ) { + self.id = id + self.name = name + self.isAvailable = isAvailable + self.driver = driver + self.requiresNewThreadForModelChange = requiresNewThreadForModelChange + self.models = models + self.slashCommands = slashCommands + self.skills = skills + } +} + +public struct FeatureSelection: Sendable, Equatable, Hashable, Codable { + public var providerID: String + public var modelID: String + public var options: [FeatureModelOptionSelection] + + public init( + providerID: String, + modelID: String, + options: [FeatureModelOptionSelection] = [] + ) { + self.providerID = providerID + self.modelID = modelID + self.options = options + } +} + +public enum FeatureAppearance: String, CaseIterable, Sendable, Codable { + case system + case light + case dark +} + +public struct FeatureTextSizeAdjustment: Sendable, Equatable, Hashable, Codable { + public static let range = -2...3 + public static let standard = FeatureTextSizeAdjustment(steps: 0) + + public let steps: Int + + public init(steps: Int) { + self.steps = min(Self.range.upperBound, max(Self.range.lowerBound, steps)) + } + + public init(from decoder: any Decoder) throws { + try self.init(steps: decoder.singleValueContainer().decode(Int.self)) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(steps) + } +} + +public struct FeatureSettings: Sendable, Equatable, Codable { + public var appearance: FeatureAppearance + public var textSize: FeatureTextSizeAdjustment + public var codeSize: FeatureTextSizeAdjustment + public var hapticsEnabled: Bool + public var notificationsEnabled: Bool + public var liveActivitiesEnabled: Bool + public var defaultSelection: FeatureSelection? + + public init( + appearance: FeatureAppearance = .system, + textSize: FeatureTextSizeAdjustment = .standard, + codeSize: FeatureTextSizeAdjustment = .standard, + hapticsEnabled: Bool = true, + notificationsEnabled: Bool = true, + liveActivitiesEnabled: Bool = true, + defaultSelection: FeatureSelection? = nil + ) { + self.appearance = appearance + self.textSize = textSize + self.codeSize = codeSize + self.hapticsEnabled = hapticsEnabled + self.notificationsEnabled = notificationsEnabled + self.liveActivitiesEnabled = liveActivitiesEnabled + self.defaultSelection = defaultSelection + } + + private enum CodingKeys: String, CodingKey { + case appearance + case textSize + case codeSize + case hapticsEnabled + case notificationsEnabled + case liveActivitiesEnabled + case defaultSelection + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + appearance = try container.decodeIfPresent( + FeatureAppearance.self, + forKey: .appearance + ) ?? .system + textSize = try container.decodeIfPresent( + FeatureTextSizeAdjustment.self, + forKey: .textSize + ) ?? .standard + codeSize = try container.decodeIfPresent( + FeatureTextSizeAdjustment.self, + forKey: .codeSize + ) ?? .standard + hapticsEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .hapticsEnabled + ) ?? true + notificationsEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .notificationsEnabled + ) ?? true + liveActivitiesEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .liveActivitiesEnabled + ) ?? true + defaultSelection = try container.decodeIfPresent( + FeatureSelection.self, + forKey: .defaultSelection + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(appearance, forKey: .appearance) + try container.encode(textSize, forKey: .textSize) + try container.encode(codeSize, forKey: .codeSize) + try container.encode(hapticsEnabled, forKey: .hapticsEnabled) + try container.encode(notificationsEnabled, forKey: .notificationsEnabled) + try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled) + try container.encodeIfPresent(defaultSelection, forKey: .defaultSelection) + } +} + +public struct FeatureEnvironmentPreferences: Sendable, Equatable, Codable { + public enum ProjectGroupingMode: String, Sendable, Equatable, Codable { + case repository + case repositoryPath = "repository_path" + case separate + } + + public var defaultWorkspaceMode: FeatureWorkspaceMode + public var newWorktreesStartFromOrigin: Bool + public var projectGroupingMode: ProjectGroupingMode + public var projectGroupingOverrides: [String: ProjectGroupingMode] + public var automaticSettlement: FeatureAutomaticSettlementSettings? + public var supportsImageUploads: Bool + public var maxFileAttachmentBytes: Int? + public var continueThreadsAfterServerUpdate: Bool? + + public init( + defaultWorkspaceMode: FeatureWorkspaceMode = .local, + newWorktreesStartFromOrigin: Bool = true, + projectGroupingMode: ProjectGroupingMode = .repository, + projectGroupingOverrides: [String: ProjectGroupingMode] = [:], + automaticSettlement: FeatureAutomaticSettlementSettings? = nil, + supportsImageUploads: Bool = false, + maxFileAttachmentBytes: Int? = nil, + continueThreadsAfterServerUpdate: Bool? = nil + ) { + self.defaultWorkspaceMode = defaultWorkspaceMode + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.projectGroupingMode = projectGroupingMode + self.projectGroupingOverrides = projectGroupingOverrides + self.automaticSettlement = automaticSettlement + self.supportsImageUploads = supportsImageUploads + self.maxFileAttachmentBytes = maxFileAttachmentBytes + self.continueThreadsAfterServerUpdate = continueThreadsAfterServerUpdate + } + + private enum CodingKeys: String, CodingKey { + case defaultWorkspaceMode + case newWorktreesStartFromOrigin + case projectGroupingMode + case projectGroupingOverrides + case automaticSettlement + case supportsImageUploads + case maxFileAttachmentBytes + case continueThreadsAfterServerUpdate + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + defaultWorkspaceMode = try container.decodeIfPresent( + FeatureWorkspaceMode.self, + forKey: .defaultWorkspaceMode + ) ?? .local + newWorktreesStartFromOrigin = try container.decodeIfPresent( + Bool.self, + forKey: .newWorktreesStartFromOrigin + ) ?? true + projectGroupingMode = try container.decodeIfPresent( + ProjectGroupingMode.self, + forKey: .projectGroupingMode + ) ?? .repository + supportsImageUploads = try container.decodeIfPresent( + Bool.self, + forKey: .supportsImageUploads + ) ?? false + maxFileAttachmentBytes = try container.decodeIfPresent( + Int.self, + forKey: .maxFileAttachmentBytes + ) + continueThreadsAfterServerUpdate = try container.decodeIfPresent( + Bool.self, + forKey: .continueThreadsAfterServerUpdate + ) + projectGroupingOverrides = try container.decodeIfPresent( + [String: ProjectGroupingMode].self, + forKey: .projectGroupingOverrides + ) ?? [:] + automaticSettlement = try container.decodeIfPresent( + FeatureAutomaticSettlementSettings.self, + forKey: .automaticSettlement + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(defaultWorkspaceMode, forKey: .defaultWorkspaceMode) + try container.encode(newWorktreesStartFromOrigin, forKey: .newWorktreesStartFromOrigin) + try container.encode(projectGroupingMode, forKey: .projectGroupingMode) + try container.encode(projectGroupingOverrides, forKey: .projectGroupingOverrides) + try container.encodeIfPresent(automaticSettlement, forKey: .automaticSettlement) + try container.encode(supportsImageUploads, forKey: .supportsImageUploads) + try container.encodeIfPresent(maxFileAttachmentBytes, forKey: .maxFileAttachmentBytes) + try container.encodeIfPresent(continueThreadsAfterServerUpdate, forKey: .continueThreadsAfterServerUpdate) + } +} + +public struct FeatureAutomaticSettlementSettings: Sendable, Equatable, Codable { + public var onMerge: Bool + public var afterDays: Double? + + public init(onMerge: Bool, afterDays: Double?) { + self.onMerge = onMerge + self.afterDays = afterDays + } +} + +public enum FeatureAutomaticSettlementChange: Sendable, Equatable { + case onMerge(Bool) + case afterDays(Double?) +} + +public struct FeatureSnapshot: Sendable, Equatable, Codable { + public var connection: FeatureConnection + public var environments: [FeatureEnvironment] + public var projects: [FeatureProject] + public var threads: [FeatureThread] + public var providers: [FeatureProvider] + /// Provider catalogues are environment-scoped. `providers` remains only + /// for decoding older cached snapshots and must not drive product choices. + public var providersByEnvironment: [String: [FeatureProvider]]? + /// Server-authoritative new-thread defaults keyed by saved environment. + public var preferencesByEnvironment: [String: FeatureEnvironmentPreferences]? + public var settings: FeatureSettings + + public init( + connection: FeatureConnection = .init(), + environments: [FeatureEnvironment] = [], + projects: [FeatureProject] = [], + threads: [FeatureThread] = [], + providers: [FeatureProvider] = [], + providersByEnvironment: [String: [FeatureProvider]]? = nil, + preferencesByEnvironment: [String: FeatureEnvironmentPreferences]? = nil, + settings: FeatureSettings = .init() + ) { + self.connection = connection + self.environments = environments + self.projects = projects + self.threads = threads + self.providers = providers + self.providersByEnvironment = providersByEnvironment + self.preferencesByEnvironment = preferencesByEnvironment + self.settings = settings + } +} + +public enum FeatureApprovalDecision: String, Sendable, Codable { + case allowOnce + case allowForSession + case allowAlways + case deny + case cancel + + init?(wireValue: String) { + switch wireValue { + case "accept": self = .allowOnce + case "acceptForSession": self = .allowForSession + case "acceptAlways": self = .allowAlways + case "decline": self = .deny + case "cancel": self = .cancel + default: return nil + } + } + + var wireValue: String { + switch self { + case .allowOnce: "accept" + case .allowForSession: "acceptForSession" + case .allowAlways: "acceptAlways" + case .deny: "decline" + case .cancel: "cancel" + } + } +} + +public enum FeatureThreadSyncState: Sendable, Equatable { + case catchingUp + case reconnecting + case live + case failed(String) +} + +public enum FeatureEvent: Sendable { + case snapshot(FeatureSnapshot) + case connection(FeatureConnection) + case thread(FeatureThread) + case threadRemoved(id: String) + case detail(FeatureThreadDetail) + case detailDelta(FeatureThreadDetail, FeatureDetailDelta) + case threadSync(id: String, state: FeatureThreadSyncState?) + case failure(String) +} diff --git a/apps/swift-ios/Features/Shared/FeatureNativeMediaPreview.swift b/apps/swift-ios/Features/Shared/FeatureNativeMediaPreview.swift new file mode 100644 index 000000000000..51cad0ad61fd --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureNativeMediaPreview.swift @@ -0,0 +1,403 @@ +import AVKit +import Foundation +import QuickLook +import SwiftUI +import UIKit + +enum FeatureMediaPreviewSource: Equatable { + case localImage(Data) + case file(URL) + case remote(URL) +} + +struct FeatureTypedMediaPreviewRoute: Equatable { + let path: String + let kind: FeatureFilePreviewKind + + static func parse(_ url: URL) -> Self? { + guard url.scheme?.lowercased() == "t3code", + url.host?.lowercased() == "media-preview", + url.path == "/open", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let path = components.queryItems?.first(where: { $0.name == "path" })?.value, + !path.isEmpty, path.count <= 1_024, + let rawKind = components.queryItems?.first(where: { $0.name == "kind" })?.value + else { return nil } + let kind: FeatureFilePreviewKind + switch rawKind { + case "image": kind = .image + case "video": kind = .video + case "pdf": kind = .pdf + case "document": kind = .document + default: return nil + } + return Self(path: path, kind: kind) + } +} + +struct FeatureMediaPreviewGeneration { + private(set) var value = 0 + mutating func begin() -> Int { + value += 1 + return value + } + mutating func invalidate() { value += 1 } + func isCurrent(_ candidate: Int) -> Bool { value == candidate } +} + +enum FeatureMediaPreviewError: LocalizedError, Equatable { + case invalidResponse + case httpStatus(Int) + case tooLarge + case invalidFileName + + var errorDescription: String? { + switch self { + case .invalidResponse: "The server returned an invalid file." + case let .httpStatus(status): "The server returned HTTP \(status)." + case .tooLarge: "The file is too large to preview." + case .invalidFileName: "The file name is invalid." + } + } +} + +enum FeatureMediaPreviewFiles { + static let maximumBytes: Int64 = 64 * 1_024 * 1_024 + + static func safeFileName(_ proposedName: String) throws -> String { + let name = URL(fileURLWithPath: proposedName).lastPathComponent + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name != ".", name != "..", name.utf8.count <= 255, + !name.contains("/") else { + throw FeatureMediaPreviewError.invalidFileName + } + return name.replacingOccurrences(of: ":", with: "_") + } + + static func ownedDirectory(fileManager: FileManager = .default) throws -> URL { + let directory = fileManager.temporaryDirectory + .appendingPathComponent("T3CodePreviews", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + static func shareURL( + for source: FeatureMediaPreviewSource, + downloadedURL: URL? + ) -> URL? { + switch source { + case let .file(url): url + case .localImage, .remote: downloadedURL + } + } +} + +@MainActor +final class FeatureMediaPreviewLoader: ObservableObject { + @Published private(set) var fileURL: URL? + @Published private(set) var errorMessage: String? + @Published private(set) var isLoading = false + + private var ownedDirectory: URL? + private var generation = FeatureMediaPreviewGeneration() + + deinit { + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + } + + func load(source: FeatureMediaPreviewSource, fileName: String) async { + guard fileURL == nil, !isLoading else { return } + let activeGeneration = generation.begin() + errorMessage = nil + isLoading = true + defer { + if generation.isCurrent(activeGeneration) { isLoading = false } + } + do { + switch source { + case let .file(url): + fileURL = url + case let .localImage(data): + guard Int64(data.count) <= FeatureMediaPreviewFiles.maximumBytes else { + throw FeatureMediaPreviewError.tooLarge + } + let directory = try FeatureMediaPreviewFiles.ownedDirectory() + ownedDirectory = directory + let destination = directory.appendingPathComponent( + try FeatureMediaPreviewFiles.safeFileName(fileName) + ) + try data.write(to: destination, options: .atomic) + fileURL = destination + case let .remote(url): + let request = URLRequest(url: url, timeoutInterval: 30) + let (temporaryURL, response) = try await URLSession.shared.download(for: request) + defer { try? FileManager.default.removeItem(at: temporaryURL) } + guard generation.isCurrent(activeGeneration), !Task.isCancelled else { return } + guard let response = response as? HTTPURLResponse else { + throw FeatureMediaPreviewError.invalidResponse + } + guard (200 ... 299).contains(response.statusCode) else { + throw FeatureMediaPreviewError.httpStatus(response.statusCode) + } + if response.expectedContentLength > FeatureMediaPreviewFiles.maximumBytes { + throw FeatureMediaPreviewError.tooLarge + } + let byteCount = try temporaryURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard Int64(byteCount) <= FeatureMediaPreviewFiles.maximumBytes else { + throw FeatureMediaPreviewError.tooLarge + } + let directory = try FeatureMediaPreviewFiles.ownedDirectory() + ownedDirectory = directory + let destination = directory.appendingPathComponent( + try FeatureMediaPreviewFiles.safeFileName(fileName) + ) + try FileManager.default.moveItem(at: temporaryURL, to: destination) + guard generation.isCurrent(activeGeneration), !Task.isCancelled else { + cleanUp() + return + } + fileURL = destination + } + } catch is CancellationError { + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + ownedDirectory = nil + return + } catch { + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + ownedDirectory = nil + guard generation.isCurrent(activeGeneration) else { return } + errorMessage = error.localizedDescription + } + } + + func cleanUp() { + generation.invalidate() + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + self.ownedDirectory = nil + fileURL = nil + isLoading = false + } +} + +struct FeatureNativeMediaPreviewView: View { + let source: FeatureMediaPreviewSource + let kind: FeatureFilePreviewKind + let fileName: String + + @StateObject private var loader = FeatureMediaPreviewLoader() + @State private var sharedFile: FeatureSharedFile? + + var body: some View { + Group { + if kind == .video, case let .remote(url) = source { + FeatureVideoPlayerView(url: url) + } else if kind == .image, case let .localImage(data) = source, + let image = UIImage(data: data) { + FeatureNativeZoomableImageView(image: image) + } else if let fileURL = loader.fileURL { + preview(fileURL) + } else if let errorMessage = loader.errorMessage { + ContentUnavailableView { + Label("Preview unavailable", systemImage: "doc.badge.ellipsis") + } description: { Text(errorMessage) } actions: { + Button("Try again") { Task { await loader.load(source: source, fileName: fileName) } } + } + } else { + Text("Loading preview…") + .foregroundStyle(T3Colors.textSecondary) + } + } + .background(kind == .image || kind == .video ? Color.black : T3Colors.background) + .task { + if kind != .video || !isRemoteSource { + await loader.load(source: source, fileName: fileName) + } + } + .onDisappear { + loader.cleanUp() + } + .sheet(item: $sharedFile) { file in + FeatureFileActivityView(url: file.url) + } + .toolbar { + if kind == .video, isRemoteSource { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { + await loader.load(source: source, fileName: fileName) + sharedFile = FeatureMediaPreviewFiles.shareURL( + for: source, + downloadedURL: loader.fileURL + ).map(FeatureSharedFile.init) + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(loader.isLoading) + .accessibilityLabel("Share file") + } + } else if let fileURL = loader.fileURL { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: fileURL) { Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share file") + } + } + } + } + + private var isRemoteSource: Bool { + if case .remote = source { true } else { false } + } + + @ViewBuilder + private func preview(_ url: URL) -> some View { + switch kind { + case .image: + if let image = UIImage(contentsOfFile: url.path) { + FeatureNativeZoomableImageView(image: image) + } else { + ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark") + } + case .video: + FeatureVideoPlayerView(url: url) + case .pdf, .document: + FeatureQuickLookPreview(url: url) + case .markdown, .source, .plainText: + FeatureQuickLookPreview(url: url) + } + } +} + +private struct FeatureVideoPlayerView: View { + let url: URL + @StateObject private var playback = FeatureVideoPlayback() + + var body: some View { + Group { + if playback.failed { + ContentUnavailableView { + Label("Video unavailable", systemImage: "video.slash") + } description: { Text("The video could not load.") } actions: { + Button("Try again") { playback.load(url) } + } + } else { + VideoPlayer(player: playback.player) + .overlay { + if !playback.ready { Text("Loading video…").foregroundStyle(.white) } + } + } + } + .onAppear { playback.load(url) } + .onDisappear { playback.stop() } + } +} + +@MainActor +private final class FeatureVideoPlayback: ObservableObject { + let player = AVPlayer() + @Published private(set) var failed = false + @Published private(set) var ready = false + private var observation: NSKeyValueObservation? + + func load(_ url: URL) { + stop() + failed = false + ready = false + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + observation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in + Task { @MainActor [weak self] in + guard let self, self.player.currentItem === item else { return } + self.failed = item.status == .failed + self.ready = item.status == .readyToPlay + } + } + } + + func stop() { + observation?.invalidate() + observation = nil + player.pause() + player.replaceCurrentItem(with: nil) + } +} + +private struct FeatureFileActivityView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [url], applicationActivities: nil) + } + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} + +private struct FeatureSharedFile: Identifiable { + let url: URL + var id: URL { url } +} + +private struct FeatureQuickLookPreview: UIViewControllerRepresentable { + let url: URL + + func makeCoordinator() -> Coordinator { Coordinator(url: url) } + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ controller: QLPreviewController, context: Context) { + context.coordinator.url = url + controller.reloadData() + } + + final class Coordinator: NSObject, QLPreviewControllerDataSource { + var url: URL + init(url: URL) { self.url = url } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + func previewController( + _ controller: QLPreviewController, + previewItemAt index: Int + ) -> QLPreviewItem { url as NSURL } + } +} + +private struct FeatureNativeZoomableImageView: UIViewRepresentable { + let image: UIImage + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.backgroundColor = .black + scrollView.delegate = context.coordinator + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = 6 + let imageView = context.coordinator.imageView + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.accessibilityLabel = "Image preview" + scrollView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + context.coordinator.scrollView = scrollView + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.imageView.image = image + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + let imageView = UIImageView() + weak var scrollView: UIScrollView? + func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift new file mode 100644 index 000000000000..0f82357e2aca --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift @@ -0,0 +1,311 @@ +import Foundation + +/// Stable wire identities make an outbox retry idempotent across app launches, +/// including the ambiguous case where the server committed a command but its +/// response never reached the phone. +public struct FeatureSubmissionIdentity: Sendable, Equatable, Hashable, Codable { + public var threadID: String + public var commandID: String + public var messageID: String + public var createdAt: Date + + public init( + threadID: String = UUID().uuidString, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: Date = .now + ) { + self.threadID = threadID + self.commandID = commandID + self.messageID = messageID + self.createdAt = createdAt + } +} + +public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { + public var id: UUID + public var data: Data? + public var ownedFileName: String? + public var byteCount: Int? + public var name: String + public var mimeType: String + public var uploadedReference: FeatureUploadedAttachmentReference? + private var resolvedOwnedFile: FeatureOwnedAttachmentFile? + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + self.data = data + ownedFileName = nil + byteCount = data.count + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + resolvedOwnedFile = nil + } + + init(_ attachment: FeatureUploadAttachment) { + id = attachment.id + data = attachment.ownedFile == nil ? attachment.data : nil + ownedFileName = attachment.ownedFile?.fileName + byteCount = attachment.byteCount + name = attachment.name + mimeType = attachment.mimeType + uploadedReference = attachment.uploadedReference + resolvedOwnedFile = attachment.ownedFile + } + + private enum CodingKeys: String, CodingKey { + case id, data, ownedFileName, byteCount, name, mimeType, uploadedReference + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + data = try container.decodeIfPresent(Data.self, forKey: .data) + ownedFileName = try container.decodeIfPresent(String.self, forKey: .ownedFileName) + byteCount = try container.decodeIfPresent(Int.self, forKey: .byteCount) ?? data?.count + name = try container.decode(String.self, forKey: .name) + mimeType = try container.decode(String.self, forKey: .mimeType) + uploadedReference = try container.decodeIfPresent( + FeatureUploadedAttachmentReference.self, + forKey: .uploadedReference + ) + resolvedOwnedFile = nil + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(data, forKey: .data) + try container.encodeIfPresent(ownedFileName, forKey: .ownedFileName) + try container.encodeIfPresent(byteCount, forKey: .byteCount) + try container.encode(name, forKey: .name) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(uploadedReference, forKey: .uploadedReference) + } + + mutating func resolveOwnedFile(using fileStore: ManagedAttachmentFileStore) { + guard let ownedFileName else { return } + resolvedOwnedFile = try? fileStore.resolvedFile( + fileName: ownedFileName, + byteCount: byteCount ?? 0 + ) + } + + var upload: FeatureUploadAttachment? { + if let resolvedOwnedFile { + return FeatureUploadAttachment( + id: id, + ownedFile: resolvedOwnedFile, + name: name, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } + guard let data else { return nil } + return FeatureUploadAttachment( + id: id, + data: data, + name: name, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } +} + +public struct FeatureQueuedCreation: Sendable, Equatable, Codable { + public var projectID: String + public var projectName: String + public var workspaceMode: FeatureWorkspaceMode + public var branch: String? + public var worktreePath: String? + public var startFromOrigin: Bool + + public init( + projectID: String, + projectName: String, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool + ) { + self.projectID = projectID + self.projectName = projectName + self.workspaceMode = workspaceMode + self.branch = branch + self.worktreePath = worktreePath + self.startFromOrigin = startFromOrigin + } +} + +public struct FeatureQueuedSubmission: Identifiable, Sendable, Equatable, Codable { + public let id: String + public var environmentID: String + public var identity: FeatureSubmissionIdentity + public var threadID: String + public var text: String + public var selection: FeatureSelection? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + public var attachments: [FeatureQueuedAttachment] + public var creation: FeatureQueuedCreation? + + public init( + id: String? = nil, + environmentID: String, + identity: FeatureSubmissionIdentity, + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment], + creation: FeatureQueuedCreation? = nil + ) { + self.id = id ?? identity.messageID + self.environmentID = environmentID + self.identity = identity + self.threadID = threadID + self.text = text + self.selection = selection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode.mobileNormalized + self.attachments = attachments.map(FeatureQueuedAttachment.init) + self.creation = creation + } + + public var uploads: [FeatureUploadAttachment] { + attachments.compactMap(\.upload) + } +} + +public enum FeatureOutboxDeliveryDecision: Equatable { + case discard + case wait + case send +} + +public enum FeatureOutboxPolicy { + /// Delivery waits for the owning environment. Existing threads accept + /// follow-up messages while a turn is running, matching the web queue. + /// A created thread does not prove that its first message was accepted. + /// Retry its stable command identity until the message itself is confirmed. + public static func decision( + for submission: FeatureQueuedSubmission, + snapshot: FeatureSnapshot, + pendingCreationThreadIDs: Set = [] + ) -> FeatureOutboxDeliveryDecision { + let environment = snapshot.environments.first { $0.id == submission.environmentID } + let isConnected = environment?.isEnabled == true + && environment?.connectionState == .connected + let thread = snapshot.threads.first { $0.id == submission.threadID } + + if submission.creation != nil { + if thread != nil { return isConnected ? .send : .wait } + let projectExists = snapshot.projects.contains { + $0.id == submission.creation?.projectID + && $0.environmentID == submission.environmentID + } + if isConnected, !projectExists { return .discard } + return isConnected ? .send : .wait + } + + if pendingCreationThreadIDs.contains(submission.threadID) { + return .wait + } + guard thread != nil else { + // A fully synchronized environment proves the thread was deleted. + return isConnected ? .discard : .wait + } + guard isConnected else { return .wait } + return .send + } +} + +public actor FeatureOutboxStore { + private struct Document: Codable { + var version = 1 + var submissions: [FeatureQueuedSubmission] + } + + public static let shared = FeatureOutboxStore() + + public let fileURL: URL + public let attachmentFileStore: ManagedAttachmentFileStore + private var cached: [FeatureQueuedSubmission]? + + public init(fileURL: URL? = nil, attachmentStorageRootURL: URL? = nil) { + attachmentFileStore = ManagedAttachmentFileStore(rootURL: attachmentStorageRootURL) + if let fileURL { + self.fileURL = fileURL + } else { + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.fileURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("outbox.json", isDirectory: false) + } + } + + public func submissions() throws -> [FeatureQueuedSubmission] { + if let cached { return cached } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + cached = [] + return [] + } + // Keep failed reads uncached so later writes cannot replace unreadable messages. + let document = try JSONDecoder.t3.decode( + Document.self, + from: Data(contentsOf: fileURL) + ) + cached = document.submissions.map { submission in + var submission = submission + submission.interactionMode = submission.interactionMode.mobileNormalized + for index in submission.attachments.indices { + submission.attachments[index].resolveOwnedFile(using: attachmentFileStore) + } + return submission + }.sorted { + $0.identity.createdAt < $1.identity.createdAt + } + return cached ?? [] + } + + public func enqueue(_ submission: FeatureQueuedSubmission) throws { + var values = try submissions() + values.removeAll { $0.id == submission.id } + values.append(submission) + values.sort { $0.identity.createdAt < $1.identity.createdAt } + try save(values) + } + + public func remove(id: String) throws { + var values = try submissions() + values.removeAll { $0.id == id } + try save(values) + } + + public func removeAll(environmentID: String) throws { + var values = try submissions() + values.removeAll { $0.environmentID == environmentID } + try save(values) + } + + private func save(_ submissions: [FeatureQueuedSubmission]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder.t3.encode(Document(submissions: submissions)) + try data.write(to: fileURL, options: .atomic) + cached = submissions + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureProjectFaviconImageDecoder.swift b/apps/swift-ios/Features/Shared/FeatureProjectFaviconImageDecoder.swift new file mode 100644 index 000000000000..9fd252f6d51d --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureProjectFaviconImageDecoder.swift @@ -0,0 +1,92 @@ +import Foundation +import UIKit +import WebKit + +enum FeatureProjectFaviconImageDecoder { + @MainActor + static func renderableData(from data: Data) async -> Data? { + if UIImage(data: data) != nil { + return data + } + guard isSVG(data) else { return nil } + return await FeatureProjectSVGRenderSession().render(data)?.pngData() + } + + private static func isSVG(_ data: Data) -> Bool { + guard let prefix = String(data: data.prefix(4_096), encoding: .utf8) else { + return false + } + return prefix.range(of: "? + + func render(_ data: Data) async -> UIImage? { + await withCheckedContinuation { continuation in + self.continuation = continuation + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView( + frame: CGRect(x: 0, y: 0, width: 64, height: 64), + configuration: configuration + ) + webView.navigationDelegate = self + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + self.webView = webView + + let source = data.base64EncodedString() + webView.loadHTMLString( + """ + + + + + """, + baseURL: nil + ) + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + let configuration = WKSnapshotConfiguration() + configuration.rect = CGRect(x: 0, y: 0, width: 64, height: 64) + configuration.afterScreenUpdates = true + webView.takeSnapshot(with: configuration) { [weak self] image, _ in + Task { @MainActor in self?.finish(image) } + } + } + + func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError _: Error + ) { + finish(nil) + } + + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation!, + withError _: Error + ) { + finish(nil) + } + + private func finish(_ image: UIImage?) { + guard let continuation else { return } + self.continuation = nil + webView?.navigationDelegate = nil + webView = nil + continuation.resume(returning: image) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureProjectFaviconStore.swift b/apps/swift-ios/Features/Shared/FeatureProjectFaviconStore.swift new file mode 100644 index 000000000000..b0678549f3b2 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureProjectFaviconStore.swift @@ -0,0 +1,180 @@ +import CryptoKit +import Foundation + +struct FeatureProjectFaviconCacheKey: Codable, Hashable, Sendable { + let environmentID: String + let workspaceRoot: String + + init(environmentID: String, workspaceRoot: String) { + self.environmentID = environmentID + self.workspaceRoot = Self.normalize(workspaceRoot) + } + + private static func normalize(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + return URL(fileURLWithPath: trimmed).standardizedFileURL.path + } + + var fingerprint: String { + let input = Data("\(environmentID)\u{0}\(workspaceRoot)".utf8) + return SHA256.hash(data: input).map { String(format: "%02x", $0) }.joined() + } +} + +struct FeatureProjectFaviconCacheValue: Equatable, Sendable { + let data: Data? + let revision: String? + let lastCheckedAt: Date +} + +enum FeatureProjectFaviconStoreError: Error, Equatable { + case invalidDataSize +} + +/// Persists the last known project icon independently of the server's signed +/// asset URL. A later missing icon or unreachable environment updates the +/// refresh time but does not discard bytes that were already shown to a user. +actor FeatureProjectFaviconStore { + private struct Metadata: Codable { + let key: FeatureProjectFaviconCacheKey + var revision: String? + var dataFileName: String? + var lastCheckedAt: Date + } + + private struct Document: Codable { + var version = 1 + var entries: [String: Metadata] + } + + static let maximumEntryCount = 256 + static let maximumDataSize = 1 * 1_024 * 1_024 + + let directoryURL: URL + private let fileManager: FileManager + private var cachedDocument: Document? + + init(directoryURL: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + if let directoryURL { + self.directoryURL = directoryURL + } else { + let root = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.directoryURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("project-favicons", isDirectory: true) + } + } + + func value(for key: FeatureProjectFaviconCacheKey) throws + -> FeatureProjectFaviconCacheValue? + { + let document = try loadDocument() + guard let metadata = document.entries[key.fingerprint], metadata.key == key else { + return nil + } + let data = metadata.dataFileName.flatMap { fileName in + try? Data(contentsOf: directoryURL.appendingPathComponent(fileName)) + } + return FeatureProjectFaviconCacheValue( + data: data, + revision: metadata.revision, + lastCheckedAt: metadata.lastCheckedAt + ) + } + + /// Records a refresh attempt. Passing no data preserves the last known + /// icon, including when the server no longer has an icon for the project. + func record( + data: Data?, + revision: String?, + for key: FeatureProjectFaviconCacheKey, + checkedAt: Date = .now + ) throws { + if let data, data.isEmpty || data.count > Self.maximumDataSize { + throw FeatureProjectFaviconStoreError.invalidDataSize + } + + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + var document = try loadDocument() + let fingerprint = key.fingerprint + var metadata = document.entries[fingerprint] ?? Metadata( + key: key, + revision: nil, + dataFileName: nil, + lastCheckedAt: checkedAt + ) + + if let data { + let fileName = "\(fingerprint).icon" + try data.write( + to: directoryURL.appendingPathComponent(fileName), + options: .atomic + ) + metadata.dataFileName = fileName + metadata.revision = revision + } + metadata.lastCheckedAt = checkedAt + document.entries[fingerprint] = metadata + try prune(&document) + try persist(document) + } + + private var manifestURL: URL { + directoryURL.appendingPathComponent("manifest.json") + } + + private func loadDocument() throws -> Document { + if let cachedDocument { return cachedDocument } + guard fileManager.fileExists(atPath: manifestURL.path) else { + let document = Document(entries: [:]) + cachedDocument = document + return document + } + do { + let document = try JSONDecoder.t3.decode( + Document.self, + from: Data(contentsOf: manifestURL) + ) + guard document.version == 1 else { throw CocoaError(.fileReadCorruptFile) } + cachedDocument = document + return document + } catch { + // A disposable cache must recover without blocking the home screen. + let document = Document(entries: [:]) + cachedDocument = document + return document + } + } + + private func persist(_ document: Document) throws { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + try JSONEncoder.t3.encode(document).write(to: manifestURL, options: .atomic) + cachedDocument = document + } + + private func prune(_ document: inout Document) throws { + guard document.entries.count > Self.maximumEntryCount else { return } + let removed = document.entries + .sorted { $0.value.lastCheckedAt > $1.value.lastCheckedAt } + .dropFirst(Self.maximumEntryCount) + for (fingerprint, metadata) in removed { + document.entries.removeValue(forKey: fingerprint) + if let dataFileName = metadata.dataFileName { + try? fileManager.removeItem( + at: directoryURL.appendingPathComponent(dataFileName) + ) + } + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift new file mode 100644 index 000000000000..a0cdab24d667 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -0,0 +1,1006 @@ +import Foundation + +public struct FeatureCapabilityUnavailable: LocalizedError, Sendable, Equatable { + public let capability: String + + public init(_ capability: String) { + self.capability = capability + } + + public var errorDescription: String? { + "\(capability) is not supported by this environment." + } +} + +/// Optional rich-file capability. The base file contract is deliberately text-only, +/// while native clients can resolve the existing signed workspace asset route for images. +@MainActor +public protocol FeatureWorkspaceAssetResolving: AnyObject { + func workspaceAssetURL(threadID: String, path: String) async throws -> URL + func mediaAssetURL(threadID: String, path: String) async throws -> URL + func mediaAsset(threadID: String, path: String) async throws -> ResolvedAssetURL + func nativeAppIconURL(threadID: String, app: ToolNativeAppReference) async throws -> URL +} + +public extension FeatureWorkspaceAssetResolving { + func mediaAsset(threadID: String, path: String) async throws -> ResolvedAssetURL { + ResolvedAssetURL( + url: try await mediaAssetURL(threadID: threadID, path: path), + expiresAt: .distantFuture + ) + } + + func nativeAppIconURL(threadID: String, app: ToolNativeAppReference) async throws -> URL { + throw FeatureCapabilityUnavailable("Native app icons") + } + + func mediaAssetURL(threadID: String, path: String) async throws -> URL { + try await workspaceAssetURL(threadID: threadID, path: path) + } +} + +@MainActor +public protocol FeatureFeedbackSubmitting: AnyObject { + func submitCodexFeedback(threadID: String, reason: String?) async throws -> String +} + +public enum FeatureFileKind: String, Sendable, Codable { + case file + case directory + case symbolicLink +} + +public struct FeatureFileEntry: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public let path: String + public var name: String + public var kind: FeatureFileKind + public var sizeBytes: Int? + public var isHidden: Bool + + public init( + path: String, + name: String, + kind: FeatureFileKind, + sizeBytes: Int? = nil, + isHidden: Bool = false + ) { + self.path = path + self.name = name + self.kind = kind + self.sizeBytes = sizeBytes + self.isHidden = isHidden + } +} + +public extension Array where Element == FeatureFileEntry { + func featureFiltered(by query: String, includesHidden: Bool) -> [FeatureFileEntry] { + let visible = includesHidden ? self : filter { !$0.isHidden } + let filtered: [FeatureFileEntry] + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + filtered = visible + } else { + filtered = visible.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + return filtered.sorted { + if $0.kind == .directory, $1.kind != .directory { return true } + if $0.kind != .directory, $1.kind == .directory { return false } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } +} + +public struct FeatureFileContent: Sendable, Equatable, Codable { + public var path: String + public var text: String + public var language: String? + public var isTruncated: Bool + public var totalBytes: Int? + + public init( + path: String, + text: String, + language: String? = nil, + isTruncated: Bool = false, + totalBytes: Int? = nil + ) { + self.path = path + self.text = text + self.language = language + self.isTruncated = isTruncated + self.totalBytes = totalBytes + } +} + +public enum FeatureFilePreviewKind: Sendable, Equatable { + case image + case pdf + case video + case document + case markdown + case source + case plainText + + public static func infer(path: String, language: String? = nil) -> Self { + let fileExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + if imageExtensions.contains(fileExtension) { return .image } + if fileExtension == "pdf" { return .pdf } + if videoExtensions.contains(fileExtension) { return .video } + if documentExtensions.contains(fileExtension) { return .document } + if language?.lowercased() == "markdown" || ["md", "mdx"].contains(fileExtension) { + return .markdown + } + if language != nil || sourceExtensions.contains(fileExtension) { return .source } + return .plainText + } + + private static let imageExtensions: Set = [ + "avif", "gif", "ico", "jpeg", "jpg", "png", "webp", + ] + + private static let videoExtensions: Set = [ + "m4v", "mov", "mp4", "mpeg", "mpg", "webm", + ] + + private static let documentExtensions: Set = [ + "doc", "docx", "key", "numbers", "pages", "ppt", "pptx", "rtf", "xls", "xlsx", + ] + + private static let sourceExtensions: Set = [ + "c", "cc", "cpp", "cs", "css", "go", "h", "hpp", "html", "java", "js", "jsx", + "json", "kt", "kts", "m", "mm", "php", "py", "rb", "rs", "scss", "sh", "sql", + "swift", "toml", "ts", "tsx", "vue", "xml", "yaml", "yml", "zsh", + ] +} + +public enum FeatureSourceTokenKind: String, Sendable, Equatable, Hashable, Codable { + case plain + case comment + case keyword + case literal + case number + case property +} + +public struct FeatureSourceSpan: Sendable, Equatable, Hashable, Codable { + public var text: String + public var kind: FeatureSourceTokenKind + + public init(text: String, kind: FeatureSourceTokenKind) { + self.text = text + self.kind = kind + } +} + +public struct FeatureSourceLine: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: Int + public var spans: [FeatureSourceSpan] + + public init(id: Int, spans: [FeatureSourceSpan]) { + self.id = id + self.spans = spans + } + + public var number: Int { id + 1 } + public var text: String { spans.map(\.text).joined() } +} + +/// A bounded, language-aware lexer for file previews. It runs once when a file loads; +/// SwiftUI receives immutable line plans and performs no regex or token work while scrolling. +public enum FeatureSourceHighlighter { + public static func lines(text: String, language: String?) -> [FeatureSourceLine] { + let sourceLines = text.split(separator: "\n", omittingEmptySubsequences: false) + let highlightsContent = text.utf8.count <= 512 * 1_024 + var isInsideBlockComment = false + return sourceLines.enumerated().map { index, line in + guard highlightsContent, line.utf8.count <= 32 * 1_024 else { + return FeatureSourceLine( + id: index, + spans: line.isEmpty + ? [] + : [FeatureSourceSpan(text: String(line), kind: .plain)] + ) + } + return FeatureSourceLine( + id: index, + spans: spans( + in: String(line), + language: language?.lowercased(), + isInsideBlockComment: &isInsideBlockComment + ) + ) + } + } + + private static func spans( + in line: String, + language: String?, + isInsideBlockComment: inout Bool + ) -> [FeatureSourceSpan] { + let characters = Array(line) + var output: [FeatureSourceSpan] = [] + var index = 0 + let lineComment = lineCommentMarker(for: language) + let supportsBlockComments = blockCommentLanguages.contains(language ?? "") + let keywords = keywords(for: language) + + func hasPrefix(_ prefix: [Character], at offset: Int) -> Bool { + guard offset + prefix.count <= characters.count else { return false } + return characters[offset ..< offset + prefix.count].elementsEqual(prefix) + } + + func append(_ range: Range, kind: FeatureSourceTokenKind) { + guard !range.isEmpty else { return } + let text = String(characters[range]) + if output.last?.kind == kind { + output[output.count - 1].text += text + } else { + output.append(FeatureSourceSpan(text: text, kind: kind)) + } + } + + while index < characters.count { + if isInsideBlockComment { + let start = index + while index < characters.count, !hasPrefix(["*", "/"], at: index) { + index += 1 + } + if index < characters.count { + index += 2 + isInsideBlockComment = false + } + append(start ..< index, kind: .comment) + continue + } + + if let lineComment, hasPrefix(Array(lineComment), at: index) { + append(index ..< characters.count, kind: .comment) + break + } + + if supportsBlockComments, hasPrefix(["/", "*"], at: index) { + let start = index + index += 2 + while index < characters.count, !hasPrefix(["*", "/"], at: index) { + index += 1 + } + if index < characters.count { + index += 2 + } else { + isInsideBlockComment = true + } + append(start ..< index, kind: .comment) + continue + } + + if ["\"", "'", "`"].contains(characters[index]) { + let start = index + let delimiter = characters[index] + index += 1 + var isEscaped = false + while index < characters.count { + let character = characters[index] + index += 1 + if character == delimiter, !isEscaped { break } + isEscaped = character == "\\" && !isEscaped + if character != "\\" { isEscaped = false } + } + var next = index + while next < characters.count, characters[next].isWhitespace { next += 1 } + let kind: FeatureSourceTokenKind = next < characters.count + && characters[next] == ":" + && propertyLanguages.contains(language ?? "") + ? .property + : .literal + append(start ..< index, kind: kind) + continue + } + + if characters[index].isNumber { + let start = index + index += 1 + while index < characters.count, + characters[index].isNumber + || [".", "_", "x", "X", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"].contains(characters[index]) { + index += 1 + } + append(start ..< index, kind: .number) + continue + } + + if isIdentifierStart(characters[index]) { + let start = index + index += 1 + while index < characters.count, isIdentifierBody(characters[index]) { + index += 1 + } + let token = String(characters[start ..< index]) + var next = index + while next < characters.count, characters[next].isWhitespace { next += 1 } + let kind: FeatureSourceTokenKind + if ["true", "false", "null", "nil", "undefined"].contains(token) { + kind = .literal + } else if keywords.contains(token) { + kind = .keyword + } else if next < characters.count, + characters[next] == ":", + propertyLanguages.contains(language ?? "") { + kind = .property + } else { + kind = .plain + } + append(start ..< index, kind: kind) + continue + } + + append(index ..< index + 1, kind: .plain) + index += 1 + } + return output + } + + private static func lineCommentMarker(for language: String?) -> String? { + switch language { + case "plain": nil + case "python", "shell", "ruby", "yaml", "toml": "#" + case "sql": "--" + case "html", "xml", "css", "scss": nil + default: "//" + } + } + + private static func keywords(for language: String?) -> Set { + switch language { + case "plain": [] + case "swift": swiftKeywords + case "typescript", "javascript": javascriptKeywords + case "python": pythonKeywords + case "rust": rustKeywords + case "go": goKeywords + case "shell": shellKeywords + default: commonKeywords + } + } + + private static func isIdentifierStart(_ character: Character) -> Bool { + character == "_" || character == "$" || character.isLetter + } + + private static func isIdentifierBody(_ character: Character) -> Bool { + isIdentifierStart(character) || character.isNumber || character == "-" + } + + private static let propertyLanguages: Set = ["json", "typescript", "javascript", "yaml"] + private static let blockCommentLanguages: Set = [ + "css", "go", "java", "javascript", "rust", "scss", "swift", "typescript", + ] + private static let commonKeywords: Set = [ + "class", "const", "else", "enum", "false", "for", "func", "function", "if", "import", + "let", "nil", "null", "private", "public", "return", "struct", "true", "var", "while", + ] + private static let swiftKeywords = commonKeywords.union([ + "actor", "any", "associatedtype", "async", "await", "case", "defer", "extension", "guard", + "in", "init", "internal", "nonisolated", "opaque", "protocol", "self", "some", "switch", + "throws", "try", "typealias", "where", + ]) + private static let javascriptKeywords = commonKeywords.union([ + "as", "break", "case", "catch", "continue", "default", "export", "extends", "from", "interface", + "new", "of", "static", "throw", "type", "typeof", "undefined", + ]) + private static let pythonKeywords = commonKeywords.union([ + "and", "as", "assert", "async", "await", "def", "elif", "except", "finally", "from", "in", + "is", "lambda", "not", "or", "pass", "raise", "with", "yield", + ]) + private static let rustKeywords = commonKeywords.union([ + "as", "async", "await", "crate", "dyn", "impl", "in", "loop", "match", "mod", "move", "mut", + "ref", "self", "trait", "type", "unsafe", "use", "where", + ]) + private static let goKeywords = commonKeywords.union([ + "break", "case", "chan", "continue", "defer", "fallthrough", "go", "goto", "interface", "map", + "package", "range", "select", "type", + ]) + private static let shellKeywords = commonKeywords.union([ + "case", "do", "done", "elif", "esac", "export", "fi", "in", "then", + ]) +} + +public enum FeatureDiffLineKind: String, Sendable, Codable { + case context + case addition + case deletion + case hunk +} + +public struct FeatureDiffLine: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var kind: FeatureDiffLineKind + public var oldLine: Int? + public var newLine: Int? + public var text: String + public var spans: [FeatureDiffTextSpan]? + + public init( + id: String, + kind: FeatureDiffLineKind, + oldLine: Int? = nil, + newLine: Int? = nil, + text: String, + spans: [FeatureDiffTextSpan]? = nil + ) { + self.id = id + self.kind = kind + self.oldLine = oldLine + self.newLine = newLine + self.text = text + self.spans = spans + } +} + +public enum FeatureDiffTextSpanKind: String, Sendable, Equatable, Hashable, Codable { + case unchanged + case changed +} + +public struct FeatureDiffTextSpan: Sendable, Equatable, Hashable, Codable { + public var text: String + public var kind: FeatureDiffTextSpanKind + + public init(text: String, kind: FeatureDiffTextSpanKind) { + self.text = text + self.kind = kind + } +} + +public enum FeatureDiffWordHighlighter { + public static func spans( + old: String, + new: String + ) -> (old: [FeatureDiffTextSpan], new: [FeatureDiffTextSpan]) { + guard old != new else { + let unchanged = [FeatureDiffTextSpan(text: old, kind: .unchanged)] + return (unchanged, unchanged) + } + let oldTokens = tokens(in: old) + let newTokens = tokens(in: new) + guard !oldTokens.isEmpty, !newTokens.isEmpty, + oldTokens.count * newTokens.count <= 20_000 else { + return (changed(old), changed(new)) + } + + var lengths = Array( + repeating: Array(repeating: 0, count: newTokens.count + 1), + count: oldTokens.count + 1 + ) + for oldIndex in oldTokens.indices.reversed() { + for newIndex in newTokens.indices.reversed() { + lengths[oldIndex][newIndex] = oldTokens[oldIndex] == newTokens[newIndex] + ? lengths[oldIndex + 1][newIndex + 1] + 1 + : max(lengths[oldIndex + 1][newIndex], lengths[oldIndex][newIndex + 1]) + } + } + + var oldMatches = Array(repeating: false, count: oldTokens.count) + var newMatches = Array(repeating: false, count: newTokens.count) + var oldIndex = 0 + var newIndex = 0 + while oldIndex < oldTokens.count, newIndex < newTokens.count { + if oldTokens[oldIndex] == newTokens[newIndex] { + oldMatches[oldIndex] = true + newMatches[newIndex] = true + oldIndex += 1 + newIndex += 1 + } else if lengths[oldIndex + 1][newIndex] >= lengths[oldIndex][newIndex + 1] { + oldIndex += 1 + } else { + newIndex += 1 + } + } + + return ( + makeSpans(tokens: oldTokens, matches: oldMatches), + makeSpans(tokens: newTokens, matches: newMatches) + ) + } + + private static func tokens(in text: String) -> [String] { + var output: [String] = [] + var current = "" + var currentClass: TokenClass? + for character in text { + let tokenClass: TokenClass = if character.isWhitespace { + .whitespace + } else if character.isLetter || character.isNumber || character == "_" || character == "$" { + .word + } else { + .punctuation + } + if tokenClass == .punctuation { + if !current.isEmpty { output.append(current) } + output.append(String(character)) + current = "" + currentClass = nil + } else if currentClass == tokenClass { + current.append(character) + } else { + if !current.isEmpty { output.append(current) } + current = String(character) + currentClass = tokenClass + } + } + if !current.isEmpty { output.append(current) } + return output + } + + private static func makeSpans( + tokens: [String], + matches: [Bool] + ) -> [FeatureDiffTextSpan] { + var output: [FeatureDiffTextSpan] = [] + for (index, token) in tokens.enumerated() { + let isWhitespace = token.allSatisfy(\.isWhitespace) + let kind: FeatureDiffTextSpanKind = matches[index] || isWhitespace + ? .unchanged + : .changed + if output.last?.kind == kind { + output[output.count - 1].text += token + } else { + output.append(FeatureDiffTextSpan(text: token, kind: kind)) + } + } + return output + } + + private static func changed(_ text: String) -> [FeatureDiffTextSpan] { + [FeatureDiffTextSpan(text: text, kind: .changed)] + } + + private enum TokenClass { + case whitespace + case word + case punctuation + } +} + +public enum FeatureReviewLineSide: String, Sendable, Equatable, Hashable, Codable { + case old + case new +} + +public struct FeatureReviewLineSelection: Sendable, Equatable, Hashable, Codable { + public var side: FeatureReviewLineSide + public var line: Int + + public init(side: FeatureReviewLineSide, line: Int) { + self.side = side + self.line = line + } +} + +public struct FeatureReviewCommentDraft: Sendable, Equatable, Hashable { + public var filePath: String + public var line: FeatureReviewLineSelection? + public var body: String + + public init(filePath: String, line: FeatureReviewLineSelection? = nil, body: String) { + self.filePath = filePath + self.line = line + self.body = body + } + + public var prompt: String { + let location = line.map { " at \($0.side.rawValue) line \($0.line)" } ?? "" + return """ + Address this review comment in `\(filePath)`\(location): + + \(body.trimmingCharacters(in: .whitespacesAndNewlines)) + + Inspect the surrounding code, make the smallest correct change, and report what changed. + """ + } +} + +public enum FeatureReviewChangeKind: String, Sendable, Codable { + case added + case modified + case deleted + case renamed + case binary +} + +public struct FeatureReviewFile: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public var path: String + public var previousPath: String? + public var change: FeatureReviewChangeKind + public var additions: Int + public var deletions: Int + public var lines: [FeatureDiffLine] + public var sourceKind: String? + public var sourceBaseReference: String? + public var sourceHeadReference: String? + + public init( + path: String, + previousPath: String? = nil, + change: FeatureReviewChangeKind, + additions: Int, + deletions: Int, + lines: [FeatureDiffLine] = [], + sourceKind: String? = nil, + sourceBaseReference: String? = nil, + sourceHeadReference: String? = nil + ) { + self.path = path + self.previousPath = previousPath + self.change = change + self.additions = additions + self.deletions = deletions + self.lines = lines + self.sourceKind = sourceKind + self.sourceBaseReference = sourceBaseReference + self.sourceHeadReference = sourceHeadReference + } +} + +public struct FeatureReviewFileContents: Sendable, Equatable { + public var oldContents: String + public var newContents: String + + public init(oldContents: String, newContents: String) { + self.oldContents = oldContents + self.newContents = newContents + } +} + +enum FeatureFullDiffHydrator { + static func lines( + for file: FeatureReviewFile, + contents: FeatureReviewFileContents + ) -> [FeatureDiffLine] { + let oldLines = contentLines(contents.oldContents) + let newLines = contentLines(contents.newContents) + + switch file.change { + case .added: + return wholeFileLines(newLines, kind: .addition, side: .new, path: file.path) + case .deleted: + return wholeFileLines(oldLines, kind: .deletion, side: .old, path: file.path) + case .renamed where file.additions == 0 && file.deletions == 0: + return newLines.enumerated().map { index, text in + FeatureDiffLine( + id: "full-\(file.path)-\(index + 1)", + kind: .context, + oldLine: index + 1, + newLine: index + 1, + text: text + ) + } + case .modified, .renamed, .binary: + break + } + + let patchLines = file.lines.filter { $0.kind != .hunk } + guard !newLines.isEmpty else { return file.lines } + guard !patchLines.isEmpty else { + return newLines.enumerated().map { index, text in + FeatureDiffLine( + id: "full-\(file.path)-\(index + 1)", + kind: .context, + oldLine: oldLines.indices.contains(index) ? index + 1 : nil, + newLine: index + 1, + text: text + ) + } + } + + let anchors = patchLines.compactMap { line -> (new: Int, offset: Int)? in + guard let oldLine = line.oldLine, let newLine = line.newLine else { return nil } + return (newLine, oldLine - newLine) + } + // Anchors ascend by new-line number; binary search keeps hydration + // linear instead of scanning every anchor per emitted context line. + func oldLine(for newLine: Int) -> Int? { + guard !anchors.isEmpty else { return nil } + var low = 0 + var high = anchors.count + while low < high { + let mid = (low + high) / 2 + if anchors[mid].new <= newLine { + low = mid + 1 + } else { + high = mid + } + } + let anchor = low > 0 ? anchors[low - 1] : anchors[0] + return newLine + anchor.offset + } + + var output: [FeatureDiffLine] = [] + var nextNewLine = 1 + var precedingAnchorOffset: Int? + for (index, line) in patchLines.enumerated() { + if let newLine = line.newLine { + if nextNewLine < newLine { + for number in nextNewLine ..< newLine where newLines.indices.contains(number - 1) { + output.append( + FeatureDiffLine( + id: "full-\(file.path)-\(number)", + kind: .context, + oldLine: oldLine(for: number), + newLine: number, + text: newLines[number - 1] + ) + ) + } + } + output.append(line) + nextNewLine = max(nextNewLine, newLine + 1) + if let oldLine = line.oldLine { + precedingAnchorOffset = oldLine - newLine + } + } else { + let insertionLine: Int + if let oldLine = line.oldLine, let precedingAnchorOffset { + insertionLine = oldLine - precedingAnchorOffset + } else { + insertionLine = patchLines.dropFirst(index + 1).compactMap(\.newLine).first + ?? (newLines.count + 1) + } + if nextNewLine < insertionLine { + for number in nextNewLine ..< insertionLine + where newLines.indices.contains(number - 1) { + output.append( + FeatureDiffLine( + id: "full-\(file.path)-\(number)", + kind: .context, + oldLine: oldLine(for: number), + newLine: number, + text: newLines[number - 1] + ) + ) + } + nextNewLine = insertionLine + } + output.append(line) + } + } + if nextNewLine <= newLines.count { + for number in nextNewLine ... newLines.count { + output.append( + FeatureDiffLine( + id: "full-\(file.path)-\(number)", + kind: .context, + oldLine: oldLine(for: number), + newLine: number, + text: newLines[number - 1] + ) + ) + } + } + return output + } + + private enum Side: Equatable { case old, new } + + private static func wholeFileLines( + _ lines: [String], + kind: FeatureDiffLineKind, + side: Side, + path: String + ) -> [FeatureDiffLine] { + lines.enumerated().map { index, text in + let number = index + 1 + return FeatureDiffLine( + id: "full-\(path)-\(number)", + kind: kind, + oldLine: side == .old ? number : nil, + newLine: side == .new ? number : nil, + text: text + ) + } + } + + private static func contentLines(_ contents: String) -> [String] { + guard !contents.isEmpty else { return [] } + var lines = contents.components(separatedBy: "\n") + if lines.last?.isEmpty == true { lines.removeLast() } + return lines + } +} + +public struct FeatureReview: Sendable, Equatable, Codable { + public var title: String + public var baseReference: String? + public var files: [FeatureReviewFile] + public var isTruncated: Bool + + public init( + title: String = "Working tree", + baseReference: String? = nil, + files: [FeatureReviewFile] = [], + isTruncated: Bool = false + ) { + self.title = title + self.baseReference = baseReference + self.files = files + self.isTruncated = isTruncated + } + + public var additions: Int { files.reduce(0) { $0 + $1.additions } } + public var deletions: Int { files.reduce(0) { $0 + $1.deletions } } +} + +public enum FeatureSourceControlFileState: String, Sendable, Codable { + case added + case modified + case deleted + case renamed + case untracked + case conflicted +} + +public struct FeatureSourceControlFile: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public var path: String + public var state: FeatureSourceControlFileState + public var isStaged: Bool + + public init(path: String, state: FeatureSourceControlFileState, isStaged: Bool) { + self.path = path + self.state = state + self.isStaged = isStaged + } +} + +public struct FeaturePullRequest: Sendable, Equatable, Hashable, Codable { + public var number: Int + public var title: String + public var state: String + public var url: URL? + public var updatedAt: String? + + public init( + number: Int, + title: String, + state: String, + url: URL? = nil, + updatedAt: String? = nil + ) { + self.number = number + self.title = title + self.state = state + self.url = url + self.updatedAt = updatedAt + } +} + +public enum FeatureSourceControlAction: String, CaseIterable, Sendable, Codable { + case commit + case push + case pull + case createPullRequest + case commitAndPush + case commitPushAndCreatePullRequest +} + +public struct FeatureSourceControlStatus: Sendable, Equatable, Codable { + public var isRepository: Bool + public var branch: String? + public var upstream: String? + public var aheadCount: Int + public var behindCount: Int + /// `false` while the remote half of a streamed status is still pending, so + /// ahead/behind/pull-request fields are "not yet known" rather than zero. + public var isRemoteKnown: Bool + public var files: [FeatureSourceControlFile] + public var pullRequest: FeaturePullRequest? + public var isBusy: Bool + + public init( + isRepository: Bool = true, + branch: String? = nil, + upstream: String? = nil, + aheadCount: Int = 0, + behindCount: Int = 0, + isRemoteKnown: Bool = true, + files: [FeatureSourceControlFile] = [], + pullRequest: FeaturePullRequest? = nil, + isBusy: Bool = false + ) { + self.isRepository = isRepository + self.branch = branch + self.upstream = upstream + self.aheadCount = aheadCount + self.behindCount = behindCount + self.isRemoteKnown = isRemoteKnown + self.files = files + self.pullRequest = pullRequest + self.isBusy = isBusy + } + + public var availableActions: [FeatureSourceControlAction] { + guard isRepository, !isBusy else { return [] } + var actions: [FeatureSourceControlAction] = [] + if !files.isEmpty { + actions.append(.commit) + actions.append(.commitAndPush) + if isRemoteKnown, pullRequest == nil { + actions.append(.commitPushAndCreatePullRequest) + } + } + if aheadCount > 0 { actions.append(.push) } + if behindCount > 0 { actions.append(.pull) } + // Withheld until the remote half lands: offering it against an unknown + // remote can propose a second PR for a branch that already has one. + if isRemoteKnown, pullRequest == nil { actions.append(.createPullRequest) } + return actions + } +} + +public enum FeatureTerminalState: String, Sendable, Codable { + case stopped + case starting + case running + case exited + case failed +} + +public struct FeatureTerminalSnapshot: Sendable, Equatable, Codable { + public var threadID: String + public var terminalID: String + public var state: FeatureTerminalState + public var title: String + public var workingDirectory: String? + public var buffer: String + public var exitCode: Int? + public var error: String? + public var hasRunningSubprocess: Bool + public var updatedAt: String? + public var lifecycleVersion: Int + + public init( + threadID: String, + terminalID: String = "default", + state: FeatureTerminalState = .stopped, + title: String = "Terminal", + workingDirectory: String? = nil, + buffer: String = "", + exitCode: Int? = nil, + error: String? = nil, + hasRunningSubprocess: Bool = false, + updatedAt: String? = nil, + lifecycleVersion: Int = 0 + ) { + self.threadID = threadID + self.terminalID = terminalID + self.state = state + self.title = title + self.workingDirectory = workingDirectory + self.buffer = buffer + self.exitCode = exitCode + self.error = error + self.hasRunningSubprocess = hasRunningSubprocess + self.updatedAt = updatedAt + self.lifecycleVersion = lifecycleVersion + } + + private enum CodingKeys: String, CodingKey { + case threadID, terminalID, state, title, workingDirectory, buffer + case exitCode, error, hasRunningSubprocess, updatedAt, lifecycleVersion + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + threadID = try container.decode(String.self, forKey: .threadID) + terminalID = try container.decodeIfPresent(String.self, forKey: .terminalID) ?? "default" + state = try container.decodeIfPresent(FeatureTerminalState.self, forKey: .state) ?? .stopped + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "Terminal" + workingDirectory = try container.decodeIfPresent(String.self, forKey: .workingDirectory) + buffer = try container.decodeIfPresent(String.self, forKey: .buffer) ?? "" + exitCode = try container.decodeIfPresent(Int.self, forKey: .exitCode) + error = try container.decodeIfPresent(String.self, forKey: .error) + hasRunningSubprocess = try container.decodeIfPresent(Bool.self, forKey: .hasRunningSubprocess) ?? false + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + lifecycleVersion = try container.decodeIfPresent(Int.self, forKey: .lifecycleVersion) ?? 0 + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureToolRecovery.swift b/apps/swift-ios/Features/Shared/FeatureToolRecovery.swift new file mode 100644 index 000000000000..d1993e60b181 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureToolRecovery.swift @@ -0,0 +1,214 @@ +import Foundation + +/// An operation on a tool surface that can fail recoverably and be retried unchanged. +public protocol FeatureRecoverableOperation: Equatable, Sendable { + /// Headline shown on the retained failure banner, e.g. "Push failed". + var failureTitle: String { get } + /// Stable accessibility label for the Retry control, e.g. "Retry push". + var retryAccessibilityLabel: String { get } + /// Spoken confirmation once the same operation succeeds. + var recoveryAnnouncement: String { get } +} + +/// Failure content retained for a tool surface. It survives the retry it triggers so the +/// useful output is never blanked while recovery is in flight. +public struct FeatureToolFailure: Identifiable, Sendable, Equatable, Hashable { + /// Distinct per presented failure so a repeat failure can move accessibility focus again. + public let id: Int + public var title: String + public var message: String + public var retryAccessibilityLabel: String + public var isRetrying: Bool + + public init( + id: Int, + title: String, + message: String, + retryAccessibilityLabel: String, + isRetrying: Bool = false + ) { + self.id = id + self.title = title + self.message = message + self.retryAccessibilityLabel = retryAccessibilityLabel + self.isRetrying = isRetrying + } + + /// Single spoken string so VoiceOver reads the retained content when focus lands on it. + public var accessibilityLabel: String { + isRetrying ? "\(title). \(message). Retrying." : "\(title). \(message)" + } +} + +/// Where accessibility focus belongs after a tool surface changes recovery state. +public enum FeatureToolRecoveryFocus: Hashable, Sendable { + /// The retained failure summary, read together with its content. + case failure + /// The first element of the recovered content. + case recoveredContent +} + +/// Recovery state for one tool surface: keeps failure content visible across retries, refuses +/// to report cancellation as a failure, and names a predictable accessibility focus target. +public struct FeatureToolFailureState: Sendable, Equatable { + public private(set) var failure: FeatureToolFailure? + /// The exact operation to run again, including any input the failed attempt carried. + public private(set) var retryOperation: Operation? + /// Set once when an operation recovers, so the surface can announce it exactly once. + public private(set) var recoveryAnnouncement: String? + private var presentedFailureCount = 0 + + public init() {} + + /// Marks an attempt as started. Retrying the failed operation keeps its content on screen + /// instead of blanking it; unrelated work leaves the retained failure untouched. + public mutating func begin(_ operation: Operation) { + recoveryAnnouncement = nil + guard failure != nil else { return } + failure?.isRetrying = retryOperation == operation + } + + /// Records the outcome of a failed attempt. Cancellation is not a failure: it never creates + /// one and never overwrites content already on screen. + public mutating func recordFailure(_ operation: Operation, error: Error) { + guard !Self.isCancellation(error) else { + failure?.isRetrying = false + return + } + presentedFailureCount += 1 + failure = FeatureToolFailure( + id: presentedFailureCount, + title: operation.failureTitle, + message: Self.message(for: error), + retryAccessibilityLabel: operation.retryAccessibilityLabel + ) + retryOperation = operation + } + + /// Records successful work. One completion can satisfy related retained operations, such as + /// an action whose explicit follow-up refresh also recovers an earlier load failure. + public mutating func recordSuccess(_ operations: Operation...) { + guard + failure != nil, + let recoveredOperation = retryOperation, + operations.contains(recoveredOperation) + else { + recoveryAnnouncement = nil + return + } + failure = nil + retryOperation = nil + recoveryAnnouncement = recoveredOperation.recoveryAnnouncement + } + + /// Records a follow-up failure after an operation already completed. A real failure becomes + /// the only retryable operation; cancellation stays silent and removes the completed work. + public mutating func recordFollowUpFailure( + _ followUpOperation: Operation, + afterCompletionOf completedOperation: Operation, + error: Error + ) { + guard Self.isCancellation(error) else { + recordFailure(followUpOperation, error: error) + return + } + guard retryOperation == completedOperation else { return } + failure = nil + retryOperation = nil + recoveryAnnouncement = nil + } + + /// Consumes the pending announcement so recovery is never spoken twice. + public mutating func takeRecoveryAnnouncement() -> String? { + defer { recoveryAnnouncement = nil } + return recoveryAnnouncement + } + + public var focusTarget: FeatureToolRecoveryFocus { + failure == nil ? .recoveredContent : .failure + } + + /// A dismissed sheet, a cancelled refresh, or a superseded request must not read as a failure. + public static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { return true } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true } + if nsError.domain == NSCocoaErrorDomain, nsError.code == NSUserCancelledError { return true } + return false + } + + private static func message(for error: Error) -> String { + let described = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + return described.isEmpty ? "The operation could not be completed." : described + } +} + +/// Single-flight ownership for a tool surface. A second request cannot mutate recovery state +/// while the operation that acquired the surface is still running. +public struct FeatureToolRunState: Sendable, Equatable { + public private(set) var operation: Operation? + + public init() {} + + public var isBusy: Bool { + operation != nil + } + + public mutating func begin(_ operation: Operation) -> Bool { + guard self.operation == nil else { return false } + self.operation = operation + return true + } + + public mutating func finish(_ operation: Operation) { + guard self.operation == operation else { return } + self.operation = nil + } +} + +/// The retryable work of the source control surface. `action` carries the commit message so a +/// retry never asks for it again. +public enum FeatureSourceControlOperation: FeatureRecoverableOperation { + case load + case action(FeatureSourceControlAction, message: String?) + + public var isLoad: Bool { + if case .load = self { return true } + return false + } + + public var failureTitle: String { + switch self { + case .load: "Repository status failed to load" + case .action(let action, _): "\(action.title) failed" + } + } + + public var retryAccessibilityLabel: String { + switch self { + case .load: "Retry loading repository status" + case .action(let action, _): "Retry \(action.title.lowercased())" + } + } + + public var recoveryAnnouncement: String { + switch self { + case .load: "Repository status loaded." + case .action(let action, _): "\(action.title) succeeded. Repository status updated." + } + } +} + +public extension FeatureSourceControlAction { + var title: String { + switch self { + case .commit: "Commit changes" + case .push: "Push" + case .pull: "Pull latest" + case .createPullRequest: "Create pull request" + case .commitAndPush: "Commit and push" + case .commitPushAndCreatePullRequest: "Commit, push, and create PR" + } + } +} diff --git a/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift b/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift new file mode 100644 index 000000000000..4e530c8c1005 --- /dev/null +++ b/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift @@ -0,0 +1,171 @@ +import Foundation + +public struct FeatureUploadedAttachmentReference: Sendable, Equatable, Codable { + public var environmentID: String + public var attachmentID: String + + public init(environmentID: String, attachmentID: String) { + self.environmentID = environmentID + self.attachmentID = attachmentID + } +} + +public struct FeatureOwnedAttachmentFile: Sendable, Equatable { + public let fileName: String + public let url: URL + public let byteCount: Int + + public init(fileName: String, url: URL, byteCount: Int) { + self.fileName = fileName + self.url = url + self.byteCount = byteCount + } +} + +public enum ManagedAttachmentFileError: LocalizedError, Equatable, Sendable { + case invalidSource + case invalidFileName + case empty + case tooLarge(actualBytes: Int, maximumBytes: Int) + case alreadyExists + + public var errorDescription: String? { + switch self { + case .invalidSource: + "The selected attachment is not a local file." + case .invalidFileName: + "The attachment file name is invalid." + case .empty: + "The selected attachment is empty." + case let .tooLarge(actualBytes, maximumBytes): + "The attachment is \(actualBytes) bytes. T3 accepts up to \(maximumBytes) bytes." + case .alreadyExists: + "An owned attachment already exists for this ID." + } + } +} + +/// Owns copies of provider files so drafts and outbox entries do not depend on +/// temporary document-picker URLs. It never removes the provider-owned source. +public struct ManagedAttachmentFileStore: Sendable { + public static let maximumBytes = 50 * 1024 * 1024 + private static let chunkBytes = 256 * 1024 + + public let rootURL: URL + + public init(rootURL: URL? = nil) { + if let rootURL { + self.rootURL = rootURL.standardizedFileURL + } else { + let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.rootURL = applicationSupport + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("attachments", isDirectory: true) + .standardizedFileURL + } + } + + public func copyOwnedFile( + from sourceURL: URL, + attachmentID: UUID, + originalFileName: String, + maximumBytes: Int = Self.maximumBytes + ) throws -> FeatureOwnedAttachmentFile { + guard sourceURL.isFileURL else { throw ManagedAttachmentFileError.invalidSource } + let effectiveMaximumBytes = min(Self.maximumBytes, max(0, maximumBytes)) + let fileName = try Self.ownedFileName( + attachmentID: attachmentID, + originalFileName: originalFileName + ) + let destination = try resolvedFile(fileName: fileName, byteCount: 0).url + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw ManagedAttachmentFileError.alreadyExists + } + + let hasSecurityAccess = sourceURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityAccess { sourceURL.stopAccessingSecurityScopedResource() } + } + + let source = try FileHandle(forReadingFrom: sourceURL) + defer { try? source.close() } + guard FileManager.default.createFile(atPath: destination.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + let output = try FileHandle(forWritingTo: destination) + var copiedBytes = 0 + var completed = false + defer { + try? output.close() + if !completed { try? FileManager.default.removeItem(at: destination) } + } + + while let chunk = try source.read(upToCount: Self.chunkBytes), !chunk.isEmpty { + copiedBytes += chunk.count + guard copiedBytes <= effectiveMaximumBytes else { + throw ManagedAttachmentFileError.tooLarge( + actualBytes: copiedBytes, + maximumBytes: effectiveMaximumBytes + ) + } + try output.write(contentsOf: chunk) + } + guard copiedBytes > 0 else { throw ManagedAttachmentFileError.empty } + completed = true + return FeatureOwnedAttachmentFile( + fileName: fileName, + url: destination, + byteCount: copiedBytes + ) + } + + public func resolvedFile(fileName: String, byteCount: Int) throws + -> FeatureOwnedAttachmentFile + { + guard Self.isValidOwnedFileName(fileName) else { + throw ManagedAttachmentFileError.invalidFileName + } + let url = rootURL.appendingPathComponent(fileName, isDirectory: false).standardizedFileURL + guard url.deletingLastPathComponent() == rootURL else { + throw ManagedAttachmentFileError.invalidFileName + } + return FeatureOwnedAttachmentFile(fileName: fileName, url: url, byteCount: byteCount) + } + + /// Removes one known owned file. Callers must coordinate ownership before + /// they use this helper because drafts and outbox entries can share a file. + public func removeOwnedFile(fileName: String) throws { + let file = try resolvedFile(fileName: fileName, byteCount: 0) + guard FileManager.default.fileExists(atPath: file.url.path) else { return } + try FileManager.default.removeItem(at: file.url) + } + + private static func ownedFileName( + attachmentID: UUID, + originalFileName: String + ) throws -> String { + let pathExtension = URL(fileURLWithPath: originalFileName).pathExtension.lowercased() + let safeExtension = pathExtension.filter { + $0.isASCII && ($0.isLetter || $0.isNumber) + } + guard safeExtension.count <= 16 else { throw ManagedAttachmentFileError.invalidFileName } + return safeExtension.isEmpty + ? attachmentID.uuidString + : "\(attachmentID.uuidString).\(safeExtension)" + } + + private static func isValidOwnedFileName(_ fileName: String) -> Bool { + guard fileName == URL(fileURLWithPath: fileName).lastPathComponent else { return false } + let url = URL(fileURLWithPath: fileName) + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) != nil else { + return false + } + let pathExtension = url.pathExtension + return pathExtension.count <= 16 + && pathExtension.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber) } + } +} diff --git a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift new file mode 100644 index 000000000000..82ae6a2345ac --- /dev/null +++ b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift @@ -0,0 +1,433 @@ +import SwiftUI + +@MainActor +func runFeatureSourceControlAction( + setRunning: (Bool) -> Void, + operation: () async throws -> Value +) async -> Result { + setRunning(true) + defer { setRunning(false) } + + do { + return .success(try await operation()) + } catch { + return .failure(error) + } +} + +public struct FeatureSourceControlView: View { + let client: any FeatureClient + let threadID: String + + @State private var status: FeatureSourceControlStatus? + @State private var isLoading = true + @State private var isRunningAction = false + @State private var runState = FeatureToolRunState() + @State private var recovery = FeatureToolFailureState() + @State private var errorMessage: String? + @State private var loadGeneration = 0 + @State private var statusGeneration = 0 + @State private var commitMessage = "" + @State private var pendingCommitAction: FeatureSourceControlAction? + @AccessibilityFocusState private var recoveryFocus: FeatureToolRecoveryFocus? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + VStack(spacing: 0) { + if let failure = recovery.failure { + failureBanner(failure) + } + Group { + if isLoading, status == nil { + ProgressView("Loading repository…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let status, status.isRepository { + statusList(status) + } else { + ContentUnavailableView( + "Source control unavailable", + systemImage: "arrow.triangle.branch", + description: Text( + status?.isRepository == false + ? "This workspace is not a Git repository." + : "Repository status could not be loaded." + ) + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(T3Colors.background) + .navigationTitle("Source Control") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await reload() } } label: { + if isLoading { + ProgressView() + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(runState.isBusy) + .accessibilityLabel("Reload source control") + } + } + .alert("Commit changes", isPresented: Binding( + get: { pendingCommitAction != nil }, + set: { if !$0 { pendingCommitAction = nil } } + )) { + TextField("Commit message", text: $commitMessage) + Button("Cancel", role: .cancel) { pendingCommitAction = nil } + Button("Commit") { + if let action = pendingCommitAction { + Task { await perform(action, message: commitMessage) } + } + pendingCommitAction = nil + } + .disabled( + commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || runState.isBusy + ) + } + .onChange(of: recovery.failure?.id) { _, failureID in + guard failureID != nil else { return } + recoveryFocus = .failure + } + .onChange(of: recovery.recoveryAnnouncement) { _, _ in + guard let announcement = recovery.takeRecoveryAnnouncement() else { return } + recoveryFocus = .recoveredContent + AccessibilityNotification.Announcement(announcement).post() + } + .task { await load() } + } + + /// Keeps the failed output on screen — including while its retry runs — with a labelled + /// Retry control immediately after it in the accessibility order. + private func failureBanner(_ failure: FeatureToolFailure) -> some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { + Label(failure.title, systemImage: "exclamationmark.triangle.fill") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.danger) + ScrollView { + Text(failure.message) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: T3Metrics.maximumToolFailureMessageHeight) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(failure.accessibilityLabel) + .accessibilityIdentifier("source-control-failure") + .accessibilityFocused($recoveryFocus, equals: .failure) + + HStack(spacing: 10) { + Button { + guard let operation = recovery.retryOperation else { return } + Task { await run(operation) } + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .font(T3Typography.control) + .frame(minHeight: T3Metrics.minimumTapTarget) + } + .buttonStyle(.borderedProminent) + .disabled(failure.isRetrying || runState.isBusy) + .accessibilityLabel(failure.retryAccessibilityLabel) + .accessibilityIdentifier("source-control-failure-retry") + + if failure.isRetrying { + ProgressView() + Text("Retrying…") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.surfaceRaised, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(T3Colors.danger.opacity(0.4), lineWidth: 1) + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + + private func statusList(_ status: FeatureSourceControlStatus) -> some View { + List { + // Once a status is on screen the unavailable-state view is + // unreachable, so a later failure needs its own inline surface. + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(T3Typography.supporting) + .foregroundStyle(.orange) + } + } + + Section("Repository") { + LabeledContent("Branch", value: status.branch ?? "Detached HEAD") + .accessibilityFocused($recoveryFocus, equals: .recoveredContent) + if let upstream = status.upstream { + LabeledContent("Upstream", value: upstream) + } + if status.isRemoteKnown { + HStack { + Label("\(status.aheadCount) ahead", systemImage: "arrow.up") + Spacer() + Label("\(status.behindCount) behind", systemImage: "arrow.down") + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } else { + // Only claim to be checking while something actually is. + Label( + isLoading ? "Checking remote…" : "Remote status unavailable", + systemImage: isLoading + ? "arrow.triangle.2.circlepath" + : "exclamationmark.triangle" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + if let pullRequest = status.pullRequest { + if let url = pullRequest.url { + Link(destination: url) { + Label("PR #\(pullRequest.number) · \(pullRequest.title)", systemImage: "arrow.up.right.square") + } + } else { + LabeledContent("Pull Request", value: "#\(pullRequest.number) · \(pullRequest.state)") + } + } + } + + Section("Actions") { + if status.availableActions.isEmpty { + Text(status.isBusy ? "Source control operation in progress" : "No actions available") + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(status.availableActions, id: \.self) { action in + Button { + begin(action) + } label: { + Label(action.title, systemImage: action.icon) + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(runState.isBusy) + } + } + + Section("\(status.files.count) changed \(status.files.count == 1 ? "file" : "files")") { + if status.files.isEmpty { + Label("Working tree clean", systemImage: "checkmark.circle") + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(status.files) { file in + HStack(spacing: 10) { + Text(file.state.shortLabel) + .font(.caption2.monospaced().weight(.bold)) + .foregroundStyle(file.state.color) + .frame(width: 18) + Text(file.path) + .font(T3Typography.threadBody) + .lineLimit(1) + Spacer() + if file.isStaged { + Text("STAGED") + .font(T3Typography.eyebrow) + .foregroundStyle(.green) + } + } + .accessibilityElement(children: .combine) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .refreshable { await reload() } + .overlay { + if isRunningAction { + ProgressView() + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + private func begin(_ action: FeatureSourceControlAction) { + if action.requiresMessage { + commitMessage = "" + pendingCommitAction = action + } else { + Task { await perform(action, message: nil) } + } + } + + /// Cached loading can be replaced by an action or an explicit refresh. + private func load() async { + await load(force: false) + } + + private func reload() async { + await load(force: true) + } + + private func load(force: Bool) async { + guard !runState.isBusy else { return } + if force, !runState.begin(.load) { return } + loadGeneration += 1 + statusGeneration += 1 + let loadID = loadGeneration + let statusID = statusGeneration + isLoading = true + recovery.begin(.load) + defer { + if loadID == loadGeneration { isLoading = false } + if force { runState.finish(.load) } + } + do { + try Task.checkCancellation() + if force { + let refreshed = try await client.sourceControlStatus(threadID: threadID) + guard statusID == statusGeneration else { return } + status = refreshed + errorMessage = nil + recovery.recordSuccess(.load) + } else { + let statuses = try await client.sourceControlStatuses(threadID: threadID) + for try await nextStatus in statuses { + guard statusID == statusGeneration else { return } + status = nextStatus + errorMessage = nil + if nextStatus.isRemoteKnown { recovery.recordSuccess(.load) } + } + } + } catch { + guard statusID == statusGeneration else { return } + if FeatureToolFailureState.isCancellation(error) { + recovery.recordFailure(.load, error: error) + return + } + // An unrelated status failure must not discard a failed action's retry. + if let retained = recovery.retryOperation, !retained.isLoad { + errorMessage = error.localizedDescription + } else { + recovery.recordFailure(.load, error: error) + } + guard !force, status?.isRemoteKnown == false else { return } + if loadID == loadGeneration { isLoading = false } + for await recoveredStatus in client.sourceControlStatusEvents(threadID: threadID) { + guard statusID == statusGeneration else { return } + status = recoveredStatus + if recoveredStatus.isRemoteKnown { + errorMessage = nil + recovery.recordSuccess(.load) + return + } + } + } + } + + private func perform(_ action: FeatureSourceControlAction, message: String?) async { + await run( + .action(action, message: message?.trimmingCharacters(in: .whitespacesAndNewlines)) + ) + } + + /// Mutations finish before refresh so Retry cannot repeat completed work. + private func run(_ operation: FeatureSourceControlOperation) async { + guard case let .action(action, message) = operation else { + await reload() + return + } + guard runState.begin(operation) else { return } + loadGeneration += 1 + statusGeneration += 1 + isLoading = false + recovery.begin(operation) + let result = await runFeatureSourceControlAction( + setRunning: { isRunningAction = $0 } + ) { + try await client.performSourceControlAction( + threadID: threadID, + action: action, + message: message + ) + } + var shouldRecoverStatus = false + switch result { + case .success: + do { + status = try await client.sourceControlStatus(threadID: threadID) + errorMessage = nil + recovery.recordSuccess(operation, .load) + } catch { + recovery.recordFollowUpFailure( + .load, + afterCompletionOf: operation, + error: error + ) + } + case let .failure(error): + recovery.recordFailure(operation, error: error) + shouldRecoverStatus = !FeatureToolFailureState + .isCancellation(error) + } + runState.finish(operation) + if shouldRecoverStatus { + await load(force: false) + } + } +} + +private extension FeatureSourceControlAction { + var requiresMessage: Bool { + switch self { + case .commit, .commitAndPush, .commitPushAndCreatePullRequest: true + case .push, .pull, .createPullRequest: false + } + } + + var icon: String { + switch self { + case .commit: "checkmark.circle" + case .push: "arrow.up.circle" + case .pull: "arrow.down.circle" + case .createPullRequest: "arrow.triangle.pull" + case .commitAndPush: "arrow.up.circle.fill" + case .commitPushAndCreatePullRequest: "point.3.connected.trianglepath.dotted" + } + } +} + +private extension FeatureSourceControlFileState { + var shortLabel: String { + switch self { + case .added: "A" + case .modified: "M" + case .deleted: "D" + case .renamed: "R" + case .untracked: "?" + case .conflicted: "!" + } + } + + var color: Color { + switch self { + case .added: .green + case .modified: .orange + case .deleted, .conflicted: .red + case .renamed: .blue + case .untracked: .secondary + } + } +} diff --git a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift new file mode 100644 index 000000000000..1299d80e4a98 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -0,0 +1,671 @@ +import SwiftUI + +private enum TerminalFontSize { + static let minimum = 6.0 + static let maximum = 32.0 + static let step = 0.5 + static let defaultValue = 10.5 + + static func normalized(_ value: Double) -> Double { + min(maximum, max(minimum, value)) + } +} + +enum TerminalSessionList { + static func initialID(in sessions: [FeatureTerminalSnapshot]) -> String { + let running = sessions.filter { $0.state == .running || $0.state == .starting } + return running.first(where: { $0.terminalID == "default" })?.terminalID + ?? running.first?.terminalID + ?? "default" + } + + static func nextID(occupiedIDs: [String]) -> String { + let occupied = Set(occupiedIDs) + guard occupied.contains("default") else { return "default" } + var index = 2 + while occupied.contains("term-\(index)") { index += 1 } + return "term-\(index)" + } + + static func fallbackID( + in sessions: [FeatureTerminalSnapshot], + excluding terminalID: String + ) -> String? { + sessions.first { + $0.terminalID != terminalID && ($0.state == .running || $0.state == .starting) + }?.terminalID + } + + static func displayTitle(for session: FeatureTerminalSnapshot) -> String { + let number: Int + if session.terminalID == "default" { + number = 1 + } else if session.terminalID.hasPrefix("term-"), + let parsed = Int(session.terminalID.dropFirst("term-".count)) { + number = parsed + } else { + return session.title + } + + let shell = session.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !shell.isEmpty, shell.caseInsensitiveCompare("Terminal") != .orderedSame else { + return "Terminal \(number)" + } + return "Terminal \(number) · \(shell)" + } +} + +/// Serializes input for one attached terminal. A session change drops unsent +/// chunks. A new paste also replaces the unsent part of an older paste. +/// Keys waiting for a response share one batch, so typing does not wait once per key. +@MainActor +final class TerminalInputSession { + struct Target: Equatable, Sendable { + let threadID: String + let terminalID: String + let lifecycleVersion: Int + } + + private final class KeyBatch { + var data: String + + init(_ data: String) { + self.data = data + } + } + + private var target: Target? + private var isAttached = false + private var generation: UInt64 = 0 + private var latestPasteRequest: UInt64 = 0 + private var writeTail: Task? + private var pendingKeys: KeyBatch? + + func attach(to target: Target?) { + isAttached = true + updateTarget(target) + } + + func updateTarget(_ target: Target?) { + guard self.target != target else { return } + self.target = target + pendingKeys = nil + generation &+= 1 + } + + func detach() { + isAttached = false + pendingKeys = nil + generation &+= 1 + } + + @discardableResult + func enqueue( + _ data: String, + target: Target, + isPaste: Bool = false, + write: @escaping @MainActor (String) async -> Bool + ) -> Task? { + guard isAttached, self.target == target, !data.isEmpty else { return nil } + let keyBatch: KeyBatch? + if isPaste { + latestPasteRequest &+= 1 + pendingKeys = nil + keyBatch = nil + } else if let pendingKeys, let writeTail { + pendingKeys.data.append(data) + return writeTail + } else { + keyBatch = KeyBatch(data) + pendingKeys = keyBatch + } + let pasteRequest = isPaste ? latestPasteRequest : nil + let generation = generation + let previousWrite = writeTail + let task = Task { @MainActor [weak self] in + _ = await previousWrite?.value + guard let self, + self.isCurrent(target: target, generation: generation, pasteRequest: pasteRequest) else { + return false + } + if let keyBatch, self.pendingKeys === keyBatch { + self.pendingKeys = nil + } + let encoded = isPaste ? TerminalInputEncoder.paste(data) : keyBatch?.data ?? data + for chunk in TerminalInputEncoder.chunks(encoded) { + guard self.isCurrent(target: target, generation: generation, pasteRequest: pasteRequest), + await write(chunk) else { + return false + } + } + return true + } + writeTail = task + return task + } + + private func isCurrent(target: Target, generation: UInt64, pasteRequest: UInt64?) -> Bool { + isAttached && self.target == target && self.generation == generation + && (pasteRequest == nil || pasteRequest == latestPasteRequest) + } +} + +public struct FeatureTerminalView: View { + let client: any FeatureClient + let threadID: String + + @SwiftUI.Environment(\.dismiss) private var dismiss + @AppStorage("terminalFontSize") private var storedFontSize = TerminalFontSize.defaultValue + @State private var terminal: FeatureTerminalSnapshot? + @State private var sessions = [FeatureTerminalSnapshot]() + @State private var activeTerminalID = "default" + @State private var resolvedThreadID: String? + @State private var columns = 80 + @State private var rows = 24 + @State private var focusRequest = 0 + @State private var surfaceGeneration = 0 + @State private var isLoading = true + @State private var isOpening = false + @State private var errorMessage: String? + @State private var inputSession = TerminalInputSession() + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + let target = inputTarget + ZStack { + T3Colors.background + + GhosttyTerminalSurface( + terminalKey: "\(threadID):\(activeTerminalID)", + lifecycleVersion: terminal?.lifecycleVersion ?? 0, + buffer: terminal?.buffer ?? "", + fontSize: CGFloat(fontSize), + isRunning: isRunning, + hostPlatform: TerminalHostPlatform(os: client.terminalHostOS(threadID: threadID)), + focusRequest: focusRequest, + onInput: { data in + _ = enqueueInput(data, target: target) + }, + onPaste: { data in + _ = enqueueInput(data, target: target, isPaste: true) + }, + onResize: { nextColumns, nextRows in + updateGrid(columns: nextColumns, rows: nextRows) + }, + onClear: { + let terminalID = activeTerminalID + Task { await clear(terminalID: terminalID) } + }, + onFontSizeStep: { direction in + stepFontSize(direction) + } + ) + .id("\(terminalTaskID):\(fontSize):\(surfaceGeneration)") + .padding(.top, 48) + + if isLoading, terminal == nil { + ProgressView("Opening terminal…") + .tint(T3Colors.textPrimary) + .foregroundStyle(T3Colors.textPrimary) + } else if let errorMessage, terminal == nil { + ContentUnavailableView( + "Terminal unavailable", + systemImage: "terminal", + description: Text(errorMessage) + ) + .foregroundStyle(T3Colors.textPrimary) + } + + if let errorMessage, terminal != nil { + VStack { + Spacer() + Text(errorMessage) + .font(T3Typography.supporting) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.red.opacity(0.88)) + .accessibilityLabel("Terminal error: \(errorMessage)") + } + } + + terminalHeader + } + .background(T3Colors.background.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .onAppear { inputSession.attach(to: inputTarget) } + .onDisappear { inputSession.detach() } + .onChange(of: threadID) { _, _ in + updateTerminal(nil) + sessions = [] + activeTerminalID = "default" + resolvedThreadID = nil + errorMessage = nil + } + .task(id: threadID) { + inputSession.updateTarget(inputTarget) + for await updates in client.terminalSessions(threadID: threadID) { + guard !Task.isCancelled else { return } + sessions = updates + if !sessionsResolved { + activeTerminalID = TerminalSessionList.initialID(in: updates) + resolvedThreadID = threadID + } + } + guard !Task.isCancelled else { return } + resolvedThreadID = threadID + } + .task(id: terminalTaskID) { + guard sessionsResolved else { return } + await loadAndOpen() + } + .task(id: terminalTaskID) { + guard sessionsResolved else { return } + let terminalID = activeTerminalID + for await update in client.terminalEvents( + threadID: threadID, + terminalID: terminalID + ) { + guard !Task.isCancelled, terminalID == activeTerminalID else { break } + let shouldSyncGrid = !isRunning + && (update.state == .running || update.state == .starting) + let currentBuffer = terminal?.buffer + guard updateTerminal(update) else { continue } + if let currentBuffer, !update.buffer.hasPrefix(currentBuffer) { + surfaceGeneration += 1 + } + if shouldSyncGrid { + try? await client.resizeTerminal( + threadID: threadID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + } + if update.state == .running { + errorMessage = nil + } else if update.state == .failed, let error = update.error { + errorMessage = error + } + } + } + } + + private var terminalHeader: some View { + VStack(spacing: 0) { + ZStack { + Text("Terminal") + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .frame(width: 44, height: 44) + } + .foregroundStyle(T3Colors.textPrimary) + .accessibilityLabel("Close terminal") + + Spacer() + + terminalMenu + .frame(width: 44, height: 44) + } + } + .frame(height: 48) + .background(T3Colors.background) + + Spacer(minLength: 0) + } + } + + private var terminalMenu: some View { + Menu { + Section { + Label(statusLabel, systemImage: statusSymbol) + if let workingDirectory = terminal?.workingDirectory { + Text(workingDirectory) + } + } + + Section("Sessions") { + ForEach(menuSessions, id: \.terminalID) { session in + Button { + selectTerminal(session.terminalID) + } label: { + Label( + TerminalSessionList.displayTitle(for: session), + systemImage: session.terminalID == activeTerminalID + ? "checkmark" + : "terminal" + ) + } + } + + Button { + openNewTerminal() + } label: { + Label("Open new terminal", systemImage: "plus") + } + } + + Section { + Menu { + Button { + stepFontSize(-1) + } label: { + Label( + "Smaller · \(formattedFontSize(fontSize - TerminalFontSize.step)) pt", + systemImage: "textformat.size.smaller" + ) + } + .disabled(fontSize <= TerminalFontSize.minimum) + + Button { + stepFontSize(1) + } label: { + Label( + "Larger · \(formattedFontSize(fontSize + TerminalFontSize.step)) pt", + systemImage: "textformat.size.larger" + ) + } + .disabled(fontSize >= TerminalFontSize.maximum) + } label: { + Label("Text size · \(formattedFontSize(fontSize)) pt", systemImage: "textformat.size") + } + + Button { + let terminalID = activeTerminalID + Task { await clear(terminalID: terminalID) } + } label: { + Label("Clear", systemImage: "eraser") + } + .disabled(terminal == nil) + } + + Section { + if isRunning { + Button(role: .destructive) { + Task { await stop() } + } label: { + Label("Stop terminal", systemImage: "stop.fill") + } + } else { + Button { + Task { await open() } + } label: { + Label("Start terminal", systemImage: "play.fill") + } + .disabled(isLoading || isOpening) + } + } + } label: { + Image(systemName: "terminal") + } + .accessibilityLabel("Terminal options") + } + + private var fontSize: Double { + TerminalFontSize.normalized(storedFontSize) + } + + private var terminalTaskID: String { + "\(threadID):\(sessionsResolved):\(activeTerminalID)" + } + + private var sessionsResolved: Bool { + resolvedThreadID == threadID + } + + private var inputTarget: TerminalInputSession.Target? { + guard let terminal, isRunning, + terminal.threadID == threadID, terminal.terminalID == activeTerminalID else { + return nil + } + return .init( + threadID: threadID, + terminalID: activeTerminalID, + lifecycleVersion: terminal.lifecycleVersion + ) + } + + @discardableResult + private func updateTerminal(_ snapshot: FeatureTerminalSnapshot?) -> Bool { + if let snapshot { + guard snapshot.threadID == threadID, snapshot.terminalID == activeTerminalID else { + return false + } + if let terminal, + terminal.threadID == snapshot.threadID, terminal.terminalID == snapshot.terminalID, + snapshot.lifecycleVersion < terminal.lifecycleVersion { + return false + } + } + terminal = snapshot + inputSession.updateTarget(inputTarget) + return true + } + + private var menuSessions: [FeatureTerminalSnapshot] { + var visible = sessions.filter { + $0.state == .running || $0.state == .starting || $0.terminalID == activeTerminalID + } + if let terminal, + !visible.contains(where: { $0.terminalID == terminal.terminalID }) { + visible.append(terminal) + } + return visible.sorted { + $0.terminalID.localizedStandardCompare($1.terminalID) == .orderedAscending + } + } + + private var isRunning: Bool { + terminal?.state == .running || terminal?.state == .starting + } + + private var statusLabel: String { + switch terminal?.state { + case .running: terminal?.hasRunningSubprocess == true ? "Task running" : "Ready" + case .starting: "Starting" + case .failed: "Error" + case .exited: "Exited" + case .stopped, nil: "Not started" + } + } + + private var statusSymbol: String { + switch terminal?.state { + case .running: "checkmark.circle.fill" + case .starting: "clock.fill" + case .failed: "exclamationmark.triangle.fill" + case .exited: "xmark.circle.fill" + case .stopped, nil: "circle" + } + } + + private func formattedFontSize(_ value: Double) -> String { + String(format: "%.1f", TerminalFontSize.normalized(value)) + } + + private func stepFontSize(_ direction: Int) { + storedFontSize = TerminalFontSize.normalized( + fontSize + Double(direction) * TerminalFontSize.step + ) + } + + private func selectTerminal(_ terminalID: String) { + guard terminalID != activeTerminalID else { return } + updateTerminal(nil) + errorMessage = nil + activeTerminalID = terminalID + } + + private func openNewTerminal() { + let nextID = TerminalSessionList.nextID( + occupiedIDs: sessions.map(\.terminalID) + [activeTerminalID] + ) + updateTerminal(nil) + errorMessage = nil + activeTerminalID = nextID + } + + private func updateGrid(columns nextColumns: Int, rows nextRows: Int) { + guard nextColumns != columns || nextRows != rows else { return } + columns = nextColumns + rows = nextRows + guard isRunning else { return } + let terminalID = activeTerminalID + Task { + do { + try await client.resizeTerminal( + threadID: threadID, + terminalID: terminalID, + columns: nextColumns, + rows: nextRows + ) + } catch { + if terminalID == activeTerminalID { + errorMessage = error.localizedDescription + } + } + } + } + + private func loadAndOpen() async { + let terminalID = activeTerminalID + isLoading = true + defer { isLoading = false } + do { + let snapshot = try await client.terminalSnapshot( + threadID: threadID, + terminalID: terminalID + ) + guard !Task.isCancelled, terminalID == activeTerminalID else { return } + guard updateTerminal(snapshot) else { return } + if snapshot.state == .stopped || snapshot.state == .exited { + try await openTerminal(terminalID: terminalID) + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func open() async { + guard !isOpening else { return } + isOpening = true + defer { isOpening = false } + do { + try await openTerminal(terminalID: activeTerminalID) + focusRequest += 1 + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func openTerminal(terminalID: String) async throws { + inputSession.updateTarget(nil) + try await client.openTerminal( + threadID: threadID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + guard !Task.isCancelled, terminalID == activeTerminalID else { return } + let snapshot = try await client.terminalSnapshot( + threadID: threadID, + terminalID: terminalID + ) + guard !Task.isCancelled, terminalID == activeTerminalID else { return } + updateTerminal(snapshot) + } + + private func stop() async { + let terminalID = activeTerminalID + inputSession.updateTarget(nil) + let fallbackID = TerminalSessionList.fallbackID( + in: sessions, + excluding: terminalID + ) + do { + try await client.closeTerminal(threadID: threadID, terminalID: terminalID) + guard terminalID == activeTerminalID else { return } + if let fallbackID { + selectTerminal(fallbackID) + } else { + let snapshot = try? await client.terminalSnapshot( + threadID: threadID, + terminalID: terminalID + ) + guard terminalID == activeTerminalID else { return } + updateTerminal(snapshot) + } + errorMessage = nil + } catch { + if terminalID == activeTerminalID { + inputSession.updateTarget(inputTarget) + errorMessage = error.localizedDescription + } + } + } + + private func clear(terminalID: String) async { + let target = inputTarget + do { + if terminalID == activeTerminalID { + terminal?.buffer = "" + surfaceGeneration += 1 + } + try await client.clearTerminal( + threadID: threadID, + terminalID: terminalID + ) + if let target, target.terminalID == terminalID { + guard let task = enqueueInput("\u{0C}", target: target), await task.value else { return } + } + if terminalID == activeTerminalID { + errorMessage = nil + } + } catch { + if terminalID == activeTerminalID { + errorMessage = error.localizedDescription + } + } + } + + private func enqueueInput( + _ data: String, + target: TerminalInputSession.Target?, + isPaste: Bool = false + ) -> Task? { + guard let target else { return nil } + return inputSession.enqueue(data, target: target, isPaste: isPaste) { chunk in + await write(chunk, target: target) + } + } + + private func write(_ data: String, target: TerminalInputSession.Target) async -> Bool { + guard target == inputTarget else { return false } + do { + try await client.writeTerminal( + threadID: target.threadID, + terminalID: target.terminalID, + data: data + ) + return true + } catch { + if target == inputTarget { + errorMessage = error.localizedDescription + } + return false + } + } +} diff --git a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift new file mode 100644 index 000000000000..dad1d1e49472 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift @@ -0,0 +1,1234 @@ +import GhosttyKit +import QuartzCore +import SwiftUI +import UIKit + +struct GhosttyTerminalSurface: UIViewRepresentable { + @SwiftUI.Environment(\.colorScheme) private var colorScheme + let terminalKey: String + let lifecycleVersion: Int + let buffer: String + let fontSize: CGFloat + let isRunning: Bool + let hostPlatform: TerminalHostPlatform + let focusRequest: Int + let onInput: (String) -> Void + let onPaste: (String) -> Void + let onResize: (Int, Int) -> Void + let onClear: () -> Void + let onFontSizeStep: (Int) -> Void + + func makeUIView(context _: Context) -> GhosttyTerminalView { + let view = GhosttyTerminalView() + configure(view) + return view + } + + func updateUIView(_ view: GhosttyTerminalView, context _: Context) { + configure(view) + } + + static func dismantleUIView(_ view: GhosttyTerminalView, coordinator _: ()) { + view.tearDown() + } + + private func configure(_ view: GhosttyTerminalView) { + view.isDarkMode = colorScheme == .dark + view.onInput = onInput + view.onPaste = onPaste + view.onResize = onResize + view.onClear = onClear + view.onFontSizeStep = onFontSizeStep + view.terminalKey = terminalKey + view.lifecycleVersion = lifecycleVersion + view.fontSize = fontSize + view.isRunning = isRunning + view.hostPlatform = hostPlatform + view.buffer = buffer + view.focusRequest = focusRequest + } +} + +enum TerminalHostPlatform: Equatable { + case mac + case linux + case windows + case unknown + + init(os: String?) { + self = switch os { + case "darwin": .mac + case "linux": .linux + case "windows": .windows + default: .unknown + } + } +} + +enum TerminalInputModifier: Equatable { + case command + case control +} + +enum TerminalInputAction: Equatable { + case write(String) + case paste +} + +enum TerminalInputEncoder { + // TerminalWriteInput.data is limited to UTF-16 code units, not Swift characters. + static let maximumWriteLength = 65_536 + + static func modified( + _ input: String, + modifier: TerminalInputModifier, + hostPlatform: TerminalHostPlatform + ) -> TerminalInputAction { + let pasteModifier: TerminalInputModifier = hostPlatform == .mac ? .command : .control + if modifier == pasteModifier, input.lowercased() == "v" { + return .paste + } + return .write(modifier == .control ? applyingControl(to: input) : "\u{1B}\(input)") + } + + static func applyingControl(to input: String) -> String { + guard let scalar = input.lowercased().unicodeScalars.first else { return input } + return controlSequence(for: scalar) ?? input + } + + static func controlSequence(for scalar: Unicode.Scalar) -> String? { + switch scalar { + case "a"..."z": return UnicodeScalar(scalar.value - 96).map(String.init) + case " ", "@": return "\u{00}" + case "[": return "\u{1B}" + case "\\": return "\u{1C}" + case "]": return "\u{1D}" + case "^": return "\u{1E}" + case "_", "-": return "\u{1F}" + case "?": return "\u{7F}" + default: return nil + } + } + + /// The native surface does not expose bracketed paste. Remove raw controls + /// and use carriage returns so pasted line breaks work in raw-mode programs. + static func paste(_ text: String) -> String { + var result = "" + result.reserveCapacity(text.utf8.count) + var previousWasCarriageReturn = false + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x0A: + if !previousWasCarriageReturn { result.append("\r") } + case 0x00...0x08, 0x0B, 0x0C, 0x0E...0x1F, 0x7F: + result.append(" ") + default: + result.unicodeScalars.append(scalar) + } + previousWasCarriageReturn = scalar.value == 0x0D + } + return result + } + + static func chunks(_ data: String) -> [String] { + let units = data.utf16 + var chunks = [String]() + var start = units.startIndex + while start < units.endIndex { + var end = units.index(start, offsetBy: maximumWriteLength, limitedBy: units.endIndex) + ?? units.endIndex + if end < units.endIndex, (0xD800...0xDBFF).contains(units[units.index(before: end)]) { + end = units.index(before: end) + } + chunks.append(String(decoding: units[start.. String { + let withoutEscapes = value + .replacingOccurrences( + of: "\u{1B}\\][^\u{7}\u{1B}]*(?:\u{7}|\u{1B}\\\\)", + with: "", + options: .regularExpression + ) + .replacingOccurrences( + of: "\u{1B}\\[[0-?]*[ -/]*[@-~]", + with: "", + options: .regularExpression + ) + .replacingOccurrences( + of: "\u{1B}[@-_]", + with: "", + options: .regularExpression + ) + + return withoutEscapes.reduce(into: "") { output, character in + if character == "\u{8}" || character == "\u{7F}" { + if !output.isEmpty { output.removeLast() } + return + } + if character == "\r" { return } + if character.unicodeScalars.count == 1, + let scalar = character.unicodeScalars.first, + scalar.value < 32, + character != "\n", + character != "\t" { + return + } + output.append(character) + } + } +} + +private enum GhosttyRuntime { + private static let lock = NSLock() + nonisolated(unsafe) private static var initialized = false + + static func ensureInitialized() -> Bool { + lock.lock() + defer { lock.unlock() } + + if initialized { return true } + initialized = ghostty_init(0, nil) == GHOSTTY_SUCCESS + return initialized + } +} + +@MainActor +enum TerminalHardwareKeyEncoder { + private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? " + + static func makeKeyCommands(action: Selector) -> [UIKeyCommand] { + var commands = [UIKeyCommand]() + let specialInputs = [ + UIKeyCommand.inputEscape, + UIKeyCommand.inputUpArrow, + UIKeyCommand.inputDownArrow, + UIKeyCommand.inputLeftArrow, + UIKeyCommand.inputRightArrow, + "\t", + ] + + for input in specialInputs { + commands.append(makeCommand(input: input, modifierFlags: [], action: action)) + } + commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action)) + + for character in controlInputs { + commands.append( + makeCommand( + input: String(character), + modifierFlags: .control, + action: action + ) + ) + commands.append( + makeCommand( + input: String(character), + modifierFlags: [.control, .shift], + action: action + ) + ) + } + + commands.append(makeCommand(input: "c", modifierFlags: .command, action: action)) + commands.append(makeCommand(input: "v", modifierFlags: .command, action: action)) + return commands + } + + private static func makeCommand( + input: String, + modifierFlags: UIKeyModifierFlags, + action: Selector + ) -> UIKeyCommand { + let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action) + command.wantsPriorityOverSystemBehavior = true + return command + } + + static func sequence( + input: String, + modifiers: UIKeyModifierFlags, + hostPlatform: TerminalHostPlatform + ) -> String? { + if modifiers == .command { + return input.lowercased() == "c" ? "copy" : input.lowercased() == "v" ? "paste" : nil + } + if modifiers.contains(.control), hostPlatform != .mac, input.lowercased() == "v" { + return "paste" + } + + switch input { + case UIKeyCommand.inputEscape: return "\u{1B}" + case UIKeyCommand.inputUpArrow: return "\u{1B}[A" + case UIKeyCommand.inputDownArrow: return "\u{1B}[B" + case UIKeyCommand.inputRightArrow: return "\u{1B}[C" + case UIKeyCommand.inputLeftArrow: return "\u{1B}[D" + case "\t": return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t" + default: break + } + + guard modifiers.contains(.control), + let scalar = input.lowercased().unicodeScalars.first else { + return nil + } + return TerminalInputEncoder.controlSequence(for: scalar) + } +} + +private final class TerminalInputField: UITextField { + var hostPlatform = TerminalHostPlatform.unknown + var onDeleteBackward: (() -> Void)? + var onInsert: ((String) -> Void)? + var onCopyOutput: (() -> Void)? + var onPasteText: (() -> Void)? + + private static let terminalKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( + action: #selector(handleHardwareKeyCommand(_:)) + ) + + override var keyCommands: [UIKeyCommand]? { Self.terminalKeyCommands } + + override func deleteBackward() { + onDeleteBackward?() + super.deleteBackward() + } + + override func paste(_ sender: Any?) { + onPasteText?() + } + + @objc private func handleHardwareKeyCommand(_ command: UIKeyCommand) { + guard let input = command.input, + let sequence = TerminalHardwareKeyEncoder.sequence( + input: input, + modifiers: command.modifierFlags, + hostPlatform: hostPlatform + ) else { + return + } + + if sequence == "copy" { + onCopyOutput?() + } else if sequence == "paste" { + onPasteText?() + } else { + onInsert?(sequence) + } + } +} + +private enum TerminalAccessoryAction: String { + case escape + case command + case control + case tab + case paste + case clear + case up + case down + case left + case right + case tilde + case pipe + case slash + case dash + case dismiss + + var label: String { + switch self { + case .escape: "esc" + case .command: "cmd" + case .control: "ctrl" + case .tab: "tab" + case .paste: "paste" + case .clear: "clear" + case .up: "↑" + case .down: "↓" + case .left: "←" + case .right: "→" + case .tilde: "~" + case .pipe: "|" + case .slash: "/" + case .dash: "-" + case .dismiss: "" + } + } + + var sequence: String? { + switch self { + case .escape: "\u{1B}" + case .tab: "\t" + case .up: "\u{1B}[A" + case .down: "\u{1B}[B" + case .left: "\u{1B}[D" + case .right: "\u{1B}[C" + case .tilde: "~" + case .pipe: "|" + case .slash: "/" + case .dash: "-" + case .command, .control, .paste, .clear, .dismiss: nil + } + } + + var width: CGFloat { + switch self { + case .escape, .tab: 44 + case .command: 48 + case .control, .paste, .clear: 50 + case .up, .down, .left, .right, .tilde, .pipe, .slash, .dash: 38 + case .dismiss: 36 + } + } +} + +private final class TerminalAccessoryButton: UIButton { + let terminalAction: TerminalAccessoryAction + + init(action: TerminalAccessoryAction) { + terminalAction = action + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { nil } +} + +private final class TerminalAccessoryView: UIInputView { + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let dismissButton = TerminalAccessoryButton(action: .dismiss) + private var actionButtons = [TerminalAccessoryAction: TerminalAccessoryButton]() + private var activeModifier: TerminalAccessoryAction? + private var hostPlatform = TerminalHostPlatform.unknown + var onAction: ((TerminalAccessoryAction) -> Void)? + + init() { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 50), inputViewStyle: .keyboard) + allowsSelfSizing = true + backgroundColor = T3Colors.uiBackground + + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 7 + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(scrollView) + addSubview(dismissButton) + scrollView.addSubview(stackView) + + let actions: [TerminalAccessoryAction] = [ + .escape, .control, .command, .tab, .paste, .clear, + .up, .down, .left, .right, .tilde, .pipe, .slash, .dash, + ] + for action in actions { + let button = TerminalAccessoryButton(action: action) + configure(button, label: action.label) + actionButtons[action] = button + stackView.addArrangedSubview(button) + } + + dismissButton.accessibilityLabel = "Dismiss keyboard" + dismissButton.addTarget(self, action: #selector(handleButton(_:)), for: .touchUpInside) + dismissButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 50), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.trailingAnchor.constraint(equalTo: dismissButton.leadingAnchor), + dismissButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + dismissButton.centerYAnchor.constraint(equalTo: centerYAnchor), + dismissButton.widthAnchor.constraint(equalToConstant: TerminalAccessoryAction.dismiss.width), + dismissButton.heightAnchor.constraint(equalToConstant: 42), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 8), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -8), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + refreshAppearance() + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { + (self: Self, _: UITraitCollection) in + self.refreshAppearance() + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { nil } + + func setRunning(_ running: Bool) { + for (action, button) in actionButtons { + button.isEnabled = running || action == .clear + } + } + + func setHostPlatform(_ platform: TerminalHostPlatform) { + guard hostPlatform != platform else { return } + hostPlatform = platform + if let command = actionButtons[.command] { + stackView.removeArrangedSubview(command) + stackView.insertArrangedSubview(command, at: platform == .mac ? 1 : 2) + } + refreshAppearance() + } + + func setActiveModifier(_ action: TerminalAccessoryAction?) { + activeModifier = action + for modifier in [TerminalAccessoryAction.command, .control] { + guard let button = actionButtons[modifier] else { continue } + applyStyle(to: button, active: modifier == action) + } + } + + func refreshAppearance() { + backgroundColor = T3Colors.uiBackground + for (action, button) in actionButtons { + applyStyle(to: button, active: action == activeModifier) + } + + var dismissConfiguration = UIButton.Configuration.plain() + dismissConfiguration.image = UIImage(systemName: "keyboard.chevron.compact.down") + dismissConfiguration.baseForegroundColor = .secondaryLabel + dismissConfiguration.contentInsets = .zero + dismissButton.configuration = dismissConfiguration + } + + private func configure(_ button: TerminalAccessoryButton, label: String) { + button.setTitle(label.uppercased(), for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 10, weight: .semibold) + button.accessibilityLabel = label + button.addTarget(self, action: #selector(handleButton(_:)), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: 34).isActive = true + button.widthAnchor.constraint(equalToConstant: button.terminalAction.width).isActive = true + applyStyle(to: button, active: false) + } + + private func applyStyle(to button: UIButton, active: Bool) { + var configuration = UIButton.Configuration.plain() + if let terminalButton = button as? TerminalAccessoryButton, + terminalButton.terminalAction != .dismiss { + let action = terminalButton.terminalAction + let label = action == .command && hostPlatform != .mac ? "alt" : action.label + configuration.title = label.uppercased() + button.accessibilityLabel = action == .paste ? "Paste" : label + button.accessibilityIdentifier = "terminal-key-\(label)" + } + let isDark = traitCollection.userInterfaceStyle == .dark + configuration.baseForegroundColor = if active { + isDark ? UIColor(white: 0.04, alpha: 1) : .white + } else { + isDark ? UIColor(white: 0.88, alpha: 1) : T3Colors.uiTextPrimary + } + configuration.background.backgroundColor = if active { + isDark ? UIColor(white: 0.94, alpha: 1) : T3Colors.uiTextPrimary + } else { + isDark ? UIColor(white: 0.08, alpha: 1) : .white + } + configuration.background.cornerRadius = 7 + configuration.background.strokeColor = isDark + ? UIColor(white: active ? 0.55 : 0.20, alpha: 1) + : UIColor(white: 0, alpha: active ? 0.18 : 0.10) + configuration.background.strokeWidth = 1 + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .systemFont(ofSize: 10, weight: .semibold) + return attributes + } + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: 4, + bottom: 0, + trailing: 4 + ) + button.configuration = configuration + } + + @objc private func handleButton(_ sender: TerminalAccessoryButton) { + onAction?(sender.terminalAction) + } +} + +final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInteractionDelegate { + private static let minimumVerticalScrollStepPoints: CGFloat = 18 + private static let verticalScrollStepMultiplier: CGFloat = 1.15 + private static let darkThemeConfig = """ + background = #0a0a0a + foreground = #adadb1 + cursor-color = #009fff + cursor-text = #0a0a0a + cursor-style-blink = false + palette = 0=#141415 + palette = 1=#ff2e3f + palette = 2=#0dbe4e + palette = 3=#ffca00 + palette = 4=#009fff + palette = 5=#c635e4 + palette = 6=#08c0ef + palette = 7=#c6c6c8 + palette = 8=#141415 + palette = 9=#ff2e3f + palette = 10=#0dbe4e + palette = 11=#ffca00 + palette = 12=#009fff + palette = 13=#c635e4 + palette = 14=#08c0ef + palette = 15=#c6c6c8 + """ + private static let lightThemeConfig = """ + background = #f2f2f7 + foreground = #6c6c71 + cursor-color = #009fff + cursor-text = #f2f2f7 + cursor-style-blink = false + palette = 0=#1f1f21 + palette = 1=#ff2e3f + palette = 2=#0dbe4e + palette = 3=#ffca00 + palette = 4=#009fff + palette = 5=#c635e4 + palette = 6=#08c0ef + palette = 7=#c6c6c8 + palette = 8=#1f1f21 + palette = 9=#ff2e3f + palette = 10=#0dbe4e + palette = 11=#ffca00 + palette = 12=#009fff + palette = 13=#c635e4 + palette = 14=#08c0ef + palette = 15=#c6c6c8 + """ + + var onInput: ((String) -> Void)? + var onPaste: ((String) -> Void)? + var onResize: ((Int, Int) -> Void)? + var onClear: (() -> Void)? + var onFontSizeStep: ((Int) -> Void)? + + var isDarkMode = true { + didSet { + guard oldValue != isDarkMode else { return } + applyChromeAppearance() + refreshSurface() + } + } + + var terminalKey = "" { + didSet { + accessibilityIdentifier = "t3-terminal-\(terminalKey)" + inputField.accessibilityIdentifier = "t3-terminal-input-\(terminalKey)" + guard oldValue != terminalKey else { return } + pendingModifier = nil + resetSurface() + } + } + + var lifecycleVersion = 0 { + didSet { + if oldValue != lifecycleVersion { pendingModifier = nil } + } + } + + var buffer = "" { + didSet { + guard oldValue != buffer else { return } + applyRemoteBuffer(buffer) + if UIAccessibility.isVoiceOverRunning { + terminalViewport.accessibilityValue = TerminalText.plainText( + from: String(buffer.suffix(8_192)) + ) + } + } + } + + var fontSize: CGFloat = 10.5 { + didSet { + guard oldValue != fontSize else { return } + inputField.font = .monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) + refreshSurface() + } + } + + var isRunning = false { + didSet { + guard oldValue != isRunning else { return } + inputField.isEnabled = isRunning + accessoryView.setRunning(isRunning) + keyboardButton.isHidden = !isRunning || inputField.isFirstResponder + if isRunning, window != nil, !hasAutoFocused { + hasAutoFocused = true + DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } + } else if !isRunning { + pendingModifier = nil + } + } + } + + var hostPlatform = TerminalHostPlatform.unknown { + didSet { + guard oldValue != hostPlatform else { return } + inputField.hostPlatform = hostPlatform + accessoryView.setHostPlatform(hostPlatform) + pendingModifier = nil + } + } + + var focusRequest = 0 { + didSet { + guard oldValue != focusRequest else { return } + DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } + } + } + + private let terminalViewport = UIView() + private let inputField = TerminalInputField() + private let accessoryView = TerminalAccessoryView() + private let keyboardButton = UIButton(type: .system) + private let focusTapGesture = UITapGestureRecognizer() + private let scrollPanGesture = UIPanGestureRecognizer() + private let fontPinchGesture = UIPinchGestureRecognizer() + private var pendingModifier: TerminalInputModifier? { + didSet { + accessoryView.setActiveModifier(pendingModifier.map { $0 == .command ? .command : .control }) + } + } + private var lastViewportSize: CGSize = .zero + private var lastContentScale: CGFloat = 0 + private var lastReportedGrid: (columns: Int, rows: Int)? + private var lastAppliedBuffer = "" + private var isReplayingBuffer = false + private var pendingVerticalScrollPoints: CGFloat = 0 + private var hasAutoFocused = false + private var app: ghostty_app_t? + private var surface: ghostty_surface_t? + private var isCreatingSurface = false + private var surfaceCreationFailed = false + + init() { + super.init(frame: .zero) + clipsToBounds = true + contentScaleFactor = UIScreen.main.scale + accessibilityLabel = "Terminal" + + terminalViewport.clipsToBounds = true + terminalViewport.contentScaleFactor = contentScaleFactor + terminalViewport.translatesAutoresizingMaskIntoConstraints = false + terminalViewport.isUserInteractionEnabled = true + terminalViewport.isAccessibilityElement = true + terminalViewport.accessibilityLabel = "Terminal output" + terminalViewport.accessibilityTraits = .staticText + + inputField.delegate = self + inputField.inputAccessoryView = accessoryView + inputField.backgroundColor = .clear + inputField.textColor = .clear + inputField.tintColor = .clear + inputField.font = .monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) + inputField.placeholder = "" + inputField.autocorrectionType = .no + inputField.autocapitalizationType = .none + inputField.spellCheckingType = .no + inputField.smartDashesType = .no + inputField.smartQuotesType = .no + inputField.returnKeyType = .send + inputField.keyboardType = .asciiCapable + inputField.enablesReturnKeyAutomatically = false + inputField.translatesAutoresizingMaskIntoConstraints = false + inputField.alpha = 0.02 + inputField.isAccessibilityElement = true + inputField.accessibilityLabel = "Terminal input" + inputField.addTarget(self, action: #selector(inputDidBegin), for: .editingDidBegin) + inputField.addTarget(self, action: #selector(inputDidEnd), for: .editingDidEnd) + inputField.onDeleteBackward = { [weak self] in self?.sendInput("\u{7F}") } + inputField.onInsert = { [weak self] in self?.sendInput($0) } + inputField.onCopyOutput = { [weak self] in self?.copyOutput() } + inputField.onPasteText = { [weak self] in self?.pasteText() } + + keyboardButton.accessibilityLabel = "Show keyboard" + keyboardButton.isHidden = true + keyboardButton.translatesAutoresizingMaskIntoConstraints = false + keyboardButton.addTarget(self, action: #selector(showKeyboard), for: .touchUpInside) + + accessoryView.onAction = { [weak self] in self?.handleAccessoryAction($0) } + + focusTapGesture.addTarget(self, action: #selector(viewportTapped)) + terminalViewport.addGestureRecognizer(focusTapGesture) + + scrollPanGesture.addTarget(self, action: #selector(viewportPanned(_:))) + scrollPanGesture.maximumNumberOfTouches = 1 + scrollPanGesture.cancelsTouchesInView = false + terminalViewport.addGestureRecognizer(scrollPanGesture) + + fontPinchGesture.addTarget(self, action: #selector(viewportPinched(_:))) + terminalViewport.addGestureRecognizer(fontPinchGesture) + terminalViewport.addInteraction(UIContextMenuInteraction(delegate: self)) + + addSubview(terminalViewport) + addSubview(inputField) + addSubview(keyboardButton) + + NSLayoutConstraint.activate([ + terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), + terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + terminalViewport.topAnchor.constraint(equalTo: topAnchor, constant: 6), + terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6), + inputField.trailingAnchor.constraint(equalTo: trailingAnchor), + inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8), + inputField.widthAnchor.constraint(equalToConstant: 1), + inputField.heightAnchor.constraint(equalToConstant: 1), + keyboardButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + keyboardButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -16), + keyboardButton.widthAnchor.constraint(equalToConstant: 48), + keyboardButton.heightAnchor.constraint(equalToConstant: 48), + ]) + applyChromeAppearance() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { nil } + + override func layoutSubviews() { + super.layoutSubviews() + updateContentScale() + if surface == nil { createSurfaceIfPossible() } + + let viewportSize = terminalViewport.bounds.size + guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else { + return + } + lastViewportSize = viewportSize + lastContentScale = contentScaleFactor + resizeSurface() + inputField.accessibilityFrame = terminalViewport.convert(terminalViewport.bounds, to: nil) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil, isRunning, !hasAutoFocused else { return } + hasAutoFocused = true + DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } + } + + func textField( + _: UITextField, + shouldChangeCharactersIn _: NSRange, + replacementString string: String + ) -> Bool { + if !string.isEmpty { + sendInput(string == "\n" || string == "\r\n" ? "\r" : string) + } + return false + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + sendInput("\r") + textField.text = "" + return false + } + + func contextMenuInteraction( + _: UIContextMenuInteraction, + configurationForMenuAtLocation _: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self else { return UIMenu() } + let copy = UIAction(title: "Copy output", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in + self?.copyOutput() + } + let paste = UIAction( + title: "Paste", + image: UIImage(systemName: "doc.on.clipboard"), + attributes: self.isRunning && UIPasteboard.general.hasStrings ? [] : .disabled + ) { [weak self] _ in + self?.pasteText() + } + let clear = UIAction(title: "Clear", image: UIImage(systemName: "eraser")) { [weak self] _ in + self?.onClear?() + } + return UIMenu(children: [copy, paste, clear]) + } + } + + private func createSurfaceIfPossible() { + guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return } + guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return } + guard GhosttyRuntime.ensureInitialized() else { + surfaceCreationFailed = true + return + } + + isCreatingSurface = true + defer { isCreatingSurface = false } + + var runtimeConfig = ghostty_runtime_config_s( + userdata: Unmanaged.passUnretained(self).toOpaque(), + supports_selection_clipboard: false, + wakeup_cb: { _ in }, + action_cb: { _, _, _ in false }, + read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, + confirm_read_clipboard_cb: { _, _, _, _ in }, + write_clipboard_cb: { _, _, _, _, _ in }, + close_surface_cb: { _, _ in } + ) + + guard let config = ghostty_config_new() else { + surfaceCreationFailed = true + return + } + loadThemeConfig(into: config) + ghostty_config_finalize(config) + defer { ghostty_config_free(config) } + + guard let createdApp = ghostty_app_new(&runtimeConfig, config) else { + surfaceCreationFailed = true + return + } + + var surfaceConfig = ghostty_surface_config_new() + surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS + surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque() + surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque() + surfaceConfig.scale_factor = Double(contentScaleFactor) + surfaceConfig.font_size = Float(fontSize) + surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW + surfaceConfig.use_custom_io = true + + guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else { + ghostty_app_free(createdApp) + surfaceCreationFailed = true + return + } + + app = createdApp + surface = createdSurface + let ghosttyColorScheme = + isDarkMode + ? GHOSTTY_COLOR_SCHEME_DARK + : GHOSTTY_COLOR_SCHEME_LIGHT + ghostty_app_set_color_scheme(createdApp, ghosttyColorScheme) + ghostty_surface_set_color_scheme(createdSurface, ghosttyColorScheme) + setupWriteCallback() + resizeSurface() + feedBuffer(buffer) + } + + private func resetSurface() { + destroySurface() + lastAppliedBuffer = "" + lastViewportSize = .zero + lastContentScale = 0 + lastReportedGrid = nil + surfaceCreationFailed = false + setNeedsLayout() + } + + private func applyChromeAppearance() { + let background = + isDarkMode + ? UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1) + : UIColor(red: 242 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1) + backgroundColor = background + terminalViewport.backgroundColor = background + accessoryView.overrideUserInterfaceStyle = isDarkMode ? .dark : .light + accessoryView.refreshAppearance() + + var keyboardConfiguration = UIButton.Configuration.filled() + keyboardConfiguration.image = UIImage(systemName: "keyboard") + keyboardConfiguration.baseForegroundColor = isDarkMode ? .white : T3Colors.uiTextPrimary + keyboardConfiguration.baseBackgroundColor = + isDarkMode ? UIColor(white: 0.10, alpha: 0.96) : .white + keyboardConfiguration.background.cornerRadius = 24 + keyboardConfiguration.background.strokeColor = + isDarkMode + ? UIColor(white: 0.25, alpha: 1) + : UIColor(white: 0, alpha: 0.10) + keyboardConfiguration.background.strokeWidth = 1 + keyboardButton.configuration = keyboardConfiguration + } + + private func refreshSurface() { + resetSurface() + createSurfaceIfPossible() + } + + func tearDown() { + onInput = nil + onPaste = nil + onResize = nil + onClear = nil + onFontSizeStep = nil + destroySurface() + } + + private func destroySurface() { + if let surface { + ghostty_surface_set_write_callback(surface, nil, nil) + ghostty_surface_free(surface) + } + if let app { ghostty_app_free(app) } + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + surface = nil + app = nil + } + + private func applyRemoteBuffer(_ newBuffer: String) { + guard surface != nil else { + createSurfaceIfPossible() + return + } + guard newBuffer != lastAppliedBuffer else { return } + + if newBuffer.isEmpty { + feedData(Data("\u{1B}[2J\u{1B}[H".utf8)) + lastAppliedBuffer = "" + return + } + + if newBuffer.hasPrefix(lastAppliedBuffer) { + feedData(Data(newBuffer.dropFirst(lastAppliedBuffer.count).utf8)) + lastAppliedBuffer = newBuffer + return + } + + resetSurface() + createSurfaceIfPossible() + } + + private func feedBuffer(_ value: String) { + guard !value.isEmpty else { return } + isReplayingBuffer = true + defer { isReplayingBuffer = false } + feedData(Data(value.utf8)) + lastAppliedBuffer = value + } + + private func feedData(_ data: Data) { + guard let surface, !data.isEmpty else { return } + data.withUnsafeBytes { bytes in + guard let pointer = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + ghostty_surface_feed_data(surface, pointer, bytes.count) + } + redrawSurface() + } + + private func setupWriteCallback() { + guard let surface else { return } + let userdata = Unmanaged.passUnretained(self).toOpaque() + ghostty_surface_set_write_callback(surface, { userdata, data, length in + guard let userdata, let data, length > 0 else { return } + let view = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard !view.isReplayingBuffer else { return } + let bytes = Data(bytes: data, count: length) + guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } + DispatchQueue.main.async { view.onInput?(input) } + }, userdata) + } + + private func resizeSurface() { + guard let surface else { + emitEstimatedResize() + return + } + + let scale = contentScaleFactor + let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1)) + let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1)) + terminalViewport.contentScaleFactor = scale + ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) + ghostty_surface_set_size(surface, width, height) + ghostty_surface_set_occlusion(surface, window != nil) + configureIOSurfaceLayers() + redrawSurface() + emitGhosttyResize() + } + + private func redrawSurface() { + guard let surface else { return } + ghostty_surface_refresh(surface) + ghostty_surface_draw(surface) + markIOSurfaceLayersForDisplay() + emitGhosttyResize() + } + + private func emitGhosttyResize() { + guard let surface else { + emitEstimatedResize() + return + } + let size = ghostty_surface_size(surface) + emitResize(columns: max(1, Int(size.columns)), rows: max(1, Int(size.rows))) + } + + private func emitEstimatedResize() { + guard bounds.width > 0, bounds.height > 0 else { return } + let columns = max(20, min(400, Int(bounds.width / max(fontSize * 0.62, 1)))) + let rows = max(5, min(200, Int(bounds.height / max(fontSize * 1.35, 1)))) + emitResize(columns: columns, rows: rows) + } + + private func emitResize(columns: Int, rows: Int) { + guard lastReportedGrid?.columns != columns || lastReportedGrid?.rows != rows else { return } + lastReportedGrid = (columns, rows) + onResize?(columns, rows) + } + + private func updateContentScale() { + let scale = window?.screen.scale ?? UIScreen.main.scale + if contentScaleFactor != scale { contentScaleFactor = scale } + } + + private func requestKeyboardFocus() { + guard window != nil, isRunning else { return } + inputField.becomeFirstResponder() + if let surface { ghostty_surface_set_focus(surface, true) } + if let app { ghostty_app_keyboard_changed(app) } + } + + private func sendInput(_ data: String) { + guard isRunning, !data.isEmpty else { return } + let modifier = pendingModifier + pendingModifier = nil + guard let modifier else { + onInput?(data) + return + } + switch TerminalInputEncoder.modified(data, modifier: modifier, hostPlatform: hostPlatform) { + case .write(let data): onInput?(data) + case .paste: pasteText() + } + } + + private func copyOutput() { + UIPasteboard.general.string = TerminalText.plainText(from: buffer) + } + + private func pasteText() { + pendingModifier = nil + let key = terminalKey + let version = lifecycleVersion + guard isRunning, let value = UIPasteboard.general.string, !value.isEmpty else { return } + guard terminalKey == key, lifecycleVersion == version, isRunning else { return } + onPaste?(value) + } + + private func handleAccessoryAction(_ action: TerminalAccessoryAction) { + switch action { + case .command: + pendingModifier = pendingModifier == .command ? nil : .command + case .control: + pendingModifier = pendingModifier == .control ? nil : .control + case .paste: + pasteText() + case .clear: + pendingModifier = nil + onClear?() + case .dismiss: + pendingModifier = nil + inputField.resignFirstResponder() + default: + if let sequence = action.sequence { sendInput(sequence) } + } + } + + private func configureIOSurfaceLayers() { + let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { layer in + layer.frame = targetBounds + layer.contentsScale = contentScaleFactor + } + CATransaction.commit() + } + + private func markIOSurfaceLayersForDisplay() { + terminalViewport.layer.setNeedsDisplay() + terminalViewport.layer.sublayers?.forEach { $0.setNeedsDisplay() } + } + + private func loadThemeConfig(into config: ghostty_config_t) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swiftui-terminal.ghostty") + do { + let themeConfig = isDarkMode ? Self.darkThemeConfig : Self.lightThemeConfig + if (try? String(contentsOf: url, encoding: .utf8)) != themeConfig { + try themeConfig.write(to: url, atomically: true, encoding: .utf8) + } + url.path.withCString { ghostty_config_load_file(config, $0) } + } catch { + // The default Ghostty configuration is still usable if the theme file cannot be staged. + } + } + + @objc private func viewportTapped() { + requestKeyboardFocus() + } + + @objc private func viewportPanned(_ gesture: UIPanGestureRecognizer) { + guard let surface else { return } + let location = gesture.location(in: terminalViewport) + ghostty_surface_mouse_pos( + surface, + Double(location.x * contentScaleFactor), + Double(location.y * contentScaleFactor), + GHOSTTY_MODS_NONE + ) + + switch gesture.state { + case .began: + pendingVerticalScrollPoints = 0 + gesture.setTranslation(.zero, in: terminalViewport) + case .changed: + let translation = gesture.translation(in: terminalViewport) + let stepSize = max( + fontSize * Self.verticalScrollStepMultiplier, + Self.minimumVerticalScrollStepPoints + ) + let total = pendingVerticalScrollPoints + translation.y + let steps = Int(total / stepSize) + pendingVerticalScrollPoints = total - CGFloat(steps) * stepSize + if steps != 0 { + ghostty_surface_mouse_scroll(surface, 0, Double(steps), 0) + redrawSurface() + } + gesture.setTranslation(.zero, in: terminalViewport) + default: + pendingVerticalScrollPoints = 0 + gesture.setTranslation(.zero, in: terminalViewport) + } + } + + @objc private func viewportPinched(_ gesture: UIPinchGestureRecognizer) { + guard gesture.state == .ended else { return } + if gesture.scale >= 1.08 { + onFontSizeStep?(1) + } else if gesture.scale <= 0.92 { + onFontSizeStep?(-1) + } + } + + @objc private func inputDidBegin() { + keyboardButton.isHidden = true + if let surface { ghostty_surface_set_focus(surface, true) } + if let app { ghostty_app_keyboard_changed(app) } + } + + @objc private func inputDidEnd() { + pendingModifier = nil + keyboardButton.isHidden = !isRunning + if let surface { ghostty_surface_set_focus(surface, false) } + } + + @objc private func showKeyboard() { + requestKeyboardFocus() + } +} diff --git a/apps/swift-ios/Features/Usage/UsageLimitsPresentation.swift b/apps/swift-ios/Features/Usage/UsageLimitsPresentation.swift new file mode 100644 index 000000000000..d84f2a06007f --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageLimitsPresentation.swift @@ -0,0 +1,258 @@ +import Foundation + +public struct FeatureEnvironmentUsageLimits: Identifiable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let providers: [ServerProviderSnapshot] + public let sources: [UsageLimitSourceSnapshot] + public let isConnected: Bool + public let errorMessage: String? + public let isPending: Bool + + public var id: String { environmentID } + + public init( + environmentID: String, + label: String, + providers: [ServerProviderSnapshot] = [], + sources: [UsageLimitSourceSnapshot] = [], + isConnected: Bool = true, + errorMessage: String? = nil, + isPending: Bool = false + ) { + self.environmentID = environmentID + self.label = label + self.providers = providers + self.sources = sources + self.isConnected = isConnected + self.errorMessage = errorMessage + self.isPending = isPending + } +} + +struct UsageLimitsGroup: Identifiable, Equatable { + let environment: FeatureEnvironmentUsageLimits + let providers: [ServerProviderSnapshot] + let sources: [UsageLimitSourceRows] + + var id: String { environment.environmentID } + var hasLimits: Bool { !providers.isEmpty || !sources.isEmpty } +} + +struct UsageLimitSourceRows: Identifiable, Equatable { + let source: UsageLimitSourceSnapshot + let accounts: [UsageLimitSourceAccount] + let hiddenAccountCount: Int + + var id: String { source.id } +} + +enum UsageLimitsPresentation { + private struct AccountKey: Hashable { + let driver: String + let email: String + } + + /// A restarted subscription keeps its last bars until that environment answers. + static func retainingPendingRows( + _ incoming: [FeatureEnvironmentUsageLimits], + previous: [FeatureEnvironmentUsageLimits] + ) -> [FeatureEnvironmentUsageLimits] { + let previousByID = Dictionary(uniqueKeysWithValues: previous.map { ($0.environmentID, $0) }) + return incoming.map { environment in + guard environment.isPending, environment.providers.isEmpty, environment.sources.isEmpty, + let prior = previousByID[environment.environmentID] else { return environment } + return FeatureEnvironmentUsageLimits( + environmentID: environment.environmentID, + label: environment.label, + providers: prior.providers, + sources: prior.sources, + isConnected: prior.isConnected, + errorMessage: environment.errorMessage, + isPending: true + ) + } + } + + /// Usable provider limits take precedence over the same account in a hub. + /// Sources remain grouped by environment even when they use the same hub. + static func groups(_ environments: [FeatureEnvironmentUsageLimits]) -> [UsageLimitsGroup] { + var nativeAccounts: Set = [] + for environment in environments where environment.isConnected { + for provider in providersWithLimits(environment.providers) { + guard let limits = provider.usageLimits, + !limits.windows.isEmpty, + limits.unavailable == nil, + let key = accountKey(driver: provider.driver, email: provider.auth.email) else { + continue + } + nativeAccounts.insert(key) + } + } + + return environments.map { environment in + UsageLimitsGroup( + environment: environment, + providers: providersWithLimits(environment.providers), + sources: environment.sources.map { source in + let accounts = source.accounts.filter { account in + guard let key = accountKey(driver: account.driver, email: account.email) else { + return true + } + return !nativeAccounts.contains(key) + } + return UsageLimitSourceRows( + source: source, + accounts: accounts, + hiddenAccountCount: source.accounts.count - accounts.count + ) + } + ) + } + } + + static func providersWithLimits(_ providers: [ServerProviderSnapshot]) -> [ServerProviderSnapshot] { + providers.filter { + $0.enabled && $0.installed && $0.availability != "unavailable" && $0.usageLimits != nil + } + } + + static func providerLabel(driver: String) -> String { + switch driver { + case "codex": "Codex" + case "claudeAgent": "Claude" + case "grok": "Grok" + case "cursor": "Cursor" + case "opencode": "OpenCode" + case "antigravity": "Antigravity" + default: driver + } + } + + static func limitsNotice(_ limits: ServerProviderUsageLimits) -> String? { + if let unavailable = limits.unavailable { + return unavailable.message ?? (unavailable.reason == .unsupported + ? "This account has no subscription limits." + : "Could not read limits.") + } + return limits.windows.isEmpty ? "No limits reported." : nil + } + + static func visibleWindows(_ limits: ServerProviderUsageLimits) -> [ServerProviderUsageWindow] { + limits.unavailable?.reason == .unsupported ? [] : limits.windows + } + + private static func accountKey(driver: String, email: String?) -> AccountKey? { + guard let normalized = email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !normalized.isEmpty else { return nil } + return AccountKey(driver: driver, email: normalized) + } +} + +enum UsageLimitPace: Equatable { + case ahead + case on + case under + + var label: String { + switch self { + case .ahead: "Ahead of pace" + case .on: "On pace" + case .under: "Under pace" + } + } +} + +enum UsageLimitsMath { + static func usedPercent(_ window: ServerProviderUsageWindow) -> Double { + window.usedPercent.isFinite ? min(100, max(0, window.usedPercent)) : 0 + } + + static func remainingPercent(_ window: ServerProviderUsageWindow) -> Double { + (100 - usedPercent(window)).rounded() + } + + static func elapsedShare(_ window: ServerProviderUsageWindow, now: Date) -> Double? { + guard let minutes = window.windowDurationMins, + minutes > 0, + let reset = window.resetsAt.flatMap(date) else { return nil } + let duration = Double(minutes) * 60 + return min(1, max(0, (duration - reset.timeIntervalSince(now)) / duration)) + } + + static func pace(_ window: ServerProviderUsageWindow, now: Date) -> UsageLimitPace? { + guard let elapsed = elapsedShare(window, now: now) else { return nil } + let gap = usedPercent(window) - elapsed * 100 + if gap > 5 { return .ahead } + if gap < -5 { return .under } + return .on + } + + static func resetsIn(_ window: ServerProviderUsageWindow, now: Date) -> String? { + guard let reset = window.resetsAt.flatMap(date) else { return nil } + let remaining = reset.timeIntervalSince(now) + return remaining <= 0 ? "Resets now" : "Resets in \(duration(remaining))" + } + + static func creditSummary(_ credits: ServerProviderResetCredits, now: Date) -> String { + guard credits.availableCount > 0 else { return "No reset credits available." } + let count = credits.availableCount + var summary = "\(count) reset \(count == 1 ? "credit" : "credits") available." + if let expiration = credits.nextExpiresAt.flatMap(date) { + let remaining = expiration.timeIntervalSince(now) + summary += remaining <= 0 + ? " Next expires now." + : " Next expires in \(duration(remaining))." + } + return summary + } + + static func duration(_ seconds: TimeInterval) -> String { + guard seconds.isFinite else { return "0m" } + let minutes = Int(max(0, seconds) / 60) + let days = minutes / (24 * 60) + let hours = minutes / 60 % 24 + if days > 0 { return "\(days)d \(hours)h" } + if hours > 0 { return "\(hours)h \(minutes % 60)m" } + return "\(minutes)m" + } + + private static func date(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } +} + +struct UsageResetCreditTarget: Hashable { + let environmentID: String + let instanceID: String +} + +/// A confirmed request can spend one credit. Keep failures visible and never retry it automatically. +struct UsageResetCreditState: Equatable { + private(set) var isPending = false + private(set) var statusMessage: String? + + mutating func begin(availableCount: Int, isConnected: Bool) -> Bool { + guard !isPending, availableCount > 0, isConnected else { return false } + isPending = true + statusMessage = nil + return true + } + + mutating func finish(_ outcome: ProviderConsumeResetCreditOutcome) { + isPending = false + statusMessage = switch outcome { + case .reset: "Reset applied. Your current limits are cleared." + case .nothingToReset: "Nothing to reset right now." + case .noCredit: "No reset credit left." + case .alreadyRedeemed: "That credit was already redeemed." + } + } + + mutating func fail(_ error: any Error) { + isPending = false + statusMessage = error.localizedDescription + } +} diff --git a/apps/swift-ios/Features/Usage/UsageLimitsView.swift b/apps/swift-ios/Features/Usage/UsageLimitsView.swift new file mode 100644 index 000000000000..87be45763322 --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageLimitsView.swift @@ -0,0 +1,368 @@ +import SwiftUI + +struct UsageLimitsView: View { + let client: any FeatureClient + @Binding var resetCreditStates: [UsageResetCreditTarget: UsageResetCreditState] + + @State private var environments: [FeatureEnvironmentUsageLimits] = [] + @State private var hasSnapshot = false + @State private var isRefreshing = false + @State private var streamError: String? + @State private var refreshError: String? + @State private var refreshErrors: [String: String] = [:] + @State private var now = Date() + @State private var subscriptionID = UUID() + + private var groups: [UsageLimitsGroup] { UsageLimitsPresentation.groups(environments) } + private var hasLimits: Bool { groups.contains(where: \.hasLimits) } + private var isWaiting: Bool { + !hasSnapshot || (!environments.isEmpty && environments.allSatisfy { + $0.isPending && $0.providers.isEmpty && $0.sources.isEmpty + }) + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + if let streamError { notice(streamError) } + if let refreshError { notice(refreshError) } + if isRefreshing { + notice("Refreshing limits...") + } else if hasLimits, environments.contains(where: \.isPending) { + notice("Some environments are still reporting limits.") + } + + if isWaiting, streamError == nil { + Text("Loading subscription limits...") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 64) + } else if environments.isEmpty { + ContentUnavailableView { + Label("No limits available", systemImage: "chart.bar.xaxis") + } description: { + Text("Connect an environment to see subscription limits.") + } + } else { + ForEach(groups) { group in + environmentSection(group) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .refreshable { await refresh() } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Refresh limits", systemImage: "arrow.clockwise") { + Task { await refresh() } + } + .disabled(isRefreshing) + } + } + .task(id: subscriptionID) { + let subscription = subscriptionID + do { + for try await snapshot in client.usageLimitsUpdates() { + try Task.checkCancellation() + guard subscription == subscriptionID else { return } + receive(snapshot) + streamError = nil + } + } catch is CancellationError { + return + } catch { + streamError = "Could not load live limits. Refresh to check the latest values." + } + } + } + + private func environmentSection(_ group: UsageLimitsGroup) -> some View { + VStack(alignment: .leading, spacing: 14) { + Text(group.environment.label) + .font(T3Typography.threadHeading3) + .foregroundStyle(T3Colors.textPrimary) + + if let error = refreshErrors[group.id] { + notice("Could not refresh limits. \(error)") + } + if let error = group.environment.errorMessage, + error != refreshErrors[group.id] { + notice(error) + } else if !group.environment.isConnected, !group.environment.isPending { + notice(group.hasLimits + ? "Disconnected. Showing the last known limits." + : "Connect this environment to see limits.") + } + + if !group.hasLimits { + if group.environment.isPending { + notice("Waiting for this environment...") + } else if group.environment.isConnected, group.environment.errorMessage == nil { + notice("No provider reports subscription limits.") + } + } + + ForEach(Array(group.providers.enumerated()), id: \.element.instanceId) { index, provider in + if index > 0 { Divider().overlay(T3Colors.separator) } + if let limits = provider.usageLimits { + VStack(alignment: .leading, spacing: 12) { + UsageLimitsAccountView( + driver: provider.driver, + instanceID: provider.instanceId, + label: provider.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? UsageLimitsPresentation.providerLabel(driver: provider.driver), + detail: provider.auth.label, + limits: limits, + now: now + ) + if let credits = limits.resetCredits { + UsageResetCreditsView( + client: client, + environmentID: group.environment.environmentID, + instanceID: provider.instanceId, + isConnected: group.environment.isConnected && !group.environment.isPending, + credits: credits, + now: now, + state: resetState(environmentID: group.id, instanceID: provider.instanceId) + ) + } + } + } + } + + ForEach(group.sources) { source in + sourceSection(source) + } + } + } + + private func sourceSection(_ row: UsageLimitSourceRows) -> some View { + VStack(alignment: .leading, spacing: 14) { + Text(row.source.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + if let error = row.source.error { + notice(error) + } else if row.accounts.isEmpty { + notice(row.hiddenAccountCount > 0 + ? "These accounts are shown by connected providers." + : "No accounts reported.") + } else { + ForEach(row.accounts) { account in + UsageLimitsAccountView( + driver: account.driver, + instanceID: account.id, + label: UsageLimitsPresentation.providerLabel(driver: account.driver), + detail: account.plan, + limits: account.usageLimits, + now: now + ) + } + } + } + .padding(.top, 8) + } + + private func notice(_ message: String) -> some View { + Text(message) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func receive(_ snapshot: [FeatureEnvironmentUsageLimits]) { + environments = UsageLimitsPresentation.retainingPendingRows(snapshot, previous: environments) + hasSnapshot = true + now = Date() + let ids = Set(snapshot.map(\.environmentID)) + refreshErrors = refreshErrors.filter { ids.contains($0.key) } + } + + private func refresh() async { + guard !isRefreshing else { return } + isRefreshing = true + refreshError = nil + refreshErrors = [:] + defer { + isRefreshing = false + // A failed environment's stream has ended. A manual refresh starts + // a new subscription so that environment can report live updates again. + if !Task.isCancelled { subscriptionID = UUID() } + } + do { + let result = try await client.refreshUsageLimits() + try Task.checkCancellation() + // Live config carries the new bars. Keep operation errors separate + // so a later config snapshot cannot silently remove them. + refreshErrors = Dictionary(uniqueKeysWithValues: result.compactMap { environment in + environment.errorMessage.map { (environment.environmentID, $0) } + }) + if !hasSnapshot || streamError != nil { receive(result) } + now = Date() + } catch is CancellationError { + return + } catch { + refreshError = "Could not refresh limits. Showing the last known values." + } + } + + private func resetState(environmentID: String, instanceID: String) -> Binding { + let target = UsageResetCreditTarget(environmentID: environmentID, instanceID: instanceID) + return Binding( + get: { resetCreditStates[target] ?? UsageResetCreditState() }, + set: { resetCreditStates[target] = $0 } + ) + } +} + +private struct UsageLimitsAccountView: View { + let driver: String + let instanceID: String + let label: String + let detail: String? + let limits: ServerProviderUsageLimits + let now: Date + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + ProviderIcon(driver: driver, providerID: instanceID, fallbackName: label, size: 18) + Text(label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + if let detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(2) + } + } + if let notice = UsageLimitsPresentation.limitsNotice(limits) { + Text(notice) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(UsageLimitsPresentation.visibleWindows(limits)) { window in + UsageLimitWindowView(window: window, driver: driver, now: now) + } + } + } +} + +private struct UsageLimitWindowView: View { + let window: ServerProviderUsageWindow + let driver: String + let now: Date + + var body: some View { + let remaining = UsageLimitsMath.remainingPercent(window) + let timeLeft = UsageLimitsMath.elapsedShare(window, now: now).map { 1 - $0 } + let pace = UsageLimitsMath.pace(window, now: now) + let resetsIn = UsageLimitsMath.resetsIn(window, now: now) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(window.label) + Spacer(minLength: 8) + Text("\(Int(remaining))% left") + .monospacedDigit() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textPrimary) + + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(T3Colors.subtleStrong) + .frame(height: 6) + Capsule().fill(barColor(remaining: remaining)) + .frame(width: geometry.size.width * remaining / 100, height: 6) + if let timeLeft { + Rectangle().fill(T3Colors.textSecondary) + .frame(width: 1, height: 12) + .offset(x: max(0, geometry.size.width - 1) * timeLeft) + } + } + .frame(height: 12) + } + .frame(height: 12) + .accessibilityHidden(true) + + if pace != nil || resetsIn != nil { + HStack(alignment: .firstTextBaseline) { + if let pace { Text(pace.label) } + Spacer(minLength: 8) + if let resetsIn { Text(resetsIn).monospacedDigit() } + } + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + } + } + .accessibilityElement(children: .combine) + } + + private func barColor(remaining: Double) -> Color { + if remaining <= 10 { return T3Colors.danger } + if remaining <= 30 { return T3Colors.warning } + return driver == "claudeAgent" + ? Color(red: 0.851, green: 0.467, blue: 0.341) + : T3Colors.textPrimary + } +} + +private struct UsageResetCreditsView: View { + let client: any FeatureClient + let environmentID: String + let instanceID: String + let isConnected: Bool + let credits: ServerProviderResetCredits + let now: Date + @Binding var state: UsageResetCreditState + + @State private var confirmationPresented = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(UsageLimitsMath.creditSummary(credits, now: now)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + if credits.availableCount > 0 || state.isPending { + Button(state.isPending ? "Using credit..." : "Use a reset credit") { + confirmationPresented = true + } + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .frame(minHeight: T3Metrics.minimumTapTarget, alignment: .leading) + .disabled(state.isPending || !isConnected) + } + if let status = state.statusMessage { + Text(status) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .alert("Use a reset credit?", isPresented: $confirmationPresented) { + Button("Cancel", role: .cancel) {} + Button("Use credit") { Task { await redeem() } } + } message: { + Text("This uses one credit on your account and clears the current rate-limit windows. You cannot undo it.") + } + } + + private func redeem() async { + guard state.begin(availableCount: credits.availableCount, isConnected: isConnected) else { return } + do { + let result = try await client.consumeResetCredit(environmentID: environmentID, instanceID: instanceID) + state.finish(result.outcome) + } catch { + state.fail(error) + } + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/apps/swift-ios/Features/Usage/UsageModels.swift b/apps/swift-ios/Features/Usage/UsageModels.swift new file mode 100644 index 000000000000..604cb36df4db --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageModels.swift @@ -0,0 +1,566 @@ +import Foundation + +public struct FeatureEnvironmentUsage: Identifiable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let summary: UsageSummary? + public let errorMessage: String? + public let isPending: Bool + + public var id: String { environmentID } + + public init( + environmentID: String, + label: String, + summary: UsageSummary?, + errorMessage: String? = nil, + isPending: Bool = false + ) { + self.environmentID = environmentID + self.label = label + self.summary = summary + self.errorMessage = errorMessage + self.isPending = isPending + } +} + +struct UsageProviderTotals: Identifiable, Equatable { + let provider: UsageProviderKind + let costUsd: Double + let totalTokens: Int + let records: Int + let costShare: Double + let tokenShare: Double + + var id: UsageProviderKind { provider } +} + +struct UsageModelTotals: Identifiable, Equatable { + let model: String + let provider: UsageProviderKind + let costUsd: Double + let totalTokens: Int + let records: Int + let costShare: Double + + var id: String { "\(provider.rawValue):\(model)" } +} + +struct UsageProviderValue: Equatable { + var costUsd = 0.0 + var totalTokens = 0 +} + +struct UsageDailyTotals: Identifiable, Equatable { + let day: String + let costUsd: Double + let totalTokens: Int + let byProvider: [UsageProviderKind: UsageProviderValue] + + var id: String { day } +} + +struct UsageHourlyTotals: Identifiable, Equatable { + let hourStart: String + let costUsd: Double + let totalTokens: Int + let byProvider: [UsageProviderKind: UsageProviderValue] + + var id: String { hourStart } +} + +struct UsageCostQuality: Equatable { + let providerReportedShare: Double + let modelPricedShare: Double + let unpricedShare: Double + let cacheSavingsUsd: Double +} + +struct MergedUsage: Equatable { + var costUsd = 0.0 + var uncachedInputTokens = 0 + var cachedInputTokens = 0 + var cacheCreationTokens = 0 + var outputTokens = 0 + var reasoningTokens = 0 + var totalTokens = 0 + var records = 0 + var sessions = 0 + var providers: [UsageProviderTotals] = [] + var models: [UsageModelTotals] = [] + var daily: [UsageDailyTotals] = [] + var hourly: [UsageHourlyTotals] = [] + var costQuality = UsageCostQuality( + providerReportedShare: 0, + modelPricedShare: 0, + unpricedShare: 0, + cacheSavingsUsd: 0 + ) + var duplicateSources: [String] = [] + var contributingEnvironments: [String] = [] + var staleEnvironments: [String] = [] +} + +struct UsageLoadRequest: Equatable { + let id: UUID + let days: Int + let input: UsageSummaryInput +} + +struct UsageLoadState: Equatable { + private(set) var windowDays: Int + private(set) var windowInput: UsageSummaryInput + private(set) var environments: [FeatureEnvironmentUsage] = [] + private(set) var merged = MergedUsage() + private(set) var isLoading = true + private(set) var errorMessage: String? + private var activeLoadID: UUID? + + var isPartial: Bool { + environments.contains { + $0.summary.map { + isCompatibleUsageContractVersion($0.contractVersion, resolution: windowInput.resolution) + } == true + } + && environments.contains { $0.isPending && $0.summary == nil } + } + + var hasPendingCachedTotals: Bool { + environments.contains { $0.isPending && $0.summary != nil } + } + + init( + days: Int = 30, + now: Date = Date(), + timeZone: TimeZone = .current + ) { + windowDays = days + windowInput = UsageWindow.make(days: days, now: now, timeZone: timeZone) + } + + mutating func begin( + days: Int, + now: Date = Date(), + timeZone: TimeZone = .current + ) -> UsageLoadRequest { + selectWindow(days: days, now: now, timeZone: timeZone) + let request = UsageLoadRequest( + id: UUID(), + days: days, + input: UsageWindow.make(days: days, now: now, timeZone: timeZone) + ) + activeLoadID = request.id + isLoading = true + errorMessage = nil + return request + } + + mutating func selectWindow( + days: Int, + now: Date = Date(), + timeZone: TimeZone = .current + ) { + guard days != windowDays else { return } + windowDays = days + windowInput = UsageWindow.make(days: days, now: now, timeZone: timeZone) + environments = [] + merged = MergedUsage() + isLoading = true + errorMessage = nil + } + + @discardableResult + mutating func receive( + _ result: [FeatureEnvironmentUsage], + for request: UsageLoadRequest + ) -> Bool { + guard activeLoadID == request.id, request.days == windowDays else { return false } + // A pending row may retain the last scan only for the same window. + // Results from a previous day or rolling hour must not enter new totals. + let previous = windowInput == request.input + ? Dictionary(uniqueKeysWithValues: environments.map { ($0.environmentID, $0) }) + : [:] + windowInput = request.input + environments = result.map { environment in + guard environment.isPending, + environment.summary == nil, + let summary = previous[environment.environmentID]?.summary else { + return environment + } + return FeatureEnvironmentUsage( + environmentID: environment.environmentID, + label: environment.label, + summary: summary, + errorMessage: environment.errorMessage, + isPending: true + ) + } + merged = UsageMerger.merge(environments, resolution: request.input.resolution) + errorMessage = nil + return true + } + + @discardableResult + mutating func fail( + _ error: any Error, + for request: UsageLoadRequest + ) -> Bool { + guard activeLoadID == request.id, request.days == windowDays else { return false } + errorMessage = error.localizedDescription + return true + } + + mutating func finish(_ request: UsageLoadRequest) { + guard activeLoadID == request.id, request.days == windowDays else { return } + activeLoadID = nil + isLoading = false + } +} + +enum UsageMerger { + private struct OwnedContribution { + let buckets: [UsageBucket] + let sessions: Int + } + + private struct ProviderAccumulator { + var costUsd = 0.0 + var totalTokens = 0 + var records = 0 + } + + private struct ModelAccumulator { + let provider: UsageProviderKind + var costUsd = 0.0 + var totalTokens = 0 + var records = 0 + } + + private struct DailyAccumulator { + var costUsd = 0.0 + var totalTokens = 0 + var byProvider: [UsageProviderKind: UsageProviderValue] = [:] + } + + static func merge( + _ environments: [FeatureEnvironmentUsage], + resolution: UsageResolution? = nil + ) -> MergedUsage { + let available = environments.compactMap { environment -> (FeatureEnvironmentUsage, UsageSummary)? in + guard let summary = environment.summary else { return nil } + return (environment, summary) + } + let current = available.filter { + isCompatibleUsageContractVersion($0.1.contractVersion, resolution: resolution) + } + let staleEnvironmentIDs = available.compactMap { environment, summary in + isCompatibleUsageContractVersion(summary.contractVersion, resolution: resolution) + ? nil + : environment.environmentID + } + let claims = claimSources(current) + + var result = MergedUsage() + result.duplicateSources = claims.duplicates + result.staleEnvironments = staleEnvironmentIDs + + var cacheSavingsUsd = 0.0 + var providerReportedRecords = 0 + var unpricedRecords = 0 + var providers: [UsageProviderKind: ProviderAccumulator] = [:] + var models: [String: ModelAccumulator] = [:] + var daily: [String: DailyAccumulator] = [:] + var hourly: [String: DailyAccumulator] = [:] + + for (environment, summary) in current { + let contribution = ownedContribution( + environment: environment, + summary: summary, + ownerByFingerprint: claims.ownerByFingerprint + ) + if !contribution.buckets.isEmpty { + result.contributingEnvironments.append(environment.environmentID) + } + result.sessions += contribution.sessions + + for bucket in contribution.buckets { + let tokens = totalTokens(bucket) + result.costUsd += bucket.costUsd + result.uncachedInputTokens += bucket.totals.uncachedInputTokens + result.cachedInputTokens += bucket.totals.cachedInputTokens + result.cacheCreationTokens += bucket.totals.cacheCreationTokens + result.outputTokens += bucket.totals.outputTokens + result.reasoningTokens += bucket.totals.reasoningTokens + result.records += bucket.records + cacheSavingsUsd += bucket.cacheSavingsUsd + unpricedRecords += bucket.unpricedRecords + if bucket.costSource == .providerReported { + providerReportedRecords += bucket.records + } + + var provider = providers[bucket.provider] ?? ProviderAccumulator() + provider.costUsd += bucket.costUsd + provider.totalTokens += tokens + provider.records += bucket.records + providers[bucket.provider] = provider + + let modelKey = "\(bucket.provider.rawValue) \(bucket.model)" + var model = models[modelKey] ?? ModelAccumulator(provider: bucket.provider) + model.costUsd += bucket.costUsd + model.totalTokens += tokens + model.records += bucket.records + models[modelKey] = model + + var day = daily[bucket.day] ?? DailyAccumulator() + day.costUsd += bucket.costUsd + day.totalTokens += tokens + var dayProvider = day.byProvider[bucket.provider] ?? UsageProviderValue() + dayProvider.costUsd += bucket.costUsd + dayProvider.totalTokens += tokens + day.byProvider[bucket.provider] = dayProvider + daily[bucket.day] = day + + if let hourStart = bucket.hourStart { + var hour = hourly[hourStart] ?? DailyAccumulator() + hour.costUsd += bucket.costUsd + hour.totalTokens += tokens + var hourProvider = hour.byProvider[bucket.provider] ?? UsageProviderValue() + hourProvider.costUsd += bucket.costUsd + hourProvider.totalTokens += tokens + hour.byProvider[bucket.provider] = hourProvider + hourly[hourStart] = hour + } + } + } + + result.totalTokens = result.uncachedInputTokens + + result.cachedInputTokens + + result.cacheCreationTokens + + result.outputTokens + result.providers = providers.map { provider, totals in + UsageProviderTotals( + provider: provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: result.costUsd == 0 ? 0 : totals.costUsd / result.costUsd, + tokenShare: result.totalTokens == 0 + ? 0 + : Double(totals.totalTokens) / Double(result.totalTokens) + ) + } + .sorted { $0.costUsd > $1.costUsd } + result.models = models.map { key, totals in + UsageModelTotals( + model: String(key.split(separator: " ", maxSplits: 1).last ?? ""), + provider: totals.provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: result.costUsd == 0 ? 0 : totals.costUsd / result.costUsd + ) + } + .sorted { + $0.costUsd == $1.costUsd + ? $0.totalTokens > $1.totalTokens + : $0.costUsd > $1.costUsd + } + result.daily = daily.map { day, totals in + UsageDailyTotals( + day: day, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider + ) + } + .sorted { $0.day < $1.day } + result.hourly = hourly.map { hourStart, totals in + UsageHourlyTotals( + hourStart: hourStart, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider + ) + } + .sorted { $0.hourStart < $1.hourStart } + result.costQuality = UsageCostQuality( + providerReportedShare: result.records == 0 + ? 0 + : Double(providerReportedRecords) / Double(result.records), + modelPricedShare: result.records == 0 + ? 0 + : Double(result.records - providerReportedRecords - unpricedRecords) + / Double(result.records), + unpricedShare: result.records == 0 + ? 0 + : Double(unpricedRecords) / Double(result.records), + cacheSavingsUsd: cacheSavingsUsd + ) + return result + } + + private static func claimSources( + _ environments: [(FeatureEnvironmentUsage, UsageSummary)] + ) -> (ownerByFingerprint: [UsageSourceFingerprint: String], duplicates: [String]) { + var selected: [ + UsageSourceFingerprint: (environmentID: String, status: UsageSourceStatus) + ] = [:] + var duplicates: [String] = [] + let ordered = environments.sorted { $0.0.environmentID < $1.0.environmentID } + + for (environment, summary) in ordered { + for source in summary.sources where source.status != .missing { + if let current = selected[source.fingerprint] { + if source.status.ownershipPriority > current.status.ownershipPriority { + selected[source.fingerprint] = (environment.environmentID, source.status) + } + } else { + selected[source.fingerprint] = (environment.environmentID, source.status) + } + } + } + + let owners = selected.mapValues(\.environmentID) + for (environment, summary) in ordered { + for source in summary.sources where source.status != .missing { + if owners[source.fingerprint] != environment.environmentID { + duplicates.append( + "\(environment.label): \(source.fingerprint.resolvedHomePath)" + ) + } + } + } + return (owners, duplicates) + } + + private static func ownedContribution( + environment: FeatureEnvironmentUsage, + summary: UsageSummary, + ownerByFingerprint: [UsageSourceFingerprint: String] + ) -> OwnedContribution { + var providers: Set = [] + var sessions = 0 + for source in summary.sources where source.status != .missing { + if ownerByFingerprint[source.fingerprint] == environment.environmentID { + providers.insert(source.fingerprint.provider) + sessions += source.distinctSessions + } + } + return OwnedContribution( + buckets: summary.buckets.filter { providers.contains($0.provider) }, + sessions: sessions + ) + } + + private static func totalTokens(_ bucket: UsageBucket) -> Int { + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens + } +} + +private extension UsageSourceStatus { + var ownershipPriority: Int { + switch self { + case .missing: 0 + case .failed: 1 + case .partial: 2 + case .ok: 3 + } + } +} + +enum UsageWindow { + static func make( + days: Int, + now: Date = Date(), + timeZone: TimeZone = .current + ) -> UsageSummaryInput { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let until = calendar.startOfDay(for: now) + let since = calendar.date(byAdding: .day, value: -(days - 1), to: until) ?? until + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd" + if days == 1 { + let untilTimeInterval = floor(now.timeIntervalSince1970 / 60) * 60 + let untilTime = Date(timeIntervalSince1970: untilTimeInterval) + let sinceTime = untilTime.addingTimeInterval(-24 * 60 * 60) + return UsageSummaryInput( + sinceDay: formatter.string(from: sinceTime), + untilDay: formatter.string(from: untilTime), + timeZone: timeZone.identifier, + resolution: .hour, + sinceTime: isoString(sinceTime), + untilTime: isoString(untilTime) + ) + } + return UsageSummaryInput( + sinceDay: formatter.string(from: since), + untilDay: formatter.string(from: until), + timeZone: timeZone.identifier, + resolution: .day + ) + } + + static func hours(in input: UsageSummaryInput) -> [String] { + guard let sinceValue = input.sinceTime, + let untilValue = input.untilTime, + let since = isoDate(sinceValue), + let until = isoDate(untilValue), + since < until else { + return [] + } + var result: [String] = [] + var cursor = since + while cursor < until { + result.append(isoString(cursor)) + cursor = cursor.addingTimeInterval(60 * 60) + } + return result + } + + private static func isoString(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + private static func isoDate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } + + static func days(in input: UsageSummaryInput) -> [String] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let parser = DateFormatter() + parser.calendar = calendar + parser.locale = Locale(identifier: "en_US_POSIX") + parser.timeZone = TimeZone(secondsFromGMT: 0) + parser.dateFormat = "yyyy-MM-dd" + guard let start = parser.date(from: input.sinceDay), + let end = parser.date(from: input.untilDay), + start <= end else { + return [] + } + + var result: [String] = [] + var cursor = start + while cursor <= end { + result.append(parser.string(from: cursor)) + guard let next = calendar.date( + byAdding: .day, + value: 1, + to: cursor + ) else { break } + cursor = next + } + return result + } +} diff --git a/apps/swift-ios/Features/Usage/UsageView.swift b/apps/swift-ios/Features/Usage/UsageView.swift new file mode 100644 index 000000000000..b6db21a23cad --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageView.swift @@ -0,0 +1,739 @@ +import Charts +import SwiftUI + +private enum UsageMetric: String, CaseIterable, Identifiable { + case cost + case tokens + + var id: Self { self } + var label: String { rawValue.uppercased() } +} + +private enum UsageBreakdown: String, CaseIterable, Identifiable { + case model + case time + + var id: Self { self } +} + +private enum UsageTab: String { + case usage + case limits +} + +public struct UsageView: View { + private let client: any FeatureClient + + @State private var loadState = UsageLoadState() + @State private var metric = UsageMetric.cost + @State private var breakdown = UsageBreakdown.model + @State private var tab = UsageTab.usage + @State private var resetCreditStates: [UsageResetCreditTarget: UsageResetCreditState] = [:] + + public init(client: any FeatureClient) { + self.client = client + } + + private var windowInput: UsageSummaryInput { loadState.windowInput } + private var environments: [FeatureEnvironmentUsage] { loadState.environments } + private var merged: MergedUsage { loadState.merged } + private var isLoading: Bool { loadState.isLoading } + private var errorMessage: String? { loadState.errorMessage } + private var windowDays: Binding { + Binding( + get: { loadState.windowDays }, + set: { loadState.selectWindow(days: $0) } + ) + } + + public var body: some View { + VStack(spacing: 0) { + Picker("Usage view", selection: $tab) { + Text("Usage").tag(UsageTab.usage) + Text("Limits").tag(UsageTab.limits) + } + .pickerStyle(.segmented) + .tint(T3Colors.textPrimary) + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + + if tab == .limits { + UsageLimitsView(client: client, resetCreditStates: $resetCreditStates) + } else { + usageContent + } + } + .background(T3Colors.background) + .navigationTitle("Usage") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .navigationBar) + .toolbar { + if tab == .usage { + ToolbarItem(placement: .topBarTrailing) { + Button("Refresh prices", systemImage: "arrow.clockwise") { + Task { await load(refreshPricing: true) } + } + .disabled(isLoading) + } + } + } + .t3NavigationChrome() + } + + private var usageContent: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + Picker("Usage window", selection: windowDays) { + Text("24h").tag(1) + Text("7d").tag(7) + Text("30d").tag(30) + Text("90d").tag(90) + } + .pickerStyle(.segmented) + .tint(T3Colors.textPrimary) + + coverageNotice + + if isLoading, !hasCompatibleSummary, + environments.isEmpty || environments.contains(where: \.isPending) { + Text("Scanning provider transcripts…") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 64) + } else if let errorMessage, environments.isEmpty { + ContentUnavailableView { + Label("Couldn’t load usage", systemImage: "exclamationmark.circle") + } description: { + Text(errorMessage) + } actions: { + Button("Try again") { Task { await load() } } + } + } else if environments.isEmpty { + ContentUnavailableView { + Label("No environments", systemImage: "chart.bar.xaxis") + } description: { + Text("Connect an environment to see usage.") + } + } else if !hasCompatibleSummary { + ContentUnavailableView { + Label("Couldn’t load usage", systemImage: "exclamationmark.circle") + } description: { + Text(hasDailyOnlySummary + ? "This server reports daily usage. Select 7d or 30d to see it." + : "No compatible usage data is available.") + } actions: { + Button("Try again") { Task { await load() } } + } + } else { + chartCard + providersSection + totalsSection + breakdownSection + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .refreshable { await load() } + .task(id: loadState.windowDays) { + await load() + } + } + + @ViewBuilder + private var coverageNotice: some View { + let failed = environments.filter { $0.errorMessage != nil } + let stale = environments.filter { merged.staleEnvironments.contains($0.environmentID) } + let hasRefreshError = errorMessage != nil && hasCompatibleSummary + if hasRefreshError + || loadState.isPartial + || loadState.hasPendingCachedTotals + || !failed.isEmpty + || !stale.isEmpty + || !merged.duplicateSources.isEmpty { + VStack(alignment: .leading, spacing: 6) { + if loadState.hasPendingCachedTotals { + Text(loadState.isPartial + ? "Updating usage. Totals are partial and include the last scan." + : "Updating usage. Some totals are from the last scan.") + } else if loadState.isPartial { + Text("Some environments are still reporting. Totals are partial.") + } + if hasRefreshError { + Text("Couldn’t refresh usage. The totals below are from the last successful scan.") + } + ForEach(failed) { environment in + Text("\(environment.label): \(environment.errorMessage ?? "Could not report usage.")") + } + ForEach(stale) { environment in + if windowInput.resolution == .hour, environment.summary?.contractVersion == 3 { + Text("\(environment.label) reports daily usage only. Select 7d or 30d to include it.") + } else { + Text("\(environment.label) uses an unsupported usage format and is excluded from totals.") + } + } + if !merged.duplicateSources.isEmpty { + Text( + "Counted once across environments sharing a transcript directory: " + + merged.duplicateSources.joined(separator: ", ") + ) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 16)) + } + } + + private var chartCard: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(metric == .cost ? "Raw token cost" : "Processed tokens") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Text( + metric == .cost + ? "\(UsageFormat.usd(merged.costUsd))*" + : UsageFormat.tokens(merged.totalTokens) + ) + .font(.system(.largeTitle, design: .default, weight: .bold)) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.55) + Text( + metric == .cost + ? "* if billed at full API rate" + : "Across \(UsageFormat.count(merged.sessions)) sessions" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("Chart metric", selection: $metric) { + ForEach(UsageMetric.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .fixedSize() + .tint(T3Colors.textPrimary) + } + + if merged.daily.contains(where: { + metric == .cost ? $0.costUsd > 0 : $0.totalTokens > 0 + }) { + UsagePeriodChart( + input: windowInput, + daily: merged.daily, + hourly: merged.hourly, + metric: metric + ) + .frame(height: 180) + } else { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 180) + } + + HStack(spacing: 8) { + Text(UsageFormat.dayShort(windowInput.sinceDay)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 14) { + ForEach(merged.providers) { provider in + HStack(spacing: 5) { + Circle() + .fill(provider.provider.color) + .frame(width: 8, height: 8) + Text(provider.provider.displayName) + } + } + } + .fixedSize() + + Text(UsageFormat.dayShort(windowInput.untilDay)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(16) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 24)) + } + + @ViewBuilder + private var providersSection: some View { + if !merged.providers.isEmpty { + UsageSection(title: "Providers") { + let ordered = merged.providers.sorted { + metric == .cost + ? $0.costUsd > $1.costUsd + : $0.totalTokens > $1.totalTokens + } + VStack(spacing: 0) { + ForEach(Array(ordered.enumerated()), id: \.element.id) { index, provider in + if index > 0 { usageDivider } + let share = metric == .cost ? provider.costShare : provider.tokenShare + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Circle() + .fill(provider.provider.color) + .frame(width: 10, height: 10) + Text(provider.provider.displayName) + .font(.title3) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 8) + Text( + metric == .cost + ? UsageFormat.usd(provider.costUsd) + : UsageFormat.tokens(provider.totalTokens) + ) + .font(.title3) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + } + UsageProgressBar(value: share, color: provider.provider.color) + Text( + metric == .cost + ? "\(UsageFormat.percent(share)) of cost · " + + "\(UsageFormat.tokens(provider.totalTokens)) tokens" + : "\(UsageFormat.percent(share)) of tokens · \(UsageFormat.usd(provider.costUsd))" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(16) + } + } + .usageCard() + } + } + } + + private var totalsSection: some View { + UsageSection(title: "Totals") { + let isHourly = windowInput.resolution == .hour + let activePeriods = isHourly + ? merged.hourly.filter { $0.totalTokens > 0 }.count + : merged.daily.filter { $0.totalTokens > 0 }.count + let periodAverage = activePeriods == 0 ? 0 : merged.totalTokens / activePeriods + let observedInput = merged.uncachedInputTokens + merged.cachedInputTokens + let cachedShare = observedInput == 0 + ? 0 + : Double(merged.cachedInputTokens) / Double(observedInput) + LazyVGrid( + columns: [GridItem(.flexible(), alignment: .topLeading), GridItem(.flexible(), alignment: .topLeading)], + alignment: .leading, + spacing: 0 + ) { + UsageMetricCell( + label: "Processed tokens", + value: UsageFormat.tokens(merged.totalTokens), + detail: "\(UsageFormat.tokens(periodAverage)) per active \(isHourly ? "hour" : "day")" + ) + UsageMetricCell( + label: "Cache savings", + value: UsageFormat.usd(merged.costQuality.cacheSavingsUsd), + detail: merged.costUsd > 0 + ? String(format: "%.1fx the raw cost", merged.costQuality.cacheSavingsUsd / merged.costUsd) + : "vs full input rates" + ) + UsageMetricCell( + label: "Cached input", + value: UsageFormat.tokens(merged.cachedInputTokens), + detail: "\(UsageFormat.percent(cachedShare)) of observed input" + ) + UsageMetricCell( + label: "Uncached input", + value: UsageFormat.tokens(merged.uncachedInputTokens), + detail: "\(UsageFormat.tokens(merged.cacheCreationTokens)) cache writes" + ) + UsageMetricCell( + label: "Output", + value: UsageFormat.tokens(merged.outputTokens), + detail: "incl. \(UsageFormat.tokens(merged.reasoningTokens)) reasoning" + ) + UsageMetricCell( + label: "Unpriced", + value: UsageFormat.percent(merged.costQuality.unpricedShare), + detail: "of records, excluded from cost" + ) + } + .usageCard() + } + } + + private var breakdownSection: some View { + UsageSection(title: "Breakdown") { + VStack(spacing: 12) { + Picker("Breakdown", selection: $breakdown) { + Text("Model").tag(UsageBreakdown.model) + Text(windowInput.resolution == .hour ? "Hour" : "Day") + .tag(UsageBreakdown.time) + } + .pickerStyle(.segmented) + + if breakdown == .model { + modelBreakdown + } else { + timeBreakdown + } + } + } + } + + @ViewBuilder + private var modelBreakdown: some View { + if merged.models.isEmpty { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(24) + .usageCard() + } else { + VStack(spacing: 0) { + ForEach(Array(merged.models.enumerated()), id: \.element.id) { index, model in + if index > 0 { usageDivider } + HStack(spacing: 12) { + Circle() + .fill(model.provider.color) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(model.model) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Text( + "\(UsageFormat.percent(model.costShare)) of cost · " + + "\(UsageFormat.tokens(model.totalTokens)) tokens" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer(minLength: 8) + Text(UsageFormat.usd(model.costUsd)) + .font(T3Typography.threadBody) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + } + .padding(16) + } + } + .usageCard() + } + } + + @ViewBuilder + private var timeBreakdown: some View { + let periods = usagePeriods + if periods.isEmpty { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(24) + .usageCard() + } else { + LazyVStack(spacing: 0) { + ForEach(Array(periods.enumerated()), id: \.element.id) { index, period in + if index > 0 { usageDivider } + HStack(spacing: 12) { + Text(period.label) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + Text(UsageFormat.usd(period.costUsd)) + .font(T3Typography.threadBody) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + Text(UsageFormat.tokens(period.totalTokens)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .padding(16) + } + } + .usageCard() + } + } + + private var usagePeriods: [UsagePeriodPresentation] { + if windowInput.resolution == .hour { + return merged.hourly.reversed().map { + UsagePeriodPresentation( + id: $0.hourStart, + label: UsageFormat.hourShort($0.hourStart, timeZone: windowInput.timeZone), + costUsd: $0.costUsd, + totalTokens: $0.totalTokens + ) + } + } + return merged.daily.reversed().map { + UsagePeriodPresentation( + id: $0.day, + label: UsageFormat.dayShort($0.day), + costUsd: $0.costUsd, + totalTokens: $0.totalTokens + ) + } + } + + private var usageDivider: some View { + Divider().overlay(T3Colors.separator) + } + + private var hasCompatibleSummary: Bool { + environments.contains { + $0.summary.map { + isCompatibleUsageContractVersion($0.contractVersion, resolution: windowInput.resolution) + } == true + } + } + + private var hasDailyOnlySummary: Bool { + windowInput.resolution == .hour + && environments.contains { $0.summary?.contractVersion == 3 } + } + + private func load(refreshPricing: Bool = false) async { + let request = loadState.begin(days: loadState.windowDays) + defer { loadState.finish(request) } + do { + for try await result in client.usageSummaryUpdates(request.input, refreshPricing: refreshPricing) { + try Task.checkCancellation() + loadState.receive(result, for: request) + } + } catch is CancellationError { + return + } catch { + loadState.fail(error, for: request) + } + } +} + +private struct UsagePeriodChart: View { + let input: UsageSummaryInput + let daily: [UsageDailyTotals] + let hourly: [UsageHourlyTotals] + let metric: UsageMetric + + private var segments: [UsageChartSegment] { + if input.resolution == .hour { + let hourlyByStart = Dictionary(uniqueKeysWithValues: hourly.map { ($0.hourStart, $0) }) + return UsageWindow.hours(in: input).flatMap { hourStart in + chartSegments( + period: hourStart, + byProvider: hourlyByStart[hourStart]?.byProvider ?? [:] + ) + } + } + let dailyByDay = Dictionary(uniqueKeysWithValues: daily.map { ($0.day, $0) }) + return UsageWindow.days(in: input).flatMap { day in + chartSegments(period: day, byProvider: dailyByDay[day]?.byProvider ?? [:]) + } + } + + private func chartSegments( + period: String, + byProvider: [UsageProviderKind: UsageProviderValue] + ) -> [UsageChartSegment] { + var start = 0.0 + return UsageProviderKind.allCases.map { provider in + let totals = byProvider[provider] + let value = metric == .cost + ? totals?.costUsd ?? 0 + : Double(totals?.totalTokens ?? 0) + defer { start += value } + return UsageChartSegment( + period: period, + provider: provider, + start: start, + end: start + value + ) + } + } + + var body: some View { + Chart(segments) { segment in + BarMark( + x: .value("Period", segment.period), + yStart: .value("Start", segment.start), + yEnd: .value("End", segment.end) + ) + .foregroundStyle(segment.provider.color) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + } +} + +private struct UsageChartSegment: Identifiable { + let period: String + let provider: UsageProviderKind + let start: Double + let end: Double + + var id: String { "\(period):\(provider.rawValue)" } +} + +private struct UsagePeriodPresentation: Identifiable { + let id: String + let label: String + let costUsd: Double + let totalTokens: Int +} + +private struct UsageSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 14) + content + } + } +} + +private struct UsageMetricCell: View { + let label: String + let value: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Text(value) + .font(.title3.weight(.medium)) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(detail) + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(2) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct UsageProgressBar: View { + let value: Double + let color: Color + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(T3Colors.subtle) + Capsule() + .fill(color) + .frame(width: geometry.size.width * min(max(value, 0), 1)) + } + } + .frame(height: 4) + .accessibilityHidden(true) + } +} + +private extension View { + func usageCard() -> some View { + background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 24)) + } +} + +private extension UsageProviderKind { + var color: Color { + switch self { + case .codex: T3Colors.textPrimary + case .claude: Color(red: 0.851, green: 0.467, blue: 0.341) + case .grok: T3Colors.textSecondary + } + } +} + +private enum UsageFormat { + static func usd(_ value: Double) -> String { + value.formatted( + .currency(code: "USD") + .locale(Locale(identifier: "en_US")) + .precision(.fractionLength(2)) + ) + } + + static func count(_ value: Int) -> String { + value.formatted(.number.locale(Locale(identifier: "en_US"))) + } + + static func tokens(_ value: Int) -> String { + let magnitude = abs(Double(value)) + if magnitude >= 1_000_000_000_000 { return compact(Double(value) / 1_000_000_000_000, suffix: "T") } + if magnitude >= 1_000_000_000 { return compact(Double(value) / 1_000_000_000, suffix: "B") } + if magnitude >= 1_000_000 { return compact(Double(value) / 1_000_000, suffix: "M") } + if magnitude >= 1_000 { return compact(Double(value) / 1_000, suffix: "K") } + return count(value) + } + + static func percent(_ value: Double) -> String { + String(format: "%.1f%%", value * 100) + } + + static func dayShort(_ day: String) -> String { + let components = day.split(separator: "-") + guard components.count == 3, + let month = Int(components[1]), + let dayOfMonth = Int(components[2]), + (1...12).contains(month) else { + return day + } + let months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + return "\(months[month - 1]) \(dayOfMonth)" + } + + static func hourShort(_ value: String, timeZone: String) -> String { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = withFractional.date(from: value) + ?? ISO8601DateFormatter().date(from: value) else { + return value + } + let formatter = DateFormatter() + formatter.locale = .current + formatter.timeZone = TimeZone(identifier: timeZone) ?? .current + formatter.setLocalizedDateFormatFromTemplate("EEEha") + return formatter.string(from: date) + } + + private static func compact(_ value: Double, suffix: String) -> String { + let digits = abs(value) >= 100 ? 0 : abs(value) >= 10 ? 1 : 2 + var formatted = String(format: "%.*f", digits, value) + while formatted.hasSuffix("0"), formatted.contains(".") { + formatted.removeLast() + } + if formatted.hasSuffix(".") { formatted.removeLast() } + return formatted + suffix + } +} diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift new file mode 100644 index 000000000000..d8c931dce1bf --- /dev/null +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -0,0 +1,1449 @@ +import Foundation + +public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { + public let id: UUID + private var inlineData: Data? + public var ownedFile: FeatureOwnedAttachmentFile? + public var thumbnailData: Data? + public var filename: String + public var mimeType: String + public var uploadedReference: FeatureUploadedAttachmentReference? + + public init( + id: UUID = UUID(), + data: Data, + thumbnailData: Data? = nil, + filename: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = data + ownedFile = nil + self.thumbnailData = thumbnailData + self.filename = filename + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + public init( + id: UUID = UUID(), + ownedFile: FeatureOwnedAttachmentFile, + thumbnailData: Data? = nil, + filename: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = nil + self.ownedFile = ownedFile + self.thumbnailData = thumbnailData + self.filename = filename + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + /// Kept for image-only callers. File-backed attachments return empty data + /// instead of loading up to 50 MB into a UI property. + public var data: Data { + get { inlineData ?? Data() } + set { + inlineData = newValue + ownedFile = nil + } + } + + public var byteCount: Int { + inlineData?.count ?? ownedFile?.byteCount ?? 0 + } +} + +public struct NewTaskRequest: Sendable, Equatable { + public var projectID: String + public var prompt: String + public var selection: FeatureSelection? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + public var workspaceMode: FeatureWorkspaceMode + public var branch: String? + public var worktreePath: String? + public var startFromOrigin: Bool + public var attachments: [FeatureDraftAttachment] + + public init( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode = .fullAccess, + interactionMode: FeatureInteractionMode = .standard, + workspaceMode: FeatureWorkspaceMode = .local, + branch: String? = nil, + worktreePath: String? = nil, + startFromOrigin: Bool = true, + attachments: [FeatureDraftAttachment] = [] + ) { + self.projectID = projectID + self.prompt = prompt + self.selection = selection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode.mobileNormalized + self.workspaceMode = workspaceMode + self.branch = Self.nonEmpty(branch) + self.worktreePath = workspaceMode == .local ? Self.nonEmpty(worktreePath) : nil + self.startFromOrigin = workspaceMode == .worktree && startFromOrigin + self.attachments = attachments + } + + public var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +public struct FeatureMessageSubmission: Sendable, Equatable { + public var threadID: String + public var text: String + public var selection: FeatureSelection? + public var attachments: [FeatureDraftAttachment] + + public init( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureDraftAttachment] = [] + ) { + self.threadID = threadID + self.text = text + self.selection = selection + self.attachments = attachments + } +} + +struct DailyUXSnoozePreset: Identifiable, Equatable { + enum ID: String { + case hour + case threeHours + case evening + case tomorrow + case nextWeek + } + + let id: ID + let label: String + let until: Date +} + +enum DailyUXSnoozePresets { + static func resolve(now: Date, calendar: Calendar = .current) -> [DailyUXSnoozePreset] { + var result = [ + DailyUXSnoozePreset( + id: .hour, + label: "In 1 hour", + until: now.addingTimeInterval(60 * 60) + ), + DailyUXSnoozePreset( + id: .threeHours, + label: "In 3 hours", + until: now.addingTimeInterval(3 * 60 * 60) + ), + ] + + if let evening = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: now), + evening.timeIntervalSince(now) > 60 * 60 { + result.append(.init(id: .evening, label: "This evening", until: evening)) + } + + let tomorrow = calendar.date( + bySettingHour: 9, + minute: 0, + second: 0, + of: calendar.date(byAdding: .day, value: 1, to: now) ?? now + ) + if let tomorrow { + result.append(.init(id: .tomorrow, label: "Tomorrow", until: tomorrow)) + } + + let weekday = calendar.component(.weekday, from: now) + let daysUntilMonday = (2 - weekday + 7) % 7 + let nextMondayOffset = daysUntilMonday == 0 ? 7 : daysUntilMonday + if let monday = calendar.date(byAdding: .day, value: nextMondayOffset, to: now), + let nextWeek = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: monday), + nextWeek != tomorrow { + result.append(.init(id: .nextWeek, label: "Next week", until: nextWeek)) + } + + return result + } +} + +enum DailyUXCreationDestination: Equatable { + case newTask + case addProject +} + +struct NewTaskRetryState: Equatable { + private(set) var isInProgress = false + + var buttonTitle: String { + isInProgress ? "Trying again…" : "Try again" + } + + mutating func begin() -> Bool { + guard !isInProgress else { return false } + isInProgress = true + return true + } + + mutating func finish() { + isInProgress = false + } +} + +struct NewTaskProjectPickerPresentation: Equatable { + enum ProjectContent: Equatable { + case projects + case noProjects + case noMatches + } + + static let visibleEnvironmentLimit = 3 + + let projectContent: ProjectContent + let unavailableEnvironments: [FeatureEnvironment] + + init( + groups: [DailyUXProjectGroup], + filteredGroups: [DailyUXProjectGroup], + unavailableEnvironments: [FeatureEnvironment] + ) { + if groups.isEmpty { + projectContent = .noProjects + } else if filteredGroups.isEmpty { + projectContent = .noMatches + } else { + projectContent = .projects + } + self.unavailableEnvironments = unavailableEnvironments + } + + var visibleUnavailableEnvironments: [FeatureEnvironment] { + Array(unavailableEnvironments.prefix(Self.visibleEnvironmentLimit)) + } + + var additionalUnavailableEnvironmentCount: Int { + max(0, unavailableEnvironments.count - Self.visibleEnvironmentLimit) + } + + var unavailableAccessibilityLabel: String { + (["Unavailable environments"] + unavailableEnvironments.map { + "\($0.name) is unreachable" + }).joined(separator: ". ") + } +} + +enum DailyUXCreationContext { + static func projects(in snapshot: FeatureSnapshot) -> [FeatureProject] { + guard !snapshot.environments.isEmpty else { return snapshot.projects } + // Cached projects can queue tasks offline. A connection change must not + // remove the selected project or its draft while the user is typing. + let availableEnvironmentIDs = Set( + snapshot.environments.filter(\.isEnabled).map(\.id) + ) + return snapshot.projects.filter { + availableEnvironmentIDs.contains($0.environmentID) + } + } + + static func projectEnvironmentValidationMessage( + projectID: String, + in snapshot: FeatureSnapshot + ) -> String? { + guard let project = snapshot.projects.first(where: { $0.id == projectID }), + let environment = snapshot.environments.first(where: { + $0.id == project.environmentID + }) else { return nil } + return environment.isEnabled ? nil : "Environment is off." + } + + static func unreachableEnvironments(in snapshot: FeatureSnapshot) -> [FeatureEnvironment] { + unreachableEnvironments(in: snapshot.environments) + } + + /// Enabled environments a new task cannot reach. `.reconnecting` is a + /// transient state whose HTTP fallback still serves work, so the sidebar + /// and connection hub present it separately; only `.disconnected` is + /// unreachable here. + static func unreachableEnvironments( + in environments: [FeatureEnvironment] + ) -> [FeatureEnvironment] { + environments.filter { environment in + guard environment.isEnabled else { return false } + return environment.connectionState == .disconnected + } + } + + static func newTaskDestination(in snapshot: FeatureSnapshot) -> DailyUXCreationDestination { + if !projects(in: snapshot).isEmpty || !unreachableEnvironments(in: snapshot).isEmpty { + return .newTask + } + return .addProject + } + + static func projectGroups(in snapshot: FeatureSnapshot) -> [DailyUXProjectGroup] { + return DailyUXProjectGrouping.groups( + projects: projects(in: snapshot), + preferencesByEnvironment: snapshot.preferencesByEnvironment ?? [:] + ) + } + + static func recentProjects(in snapshot: FeatureSnapshot) -> [DailyUXRecentProject] { + let groups = projectGroups(in: snapshot) + let availableProjectByID = projects(in: snapshot).reduce( + into: [String: FeatureProject]() + ) { $0[$1.id] = $1 } + let groupByProjectID = groups.reduce(into: [String: DailyUXProjectGroup]()) { + result, group in + for projectID in group.memberProjectIDs { + result[projectID] = group + } + } + var seenGroupIDs = Set() + + return snapshot.threads + .sorted(by: recentUseOrder) + .compactMap { thread in + guard let group = groupByProjectID[thread.projectID], + let sourceProject = availableProjectByID[thread.projectID], + let project = DailyUXProjectGrouping.physicalRepresentative( + for: sourceProject, + in: group + ), + seenGroupIDs.insert(group.id).inserted else { + return nil + } + return DailyUXRecentProject(group: group, project: project) + } + } + + static func initialProject( + in snapshot: FeatureSnapshot, + requestedProjectID: String? + ) -> FeatureProject? { + let availableProjects = projects(in: snapshot) + if let requestedProjectID, + let requestedProject = availableProjects.first(where: { $0.id == requestedProjectID }), + let group = DailyUXProjectGrouping.group( + containing: requestedProjectID, + in: projectGroups(in: snapshot) + ), + let representative = DailyUXProjectGrouping.physicalRepresentative( + for: requestedProject, + in: group + ) { + return representative + } + + return recentProjects(in: snapshot).first?.project + ?? projectGroups(in: snapshot).first?.projects.first + } + + static func logicalProjectID( + for project: FeatureProject, + in snapshot: FeatureSnapshot + ) -> String { + let groups = DailyUXProjectGrouping.groups( + projects: snapshot.projects, + preferencesByEnvironment: snapshot.preferencesByEnvironment ?? [:] + ) + return DailyUXProjectGrouping.group(containing: project.id, in: groups)?.id + ?? DailyUXProjectGrouping.logicalProjectID( + for: project, + mode: snapshot.preferencesByEnvironment?[project.environmentID]? + .projectGroupingMode ?? .repository, + overrides: snapshot.preferencesByEnvironment?[project.environmentID]? + .projectGroupingOverrides ?? [:] + ) + } + + private static func recentUseOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + let lhsDate = lhs.lastActivityAt ?? lhs.updatedAt + let rhsDate = rhs.lastActivityAt ?? rhs.updatedAt + if lhsDate != rhsDate { return lhsDate > rhsDate } + return lhs.id < rhs.id + } + + static func shouldAdoptAutomaticProject( + currentProjectID: String, + nextRecentProjectID: String?, + isAwaitingRecentActivity: Bool, + projectSelectionIsExplicit: Bool, + modelSelectionIsExplicit: Bool, + workspaceSelectionIsExplicit: Bool, + hasDraftContent: Bool, + draftRestoreIsComplete: Bool + ) -> Bool { + guard isAwaitingRecentActivity, + let nextRecentProjectID, + nextRecentProjectID != currentProjectID else { + return false + } + return !projectSelectionIsExplicit + && !modelSelectionIsExplicit + && !workspaceSelectionIsExplicit + && !hasDraftContent + && draftRestoreIsComplete + } + + static func providers( + for project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> [FeatureProvider] { + if let project, + let providers = snapshot.providersByEnvironment?[project.environmentID] { + return providers + } + guard let project else { return [] } + guard let selection = project.defaultSelection else { return [] } + return [ + FeatureProvider( + id: selection.providerID, + name: selection.providerID, + driver: selection.providerID, + models: [ + FeatureModel( + id: selection.modelID, + name: selection.modelID, + isDefault: true + ), + ] + ), + ] + } + + static func initialSelection( + for project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> FeatureSelection? { + let providers = providers(for: project, in: snapshot) + return DailyUXModelOptions.validated(project?.defaultSelection, in: providers) + ?? DailyUXModelOptions.preferredSelection(in: providers) + } + + static func selection( + carrying preferredSelection: FeatureSelection?, + to project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> FeatureSelection? { + let providers = providers(for: project, in: snapshot) + return DailyUXModelOptions.validated(preferredSelection, in: providers) + ?? initialSelection(for: project, in: snapshot) + } + + static func environmentPreferences( + for project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> FeatureEnvironmentPreferences { + guard let environmentID = project?.environmentID else { + return FeatureEnvironmentPreferences() + } + return snapshot.preferencesByEnvironment?[environmentID] + ?? FeatureEnvironmentPreferences() + } +} + +struct DailyUXRecentProject: Equatable { + let group: DailyUXProjectGroup + let project: FeatureProject +} + +/// The project picker leads with the projects the account actually worked in +/// most recently and keeps every remaining project below in the usual +/// alphabetical order. A group appears in exactly one section so the list never +/// repeats itself on the small project counts this picker normally shows. +struct DailyUXProjectPickerSections: Equatable { + static let recentLimit = 3 + + let recents: [DailyUXProjectGroup] + let others: [DailyUXProjectGroup] + + init( + groups: [DailyUXProjectGroup], + recentGroupIDs: [String], + limit: Int = DailyUXProjectPickerSections.recentLimit + ) { + let groupsByID = groups.reduce(into: [String: DailyUXProjectGroup]()) { + $0[$1.id] = $0[$1.id] ?? $1 + } + var seenGroupIDs = Set() + var ranked: [DailyUXProjectGroup] = [] + for groupID in recentGroupIDs where ranked.count < max(0, limit) { + guard let group = groupsByID[groupID], + seenGroupIDs.insert(group.id).inserted else { + continue + } + ranked.append(group) + } + + recents = ranked + others = groups.filter { !seenGroupIDs.contains($0.id) } + } +} + +struct DailyUXProjectGroup: Identifiable, Equatable { + let id: String + let name: String + let projects: [FeatureProject] + let memberProjectIDs: Set + + func project(in environmentID: String) -> FeatureProject? { + projects.first { $0.environmentID == environmentID } + } + + func preferredProject(environmentID: String?) -> FeatureProject? { + environmentID.flatMap(project(in:)) ?? projects.first + } +} + +enum DailyUXProjectGrouping { + static func logicalProjectID( + for project: FeatureProject, + mode: FeatureEnvironmentPreferences.ProjectGroupingMode = .repository, + overrides: [String: FeatureEnvironmentPreferences.ProjectGroupingMode] = [:] + ) -> String { + logicalKey(project, mode: resolvedMode(project, mode: mode, overrides: overrides)) + } + + static func groups( + projects: [FeatureProject], + mode: FeatureEnvironmentPreferences.ProjectGroupingMode = .repository, + overrides: [String: FeatureEnvironmentPreferences.ProjectGroupingMode] = [:], + preferencesByEnvironment: [String: FeatureEnvironmentPreferences] = [:] + ) -> [DailyUXProjectGroup] { + var projectsByLogicalKey: [String: [FeatureProject]] = [:] + var memberIDsByLogicalKey: [String: Set] = [:] + for physicalProjects in Dictionary(grouping: projects, by: physicalKey).values { + guard let winner = physicalWinner(physicalProjects) else { continue } + let identitySource = identitySource(projects: physicalProjects, winner: winner) + let preferences = preferencesByEnvironment[winner.environmentID] + let groupingMode = resolvedMode( + winner, + mode: preferences?.projectGroupingMode ?? mode, + overrides: preferences?.projectGroupingOverrides ?? overrides + ) + let key = logicalKey(identitySource, mode: groupingMode) + projectsByLogicalKey[key, default: []].append(winner) + memberIDsByLogicalKey[key, default: []].formUnion(physicalProjects.map(\.id)) + } + + return projectsByLogicalKey + .map { key, members in + let sorted = members.sorted(by: projectOrder) + return DailyUXProjectGroup( + id: key, + name: groupName(projects: sorted), + projects: sorted, + memberProjectIDs: memberIDsByLogicalKey[key] ?? [] + ) + } + .sorted { lhs, rhs in + let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + return comparison == .orderedSame ? lhs.id < rhs.id : comparison == .orderedAscending + } + } + + static func group(containing projectID: String, in groups: [DailyUXProjectGroup]) + -> DailyUXProjectGroup? + { + groups.first { $0.memberProjectIDs.contains(projectID) } + } + + static func selectionTarget( + groupID: String, + preferredEnvironmentID: String?, + in groups: [DailyUXProjectGroup] + ) -> FeatureProject? { + groups.first { $0.id == groupID }? + .preferredProject(environmentID: preferredEnvironmentID) + } + + static func physicalRepresentative( + for project: FeatureProject, + in group: DailyUXProjectGroup + ) -> FeatureProject? { + let path = normalizedPath(project.path) + return group.projects.first { + $0.environmentID == project.environmentID + && normalizedPath($0.path) == path + } + } + + private static func physicalKey(_ project: FeatureProject) -> String { + "\(project.environmentID):\(normalizedPath(project.path))" + } + + private static func logicalKey( + _ project: FeatureProject, + mode: FeatureEnvironmentPreferences.ProjectGroupingMode + ) -> String { + if mode == .separate { return physicalKey(project) } + guard let key = project.repositoryIdentity?.canonicalKey.trimmingCharacters( + in: .whitespacesAndNewlines + ), !key.isEmpty else { + return physicalKey(project) + } + if mode == .repositoryPath, + let relativePath = repositoryRelativePath(project), + !relativePath.isEmpty { + return "\(key)::\(relativePath)" + } + return key + } + + private static func resolvedMode( + _ project: FeatureProject, + mode: FeatureEnvironmentPreferences.ProjectGroupingMode, + overrides: [String: FeatureEnvironmentPreferences.ProjectGroupingMode] + ) -> FeatureEnvironmentPreferences.ProjectGroupingMode { + overrides[physicalKey(project)] ?? mode + } + + private static func repositoryRelativePath(_ project: FeatureProject) -> String? { + guard let rootPath = project.repositoryIdentity?.rootPath else { return nil } + let projectPath = normalizedPath(project.path) + let repositoryPath = normalizedPath(rootPath) + guard !projectPath.isEmpty, !repositoryPath.isEmpty else { return nil } + if projectPath == repositoryPath { return "" } + let separator = repositoryPath.contains("\\") ? "\\" : "/" + let prefix = repositoryPath + separator + guard projectPath.hasPrefix(prefix) else { return nil } + return String(projectPath.dropFirst(prefix.count)).replacingOccurrences(of: "\\", with: "/") + } + + private static func physicalWinner(_ projects: [FeatureProject]) -> FeatureProject? { + projects.max { lhs, rhs in + let lhsFreshness = freshness(lhs) + let rhsFreshness = freshness(rhs) + if lhsFreshness != rhsFreshness { return lhsFreshness < rhsFreshness } + return lhs.id < rhs.id + } + } + + private static func identitySource( + projects: [FeatureProject], + winner: FeatureProject + ) -> FeatureProject { + guard winner.repositoryIdentity == nil else { return winner } + return physicalWinner(projects.filter { $0.repositoryIdentity != nil }) ?? winner + } + + private static func freshness(_ project: FeatureProject) -> String { + project.updatedAt ?? project.createdAt ?? "" + } + + private static func groupName(projects: [FeatureProject]) -> String { + let displayNames = uniqueNonEmpty(projects.compactMap(\.repositoryIdentity?.displayName)) + if displayNames.count == 1, let name = displayNames.first { return name } + let repositoryNames = uniqueNonEmpty(projects.compactMap(\.repositoryIdentity?.name)) + if repositoryNames.count == 1, let name = repositoryNames.first { return name } + return projects.first?.name ?? "Project" + } + + private static func uniqueNonEmpty(_ values: [String]) -> [String] { + var seen = Set() + return values.compactMap { value in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } + return trimmed + } + } + + private static func normalizedPath(_ path: String) -> String { + var normalized = path.trimmingCharacters(in: .whitespacesAndNewlines) + let isWindowsPath = normalized.range( + of: #"^[a-zA-Z]:([/\\]|$)"#, + options: .regularExpression + ) != nil || normalized.hasPrefix("\\\\") + let separators = isWindowsPath + ? CharacterSet(charactersIn: "/\\") + : CharacterSet(charactersIn: "/") + while normalized.count > 1, + let scalar = normalized.unicodeScalars.last, + separators.contains(scalar) { + normalized.removeLast() + } + if isWindowsPath { + return normalized.replacingOccurrences(of: "/", with: "\\").lowercased() + } + return normalized + } + + private static func projectOrder(_ lhs: FeatureProject, _ rhs: FeatureProject) -> Bool { + if lhs.environmentID != rhs.environmentID { return lhs.environmentID < rhs.environmentID } + return lhs.id < rhs.id + } +} + +struct DailyUXSidebarIndex { + let pinned: [FeatureThread] + let active: [FeatureThread] + let snoozed: [FeatureThread] + let settled: [FeatureThread] + let searchResults: [FeatureThread] + + var needsInput: [FeatureThread] { + active.filter { + $0.state == .waitingForApproval || $0.state == .waitingForInput + } + } + + var failed: [FeatureThread] { + active.filter { $0.state == .failed } + } + + init( + snapshot: FeatureSnapshot, + query: String, + projectID: String? = nil, + now: Date = .now, + pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + ) { + let visible = snapshot.threads.filter { thread in + guard !thread.isArchived else { return false } + return projectID == nil || thread.projectID == projectID + } + let available = visible.filter { !$0.isEffectivelySnoozed(at: now) } + + pinned = available + .filter { + $0.pinnedAt != nil + && !($0.supportsSettlement == true && $0.isEffectivelySettled()) + } + .sorted(by: Self.creationOrder) + + active = available + .filter { + $0.pinnedAt == nil + && !($0.supportsSettlement == true && $0.isEffectivelySettled()) + } + .sorted { lhs, rhs in + switch (lhs.activeOrderKey, rhs.activeOrderKey) { + case (.none, .some): return true + case (.some, .none): return false + case let (.some(left), .some(right)): + return left == right ? Self.activeIdentityOrder(lhs, rhs) : left < right + case (.none, .none): break + } + let leftAnchor = max(lhs.createdAt, lhs.unsettledAt ?? lhs.createdAt) + let rightAnchor = max(rhs.createdAt, rhs.unsettledAt ?? rhs.createdAt) + return leftAnchor == rightAnchor + ? Self.activeIdentityOrder(lhs, rhs) + : leftAnchor > rightAnchor + } + + snoozed = visible + .filter { $0.isEffectivelySnoozed(at: now) } + .sorted { lhs, rhs in + let lhsUntil = lhs.snoozedUntil ?? .distantFuture + let rhsUntil = rhs.snoozedUntil ?? .distantFuture + if lhsUntil != rhsUntil { + return lhsUntil < rhsUntil + } + return lhs.id < rhs.id + } + + settled = available + .filter { + $0.supportsSettlement == true + && $0.isEffectivelySettled() + } + .sorted { lhs, rhs in + if lhs.settledSortDate != rhs.settledSortDate { + return lhs.settledSortDate > rhs.settledSortDate + } + return lhs.id < rhs.id + } + + searchResults = Self.matchingThreads( + pinned + active + snoozed + settled, + snapshot: snapshot, + query: query + ) + } + + private static func creationOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id < rhs.id + } + + private static func activeIdentityOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + let leftID = lhs.wireID ?? lhs.id + let rightID = rhs.wireID ?? rhs.id + if leftID != rightID { return leftID < rightID } + return (lhs.environmentID ?? "") < (rhs.environmentID ?? "") + } + + static func matchingThreads( + _ candidates: [FeatureThread], + snapshot: FeatureSnapshot, + query: String + ) -> [FeatureThread] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedQuery.isEmpty else { return [] } + // Aggregate snapshots can include legacy fixtures with duplicate raw IDs. + // Native projects are environment-scoped, while this defensive reduce + // keeps search non-crashing for older callers during migration. + let projectByID = snapshot.projects.reduce(into: [String: FeatureProject]()) { + $0[$1.id] = $1 + } + return candidates.filter { thread in + let project = projectByID[thread.projectID] + return [ + thread.title, + thread.preview ?? "", + project?.name ?? "", + project?.path ?? "", + ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + } +} + +/// The Home list only needs a parent-level refresh when a thread crosses a shelf boundary. +/// Working timers and relative ages are rendered by each visible row instead. +enum DailyUXSidebarRefresh { + static func nextBoundary( + for threads: [FeatureThread], + after now: Date, + settings _: FeatureSettings = .init(), + pullRequestsByThreadID _: [String: HomeThreadPullRequestPresentation] = [:] + ) -> Date? { + threads.reduce(nil as Date?) { earliest, thread in + let snoozeBoundary = thread.isEffectivelySnoozed(at: now) + ? thread.snoozedUntil + : nil + let queuedBoundary = thread.isArchived + ? nil + : thread.queuedSettlementBoundary(after: now) + let threadBoundary = [snoozeBoundary, queuedBoundary] + .compactMap { $0 } + .min() + + guard let threadBoundary else { return earliest } + return min(earliest ?? threadBoundary, threadBoundary) + } + } +} + +enum SidebarRelativeAge { + static func compact(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + switch seconds { + case ..<60: + return "now" + case ..<3_600: + return "\(seconds / 60)m" + case ..<86_400: + return "\(seconds / 3_600)h" + case ..<604_800: + return "\(seconds / 86_400)d" + case ..<31_536_000: + return "\(seconds / 604_800)w" + default: + return "\(seconds / 31_536_000)y" + } + } + + static func accessibility(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + switch seconds { + case ..<60: + return "Updated just now" + case ..<3_600: + return "Updated \(unit(seconds / 60, singular: "minute")) ago" + case ..<86_400: + return "Updated \(unit(seconds / 3_600, singular: "hour")) ago" + case ..<604_800: + return "Updated \(unit(seconds / 86_400, singular: "day")) ago" + case ..<31_536_000: + return "Updated \(unit(seconds / 604_800, singular: "week")) ago" + default: + return "Updated \(unit(seconds / 31_536_000, singular: "year")) ago" + } + } + + private static func unit(_ value: Int, singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } +} + +enum HomeThreadStatus: String, Sendable, Equatable { + case approval + case input + case working + case monitoring + case failed + case done + case ready +} + +enum HomeWorkingDuration { + static func compact(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + guard seconds >= 60 else { return "\(seconds)s" } + let minutes = seconds / 60 + guard minutes >= 60 else { return "\(minutes)m" } + return "\(minutes / 60)h \(minutes % 60)m" + } + + static func accessibility(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + guard seconds >= 60 else { return unit(seconds, singular: "second") } + let minutes = seconds / 60 + guard minutes >= 60 else { return unit(minutes, singular: "minute") } + + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + guard remainingMinutes > 0 else { return unit(hours, singular: "hour") } + return "\(unit(hours, singular: "hour")), \(unit(remainingMinutes, singular: "minute"))" + } + + private static func unit(_ value: Int, singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } +} + +/// The completion age shown in a completed rich Home row. +/// +/// Recent completions stay minute-granular because Home refreshes quiet rows every 60 seconds. +enum HomeDoneDuration { + static func compact(since date: Date, now: Date) -> String { + let minutes = elapsedMinutes(since: date, now: now) + guard minutes >= 1 else { return "now" } + guard minutes >= 60 else { return "\(minutes)m" } + let hours = minutes / 60 + guard hours >= 24 else { return "\(hours)h \(minutes % 60)m" } + let days = hours / 24 + guard days >= 7 else { return "\(days)d \(hours % 24)h" } + guard days >= 365 else { return "\(days / 7)w" } + return "\(days / 365)y" + } + + static func accessibility(since date: Date, now: Date) -> String { + "Completed \(elapsedPhrase(since: date, now: now))" + } + + private static func elapsedPhrase(since date: Date, now: Date) -> String { + let minutes = elapsedMinutes(since: date, now: now) + guard minutes >= 1 else { return "just now" } + guard minutes >= 60 else { return "\(unit(minutes, singular: "minute")) ago" } + + let hours = minutes / 60 + guard hours >= 24 else { + let remainingMinutes = minutes % 60 + guard remainingMinutes > 0 else { return "\(unit(hours, singular: "hour")) ago" } + return "\(unit(hours, singular: "hour")), \(unit(remainingMinutes, singular: "minute")) ago" + } + + let days = hours / 24 + guard days < 7 else { + guard days >= 365 else { return "\(unit(days / 7, singular: "week")) ago" } + return "\(unit(days / 365, singular: "year")) ago" + } + let remainingHours = hours % 24 + guard remainingHours > 0 else { return "\(unit(days, singular: "day")) ago" } + return "\(unit(days, singular: "day")), \(unit(remainingHours, singular: "hour")) ago" + } + + private static func elapsedMinutes(since date: Date, now: Date) -> Int { + max(0, Int(now.timeIntervalSince(date))) / 60 + } + + private static func unit(_ value: Int, singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } +} + +extension FeatureThread { + var homeStatus: HomeThreadStatus { + switch state { + case .queued, .working: + .working + case .monitoring: + .monitoring + case .waitingForApproval: + .approval + case .waitingForInput: + .input + case .failed: + .failed + case .completed: + .done + case .idle: + .ready + } + } + + var homeStatusLabel: String? { + switch homeStatus { + case .approval: "Approval" + case .input: "Input" + case .working: "Working" + case .monitoring: "Monitoring" + case .failed: "Failed" + case .done: "Done" + case .ready: nil + } + } + + var detailHeaderStatusLabel: String? { + switch homeStatus { + case .done: + nil + case .ready: + "Ready" + case .approval, .input, .working, .monitoring, .failed: + homeStatusLabel + } + } + + var detailHeaderStatusIcon: String? { + switch homeStatus { + case .working: + "circle.dotted" + case .failed: + "exclamationmark.circle" + case .done, .approval, .input, .monitoring, .ready: + nil + } + } + + func homeRowStatusLabel(at now: Date) -> String { + switch homeStatus { + case .done: + homeDoneDuration(at: now) ?? SidebarRelativeAge.compact(since: updatedAt, now: now) + case .ready: + SidebarRelativeAge.compact(since: updatedAt, now: now) + case .approval, .input, .working, .monitoring, .failed: + homeStatusLabel ?? SidebarRelativeAge.compact(since: updatedAt, now: now) + } + } + + func homeWorkingDuration(at now: Date) -> String? { + guard homeStatus == .working, let workingStartedAt else { return nil } + return HomeWorkingDuration.compact(since: workingStartedAt, now: now) + } + + func homeDoneDuration(at now: Date) -> String? { + guard homeStatus == .done, let latestTurnCompletedAt else { return nil } + return HomeDoneDuration.compact(since: latestTurnCompletedAt, now: now) + } + + func homeDoneAccessibilityLabel(at now: Date) -> String? { + guard homeStatus == .done, let latestTurnCompletedAt else { return nil } + return HomeDoneDuration.accessibility(since: latestTurnCompletedAt, now: now) + } + + func homeRowAccessibilityStatus(rich: Bool, at now: Date) -> String { + guard rich else { return homeStatusLabel ?? "Ready" } + if let completed = homeDoneAccessibilityLabel(at: now) { return completed } + if homeStatus == .done { + return "Done. \(SidebarRelativeAge.accessibility(since: updatedAt, now: now))" + } + return homeStatusLabel ?? "Ready" + } + + var hasLiveWorkingDuration: Bool { + homeStatus == .working && workingStartedAt != nil + } + + func homeStatusAccessibilityLabel(at now: Date) -> String { + guard homeStatus == .working else { + return homeStatusLabel ?? "Ready" + } + guard let workingStartedAt else { + return "Agent is working" + } + return "Agent is working for \(HomeWorkingDuration.accessibility(since: workingStartedAt, now: now))" + } + + func homeEnvironmentLabel(in snapshot: FeatureSnapshot) -> String? { + let projectEnvironmentID = snapshot.projects + .first(where: { $0.id == projectID })? + .environmentID + if let resolvedID = environmentID ?? projectEnvironmentID, + let currentName = snapshot.environments.first(where: { $0.id == resolvedID })?.name, + !currentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return currentName + } + guard let environmentName = environmentName? + .trimmingCharacters(in: .whitespacesAndNewlines), + !environmentName.isEmpty else { + return nil + } + return environmentName + } + + func homeProviderLabel(in snapshot: FeatureSnapshot) -> String? { + if let providerName = providerName?.trimmingCharacters(in: .whitespacesAndNewlines), + !providerName.isEmpty { + return providerName + } + guard let providerID else { return nil } + let projectEnvironmentID = snapshot.projects + .first(where: { $0.id == projectID })? + .environmentID + let resolvedEnvironmentID = environmentID ?? projectEnvironmentID + let providers = resolvedEnvironmentID.flatMap { + snapshot.providersByEnvironment?[$0] + } ?? [] + return providers.first(where: { $0.id == providerID })?.name ?? providerID + } + + var needsAttention: Bool { + state == .waitingForApproval || state == .waitingForInput || state == .failed + } + + func isEffectivelySettled() -> Bool { + effectiveSettlementOverride == .settled + } + + func canSettleNow(at now: Date = .now) -> Bool { + guard canToggleSettlement else { return false } + return !hasSettlementActivityBlock(at: now) + } + + var effectiveSettlementOverride: FeatureThreadSettlementOverride? { + if let settlementFacts { return settlementFacts.settlementOverride } + if keepsActive { return .active } + if isSettled { return .settled } + return nil + } + + func hasSettlementActivityBlock(at now: Date) -> Bool { + guard settlementFacts != nil else { + return [.queued, .working, .monitoring, .waitingForApproval, .waitingForInput] + .contains(state) + } + if hasHardSettlementActivityBlock { return true } + return hasQueuedTurnStart(at: now) + } + + var hasHardSettlementActivityBlock: Bool { + guard let facts = settlementFacts else { + return [ + .queued, + .working, + .monitoring, + .waitingForApproval, + .waitingForInput, + ].contains(state) + } + return facts.hasPendingApprovals + || facts.hasPendingUserInput + || facts.sessionStatus == "starting" + || facts.sessionStatus == "running" + } + + func hasQueuedTurnStart(at now: Date) -> Bool { + guard let facts = settlementFacts, + facts.sessionStatus != "error", + let messageAt = facts.latestUserMessageAt, + abs(now.timeIntervalSince(messageAt)) <= 2 * 60 else { + return false + } + guard let turn = facts.latestTurn else { return true } + if turn.requestedAtIsInvalid || turn.startedAtIsInvalid || turn.completedAtIsInvalid { + return false + } + return [turn.requestedAt, turn.startedAt, turn.completedAt].allSatisfy { + $0 == nil || $0! < messageAt + } + } + + func queuedSettlementBoundary(after now: Date) -> Date? { + guard hasQueuedTurnStart(at: now), + let messageAt = settlementFacts?.latestUserMessageAt else { + return nil + } + let boundary = messageAt.addingTimeInterval(2 * 60 + 0.001) + return boundary > now ? boundary : nil + } + + func isEffectivelySnoozed(at now: Date) -> Bool { + guard let snoozedUntil, snoozedUntil > now else { return false } + if state == .waitingForApproval || state == .waitingForInput { + return false + } + if state == .failed, + let snoozedAt, + let attentionAt, + attentionAt > snoozedAt { + return false + } + if let snoozedAt, + let latestTurnCompletedAt, + latestTurnCompletedAt > snoozedAt { + return false + } + return true + } + + var settledSortDate: Date { + settledAt ?? lastActivityAt ?? updatedAt + } +} + +struct DailyUXModelOption: Identifiable, Equatable, Hashable { + let provider: FeatureProvider + let model: FeatureModel + + var id: String { Self.key(providerID: provider.id, modelID: model.id) } + + static func key(providerID: String, modelID: String) -> String { + "\(providerID)::\(modelID)" + } +} + +struct DailyUXModelCatalog { + let all: [DailyUXModelOption] + let favorites: [DailyUXModelOption] + let recents: [DailyUXModelOption] + let providerGroups: [(provider: FeatureProvider, models: [DailyUXModelOption])] + + init( + providers: [FeatureProvider], + query: String, + favoriteIDs: Set, + recentIDs: [String] + ) { + let available = providers.filter(\.isAvailable) + let rawOptions = available.flatMap { provider in + provider.models.map { DailyUXModelOption(provider: provider, model: $0) } + } + var seenOptionIDs = Set() + let unfiltered = rawOptions.filter { seenOptionIDs.insert($0.id).inserted } + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let matches = normalizedQuery.isEmpty + ? unfiltered + : unfiltered.filter { option in + [ + option.provider.name, + option.model.name, + option.model.id, + option.model.detail ?? "", + option.model.supportsImages ? "images vision" : "", + ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + + all = matches + favorites = matches.filter { favoriteIDs.contains($0.id) } + + // Provider catalogs can repeat an ID (see matchingThreads above); keep + // the first occurrence instead of trapping on duplicate keys. + let byID = matches.reduce(into: [String: DailyUXModelOption]()) { + $0[$1.id] = $0[$1.id] ?? $1 + } + recents = recentIDs.compactMap { byID[$0] }.filter { !favoriteIDs.contains($0.id) } + + var seenProviderIDs = Set() + let uniqueProviders = available.filter { seenProviderIDs.insert($0.id).inserted } + providerGroups = uniqueProviders.compactMap { provider in + let options = matches.filter { $0.provider.id == provider.id } + return options.isEmpty ? nil : (provider, options) + } + } +} + +enum DailyUXModelOptions { + static func reasoningDescriptor( + for model: FeatureModel + ) -> FeatureModelOptionDescriptor? { + model.options.first(where: isReasoningDescriptor) + } + + static func advancedDescriptors( + for model: FeatureModel + ) -> [FeatureModelOptionDescriptor] { + let primaryID = reasoningDescriptor(for: model)?.id + return model.options.filter { $0.id != primaryID } + } + + static func undescribedSelections( + for model: FeatureModel, + selections: [FeatureModelOptionSelection] + ) -> [FeatureModelOptionSelection] { + let describedIDs = Set(model.options.map(\.id)) + return selections.filter { !describedIDs.contains($0.id) } + } + + static func isSupportedValue( + _ value: FeatureModelOptionValue, + for descriptor: FeatureModelOptionDescriptor + ) -> Bool { + switch (descriptor.kind, value) { + case let (.select, .string(choiceID)): + return descriptor.choices.contains { $0.id == choiceID } + case (.boolean, .boolean): + return true + case (.select, .boolean), (.boolean, .string): + return false + } + } + + static func initialSelection( + projectDefault: FeatureSelection?, + appDefault: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + validated(projectDefault, in: providers) + ?? validated(appDefault, in: providers) + ?? preferredSelection(in: providers) + } + + static func validated( + _ selection: FeatureSelection?, + in providers: [FeatureProvider] + ) -> FeatureSelection? { + guard let selection, + let provider = providers.first(where: { + $0.id == selection.providerID && $0.isAvailable + }), + provider.models.contains(where: { $0.id == selection.modelID }) else { + return nil + } + return selection + } + + static func preferredSelection(in providers: [FeatureProvider]) -> FeatureSelection? { + let available = providers.filter(\.isAvailable) + let preferred = available.lazy.compactMap { provider in + provider.models.first(where: \.isDefault).map { (provider, $0) } + }.first + ?? available.first.flatMap { provider in + provider.models.first.map { (provider, $0) } + } + guard let (provider, model) = preferred else { return nil } + return FeatureSelection( + providerID: provider.id, + modelID: model.id, + options: defaults(for: model) + ) + } + + static func defaults(for model: FeatureModel) -> [FeatureModelOptionSelection] { + model.options.compactMap { descriptor in + defaultValue(for: descriptor).map { value in + FeatureModelOptionSelection(id: descriptor.id, value: value) + } + } + } + + /// An option without a declared default stays unset until the user selects it. + static func defaultValue( + for descriptor: FeatureModelOptionDescriptor + ) -> FeatureModelOptionValue? { + if let defaultValue = descriptor.defaultValue { + return defaultValue + } + switch descriptor.kind { + case .select: + return descriptor.choices.first(where: \.isDefault).map { .string($0.id) } + case .boolean: + return nil + } + } + + static func value( + for descriptor: FeatureModelOptionDescriptor, + in selections: [FeatureModelOptionSelection] + ) -> FeatureModelOptionValue? { + if let selected = selections.first(where: { $0.id == descriptor.id })?.value { + return selected + } + return defaultValue(for: descriptor) + } + + static func updating( + _ selections: [FeatureModelOptionSelection], + id: String, + value: FeatureModelOptionValue? + ) -> [FeatureModelOptionSelection] { + var next = selections.filter { $0.id != id } + if let value { + next.append(FeatureModelOptionSelection(id: id, value: value)) + } + return next + } + + static func summary( + for model: FeatureModel, + selections: [FeatureModelOptionSelection] + ) -> String? { + let labels = model.options.compactMap { descriptor -> String? in + guard let value = value(for: descriptor, in: selections) else { return nil } + switch value { + case let .string(choiceID): + return descriptor.choices.first(where: { $0.id == choiceID })?.label + ?? choiceID + case let .boolean(isEnabled): + return isEnabled ? descriptor.label : nil + } + } + return labels.isEmpty ? nil : labels.joined(separator: " · ") + } + + /// The compact composer gives reasoning its own non-compressible label so + /// a long model name cannot hide the setting users change most often. + static func reasoningSummary( + for model: FeatureModel, + selections: [FeatureModelOptionSelection] + ) -> String? { + guard let descriptor = reasoningDescriptor(for: model), + let value = value(for: descriptor, in: selections) else { + return nil + } + + switch value { + case let .string(choiceID): + return descriptor.choices.first(where: { $0.id == choiceID })?.label + ?? choiceID + case let .boolean(isEnabled): + return isEnabled ? descriptor.label : nil + } + } + + private static func isReasoningDescriptor( + _ descriptor: FeatureModelOptionDescriptor + ) -> Bool { + let searchable = "\(descriptor.id) \(descriptor.label)".lowercased() + return searchable.contains("reason") + || searchable.contains("effort") + || searchable.contains("thinking") + || searchable.contains("thought") + } + + static func supportsImages( + selection: FeatureSelection?, + providers: [FeatureProvider] + ) -> Bool { + // Older environments do not advertise image capability. In that case the + // server remains the source of truth instead of hiding attachments entirely. + guard providers.lazy.flatMap(\.models).contains(where: \.supportsImages) else { + return true + } + guard let selection, + let provider = providers.first(where: { $0.id == selection.providerID }), + let model = provider.models.first(where: { $0.id == selection.modelID }) else { + return true + } + return model.imageSupportIsUnknown == true || model.supportsImages + } +} diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift new file mode 100644 index 000000000000..cd6e5fc42efb --- /dev/null +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -0,0 +1,1180 @@ +import SwiftUI +import UIKit + +/// A recycled, diffable Home surface. SwiftUI still owns the surrounding shell, +/// while UIKit keeps row creation and updates proportional to visible threads. +struct HomeThreadCollectionView: UIViewRepresentable { + let presentation: HomePresentation + let projectFaviconClient: any FeatureClient + let query: String + let selectedThreadID: String? + let forceRichRows: Bool + let hapticsEnabled: Bool + let settings: FeatureSettings + let pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] + let isSnoozedExpanded: Bool + let isSettledExpanded: Bool + let isArchiveExpanded: Bool + let settledLimit: Int + let onOpen: (String) -> Void + let onToggleSnoozed: () -> Void + let onToggleSettled: () -> Void + let onToggleArchive: () -> Void + let onShowMoreSettled: () -> Void + let onRename: (FeatureThread) -> Void + let onRegenerateTitle: (FeatureThread) -> Void + let onArchive: (FeatureThread, Bool) -> Void + let onSettle: (FeatureThread, Bool, @escaping (Bool) -> Void) -> Void + let onSnooze: (FeatureThread, Date?) -> Void + let onPin: (FeatureThread, Bool) -> Void + let onDelete: (FeatureThread) -> Void + let onPullRequestChange: (String, String, HomeThreadPullRequestPresentation?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UICollectionView { + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) + configuration.backgroundColor = T3Colors.uiBackground + configuration.showsSeparators = false + configuration.headerMode = .none + configuration.footerMode = .none + configuration.trailingSwipeActionsConfigurationProvider = { [weak coordinator = context.coordinator] indexPath in + coordinator?.trailingSwipeActions(at: indexPath) + } + + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout.list(using: configuration) + ) + collectionView.backgroundColor = T3Colors.uiBackground + collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .interactive + collectionView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 74, right: 0) + collectionView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 4, left: 0, bottom: 74, right: 0) + collectionView.delegate = context.coordinator + context.coordinator.configure(collectionView) + return collectionView + } + + func updateUIView(_ collectionView: UICollectionView, context: Context) { + context.coordinator.update(parent: self, collectionView: collectionView) + } + + static func dismantleUIView(_ collectionView: UICollectionView, coordinator: Coordinator) { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + collectionView.delegate = nil + } + + @MainActor + final class Coordinator: NSObject, UICollectionViewDelegate { + private enum Section: Hashable { + case main + } + + private struct PendingSwipeCompletion { + let id: UUID + let settled: Bool + let finish: (Bool) -> Void + } + + private var parent: HomeThreadCollectionView + private var dataSource: UICollectionViewDiffableDataSource? + private var registration: UICollectionView.CellRegistration? + private var itemsByID: [HomeCollectionItem.ID: HomeCollectionItem] = [:] + private var threadItemIDs: [String: HomeCollectionItem.ID] = [:] + private var pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + private var selectedThreadID: String? + private weak var collectionView: UICollectionView? + private var timer: Timer? + private var timerTick = 0 + private var timerInterval: TimeInterval = 0 + private var pendingSwipeCompletions: [String: PendingSwipeCompletion] = [:] + private var isApplyingSnapshot = false + private var hasQueuedUpdate = false + + init(parent: HomeThreadCollectionView) { + self.parent = parent + selectedThreadID = parent.selectedThreadID + } + + func configure(_ collectionView: UICollectionView) { + self.collectionView = collectionView + + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, identifier in + self?.configure(cell, identifier: identifier, now: .now) + } + self.registration = registration + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, identifier in + guard let self, let registration = self.registration else { return nil } + return collectionView.dequeueConfiguredReusableCell( + using: registration, + for: indexPath, + item: identifier + ) + } + + update(parent: parent, collectionView: collectionView) + } + + func update(parent: HomeThreadCollectionView, collectionView: UICollectionView) { + self.parent = parent + hasQueuedUpdate = true + applyLatestSnapshot(in: collectionView) + } + + /// Keep a row's content and size fixed during its removal. Stream updates + /// that arrive mid-animation are applied together after that animation. + private func applyLatestSnapshot(in collectionView: UICollectionView) { + guard !isApplyingSnapshot, hasQueuedUpdate, let dataSource else { return } + hasQueuedUpdate = false + let previousItems = itemsByID + let previousThreadItemIDs = threadItemIDs + let previousSelection = selectedThreadID + selectedThreadID = parent.selectedThreadID + + var seenIdentifiers = Set() + var seenThreadIDs = Set() + let items = parent.collectionItems.filter { item in + if let threadID = item.id.threadID, !seenThreadIDs.insert(threadID).inserted { + return false + } + return seenIdentifiers.insert(item.id).inserted + } + itemsByID = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + threadItemIDs = Dictionary(uniqueKeysWithValues: items.compactMap { item in + item.id.threadID.map { ($0, item.id) } + }) + pullRequestsByThreadID = pullRequestsByThreadID.filter { + threadItemIDs[$0.key] != nil + } + // After items land: picks 1 Hz when a working thread is present, + // 60s otherwise, and is a no-op when the interval is unchanged. + startTimer() + + let currentIdentifiers = dataSource.snapshot().itemIdentifiers + let newIdentifiers = items.map(\.id) + let resolvedSwipes = pendingSwipeCompletions.filter { threadID, pending in + guard let identifier = threadItemIDs[threadID], + case let .thread(thread, _, _, _, _, _) = itemsByID[identifier] else { + return true + } + return thread.isEffectivelySettled() == pending.settled + } + let finishUpdate = { [weak self, weak collectionView] in + guard let self else { return } + for (threadID, pending) in resolvedSwipes { + guard self.pendingSwipeCompletions[threadID]?.id == pending.id else { continue } + self.pendingSwipeCompletions.removeValue(forKey: threadID)?.finish(true) + } + self.isApplyingSnapshot = false + guard let collectionView else { return } + self.synchronizeSelection(in: collectionView) + self.applyLatestSnapshot(in: collectionView) + } + + if currentIdentifiers == newIdentifiers { + let changed = newIdentifiers.filter { previousItems[$0] != itemsByID[$0] } + let selectionChanged = (previousSelection != selectedThreadID + ? [previousSelection, selectedThreadID] : []) + .compactMap { $0.flatMap { threadItemIDs[$0] } } + let identifiers = Array(Set(changed + selectionChanged)) + if !identifiers.isEmpty { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems(identifiers) + isApplyingSnapshot = true + dataSource.apply( + snapshot, + animatingDifferences: false, + completion: finishUpdate + ) + } else { + finishUpdate() + } + } else { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(newIdentifiers, toSection: .main) + // Retained rows also need fresh content when another row moves, + // arrives, or leaves in the same update. + let retained = Set(currentIdentifiers) + let selectionChanged = previousSelection != selectedThreadID + ? Set([previousSelection, selectedThreadID].compactMap { $0 }) : [] + snapshot.reconfigureItems(newIdentifiers.filter { identifier in + retained.contains(identifier) + && (previousItems[identifier] != itemsByID[identifier] + || identifier.threadID.map(selectionChanged.contains) == true) + }) + let shouldAnimate = !resolvedSwipes.isEmpty + && !currentIdentifiers.isEmpty + && collectionView.window != nil + && !UIAccessibility.isReduceMotionEnabled + if shouldAnimate { + for threadID in resolvedSwipes.keys { + guard let identifier = previousThreadItemIDs[threadID], + identifier != threadItemIDs[threadID], + let indexPath = dataSource.indexPath(for: identifier), + let cell = collectionView.cellForItem(at: indexPath) else { continue } + // UIKit fades deleted cells while their neighbors move up. + // Hide the departed text so it cannot show through those rows. + // The native swipe action view is outside contentView. + cell.contentView.isHidden = true + } + } + isApplyingSnapshot = true + dataSource.apply( + snapshot, + animatingDifferences: shouldAnimate, + completion: finishUpdate + ) + } + + synchronizeSelection(in: collectionView) + } + + func invalidateTimer() { + timer?.invalidate() + timer = nil + } + + func cancelPendingSwipeActions() { + hasQueuedUpdate = false + pendingSwipeCompletions.values.forEach { $0.finish(false) } + pendingSwipeCompletions.removeAll() + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = item(at: indexPath) else { return } + switch item { + case let .thread(thread, _, _, _, _, _): + let previousSelection = selectedThreadID + selectedThreadID = thread.id + parent.onOpen(thread.id) + refreshSelection( + in: collectionView, + ids: [previousSelection, thread.id].compactMap { $0 } + ) + case let .shelfHeader(shelf, _, _): + collectionView.deselectItem(at: indexPath, animated: false) + toggle(shelf) + case .showMoreSettled: + collectionView.deselectItem(at: indexPath, animated: false) + parent.onShowMoreSettled() + case .empty, .searchEmpty, .pinnedDivider: + collectionView.deselectItem(at: indexPath, animated: false) + } + } + + func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + guard case let .thread(thread, context, _, isArchived, _, _) = item(at: indexPath) else { + return nil + } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self else { return nil } + return UIMenu( + children: self.menuActions( + for: thread, + context: context, + isArchived: isArchived + ) + ) + } + } + + func trailingSwipeActions(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard case let .thread(thread, _, _, isArchived, _, _) = item(at: indexPath) else { + return nil + } + + let actions = HomeThreadSwipeAction + .trailingActions( + for: thread, + isArchived: isArchived, + at: .now + ) + let configuration = UISwipeActionsConfiguration( + actions: actions.map { contextualAction($0, for: thread) } + ) + // A full swipe runs the edge action, which is only ever settlement. + // Delete can never reach that slot, so the gesture cannot destroy a + // thread; rows with nothing to settle keep the full swipe disabled. + configuration.performsFirstActionWithFullSwipe = + HomeThreadSwipeAction.performsFullSwipe(with: actions) + return configuration + } + + private func contextualAction( + _ action: HomeThreadSwipeAction, + for thread: FeatureThread + ) -> UIContextualAction { + let contextualAction = UIContextualAction( + style: action.style, + title: action.title + ) { [weak self] _, _, finish in + guard let self else { + finish(false) + return + } + self.performSwipe(action, for: thread, finish: finish) + } + contextualAction.image = UIImage(systemName: action.systemImage) + if let backgroundColor = action.backgroundColor { + contextualAction.backgroundColor = backgroundColor + } + return contextualAction + } + + func performSwipe( + _ action: HomeThreadSwipeAction, + for thread: FeatureThread, + finish: @escaping (Bool) -> Void + ) { + if case let .setSettled(settled) = action.intent { + guard pendingSwipeCompletions[thread.id] == nil else { + finish(false) + return + } + let completionID = UUID() + pendingSwipeCompletions[thread.id] = PendingSwipeCompletion( + id: completionID, + settled: settled, + finish: finish + ) + PlatformHapticEngine.shared.selection(enabled: parent.hapticsEnabled) + parent.onSettle(thread, settled) { [weak self] succeeded in + guard !succeeded, + let self, + self.pendingSwipeCompletions[thread.id]?.id == completionID else { + return + } + self.pendingSwipeCompletions.removeValue(forKey: thread.id)?.finish(false) + } + } else { + perform(action.intent, for: thread) + finish(true) + } + } + + /// Swipe actions reuse the same closures the context menu does, so a + /// settle from either surface takes the one real settlement path. + private func perform(_ intent: HomeThreadSwipeAction.Intent, for thread: FeatureThread) { + switch intent { + case .delete: + parent.onDelete(thread) + case let .setArchived(archived): + parent.onArchive(thread, archived) + case let .setPinned(pinned): + parent.onPin(thread, pinned) + case let .setSettled(settled): + parent.onSettle(thread, settled) { _ in } + } + } + + private func configure( + _ cell: HomeCollectionCell, + identifier: HomeCollectionItem.ID, + now: Date + ) { + guard let item = itemsByID[identifier] else { return } + cell.contentView.isHidden = false + let pullRequestObservationIdentity: String? + if case let .thread(thread, _, _, _, _, _) = item { + pullRequestObservationIdentity = thread.pullRequestObservationIdentity + } else { + pullRequestObservationIdentity = nil + } + cell.contentConfiguration = UIHostingConfiguration { + HomeCollectionCellContent( + item: item, + projectFaviconClient: parent.projectFaviconClient, + isSelected: identifier.threadID == selectedThreadID, + now: now, + onPullRequestChange: { [weak self, weak cell] pullRequest in + guard let self, + let cell, + let threadID = identifier.threadID else { + return + } + self.updatePullRequestAccessibility( + pullRequest, + threadID: threadID, + cell: cell + ) + if let observationIdentity = pullRequestObservationIdentity { + self.parent.onPullRequestChange( + threadID, + observationIdentity, + pullRequest + ) + } + } + ) + } + .margins(.all, 0) + + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessories = [] + cell.tintColor = T3Colors.uiTextPrimary + cell.clipsToBounds = true + cell.contentView.clipsToBounds = true + cell.contentView.accessibilityElementsHidden = true + configureAccessibility(cell, item: item) + } + + private func configureAccessibility(_ cell: HomeCollectionCell, item: HomeCollectionItem) { + cell.accessibilityCustomActions = nil + switch item { + case let .thread(thread, context, style, isArchived, _, _): + cell.isAccessibilityElement = true + cell.accessibilityTraits = selectedThreadID == thread.id + ? [.button, .selected] + : .button + cell.accessibilityLabel = thread.title + cell.accessibilityValue = threadAccessibilityValue( + thread, + context: context, + style: style + ) + cell.accessibilityHint = "Opens thread. More actions are available." + cell.accessibilityCustomActions = threadAccessibilityActions( + for: thread, + isArchived: isArchived + ) + cell.onAccessibilityActivate = { [weak self] in + guard let self else { return } + let previousSelection = self.selectedThreadID + self.selectedThreadID = thread.id + self.parent.onOpen(thread.id) + if let collectionView = self.collectionView { + self.refreshSelection( + in: collectionView, + ids: [previousSelection, thread.id].compactMap { $0 } + ) + } + } + case let .shelfHeader(shelf, count, isExpanded): + cell.isAccessibilityElement = true + cell.accessibilityTraits = .button + cell.accessibilityLabel = "\(shelf.title), \(count) \(count == 1 ? "task" : "tasks")" + cell.accessibilityValue = isExpanded ? "Expanded" : "Collapsed" + cell.accessibilityHint = isExpanded ? "Collapses the task list" : "Expands the task list" + cell.onAccessibilityActivate = { [weak self] in self?.toggle(shelf) } + case let .showMoreSettled(remaining): + cell.isAccessibilityElement = true + cell.accessibilityTraits = .button + cell.accessibilityLabel = "Show \(remaining) more settled \(remaining == 1 ? "task" : "tasks")" + cell.accessibilityValue = nil + cell.accessibilityHint = nil + cell.onAccessibilityActivate = { [weak self] in + self?.parent.onShowMoreSettled() + } + case let .empty(shelf): + cell.isAccessibilityElement = true + cell.accessibilityTraits = .staticText + cell.accessibilityLabel = shelf == .active ? "No active tasks" : "No \(shelf.title.lowercased()) tasks" + cell.accessibilityValue = nil + cell.accessibilityHint = nil + cell.onAccessibilityActivate = nil + case .searchEmpty: + cell.isAccessibilityElement = true + cell.accessibilityTraits = .staticText + cell.accessibilityLabel = "No matching tasks" + cell.accessibilityValue = nil + cell.accessibilityHint = nil + cell.onAccessibilityActivate = nil + case .pinnedDivider: + cell.isAccessibilityElement = false + cell.onAccessibilityActivate = nil + } + } + + private func threadAccessibilityValue( + _ thread: FeatureThread, + context: HomeThreadRowContext, + style: FeatureThreadRow.Style + ) -> String { + var status = thread.homeRowAccessibilityStatus(rich: style == .rich, at: .now) + if let duration = thread.homeWorkingDuration(at: .now) { + status += " for \(duration)" + } + var values = [status, "Project \(context.projectName)"] + if let pullRequest = pullRequestsByThreadID[thread.id] { + values.append(pullRequest.accessibilityLabel) + } + if thread.pinnedAt != nil { + values.append("Pinned") + } + if thread.isArchived { + values.append("Archived") + } else if thread.isEffectivelySnoozed(at: .now) { + values.append("Snoozed") + } else if thread.isEffectivelySettled() { + values.append("Settled") + } + values.append("Provider \(context.providerName)") + if let environment = context.environmentLabel { + values.append("on \(environment)") + } + return values.joined(separator: ". ") + } + + private func updatePullRequestAccessibility( + _ pullRequest: HomeThreadPullRequestPresentation?, + threadID: String, + cell: HomeCollectionCell + ) { + guard let identifier = threadItemIDs[threadID], + case let .thread(thread, context, style, _, _, _) = itemsByID[identifier], + let indexPath = dataSource?.indexPath(for: identifier), + collectionView?.cellForItem(at: indexPath) === cell else { + return + } + if let pullRequest { + pullRequestsByThreadID[threadID] = pullRequest + } else { + pullRequestsByThreadID.removeValue(forKey: threadID) + } + cell.accessibilityValue = threadAccessibilityValue( + thread, + context: context, + style: style + ) + } + + private func threadAccessibilityActions( + for thread: FeatureThread, + isArchived: Bool + ) -> [UIAccessibilityCustomAction] { + var actions = [accessibilityAction("Rename", systemImage: "pencil") { coordinator in + coordinator.parent.onRename(thread) + }] + + if thread.supportsTitleRegeneration == true { + actions.append(accessibilityAction("Regenerate title", systemImage: "sparkles") { coordinator in + coordinator.parent.onRegenerateTitle(thread) + }) + } + + if !isArchived { + if thread.canTogglePin { + let isPinned = thread.pinnedAt != nil + actions.append(accessibilityAction( + isPinned ? "Unpin" : "Pin", + systemImage: isPinned ? "pin.slash" : "pin" + ) { coordinator in + coordinator.parent.onPin(thread, !isPinned) + }) + } + + let isSettled = thread.isEffectivelySettled() + if isSettled || thread.canSettleNow() { + actions.append(accessibilityAction( + isSettled ? "Reopen" : "Settle", + systemImage: isSettled ? "arrow.counterclockwise" : "checkmark" + ) { coordinator in + coordinator.parent.onSettle(thread, !isSettled) { _ in } + }) + } + + if thread.canToggleSnooze { + if thread.isEffectivelySnoozed(at: .now) { + actions.append(accessibilityAction("Wake", systemImage: "bell") { coordinator in + coordinator.parent.onSnooze(thread, nil) + }) + } else if thread.state != .queued, + thread.state != .waitingForApproval, + thread.state != .waitingForInput { + actions.append(contentsOf: DailyUXSnoozePresets.resolve(now: .now).map { preset in + accessibilityAction("Snooze: \(preset.label)", systemImage: "clock") { coordinator in + coordinator.parent.onSnooze(thread, preset.until) + } + }) + } + } + } + + actions.append(accessibilityAction( + isArchived ? "Restore" : "Archive", + systemImage: isArchived ? "arrow.uturn.backward" : "archivebox" + ) { coordinator in + coordinator.parent.onArchive(thread, !isArchived) + }) + actions.append(accessibilityAction("Delete thread", systemImage: "trash") { coordinator in + coordinator.parent.onDelete(thread) + }) + return actions + } + + private func accessibilityAction( + _ title: String, + systemImage: String, + perform: @escaping (Coordinator) -> Void + ) -> UIAccessibilityCustomAction { + UIAccessibilityCustomAction(name: title, image: UIImage(systemName: systemImage)) { [weak self] _ in + guard let self else { return false } + perform(self) + return true + } + } + + private func synchronizeSelection(in collectionView: UICollectionView) { + for indexPath in collectionView.indexPathsForSelectedItems ?? [] { + guard dataSource?.itemIdentifier(for: indexPath)?.threadID != selectedThreadID else { + continue + } + collectionView.deselectItem(at: indexPath, animated: false) + } + guard let selectedThreadID, + let identifier = threadItemIDs[selectedThreadID], + let indexPath = dataSource?.indexPath(for: identifier), + !collectionView.indexPathsForSelectedItems.orEmpty.contains(indexPath) else { + return + } + collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) + } + + private func refreshSelection(in collectionView: UICollectionView, ids: [String]) { + for id in ids { + guard let identifier = threadItemIDs[id], + let indexPath = dataSource?.indexPath(for: identifier), + let cell = collectionView.cellForItem(at: indexPath) as? HomeCollectionCell else { + continue + } + configure(cell, identifier: identifier, now: .now) + } + } + + private func item(at indexPath: IndexPath) -> HomeCollectionItem? { + guard let identifier = dataSource?.itemIdentifier(for: indexPath) else { return nil } + return itemsByID[identifier] + } + + private func toggle(_ shelf: HomeShelf) { + switch shelf { + case .snoozed: parent.onToggleSnoozed() + case .settled: parent.onToggleSettled() + case .archived: parent.onToggleArchive() + case .active: break + } + } + + private func menuActions( + for thread: FeatureThread, + context: HomeThreadRowContext, + isArchived: Bool + ) -> [UIMenuElement] { + let rename = UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { [weak self] _ in + self?.parent.onRename(thread) + } + + var titleActions: [UIMenuElement] = [rename] + if thread.supportsTitleRegeneration == true { + titleActions.append( + UIAction( + title: "Regenerate title", + image: UIImage(systemName: "sparkles") + ) { [weak self] _ in + self?.parent.onRegenerateTitle(thread) + } + ) + } + let copyActions = ThreadCopyModel.menuActions( + for: thread, + context: context.copyContext + ) + if !copyActions.isEmpty { + titleActions.append( + UIMenu( + title: "Copy", + image: UIImage(systemName: "doc.on.doc"), + children: copyActions.map { action in + UIAction( + title: action.kind.title, + image: UIImage(systemName: action.kind.systemImage), + attributes: action.isAvailable ? [] : .disabled + ) { _ in + ThreadCopyClipboard.copy(action) + } + } + ) + ) + } + + var statusActions: [UIMenuElement] = [] + if !isArchived { + if thread.canTogglePin { + let isPinned = thread.pinnedAt != nil + statusActions.append( + UIAction( + title: isPinned ? "Unpin" : "Pin", + image: UIImage(systemName: isPinned ? "pin.slash" : "pin") + ) { [weak self] _ in + self?.parent.onPin(thread, !isPinned) + } + ) + } + let isSettled = thread.isEffectivelySettled() + if isSettled || thread.canSettleNow() { + statusActions.append( + UIAction( + title: isSettled ? "Reopen" : "Settle", + image: UIImage( + systemName: isSettled ? "arrow.counterclockwise" : "checkmark" + ) + ) { [weak self] _ in + self?.parent.onSettle(thread, !isSettled) { _ in } + } + ) + } + + if thread.canToggleSnooze { + let isSnoozed = thread.isEffectivelySnoozed(at: .now) + if isSnoozed { + statusActions.append( + UIAction(title: "Wake", image: UIImage(systemName: "bell")) { + [weak self] _ in + self?.parent.onSnooze(thread, nil) + } + ) + } else { + let presets = DailyUXSnoozePresets.resolve(now: .now) + let children = presets.map { preset in + UIAction(title: preset.label) { [weak self] _ in + self?.parent.onSnooze(thread, preset.until) + } + } + let snoozeIsDisabled = thread.state == .queued + || thread.state == .waitingForApproval + || thread.state == .waitingForInput + if snoozeIsDisabled { + children.forEach { $0.attributes = .disabled } + } + let snooze = UIMenu( + title: "Snooze", + image: UIImage(systemName: "clock"), + children: children + ) + statusActions.append(snooze) + } + } + } + + let archive = UIAction( + title: isArchived ? "Restore" : "Archive", + image: UIImage(systemName: isArchived ? "arrow.uturn.backward" : "archivebox") + ) { [weak self] _ in + self?.parent.onArchive(thread, !isArchived) + } + let delete = UIAction( + title: "Delete thread", + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { [weak self] _ in + self?.parent.onDelete(thread) + } + + var sections = [UIMenu(options: .displayInline, children: titleActions)] + if !statusActions.isEmpty { + sections.append(UIMenu(options: .displayInline, children: statusActions)) + } + sections.append(UIMenu(options: .displayInline, children: [archive])) + sections.append(UIMenu(options: .displayInline, children: [delete])) + return sections + } + + /// Working rows show a live per-second duration, so they need a 1 Hz + /// tick. Without any, relative ages only change by the minute, and the + /// timer idles down to match instead of waking the main thread every + /// second for the lifetime of the sidebar. + private func startTimer() { + let interval: TimeInterval = itemsByID.values.contains { + if case let .thread(thread, _, _, _, _, _) = $0 { + return thread.homeStatus == .working + } + return false + } ? 1 : 60 + + if timer != nil, timerInterval == interval { return } + invalidateTimer() + timerInterval = interval + timerTick = 0 + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.refreshVisibleTimes() + } + } + timer?.tolerance = interval * 0.12 + } + + private func refreshVisibleTimes() { + guard !isApplyingSnapshot, + let collectionView, let dataSource, + !collectionView.isTracking, !collectionView.isDecelerating else { return } + timerTick = (timerTick + 1) % 60 + let refreshRelativeAges = timerInterval >= 60 || timerTick == 0 + let now = Date.now + + for indexPath in collectionView.indexPathsForVisibleItems { + guard let identifier = dataSource.itemIdentifier(for: indexPath), + case let .thread(thread, _, _, _, _, _) = itemsByID[identifier], + refreshRelativeAges || thread.homeStatus == .working, + let cell = collectionView.cellForItem(at: indexPath) as? HomeCollectionCell else { + continue + } + configure(cell, identifier: identifier, now: now) + } + } + } + + var collectionItems: [HomeCollectionItem] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedQuery.isEmpty { + if presentation.searchResults.isEmpty { + return [.searchEmpty(normalizedQuery)] + } + return presentation.searchResults.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + .rich, + $0.isArchived, + forceRichRows, + nil + ) + } + } + + var items = presentation.pinned.map { + HomeCollectionItem.thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + .rich, + false, + forceRichRows, + .active + ) + } + if !presentation.pinned.isEmpty, !presentation.active.isEmpty { + items.append(.pinnedDivider) + } + if presentation.active.isEmpty, presentation.pinned.isEmpty { + items.append(.empty(.active)) + } else { + items.append(contentsOf: presentation.active.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + .rich, + false, + forceRichRows, + .active + ) + }) + } + + if !presentation.snoozed.isEmpty { + items.append(.shelfHeader(.snoozed, presentation.snoozed.count, isSnoozedExpanded)) + if isSnoozedExpanded { + items.append(contentsOf: presentation.snoozed.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + forceRichRows ? .rich : .slim, + false, + forceRichRows, + .snoozed + ) + }) + } + } + + if !presentation.settled.isEmpty { + items.append(.shelfHeader(.settled, presentation.settled.count, isSettledExpanded)) + if isSettledExpanded { + items.append(contentsOf: presentation.settled.prefix(settledLimit).map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + forceRichRows ? .rich : .slim, + false, + forceRichRows, + .settled + ) + }) + if presentation.settled.count > settledLimit { + items.append(.showMoreSettled(presentation.settled.count - settledLimit)) + } + } + } + + if !presentation.archived.isEmpty { + items.append(.shelfHeader(.archived, presentation.archived.count, isArchiveExpanded)) + if isArchiveExpanded { + items.append(contentsOf: presentation.archived.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + forceRichRows ? .rich : .slim, + true, + forceRichRows, + .archived + ) + }) + } + } + return items + } +} + +/// The trailing swipe actions a Home row offers, resolved as data so the row's +/// gesture semantics stay deterministic and testable without hosting a +/// collection view. Order is outermost-first, matching +/// `UISwipeActionsConfiguration`, which lays trailing actions out from the +/// trailing edge inward and runs the first action on a full swipe. +enum HomeThreadSwipeAction: Equatable { + case delete + case restore + case unpin + case settle + case reopen + case archive + + /// The lifecycle mutation an action requests. Keeping it separate from the + /// action keeps the swipe wiring verifiable and forces every case through + /// the row's existing callbacks instead of a second settlement path. + enum Intent: Equatable { + case delete + case setArchived(Bool) + case setPinned(Bool) + case setSettled(Bool) + } + + /// Settlement owns the edge slot on every row that can settle, so a full + /// swipe clears the task in one motion and a partial swipe still reveals + /// every button. Delete is always last and therefore can never be the + /// full-swipe action. A pinned row keeps Unpin between the two: settling + /// already clears the pin, so the full swipe unpins and settles together. + /// Archived rows stay restore-only, and a row with nothing to settle keeps + /// its reversible action at the edge with the full swipe turned off. + static func trailingActions( + for thread: FeatureThread, + isArchived: Bool, + at now: Date + ) -> [HomeThreadSwipeAction] { + guard !isArchived else { return [.restore, .delete] } + + let isSettled = thread.isEffectivelySettled() + let settlement: HomeThreadSwipeAction? = isSettled + ? .reopen + : (thread.canSettleNow(at: now) ? .settle : nil) + let isPinned = thread.pinnedAt != nil && thread.canTogglePin + + var actions: [HomeThreadSwipeAction] = [] + if let settlement { + actions.append(settlement) + if isPinned { + actions.append(.unpin) + } + } else if isPinned { + actions.append(.unpin) + } else { + actions.append(.archive) + } + actions.append(.delete) + return actions + } + + /// The full swipe is armed only when the edge action settles or reopens. + /// Nothing else may run from the gesture alone. + static func performsFullSwipe(with actions: [HomeThreadSwipeAction]) -> Bool { + actions.first?.isSettlement ?? false + } + + var isSettlement: Bool { + self == .settle || self == .reopen + } + + var intent: Intent { + switch self { + case .delete: .delete + case .restore: .setArchived(false) + case .archive: .setArchived(true) + case .unpin: .setPinned(false) + case .settle: .setSettled(true) + case .reopen: .setSettled(false) + } + } + + var title: String { + switch self { + case .delete: "Delete" + case .restore: "Restore" + case .unpin: "Unpin" + case .settle: "Settle" + case .reopen: "Reopen" + case .archive: "Archive" + } + } + + var systemImage: String { + switch self { + case .delete: "trash" + case .restore: "arrow.uturn.backward" + case .unpin: "pin.slash" + case .settle: "checkmark" + case .reopen: "arrow.counterclockwise" + case .archive: "archivebox" + } + } + + var style: UIContextualAction.Style { + self == .delete ? .destructive : .normal + } + + /// Destructive actions keep UIKit's own tint. + var backgroundColor: UIColor? { + switch self { + case .delete: nil + case .restore, .unpin, .reopen: .systemBlue + case .settle: .systemGreen + case .archive: .systemGray + } + } +} + +private final class HomeCollectionCell: UICollectionViewListCell { + var onAccessibilityActivate: (() -> Void)? + + override func accessibilityActivate() -> Bool { + guard let onAccessibilityActivate else { return super.accessibilityActivate() } + onAccessibilityActivate() + return true + } + + override func prepareForReuse() { + super.prepareForReuse() + contentView.isHidden = false + onAccessibilityActivate = nil + } +} + +enum HomeShelf: String, Hashable { + case active + case snoozed + case settled + case archived + + var title: String { + rawValue.capitalized + } +} + +enum HomeCollectionItem: Equatable { + enum ID: Hashable { + // A shelf change replaces the cell. Moving the same cell while it + // changes between rich and slim layouts makes its height jump mid-swipe. + case thread(String, HomeShelf?) + case shelfHeader(HomeShelf) + case empty(HomeShelf) + case showMoreSettled + case searchEmpty + case pinnedDivider + + var threadID: String? { + guard case let .thread(id, _) = self else { return nil } + return id + } + } + + case thread(FeatureThread, HomeThreadRowContext, FeatureThreadRow.Style, Bool, Bool, HomeShelf?) + case shelfHeader(HomeShelf, Int, Bool) + case empty(HomeShelf) + case showMoreSettled(Int) + case searchEmpty(String) + case pinnedDivider + + var id: ID { + switch self { + case let .thread(thread, _, _, _, _, shelf): .thread(thread.id, shelf) + case let .shelfHeader(shelf, _, _): .shelfHeader(shelf) + case let .empty(shelf): .empty(shelf) + case .showMoreSettled: .showMoreSettled + case .searchEmpty: .searchEmpty + case .pinnedDivider: .pinnedDivider + } + } +} + +private struct HomeCollectionCellContent: View { + let item: HomeCollectionItem + let projectFaviconClient: any FeatureClient + let isSelected: Bool + let now: Date + let onPullRequestChange: (HomeThreadPullRequestPresentation?) -> Void + + @ViewBuilder + var body: some View { + switch item { + case let .thread(thread, context, style, _, allowsMultilineTitle, _): + FeatureThreadRow( + thread: thread, + context: context, + projectFaviconClient: projectFaviconClient, + onPullRequestChange: onPullRequestChange, + isSelected: isSelected, + style: style, + now: now, + allowsMultilineTitle: allowsMultilineTitle + ) + case let .shelfHeader(shelf, count, isExpanded): + HomeShelfHeader( + title: shelf.title, + count: count, + isExpanded: isExpanded, + accent: shelf == .snoozed ? T3Colors.accent : nil + ) + case let .empty(shelf): + Text(shelf == .active ? "No active tasks" : "None") + .font(T3Typography.homeMetadata) + .foregroundStyle(T3Colors.textTertiary) + .frame(maxWidth: .infinity, minHeight: shelf == .active ? 68 : 34, alignment: .center) + case let .showMoreSettled(remaining): + HStack { + Text("Show more") + Spacer() + Text("\(remaining)") + .monospacedDigit() + .foregroundStyle(T3Colors.textTertiary) + } + .font(T3Typography.homeMetadata.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 34) + .frame(minHeight: T3Metrics.minimumTapTarget) + case .searchEmpty: + ContentUnavailableView("No matching tasks", systemImage: "magnifyingglass") + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 160) + case .pinnedDivider: + Rectangle() + .fill(T3Colors.textTertiary.opacity(0.18)) + .frame(height: 1) + .padding(.horizontal, 18) + .padding(.vertical, 3) + } + } +} + +private extension Optional where Wrapped == [IndexPath] { + var orEmpty: [IndexPath] { self ?? [] } +} diff --git a/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift b/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift new file mode 100644 index 000000000000..6b73318af697 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift @@ -0,0 +1,84 @@ +import Foundation + +public enum FeatureWorkspaceMode: String, CaseIterable, Sendable, Codable { + case local + case worktree + + var title: String { + switch self { + case .local: "Current checkout" + case .worktree: "New worktree" + } + } + + var systemImage: String { + switch self { + case .local: "folder" + case .worktree: "arrow.triangle.branch" + } + } +} + +public struct FeatureWorkspaceBranch: Identifiable, Sendable, Equatable, Hashable { + public var name: String + public var isRemote: Bool + public var isCurrent: Bool + public var isDefault: Bool + public var worktreePath: String? + + public init( + name: String, + isRemote: Bool = false, + isCurrent: Bool = false, + isDefault: Bool = false, + worktreePath: String? = nil + ) { + self.name = name + self.isRemote = isRemote + self.isCurrent = isCurrent + self.isDefault = isDefault + self.worktreePath = worktreePath + } + + public var id: String { + "\(isRemote ? "remote" : "local"):\(name)" + } + + var badge: String? { + if isCurrent { return "Current" } + if worktreePath != nil { return "Worktree" } + if isDefault { return "Default" } + if isRemote { return "Remote" } + return nil + } +} + +enum NewTaskWorkspaceDefaults { + static func localBranch(in branches: [FeatureWorkspaceBranch]) -> FeatureWorkspaceBranch? { + branches.first { $0.isCurrent } + ?? branches.first { $0.isDefault && !$0.isRemote } + ?? branches.first { !$0.isRemote } + ?? branches.first + } + + static func worktreeBase(in branches: [FeatureWorkspaceBranch]) -> FeatureWorkspaceBranch? { + branches.first { $0.isDefault && !$0.isRemote } + ?? branches.first { $0.isCurrent } + ?? branches.first { $0.isDefault } + ?? branches.first { !$0.isRemote } + ?? branches.first + } + + static func normalizedWorktreePath( + for branch: FeatureWorkspaceBranch?, + projectPath: String + ) -> String? { + guard let path = branch?.worktreePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty, + URL(fileURLWithPath: path).standardizedFileURL.path + != URL(fileURLWithPath: projectPath).standardizedFileURL.path else { + return nil + } + return path + } +} diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift new file mode 100644 index 000000000000..e5a9fb872a51 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -0,0 +1,1667 @@ +import SwiftUI + +public struct NewThreadView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @Bindable var model: FeatureRootModel + let submit: (NewTaskRequest) async -> FeatureThread? + let onCreated: (FeatureThread) -> Void + let onCreateProject: @MainActor () -> Void + private let draftStore: FeatureComposerDraftStore + private let initialProjectID: String? + + @State private var projectID = "" + @State private var projectSelectionIsExplicit = false + @State private var isAwaitingRecentProject = false + @State private var prompt = "" + @State private var selection: FeatureSelection? + @State private var selectionIsExplicit = false + @State private var preferredSelection: FeatureSelection? + @State private var attachments: [FeatureDraftAttachment] = [] + @State private var workspaceMode: FeatureWorkspaceMode = .local + @State private var workspaceSelectionIsExplicit = false + @State private var branches: [FeatureWorkspaceBranch] = [] + @State private var selectedBranch: FeatureWorkspaceBranch? + @State private var startFromOrigin = true + @State private var branchesLoading = false + @State private var branchLoadFailed = false + @State private var activePicker: NewTaskPicker? + @State private var isSubmitting = false + @State private var submissionFailed = false + @State private var submissionValidationError: String? + @State private var restoredDraftProjectID: String? + @State private var draftRestoreContext: NewTaskDraftRestoreContext? + @State private var draftSaveTask: Task? + @State private var draftSaveError: String? + @State private var immediateDraftSaveTasks: [String: Task] = [:] + @State private var submittedSuccessfully = false + @State private var restoresPromptAfterPickerDismissal = false + @State private var unreachableRetry = NewTaskRetryState() + // Plain state, not `FocusState`; see the note on `composerFocused` in + // ThreadDetailView. + @State private var promptFocused = false + + public init( + model: FeatureRootModel, + submit: @escaping (NewTaskRequest) async -> FeatureThread?, + onCreated: @escaping (FeatureThread) -> Void, + onCreateProject: @escaping @MainActor () -> Void = {}, + initialProjectID: String? = nil, + draftStore: FeatureComposerDraftStore = .shared + ) { + self.model = model + self.submit = submit + self.onCreated = onCreated + self.onCreateProject = onCreateProject + self.initialProjectID = initialProjectID + self.draftStore = draftStore + } + + public var body: some View { + ZStack { + T3Colors.background.ignoresSafeArea() + + VStack(spacing: 0) { + topBar + if creationProjects.isEmpty { + noProjects + } else if !usesCompactProjectContext { + hero + .padding(.top, 82) + } + Spacer(minLength: 0) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if !creationProjects.isEmpty { + VStack(spacing: 0) { + if usesCompactProjectContext { + compactProjectContext + } + + if let submissionValidationError { + Label(submissionValidationError, systemImage: "exclamationmark.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + workspaceControls + + FeatureComposerView( + text: $prompt, + selection: selectionBinding, + attachments: attachmentBinding, + draftOwnerID: selectedProject.map { + "new-task:\($0.environmentID):\($0.id)" + } ?? "new-task:unselected", + environmentID: selectedProject?.environmentID, + draftStorageKey: currentDraftKey, + environmentIsConnected: selectedProject.flatMap { project in + model.snapshot.environments.first { + $0.id == project.environmentID + }?.connectionState + } == .connected, + attachmentUploads: model.attachmentUploads, + attachmentPreferences: environmentPreferences, + providers: creationProviders, + threadSelection: nil, + isSending: isSubmitting, + isWorking: false, + focused: $promptFocused, + onSend: startTask, + onStop: {}, + forceExpanded: true, + powerFeatures: composerPowerFeatures, + onDismissKeyboard: { promptFocused = false }, + onRefreshModels: refreshSelectedEnvironmentModels, + draftSaveError: draftSaveError, + onRetryDraftSave: persistCurrentDraftImmediately + ) + } + .background(T3Colors.background) + } + } + .onAppear { + if projectID.isEmpty { + let recentProject = DailyUXCreationContext.recentProjects( + in: model.snapshot + ).first?.project + let initialID = DailyUXCreationContext.initialProject( + in: model.snapshot, + requestedProjectID: initialProjectID + )?.id ?? "" + isAwaitingRecentProject = initialProjectID == nil && recentProject == nil + selectInitialProject(initialID) + } + } + .onChange(of: projectID) { prepareProjectIfNeeded(projectID) } + .onChange(of: creationProjectIDs) { _, ids in + guard !ids.contains(projectID) else { return } + if projectID.isEmpty { + let recentProject = DailyUXCreationContext.recentProjects( + in: model.snapshot + ).first?.project + let initialID = DailyUXCreationContext.initialProject( + in: model.snapshot, + requestedProjectID: initialProjectID + )?.id ?? "" + isAwaitingRecentProject = initialProjectID == nil && recentProject == nil + selectInitialProject(initialID) + return + } + persistCurrentDraftImmediately() + let previousProject = model.snapshot.projects.first { $0.id == projectID } + let previousGroupID = previousProject.map { + DailyUXCreationContext.logicalProjectID(for: $0, in: model.snapshot) + } + let replacement = creationProjectGroups.first { $0.id == previousGroupID }? + .preferredProject(environmentID: previousProject?.environmentID) + ?? creationProjectGroups.first?.projects.first + selectInitialProject(replacement?.id ?? "") + } + .onChange(of: model.homePresentationRevision) { _, _ in + refreshAutomaticProjectIfNeeded() + } + .onChange(of: prompt) { scheduleDraftSave() } + .onChange(of: selection) { scheduleDraftSave() } + .onChange(of: workspaceMode) { scheduleDraftSave() } + .onChange(of: selectedBranch) { scheduleDraftSave() } + .onChange(of: startFromOrigin) { scheduleDraftSave() } + .onChange(of: submissionValidationMessage) { _, _ in + submissionValidationError = nil + } + .onChange(of: scenePhase) { _, phase in + if phase != .active, !submittedSuccessfully { + persistCurrentDraftImmediately() + } + } + .task(id: projectID) { await restoreDraftAndLoadBranches() } + .environment(\.providerSetupContext, selectedProject.map { + ProviderSetupContext(model: model, environmentID: $0.environmentID) + }) + .task(id: "\(selectedProject?.id ?? ""):\(selection?.providerID ?? "")") { + if let project = selectedProject, let instanceID = selection?.providerID { + await model.refreshWorkspaceProviders(environmentID: project.environmentID, cwd: project.path, instanceID: instanceID) + } + } + .onDisappear { + guard !submittedSuccessfully else { return } + persistCurrentDraftImmediately() + } + .sheet(item: $activePicker, onDismiss: { + let shouldRestorePrompt = restoresPromptAfterPickerDismissal + restoresPromptAfterPickerDismissal = false + if shouldRestorePrompt, !creationProjects.isEmpty, !isSubmitting { + promptFocused = true + } + }) { picker in + switch picker { + case .project: + NewTaskProjectPicker( + groups: creationProjectGroups, + environments: model.snapshot.environments, + recentGroupIDs: recentProjectGroupIDs, + selectionID: selectedProjectGroup?.id, + retryState: unreachableRetry, + onRetry: retryUnreachableEnvironments, + onSelect: { group in + if selectProjectGroup(group) { + activePicker = nil + } + } + ) + case .branch: + NewTaskBranchPicker( + branches: branches, + selection: selectedBranch, + isLoading: branchesLoading, + loadFailed: branchLoadFailed, + onSelect: { branch in + workspaceSelectionIsExplicit = true + selectedBranch = branch + activePicker = nil + }, + onRefresh: { Task { await loadBranches(refresh: true) } } + ) + } + } + .alert("Couldn’t start task", isPresented: $submissionFailed) { + // Refocus on dismissal, not on failure: the alert takes first + // responder, so an earlier refocus never survives it. + Button("OK") { promptFocused = true } + } message: { + Text("Check your connection and try again.") + } + .interactiveDismissDisabled(isSubmitting) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + + private var usesCompactProjectContext: Bool { + NewThreadComposerLayout.usesCompactContext( + prompt: prompt, + isFocused: promptFocused, + hasAttachments: !attachments.isEmpty + ) + } + + private var topBar: some View { + HStack { + Button("Cancel") { dismiss() } + .font(.body) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: 44) + .disabled(isSubmitting) + .accessibilityLabel("Cancel new task") + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 48) + } + + private var hero: some View { + VStack(spacing: 8) { + VStack(spacing: 4) { + Text("What should we build") + + HStack(spacing: 0) { + Text("in") + + Button { + presentPicker(.project) + } label: { + Text(selectedProjectGroup?.name ?? selectedProject?.name ?? "a project") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(T3Colors.textPrimary) + .overlay(alignment: .bottom) { + DottedUnderline() + .stroke( + T3Colors.textPrimary.opacity(0.58), + style: StrokeStyle(lineWidth: 1, dash: [2, 3]) + ) + .frame(height: 1) + .offset(y: 3) + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .padding(.leading, 5) + .layoutPriority(1) + .accessibilityLabel("Choose project") + .accessibilityValue( + selectedProjectGroup?.name ?? selectedProject?.name ?? "Not selected" + ) + + Text("?") + } + } + .font(T3Typography.threadHeading1.weight(.regular)) + .foregroundStyle(T3Colors.textPrimary) + .multilineTextAlignment(.center) + + environmentPicker + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + } + + private var compactProjectContext: some View { + HStack(spacing: 12) { + Button { + presentPicker(.project) + } label: { + HStack(spacing: 6) { + Image(systemName: "folder") + .font(.system(size: 11, weight: .medium)) + Text( + selectedProjectGroup?.name + ?? selectedProject?.name + ?? "Choose project" + ) + .lineLimit(1) + .truncationMode(.middle) + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textPrimary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .layoutPriority(1) + .accessibilityLabel("Choose project") + .accessibilityValue( + selectedProjectGroup?.name ?? selectedProject?.name ?? "Not selected" + ) + + Spacer(minLength: 0) + + environmentPicker + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + } + + private var environmentPicker: some View { + Menu { + ForEach(creationEnvironments) { environment in + Button { + selectEnvironment(environment.id) + } label: { + if environment.id == selectedProject?.environmentID { + Label(environmentLabel(environment), systemImage: "checkmark") + } else { + Text(environmentLabel(environment)) + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: creationEnvironments.first { $0.id == selectedProject?.environmentID }?.systemImage ?? "server.rack") + .font(.system(size: 11, weight: .medium)) + Text(environmentName) + .lineLimit(1) + .truncationMode(.middle) + if let environmentStatus { + Text(environmentStatus) + .foregroundStyle(T3Colors.warning) + } + if creationEnvironments.count > 1 { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting || creationEnvironments.count < 2) + .accessibilityLabel("Environment") + .accessibilityValue(environmentAccessibilityValue) + } + + private var noProjects: some View { + ScrollView { + VStack(spacing: 14) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(T3Colors.textSecondary) + Text("No projects") + .font(T3Typography.threadHeading1.weight(.regular)) + .foregroundStyle(T3Colors.textPrimary) + if !unreachableEnvironments.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(unreachableEnvironments) { environment in + Label( + "\(environment.name) is unreachable", + systemImage: "network.slash" + ) + .accessibilityLabel("\(environment.name) is unreachable") + .accessibilityIdentifier( + "new-task-unreachable-environment-\(environment.id)" + ) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + + Button(action: retryUnreachableEnvironments) { + HStack(spacing: 8) { + if unreachableRetry.isInProgress { + ProgressView() + .controlSize(.small) + } + Text(unreachableRetry.buttonTitle) + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(unreachableRetry.isInProgress) + .accessibilityLabel(unreachableRetry.buttonTitle) + .accessibilityHint("Refresh environment status") + .accessibilityIdentifier("new-task-unreachable-retry") + } + Button("Add project") { + dismiss() + Task { @MainActor in + await Task.yield() + onCreateProject() + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(T3Colors.primaryAction) + .foregroundStyle(T3Colors.primaryActionForeground) + .padding(.top, 6) + } + .padding(.top, 82) + .padding(.bottom, 28) + .padding(.horizontal, 28) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @MainActor + private func retryUnreachableEnvironments() { + guard unreachableRetry.begin() else { return } + Task { @MainActor in + defer { unreachableRetry.finish() } + await model.reload() + } + } + + private var selectedProject: FeatureProject? { + creationProjects.first { $0.id == projectID } + } + + private var creationProjectGroups: [DailyUXProjectGroup] { + DailyUXCreationContext.projectGroups(in: model.snapshot) + } + + private var recentProjectGroupIDs: [String] { + DailyUXCreationContext.recentProjects(in: model.snapshot).map(\.group.id) + } + + private var selectedProjectGroup: DailyUXProjectGroup? { + DailyUXProjectGrouping.group(containing: projectID, in: creationProjectGroups) + } + + private var workspaceControls: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + Menu { + Button { + setWorkspaceMode(.local) + } label: { + Label( + FeatureWorkspaceMode.local.title, + systemImage: workspaceMode == .local ? "checkmark" : "folder" + ) + } + Button { + setWorkspaceMode(.worktree) + } label: { + Label( + FeatureWorkspaceMode.worktree.title, + systemImage: workspaceMode == .worktree + ? "checkmark" + : "arrow.triangle.branch" + ) + } + } label: { + workspaceControlLabel( + workspaceMode.title, + systemImage: workspaceMode.systemImage, + showsChevron: true + ) + } + .disabled(isSubmitting) + .accessibilityLabel("Workspace") + .accessibilityValue(workspaceMode.title) + + if workspaceMode == .worktree { + Button { + presentPicker(.branch) + } label: { + workspaceControlLabel( + selectedBranch?.name + ?? (branchesLoading ? "Loading branches" : "Choose branch"), + systemImage: "arrow.triangle.branch", + showsChevron: true + ) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .accessibilityLabel("Base branch") + .accessibilityValue(selectedBranch?.name ?? "Not selected") + + Button { + workspaceSelectionIsExplicit = true + startFromOrigin.toggle() + } label: { + Label( + "Latest origin", + systemImage: startFromOrigin ? "checkmark.circle.fill" : "circle" + ) + .lineLimit(1) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle( + startFromOrigin ? T3Colors.textSecondary : T3Colors.textTertiary + ) + .disabled(isSubmitting) + .accessibilityLabel("Start from latest origin") + .accessibilityValue(startFromOrigin ? "On" : "Off") + } else if let selectedBranch { + Label(selectedBranch.name, systemImage: "arrow.triangle.branch") + .lineLimit(1) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityLabel("Current branch, \(selectedBranch.name)") + } + } + .padding(.horizontal, 18) + } + .font(T3Typography.supporting) + .frame(minHeight: 44) + .animation(.snappy(duration: 0.18), value: workspaceMode) + } + + private func workspaceControlLabel( + _ title: String, + systemImage: String, + showsChevron: Bool + ) -> some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .medium)) + Text(title) + .lineLimit(1) + if showsChevron { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + } + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + + private var creationProjects: [FeatureProject] { + DailyUXCreationContext.projects(in: model.snapshot) + } + + private var unreachableEnvironments: [FeatureEnvironment] { + DailyUXCreationContext.unreachableEnvironments(in: model.snapshot) + } + + private var creationProjectIDs: [String] { + creationProjectGroups.flatMap(\.projects).map(\.id) + } + + private var creationEnvironments: [FeatureEnvironment] { + let environmentIDs = Set(selectedProjectGroup?.projects.map(\.environmentID) ?? []) + return model.snapshot.environments.filter { environmentIDs.contains($0.id) } + } + + private var selectedEnvironment: FeatureEnvironment? { + guard let environmentID = selectedProject?.environmentID else { return nil } + return model.snapshot.environments.first { $0.id == environmentID } + } + + private var environmentName: String { + if let selectedEnvironment { return selectedEnvironment.name } + return model.snapshot.connection.environmentName ?? "this server" + } + + private var environmentStatus: String? { + guard let selectedEnvironment else { return nil } + return environmentStatus(selectedEnvironment) + } + + private var environmentAccessibilityValue: String { + guard let environmentStatus else { return environmentName } + return "\(environmentName), \(environmentStatus)" + } + + private func environmentLabel(_ environment: FeatureEnvironment) -> String { + guard let status = environmentStatus(environment) else { return environment.name } + return "\(environment.name) · \(status)" + } + + private func environmentStatus(_ environment: FeatureEnvironment) -> String? { + guard environment.isEnabled else { return "Off" } + switch environment.connectionState { + case .disconnected: return "Offline" + case .connecting: return "Connecting" + case .reconnecting: return "Reconnecting" + case .connected, .none: return nil + } + } + + private var initialSelection: FeatureSelection? { + ProviderModelSelectionResolver.materialized( + DailyUXCreationContext.initialSelection( + for: selectedProject, + in: model.snapshot + ), + in: creationProviders + ) + } + + private var environmentPreferences: FeatureEnvironmentPreferences { + DailyUXCreationContext.environmentPreferences( + for: selectedProject, + in: model.snapshot + ) + } + + private func refreshSelectedEnvironmentModels() async throws { + guard let environmentID = selectedProject?.environmentID else { return } + guard await model.refreshProviders(environmentID: environmentID) else { + throw FeatureModelRefreshError() + } + } + + private var selectionBinding: Binding { + Binding( + get: { selection }, + set: { value in + let materializesProjectDefault = !selectionIsExplicit + && value == initialSelection + selection = value + guard !materializesProjectDefault else { return } + selectionIsExplicit = true + preferredSelection = value + } + ) + } + + /// Model and provider capabilities belong to the project's environment, + /// which may not be the connection currently selected in Settings. + private var creationProviders: [FeatureProvider] { + ProviderModelCatalogNormalizer.normalized( + DailyUXCreationContext.providers( + for: selectedProject, + in: model.snapshot + ) + ) + } + + private var composerPowerFeatures: FeatureComposerPowerFeatures { + let provider = creationProviders.first { + $0.id == selection?.providerID + } + guard let project = selectedProject else { + return FeatureComposerPowerFeatures( + slashCommands: provider?.slashCommands ?? [], + skills: provider?.skills ?? [] + ) + } + return FeatureComposerPowerFeatures( + slashCommands: provider?.workspaceCatalog(cwd: project.path).slashCommands ?? [], + skills: provider?.workspaceCatalog(cwd: project.path).skills ?? [], + pathSearchScopeID: project.id, + searchPaths: { query in + try await model.client.searchProjectFiles( + projectID: project.id, + query: query, + limit: 20 + ).map(Self.composerPathEntry) + } + ) + } + + private static func composerPathEntry(_ entry: FeatureFileEntry) -> FeatureComposerPathEntry { + FeatureComposerPathEntry( + path: entry.path, + kind: entry.kind == .directory ? .directory : .file + ) + } + + private var canSubmit: Bool { + !isSubmitting && submissionValidationMessage == nil + } + + private var submissionValidationMessage: String? { + if let environmentMessage = DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: projectID, + in: model.snapshot + ) { + return environmentMessage + } + guard selectedProject != nil else { return "Choose a project." } + guard restoredDraftProjectID == projectID else { return "Project is loading." } + guard concreteSelection != nil else { + guard !creationProviders.isEmpty else { return "No providers available." } + guard creationProviders.contains(where: \.isAvailable) else { + return "No providers are online." + } + guard creationProviders.contains(where: { $0.isAvailable && !$0.models.isEmpty }) + else { + return "No models available." + } + return "Choose a model." + } + guard !trimmedPrompt.isEmpty || !attachments.isEmpty else { + return "Add a message or image." + } + guard attachments.isEmpty || imagesAllowed else { + return "This model does not support images." + } + guard workspaceMode != .worktree || selectedBranch != nil else { + if branchesLoading { return "Branches are loading." } + return branchLoadFailed ? "Could not load branches." : "Choose a base branch." + } + return nil + } + + private var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var imagesAllowed: Bool { + DailyUXModelOptions.supportsImages( + selection: concreteSelection, + providers: creationProviders + ) + } + + private var concreteSelection: FeatureSelection? { + guard creationProviders.contains(where: { $0.isAvailable && !$0.models.isEmpty }) else { + return nil + } + return ProviderModelSelectionResolver.materialized(selection, in: creationProviders) + } + + private func presentPicker(_ picker: NewTaskPicker) { + restoresPromptAfterPickerDismissal = promptFocused + promptFocused = false + activePicker = picker + } + + private func startTask() { + guard !isSubmitting else { return } + guard canSubmit, + let project = selectedProject, + let concreteSelection else { + submissionValidationError = submissionValidationMessage + if submissionValidationError == "Choose a base branch." + || submissionValidationError == "Could not load branches." { + presentPicker(.branch) + } + return + } + submissionValidationError = nil + promptFocused = false + isSubmitting = true + let pendingDraftSaveTask = draftSaveTask + pendingDraftSaveTask?.cancel() + draftSaveTask = nil + let draftKey = currentDraftKey + let draftSnapshot = composerDraft + let immediateDraftSaveTask = draftKey.flatMap { + immediateDraftSaveTasks.removeValue(forKey: $0) + } + let request = NewTaskRequest( + projectID: project.id, + prompt: trimmedPrompt, + selection: concreteSelection, + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: workspaceMode, + branch: selectedBranch?.name, + worktreePath: workspaceMode == .local + ? NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: selectedBranch, + projectPath: project.path + ) + : nil, + startFromOrigin: startFromOrigin, + attachments: model.attachmentUploads.attachmentsForSend( + draftKey: draftKey ?? FeatureComposerDraftStore.newTaskKey(project: project), + environmentID: project.environmentID, + attachments: attachments + ) + ) + + Task { @MainActor in + await NewTaskDraftWriteFence.cancelAndWait(pendingDraftSaveTask) + await NewTaskDraftWriteFence.cancelAndWait(immediateDraftSaveTask) + if let draftKey { + try? await draftStore.setDraft(draftSnapshot, for: draftKey) + } + if let thread = await submit(request) { + submittedSuccessfully = true + let trailingDraftSaveTask = draftSaveTask + draftSaveTask = nil + await NewTaskDraftWriteFence.cancelAndWait(trailingDraftSaveTask) + if let draftKey { + let trailingSave = immediateDraftSaveTasks.removeValue(forKey: draftKey) + await NewTaskDraftWriteFence.cancelAndWait(trailingSave) + try? await draftStore.removeDraft(for: draftKey) + } + onCreated(thread) + } else { + isSubmitting = false + persistCurrentDraftImmediately() + submissionFailed = true + } + } + } + + @discardableResult + private func selectProject(_ id: String, carryingContent: FeatureComposerDraft? = nil) -> Bool { + guard creationProjects.contains(where: { $0.id == id }) else { return false } + projectSelectionIsExplicit = true + isAwaitingRecentProject = false + guard id != projectID else { return true } + persistCurrentDraftImmediately() + projectID = id + prepareProjectIfNeeded(id, carryingContent: carryingContent) + return true + } + + @discardableResult + private func selectProjectGroup(_ group: DailyUXProjectGroup) -> Bool { + guard let target = DailyUXProjectGrouping.selectionTarget( + groupID: group.id, + preferredEnvironmentID: selectedProject?.environmentID, + in: creationProjectGroups + ) else { return false } + projectSelectionIsExplicit = true + guard group.id != selectedProjectGroup?.id else { return true } + return selectProject(target.id) + } + + private func selectEnvironment(_ id: String) { + guard selectedProject?.environmentID != id else { return } + let project = selectedProjectGroup?.project(in: id) + guard let project else { return } + selectProject( + project.id, + carryingContent: NewTaskDraftRestoreContext.content( + from: composerDraft, + forEnvironment: id + ) + ) + } + + private func selectInitialProject(_ id: String) { + projectID = id + prepareProjectIfNeeded(id) + } + + private func refreshAutomaticProjectIfNeeded() { + guard isAwaitingRecentProject else { return } + let nextProjectID = DailyUXCreationContext.recentProjects( + in: model.snapshot + ).first?.project.id + guard let nextProjectID else { return } + if nextProjectID == projectID { + isAwaitingRecentProject = false + return + } + let draftRestoreIsComplete = restoredDraftProjectID == projectID + guard DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: projectID, + nextRecentProjectID: nextProjectID, + isAwaitingRecentActivity: isAwaitingRecentProject, + projectSelectionIsExplicit: projectSelectionIsExplicit, + modelSelectionIsExplicit: selectionIsExplicit, + workspaceSelectionIsExplicit: workspaceSelectionIsExplicit, + hasDraftContent: !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !attachments.isEmpty, + draftRestoreIsComplete: draftRestoreIsComplete + ) else { + if draftRestoreIsComplete { + isAwaitingRecentProject = false + } + return + } + isAwaitingRecentProject = false + selectInitialProject(nextProjectID) + } + + private func prepareProjectIfNeeded(_ id: String, carryingContent: FeatureComposerDraft? = nil) { + guard draftRestoreContext?.projectID != id else { return } + + if selectionIsExplicit, let selection { + preferredSelection = selection + } + + restoredDraftProjectID = nil + draftSaveError = nil + draftSaveTask?.cancel() + draftSaveTask = nil + prompt = carryingContent?.text ?? "" + attachments = carryingContent?.attachments ?? [] + selectionIsExplicit = false + workspaceSelectionIsExplicit = false + branches = [] + selectedBranch = nil + branchLoadFailed = false + branchesLoading = false + + guard let project = creationProjects.first(where: { $0.id == id }) else { + selection = nil + workspaceMode = .local + startFromOrigin = true + draftRestoreContext = nil + return + } + + let providers = ProviderModelCatalogNormalizer.normalized( + DailyUXCreationContext.providers(for: project, in: model.snapshot) + ) + let carriedSelection = DailyUXModelOptions.validated(preferredSelection, in: providers) + selection = ProviderModelSelectionResolver.materialized( + DailyUXCreationContext.selection( + carrying: preferredSelection, + to: project, + in: model.snapshot + ), + in: providers + ) + selectionIsExplicit = carriedSelection != nil + let preferences = DailyUXCreationContext.environmentPreferences( + for: project, + in: model.snapshot + ) + workspaceMode = preferences.defaultWorkspaceMode + startFromOrigin = preferences.newWorktreesStartFromOrigin + draftRestoreContext = NewTaskDraftRestoreContext( + projectID: id, + baseline: carryingContent ?? FeatureComposerDraft(), + environmentID: project.environmentID + ) + } + + private func setWorkspaceMode(_ mode: FeatureWorkspaceMode) { + workspaceSelectionIsExplicit = true + workspaceMode = mode + selectedBranch = switch mode { + case .local: NewTaskWorkspaceDefaults.localBranch(in: branches) + case .worktree: NewTaskWorkspaceDefaults.worktreeBase(in: branches) + } + } + + @MainActor + private func loadBranches(refresh: Bool = false) async { + let requestedProjectID = projectID + guard !requestedProjectID.isEmpty else { return } + + branchesLoading = true + branchLoadFailed = false + do { + let loaded = try await model.workspaceBranches( + projectID: requestedProjectID, + refresh: refresh + ) + guard !Task.isCancelled, projectID == requestedProjectID else { return } + branches = loaded.sorted(by: Self.branchSort) + + if let selectedBranch, + let updated = branches.first(where: { $0.name == selectedBranch.name }) { + self.selectedBranch = updated + } else { + self.selectedBranch = switch workspaceMode { + case .local: NewTaskWorkspaceDefaults.localBranch(in: branches) + case .worktree: NewTaskWorkspaceDefaults.worktreeBase(in: branches) + } + } + } catch is CancellationError { + return + } catch { + guard projectID == requestedProjectID else { return } + branchLoadFailed = true + } + guard projectID == requestedProjectID else { return } + branchesLoading = false + } + + @MainActor + private func restoreDraftAndLoadBranches() async { + let requestedProjectID = projectID + guard let project = selectedProject, + let context = draftRestoreContext, + context.projectID == requestedProjectID, + !requestedProjectID.isEmpty else { + return + } + let key = draftKey(for: project) + let pendingImmediateSave = immediateDraftSaveTasks[key] + await NewTaskDraftWriteFence.wait(pendingImmediateSave) + guard !Task.isCancelled, + projectID == requestedProjectID, + draftRestoreContext?.projectID == requestedProjectID else { + return + } + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled, + projectID == requestedProjectID, + draftRestoreContext?.projectID == requestedProjectID else { + return + } + + let liveDraft = composerDraft + let liveSelectionIsExplicit = selectionIsExplicit + let liveWorkspaceSelectionIsExplicit = workspaceSelectionIsExplicit + let restored = context.merging( + saved: saved, + current: liveDraft, + fallbackSelection: initialSelection, + fallbackWorkspace: FeatureComposerWorkspaceDraft( + mode: environmentPreferences.defaultWorkspaceMode, + branch: nil, + worktreePath: nil, + startFromOrigin: environmentPreferences.newWorktreesStartFromOrigin + ) + ) + prompt = restored.text + attachments = restored.attachments + selection = DailyUXModelOptions.validated(restored.selection, in: creationProviders) + ?? initialSelection + selectionIsExplicit = liveSelectionIsExplicit || saved?.selection != nil + if selectionIsExplicit, let selection { + preferredSelection = selection + } + if let workspace = restored.workspace { + workspaceMode = workspace.mode + selectedBranch = workspace.branch.map { + FeatureWorkspaceBranch( + name: $0, + worktreePath: workspace.worktreePath + ) + } + startFromOrigin = workspace.startFromOrigin + } + workspaceSelectionIsExplicit = liveWorkspaceSelectionIsExplicit + || saved?.workspace != nil + restoredDraftProjectID = requestedProjectID + if context.shouldCarryContent(into: saved) { + persistCurrentDraftImmediately() + } else if liveDraft != context.baseline { + scheduleDraftSave() + } else if saved != nil { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: project.environmentID, + attachments: restored.attachments + ) + } + refreshAutomaticProjectIfNeeded() + guard projectID == requestedProjectID else { return } + await loadBranches() + } + + private var currentDraftKey: String? { + guard let project = selectedProject else { return nil } + return draftKey(for: project) + } + + private var attachmentBinding: Binding<[FeatureDraftAttachment]> { + Binding( + get: { attachments }, + set: { value in + attachments = value + if restoredDraftProjectID == projectID { + persistCurrentDraftImmediately() + } + } + ) + } + + private func draftKey(for project: FeatureProject) -> String { + FeatureComposerDraftStore.newTaskKey(project: project, in: model.snapshot) + } + + private var composerDraft: FeatureComposerDraft { + FeatureComposerDraft( + text: prompt, + attachments: attachments, + selection: selectionIsExplicit ? selection : nil, + workspace: workspaceSelectionIsExplicit + ? FeatureComposerWorkspaceDraft( + mode: workspaceMode, + branch: selectedBranch?.name, + worktreePath: workspaceMode == .local + ? NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: selectedBranch, + projectPath: selectedProject?.path ?? "" + ) + : nil, + startFromOrigin: startFromOrigin + ) + : nil + ) + } + + private func scheduleDraftSave() { + guard restoredDraftProjectID == projectID, + !isSubmitting, + !submittedSuccessfully, + let key = currentDraftKey else { + return + } + let pendingDraftSaveTask = draftSaveTask + pendingDraftSaveTask?.cancel() + draftSaveTask = nil + let snapshot = composerDraft + let environmentID = selectedProject?.environmentID + let immediateSave = immediateDraftSaveTasks[key] + draftSaveTask = Task { + await NewTaskDraftWriteFence.wait(pendingDraftSaveTask) + await NewTaskDraftWriteFence.wait(immediateSave) + do { + try await Task.sleep(for: .milliseconds(220)) + try Task.checkCancellation() + try await draftStore.setDraft(snapshot, for: key) + guard !Task.isCancelled else { return } + if currentDraftKey == key { draftSaveError = nil } + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, currentDraftKey == key else { return } + draftSaveError = "Could not save draft. \(error.localizedDescription)" + } + } + } + + private func persistCurrentDraftImmediately() { + guard !submittedSuccessfully, !isSubmitting, + let key = currentDraftKey else { + return + } + let pendingDraftSaveTask = draftSaveTask + pendingDraftSaveTask?.cancel() + draftSaveTask = nil + let snapshot = composerDraft + let restoreContext = draftRestoreContext + let draftProjectID = projectID + let environmentID = selectedProject?.environmentID + let needsRestoreMerge = restoredDraftProjectID != draftProjectID + let previousSave = immediateDraftSaveTasks[key] + previousSave?.cancel() + let task = Task { @MainActor in + await NewTaskDraftWriteFence.wait(pendingDraftSaveTask) + await NewTaskDraftWriteFence.wait(previousSave) + guard !Task.isCancelled else { return } + if needsRestoreMerge, + let restoreContext, + restoreContext.projectID == draftProjectID { + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled else { return } + let merged = restoreContext.merging(saved: saved, current: snapshot) + do { + try await draftStore.setDraft(merged, for: key) + guard !Task.isCancelled else { return } + if currentDraftKey == key { draftSaveError = nil } + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: merged.attachments + ) + } + } catch { + guard !Task.isCancelled, currentDraftKey == key else { return } + draftSaveError = "Could not save draft. \(error.localizedDescription)" + } + } else { + do { + try await draftStore.setDraft(snapshot, for: key) + guard !Task.isCancelled else { return } + if currentDraftKey == key { draftSaveError = nil } + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch { + guard !Task.isCancelled, currentDraftKey == key else { return } + draftSaveError = "Could not save draft. \(error.localizedDescription)" + } + } + } + immediateDraftSaveTasks[key] = task + } + + private static func branchSort( + _ lhs: FeatureWorkspaceBranch, + _ rhs: FeatureWorkspaceBranch + ) -> Bool { + let lhsRank = lhs.isCurrent ? 0 : lhs.isDefault ? 1 : lhs.isRemote ? 3 : 2 + let rhsRank = rhs.isCurrent ? 0 : rhs.isDefault ? 1 : rhs.isRemote ? 3 : 2 + if lhsRank != rhsRank { return lhsRank < rhsRank } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } +} + +enum NewThreadComposerLayout { + /// The full prompt is useful before editing starts. Once a draft needs + /// room, a compact row keeps the project and environment visible while the + /// editor uses the rest of the hero's space. + static func usesCompactContext( + prompt: String, + isFocused: Bool, + hasAttachments: Bool + ) -> Bool { + !prompt.isEmpty || isFocused || hasAttachments + } +} + +enum NewTaskDraftWriteFence { + static func wait(_ task: Task?) async { + await task?.value + } + + static func cancelAndWait(_ task: Task?) async { + task?.cancel() + await task?.value + } +} + +/// Keeps edits made during a draft read. A computer switch carries text and +/// local attachments only when the target has no saved content of its own. +struct NewTaskDraftRestoreContext: Equatable { + let projectID: String + let baseline: FeatureComposerDraft + var environmentID: String? = nil + + static func content( + from draft: FeatureComposerDraft, + forEnvironment environmentID: String + ) -> FeatureComposerDraft { + FeatureComposerDraft( + text: draft.text, + attachments: draft.attachments.map { attachment in + var attachment = attachment + if attachment.uploadedReference?.environmentID != environmentID { + attachment.uploadedReference = nil + } + return attachment + } + ) + } + + func shouldCarryContent(into saved: FeatureComposerDraft?) -> Bool { + let targetHasContent = saved.map { + !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !$0.attachments.isEmpty + } ?? false + return !targetHasContent && ( + !baseline.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !baseline.attachments.isEmpty + ) + } + + func merging( + saved: FeatureComposerDraft?, + current: FeatureComposerDraft, + fallbackSelection: FeatureSelection? = nil, + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil + ) -> FeatureComposerDraft { + var target = saved + if shouldCarryContent(into: saved) { + target = saved ?? FeatureComposerDraft() + target?.text = baseline.text + target?.attachments = baseline.attachments + } + var restored = FeatureComposerDraftRestoration.merge( + saved: target, + baseline: baseline, + current: current, + fallbackSelection: fallbackSelection, + fallbackWorkspace: fallbackWorkspace + ) + if let environmentID { + restored.attachments = Self.content( + from: restored, + forEnvironment: environmentID + ).attachments + } + return restored + } +} + +private struct DottedUnderline: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + return path + } +} + +private enum NewTaskPicker: String, Identifiable { + case project + case branch + + var id: String { rawValue } +} + +enum NewTaskProjectPickerSearch { + static func matching( + _ groups: [DailyUXProjectGroup], + query: String, + environments: [FeatureEnvironment] + ) -> [DailyUXProjectGroup] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return groups } + + let environmentNames = Dictionary( + environments.map { ($0.id, $0.name) }, + uniquingKeysWith: { first, _ in first } + ) + + return groups.filter { group in + group.name.localizedCaseInsensitiveContains(query) + || group.projects.contains { project in + project.path.localizedCaseInsensitiveContains(query) + || environmentNames[project.environmentID]? + .localizedCaseInsensitiveContains(query) == true + } + } + } +} + +private struct NewTaskProjectPicker: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let groups: [DailyUXProjectGroup] + let environments: [FeatureEnvironment] + let recentGroupIDs: [String] + let selectionID: String? + let retryState: NewTaskRetryState + let onRetry: () -> Void + let onSelect: (DailyUXProjectGroup) -> Void + + @State private var query = "" + + var body: some View { + NavigationStack { + let presentation = NewTaskProjectPickerPresentation( + groups: groups, + filteredGroups: filteredGroups, + unavailableEnvironments: unreachableEnvironments + ) + List { + switch presentation.projectContent { + case .noProjects: + projectUnavailableRow("No projects", systemImage: "folder") + case .noMatches: + projectUnavailableRow( + "No matching projects", + systemImage: "magnifyingglass" + ) + case .projects: + let sections = DailyUXProjectPickerSections( + groups: filteredGroups, + recentGroupIDs: recentGroupIDs + ) + if sections.recents.isEmpty { + ForEach(sections.others) { group in + projectRow(group) + } + } else { + Section("Recent") { + ForEach(sections.recents) { group in + projectRow(group) + } + } + + if !sections.others.isEmpty { + Section("Other projects") { + ForEach(sections.others) { group in + projectRow(group) + } + } + } + } + } + + if !presentation.unavailableEnvironments.isEmpty { + Section("Unavailable environments") { + VStack(alignment: .leading, spacing: 8) { + ForEach(presentation.visibleUnavailableEnvironments) { environment in + Label( + "\(environment.name) is unreachable", + systemImage: "network.slash" + ) + } + + if presentation.additionalUnavailableEnvironmentCount > 0 { + Text( + "And \(presentation.additionalUnavailableEnvironmentCount) more" + ) + .foregroundStyle(T3Colors.textTertiary) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .accessibilityElement(children: .ignore) + .accessibilityLabel(presentation.unavailableAccessibilityLabel) + .accessibilityIdentifier( + "new-task-unreachable-environments-notice" + ) + + Button(retryState.buttonTitle, action: onRetry) + .disabled(retryState.isInProgress) + .accessibilityHint("Refresh environment status") + .accessibilityIdentifier("new-task-project-picker-retry") + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .background(T3Colors.background) + .navigationTitle("Project") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Search projects") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationBackground(T3Colors.background) + } + + private func projectUnavailableRow(_ title: String, systemImage: String) -> some View { + ContentUnavailableView { + Label { + Text(title) + } icon: { + Image(systemName: systemImage) + } + } description: { + EmptyView() + } + .frame(maxWidth: .infinity, minHeight: 220) + .listRowSeparator(.hidden) + .listRowBackground(T3Colors.background) + } + + private func projectRow(_ group: DailyUXProjectGroup) -> some View { + Button { + onSelect(group) + } label: { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(group.name) + .foregroundStyle(T3Colors.textPrimary) + + Text(projectLocation(group)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 10) + + if group.id == selectionID { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(T3Colors.accent) + } + } + .frame(minHeight: 46) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(group.name) + .accessibilityValue(projectLocation(group)) + .accessibilityAddTraits( + group.id == selectionID ? .isSelected : [] + ) + .listRowBackground(T3Colors.background) + } + + private var filteredGroups: [DailyUXProjectGroup] { + NewTaskProjectPickerSearch.matching( + groups, + query: query, + environments: environments + ) + } + + private var unreachableEnvironments: [FeatureEnvironment] { + DailyUXCreationContext.unreachableEnvironments(in: environments) + } + + private func projectLocation(_ group: DailyUXProjectGroup) -> String { + guard let firstProject = group.projects.first else { return "" } + + var seenEnvironmentIDs = Set() + let allNames = group.projects.compactMap { project -> String? in + guard seenEnvironmentIDs.insert(project.environmentID).inserted else { return nil } + return environments.first { $0.id == project.environmentID }?.name + ?? project.environmentID + } + let names = Array(allNames.prefix(2)) + let additionalCount = allNames.count - names.count + let additionalLocations = additionalCount > 0 ? " +\(additionalCount)" : "" + + return "\(names.joined(separator: ", "))\(additionalLocations) · \(firstProject.path)" + } +} + +private struct NewTaskBranchPicker: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + + let branches: [FeatureWorkspaceBranch] + let selection: FeatureWorkspaceBranch? + let isLoading: Bool + let loadFailed: Bool + let onSelect: (FeatureWorkspaceBranch) -> Void + let onRefresh: () -> Void + + @State private var query = "" + + var body: some View { + NavigationStack { + Group { + if isLoading, branches.isEmpty { + ProgressView("Loading branches") + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredBranches.isEmpty { + ContentUnavailableView { + Label( + loadFailed + ? "Could not load branches" + : query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "No branches available" + : "No matching branches", + systemImage: loadFailed + ? "exclamationmark.triangle" + : "arrow.triangle.branch" + ) + } description: { + EmptyView() + } actions: { + if loadFailed { + Button("Try again", action: onRefresh) + } + } + } else { + List(filteredBranches) { branch in + Button { + onSelect(branch) + } label: { + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.branch") + .foregroundStyle(T3Colors.textTertiary) + + Text(branch.name) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + + Spacer(minLength: 10) + + if let badge = branch.badge { + Text(badge) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + + if branch.id == selection?.id { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(T3Colors.accent) + } + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + branch.badge.map { "\(branch.name), \($0)" } ?? branch.name + ) + .accessibilityAddTraits( + branch.id == selection?.id ? .isSelected : [] + ) + .listRowBackground(T3Colors.background) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .refreshable { onRefresh() } + } + } + .background(T3Colors.background) + .navigationTitle("Base branch") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Search branches") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading) + .accessibilityLabel("Refresh branches") + } + } + } + .presentationDetents([.medium, .large]) + .presentationBackground(T3Colors.background) + } + + private var filteredBranches: [FeatureWorkspaceBranch] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return branches } + return branches.filter { + $0.name.localizedCaseInsensitiveContains(trimmed) + } + } +} diff --git a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift new file mode 100644 index 000000000000..3b494f005883 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift @@ -0,0 +1,952 @@ +import SwiftUI + +public struct AddProjectView: View { + private struct PendingCloneRegistration: Equatable { + let environmentID: String + let remoteURL: String + let destinationPath: String + let clonedPath: String + } + + private enum ProjectMode: String, CaseIterable, Identifiable { + case folder + case repository + + var id: String { rawValue } + var label: String { self == .folder ? "Folder" : "Clone" } + var icon: String { self == .folder ? "folder" : "arrow.down.circle" } + } + + private enum Field: Hashable { + case localPath + case repository + case destination + } + + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + + @State private var selectedEnvironmentID: String? + @State private var mode = ProjectMode.folder + @State private var localPath = "~/" + @State private var source = ProjectRemoteSource.url + @State private var repositoryInput = "" + @State private var destinationPath = "~/" + @State private var resolvedRepository: SourceControlRepositoryInfo? + @State private var didEditDestination = false + @State private var pendingCloneRegistration: PendingCloneRegistration? + + @State private var browsePath = "~/" + @State private var browseResult: FilesystemBrowseResult? + @State private var isBrowsing = false + @State private var browseError: String? + @State private var browseRequestID: UUID? + + @State private var discovery: SourceControlDiscoveryResult? + @State private var isDiscovering = false + @State private var discoveryError: String? + @State private var discoveryRequestID: UUID? + + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var cloneRequestID: UUID? + @FocusState private var focusedField: Field? + + public init(model: FeatureRootModel) { + self.model = model + } + + public var body: some View { + NavigationStack { + Group { + if let environment = selectedEnvironment { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + if environments.count > 1 { + environmentPicker(environment) + } + modePicker + if let errorMessage { + errorBanner(errorMessage) + } + switch mode { + case .folder: + localProjectForm(environment) + case .repository: + repositoryProjectForm(environment) + } + if showsFolderBrowser { + folderBrowser(environment) + } + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 32) + .disabled(isSubmitting) + } + .scrollDismissesKeyboard(.interactively) + } else { + ContentUnavailableView( + "Environment unavailable", + systemImage: "server.rack", + description: Text("Reconnect a T3 environment before adding a project.") + ) + } + } + .background(T3Colors.background) + .navigationTitle("Add project") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .onAppear(perform: selectEnvironmentIfNeeded) + .onChange(of: model.snapshot.environments) { + selectEnvironmentIfNeeded() + } + .onChange(of: source) { + resolvedRepository = nil + pendingCloneRegistration = nil + cloneRequestID = nil + updateSuggestedDestination() + errorMessage = nil + } + .onChange(of: repositoryInput) { + resolvedRepository = nil + pendingCloneRegistration = nil + cloneRequestID = nil + updateSuggestedDestination() + errorMessage = nil + } + .task(id: selectedEnvironmentID) { + guard selectedEnvironmentID != nil else { return } + resetEnvironmentState() + await loadDirectory(browsePath, updateSelection: false) + await loadDiscovery() + } + } + + private var projectClient: (any FeatureProjectCreationClient)? { + model.client as? any FeatureProjectCreationClient + } + + private var environments: [FeatureEnvironment] { + model.snapshot.environments + .filter(\.isEnabled) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private var selectedEnvironment: FeatureEnvironment? { + environments.first { $0.id == selectedEnvironmentID && canCreateProject(in: $0) } + } + + private var sourceOptions: [ProjectRemoteSourceOption] { + ProjectRemoteSourceOptions.options(discovery: discovery) + } + + private var selectedSourceOption: ProjectRemoteSourceOption? { + sourceOptions.first { $0.source == source } + } + + private var needsRepositoryLookup: Bool { + source.provider != nil && resolvedRepository == nil + } + + private var showsFolderBrowser: Bool { + mode == .folder || !needsRepositoryLookup + } + + private var repositoryName: String { + ProjectCreationPath.repositoryName( + from: resolvedRepository?.nameWithOwner ?? repositoryInput + ) + } + + private var modePicker: some View { + HStack(spacing: 24) { + ForEach(ProjectMode.allCases) { candidate in + Button { + focusedField = nil + errorMessage = nil + mode = candidate + } label: { + VStack(spacing: 9) { + Label(candidate.label, systemImage: candidate.icon) + .font(T3Typography.control) + .foregroundStyle( + mode == candidate ? T3Colors.textPrimary : T3Colors.textTertiary + ) + Rectangle() + .fill(mode == candidate ? T3Colors.textPrimary : Color.clear) + .frame(height: 2) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .accessibilityElement(children: .contain) + } + + private func environmentPicker(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Environment") + Menu { + ForEach(environments) { option in + Button { + selectedEnvironmentID = option.id + } label: { + if option.id == environment.id { + Label(option.name, systemImage: "checkmark") + } else { + Text(option.name) + } + } + .disabled(!canCreateProject(in: option)) + } + } label: { + HStack(spacing: 10) { + Image(systemName: environment.systemImage) + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + Text(environment.endpoint) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } + Spacer(minLength: 12) + Image(systemName: "chevron.up.chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 13) + .frame(minHeight: 52) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + + private func localProjectForm(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + sectionTitle("Workspace path") + Spacer() + Text("on \(environment.name)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + pathField( + placeholder: "~/projects/my-app", + text: $localPath, + field: .localPath, + browseAction: { + Task { + await loadDirectory( + ProjectCreationPath.directoryBrowsePath(localPath), + updateSelection: false + ) + } + } + ) + primaryAction(label: "Add project", icon: "plus") { + await addLocalProject(environment) + } + } + } + + private func repositoryProjectForm(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionTitle("Repository source") + if isDiscovering { + ProgressView().controlSize(.small) + } + } + sourcePicker + if let discoveryError { + Label(discoveryError, systemImage: "info.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + } + + VStack(alignment: .leading, spacing: 8) { + sectionTitle(source == .url ? "Remote URL" : "Repository") + TextField(source.prompt, text: $repositoryInput) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(source == .url ? .URL : .default) + .submitLabel(needsRepositoryLookup ? .next : .done) + .focused($focusedField, equals: .repository) + .onSubmit { + Task { + if needsRepositoryLookup { + await resolveRepository(environment) + } else { + focusedField = .destination + } + } + } + .t3ProjectInput() + } + + if let resolvedRepository { + repositorySummary(resolvedRepository) + } + + if needsRepositoryLookup { + primaryAction(label: "Find repository", icon: "magnifyingglass") { + await resolveRepository(environment) + } + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + sectionTitle("Clone destination") + Spacer() + Text("on \(environment.name)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + pathField( + placeholder: "~/projects/\(repositoryName)", + text: destinationBinding, + field: .destination, + browseAction: nil + ) + } + primaryAction(label: "Clone and add", icon: "arrow.down.circle") { + await cloneProject(environment) + } + } + } + } + + private var sourcePicker: some View { + Menu { + ForEach(sourceOptions) { option in + Button { + source = option.source + } label: { + if option.source == source { + Label(option.source.label, systemImage: "checkmark") + } else if let detail = option.detail { + Text("\(option.source.label) · \(detail)") + } else { + Text(option.source.label) + } + } + .disabled(!option.isReady) + } + } label: { + HStack(spacing: 10) { + Image(systemName: sourceIcon(source)) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(source.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + if let detail = selectedSourceOption?.detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } + } + Spacer(minLength: 12) + Image(systemName: "chevron.up.chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 13) + .frame(minHeight: 52) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + private func repositorySummary(_ repository: SourceControlRepositoryInfo) -> some View { + HStack(alignment: .top, spacing: 11) { + Image(systemName: sourceIcon(source)) + .font(.body.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 3) { + Text(repository.nameWithOwner) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + Text(repository.sshUrl) + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(2) + } + Spacer(minLength: 0) + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(T3Colors.success) + } + .padding(.vertical, 4) + } + + private func folderBrowser(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionTitle("Folders on \(environment.name)") + Spacer() + if isBrowsing { + ProgressView().controlSize(.small) + } else { + Button { + Task { await loadDirectory(browsePath, updateSelection: false) } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Refresh folders") + } + } + + Text(browsePath) + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + + Divider().overlay(T3Colors.separator) + if let browseError { + Text(browseError) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .padding(.vertical, 8) + } + if let parentPath = ProjectCreationPath.parentBrowsePath(of: browsePath) { + folderRow(name: "..", icon: "arrow.turn.left.up") { + await loadDirectory(parentPath, updateSelection: true) + } + } + if let entries = browseResult?.entries, !entries.isEmpty { + ForEach(entries, id: \.fullPath) { entry in + Divider().overlay(T3Colors.separator) + folderRow(name: entry.name, icon: "folder") { + await loadDirectory( + ProjectCreationPath.directoryBrowsePath(entry.fullPath), + updateSelection: true + ) + } + } + } else if !isBrowsing, browseError == nil { + Text("No folders here") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .frame(maxWidth: .infinity, minHeight: 54, alignment: .center) + } + } + } + + private func folderRow( + name: String, + icon: String, + action: @escaping @MainActor () async -> Void + ) -> some View { + Button { + focusedField = nil + Task { await action() } + } label: { + HStack(spacing: 11) { + Image(systemName: icon) + .font(.body.weight(.medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 24) + Text(name) + .font(.body.weight(.medium)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Spacer(minLength: 12) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBrowsing) + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + } + + private func pathField( + placeholder: String, + text: Binding, + field: Field, + browseAction: (() -> Void)? + ) -> some View { + HStack(spacing: 4) { + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .focused($focusedField, equals: field) + if let browseAction { + Button(action: browseAction) { + Image(systemName: "folder") + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Browse entered path") + } + } + .t3ProjectInput() + } + + private func primaryAction( + label: String, + icon: String, + action: @escaping @MainActor () async -> Void + ) -> some View { + Button { + focusedField = nil + Task { await action() } + } label: { + HStack(spacing: 8) { + if isSubmitting { + ProgressView() + .tint(T3Colors.primaryActionForeground) + } else { + Image(systemName: icon) + } + Text(isSubmitting ? "Working…" : label) + } + .font(.body.weight(.semibold)) + .foregroundStyle(T3Colors.primaryActionForeground) + .frame(maxWidth: .infinity, minHeight: 48) + .background(T3Colors.primaryAction, in: RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .opacity(isSubmitting ? 0.66 : 1) + } + + private func errorBanner(_ message: String) -> some View { + HStack(alignment: .top, spacing: 9) { + Image(systemName: "exclamationmark.triangle.fill") + Text(message) + .font(T3Typography.supporting) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(T3Colors.danger) + .padding(12) + .background(T3Colors.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + + private var destinationBinding: Binding { + Binding( + get: { destinationPath }, + set: { value in + didEditDestination = true + destinationPath = value + pendingCloneRegistration = nil + cloneRequestID = nil + } + ) + } + + private func canCreateProject(in environment: FeatureEnvironment) -> Bool { + environment.isEnabled && environment.connectionState != .disconnected + } + + private func selectEnvironmentIfNeeded() { + if let selectedEnvironmentID, + environments.contains(where: { + $0.id == selectedEnvironmentID && canCreateProject(in: $0) + }) { + return + } + selectedEnvironmentID = environments.first(where: canCreateProject)?.id + } + + private func resetEnvironmentState() { + browsePath = "~/" + browseResult = nil + browseError = nil + browseRequestID = nil + discovery = nil + discoveryError = nil + discoveryRequestID = nil + source = .url + resolvedRepository = nil + pendingCloneRegistration = nil + cloneRequestID = nil + didEditDestination = false + localPath = "~/" + destinationPath = repositoryInput.isEmpty + ? "~/" + : ProjectCreationPath.appending(repositoryName, to: "~/") + errorMessage = nil + } + + private func loadDiscovery() async { + guard let environmentID = selectedEnvironmentID, + let projectClient else { + discoveryError = "Git URL cloning is available. Provider discovery is unavailable." + return + } + let requestID = UUID() + discoveryRequestID = requestID + isDiscovering = true + defer { + if discoveryRequestID == requestID { + isDiscovering = false + } + } + do { + let result = try await projectClient.discoverProjectSources( + environmentID: environmentID + ) + guard discoveryRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + discovery = result + discoveryError = nil + } catch is CancellationError { + return + } catch { + guard discoveryRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + discovery = nil + discoveryError = "Provider discovery unavailable. Git URL still works." + } + } + + private func loadDirectory(_ path: String, updateSelection: Bool) async { + guard let environmentID = selectedEnvironmentID, + let projectClient else { + browseError = "Folder browsing is unavailable. You can still enter a path directly." + return + } + let requestedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !requestedPath.isEmpty else { return } + let requestID = UUID() + browseRequestID = requestID + isBrowsing = true + browseError = nil + defer { + if browseRequestID == requestID { + isBrowsing = false + } + } + do { + let result = try await projectClient.browseProjectFolders( + environmentID: environmentID, + partialPath: requestedPath + ) + guard browseRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + let selectedDirectory = result.parentPath + browsePath = ProjectCreationPath.directoryBrowsePath(selectedDirectory) + browseResult = result + if updateSelection { + switch mode { + case .folder: + localPath = selectedDirectory + case .repository: + if !didEditDestination { + pendingCloneRegistration = nil + destinationPath = ProjectCreationPath.appending( + repositoryName, + to: selectedDirectory + ) + } + } + } + } catch is CancellationError { + return + } catch { + guard browseRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + browseError = "Couldn’t browse that folder. Direct path entry still works." + } + } + + private func addLocalProject(_ environment: FeatureEnvironment) async { + errorMessage = nil + let validated: String + switch ProjectCreationPath.validated(localPath) { + case let .success(path): validated = path + case let .failure(error): + errorMessage = error.localizedDescription + return + } + if let serverPath = browseResult?.parentPath, + !ProjectCreationPath.isCompatibleWithServerPath( + validated, + serverPath: serverPath + ) { + errorMessage = "Use a path that matches \(environment.name)’s filesystem." + return + } + if let existing = existingProject(environmentID: environment.id, path: validated) { + errorMessage = "\(existing.name) already uses this folder." + return + } + + isSubmitting = true + defer { isSubmitting = false } + do { + if let projectClient { + try await projectClient.addProject( + environmentID: environment.id, + path: validated + ) + dismiss() + } else if environments.count == 1, await model.addProject(path: validated) { + dismiss() + } else { + errorMessage = model.errorMessage ?? "The project could not be added." + } + } catch is CancellationError { + return + } catch { + errorMessage = projectErrorMessage(error) + } + } + + private func resolveRepository(_ environment: FeatureEnvironment) async { + guard let provider = source.provider else { return } + let repository = repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !repository.isEmpty else { + errorMessage = "Enter a repository name." + return + } + guard let projectClient else { + errorMessage = "Repository lookup is unavailable on this connection." + return + } + + errorMessage = nil + isSubmitting = true + defer { isSubmitting = false } + do { + let result = try await projectClient.lookupProjectRepository( + environmentID: environment.id, + provider: provider, + repository: repository + ) + guard selectedEnvironmentID == environment.id, + source.provider == provider, + repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + == repository else { + return + } + resolvedRepository = result + updateSuggestedDestination() + focusedField = .destination + } catch is CancellationError { + return + } catch { + guard selectedEnvironmentID == environment.id, + source.provider == provider, + repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + == repository else { + return + } + errorMessage = projectErrorMessage(error) + } + } + + private func cloneProject(_ environment: FeatureEnvironment) async { + let remoteURL = resolvedRepository.map(ProjectCreationPath.defaultCloneURL) + ?? ProjectCreationPath.normalizedCloneURL(repositoryInput) + guard !remoteURL.isEmpty else { + errorMessage = "Enter a Git remote URL." + return + } + let validatedDestination: String + switch ProjectCreationPath.validated(destinationPath) { + case let .success(path): validatedDestination = path + case let .failure(error): + errorMessage = error.localizedDescription + return + } + if let serverPath = browseResult?.parentPath, + !ProjectCreationPath.isCompatibleWithServerPath( + validatedDestination, + serverPath: serverPath + ) { + errorMessage = "Use a path that matches \(environment.name)’s filesystem." + return + } + if let existing = existingProject( + environmentID: environment.id, + path: validatedDestination + ) { + errorMessage = "\(existing.name) already uses this destination." + return + } + guard let projectClient else { + errorMessage = "Repository cloning is unavailable on this connection." + return + } + + errorMessage = nil + let requestID = UUID() + cloneRequestID = requestID + isSubmitting = true + defer { + isSubmitting = false + if cloneRequestID == requestID { + cloneRequestID = nil + } + } + do { + let clonedPath: String + if let pending = pendingCloneRegistration, + pending.environmentID == environment.id, + pending.remoteURL == remoteURL, + pending.destinationPath == validatedDestination { + clonedPath = pending.clonedPath + } else { + let result = try await projectClient.cloneProjectRepository( + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + clonedPath = result.cwd + pendingCloneRegistration = PendingCloneRegistration( + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination, + clonedPath: result.cwd + ) + } + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + try await projectClient.addProject( + environmentID: environment.id, + path: clonedPath + ) + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + pendingCloneRegistration = nil + dismiss() + } catch is CancellationError { + return + } catch { + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + if pendingCloneRegistration != nil { + errorMessage = "Repository cloned. Try again to finish adding the project." + } else { + errorMessage = projectErrorMessage(error) + } + } + } + + private func cloneRequestIsCurrent( + _ requestID: UUID, + environmentID: String, + remoteURL: String, + destinationPath: String + ) -> Bool { + let currentRemoteURL = resolvedRepository?.sshUrl + ?? ProjectCreationPath.normalizedCloneURL(repositoryInput) + return cloneRequestID == requestID + && selectedEnvironmentID == environmentID + && currentRemoteURL == remoteURL + && self.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + == destinationPath + } + + private func updateSuggestedDestination() { + guard !didEditDestination, !repositoryInput.isEmpty else { return } + destinationPath = ProjectCreationPath.appending(repositoryName, to: browsePath) + } + + private func existingProject(environmentID: String, path: String) -> FeatureProject? { + let normalized = ProjectCreationPath.normalizedForComparison(path) + return model.snapshot.projects.first { + $0.environmentID == environmentID + && ProjectCreationPath.normalizedForComparison($0.path) == normalized + } + } + + private func sourceIcon(_ source: ProjectRemoteSource) -> String { + switch source { + case .url: "link" + case .github: "chevron.left.forwardslash.chevron.right" + case .gitlab: "shippingbox" + case .bitbucket: "shippingbox.fill" + case .azureDevOps: "point.3.connected.trianglepath.dotted" + } + } + + private func projectErrorMessage(_ error: Error) -> String { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? "The server could not complete that request." : message + } +} + +private extension View { + func t3ProjectInput() -> some View { + font(.body) + .foregroundStyle(T3Colors.textPrimary) + .padding(.horizontal, 13) + .frame(minHeight: 48) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1) + } + } +} diff --git a/apps/swift-ios/Features/Workspace/ProjectCreationModels.swift b/apps/swift-ios/Features/Workspace/ProjectCreationModels.swift new file mode 100644 index 000000000000..23003259f042 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProjectCreationModels.swift @@ -0,0 +1,283 @@ +import Foundation + +@MainActor +protocol FeatureProjectCreationClient: AnyObject { + func addProject(environmentID: String, path: String) async throws + func browseProjectFolders( + environmentID: String, + partialPath: String + ) async throws -> FilesystemBrowseResult + func discoverProjectSources(environmentID: String) async throws -> SourceControlDiscoveryResult + func lookupProjectRepository( + environmentID: String, + provider: SourceControlProviderKind, + repository: String + ) async throws -> SourceControlRepositoryInfo + func cloneProjectRepository( + environmentID: String, + remoteURL: String, + destinationPath: String + ) async throws -> SourceControlCloneResult +} + +enum ProjectRemoteSource: String, CaseIterable, Hashable, Identifiable { + case url + case github + case gitlab + case bitbucket + case azureDevOps = "azure-devops" + + var id: String { rawValue } + + var provider: SourceControlProviderKind? { + switch self { + case .url: nil + case .github: .github + case .gitlab: .gitlab + case .bitbucket: .bitbucket + case .azureDevOps: .azureDevOps + } + } + + var label: String { + switch self { + case .url: "Git URL" + case .github: "GitHub" + case .gitlab: "GitLab" + case .bitbucket: "Bitbucket" + case .azureDevOps: "Azure DevOps" + } + } + + var prompt: String { + switch self { + case .url: "https://github.com/org/repository.git" + case .github, .gitlab, .bitbucket: "owner/repository" + case .azureDevOps: "organization/project/repository" + } + } +} + +struct ProjectRemoteSourceOption: Equatable, Identifiable { + let source: ProjectRemoteSource + let isReady: Bool + let detail: String? + + var id: String { source.id } +} + +enum ProjectRemoteSourceOptions { + static func options( + discovery: SourceControlDiscoveryResult? + ) -> [ProjectRemoteSourceOption] { + let providerByKind = Dictionary( + uniqueKeysWithValues: (discovery?.sourceControlProviders ?? []).map { + ($0.kind.rawValue, $0) + } + ) + + return ProjectRemoteSource.allCases.map { source in + guard let providerKind = source.provider else { + return ProjectRemoteSourceOption(source: source, isReady: true, detail: nil) + } + guard let provider = providerByKind[providerKind.rawValue] else { + return ProjectRemoteSourceOption( + source: source, + isReady: false, + detail: "Provider status unavailable" + ) + } + guard provider.status == .available else { + return ProjectRemoteSourceOption( + source: source, + isReady: false, + detail: provider.detail ?? provider.installHint + ) + } + guard provider.auth.status != .unauthenticated else { + return ProjectRemoteSourceOption( + source: source, + isReady: false, + detail: provider.auth.detail ?? "Authentication required" + ) + } + let account = provider.auth.account.map { "Signed in as \($0)" } + return ProjectRemoteSourceOption( + source: source, + isReady: true, + detail: account ?? provider.version + ) + } + } +} + +enum ProjectCreationPath { + static func normalizedCloneURL(_ input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]+(?:\.git)?$"# + guard trimmed.range(of: pattern, options: .regularExpression) != nil else { + return trimmed + } + let repository = trimmed.hasSuffix(".git") ? trimmed : "\(trimmed).git" + return "https://github.com/\(repository)" + } + + static func defaultCloneURL(for repository: SourceControlRepositoryInfo) -> String { + repository.provider == .github ? repository.url : repository.sshUrl + } + + static func validated(_ rawValue: String) -> Result { + let path = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { + return .failure(.init(message: "Enter a project path.")) + } + guard isAbsoluteOrHomeRelative(path) else { + return .failure( + .init(message: "Use an absolute path, or start with ~/.") + ) + } + return .success(path) + } + + static func repositoryName(from value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "repository" } + let withoutQuery = trimmed.split(separator: "?", maxSplits: 1).first.map(String.init) + ?? trimmed + let withoutFragment = withoutQuery.split(separator: "#", maxSplits: 1).first.map(String.init) + ?? withoutQuery + let normalized = withoutFragment + .replacingOccurrences(of: "\\", with: "/") + .replacingOccurrences(of: ":", with: "/") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let component = normalized.split(separator: "/").last.map(String.init) ?? "repository" + let withoutGit = component.lowercased().hasSuffix(".git") + ? String(component.dropLast(4)) + : component + let sanitized = withoutGit.trimmingCharacters(in: .whitespacesAndNewlines) + return sanitized.isEmpty ? "repository" : sanitized + } + + static func appending(_ component: String, to basePath: String) -> String { + let base = basePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !base.isEmpty else { return component } + guard !component.isEmpty else { return base } + let separator = base.contains("\\") && !base.contains("/") ? "\\" : "/" + if base.hasSuffix("/") || base.hasSuffix("\\") { + return base + component + } + return base + separator + component + } + + /// `filesystem.browse` interprets a path without a trailing separator as + /// a prefix search. Directory navigation always sends an explicit folder. + static func directoryBrowsePath(_ value: String) -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, + !path.hasSuffix("/"), + !path.hasSuffix("\\") else { + return path + } + if isWindowsAbsolutePath(path) { + return path.replacingOccurrences(of: "/", with: "\\") + "\\" + } + return path + "/" + } + + static func parentBrowsePath(of value: String) -> String? { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + + if isWindowsAbsolutePath(path) { + let normalized = path.replacingOccurrences(of: "/", with: "\\") + if normalized.hasPrefix("\\\\") { + let components = normalized.dropFirst(2).split(separator: "\\") + guard components.count > 2 else { return nil } + return "\\\\" + + components.dropLast().joined(separator: "\\") + + "\\" + } + + var trimmed = normalized + while trimmed.count > 3, trimmed.hasSuffix("\\") { + trimmed.removeLast() + } + guard trimmed.count > 3, + let separator = trimmed.lastIndex(of: "\\") else { + return nil + } + let parent = String(trimmed[...separator]) + return parent.count == 3 ? parent : directoryBrowsePath(parent) + } + + var trimmed = path + while trimmed.count > 1, trimmed.hasSuffix("/") { + trimmed.removeLast() + } + guard trimmed != "/", trimmed != "~", + let separator = trimmed.lastIndex(of: "/") else { + return nil + } + let parent = String(trimmed[...separator]) + guard parent != "~/" || trimmed != "~" else { return nil } + return directoryBrowsePath(parent) + } + + static func lastPathComponent(_ value: String) -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = isWindowsAbsolutePath(path) + ? path.replacingOccurrences(of: "\\", with: "/") + : path + return normalized + .split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? path + } + + static func isCompatibleWithServerPath(_ value: String, serverPath: String) -> Bool { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + let reference = serverPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.hasPrefix("~"), + isAbsoluteOrHomeRelative(reference) else { + return true + } + return isWindowsAbsolutePath(path) == isWindowsAbsolutePath(reference) + } + + static func normalizedForComparison(_ value: String) -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + let isWindows = isWindowsAbsolutePath(path) + var normalized = isWindows + ? path.replacingOccurrences(of: "\\", with: "/") + : path + while normalized.count > 1, normalized.hasSuffix("/") { + normalized.removeLast() + } + return isWindows ? normalized.lowercased() : normalized + } + + private static func isAbsoluteOrHomeRelative(_ path: String) -> Bool { + if path == "~" || path.hasPrefix("~/") || path.hasPrefix("~\\") { + return true + } + if path.hasPrefix("/") || path.hasPrefix("\\\\") { + return true + } + return isWindowsAbsolutePath(path) + } + + private static func isWindowsAbsolutePath(_ path: String) -> Bool { + if path.hasPrefix("\\\\") || path.hasPrefix("//") { return true } + let scalars = Array(path.unicodeScalars.prefix(3)) + return scalars.count == 3 + && CharacterSet.letters.contains(scalars[0]) + && scalars[1] == ":" + && (scalars[2] == "\\" || scalars[2] == "/") + } +} + +struct ProjectCreationValidationError: LocalizedError, Equatable { + let message: String + + var errorDescription: String? { message } +} diff --git a/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift new file mode 100644 index 000000000000..8ee10761f2fa --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift @@ -0,0 +1,1665 @@ +import SwiftUI + +public struct ProviderModelPicker: View { + public enum Style { + case row + case compact + } + + let providers: [FeatureProvider] + private let normalizedProviders: [FeatureProvider] + @Binding var selection: FeatureSelection? + let style: Style + let isLoading: Bool + let threadSelection: FeatureSelection? + let materializesDefaultSelection: Bool + private let onPresentationChange: ((Bool) -> Void)? + private let onRefresh: (@MainActor () async throws -> Void)? + + @State private var isPresented = false + @State private var preservesSelectionDuringRefresh = false + + public init( + providers: [FeatureProvider], + selection: Binding, + style: Style = .row, + isLoading: Bool = false, + threadSelection: FeatureSelection? = nil, + materializesDefaultSelection: Bool = true, + onRefresh: (@MainActor () async throws -> Void)? = nil, + onPresentationChange: ((Bool) -> Void)? = nil + ) { + self.providers = providers + normalizedProviders = ProviderModelCatalogNormalizer.normalized(providers) + _selection = selection + self.style = style + self.isLoading = isLoading + self.threadSelection = threadSelection + self.materializesDefaultSelection = materializesDefaultSelection + self.onRefresh = onRefresh + self.onPresentationChange = onPresentationChange + } + + public var body: some View { + Button { + onPresentationChange?(true) + isPresented = true + } label: { + switch style { + case .row: + HStack(spacing: 12) { + selectionMark(size: 22) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text("Model") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Text(selectionLabel) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + case .compact: + HStack(spacing: 5) { + selectionMark(size: 14) + Text(compactModelName) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + .fixedSize() + } + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + .compositingGroup() + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Choose model") + .accessibilityValue(selectionLabel) + .accessibilityIdentifier("model-picker") + .sheet(isPresented: $isPresented, onDismiss: { onPresentationChange?(false) }) { + ModelPickerSheet( + providers: normalizedProviders, + selection: $selection, + isLoading: isLoading, + threadSelection: threadSelection, + materializesDefaultSelection: materializesDefaultSelection, + onRefresh: onRefresh.map { refresh in + { + preservesSelectionDuringRefresh = true + defer { preservesSelectionDuringRefresh = false } + try await refresh() + } + } + ) + } + .onAppear(perform: materializeSelection) + .onChange(of: providers) { + if !preservesSelectionDuringRefresh { materializeSelection() } + } + .onChange(of: selection) { materializeSelection() } + } + + private var selectedOption: DailyUXModelOption? { + guard let resolvedSelection, + let provider = normalizedProviders.first(where: { + $0.id == resolvedSelection.providerID + }), + let model = provider.models.first(where: { $0.id == resolvedSelection.modelID }) else { + return nil + } + return DailyUXModelOption(provider: provider, model: model) + } + + private var resolvedSelection: FeatureSelection? { + if materializesDefaultSelection { + return ProviderModelSelectionResolver.materialized(selection, in: normalizedProviders) + } + return ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: selection, + inherited: threadSelection, + providers: normalizedProviders + ) + } + + private func materializeSelection() { + guard !normalizedProviders.isEmpty else { return } + let resolved = materializesDefaultSelection + ? ProviderModelSelectionResolver.materialized(selection, in: normalizedProviders) + : ThreadComposerModelSelectionPolicy.explicitSelection( + selection, + inherited: threadSelection, + providers: normalizedProviders + ) + guard selection != resolved else { return } + selection = resolved + } + + private var selectionLabel: String { + guard let selectedOption else { + return unavailableSelectionLabel + } + let base = "\(selectedOption.provider.name) · \(selectedOption.model.name)" + guard let resolvedSelection, + let summary = DailyUXModelOptions.summary( + for: selectedOption.model, + selections: resolvedSelection.options + ) else { + return base + } + return "\(base) · \(summary)" + } + + private var compactModelName: String { + guard let selectedOption else { + return unavailableSelectionLabel + } + return selectedOption.model.name + } + + private var unavailableSelectionLabel: String { + if isLoading { return "Loading models" } + if normalizedProviders.isEmpty { return "No providers" } + if !normalizedProviders.contains(where: \.isAvailable) { return "Providers offline" } + if !normalizedProviders.contains(where: { $0.isAvailable && !$0.models.isEmpty }) { + return "No models" + } + return "Choose model" + } + + @ViewBuilder + private func selectionMark(size: CGFloat) -> some View { + if let provider = selectedOption?.provider { + ProviderIcon( + driver: provider.driver, + providerID: provider.id, + fallbackName: provider.name, + size: size + ) + } else { + Image(systemName: "cpu") + .font(.system(size: size * 0.72, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: size, height: size) + } + } +} + +private struct ModelPickerSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @SwiftUI.Environment(\.providerSetupContext) private var setupContext + let providers: [FeatureProvider] + @Binding var selection: FeatureSelection? + let isLoading: Bool + let threadSelection: FeatureSelection? + let materializesDefaultSelection: Bool + let onRefresh: (@MainActor () async throws -> Void)? + + @AppStorage("swift-ios.model-picker.favorites") private var favoriteStorage = "" + @AppStorage("swift-ios.model-picker.recents") private var recentStorage = "" + @State private var query = "" + @State private var configuring: DailyUXModelOption? + @State private var legacyModelsExpanded = false + @State private var catalogCache = ModelPickerCatalogCache() + @State private var draftSelection: FeatureSelection? + @State private var draftBaseSelection: FeatureSelection? + @State private var modelDrafts: [String: FeatureSelection] + @State private var hasEditedDraft = false + @State private var isRefreshing = false + @State private var refreshError: String? + + init( + providers: [FeatureProvider], + selection: Binding, + isLoading: Bool, + threadSelection: FeatureSelection?, + materializesDefaultSelection: Bool, + onRefresh: (@MainActor () async throws -> Void)? + ) { + self.providers = providers + _selection = selection + self.isLoading = isLoading + self.threadSelection = threadSelection + self.materializesDefaultSelection = materializesDefaultSelection + self.onRefresh = onRefresh + let initialSelection = Self.effectiveSelection( + explicit: selection.wrappedValue, + inherited: threadSelection, + providers: providers, + materializesDefaultSelection: materializesDefaultSelection + ) + _draftSelection = State(initialValue: initialSelection) + _draftBaseSelection = State(initialValue: initialSelection) + _modelDrafts = State(initialValue: initialSelection.map { + [DailyUXModelOption.key(providerID: $0.providerID, modelID: $0.modelID): $0] + } ?? [:]) + } + + var body: some View { + NavigationStack { + Group { + if isLoading, availableModelCount == 0 { + VStack(spacing: 12) { + Image(systemName: "cpu") + .font(.title2) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityHidden(true) + Text("Loading models") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if availableModelCount == 0 { + ContentUnavailableView( + emptyStateTitle, + systemImage: emptyStateSymbol, + description: Text(emptyStateMessage) + ) + } else { + modelList + } + } + .background(T3Colors.background) + .navigationTitle("Choose model") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search models") + .toolbar { + if let setupContext { + ToolbarItem(placement: .topBarLeading) { + NavigationLink("Providers") { + ProvidersSettingsView(model: setupContext.model, environmentID: setupContext.environmentID) + } + } + } + if onRefresh != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + refreshCatalog() + } label: { + if isRefreshing { + Image(systemName: "hourglass") + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshing) + .accessibilityLabel(isRefreshing ? "Refreshing models" : "Refresh models") + .accessibilityIdentifier("model-picker-refresh") + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { applySelection() } + .fontWeight(.semibold) + .disabled(!hasDraftChanges) + .accessibilityIdentifier("model-picker-apply") + } + } + .navigationDestination(item: $configuring) { option in + ModelConfigurationView( + option: option, + currentSelection: pickerSelection + ) { configuredSelection in + draftSelection = configuredSelection + rememberDraft(configuredSelection) + hasEditedDraft = configuredSelection != committedSelection + configuring = nil + } + } + .t3NavigationChrome() + .alert("Could not refresh models", isPresented: Binding( + get: { refreshError != nil }, + set: { if !$0 { refreshError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(refreshError ?? "Try again.") + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .onAppear { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + .onChange(of: selection) { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + .onChange(of: threadSelection) { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + .onChange(of: providers) { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + } + + private func refreshCatalog() { + guard let onRefresh, !isRefreshing else { return } + isRefreshing = true + refreshError = nil + Task { + do { + try await onRefresh() + } catch { + refreshError = error.localizedDescription + } + isRefreshing = false + } + } + + private var modelList: some View { + let catalog = cachedCatalog + let sections = ProviderModelDisplaySections(catalog: catalog) + let isSearching = !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return List { + if modelChangesAreLocked { + Section { + Label( + "This task cannot change models.", + systemImage: "lock" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + + if isSearching { + ForEach(catalog.all) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } else { + if !sections.favorites.isEmpty { + Section("Favorites") { + ForEach(sections.favorites) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } + } + + if !sections.recents.isEmpty { + Section("Recent") { + ForEach(sections.recents) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } + } + + ForEach(sections.currentProviderGroups, id: \.provider.id) { group in + Section(group.provider.name) { + ForEach(group.models) { option in + modelRow( + option, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } + } + + if !sections.legacy.isEmpty { + Section { + DisclosureGroup(isExpanded: $legacyModelsExpanded) { + ForEach(sections.legacy) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } label: { + HStack { + Text("Legacy models") + .font(T3Typography.control.weight(.semibold)) + Spacer() + Text("\(sections.legacy.count)") + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + } + } + } + } + + if catalog.all.isEmpty { + ContentUnavailableView.search(text: query) + .listRowBackground(Color.clear) + } + + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .background(T3Colors.background) + .safeAreaInset(edge: .bottom, spacing: 0) { + selectionControls + } + } + + private var emptyStateTitle: String { + if providers.isEmpty { return "No providers" } + if !providers.contains(where: \.isAvailable) { return "Providers offline" } + return "No models available" + } + + private var emptyStateSymbol: String { + providers.isEmpty || !providers.contains(where: \.isAvailable) + ? "wifi.slash" + : "cpu" + } + + private var emptyStateMessage: String { + if providers.isEmpty { return "Connect an environment to see its models." } + if !providers.contains(where: \.isAvailable) { + return "Reconnect this environment to choose a model." + } + return "This environment has no available models." + } + + private var availableModelCount: Int { + pickerProviders + .filter(\.isAvailable) + .reduce(into: 0) { count, provider in + count += provider.models.count + } + } + + private func modelRow( + _ option: DailyUXModelOption, + showsProvider: Bool = false, + disambiguatesModel: Bool = false + ) -> some View { + let isSelected = pickerSelection?.providerID == option.provider.id + && pickerSelection?.modelID == option.model.id + let isFavorite = favoriteIDs.contains(option.id) + return HStack(spacing: 10) { + Button { + select(option) + } label: { + ModelOptionLabel( + option: option, + isSelected: isSelected, + showsProvider: showsProvider, + disambiguatesModel: disambiguatesModel + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLocked(option)) + .opacity(isLocked(option) ? 0.36 : 1) + .accessibilityLabel(option.model.name) + .accessibilityValue( + disambiguatesModel + ? "\(option.provider.name), \(option.model.id)" + : option.provider.name + ) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier("model-option-\(option.id)") + .accessibilityHint( + isLocked(option) + ? "This task cannot change models." + : "Select this model." + ) + + Button { + toggleFavorite(option.id) + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 15)) + .foregroundStyle( + isFavorite ? T3Colors.warning : T3Colors.textTertiary + ) + .frame( + width: T3Metrics.minimumTapTarget, + height: T3Metrics.minimumTapTarget + ) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites") + .accessibilityValue(option.model.name) + } + .listRowBackground(T3Colors.background) + } + + @ViewBuilder + private var selectionControls: some View { + if let selectedOption { + VStack(alignment: .leading, spacing: 10) { + Divider() + if let descriptor = DailyUXModelOptions.reasoningDescriptor( + for: selectedOption.model + ) { + modelOptionControl(descriptor) + optionFooter(for: descriptor) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } else { + VStack(alignment: .leading, spacing: 3) { + Text("Reasoning effort") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + Text("This environment does not describe reasoning effort choices for this model.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("reasoning-effort-unavailable") + } + + if !DailyUXModelOptions.advancedDescriptors(for: selectedOption.model).isEmpty + || !undescribedSelections.isEmpty { + Button { + configuring = selectedOption + } label: { + HStack { + Text("Advanced options") + .foregroundStyle(T3Colors.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("advanced-model-options") + + if !undescribedSelections.isEmpty { + Text(undescribedOptionsMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + .background(T3Colors.background) + } + } + + private var selectedOption: DailyUXModelOption? { + guard let pickerSelection, + let provider = providers.first(where: { $0.id == pickerSelection.providerID }), + let model = provider.models.first(where: { $0.id == pickerSelection.modelID }) else { + return nil + } + return DailyUXModelOption(provider: provider, model: model) + } + + private var undescribedSelections: [FeatureModelOptionSelection] { + guard let selectedOption, let pickerSelection else { return [] } + return DailyUXModelOptions.undescribedSelections( + for: selectedOption.model, + selections: pickerSelection.options + ) + } + + private var undescribedOptionsMessage: String { + let optionIDs = undescribedSelections.map(\.id).joined(separator: ", ") + return "This environment does not describe these saved options: \(optionIDs). They will be kept when you apply." + } + + @ViewBuilder + private func modelOptionControl( + _ descriptor: FeatureModelOptionDescriptor + ) -> some View { + switch descriptor.kind { + case .select: + HStack { + Text("Reasoning effort") + .font(T3Typography.control) + Spacer() + if descriptor.choices.isEmpty { + Text("No choices available") + .foregroundStyle(T3Colors.textSecondary) + .accessibilityIdentifier("reasoning-effort-control") + } else { + Menu { + if DailyUXModelOptions.defaultValue(for: descriptor) == nil { + Button { + updateDraftOption(id: descriptor.id, value: nil) + } label: { + if currentValue(for: descriptor) == nil { + Label("Provider default", systemImage: "checkmark") + } else { + Text("Provider default") + } + } + } + ForEach(descriptor.choices) { choice in + Button { + updateDraftOption( + id: descriptor.id, + value: .string(choice.id) + ) + } label: { + if isSelected(choice, for: descriptor) { + Label(choice.label, systemImage: "checkmark") + } else { + Text(choice.label) + } + } + } + } label: { + HStack(spacing: 5) { + Text(optionValueLabel(for: descriptor)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(T3Colors.textPrimary) + } + .accessibilityLabel("Reasoning effort") + .accessibilityValue(optionValueLabel(for: descriptor)) + .accessibilityIdentifier("reasoning-effort-control") + } + } + .frame(minHeight: T3Metrics.minimumTapTarget) + case .boolean: + BooleanModelOptionControl( + title: "Reasoning effort", + descriptor: descriptor, + value: optionBinding(for: descriptor) + ) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("reasoning-effort-control") + } + } + + @ViewBuilder + private func optionFooter( + for descriptor: FeatureModelOptionDescriptor + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let detail = descriptor.detail { + Text(detail) + } + if let value = currentValue(for: descriptor), + !DailyUXModelOptions.isSupportedValue(value, for: descriptor) { + Text("The saved value is not listed by this environment. Choose a listed value or keep the saved value.") + } else if descriptor.kind == .select, descriptor.choices.isEmpty { + Text("This environment did not provide choices for this option.") + } + } + } + + private func optionValueLabel( + for descriptor: FeatureModelOptionDescriptor + ) -> String { + switch currentValue(for: descriptor) { + case let .string(value): + return descriptor.choices.first(where: { $0.id == value })?.label ?? value + case let .boolean(value): + return value ? "On" : "Off" + case nil: + return "Provider default" + } + } + + private func currentValue( + for descriptor: FeatureModelOptionDescriptor + ) -> FeatureModelOptionValue? { + DailyUXModelOptions.value( + for: descriptor, + in: pickerSelection?.options ?? [] + ) + } + + private func updateDraftOption(id: String, value: FeatureModelOptionValue?) { + guard var next = pickerSelection else { return } + next.options = DailyUXModelOptions.updating(next.options, id: id, value: value) + draftSelection = next + rememberDraft(next) + hasEditedDraft = next != committedSelection + } + + private func isSelected( + _ choice: FeatureModelOptionChoice, + for descriptor: FeatureModelOptionDescriptor + ) -> Bool { + currentValue(for: descriptor) == .string(choice.id) + } + + private func optionBinding( + for descriptor: FeatureModelOptionDescriptor + ) -> Binding { + Binding( + get: { currentValue(for: descriptor) }, + set: { updateDraftOption(id: descriptor.id, value: $0) } + ) + } + + private var favoriteIDs: Set { + Set(favoriteStorage.split(separator: "\n").map(String.init)) + } + + private var recentIDs: [String] { + recentStorage.split(separator: "\n").map(String.init) + } + + private var cachedCatalog: DailyUXModelCatalog { + catalogCache.catalog( + providers: pickerProviders, + query: query, + favoriteStorage: favoriteStorage, + recentStorage: recentStorage + ) + } + + private var pickerProviders: [FeatureProvider] { + ThreadComposerModelSelectionPolicy.pickerProviders( + providers, + inherited: threadSelection, + allowsProviderChange: materializesDefaultSelection + ) + } + + private var displaySections: ProviderModelDisplaySections { + ProviderModelDisplaySections(catalog: cachedCatalog) + } + + private var committedSelection: FeatureSelection? { + Self.effectiveSelection( + explicit: selection, + inherited: threadSelection, + providers: providers, + materializesDefaultSelection: materializesDefaultSelection + ) + } + + private var pickerSelection: FeatureSelection? { + draftSelection ?? committedSelection + } + + private func select(_ option: DailyUXModelOption) { + guard !isLocked(option) else { return } + let next = ProviderModelDraftPolicy.selection( + for: option, + cached: modelDrafts[option.id], + current: pickerSelection, + committed: committedSelection + ) + draftSelection = next + rememberDraft(next) + hasEditedDraft = next != committedSelection + } + + private var hasDraftChanges: Bool { + hasEditedDraft && draftSelection != nil && draftSelection != committedSelection + } + + private func applySelection() { + guard hasDraftChanges else { return } + guard let validated = ProviderModelDraftPolicy.validated( + draftSelection, + providers: providers, + inheriting: threadSelection, + allowsProviderChange: materializesDefaultSelection + ) else { + replaceDraft(with: committedSelection) + return + } + selection = validated + recordRecent(DailyUXModelOption.key( + providerID: validated.providerID, + modelID: validated.modelID + )) + dismiss() + } + + private func reconcileDraftSelectionWithCurrentState() { + let committed = committedSelection + guard hasEditedDraft else { + replaceDraft(with: committed) + return + } + guard ProviderModelDraftPolicy.canKeepEditedDraft( + base: draftBaseSelection, + currentCommitted: committed, + draft: draftSelection, + providers: providers, + inheriting: threadSelection, + allowsProviderChange: materializesDefaultSelection + ) else { + replaceDraft(with: committed) + return + } + } + + private func replaceDraft(with value: FeatureSelection?) { + draftSelection = value + draftBaseSelection = value + modelDrafts = value.map { + [DailyUXModelOption.key(providerID: $0.providerID, modelID: $0.modelID): $0] + } ?? [:] + hasEditedDraft = false + } + + private func rememberDraft(_ value: FeatureSelection) { + modelDrafts[DailyUXModelOption.key( + providerID: value.providerID, + modelID: value.modelID + )] = value + } + + private func recordRecent(_ id: String) { + recentStorage = ([id] + recentIDs.filter { $0 != id }) + .prefix(8) + .joined(separator: "\n") + } + + private func revealSelectedLegacyModel() { + guard !legacyModelsExpanded, let pickerSelection else { return } + if displaySections.legacy.contains(where: { + $0.provider.id == pickerSelection.providerID + && $0.model.id == pickerSelection.modelID + }) { + legacyModelsExpanded = true + } + } + + private var modelChangesAreLocked: Bool { + guard let threadSelection, + let provider = providers.first(where: { $0.id == threadSelection.providerID }) else { + return false + } + return provider.requiresNewThreadForModelChange + } + + private func isLocked(_ option: DailyUXModelOption) -> Bool { + guard let threadSelection else { return false } + if option.provider.id != threadSelection.providerID { return true } + return modelChangesAreLocked && option.model.id != threadSelection.modelID + } + + private func toggleFavorite(_ id: String) { + var next = favoriteIDs + if next.contains(id) { + next.remove(id) + } else { + next.insert(id) + } + favoriteStorage = next.sorted().joined(separator: "\n") + } + + private static func effectiveSelection( + explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider], + materializesDefaultSelection: Bool + ) -> FeatureSelection? { + if materializesDefaultSelection { + return ProviderModelSelectionResolver.materialized(explicit, in: providers) + } + return ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: explicit, + inherited: inherited, + providers: providers + ) + } +} + +enum ProviderModelDraftPolicy { + static func selection( + for option: DailyUXModelOption, + cached: FeatureSelection?, + current: FeatureSelection?, + committed: FeatureSelection? + ) -> FeatureSelection { + if let cached, matches(cached, option: option) { + return ProviderModelConfiguration.selection(for: option, preserving: cached) + } + if let committed, matches(committed, option: option) { + return ProviderModelConfiguration.selection(for: option, preserving: committed) + } + return ProviderModelConfiguration.selection(for: option, preserving: current) + } + + static func validated( + _ selection: FeatureSelection?, + providers: [FeatureProvider], + inheriting inherited: FeatureSelection?, + allowsProviderChange: Bool + ) -> FeatureSelection? { + guard let selection, + providers.contains(where: { provider in + provider.id == selection.providerID + && provider.isAvailable + && provider.models.contains { $0.id == selection.modelID } + }) else { + return nil + } + guard let validated = ProviderModelSelectionResolver.validated(selection, in: providers) + else { + return nil + } + if !allowsProviderChange { + guard let inherited else { return nil } + guard validated.providerID == inherited.providerID else { return nil } + let inheritedProvider = providers.first { $0.id == inherited.providerID } + if inheritedProvider?.requiresNewThreadForModelChange == true, + validated.modelID != inherited.modelID { + return nil + } + } + return validated + } + + static func canKeepEditedDraft( + base: FeatureSelection?, + currentCommitted: FeatureSelection?, + draft: FeatureSelection?, + providers: [FeatureProvider], + inheriting inherited: FeatureSelection?, + allowsProviderChange: Bool + ) -> Bool { + base == currentCommitted + && validated( + draft, + providers: providers, + inheriting: inherited, + allowsProviderChange: allowsProviderChange + ) != nil + } + + private static func matches( + _ selection: FeatureSelection, + option: DailyUXModelOption + ) -> Bool { + selection.providerID == option.provider.id + && selection.modelID == option.model.id + } +} + +/// SwiftUI computed properties are ordinary function calls. The picker reads +/// its catalog throughout one body evaluation and invalidates on every search +/// keystroke, so retain the last derivation by its real inputs. +@MainActor +private final class ModelPickerCatalogCache { + private struct Key: Equatable { + let providers: [FeatureProvider] + let query: String + let favoriteStorage: String + let recentStorage: String + } + + private var key: Key? + private var value: DailyUXModelCatalog? + + func catalog( + providers: [FeatureProvider], + query: String, + favoriteStorage: String, + recentStorage: String + ) -> DailyUXModelCatalog { + let key = Key( + providers: providers, + query: query, + favoriteStorage: favoriteStorage, + recentStorage: recentStorage + ) + if self.key == key, let value { return value } + + let value = DailyUXModelCatalog( + providers: ProviderModelSearch.matching(providers, query: query), + query: "", + favoriteIDs: Set(favoriteStorage.split(separator: "\n").map(String.init)), + recentIDs: recentStorage.split(separator: "\n").map(String.init) + ) + self.key = key + self.value = value + return value + } +} + +enum ProviderModelSearch { + static func matching(_ providers: [FeatureProvider], query: String) -> [FeatureProvider] { + let terms = query.split { !$0.isLetter && !$0.isNumber }.map(String.init) + guard !terms.isEmpty else { return providers } + + return providers.compactMap { provider in + var matchingProvider = provider + matchingProvider.models = provider.models.filter { model in + let searchableFields = [ + provider.name, + provider.id, + model.name, + model.id, + model.detail ?? "", + model.supportsImages ? "images vision" : "", + ] + return terms.allSatisfy { term in + searchableFields.contains { $0.localizedCaseInsensitiveContains(term) } + } + } + return matchingProvider.models.isEmpty ? nil : matchingProvider + } + } +} + +/// The picker never represents an implicit "automatic" model. A missing or stale +/// selection becomes the environment's concrete preferred model as soon as the +/// catalog is available. +enum ProviderModelSelectionResolver { + static func validated( + _ selection: FeatureSelection?, + in providers: [FeatureProvider] + ) -> FeatureSelection? { + guard !providers.isEmpty else { return selection } + guard var validated = DailyUXModelOptions.validated(selection, in: providers), + let model = providers + .first(where: { $0.id == validated.providerID })? + .models.first(where: { $0.id == validated.modelID }) else { + return nil + } + validated.options = ProviderModelConfiguration.materializedOptions( + for: model, + preserving: validated.options + ) + return validated + } + + static func materialized( + _ selection: FeatureSelection?, + in providers: [FeatureProvider] + ) -> FeatureSelection? { + guard !providers.isEmpty else { return selection } + if let validated = validated(selection, in: providers) { + return validated + } + let currentProviders = providers.compactMap { provider -> FeatureProvider? in + var current = provider + current.models = provider.models.filter { + ProviderModelFamilyClassifier.isCurrent($0, provider: provider) + } + return current.models.isEmpty ? nil : current + } + return DailyUXModelOptions.preferredSelection(in: currentProviders) + ?? DailyUXModelOptions.preferredSelection(in: providers) + } +} + +/// Existing threads inherit their persisted model until the user deliberately +/// chooses an override. Unlike new-task composers, a missing selection must not +/// materialize the environment default and silently change providers. +enum ThreadComposerModelSelectionPolicy { + static func pickerProviders( + _ providers: [FeatureProvider], + inherited: FeatureSelection?, + allowsProviderChange: Bool + ) -> [FeatureProvider] { + if allowsProviderChange { return providers } + guard let inherited else { return [] } + return providers.filter { $0.id == inherited.providerID } + } + + static func resolvedSelection( + explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + explicitSelection(explicit, inherited: inherited, providers: providers) + ?? preservedSelection(inherited, providers: providers) + } + + static func explicitSelection( + _ explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + guard let explicit, let inherited else { return nil } + guard explicit.providerID == inherited.providerID else { return nil } + let inheritedProvider = providers.first { $0.id == inherited.providerID } + if inheritedProvider?.requiresNewThreadForModelChange == true, + explicit.modelID != inherited.modelID { + return nil + } + + // An environment refresh can briefly remove a provider or a custom + // model from discovery. Keep an existing override until discovery can + // validate it again. Applying a new choice still uses the stricter + // ProviderModelDraftPolicy validation path. + return ProviderModelSelectionResolver.validated(explicit, in: providers) + ?? explicit + } + + private static func preservedSelection( + _ selection: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + guard var selection else { return nil } + guard let model = providers + .first(where: { $0.id == selection.providerID })? + .models.first(where: { $0.id == selection.modelID }) else { + return selection + } + selection.options = ProviderModelConfiguration.materializedOptions( + for: model, + preserving: selection.options + ) + return selection + } +} + +/// Existing threads use their saved environment and provider instance as the +/// catalog identity. Project rows and provider discovery can be temporarily +/// absent, but neither should make the thread's saved model disappear. +enum ThreadComposerProviderCatalog { + static func providers( + for thread: FeatureThread, + in snapshot: FeatureSnapshot + ) -> [FeatureProvider] { + let environmentID = thread.environmentID + ?? snapshot.projects.first(where: { $0.id == thread.projectID })?.environmentID + var providers = environmentID.flatMap { + snapshot.providersByEnvironment?[$0] + } ?? [] + + guard let providerID = thread.providerID, + let modelID = thread.modelID else { + return providers + } + + // A missing catalog entry has unknown image support, not a known restriction. + var savedModel = FeatureModel(id: modelID, name: modelID) + savedModel.imageSupportIsUnknown = true + if let providerIndex = providers.firstIndex(where: { $0.id == providerID }) { + guard !providers[providerIndex].models.contains(where: { $0.id == modelID }) else { + return providers + } + providers[providerIndex].models.append(savedModel) + return providers + } + + let providerName = thread.providerName? + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedProviderName = providerName.flatMap { name in + name.isEmpty ? nil : name + } ?? providerID + providers.append(FeatureProvider( + id: providerID, + name: resolvedProviderName, + isAvailable: false, + models: [savedModel] + )) + return providers + } +} + +enum ProviderModelCatalogNormalizer { + static func normalized(_ providers: [FeatureProvider]) -> [FeatureProvider] { + var order: [String] = [] + var providersByID: [String: FeatureProvider] = [:] + var modelIDsByProvider: [String: Set] = [:] + + for provider in providers { + let visibleModels = provider.models.filter { !isImplicitModel($0) } + if var existing = providersByID[provider.id] { + existing.isAvailable = existing.isAvailable || provider.isAvailable + existing.requiresNewThreadForModelChange = + existing.requiresNewThreadForModelChange + || provider.requiresNewThreadForModelChange + if existing.name.isEmpty { + existing.name = provider.name + } + if existing.driver.isEmpty { + existing.driver = provider.driver + } + existing.slashCommands = mergingMetadata( + existing.slashCommands, + provider.slashCommands, + id: \.id + ) + existing.skills = mergingMetadata( + existing.skills, + provider.skills, + id: \.id + ) + providersByID[provider.id] = existing + } else { + var normalized = provider + normalized.models = [] + providersByID[provider.id] = normalized + modelIDsByProvider[provider.id] = [] + order.append(provider.id) + } + + for model in visibleModels { + let wasInserted = modelIDsByProvider[provider.id, default: []] + .insert(model.id) + .inserted + if wasInserted { + providersByID[provider.id]?.models.append(model) + } + } + } + + return order.compactMap { providersByID[$0] } + } + + private static func mergingMetadata( + _ first: [Value]?, + _ second: [Value]?, + id: KeyPath + ) -> [Value]? { + guard first != nil || second != nil else { return nil } + var seen = Set() + return ((first ?? []) + (second ?? [])).filter { + seen.insert($0[keyPath: id]).inserted + } + } + + private static func isImplicitModel(_ model: FeatureModel) -> Bool { + if model.id == "antigravity-default" { return true } + let tokens = [model.id, model.name].flatMap { + $0.lowercased() + .split { !$0.isLetter && !$0.isNumber } + .map(String.init) + } + return tokens.contains("automatic") || tokens.contains("auto") + } +} + +struct ProviderModelDisplaySections { + let favorites: [DailyUXModelOption] + let recents: [DailyUXModelOption] + let currentProviderGroups: [( + provider: FeatureProvider, + models: [DailyUXModelOption] + )] + let legacy: [DailyUXModelOption] + let disambiguatedModelIDs: Set + + init(catalog: DailyUXModelCatalog) { + let currentIDs = Set(catalog.all.compactMap { option in + ProviderModelFamilyClassifier.isCurrent( + option.model, + provider: option.provider + ) ? option.id : nil + }) + favorites = catalog.favorites + var seenRecentIDs = Set() + recents = catalog.recents.filter { + currentIDs.contains($0.id) && seenRecentIDs.insert($0.id).inserted + } + let promoted = Set((favorites + recents).map(\.id)) + currentProviderGroups = catalog.providerGroups.compactMap { group in + let models = group.models.filter { + currentIDs.contains($0.id) && !promoted.contains($0.id) + } + return models.isEmpty ? nil : (group.provider, models) + } + legacy = catalog.all.filter { !currentIDs.contains($0.id) && !promoted.contains($0.id) } + + let matchingLabels = Dictionary(grouping: catalog.all) { option in + ModelPresentationKey( + providerName: option.provider.name, + name: option.model.name, + detail: option.model.detail ?? option.model.id + ) + } + disambiguatedModelIDs = Set( + matchingLabels.values + .filter { $0.count > 1 } + .flatMap { $0.map(\.id) } + ) + } + + private struct ModelPresentationKey: Hashable { + let providerName: String + let name: String + let detail: String + } +} + +enum ProviderModelFamilyClassifier { + static func isCurrent(_ model: FeatureModel, provider _: FeatureProvider) -> Bool { + model.isLegacy != true + } +} + +private struct BooleanModelOptionControl: View { + let title: String + let descriptor: FeatureModelOptionDescriptor + @Binding var value: FeatureModelOptionValue? + + var body: some View { + if DailyUXModelOptions.defaultValue(for: descriptor) != nil { + Toggle(title, isOn: Binding( + get: { value == .boolean(true) }, + set: { value = .boolean($0) } + )) + } else { + Picker(title, selection: $value) { + Text("Provider default").tag(Optional.none) + Text("On").tag(Optional(FeatureModelOptionValue.boolean(true))) + Text("Off").tag(Optional(FeatureModelOptionValue.boolean(false))) + if case let .string(savedValue) = value { + Text(savedValue).tag(Optional(FeatureModelOptionValue.string(savedValue))) + } + } + .pickerStyle(.menu) + .tint(T3Colors.textPrimary) + } + } +} + +private struct ModelConfigurationView: View { + let option: DailyUXModelOption + let onConfirm: (FeatureSelection) -> Void + @State private var optionSelections: [FeatureModelOptionSelection] + + init( + option: DailyUXModelOption, + currentSelection: FeatureSelection?, + onConfirm: @escaping (FeatureSelection) -> Void + ) { + self.option = option + self.onConfirm = onConfirm + _optionSelections = State(initialValue: ProviderModelConfiguration.selection( + for: option, + preserving: currentSelection + ).options) + } + + var body: some View { + Form { + Section { + ModelOptionLabel(option: option, isSelected: false) + } + + ForEach(DailyUXModelOptions.advancedDescriptors(for: option.model)) { descriptor in + Section { + switch descriptor.kind { + case .select: + HStack { + Text(descriptor.label) + Spacer() + if descriptor.choices.isEmpty { + Text("No choices available") + .foregroundStyle(T3Colors.textSecondary) + } else { + Menu { + if DailyUXModelOptions.defaultValue(for: descriptor) == nil { + Button { + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: nil + ) + } label: { + if DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) == nil { + Label("Provider default", systemImage: "checkmark") + } else { + Text("Provider default") + } + } + } + ForEach(descriptor.choices) { choice in + Button { + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: .string(choice.id) + ) + } label: { + if isSelected(choice, for: descriptor) { + Label(choice.label, systemImage: "checkmark") + } else { + Text(choice.label) + } + } + } + } label: { + HStack(spacing: 5) { + Text(optionValueLabel(for: descriptor)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(T3Colors.textPrimary) + } + } + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("advanced-option-\(descriptor.id)") + .accessibilityValue(optionValueLabel(for: descriptor)) + case .boolean: + BooleanModelOptionControl( + title: descriptor.label, + descriptor: descriptor, + value: optionBinding(for: descriptor) + ) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("advanced-option-\(descriptor.id)") + } + } footer: { + optionFooter(for: descriptor) + } + } + + if !undescribedSelections.isEmpty { + Section("Saved options") { + Text(undescribedOptionsMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle("Model options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save options") { + onConfirm( + FeatureSelection( + providerID: option.provider.id, + modelID: option.model.id, + options: optionSelections + ) + ) + } + .fontWeight(.semibold) + } + } + } + + private var undescribedSelections: [FeatureModelOptionSelection] { + DailyUXModelOptions.undescribedSelections( + for: option.model, + selections: optionSelections + ) + } + + private var undescribedOptionsMessage: String { + let optionIDs = undescribedSelections.map(\.id).joined(separator: ", ") + return "This environment does not describe these saved options: \(optionIDs). T3 Code will keep them." + } + + private func optionValueLabel( + for descriptor: FeatureModelOptionDescriptor + ) -> String { + switch DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) { + case let .string(value): + return descriptor.choices.first(where: { $0.id == value })?.label ?? value + case let .boolean(value): + return value ? "On" : "Off" + case nil: + return "Provider default" + } + } + + private func isSelected( + _ choice: FeatureModelOptionChoice, + for descriptor: FeatureModelOptionDescriptor + ) -> Bool { + DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) == .string(choice.id) + } + + @ViewBuilder + private func optionFooter( + for descriptor: FeatureModelOptionDescriptor + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let detail = descriptor.detail { + Text(detail) + } + if let value = DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ), !DailyUXModelOptions.isSupportedValue(value, for: descriptor) { + Text("The saved value is not listed by this environment. Choose a listed value or keep the saved value.") + } else if descriptor.kind == .select, descriptor.choices.isEmpty { + Text("This environment did not provide choices for this option.") + } + } + } + + private func optionBinding( + for descriptor: FeatureModelOptionDescriptor + ) -> Binding { + Binding( + get: { + DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) + }, + set: { value in + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: value + ) + } + ) + } +} + +enum ProviderModelConfiguration { + static func selection( + for option: DailyUXModelOption, + preserving currentSelection: FeatureSelection? + ) -> FeatureSelection { + let selections: [FeatureModelOptionSelection] + if currentSelection?.providerID == option.provider.id, + currentSelection?.modelID == option.model.id { + selections = materializedOptions( + for: option.model, + preserving: currentSelection?.options ?? [] + ) + } else { + selections = DailyUXModelOptions.defaults(for: option.model) + } + return FeatureSelection( + providerID: option.provider.id, + modelID: option.model.id, + options: selections + ) + } + + static func materializedOptions( + for model: FeatureModel, + preserving selections: [FeatureModelOptionSelection] + ) -> [FeatureModelOptionSelection] { + let selectedIDs = Set(selections.map(\.id)) + return selections + DailyUXModelOptions.defaults(for: model).filter { + !selectedIDs.contains($0.id) + } + } +} + +private struct ModelOptionLabel: View { + let option: DailyUXModelOption + let isSelected: Bool + var showsProvider = false + var disambiguatesModel = false + + var body: some View { + HStack(spacing: 12) { + providerMark + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Text(option.model.name) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + if option.model.supportsImages { + capability("Images", icon: "photo") + } + } + Text(modelDetail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 6) + if isSelected { + Image(systemName: "checkmark") + .font(.subheadline.weight(.bold)) + .foregroundStyle(T3Colors.textPrimary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + + private var modelDetail: String { + let detail = disambiguatesModel + ? option.model.id + : option.model.detail ?? option.model.id + return showsProvider ? "\(option.provider.name) · \(detail)" : detail + } + + private var providerMark: some View { + ProviderIcon( + driver: option.provider.driver, + providerID: option.provider.id, + fallbackName: option.provider.name, + size: 26 + ) + } + + private func capability(_ title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } +} diff --git a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift new file mode 100644 index 000000000000..083a28904c26 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift @@ -0,0 +1,144 @@ +import Foundation +import UIKit + +enum ThreadCopyActionKind: Equatable, Sendable { + case path + case branch + case threadID + case project + case environment + case url + + var title: String { + switch self { + case .path: "Path" + case .branch: "Branch" + case .threadID: "Thread ID" + case .project: "Project" + case .environment: "Environment" + case .url: "URL" + } + } + + var systemImage: String { + switch self { + case .path: "folder" + case .branch: "arrow.triangle.branch" + case .threadID: "number" + case .project: "folder.badge.gearshape" + case .environment: "desktopcomputer" + case .url: "link" + } + } + + var copyAnnouncement: String { "\(title) copied" } +} + +struct ThreadCopyContext: Equatable, Sendable { + let projectName: String? + let projectWorkspaceRoot: String? + let environmentName: String? + let environmentID: String? +} + +struct ThreadCopyAction: Equatable, Sendable { + let kind: ThreadCopyActionKind + let value: String? + + var isAvailable: Bool { value != nil } + + var announcement: String { + isAvailable ? kind.copyAnnouncement : "\(kind.title) unavailable" + } +} + +enum ThreadCopyModel { + /// The long-press row menu mirrors the Electron thread menu. + static func menuActions( + for thread: FeatureThread, + context: ThreadCopyContext + ) -> [ThreadCopyAction] { + actions(for: thread, context: context).filter { action in + switch action.kind { + case .path, .branch, .threadID: + true + case .project, .environment, .url: + false + } + } + } + + static func actions( + for thread: FeatureThread, + context: ThreadCopyContext + ) -> [ThreadCopyAction] { + var actions: [ThreadCopyAction] = [] + + let path = nonBlank(thread.worktreePath) ?? nonBlank(context.projectWorkspaceRoot) + actions.append(ThreadCopyAction(kind: .path, value: path)) + if let branch = nonBlank(thread.branch) { + actions.append(ThreadCopyAction(kind: .branch, value: branch)) + } + if let threadID = nonBlank(thread.wireID) ?? nonBlank(thread.id) { + actions.append(ThreadCopyAction(kind: .threadID, value: threadID)) + } + if let project = nonBlank(context.projectName) { + actions.append(ThreadCopyAction(kind: .project, value: project)) + } + if let environment = nonBlank(context.environmentName) { + actions.append(ThreadCopyAction(kind: .environment, value: environment)) + } + + let environmentID = nonBlank(thread.environmentID) ?? nonBlank(context.environmentID) + if let url = threadURL(environmentID: environmentID, threadID: nonBlank(thread.wireID)) { + actions.append(ThreadCopyAction(kind: .url, value: url)) + } + + return actions + } + + private static func threadURL(environmentID: String?, threadID: String?) -> String? { + guard let environmentID, + let threadID, + let encodedEnvironmentID = pathSegment(environmentID), + let encodedThreadID = pathSegment(threadID) else { + return nil + } + + var components = URLComponents() + components.scheme = "https" + components.host = "app.t3.codes" + components.percentEncodedPath = "/\(encodedEnvironmentID)/\(encodedThreadID)" + return components.url?.absoluteString + } + + private static func pathSegment(_ value: String) -> String? { + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + return value.addingPercentEncoding(withAllowedCharacters: allowed) + } + + /// Availability ignores surrounding whitespace, while the copied value remains byte-for-byte + /// identical to the value received from the server. + private static func nonBlank(_ value: String?) -> String? { + guard let value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return value + } +} + +@MainActor +enum ThreadCopyClipboard { + static func copy(_ action: ThreadCopyAction) { + if let value = action.value { + UIPasteboard.general.string = value + } + UIAccessibility.post( + notification: .announcement, + argument: action.announcement + ) + } +} diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift new file mode 100644 index 000000000000..ad3c40a70a1c --- /dev/null +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -0,0 +1,1507 @@ +import SwiftUI +import UIKit + +struct FeatureWorkspaceNavigationRequest: Equatable, Sendable { + enum Destination: Equatable, Sendable { + case thread(id: String) + case project(id: String) + case newTask(projectID: String?) + } + + let id: UUID + let destination: Destination + + init(id: UUID = UUID(), destination: Destination) { + self.id = id + self.destination = destination + } +} + +struct WorkspaceThreadSelection: Equatable { + private(set) var selectedID: String? + private(set) var lastOpenedID: String? + + var highlightedID: String? { selectedID ?? lastOpenedID } + + mutating func open(_ id: String) { + selectedID = id + lastOpenedID = id + } + + mutating func close() { + selectedID = nil + } +} + +public struct WorkspaceView: View { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @Bindable var model: FeatureRootModel + private let navigationRequest: FeatureWorkspaceNavigationRequest? + private let onNavigationRequestConsumed: @MainActor (UUID) -> Void + private let submitNewTask: (NewTaskRequest) async -> FeatureThread? + private let submitMessage: (FeatureMessageSubmission) async -> Bool + + @State private var threadSelection = WorkspaceThreadSelection() + @State private var selectedProjectID: String? + @State private var searchText = "" + @State private var isSearching = false + @AppStorage("t3.swiftui.home.snoozedExpanded") private var isSnoozedExpanded = false + @AppStorage("t3.swiftui.home.settledExpanded") private var isSettledExpanded = true + @AppStorage("t3.swiftui.home.archiveExpanded") private var isArchiveExpanded = false + @State private var settledLimit = 10 + @State private var showingNewTask = false + @State private var newTaskInitialProjectID: String? + @State private var showingAddProject = false + @State private var showingEnvironments = false + @State private var showingSettings = false + @State private var renamingThread: FeatureThread? + @State private var deletingThread: FeatureThread? + @State private var renameTitle = "" + @State private var sidebarBoundaryNow = Date.now + @State private var preferredCompactColumn = NavigationSplitViewColumn.sidebar + @State private var homePresentationCache = HomePresentationCache() + @FocusState private var isSearchFocused: Bool + + public init( + model: FeatureRootModel, + submitNewTask: ((NewTaskRequest) async -> FeatureThread?)? = nil, + submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil + ) { + self.init( + model: model, + navigationRequest: nil, + onNavigationRequestConsumed: { _ in }, + submitNewTask: submitNewTask, + submitMessage: submitMessage + ) + } + + init( + model: FeatureRootModel, + navigationRequest: FeatureWorkspaceNavigationRequest?, + onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void, + submitNewTask: ((NewTaskRequest) async -> FeatureThread?)? = nil, + submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil + ) { + self.model = model + self.navigationRequest = navigationRequest + self.onNavigationRequestConsumed = onNavigationRequestConsumed + self.submitNewTask = submitNewTask ?? { request in + do { + let thread = try await model.client.createThreadAndSend( + projectID: request.projectID, + prompt: request.trimmedPrompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode, + workspaceMode: request.workspaceMode, + branch: request.branch, + worktreePath: request.worktreePath, + startFromOrigin: request.startFromOrigin, + attachments: request.attachments.map(\.uploadValue) + ) + await model.reload() + return thread + } catch { + return nil + } + } + self.submitMessage = submitMessage ?? { submission in + if submission.attachments.isEmpty { + return await model.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection + ) + } + do { + try await model.client.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection, + attachments: submission.attachments.map(\.uploadValue) + ) + _ = await model.detail(for: submission.threadID, force: true) + return true + } catch { + return false + } + } + } + + public var body: some View { + NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) { + sidebar + .navigationSplitViewColumnWidth( + min: T3Metrics.minimumSidebarWidth, + ideal: T3Metrics.sidebarWidth, + max: T3Metrics.maximumSidebarWidth + ) + } detail: { + detail + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $showingNewTask) { + NewThreadView( + model: model, + submit: submitNewTask, + onCreated: { thread in + openThread(thread.id) + showingNewTask = false + }, + onCreateProject: openProjectCreation, + initialProjectID: newTaskInitialProjectID + ) + } + .sheet(isPresented: $showingAddProject) { + AddProjectView(model: model) + } + .sheet(isPresented: $showingEnvironments) { + NavigationStack { + ConnectionsView(model: model) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showingEnvironments = false } + } + } + } + .presentationDragIndicator(.visible) + .onAppear { model.setConnectionManagementPresented(true) } + .onDisappear { model.setConnectionManagementPresented(false) } + } + .sheet(isPresented: $showingSettings) { + SettingsView(model: model) + } + .alert( + "Rename thread", + isPresented: Binding( + get: { renamingThread != nil }, + set: { if !$0 { renamingThread = nil } } + ) + ) { + TextField("Thread title", text: $renameTitle) + Button("Cancel", role: .cancel) { renamingThread = nil } + Button("Save") { + guard let thread = renamingThread else { return } + let title = renameTitle + renamingThread = nil + Task { await model.renameThread(thread.id, title: title) } + } + .disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .alert( + "Delete thread?", + isPresented: Binding( + get: { deletingThread != nil }, + set: { if !$0 { deletingThread = nil } } + ), + presenting: deletingThread + ) { thread in + Button("Delete", role: .destructive) { + deletingThread = nil + Task { await model.deleteThread(thread.id) } + } + Button("Cancel", role: .cancel) { deletingThread = nil } + } message: { thread in + Text("\"\(thread.title)\" and its terminal history will be permanently deleted.") + } + .onChange(of: selectedThreadIsAvailable) { _, isAvailable in + if !isAvailable { closeSelectedThread() } + } + .onChange(of: selectedThreadID) { _, newValue in + preferredCompactColumn = newValue == nil ? .sidebar : .detail + } + .onChange(of: selectedProjectIsAvailable) { _, isAvailable in + if !isAvailable { selectedProjectID = nil } + } + .onChange(of: navigationRequest?.id, initial: true) { _, _ in + consumeNavigationRequest() + } + // A request that arrives before its thread or project exists in the + // snapshot stays pending; retry it as data lands so cold-start deep + // links are not silently stranded. + .onChange(of: model.homePresentationRevision) { _, _ in + if navigationRequest != nil { consumeNavigationRequest() } + } + .task(id: nextSidebarBoundary) { + guard let boundary = nextSidebarBoundary else { return } + do { + try await Task.sleep(for: .seconds(max(0, boundary.timeIntervalSinceNow))) + sidebarBoundaryNow = max(.now, boundary) + } catch { + return + } + } + } + + private var sidebar: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + homeBar + if isSearching { + searchBar + .transition(.opacity.combined(with: .move(edge: .top))) + } + threadList + } + + composeButton + .padding(.trailing, 16) + .padding(.bottom, 14) + } + .background(T3Colors.background) + .toolbar(.hidden, for: .navigationBar) + .onChange(of: selectedProjectID) { + settledLimit = 10 + } + } + + private var threadList: some View { + let presentation = homePresentationCache.presentation( + snapshot: model.snapshot, + revision: model.homePresentationRevision, + query: searchText, + projectID: selectedProjectID, + now: sidebarBoundaryNow, + pullRequestsByThreadID: model.pullRequestsByThreadID + ) + + return VStack(spacing: 0) { + projectFilter + HomeThreadCollectionView( + presentation: presentation, + projectFaviconClient: model.client, + query: searchText, + selectedThreadID: threadSelection.highlightedID, + forceRichRows: dynamicTypeSize.isAccessibilitySize, + hapticsEnabled: model.snapshot.settings.hapticsEnabled, + settings: model.snapshot.settings, + pullRequestsByThreadID: model.pullRequestsByThreadID, + isSnoozedExpanded: isSnoozedExpanded, + isSettledExpanded: isSettledExpanded, + isArchiveExpanded: isArchiveExpanded, + settledLimit: settledLimit, + onOpen: openThread, + onToggleSnoozed: { isSnoozedExpanded.toggle() }, + onToggleSettled: { isSettledExpanded.toggle() }, + onToggleArchive: { isArchiveExpanded.toggle() }, + onShowMoreSettled: { settledLimit += 25 }, + onRename: { thread in + renameTitle = thread.title + renamingThread = thread + }, + onRegenerateTitle: { thread in + Task { await model.regenerateThreadTitle(thread.id) } + }, + onArchive: { thread, archived in + Task { await model.setArchived(thread.id, archived: archived) } + }, + onSettle: { thread, settled, completion in + Task { completion(await model.setSettled(thread.id, settled: settled)) } + }, + onSnooze: { thread, until in + Task { await model.setSnoozed(thread.id, until: until) } + }, + onPin: { thread, pinned in + Task { await model.setPinned(thread.id, pinned: pinned) } + }, + onDelete: { thread in + deletingThread = thread + }, + onPullRequestChange: { threadID, observationIdentity, pullRequest in + model.updatePullRequest( + pullRequest, + threadID: threadID, + observationIdentity: observationIdentity + ) + } + ) + } + .background(T3Colors.background) + } + + @ViewBuilder + private var detail: some View { + if let id = selectedThreadID, + let thread = model.snapshot.threads.first(where: { $0.id == id }) { + ThreadDetailView( + model: model, + thread: thread, + submitMessage: submitMessage, + onNavigateBack: closeSelectedThread + ) + .id(id) + } else { + VStack(spacing: 14) { + Image(systemName: "square.and.pencil") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(T3Colors.textTertiary) + Text("Start a task") + .font(.title3.weight(.semibold)) + Text("Choose a thread or compose something new.") + .font(.subheadline) + .foregroundStyle(T3Colors.textSecondary) + Button("New task", action: openNewTaskOrProjectCreation) + .buttonStyle(.borderedProminent) + .tint(T3Colors.primaryAction) + .foregroundStyle(T3Colors.primaryActionForeground) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + } + } + + private var homeBar: some View { + HStack(spacing: 2) { + connectionBrand + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + withAnimation(.easeOut(duration: 0.16)) { + isSearching.toggle() + } + if isSearching { + Task { @MainActor in + await Task.yield() + isSearchFocused = true + } + } else { + searchText = "" + isSearchFocused = false + } + } label: { + Image(systemName: isSearching ? "xmark" : "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .frame(width: 40, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel(isSearching ? "Close search" : "Search tasks") + .accessibilityIdentifier("sidebar-search-button") + + Button { showingSettings = true } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 17, weight: .medium)) + .frame(width: 40, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Settings") + .accessibilityIdentifier("sidebar-settings-button") + } + .padding(.leading, 15) + .padding(.trailing, 8) + .frame(height: 49) + .background(T3Colors.background) + } + + @ViewBuilder + private var connectionBrand: some View { + if !unreachableEnvironments.isEmpty { + Button { showingEnvironments = true } label: { + HStack(spacing: 7) { + Image(systemName: "network.slash") + .font(.system(size: 13, weight: .semibold)) + Text(unreachableBrandLabel) + .lineLimit(1) + .font(.system(size: 13, weight: .semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.danger) + .accessibilityLabel("\(unreachableBrandLabel). Manage environments") + .accessibilityIdentifier("sidebar-environments-button") + } else if let reconnecting = reconnectingEnvironments.first { + Button { showingEnvironments = true } label: { + HStack(spacing: 7) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 13, weight: .semibold)) + Text(reconnecting.name) + .lineLimit(1) + Text("reconnecting") + .fontWeight(.medium) + .opacity(0.76) + } + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(T3Colors.warning) + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(reconnecting.name) reconnecting. Manage environments") + .accessibilityIdentifier("sidebar-environments-button") + } else { + Button { showingEnvironments = true } label: { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("T3") + .fontWeight(.bold) + .foregroundStyle(T3Colors.textPrimary) + Text("Code") + .fontWeight(.medium) + .foregroundStyle(T3Colors.textSecondary) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(T3Colors.textTertiary) + .padding(.leading, 2) + } + .font(.system(size: 16)) + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("T3 Code. Manage environments") + .accessibilityIdentifier("sidebar-environments-button") + } + } + + private var searchBar: some View { + HStack(spacing: 9) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + TextField("Search tasks and projects", text: $searchText) + .font(.subheadline) + .foregroundStyle(T3Colors.textPrimary) + .focused($isSearchFocused) + .submitLabel(.search) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("sidebar-search-field") + if !searchText.isEmpty { + Button { searchText = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 12) + .frame(height: T3Metrics.minimumTapTarget) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(T3Colors.border, lineWidth: 1) + } + .padding(.horizontal, 10) + .padding(.bottom, 4) + } + + private var composeButton: some View { + Button { + isSearchFocused = false + openNewTaskOrProjectCreation() + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(T3Colors.primaryActionForeground) + .frame(width: 52, height: 52) + .background(T3Colors.primaryAction, in: Circle()) + .shadow(color: T3Colors.shadow, radius: 16, y: 8) + } + .buttonStyle(.plain) + .accessibilityLabel("New task") + .accessibilityHint(newTaskAccessibilityHint) + .accessibilityIdentifier("sidebar-new-task-button") + } + + private var newTaskAccessibilityHint: String { + if !creationProjects.isEmpty { + return "Compose a message and start a thread" + } + if !DailyUXCreationContext.unreachableEnvironments(in: model.snapshot).isEmpty { + return "Review unreachable environments and try again" + } + return "Create a project to start a task" + } + + private var projectFilter: some View { + HStack(spacing: 0) { + Menu { + Button { + selectedProjectID = nil + } label: { + if selectedProjectID == nil { + Label("All projects", systemImage: "checkmark") + } else { + Text("All projects") + } + } + ForEach(model.snapshot.projects) { project in + Button { + selectedProjectID = project.id + } label: { + let title = projectMenuTitle(project) + if selectedProjectID == project.id { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + } + } label: { + HStack(spacing: 7) { + Image(systemName: "folder") + .font(.system(size: 13, weight: .medium)) + Text(selectedProject?.name ?? "All projects") + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(T3Typography.homeMetadata.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Project filter") + .accessibilityValue(selectedProject?.name ?? "All projects") + .accessibilityIdentifier("sidebar-project-filter") + + Button { showingAddProject = true } label: { + Image(systemName: "folder.badge.plus") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: T3Metrics.minimumTapTarget, height: 34) + } + .buttonStyle(.plain) + .accessibilityLabel("Add project") + .accessibilityIdentifier("sidebar-add-project-button") + } + .padding(.leading, 10) + .padding(.trailing, 2) + .accessibilityElement(children: .contain) + } + + private var selectedProject: FeatureProject? { + model.snapshot.projects.first { $0.id == selectedProjectID } + } + + private var creationProjects: [FeatureProject] { + DailyUXCreationContext.projects(in: model.snapshot) + } + + private var unreachableEnvironments: [FeatureEnvironment] { + model.snapshot.environments.filter { + $0.isEnabled && $0.connectionState == .disconnected + } + } + + private var reconnectingEnvironments: [FeatureEnvironment] { + model.snapshot.environments.filter { + $0.isEnabled + && ($0.connectionState == .connecting || $0.connectionState == .reconnecting) + } + } + + private var unreachableBrandLabel: String { + if unreachableEnvironments.count == 1 { + return "\(unreachableEnvironments[0].name) offline" + } + return "\(unreachableEnvironments.count) environments offline" + } + + private var nextSidebarBoundary: Date? { + DailyUXSidebarRefresh.nextBoundary( + for: model.snapshot.threads, + after: max(sidebarBoundaryNow, .now), + settings: model.snapshot.settings, + pullRequestsByThreadID: model.pullRequestsByThreadID + ) + } + + private var selectedThreadIsAvailable: Bool { + guard let selectedThreadID else { return true } + return model.snapshot.threads.contains { $0.id == selectedThreadID } + } + + private var selectedThreadID: String? { threadSelection.selectedID } + + private var selectedProjectIsAvailable: Bool { + guard let selectedProjectID else { return true } + return model.snapshot.projects.contains { $0.id == selectedProjectID } + } + + private func openThread(_ id: String) { + threadSelection.open(id) + preferredCompactColumn = .detail + } + + private func closeSelectedThread() { + threadSelection.close() + preferredCompactColumn = .sidebar + } + + @MainActor + private func openProjectCreation() { + showingNewTask = false + showingAddProject = true + } + + private func openNewTaskOrProjectCreation() { + openNewTaskOrProjectCreation(initialProjectID: selectedProjectID) + } + + private func openNewTaskOrProjectCreation(initialProjectID: String?) { + switch DailyUXCreationContext.newTaskDestination(in: model.snapshot) { + case .newTask: + newTaskInitialProjectID = initialProjectID + showingNewTask = true + case .addProject: + showingAddProject = true + } + } + + private func consumeNavigationRequest() { + guard let navigationRequest else { return } + switch navigationRequest.destination { + case let .thread(id): + guard model.snapshot.threads.contains(where: { $0.id == id }) else { return } + dismissTransientPresentations() + openThread(id) + case let .project(id): + guard model.snapshot.projects.contains(where: { $0.id == id }) else { return } + dismissTransientPresentations() + selectedProjectID = id + closeSelectedThread() + case let .newTask(projectID): + if let projectID, + model.snapshot.projects.contains(where: { $0.id == projectID }) { + selectedProjectID = projectID + } + dismissTransientPresentations() + Task { @MainActor in + await Task.yield() + openNewTaskOrProjectCreation(initialProjectID: projectID) + } + } + onNavigationRequestConsumed(navigationRequest.id) + } + + private func dismissTransientPresentations() { + showingNewTask = false + showingAddProject = false + showingEnvironments = false + showingSettings = false + renamingThread = nil + } + + private func projectMenuTitle(_ project: FeatureProject) -> String { + guard model.snapshot.environments.count > 1, + let environment = model.snapshot.environments.first(where: { + $0.id == project.environmentID + }) else { + return project.name + } + return "\(project.name) · \(environment.name)" + } +} + +private extension FeatureDraftAttachment { + var uploadValue: FeatureUploadAttachment { + FeatureUploadAttachment(self) + } +} + +struct HomePresentation { + let pinned: [FeatureThread] + let active: [FeatureThread] + let snoozed: [FeatureThread] + let settled: [FeatureThread] + let archived: [FeatureThread] + let searchResults: [FeatureThread] + let rowContexts: [String: HomeThreadRowContext] + + init( + snapshot: FeatureSnapshot, + query: String, + projectID: String?, + now: Date, + pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + ) { + let index = DailyUXSidebarIndex( + snapshot: snapshot, + query: "", + projectID: projectID, + now: now, + pullRequestsByThreadID: pullRequestsByThreadID + ) + let archived = snapshot.threads + .filter { thread in + thread.isArchived && (projectID == nil || thread.projectID == projectID) + } + .sorted { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.id < $1.id + } + + pinned = index.pinned + active = index.active + snoozed = index.snoozed + settled = index.settled + self.archived = archived + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + searchResults = normalizedQuery.isEmpty + ? [] + : DailyUXSidebarIndex.matchingThreads( + index.pinned + index.active + index.snoozed + index.settled + archived, + snapshot: snapshot, + query: normalizedQuery + ) + rowContexts = HomeThreadRowContext.index(snapshot: snapshot) + } +} + +@MainActor +private final class HomePresentationCache { + private struct Key: Equatable { + let revision: UInt64 + let query: String + let projectID: String? + let now: Date + } + + private var cachedKey: Key? + private var cachedPresentation: HomePresentation? + + func presentation( + snapshot: FeatureSnapshot, + revision: UInt64, + query: String, + projectID: String?, + now: Date, + pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] + ) -> HomePresentation { + let key = Key( + revision: revision, + query: query, + projectID: projectID, + now: now + ) + if cachedKey == key, let cachedPresentation { + return cachedPresentation + } + + let presentation = HomePresentation( + snapshot: snapshot, + query: query, + projectID: projectID, + now: max(now, .now), + pullRequestsByThreadID: pullRequestsByThreadID + ) + cachedKey = key + cachedPresentation = presentation + return presentation + } +} + +struct HomeShelfHeader: View { + let title: String + let count: Int + let isExpanded: Bool + let accent: Color? + + var body: some View { + HStack(spacing: 8) { + Text(count > 0 ? "\(title) (\(count))" : title) + .lineLimit(1) + Rectangle() + .fill((accent ?? T3Colors.textTertiary).opacity(accent == nil ? 0.16 : 0.24)) + .frame(height: 1) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(T3Typography.homeMetadata.weight(.bold)) + .foregroundStyle(accent ?? T3Colors.textTertiary) + .padding(.horizontal, 10) + .padding(.top, 4) + .frame(minHeight: 40) + .contentShape(Rectangle()) + } +} + +struct HomeThreadRowContext: Equatable { + var projectIcon: ProjectIconOverride? = nil + let projectName: String + let projectEnvironmentID: String? + let projectWorkspaceRoot: String? + let environmentLabel: String? + let providerID: String + let providerDriver: String + let providerName: String + let connectionState: FeatureConnection.State? + + static let fallback = HomeThreadRowContext( + projectName: "Project", + projectEnvironmentID: nil, + projectWorkspaceRoot: nil, + environmentLabel: nil, + providerID: "agent", + providerDriver: "", + providerName: "Agent", + connectionState: nil + ) + + var copyContext: ThreadCopyContext { + ThreadCopyContext( + projectName: projectWorkspaceRoot == nil ? nil : projectName, + projectWorkspaceRoot: projectWorkspaceRoot, + environmentName: environmentLabel, + environmentID: projectEnvironmentID + ) + } + + var providerLooksTerminal: Bool { + let normalized = [providerDriver, providerID, providerName] + .joined(separator: " ") + .lowercased() + return normalized.contains("codex") + || normalized.contains("cursor") + || normalized.contains("open") + } + + static func index(snapshot: FeatureSnapshot) -> [String: HomeThreadRowContext] { + let projectByID = snapshot.projects.reduce(into: [String: FeatureProject]()) { + $0[$1.id] = $1 + } + let projectGroupNameByID = DailyUXCreationContext.projectGroups(in: snapshot).reduce( + into: [String: String]() + ) { result, group in + for projectID in group.memberProjectIDs { + result[projectID] = group.name + } + } + let environmentByID = snapshot.environments.reduce(into: [String: FeatureEnvironment]()) { + $0[$1.id] = $1 + } + return snapshot.threads.reduce(into: [String: HomeThreadRowContext]()) { result, thread in + let project = projectByID[thread.projectID] + let normalizedThreadEnvironmentID = thread.environmentID? + .trimmingCharacters(in: .whitespacesAndNewlines) + let environmentID = normalizedThreadEnvironmentID?.isEmpty == false + ? normalizedThreadEnvironmentID + : project?.environmentID + let environment = environmentID.flatMap { environmentByID[$0] } + let environmentLabel = (environment?.name ?? thread.environmentName)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let explicitProvider = thread.providerName? + .trimmingCharacters(in: .whitespacesAndNewlines) + let configuredProvider = thread.providerID.flatMap { providerID in + environmentID.flatMap { + snapshot.providersByEnvironment?[$0]?.first(where: { $0.id == providerID }) + } + } + let providerName = (explicitProvider?.isEmpty == false ? explicitProvider : nil) + ?? configuredProvider?.name + ?? thread.providerID + ?? "Agent" + let providerID = thread.providerID ?? providerName + let providerDriver = configuredProvider?.driver ?? thread.providerID ?? "" + + let connectionState = environment?.isEnabled == false + ? FeatureConnection.State.disconnected + : environment?.connectionState + + result[thread.id] = HomeThreadRowContext( + projectIcon: project?.projectIcon, + projectName: projectGroupNameByID[thread.projectID] ?? project?.name ?? "Project", + projectEnvironmentID: project?.environmentID, + projectWorkspaceRoot: project?.path, + environmentLabel: environmentLabel?.isEmpty == false ? environmentLabel : nil, + providerID: providerID, + providerDriver: providerDriver, + providerName: providerName, + connectionState: connectionState + ) + } + } +} + +struct HomeThreadPullRequestPresentation: Equatable { + enum State: String { + case open + case merged + case closed + } + + let number: Int + let state: State + let updatedAt: Date? + + var label: String { "#\(number)" } + + var accessibilityLabel: String { + "Pull request #\(number), \(state.rawValue)" + } + + static func resolve( + thread: FeatureThread, + status: FeatureSourceControlStatus + ) -> Self? { + guard let branch = thread.branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty, + status.branch == branch, + let pullRequest = status.pullRequest, + let state = State(rawValue: pullRequest.state.lowercased()) else { + return nil + } + return Self( + number: pullRequest.number, + state: state, + updatedAt: parseDate(pullRequest.updatedAt) + ) + } + + static func resolve( + linkedPullRequest: ThreadLinkedPullRequest, + detail: PullRequestDetail + ) -> Self? { + guard detail.number == linkedPullRequest.number, + detail.repository.caseInsensitiveCompare(linkedPullRequest.repository) == .orderedSame, + let state = State(rawValue: detail.state.rawValue) else { + return nil + } + return Self( + number: detail.number, + state: state, + updatedAt: parseDate(detail.updatedAt) + ) + } + + private static func parseDate(_ value: String?) -> Date? { + guard let value else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } +} + +extension FeatureThread { + var pullRequestObservationIdentity: String? { + let environment = environmentID ?? "" + if let linkedPullRequest = effectivePullRequest { + return [ + id, + environment, + projectID, + linkedPullRequest.projectId, + linkedPullRequest.repository.lowercased(), + String(linkedPullRequest.number), + ].joined(separator: "\u{0}") + } + guard let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty else { + return nil + } + return [id, environment, projectID, worktreePath ?? "", branch] + .joined(separator: "\u{0}") + } +} + +struct FeatureThreadRow: View { + enum Style: Equatable { + case rich + case slim + } + + let thread: FeatureThread + private let context: HomeThreadRowContext + private let projectFaviconClient: (any FeatureClient)? + private let onPullRequestChange: (HomeThreadPullRequestPresentation?) -> Void + let isSelected: Bool + let style: Style + let now: Date + let allowsMultilineTitle: Bool + @State private var pullRequest: HomeThreadPullRequestPresentation? + + init( + thread: FeatureThread, + context: HomeThreadRowContext, + projectFaviconClient: (any FeatureClient)? = nil, + onPullRequestChange: @escaping (HomeThreadPullRequestPresentation?) -> Void = { _ in }, + isSelected: Bool = false, + style: Style = .rich, + now: Date = .now, + allowsMultilineTitle: Bool = false + ) { + self.thread = thread + self.context = context + self.projectFaviconClient = projectFaviconClient + self.onPullRequestChange = onPullRequestChange + self.isSelected = isSelected + self.style = style + self.now = now + self.allowsMultilineTitle = allowsMultilineTitle + } + + var body: some View { + row(at: now) + .accessibilityElement(children: .ignore) + .accessibilityLabel(thread.title) + .accessibilityValue(accessibilityValue(at: now)) + .accessibilityHint("Opens task") + .accessibilityIdentifier("thread-\(thread.id)") + .accessibilityAddTraits(isSelected ? .isSelected : []) + .task(id: pullRequestObservationID) { + await observePullRequest() + } + } + + @ViewBuilder + private func row(at now: Date) -> some View { + Group { + switch style { + case .rich: richRow(at: now) + case .slim: slimRow(at: now) + } + } + .contentShape(Rectangle()) + } + + private func richRow(at now: Date) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + projectBadge + Text(context.projectName) + .lineLimit(1) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 8) + status(at: now) + } + .font(T3Typography.homeMetadata.weight(.medium)) + .frame(minHeight: 20) + + Text(thread.title) + .font(T3Typography.homeTitle) + .tracking(-0.14) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(allowsMultilineTitle ? 2 : 1) + .padding(.top, 4) + + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .medium)) + Text(branchLabel) + .lineLimit(1) + if context.providerLooksTerminal { + Text(">_") + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(T3Colors.syntaxProperty) + } + Spacer(minLength: 8) + if let environmentLabel { + HStack(spacing: 4) { + Image(systemName: environmentIcon) + .font(.system(size: 9)) + Text(environmentLabel) + .lineLimit(1) + } + .foregroundStyle(environmentColor) + } + if let pullRequest { + pullRequestIndicator(pullRequest) + } + if thread.pinnedAt != nil { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + } + providerIcon(size: 16) + } + .font(T3Typography.homeMetadata) + .foregroundStyle(T3Colors.textTertiary) + .frame(minHeight: 20) + .padding(.top, 3) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(minHeight: 88) + .background( + isSelected ? T3Colors.subtleStrong : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + .padding(.horizontal, 8) + } + + private func slimRow(at now: Date) -> some View { + HStack(spacing: 9) { + projectBadge + .saturation(0) + .opacity(0.48) + Text(thread.title) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(allowsMultilineTitle ? 2 : 1) + Spacer(minLength: 8) + if let pullRequest { + pullRequestIndicator(pullRequest) + } + if thread.pinnedAt != nil { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + } + providerIcon(size: 15) + Text(SidebarRelativeAge.compact(since: thread.updatedAt, now: now)) + .font(T3Typography.homeMetadata.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 10) + .frame(minHeight: 44) + .padding(.horizontal, 8) + .background( + isSelected ? T3Colors.subtleStrong : Color.clear, + in: RoundedRectangle(cornerRadius: 7) + ) + } + + @ViewBuilder + private func status(at now: Date) -> some View { + HStack(spacing: 5) { + if let icon = statusIcon { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + } + Text(thread.homeRowStatusLabel(at: now)) + if let duration = thread.homeWorkingDuration(at: now) { + Text(duration) + .font(.system(.footnote, design: .monospaced, weight: .semibold)) + .monospacedDigit() + } + } + .font(T3Typography.status) + .foregroundStyle(statusColor) + } + + private var statusIcon: String? { + switch thread.homeStatus { + case .working: "circle.dotted" + case .failed: "exclamationmark.circle" + case .approval, .input, .monitoring, .done, .ready: nil + } + } + + private var statusColor: Color { + switch thread.homeStatus { + case .working: T3Colors.statusRunning + case .monitoring: T3Colors.statusRunning + case .approval: T3Colors.warning + case .input: T3Colors.statusInput + case .failed: T3Colors.danger + case .done, .ready: T3Colors.textTertiary + } + } + + private var environmentIcon: String { + switch context.connectionState { + case .connecting, .reconnecting: + "wifi" + case .disconnected: + "wifi.slash" + case .connected, nil: + "server.rack" + } + } + + private var environmentColor: Color { + switch context.connectionState { + case .connecting, .reconnecting: + T3Colors.warning.opacity(0.78) + case .disconnected: + T3Colors.danger.opacity(0.78) + case .connected, nil: + T3Colors.textTertiary + } + } + + private var isConnectionStale: Bool { + context.connectionState == .connecting + || context.connectionState == .reconnecting + || context.connectionState == .disconnected + } + + private var branchLabel: String { + if let branch = thread.branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty { + return branch + } + if let worktreePath = thread.worktreePath, + !worktreePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: worktreePath).lastPathComponent + } + return "workspace" + } + + private var environmentLabel: String? { + context.environmentLabel + } + + private var pullRequestObservationID: String? { + guard projectFaviconClient != nil else { return nil } + return thread.pullRequestObservationIdentity + } + + @MainActor + private func observePullRequest() async { + guard pullRequestObservationID != nil, + let projectFaviconClient else { + pullRequest = nil + onPullRequestChange(nil) + return + } + + if let linked = thread.effectivePullRequest, + let environmentID = thread.environmentID { + let target = FeaturePullRequestTarget( + environmentID: environmentID, + environmentName: thread.environmentName ?? environmentID, + reference: PullRequestRef( + projectId: linked.projectId, + repository: linked.repository, + number: linked.number + ) + ) + while !Task.isCancelled { + if let detail = try? await projectFaviconClient.pullRequestDetail(target), + let next = HomeThreadPullRequestPresentation.resolve( + linkedPullRequest: linked, + detail: detail + ), + next != pullRequest { + pullRequest = next + onPullRequestChange(next) + } + do { + try await Task.sleep(for: .seconds(30)) + } catch { + return + } + } + return + } + + for await status in projectFaviconClient.sourceControlStatusEvents(threadID: thread.id) { + guard !Task.isCancelled else { return } + let next = HomeThreadPullRequestPresentation.resolve( + thread: thread, + status: status + ) + guard next != pullRequest else { continue } + pullRequest = next + onPullRequestChange(next) + } + } + + private func pullRequestIndicator(_ pullRequest: HomeThreadPullRequestPresentation) -> some View { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 10, weight: .semibold)) + Text(pullRequest.label) + .font(T3Typography.homeMetadata.monospacedDigit().weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(pullRequestColor(pullRequest.state)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(pullRequest.accessibilityLabel) + } + + private func pullRequestColor(_ state: HomeThreadPullRequestPresentation.State) -> Color { + switch state { + case .open: T3Colors.success + case .merged: T3Colors.syntaxKeyword + case .closed: T3Colors.danger + } + } + + private var projectBadge: some View { + ProjectBadge( + name: context.projectName, + icon: context.projectIcon, + environmentID: context.projectEnvironmentID, + workspaceRoot: context.projectWorkspaceRoot, + client: projectFaviconClient + ) + } + + private func providerIcon(size: CGFloat) -> some View { + ProviderIcon( + driver: context.providerDriver, + providerID: context.providerID, + fallbackName: context.providerName, + size: size + ) + } + + private func accessibilityValue(at now: Date) -> String { + let status = thread.homeRowAccessibilityStatus(rich: style == .rich, at: now) + var values = [status, "Project \(context.projectName)"] + values.append("Harness \(context.providerName)") + if let duration = thread.homeWorkingDuration(at: now) { + values.append("for \(duration)") + } + values.append("Branch \(branchLabel)") + if let pullRequest { + values.append(pullRequest.accessibilityLabel) + } + if let environmentLabel { + values.append("on \(environmentLabel)") + } + if isConnectionStale { + values.append("last known state") + } + return values.joined(separator: ". ") + } + +} + +private struct ProjectBadge: View { + let name: String + let icon: ProjectIconOverride? + let environmentID: String? + let workspaceRoot: String? + let client: (any FeatureClient)? + @State private var favicon: UIImage? + + init( + name: String, + icon: ProjectIconOverride? = nil, + environmentID: String?, + workspaceRoot: String?, + client: (any FeatureClient)? + ) { + self.name = name + self.icon = icon + self.environmentID = environmentID + self.workspaceRoot = workspaceRoot + self.client = client + let initialKey = environmentID.flatMap { environmentID in + workspaceRoot.map { workspaceRoot in + FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ).fingerprint + } + } + _favicon = State(initialValue: initialKey.flatMap { + FeatureProjectFaviconImageCache.shared.image(for: $0) + }) + } + + var body: some View { + Group { + if let icon, icon.kind == "emoji", let emoji = icon.emoji { + Text(emoji).font(.system(size: 14)) + } else if let icon, icon.kind == "lucide" { + Image(systemName: ProjectIconPresentation.symbol(icon.name)) + .font(.system(size: 13)) + .foregroundStyle(ProjectIconPresentation.color(icon.color)) + } else if let favicon { + Image(uiImage: favicon) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 3)) + } else { + Text(label) + .font(.system(size: 8, weight: .heavy)) + .foregroundStyle(foreground) + .frame(width: 16, height: 16) + .background(background, in: RoundedRectangle(cornerRadius: 4)) + } + } + .frame(width: 16, height: 16) + .accessibilityHidden(true) + .task(id: faviconKey) { + await loadFavicon() + } + } + + private var faviconKey: String? { + guard let environmentID, let workspaceRoot else { return nil } + return FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ).fingerprint + } + + private func loadFavicon() async { + guard icon == nil else { return } + guard let environmentID, let workspaceRoot, let client, let faviconKey else { + return + } + favicon = FeatureProjectFaviconImageCache.shared.image(for: faviconKey) + if let cached = await client.cachedProjectFavicon( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) { + apply(cached, key: faviconKey) + } + guard !Task.isCancelled else { return } + if let refreshed = await client.refreshProjectFavicon( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) { + apply(refreshed, key: faviconKey) + } + } + + private func apply(_ data: Data, key: String) { + guard let image = UIImage(data: data) else { return } + FeatureProjectFaviconImageCache.shared.set(image, for: key) + favicon = image + } + + private var label: String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "?" } + if trimmed.lowercased().hasPrefix("t3") { return "T3" } + return String(trimmed.prefix(1)).uppercased() + } + + private var paletteIndex: Int { + if label == "T3" { return 0 } + return name.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % 4 } + } + + private var background: Color { + switch paletteIndex { + case 0: Color(red: 0.03, green: 0.24, blue: 0.21) + case 1: Color(red: 0.19, green: 0.13, blue: 0.37) + case 2: Color(red: 0.29, green: 0.18, blue: 0.02) + default: Color(red: 0.10, green: 0.18, blue: 0.34) + } + } + + private var foreground: Color { + switch paletteIndex { + case 0: Color(red: 0.78, green: 0.98, blue: 0.95) + case 1: Color(red: 0.93, green: 0.91, blue: 1) + case 2: Color(red: 1, green: 0.95, blue: 0.78) + default: Color(red: 0.82, green: 0.9, blue: 1) + } + } +} + +@MainActor +private final class FeatureProjectFaviconImageCache { + static let shared = FeatureProjectFaviconImageCache() + + private let images = NSCache() + + private init() { + images.countLimit = FeatureProjectFaviconStore.maximumEntryCount + } + + func image(for key: String) -> UIImage? { + images.object(forKey: key as NSString) + } + + func set(_ image: UIImage, for key: String) { + images.setObject(image, forKey: key as NSString) + } +} diff --git a/apps/swift-ios/README.md b/apps/swift-ios/README.md new file mode 100644 index 000000000000..ed759f7a7cdd --- /dev/null +++ b/apps/swift-ios/README.md @@ -0,0 +1,162 @@ +# T3 Code (SwiftUI) + +A native SwiftUI client for T3 Code. The project targets iOS 17 and later on +iPhone and iPad. It has its own bundle identifier and can be installed beside the +React Native T3 Code app. + +## Requirements + +- A current Xcode release with an iOS Simulator runtime. +- iOS 17 or later for physical-device builds. +- A T3 pairing URL for direct connections. T3 Connect builds additionally need + the cloud settings below. + +## Open + +Open `T3Code.xcodeproj`, choose the `T3Code` scheme, and run an installed iOS +Simulator. Xcode automatically includes files added below `App`, `Core`, +`Features`, `DesignSystem`, and `Resources`; `Info.plist` is the one resource +excluded from copying because it supplies the target's generated Info.plist. + +Pair with the same URL produced by a T3 server. The one-time pairing credential is +exchanged for an access token and stored in the Keychain. Environment metadata and +the active selection are stored separately in Application Support. + +## Structure + +- `App` owns the app lifecycle and the thin root composition seam. +- `Core` owns persistence, credentials, transport, and the T3 protocol. +- `Features` owns onboarding, environments, threads, messages, and settings. +- `DesignSystem` contains the small set of shared visual tokens. +- `Resources` contains the asset catalog. +- `Tests` covers pairing, wire contracts, persistence, and feature state changes. + +`RootView` deliberately accepts any SwiftUI content. Production composition injects +`FeatureRootView(client:)` there, keeping protocol adapters out of the UI shell. + +## Included + +- Local-network preflight, direct pairing links, QR scanning, token exchange, + Keychain credentials, saved environment management, and optional T3 Connect + account and relay discovery. +- A merged Web V2 home across saved environments, with per-device reachability, + collision-safe identities, last-known rows, live active-device updates, and + low-frequency passive refresh. +- Remote filesystem browsing, source discovery, repository cloning, project + creation, plus thread search, creation, rename, archive, restore, delete, + settle, and snooze. +- Provider/model selection, paginated synchronized conversation history, rich Markdown, + photo/camera/file image attachments, turn cancellation, approval decisions, and + structured user-input requests. +- Workspace files and previews, working-tree review, Git status and common actions, + plus Ghostty-rendered terminal sessions with VT/ANSI output, scrollback, hardware + and software keyboard controls, and per-thread session switching. +- Native settings with persisted appearance and behavior preferences, platform + deep links, shortcuts, background refresh, and notification routing. +- A Share extension that imports text, URLs, and images into persistent project + drafts, plus Home Screen widgets and aggregate Live Activities for active work. +- DPoP-bound T3 Connect sessions with account-scoped relay credentials, APNs + device registration on iOS 18+, and automatic credential recovery. + +The app speaks the existing HTTP and Effect RPC WebSocket contracts directly. It +does not embed a JavaScript runtime. + +## Build configuration + +The project expands these user-defined Xcode build settings into its generated +Info.plist: + +| Setting | Required | Purpose | +| ------------------------------ | --------------- | ----------------------------------------------- | +| `T3CODE_CLERK_PUBLISHABLE_KEY` | T3 Connect only | Clerk publishable key. | +| `T3CODE_CLERK_JWT_TEMPLATE` | No | Relay JWT template; defaults to `t3-relay`. | +| `T3CODE_RELAY_URL` | T3 Connect only | Relay base URL using HTTPS. | +| `DEVELOPMENT_TEAM` | Device/archive | Apple Developer team used by automatic signing. | +| `MARKETING_VERSION` | Release | User-facing version. | +| `CURRENT_PROJECT_VERSION` | Release | Monotonically increasing build number. | + +Unset T3 Connect values disable that connection method without affecting direct +pairing. Supply settings on the `xcodebuild` command line or through a local +`.xcconfig`; do not commit private release configuration. + +Debug and Release use separate identities so a local build can remain installed +beside TestFlight: + +| Configuration | Display name | Bundle identifier | URL scheme | +| ------------- | --------------- | -------------------------------- | -------------------- | +| Debug | T3 Swift Dev | `com.t3tools.t3code.swiftui.dev` | `t3code-swiftui-dev` | +| Release | T3 Code SwiftUI | `com.t3tools.t3code.swiftui` | `t3code-swiftui` | + +Each identity also has matching widget and share-extension bundle identifiers +and a separate App Group. Debug data and credentials therefore do not alter the +TestFlight installation. + +The registered Debug App Group is `group.com.t3tools.t3code.swiftui.debug`. +Its suffix differs from the `.dev` bundle identifier. Keep the host, widget, +share extension, and shared-container code on that same group. + +## Verify + +Run the `T3Code` scheme's tests in Xcode, or use the same entry point as CI. It +chooses an available iPhone from the newest installed Simulator runtime: + +```sh +./Scripts/ci-test.sh +``` + +Set `T3_SWIFT_SIMULATOR_ID` to pin a specific simulator. CI can invoke this same +entry point without duplicating the simulator-selection or signing policy. + +Contract fixtures are encoded from the TypeScript schemas and decoded by the +Swift test target. Regenerate and verify them after a relevant wire change: + +```sh +node scripts/generate-swift-wire-fixtures.ts +node scripts/generate-swift-wire-fixtures.ts --check +``` + +Pull requests that change `apps/swift-ios`, `packages/contracts`, or the fixture +generator run both checks in the path-gated SwiftUI workflow. + +## Install on a physical device + +Enable Developer Mode on the device, connect and trust the Mac, then find its +CoreDevice identifier or hardware UDID with +`xcrun devicectl list devices --columns UDID`. The script resolves either form to +the destination UDID expected by Xcode. Xcode must be signed into an Apple +Developer account for the requested team. + +```sh +T3_SWIFT_DEVICE_ID="DEVICE-IDENTIFIER" \ +T3_SWIFT_DEVELOPMENT_TEAM="TEAMID1234" \ +./Scripts/install-device.sh +``` + +The script builds, provisions, installs, and launches the Debug identity by +default. Set `T3_SWIFT_CONFIGURATION=Release` for the TestFlight identity. It +accepts the T3 Connect build settings above as environment variables. Optional +overrides are `T3_SWIFT_DERIVED_DATA_PATH`, `T3_SWIFT_VERSION`, and +`T3_SWIFT_BUILD_NUMBER`. Run with +`T3_SWIFT_VERIFY_BUNDLE_IDENTIFIERS_ONLY=1` to verify the configuration's host +and extension bundle identifiers without a device build. + +## Release checklist + +Use the [API-key TestFlight workflow](../../docs/operations/swiftui-testflight.md) +for uploads and beta distribution. It does not depend on a Safari login. + +1. Set a unique `MARKETING_VERSION` and a higher `CURRENT_PROJECT_VERSION`. +2. Confirm the production bundle identifier, display name, app icon, signing team, + and T3 Connect HTTPS relay configuration. +3. Run `./Scripts/ci-test.sh` and confirm the native test job is green. +4. Smoke-test direct URL and QR pairing, T3 Connect, multi-environment navigation, + task creation, follow-up messages, attachments, approvals, input requests, + background/reconnect behavior, and deep links on an iPhone and iPad. +5. Confirm the host, widget, and share-extension identifiers have App Group + provisioning, and the host has Push Notifications provisioning. Verify APNs + device registration and Share-extension handoff end to end. +6. Archive the `T3Code` scheme in Release, run Xcode's Validate App and privacy + report, and confirm `PrivacyInfo.xcprivacy` is bundled. Re-audit the manifest + whenever code adds a Required Reason API or data collection. +7. Confirm `ITSAppUsesNonExemptEncryption = NO` remains accurate, then distribute + an internal TestFlight build before App Store submission. diff --git a/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000000..b1e4d30e3c2b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "1.000", + "green": "0.520", + "red": "0.040" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 000000000000..6efba8682b15 Binary files /dev/null and b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..f01d4958bf8a --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/AppIconDev-1024.png b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/AppIconDev-1024.png new file mode 100644 index 000000000000..bd1b6801ec53 Binary files /dev/null and b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/AppIconDev-1024.png differ diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/Contents.json new file mode 100644 index 000000000000..7a0f3a741500 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIconDev-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/Contents.json new file mode 100644 index 000000000000..1a6d31b41272 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "github.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/github.svg b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/github.svg new file mode 100644 index 000000000000..478b49211cfd --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/github.svg @@ -0,0 +1,4 @@ + + GitHub + + diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/Contents.json new file mode 100644 index 000000000000..eed4f40a063f --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "google.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/google.svg b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/google.svg new file mode 100644 index 000000000000..65c447fbc11f --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/Contents.json new file mode 100644 index 000000000000..fc7ae4e5b13b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "microsoft.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/microsoft.svg b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/microsoft.svg new file mode 100644 index 000000000000..1e8f2fa88816 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/microsoft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/swift-ios/Resources/Assets.xcassets/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..74d6a722cf39 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/Contents.json new file mode 100644 index 000000000000..ad170a4dbdc1 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images": [{ "filename": "antigravity.png", "idiom": "universal" }], + "info": { "author": "xcode", "version": 1 } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/antigravity.png b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/antigravity.png new file mode 100644 index 000000000000..df1e22dbbd21 Binary files /dev/null and b/apps/swift-ios/Resources/Assets.xcassets/ProviderAntigravity.imageset/antigravity.png differ diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/Contents.json new file mode 100644 index 000000000000..eb825d22279c --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "claude.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/claude.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/claude.svg new file mode 100644 index 000000000000..324389017b5b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/Contents.json new file mode 100644 index 000000000000..46d892b7b03b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "cursor.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/cursor.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/cursor.svg new file mode 100644 index 000000000000..089d4676370b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/cursor.svg @@ -0,0 +1 @@ + diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/Contents.json new file mode 100644 index 000000000000..e7bc254a84b5 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "grok.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/grok.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/grok.svg new file mode 100644 index 000000000000..d094fcc6f853 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/grok.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/Contents.json new file mode 100644 index 000000000000..21dacb7d13cf --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "openai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/openai.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/openai.svg new file mode 100644 index 000000000000..b78a51db7bc6 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/Contents.json new file mode 100644 index 000000000000..bc6ee1e94730 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "opencode.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/opencode.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/opencode.svg new file mode 100644 index 000000000000..fc467bf84407 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Info.plist b/apps/swift-ios/Resources/Info.plist new file mode 100644 index 000000000000..89a3078888bf --- /dev/null +++ b/apps/swift-ios/Resources/Info.plist @@ -0,0 +1,58 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).refresh + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + T3 Code SwiftUI Routes + CFBundleURLSchemes + + $(T3CODE_URL_SCHEME) + + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + ts.net + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + NSMicrophoneUsageDescription + Allow T3 Code to use your microphone for voice input. + T3GitCommit + $(T3_GIT_COMMIT) + T3ConnectClerkJWTTemplate + $(T3CODE_CLERK_JWT_TEMPLATE) + T3ConnectClerkPublishableKey + $(T3CODE_CLERK_PUBLISHABLE_KEY) + T3ConnectRelayHTTPURL + $(T3CODE_RELAY_URL) + UIBackgroundModes + + fetch + remote-notification + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + diff --git a/apps/swift-ios/Resources/PrivacyInfo.xcprivacy b/apps/swift-ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000000..396aeea9d154 --- /dev/null +++ b/apps/swift-ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,21 @@ + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/apps/swift-ios/Scripts/ci-test.sh b/apps/swift-ios/Scripts/ci-test.sh new file mode 100755 index 000000000000..b5b4273993a1 --- /dev/null +++ b/apps/swift-ios/Scripts/ci-test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SIMULATOR_ID="${T3_SWIFT_SIMULATOR_ID:-}" +DERIVED_DATA_ROOT="${RUNNER_TEMP:-${APP_DIR}/.derivedData}" +DERIVED_DATA_PATH="${T3_SWIFT_DERIVED_DATA_PATH:-${DERIVED_DATA_ROOT}/swift-ios-ci}" + +die() { + printf '[swift-ios-ci] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +require_cmd awk +require_cmd xcodebuild +require_cmd xcrun + +if [[ -z "${SIMULATOR_ID}" ]]; then + # simctl groups devices by runtime. Keeping the last available iPhone picks + # the newest installed iOS runtime without coupling CI to a device model. + SIMULATOR_ID="$( + xcrun simctl list devices available \ + | awk ' + /^[[:space:]]+iPhone/ { + line = $0 + while (match(line, /\([[:xdigit:]-]+\)/)) { + value = substr(line, RSTART + 1, RLENGTH - 2) + if (value ~ /^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$/) { + candidate = value + } + line = substr(line, RSTART + RLENGTH) + } + } + END { print candidate } + ' + )" +fi + +[[ -n "${SIMULATOR_ID}" ]] || die "no available iPhone simulator was found" + +printf '[swift-ios-ci] Xcode: %s\n' "$(xcodebuild -version | tr '\n' ' ')" +printf '[swift-ios-ci] simulator: %s\n' "${SIMULATOR_ID}" + +xcodebuild test \ + -project "${APP_DIR}/T3Code.xcodeproj" \ + -scheme T3Code \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -maximum-concurrent-test-simulator-destinations 1 \ + -parallel-testing-enabled NO \ + -collect-test-diagnostics never \ + -test-timeouts-enabled YES \ + -default-test-execution-time-allowance 30 \ + -maximum-test-execution-time-allowance 60 \ + -only-testing:T3CodeTests \ + CODE_SIGNING_ALLOWED=NO diff --git a/apps/swift-ios/Scripts/install-device.sh b/apps/swift-ios/Scripts/install-device.sh new file mode 100755 index 000000000000..091d96365db7 --- /dev/null +++ b/apps/swift-ios/Scripts/install-device.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DEVICE_ID="${T3_SWIFT_DEVICE_ID:-${1:-}}" +DEVELOPMENT_TEAM="${T3_SWIFT_DEVELOPMENT_TEAM:-${2:-}}" +CONFIGURATION="${T3_SWIFT_CONFIGURATION:-Debug}" +DERIVED_DATA_PATH="${T3_SWIFT_DERIVED_DATA_PATH:-${APP_DIR}/.derivedData/device}" + +die() { + printf '[swift-ios-device] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +require_cmd awk +require_cmd mktemp +require_cmd plutil +require_cmd xcodebuild +require_cmd xcrun + +[[ "${CONFIGURATION}" == "Debug" || "${CONFIGURATION}" == "Release" ]] || die \ + "T3_SWIFT_CONFIGURATION must be Debug or Release" + +if [[ "${CONFIGURATION}" == "Debug" ]]; then + BUNDLE_IDENTIFIER="com.t3tools.t3code.swiftui.dev" +else + BUNDLE_IDENTIFIER="com.t3tools.t3code.swiftui" +fi +WIDGET_BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER}.widgets" +SHARE_BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER}.sharing" + +[[ -z "${T3_SWIFT_BUNDLE_IDENTIFIER:-}" || "${T3_SWIFT_BUNDLE_IDENTIFIER}" == "${BUNDLE_IDENTIFIER}" ]] || die \ + "custom bundle identifiers are unsupported; select the Debug or Release identity with T3_SWIFT_CONFIGURATION" + +bundle_identifier_for_target() { + local target="$1" + xcodebuild -showBuildSettings \ + -project "${APP_DIR}/T3Code.xcodeproj" \ + -target "${target}" \ + -configuration "${CONFIGURATION}" \ + | awk '$1 == "PRODUCT_BUNDLE_IDENTIFIER" && $2 == "=" { print $3; exit }' +} + +verify_bundle_identifiers() { + local host widgets share + host="$(bundle_identifier_for_target T3Code)" + widgets="$(bundle_identifier_for_target T3CodeWidgets)" + share="$(bundle_identifier_for_target T3CodeShare)" + [[ "${host}" == "${BUNDLE_IDENTIFIER}" ]] || die \ + "host bundle identifier resolved to '${host}'" + [[ "${widgets}" == "${WIDGET_BUNDLE_IDENTIFIER}" ]] || die \ + "widget bundle identifier resolved to '${widgets}'" + [[ "${share}" == "${SHARE_BUNDLE_IDENTIFIER}" ]] || die \ + "share bundle identifier resolved to '${share}'" + printf '[swift-ios-device] bundle identifiers: %s, %s, %s\n' \ + "${host}" "${widgets}" "${share}" +} + +if [[ "${T3_SWIFT_VERIFY_BUNDLE_IDENTIFIERS_ONLY:-0}" == "1" ]]; then + verify_bundle_identifiers + exit 0 +fi + +[[ -n "${DEVICE_ID}" ]] || die \ + "set T3_SWIFT_DEVICE_ID to a CoreDevice identifier or UDID from 'xcrun devicectl list devices --columns UDID'" +[[ -n "${DEVELOPMENT_TEAM}" ]] || die \ + "set T3_SWIFT_DEVELOPMENT_TEAM to your Apple Developer team ID" + +build_settings=( + "DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM}" +) + +DEVICE_JSON="$(mktemp -t t3-swift-devices.XXXXXX)" +trap 'unlink "${DEVICE_JSON}" 2>/dev/null || true' EXIT +xcrun devicectl list devices --json-output "${DEVICE_JSON}" --quiet >/dev/null +DESTINATION_ID="$( + xcrun swift "${SCRIPT_DIR}/resolve-device-udid.swift" "${DEVICE_JSON}" "${DEVICE_ID}" +)" || die "could not resolve device '${DEVICE_ID}' to an Xcode destination UDID" + +for key in \ + T3CODE_CLERK_PUBLISHABLE_KEY \ + T3CODE_CLERK_JWT_TEMPLATE \ + T3CODE_RELAY_URL; do + value="${!key:-}" + if [[ -n "${value}" ]]; then + build_settings+=("${key}=${value}") + fi +done + +if [[ -n "${T3_SWIFT_VERSION:-}" ]]; then + build_settings+=("MARKETING_VERSION=${T3_SWIFT_VERSION}") +fi +if [[ -n "${T3_SWIFT_BUILD_NUMBER:-}" ]]; then + build_settings+=("CURRENT_PROJECT_VERSION=${T3_SWIFT_BUILD_NUMBER}") +fi + +printf '[swift-ios-device] building %s for %s\n' "${BUNDLE_IDENTIFIER}" "${DESTINATION_ID}" +xcodebuild build \ + -project "${APP_DIR}/T3Code.xcodeproj" \ + -scheme T3Code \ + -configuration "${CONFIGURATION}" \ + -destination "platform=iOS,id=${DESTINATION_ID}" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration \ + "${build_settings[@]}" + +APP_PATH="${DERIVED_DATA_PATH}/Build/Products/${CONFIGURATION}-iphoneos/T3Code.app" +[[ -d "${APP_PATH}" ]] || die "built app was not found at ${APP_PATH}" + +actual_host="$(plutil -extract CFBundleIdentifier raw -o - "${APP_PATH}/Info.plist")" +actual_widgets="$( + plutil -extract CFBundleIdentifier raw -o - \ + "${APP_PATH}/PlugIns/T3CodeWidgets.appex/Info.plist" +)" +actual_share="$( + plutil -extract CFBundleIdentifier raw -o - \ + "${APP_PATH}/PlugIns/T3CodeShare.appex/Info.plist" +)" +[[ "${actual_host}" == "${BUNDLE_IDENTIFIER}" ]] || die \ + "built host bundle identifier is '${actual_host}'" +[[ "${actual_widgets}" == "${WIDGET_BUNDLE_IDENTIFIER}" ]] || die \ + "built widget bundle identifier is '${actual_widgets}'" +[[ "${actual_share}" == "${SHARE_BUNDLE_IDENTIFIER}" ]] || die \ + "built share bundle identifier is '${actual_share}'" + +printf '[swift-ios-device] installing %s\n' "${APP_PATH}" +xcrun devicectl device install app --device "${DESTINATION_ID}" "${APP_PATH}" + +printf '[swift-ios-device] launching %s\n' "${BUNDLE_IDENTIFIER}" +if ! launch_output="$( + xcrun devicectl device process launch \ + --device "${DESTINATION_ID}" \ + "${BUNDLE_IDENTIFIER}" 2>&1 +)"; then + printf '%s\n' "${launch_output}" >&2 + if [[ "${launch_output}" == *"BSErrorCodeDescription = Locked"* ]]; then + printf '[swift-ios-device] installed; unlock the device to launch the app\n' + exit 0 + fi + exit 1 +fi +printf '%s\n' "${launch_output}" diff --git a/apps/swift-ios/Scripts/resolve-device-udid.swift b/apps/swift-ios/Scripts/resolve-device-udid.swift new file mode 100644 index 000000000000..70d4d0315622 --- /dev/null +++ b/apps/swift-ios/Scripts/resolve-device-udid.swift @@ -0,0 +1,47 @@ +import Foundation + +private struct DeviceList: Decodable { + struct Result: Decodable { + struct Device: Decodable { + struct HardwareProperties: Decodable { + let udid: String? + } + + let identifier: String + let hardwareProperties: HardwareProperties? + } + + let devices: [Device] + } + + let result: Result +} + +guard CommandLine.arguments.count == 3 else { + FileHandle.standardError.write(Data("usage: resolve-device-udid \n".utf8)) + exit(64) +} + +do { + let payload = try JSONDecoder().decode( + DeviceList.self, + from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1])) + ) + let requested = CommandLine.arguments[2] + guard let udid = payload.result.devices.lazy.compactMap({ device -> String? in + guard let udid = device.hardwareProperties?.udid, !udid.isEmpty else { + return nil + } + let matchesIdentifier = device.identifier.caseInsensitiveCompare(requested) == .orderedSame + let matchesUDID = udid.caseInsensitiveCompare(requested) == .orderedSame + return matchesIdentifier || matchesUDID ? udid : nil + }).first else { + throw CocoaError(.fileNoSuchFile) + } + print(udid) +} catch { + FileHandle.standardError.write( + Data("Could not resolve that device identifier: \(error.localizedDescription)\n".utf8) + ) + exit(1) +} diff --git a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..e96fbb416205 --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj @@ -0,0 +1,999 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + B10000000000000000000001 /* ClerkKit in Frameworks */ = {isa = PBXBuildFile; productRef = B30000000000000000000001 /* ClerkKit */; }; + B10000000000000000000002 /* ClerkKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = B30000000000000000000002 /* ClerkKitUI */; }; + D10000000000000000000001 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D40000000000000000000001 /* GhosttyKit.xcframework */; }; + C10000000000000000000001 /* AgentActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000001 /* AgentActivityAttributes.swift */; }; + C10000000000000000000002 /* SharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000002 /* SharedContainer.swift */; }; + C10000000000000000000003 /* TaskWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000003 /* TaskWidgetSnapshot.swift */; }; + C10000000000000000000004 /* ShareInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000004 /* ShareInbox.swift */; }; + C10000000000000000000005 /* AgentActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000001 /* AgentActivityAttributes.swift */; }; + C10000000000000000000006 /* SharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000002 /* SharedContainer.swift */; }; + C10000000000000000000007 /* TaskWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000003 /* TaskWidgetSnapshot.swift */; }; + C10000000000000000000008 /* T3CodeWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000006 /* T3CodeWidgets.swift */; }; + C10000000000000000000009 /* AgentActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000007 /* AgentActivityWidget.swift */; }; + C1000000000000000000000A /* RecentTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000008 /* RecentTasksWidget.swift */; }; + C1000000000000000000000B /* SharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000002 /* SharedContainer.swift */; }; + C1000000000000000000000C /* ShareInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000004 /* ShareInbox.swift */; }; + C1000000000000000000000D /* SharePayloadLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4000000000000000000000B /* SharePayloadLoader.swift */; }; + C1000000000000000000000E /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4000000000000000000000C /* ShareViewController.swift */; }; + C1000000000000000000000F /* T3CodeWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = C4000000000000000000000F /* T3CodeWidgets.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C10000000000000000000010 /* T3CodeShare.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = C40000000000000000000010 /* T3CodeShare.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C10000000000000000000011 /* ExtensionContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000011 /* ExtensionContractTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CE0000000000000000000001 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C1000000000000000000000F /* T3CodeWidgets.appex in Embed App Extensions */, + C10000000000000000000010 /* T3CodeShare.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BE0000000000000000000001 /* Exceptions for "Resources" folder in "T3Code" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A50000000000000000000001 /* T3Code */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000001 /* App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = App; + sourceTree = ""; + }; + A10000000000000000000002 /* Core */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Core; + sourceTree = ""; + }; + A10000000000000000000003 /* Features */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Features; + sourceTree = ""; + }; + A10000000000000000000004 /* DesignSystem */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DesignSystem; + sourceTree = ""; + }; + A10000000000000000000005 /* Resources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BE0000000000000000000001 /* Exceptions for "Resources" folder in "T3Code" target */, + ); + path = Resources; + sourceTree = ""; + }; + A10000000000000000000006 /* Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Tests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A20000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000001 /* ClerkKit in Frameworks */, + B10000000000000000000002 /* ClerkKitUI in Frameworks */, + D10000000000000000000001 /* GhosttyKit.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A20000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C20000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C20000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + AC0000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = T3Code; + }; + CC0000000000000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C50000000000000000000003; + remoteInfo = T3CodeWidgets; + }; + CC0000000000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C50000000000000000000004; + remoteInfo = T3CodeShare; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXGroup section */ + A30000000000000000000001 = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* App */, + A10000000000000000000002 /* Core */, + A10000000000000000000003 /* Features */, + A10000000000000000000004 /* DesignSystem */, + A10000000000000000000005 /* Resources */, + A10000000000000000000006 /* Tests */, + C30000000000000000000001 /* Extensions */, + D40000000000000000000001 /* GhosttyKit.xcframework */, + A30000000000000000000002 /* Products */, + ); + sourceTree = ""; + }; + A30000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + A40000000000000000000001 /* T3 Code.app */, + A40000000000000000000002 /* T3CodeTests.xctest */, + C4000000000000000000000F /* T3CodeWidgets.appex */, + C40000000000000000000010 /* T3CodeShare.appex */, + ); + name = Products; + sourceTree = ""; + }; + C30000000000000000000001 /* Extensions */ = { + isa = PBXGroup; + children = ( + C30000000000000000000002 /* Shared */, + C30000000000000000000003 /* Widgets */, + C30000000000000000000004 /* Share */, + C30000000000000000000005 /* Tests */, + ); + path = Extensions; + sourceTree = ""; + }; + C30000000000000000000002 /* Shared */ = { + isa = PBXGroup; + children = ( + C40000000000000000000001 /* AgentActivityAttributes.swift */, + C40000000000000000000002 /* SharedContainer.swift */, + C40000000000000000000003 /* TaskWidgetSnapshot.swift */, + C40000000000000000000004 /* ShareInbox.swift */, + C40000000000000000000005 /* T3Code.entitlements */, + ); + path = Shared; + sourceTree = ""; + }; + C30000000000000000000003 /* Widgets */ = { + isa = PBXGroup; + children = ( + C40000000000000000000006 /* T3CodeWidgets.swift */, + C40000000000000000000007 /* AgentActivityWidget.swift */, + C40000000000000000000008 /* RecentTasksWidget.swift */, + C40000000000000000000009 /* Info.plist */, + C4000000000000000000000A /* T3CodeWidgets.entitlements */, + ); + path = Widgets; + sourceTree = ""; + }; + C30000000000000000000004 /* Share */ = { + isa = PBXGroup; + children = ( + C4000000000000000000000B /* SharePayloadLoader.swift */, + C4000000000000000000000C /* ShareViewController.swift */, + C4000000000000000000000D /* Info.plist */, + C4000000000000000000000E /* T3CodeShare.entitlements */, + ); + path = Share; + sourceTree = ""; + }; + C30000000000000000000005 /* Tests */ = { + isa = PBXGroup; + children = ( + C40000000000000000000011 /* ExtensionContractTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A50000000000000000000001 /* T3Code */ = { + isa = PBXNativeTarget; + buildConfigurationList = A60000000000000000000002 /* Build configuration list for PBXNativeTarget "T3Code" */; + buildPhases = ( + A70000000000000000000001 /* Sources */, + A20000000000000000000001 /* Frameworks */, + A80000000000000000000001 /* Resources */, + CE0000000000000000000001 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + CD0000000000000000000002 /* PBXTargetDependency */, + CD0000000000000000000003 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000001 /* App */, + A10000000000000000000002 /* Core */, + A10000000000000000000003 /* Features */, + A10000000000000000000004 /* DesignSystem */, + A10000000000000000000005 /* Resources */, + ); + name = T3Code; + packageProductDependencies = ( + B30000000000000000000001 /* ClerkKit */, + B30000000000000000000002 /* ClerkKitUI */, + ); + productName = T3Code; + productReference = A40000000000000000000001 /* T3 Code.app */; + productType = "com.apple.product-type.application"; + }; + A50000000000000000000002 /* T3CodeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A60000000000000000000003 /* Build configuration list for PBXNativeTarget "T3CodeTests" */; + buildPhases = ( + A70000000000000000000002 /* Sources */, + A20000000000000000000002 /* Frameworks */, + A80000000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AD0000000000000000000001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000006 /* Tests */, + ); + name = T3CodeTests; + packageProductDependencies = ( + ); + productName = T3CodeTests; + productReference = A40000000000000000000002 /* T3CodeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + C50000000000000000000003 /* T3CodeWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = C60000000000000000000004 /* Build configuration list for PBXNativeTarget "T3CodeWidgets" */; + buildPhases = ( + C70000000000000000000003 /* Sources */, + C20000000000000000000003 /* Frameworks */, + C80000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = T3CodeWidgets; + packageProductDependencies = ( + ); + productName = T3CodeWidgets; + productReference = C4000000000000000000000F /* T3CodeWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + C50000000000000000000004 /* T3CodeShare */ = { + isa = PBXNativeTarget; + buildConfigurationList = C60000000000000000000005 /* Build configuration list for PBXNativeTarget "T3CodeShare" */; + buildPhases = ( + C70000000000000000000004 /* Sources */, + C20000000000000000000004 /* Frameworks */, + C80000000000000000000004 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = T3CodeShare; + packageProductDependencies = ( + ); + productName = T3CodeShare; + productReference = C40000000000000000000010 /* T3CodeShare.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A90000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A50000000000000000000001 = { + CreatedOnToolsVersion = 16.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Push = { + enabled = 1; + }; + com.apple.SafariKeychain = { + enabled = 1; + }; + com.apple.SignInWithApple = { + enabled = 1; + }; + }; + }; + A50000000000000000000002 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = A50000000000000000000001; + }; + C50000000000000000000003 = { + CreatedOnToolsVersion = 16.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + }; + }; + C50000000000000000000004 = { + CreatedOnToolsVersion = 16.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = A60000000000000000000001 /* Build configuration list for PBXProject "T3Code" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A30000000000000000000001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A30000000000000000000002 /* Products */; + packageReferences = ( + B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + A50000000000000000000001 /* T3Code */, + A50000000000000000000002 /* T3CodeTests */, + C50000000000000000000003 /* T3CodeWidgets */, + C50000000000000000000004 /* T3CodeShare */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A80000000000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A80000000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C80000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C80000000000000000000004 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A70000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000001 /* AgentActivityAttributes.swift in Sources */, + C10000000000000000000002 /* SharedContainer.swift in Sources */, + C10000000000000000000003 /* TaskWidgetSnapshot.swift in Sources */, + C10000000000000000000004 /* ShareInbox.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A70000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000011 /* ExtensionContractTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C70000000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000005 /* AgentActivityAttributes.swift in Sources */, + C10000000000000000000006 /* SharedContainer.swift in Sources */, + C10000000000000000000007 /* TaskWidgetSnapshot.swift in Sources */, + C10000000000000000000008 /* T3CodeWidgets.swift in Sources */, + C10000000000000000000009 /* AgentActivityWidget.swift in Sources */, + C1000000000000000000000A /* RecentTasksWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C70000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C1000000000000000000000B /* SharedContainer.swift in Sources */, + C1000000000000000000000C /* ShareInbox.swift in Sources */, + C1000000000000000000000D /* SharePayloadLoader.swift in Sources */, + C1000000000000000000000E /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AD0000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* T3Code */; + targetProxy = AC0000000000000000000001 /* PBXContainerItemProxy */; + }; + CD0000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C50000000000000000000003 /* T3CodeWidgets */; + targetProxy = CC0000000000000000000002 /* PBXContainerItemProxy */; + }; + CD0000000000000000000003 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C50000000000000000000004 /* T3CodeShare */; + targetProxy = CC0000000000000000000003 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + AB0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AB0000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Release; + }; + AB0000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APS_ENVIRONMENT = development; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Extensions/Shared/T3Code.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 47; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "T3 Swift Dev"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes and attach photos to T3 Code tasks."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-lz", + "-framework", + IOSurface, + "-framework", + Metal, + "-framework", + MetalKit, + "-framework", + QuartzCore, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = group.com.t3tools.t3code.swiftui.debug; + T3CODE_URL_SCHEME = t3code-swiftui-dev; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AB0000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APS_ENVIRONMENT = production; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Extensions/Shared/T3Code.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 47; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "T3 Code SwiftUI"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes and attach photos to T3 Code tasks."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-lz", + "-framework", + IOSurface, + "-framework", + Metal, + "-framework", + MetalKit, + "-framework", + QuartzCore, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = group.com.t3tools.t3code.swiftui; + T3CODE_URL_SCHEME = t3code-swiftui; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AB0000000000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/T3Code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/T3Code"; + }; + name = Debug; + }; + AB0000000000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/T3Code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/T3Code"; + }; + name = Release; + }; + CB0000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Widgets/T3CodeWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 47; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Widgets/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.dev.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = group.com.t3tools.t3code.swiftui.debug; + T3CODE_WIDGET_DISPLAY_NAME = "T3 Swift Dev Widgets"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CB0000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Widgets/T3CodeWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 47; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Widgets/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = group.com.t3tools.t3code.swiftui; + T3CODE_WIDGET_DISPLAY_NAME = "T3 Code Widgets"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CB0000000000000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Share/T3CodeShare.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 47; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Share/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.dev.sharing; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = group.com.t3tools.t3code.swiftui.debug; + T3CODE_SHARE_DISPLAY_NAME = "T3 Swift Dev"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CB000000000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Share/T3CodeShare.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 47; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Share/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.sharing; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = group.com.t3tools.t3code.swiftui; + T3CODE_SHARE_DISPLAY_NAME = "T3 Code (SwiftUI)"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A60000000000000000000001 /* Build configuration list for PBXProject "T3Code" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000001 /* Debug */, + AB0000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A60000000000000000000002 /* Build configuration list for PBXNativeTarget "T3Code" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000003 /* Debug */, + AB0000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A60000000000000000000003 /* Build configuration list for PBXNativeTarget "T3CodeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000005 /* Debug */, + AB0000000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C60000000000000000000004 /* Build configuration list for PBXNativeTarget "T3CodeWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CB0000000000000000000007 /* Debug */, + CB0000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C60000000000000000000005 /* Build configuration list for PBXNativeTarget "T3CodeShare" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CB0000000000000000000009 /* Debug */, + CB000000000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/clerk/clerk-ios.git"; + requirement = { + kind = exactVersion; + version = 1.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B30000000000000000000001 /* ClerkKit */ = { + isa = XCSwiftPackageProductDependency; + package = B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */; + productName = ClerkKit; + }; + B30000000000000000000002 /* ClerkKitUI */ = { + isa = XCSwiftPackageProductDependency; + package = B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */; + productName = ClerkKitUI; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin PBXFileReference section */ + A40000000000000000000001 /* T3 Code.app */ = { + isa = PBXFileReference; + explicitFileType = wrapper.application; + includeInIndex = 0; + path = "T3 Code.app"; + sourceTree = BUILT_PRODUCTS_DIR; + }; + A40000000000000000000002 /* T3CodeTests.xctest */ = { + isa = PBXFileReference; + explicitFileType = wrapper.cfbundle; + includeInIndex = 0; + path = T3CodeTests.xctest; + sourceTree = BUILT_PRODUCTS_DIR; + }; + C40000000000000000000001 /* AgentActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityAttributes.swift; sourceTree = ""; }; + C40000000000000000000002 /* SharedContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedContainer.swift; sourceTree = ""; }; + C40000000000000000000003 /* TaskWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskWidgetSnapshot.swift; sourceTree = ""; }; + C40000000000000000000004 /* ShareInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInbox.swift; sourceTree = ""; }; + C40000000000000000000005 /* T3Code.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = T3Code.entitlements; sourceTree = ""; }; + C40000000000000000000006 /* T3CodeWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T3CodeWidgets.swift; sourceTree = ""; }; + C40000000000000000000007 /* AgentActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityWidget.swift; sourceTree = ""; }; + C40000000000000000000008 /* RecentTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentTasksWidget.swift; sourceTree = ""; }; + C40000000000000000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C4000000000000000000000A /* T3CodeWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = T3CodeWidgets.entitlements; sourceTree = ""; }; + C4000000000000000000000B /* SharePayloadLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePayloadLoader.swift; sourceTree = ""; }; + C4000000000000000000000C /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + C4000000000000000000000D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C4000000000000000000000E /* T3CodeShare.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = T3CodeShare.entitlements; sourceTree = ""; }; + C4000000000000000000000F /* T3CodeWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = T3CodeWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C40000000000000000000010 /* T3CodeShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = T3CodeShare.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C40000000000000000000011 /* ExtensionContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContractTests.swift; sourceTree = ""; }; + D40000000000000000000001 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + }; + rootObject = A90000000000000000000001 /* Project object */; +} diff --git a/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000000..828e8e704d0a --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "0da9fa23290c75a06cf2d88ffeab37061a648ea0656f8262ec633d415fa7466a", + "pins" : [ + { + "identity" : "clerk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/clerk/clerk-ios.git", + "state" : { + "revision" : "d0a5f2231dcb4b66e091a514ce2d0bead9056404", + "version" : "1.2.0" + } + }, + { + "identity" : "nuke", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Nuke.git", + "state" : { + "revision" : "30f7a7e72e0607d304fbf69c799474bd5fb6d1ce", + "version" : "13.2.0" + } + }, + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit", + "state" : { + "revision" : "169ab10234347fb19b37441f2867ace896a284b0", + "version" : "4.3.0" + } + } + ], + "version" : 3 +} diff --git a/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme b/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme new file mode 100644 index 000000000000..45680ddc0e70 --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift b/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift new file mode 100644 index 000000000000..20775900c61c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift @@ -0,0 +1,481 @@ +import XCTest +@testable import T3Code + +@MainActor +final class CoreContractTests: XCTestCase { + func testJSONValueRoundTripsLargeIntegersWithoutDoubleRounding() throws { + let signedData = Data("9007199254740993".utf8) + let signed = try JSONDecoder.t3.decode(JSONValue.self, from: signedData) + XCTAssertEqual(signed, .integer(9_007_199_254_740_993)) + XCTAssertEqual(try JSONEncoder.t3.encode(signed), signedData) + + let unsignedData = Data("18446744073709551615".utf8) + let unsigned = try JSONDecoder.t3.decode(JSONValue.self, from: unsignedData) + XCTAssertEqual(unsigned, .unsignedInteger(UInt64.max)) + XCTAssertEqual(try JSONEncoder.t3.encode(unsigned), unsignedData) + } + + func testDirectAndHostedPairingURLsResolveLikeExistingClients() throws { + let direct = try PairingURL.resolve("https://studio.example/pair#token=secret") + XCTAssertEqual(direct.credential, "secret") + XCTAssertEqual(direct.httpBaseURL.absoluteString, "https://studio.example/") + XCTAssertEqual(direct.webSocketBaseURL.absoluteString, "wss://studio.example/") + + let hosted = try PairingURL.resolve( + "https://app.t3.codes/pair?host=https%3A%2F%2Fremote.example#token=hosted-secret" + ) + XCTAssertEqual(hosted.credential, "hosted-secret") + XCTAssertEqual(hosted.httpBaseURL.absoluteString, "https://remote.example/") + XCTAssertEqual(hosted.webSocketBaseURL.absoluteString, "wss://remote.example/") + } + + func testShellSnapshotDecodesCurrentWireShape() throws { + let data = Data( + """ + { + "snapshotSequence": 7, + "projects": [{ + "id": "project-1", + "title": "T3 Code", + "workspaceRoot": "/work/t3", + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol" + }, + "scripts": [], + "createdAt": "2026-07-30T12:00:00.000Z", + "updatedAt": "2026-07-30T12:00:00.000Z" + }], + "threads": [], + "updatedAt": "2026-07-30T12:00:00.000Z" + } + """.utf8 + ) + + let snapshot = try JSONDecoder.t3.decode(OrchestrationShellSnapshot.self, from: data) + XCTAssertEqual(snapshot.snapshotSequence, 7) + XCTAssertEqual(snapshot.projects.first?.defaultModelSelection?.instanceId, "codex") + XCTAssertNil(snapshot.projects.first?.deletedAt) + } + + func testThreadPageMetadataDecodesCurrentWireShape() throws { + let page = try JSONDecoder.t3.decode( + OrchestrationThreadDetailPage.self, + from: Data( + #"{"beforeCursor":"opaque-cursor","hasMore":true,"snapshotSequence":42,"threadSequence":39}"#.utf8 + ) + ) + + XCTAssertEqual(page.beforeCursor, "opaque-cursor") + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.snapshotSequence, 42) + XCTAssertEqual(page.threadSequence, 39) + } + + func testServerConfigAdvertisesThreadSnapshotPagination() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data(#"{"providers":[],"threadSnapshotPagination":true}"#.utf8) + ) + + XCTAssertEqual(config.threadSnapshotPagination, true) + XCTAssertNil(config.environment) + } + + func testEnvironmentDescriptorRequiresExplicitAutomaticSettlementCapability() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + #"{"providers":[],"environment":{"environmentId":"wire-environment","label":"Studio","platform":{"os":"darwin","arch":"arm64"},"serverVersion":"1.0.0","capabilities":{"repositoryIdentity":true,"threadAutoSettlement":true}}}"#.utf8 + ) + ) + let supported = try XCTUnwrap(config.environment) + let absent = try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + #"{"environmentId":"older-server","label":"Old","platform":{"os":"darwin","arch":"arm64"},"serverVersion":"0.9.0","capabilities":{"repositoryIdentity":true}}"#.utf8 + ) + ) + + XCTAssertEqual(supported.capabilities.threadAutoSettlement, true) + XCTAssertNil(absent.capabilities.threadAutoSettlement) + } + + func testAutomaticSettlementSettingsPreserveNullAndApplyMissingDefaults() throws { + let missing = try JSONDecoder.t3.decode( + ServerSettingsSnapshot.self, + from: Data("{}".utf8) + ) + let explicit = try JSONDecoder.t3.decode( + ServerSettingsSnapshot.self, + from: Data( + #"{"sidebarAutoSettleOnMerge":false,"sidebarAutoSettleAfterDays":null}"#.utf8 + ) + ) + let fractional = try JSONDecoder.t3.decode( + ServerSettingsSnapshot.self, + from: Data(#"{"sidebarAutoSettleAfterDays":2.5}"#.utf8) + ) + + XCTAssertTrue(missing.sidebarAutoSettleOnMerge) + XCTAssertEqual(missing.sidebarAutoSettleAfterDays, 3) + XCTAssertFalse(explicit.sidebarAutoSettleOnMerge) + XCTAssertNil(explicit.sidebarAutoSettleAfterDays) + XCTAssertEqual(fractional.sidebarAutoSettleAfterDays, 2.5) + } + + func testAutomaticSettlementPatchesContainOnlyTheChangedKey() { + XCTAssertEqual( + ServerSettingsChange.sidebarAutoSettleOnMerge(false).jsonValue, + .object(["sidebarAutoSettleOnMerge": .bool(false)]) + ) + XCTAssertEqual( + ServerSettingsChange.sidebarAutoSettleAfterDays(4.5).jsonValue, + .object(["sidebarAutoSettleAfterDays": .number(4.5)]) + ) + XCTAssertEqual( + ServerSettingsChange.sidebarAutoSettleAfterDays(nil).jsonValue, + .object(["sidebarAutoSettleAfterDays": .null]) + ) + XCTAssertEqual(RPCMethod.serverUpdateSettings.rawValue, "server.updateSettings") + } + + func testCommandBuildersMatchOrchestrationContract() throws { + let model = ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") + let command = try OrchestrationCommands.createThread( + threadID: "thread-1", + projectID: "project-1", + title: "Native rebuild", + model: model, + runtimeMode: .fullAccess, + commandID: "command-1", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + XCTAssertEqual(command["type"]?.stringValue, "thread.create") + XCTAssertEqual(command["threadId"]?.stringValue, "thread-1") + XCTAssertEqual(command["modelSelection"]?["instanceId"]?.stringValue, "codex") + XCTAssertEqual(command["runtimeMode"]?.stringValue, "full-access") + + let pin = OrchestrationCommands.pin( + threadID: "thread-1", + pinned: true, + commandID: "command-pin" + ) + XCTAssertEqual(pin["type"]?.stringValue, "thread.pin") + XCTAssertEqual(pin["threadId"]?.stringValue, "thread-1") + + let unpin = OrchestrationCommands.pin( + threadID: "thread-1", + pinned: false, + commandID: "command-unpin" + ) + XCTAssertEqual(unpin["type"]?.stringValue, "thread.unpin") + } + + func testRegenerateTitleCommandMatchesOrchestrationContract() { + let command = OrchestrationCommands.regenerateTitle( + threadID: "thread-1", + commandID: "command-title" + ) + + XCTAssertEqual( + command, + .object([ + "type": .string("thread.meta.update"), + "commandId": .string("command-title"), + "threadId": .string("thread-1"), + "regenerateTitle": .bool(true), + ]) + ) + } + + func testFirstSendCommandCarriesCanonicalBootstrapMetadata() throws { + let model = ModelSelection(instanceId: "codex", model: "gpt-5.4") + let command = try OrchestrationCommands.createThreadAndSend( + threadID: "thread-first-send", + projectID: "project-1", + title: "Build the native app", + text: "Build the native app", + model: model, + runtimeMode: .fullAccess, + commandID: "command-first-send", + messageID: "message-first-send", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + XCTAssertEqual(command["type"]?.stringValue, "thread.turn.start") + XCTAssertEqual(command["titleSeed"]?.stringValue, "Build the native app") + XCTAssertEqual(command["modelSelection"]?["model"]?.stringValue, "gpt-5.4") + XCTAssertEqual( + command["bootstrap"]?["createThread"]?["projectId"]?.stringValue, + "project-1" + ) + XCTAssertEqual( + command["bootstrap"]?["createThread"]?["modelSelection"]?["instanceId"]?.stringValue, + "codex" + ) + } + + func testFirstSendCanPrepareAWorktreeBeforeDispatchingTheTurn() throws { + let command = try OrchestrationCommands.createThreadAndSend( + threadID: "thread-worktree", + projectID: "project-1", + title: "Build in isolation", + text: "Build in isolation", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + branch: "main", + worktreePreparation: ThreadWorktreePreparation( + projectCwd: "/work/t3", + baseBranch: "main", + branch: "t3code/deadbeef", + startFromOrigin: true + ) + ) + + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["projectCwd"]?.stringValue, + "/work/t3" + ) + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["baseBranch"]?.stringValue, + "main" + ) + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["branch"]?.stringValue, + "t3code/deadbeef" + ) + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["startFromOrigin"], + .bool(true) + ) + XCTAssertEqual(command["bootstrap"]?["runSetupScript"], .bool(true)) + } + + func testEnvironmentStorePersistsSelectionAndClearsRemovedActiveEnvironment() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-core-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let store = EnvironmentStore(fileURL: file) + let first = Environment( + id: "one", + label: "One", + httpBaseURL: URL(string: "https://one.example")!, + webSocketBaseURL: URL(string: "wss://one.example")! + ) + let second = Environment( + id: "two", + label: "Two", + httpBaseURL: URL(string: "https://two.example")!, + webSocketBaseURL: URL(string: "wss://two.example")! + ) + + try await store.save([first, second]) + try await store.setActiveEnvironment(id: second.id) + let selected = try await store.activeEnvironmentID() + XCTAssertEqual(selected, second.id) + + let remaining = try await store.remove(id: second.id) + let fallback = try await store.activeEnvironmentID() + XCTAssertEqual(remaining.map(\.id), [first.id]) + XCTAssertEqual(fallback, first.id) + } + + func testKeychainCredentialUpdatesAreAtomicAcrossStoreInstances() async throws { + let service = "codes.t3.swift-ios.credential-tests.\(UUID().uuidString)" + let environmentID = "shared-environment" + let backend = InMemoryKeychainCredentialBackend() + let first = KeychainCredentialStore(service: service, backend: backend) + let second = KeychainCredentialStore(service: service, backend: backend) + + do { + for index in 0..<12 { + let original = EnvironmentCredential(accessToken: "original-\(index)") + let replacement = EnvironmentCredential(accessToken: "replacement-\(index)") + try await first.setCredential(original, for: environmentID) + + async let replaced = first.replaceCredential( + replacement, + ifMatching: original, + for: environmentID + ) + async let removed = second.removeCredential( + ifMatching: original, + for: environmentID + ) + let (didReplace, didRemove) = try await (replaced, removed) + + XCTAssertNotEqual(didReplace, didRemove) + let stored = try await first.credential(for: environmentID) + XCTAssertEqual(stored, didReplace ? replacement : nil) + } + try await first.removeCredential(for: environmentID) + } catch { + try? await first.removeCredential(for: environmentID) + throw error + } + } + + func testRuntimeReplacesCachedClientWhenSavedEndpointChanges() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-runtime-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let store = EnvironmentStore(fileURL: file) + let first = Environment( + id: "same-server", + label: "Studio", + httpBaseURL: URL(string: "http://192.168.1.10:3773")!, + webSocketBaseURL: URL(string: "ws://192.168.1.10:3773")! + ) + let moved = Environment( + id: "same-server", + label: "Studio", + httpBaseURL: URL(string: "http://192.168.1.20:4773")!, + webSocketBaseURL: URL(string: "ws://192.168.1.20:4773")! + ) + try await store.save([first]) + try await store.setActiveEnvironment(id: first.id) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore() + ) + + let firstClientValue = try await runtime.activeClient() + let firstClient = try XCTUnwrap(firstClientValue) + try await store.upsert(moved) + let movedClientValue = try await runtime.activeClient() + let movedClient = try XCTUnwrap(movedClientValue) + let movedEnvironment = await movedClient.environment + + XCTAssertFalse(firstClient === movedClient) + XCTAssertEqual(movedEnvironment.httpBaseURL, moved.httpBaseURL) + XCTAssertEqual(movedEnvironment.webSocketBaseURL, moved.webSocketBaseURL) + } + + func testRuntimeRemovesCatalogEntryBeforeDestroyingCredential() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-removal-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = Environment( + id: "removable", + label: "Removable", + httpBaseURL: URL(string: "https://remove.example")!, + webSocketBaseURL: URL(string: "wss://remove.example")! + ) + let store = EnvironmentStore(fileURL: file) + try await store.save([environment]) + let credentials = RemovalOrderCredentialStore( + environmentStore: store, + environmentID: environment.id, + credential: EnvironmentCredential(accessToken: "secret") + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials + ) + + try await runtime.remove(id: environment.id) + + let catalogContainedEnvironment = await credentials.catalogContainedEnvironmentOnRemoval + XCTAssertEqual(catalogContainedEnvironment, false) + let remaining = try await store.load() + XCTAssertTrue(remaining.isEmpty) + let credential = await credentials.credential(for: environment.id) + XCTAssertNil(credential) + } +} + +private final class InMemoryKeychainCredentialBackend: @unchecked Sendable, + KeychainCredentialBackend +{ + private var credentials: [String: EnvironmentCredential] = [:] + + func credential(for environmentID: String) -> EnvironmentCredential? { + credentials[environmentID] + } + + func setCredential(_ credential: EnvironmentCredential, for environmentID: String) { + credentials[environmentID] = credential + } + + func removeCredential(for environmentID: String) { + credentials.removeValue(forKey: environmentID) + } +} + +private actor RemovalOrderCredentialStore: CredentialStore { + let environmentStore: EnvironmentStore + let environmentID: String + var storedCredential: EnvironmentCredential? + private(set) var catalogContainedEnvironmentOnRemoval: Bool? + + init( + environmentStore: EnvironmentStore, + environmentID: String, + credential: EnvironmentCredential + ) { + self.environmentStore = environmentStore + self.environmentID = environmentID + storedCredential = credential + } + + func credential(for environmentID: String) -> EnvironmentCredential? { + environmentID == self.environmentID ? storedCredential : nil + } + + func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + guard environmentID == self.environmentID else { return } + storedCredential = credential + } + + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + guard environmentID == self.environmentID else { return nil } + let previousCredential = storedCredential + storedCredential = credential + return previousCredential + } + + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard environmentID == self.environmentID, + storedCredential == expected else { return false } + storedCredential = credential + return true + } + + func removeCredential(for environmentID: String) async throws { + guard environmentID == self.environmentID else { return } + catalogContainedEnvironmentOnRemoval = try await environmentStore.load() + .contains(where: { $0.id == environmentID }) + storedCredential = nil + } + + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) async throws -> Bool { + guard environmentID == self.environmentID, + storedCredential == expected else { return false } + catalogContainedEnvironmentOnRemoval = try await environmentStore.load() + .contains(where: { $0.id == environmentID }) + guard storedCredential == expected else { return false } + storedCredential = nil + return true + } +} diff --git a/apps/swift-ios/Tests/CoreTests/EnvironmentConnectionStateTests.swift b/apps/swift-ios/Tests/CoreTests/EnvironmentConnectionStateTests.swift new file mode 100644 index 000000000000..c49e38450235 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/EnvironmentConnectionStateTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Environment connection state") +struct EnvironmentConnectionStateTests { + @Test + func oldEnvironmentRecordsDefaultToEnabled() throws { + let data = Data( + #"{"id":"studio","label":"Studio","httpBaseURL":"https://studio.example","webSocketBaseURL":"wss://studio.example/ws","kind":"bearer"}"#.utf8 + ) + + let environment = try JSONDecoder.t3.decode(Environment.self, from: data) + + #expect(environment.isEnabled) + } + + @Test + func oldFeatureEnvironmentRecordsDefaultToDirectAndEnabled() throws { + let data = Data( + #"{"id":"studio","name":"Studio","endpoint":"https://studio.example","isActive":true}"#.utf8 + ) + + let environment = try JSONDecoder.t3.decode(FeatureEnvironment.self, from: data) + + #expect(environment.isEnabled) + #expect(environment.source == .direct) + } + + @Test + func disablingLastUsedEnvironmentSelectsAnotherEnabledFallback() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("environment-enabled-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let store = EnvironmentStore(fileURL: directory.appendingPathComponent("environments.json")) + let studio = environment(id: "studio") + let laptop = environment(id: "laptop") + try await store.save([studio, laptop]) + try await store.setActiveEnvironment(id: studio.id) + + let afterStudio = try await store.setEnabled(id: studio.id, enabled: false) + + #expect(afterStudio.first(where: { $0.id == studio.id })?.isEnabled == false) + #expect(try await store.activeEnvironmentID() == laptop.id) + + _ = try await store.setEnabled(id: laptop.id, enabled: false) + #expect(try await store.activeEnvironmentID() == nil) + + _ = try await store.setEnabled(id: studio.id, enabled: true) + #expect(try await store.activeEnvironmentID() == nil) + #expect(try await store.load().first(where: { $0.id == studio.id })?.isEnabled == true) + } + + private func environment(id: String) -> Environment { + Environment( + id: id, + label: id.capitalized, + httpBaseURL: URL(string: "https://\(id).example")!, + webSocketBaseURL: URL(string: "wss://\(id).example/ws")! + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift new file mode 100644 index 000000000000..d2e347fe8ae2 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift @@ -0,0 +1,495 @@ +import XCTest +@testable import T3Code + +@MainActor +final class NativeContractExpansionTests: XCTestCase { + func testAdministrativeClientSessionContractsAndRequests() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential( + accessToken: "bearer", + scopes: ["access:read", "access:write"] + ), + ]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + let sessions = try await api.clientSessions(for: environment) + let revoked = try await api.revokeClientSession( + id: "session-2", + environment: environment + ) + let others = try await api.revokeOtherClientSessions(for: environment) + + XCTAssertEqual(sessions.first?.client.label, "Big O") + XCTAssertEqual(sessions.first?.client.deviceType, "mobile") + XCTAssertFalse(sessions.first?.current ?? true) + XCTAssertTrue(revoked.revoked) + XCTAssertEqual(others.revokedCount, 2) + + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url?.path }, [ + "/api/auth/clients", + "/api/auth/clients/revoke", + "/api/auth/clients/revoke-others", + ]) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "Bearer bearer" + }) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Accept-Encoding") == "gzip" + }) + let revokeBody = try JSONDecoder.t3.decode( + [String: String].self, + from: try XCTUnwrap(requests[1].httpBody) + ) + XCTAssertEqual(revokeBody, ["sessionId": "session-2"]) + } + + func testImageAttachmentBuildsExactTurnUploadShape() throws { + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + let command = try OrchestrationCommands.sendTurn( + threadID: "thread-1", + text: "What is in this image?", + runtimeMode: .fullAccess, + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + attachments: [image], + commandID: "command-1", + messageID: "message-1", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + guard case let .array(attachments)? = command["message"]?["attachments"] else { + return XCTFail("Expected an attachment array") + } + let attachment = try XCTUnwrap(attachments.first) + XCTAssertEqual(attachment["type"]?.stringValue, "image") + XCTAssertEqual(attachment["name"]?.stringValue, "screenshot.png") + XCTAssertEqual(attachment["mimeType"]?.stringValue, "image/png") + guard case let .number(sizeBytes)? = attachment["sizeBytes"] else { + return XCTFail("Expected numeric attachment size") + } + XCTAssertEqual(sizeBytes, 4) + XCTAssertEqual( + attachment["dataUrl"]?.stringValue, + "data:image/png;base64,iVBORw==" + ) + XCTAssertEqual(command["modelSelection"]?["instanceId"]?.stringValue, "codex") + } + + func testImageAttachmentRejectsOversizedInput() { + XCTAssertThrowsError( + try UploadChatImageAttachment( + data: Data(count: UploadChatImageAttachment.maximumBytes + 1), + name: "huge.png", + mimeType: "image/png" + ) + ) { error in + guard case ImageAttachmentError.tooLarge = error else { + return XCTFail("Expected size validation, got \(error)") + } + } + } + + func testUploadedImageAttachmentUsesPersistedReferenceInsteadOfInlineBytes() throws { + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + let command = try OrchestrationCommands.sendTurn( + threadID: "thread-1", + text: "Review the screenshot", + runtimeMode: .fullAccess, + attachments: [image], + uploadedAttachments: [image.uploadedJSONValue(id: "attachment-1")] + ) + + guard case let .array(attachments)? = command["message"]?["attachments"], + let attachment = attachments.first else { + return XCTFail("Expected an uploaded attachment reference") + } + XCTAssertEqual(attachment["id"]?.stringValue, "attachment-1") + XCTAssertEqual(attachment["mimeType"]?.stringValue, "image/png") + XCTAssertNil(attachment["dataUrl"]) + } + + func testSignedAttachmentUploadPostsImageBytesWithoutCredentials() async throws { + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: InMemoryCredentialStore()) + let data = Data([0x89, 0x50, 0x4e, 0x47]) + + try await api.uploadAttachment( + data, + mimeType: "image/png", + to: URL(string: "https://studio.example/api/attachments/upload/signed-token")! + ) + + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.httpBody, data) + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "image/png") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Length"), "4") + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + + func testEnvironmentDescriptorDecodesAttachmentUploadCapability() throws { + let descriptor = try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": { + "repositoryIdentity": true, + "attachmentUploads": true, + "fileAttachments": {"maxUploadBytes": 123456} + } + } + """.utf8 + ) + ) + + XCTAssertEqual(descriptor.capabilities.attachmentUploads, true) + XCTAssertEqual(descriptor.capabilities.fileAttachments?.maxUploadBytes, 123_456) + XCTAssertEqual(RPCMethod.attachmentsCreateUploadURL.rawValue, "attachments.createUploadUrl") + XCTAssertEqual(RPCMethod.attachmentsDelete.rawValue, "attachments.delete") + } + + func testCodexFeedbackContractMatchesTheServerRPC() throws { + let result = try JSONDecoder.t3.decode( + ProviderUploadFeedbackResult.self, + from: Data(#"{"feedbackId":"codex-thread-1"}"#.utf8) + ) + + XCTAssertEqual(result.feedbackId, "codex-thread-1") + XCTAssertEqual(RPCMethod.providerUploadFeedback.rawValue, "provider.uploadFeedback") + } + + func testAssetContractUsesExactTagsAndResultFields() throws { + XCTAssertEqual(RPCMethod.assetsCreateURL.rawValue, "assets.createUrl") + let legacyAttachment = AssetResource.attachment(id: "attachment-1").jsonValue + XCTAssertEqual( + legacyAttachment["_tag"]?.stringValue, + "attachment" + ) + XCTAssertNil(legacyAttachment["fileName"]) + XCTAssertNil(legacyAttachment["mimeType"]) + XCTAssertEqual( + AssetResource.attachment( + id: "attachment-2", + fileName: "report.pdf", + mimeType: "application/pdf" + ).jsonValue, + .object([ + "_tag": .string("attachment"), + "attachmentId": .string("attachment-2"), + "fileName": .string("report.pdf"), + "mimeType": .string("application/pdf"), + ]) + ) + XCTAssertEqual( + AssetResource.workspaceFile( + threadID: "thread-1", + path: "screenshots/app.png" + ).jsonValue["threadId"]?.stringValue, + "thread-1" + ) + XCTAssertEqual( + AssetResource.mediaFile( + threadID: "thread-2", + path: "uploads/report.pdf" + ).jsonValue, + .object([ + "_tag": .string("media-file"), + "threadId": .string("thread-2"), + "path": .string("uploads/report.pdf"), + ]) + ) + let result = try JSONDecoder.t3.decode( + AssetCreateURLResult.self, + from: Data( + """ + { + "relativeUrl": "/api/assets/signed/image.png", + "expiresAt": 1785466800000 + } + """.utf8 + ) + ) + XCTAssertEqual(result.relativeUrl, "/api/assets/signed/image.png") + XCTAssertEqual(result.expiresAt, 1_785_466_800_000) + XCTAssertEqual( + RPCMethod.reviewDiffFileContents.rawValue, + "review.getDiffFileContents" + ) + let contents = try JSONDecoder.t3.decode( + ReviewDiffFileContents.self, + from: Data(#"{"oldContents":"before\n","newContents":"after\n"}"#.utf8) + ) + XCTAssertEqual(contents.oldContents, "before\n") + XCTAssertEqual(contents.newContents, "after\n") + } + + func testServerConfigDecodesFullModelPickerCatalogue() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + """ + { + "settings": { + "defaultThreadEnvMode": "worktree", + "newWorktreesStartFromOrigin": false + }, + "providers": [{ + "instanceId": "codex-work", + "driver": "codex", + "displayName": "Codex", + "accentColor": "#10a37f", + "badgeLabel": "OpenAI", + "showInteractionModeToggle": true, + "requiresNewThreadForModelChange": false, + "enabled": true, + "installed": true, + "version": "1.2.3", + "status": "ready", + "auth": { + "status": "authenticated", + "type": "chatgpt", + "label": "ChatGPT", + "email": "theo@example.com" + }, + "checkedAt": "2026-07-30T12:00:00.000Z", + "availability": "available", + "models": [{ + "slug": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "shortName": "Sol", + "isCustom": false, + "isDefault": true, + "capabilities": { + "optionDescriptors": [{ + "id": "effort", + "type": "select", + "label": "Reasoning", + "description": "How hard the model thinks.", + "options": [{ + "id": "high", + "label": "High", + "isDefault": true + }], + "currentValue": "high" + }, { + "id": "fastMode", + "type": "boolean", + "label": "Fast mode", + "currentValue": true + }] + } + }, { + "slug": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "shortName": "Terra", + "isCustom": false, + "isDefault": false, + "isLegacy": true, + "capabilities": null + }], + "slashCommands": [{ + "name": "review", + "description": "Review the current changes", + "input": { "hint": "focus" } + }], + "skills": [{ + "name": "gh-fix-ci", + "description": "Fix CI failures", + "path": "/skills/gh-fix-ci/SKILL.md", + "scope": "user", + "enabled": true, + "displayName": "Fix CI", + "shortDescription": "Debug GitHub Actions" + }] + }] + } + """.utf8 + ) + ) + + let provider = try XCTUnwrap(config.providers.first) + XCTAssertEqual(provider.instanceId, "codex-work") + XCTAssertEqual(provider.auth.status, "authenticated") + XCTAssertEqual(provider.models.map(\.slug), ["gpt-5.6-sol", "gpt-5.6-terra"]) + XCTAssertEqual(provider.slashCommands?.first?.name, "review") + XCTAssertEqual(provider.slashCommands?.first?.input?.hint, "focus") + XCTAssertEqual(provider.skills?.first?.displayName, "Fix CI") + XCTAssertEqual(config.settings?.defaultThreadEnvMode, .worktree) + XCTAssertEqual(config.settings?.newWorktreesStartFromOrigin, false) + let model = try XCTUnwrap(provider.models.first) + XCTAssertEqual(model.slug, "gpt-5.6-sol") + XCTAssertNil(model.isLegacy) + XCTAssertEqual(provider.models[1].isLegacy, true) + let descriptors = try XCTUnwrap(model.capabilities?.optionDescriptors) + guard case let .select(effort) = descriptors[0], + case let .boolean(fastMode) = descriptors[1] + else { + return XCTFail("Expected typed select and boolean descriptors") + } + XCTAssertEqual(effort.options.first?.label, "High") + XCTAssertEqual(effort.currentValue, "high") + XCTAssertEqual(fastMode.currentValue, true) + } + + func testServerConfigSettingsUpdateDecodesEnvironmentPreferences() throws { + let event = try JSONDecoder.t3.decode( + ServerConfigStreamEvent.self, + from: Data( + """ + { + "version": 1, + "type": "settingsUpdated", + "payload": { + "settings": { + "defaultThreadEnvMode": "worktree", + "newWorktreesStartFromOrigin": false + } + } + } + """.utf8 + ) + ) + + guard case let .settingsUpdated(settings) = event else { + return XCTFail("Expected a settings update") + } + XCTAssertEqual(settings.defaultThreadEnvMode, .worktree) + XCTAssertFalse(settings.newWorktreesStartFromOrigin) + } + + func testProviderArraysDropOnlyUnknownProviderEntries() throws { + let providers = """ + [{ + "instanceId": "future-provider", + "driver": "future", + "enabled": true, + "installed": true, + "status": "ready", + "auth": { "status": "authenticated" }, + "checkedAt": "2026-08-04T12:00:00.000Z", + "models": [{ + "slug": "future-model", + "name": "Future", + "isCustom": false, + "capabilities": { + "optionDescriptors": [{ "type": "future-option" }] + } + }] + }, { + "instanceId": "codex", + "driver": "codex", + "enabled": true, + "installed": true, + "status": "ready", + "auth": { "status": "authenticated" }, + "checkedAt": "2026-08-04T12:00:00.000Z", + "models": [{ + "slug": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "isCustom": false, + "isLegacy": false + }] + }] + """ + let snapshot = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + """ + { + "providers": \(providers), + "settings": { + "defaultThreadEnvMode": "worktree", + "newWorktreesStartFromOrigin": false + } + } + """.utf8 + ) + ) + + XCTAssertEqual(snapshot.providers.map(\.instanceId), ["codex"]) + XCTAssertEqual(snapshot.settings?.defaultThreadEnvMode, .worktree) + + let event = try JSONDecoder.t3.decode( + ServerConfigStreamEvent.self, + from: Data( + """ + { + "type": "providerStatuses", + "payload": { "providers": \(providers) } + } + """.utf8 + ) + ) + guard case let .providerStatuses(decodedProviders) = event else { + return XCTFail("Expected provider statuses") + } + XCTAssertEqual(decodedProviders.map(\.instanceId), ["codex"]) + } +} + +private actor AccessHTTPTransport: HTTPTransport { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url?.path { + case "/api/auth/clients": + body = """ + [{ + "sessionId": "session-2", + "subject": "paired-client", + "scopes": ["orchestration:read"], + "method": "bearer-access-token", + "client": { + "label": "Big O", + "ipAddress": "192.168.1.10", + "deviceType": "mobile", + "os": "iOS" + }, + "issuedAt": "2026-07-30T12:00:00.000Z", + "expiresAt": "2026-08-30T12:00:00.000Z", + "lastConnectedAt": "2026-07-30T12:05:00.000Z", + "connected": true, + "current": false + }] + """ + case "/api/auth/clients/revoke": + body = #"{"revoked":true}"# + case "/api/auth/clients/revoke-others": + body = #"{"revokedCount":2}"# + default: + body = "{}" + } + return ( + Data(body.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift b/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift new file mode 100644 index 000000000000..ae831374bb4c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift @@ -0,0 +1,270 @@ +import XCTest +@testable import T3Code + +@MainActor +final class PairingServiceTests: XCTestCase { + func testPairingExchangesTokenAndPersistsSecretSeparately() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transport = PairingHTTPTransport() + let credentials = InMemoryCredentialStore() + let environments = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + let service = PairingService( + transport: transport, + environmentStore: environments, + credentialStore: credentials + ) + + let environment = try await service.pair( + url: "https://studio.example/#token=pair-once", + label: "Theo's iPhone" + ) + + XCTAssertEqual(environment.id, "environment-1") + let storedEnvironments = try await environments.load() + let activeID = try await environments.activeEnvironmentID() + let credential = await credentials.credential(for: "environment-1") + XCTAssertEqual(storedEnvironments.map(\.id), ["environment-1"]) + XCTAssertEqual(activeID, "environment-1") + XCTAssertEqual(credential?.accessToken, "access-token") + + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url?.path }, [ + "/.well-known/t3/environment", + "/oauth/token", + ]) + let form = String(data: requests[1].httpBody!, encoding: .utf8)! + XCTAssertTrue(form.contains("subject_token=pair-once")) + XCTAssertTrue(form.contains("client_device_type=mobile")) + XCTAssertTrue(form.contains("client_surface=mobile")) + XCTAssertTrue(form.contains("client_app_version=")) + // Omitting scope accepts the exact grant carried by the one-time link. + // Requesting administrative scopes consumes ordinary links and then + // fails with scope_not_granted. + XCTAssertFalse(form.contains("scope=")) + } + + func testFailedRepairRestoresTheExistingCredential() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-rollback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + let credentials = InMemoryCredentialStore(credentials: [ + "environment-1": EnvironmentCredential(accessToken: "previous-access-token"), + ]) + let environments = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + let service = PairingService( + transport: PairingHTTPTransport(), + environmentStore: environments, + credentialStore: credentials + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + do { + _ = try await service.pair( + url: "https://studio.example/#token=pair-once", + label: "Theo's iPhone" + ) + XCTFail("Pairing unexpectedly updated a read-only environment catalog") + } catch { + let restored = await credentials.credential(for: "environment-1") + XCTAssertEqual(restored?.accessToken, "previous-access-token") + } + } + + func testFailedRepairDoesNotOverwriteNewerCredential() async throws { + let credentials = InterleavedCredentialStore( + previousCredential: EnvironmentCredential(accessToken: "previous-access-token"), + newerCredential: EnvironmentCredential(accessToken: "newer-access-token") + ) + + try await assertFailedPairingPreservesNewerCredential(credentials) + } + + func testFailedFirstPairingDoesNotDeleteNewerCredential() async throws { + let credentials = InterleavedCredentialStore( + previousCredential: nil, + newerCredential: EnvironmentCredential(accessToken: "newer-access-token") + ) + + try await assertFailedPairingPreservesNewerCredential(credentials) + } + + func testFailedRepairRestoresCredentialRefreshedBeforeInstallation() async throws { + let credentials = InterleavedCredentialStore( + previousCredential: EnvironmentCredential(accessToken: "previous-access-token"), + newerCredential: EnvironmentCredential(accessToken: "newer-access-token"), + replacementTiming: .beforeInstallation + ) + + try await assertFailedPairingPreservesNewerCredential(credentials) + } + + private func assertFailedPairingPreservesNewerCredential( + _ credentials: InterleavedCredentialStore + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + let service = PairingService( + transport: PairingHTTPTransport(), + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: credentials + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + do { + _ = try await service.pair(url: "https://studio.example/#token=pair-once") + XCTFail("Pairing unexpectedly updated a read-only environment catalog") + } catch { + let stored = await credentials.credential(for: "environment-1") + XCTAssertEqual(stored?.accessToken, "newer-access-token") + } + } +} + +private actor InterleavedCredentialStore: CredentialStore { + enum ReplacementTiming { + case beforeInstallation + case afterInstallation + } + + private var storedCredential: EnvironmentCredential? + private let newerCredential: EnvironmentCredential + private let replacementTiming: ReplacementTiming + private var hasInsertedNewerCredential = false + + init( + previousCredential: EnvironmentCredential?, + newerCredential: EnvironmentCredential, + replacementTiming: ReplacementTiming = .afterInstallation + ) { + storedCredential = previousCredential + self.newerCredential = newerCredential + self.replacementTiming = replacementTiming + } + + func credential(for environmentID: String) -> EnvironmentCredential? { + let currentCredential = storedCredential + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + return currentCredential + } + + func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + storedCredential = credential + if replacementTiming == .afterInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + } + + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + let previousCredential = storedCredential + setCredential(credential, for: environmentID) + return previousCredential + } + + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = credential + return true + } + + func removeCredential(for environmentID: String) { + storedCredential = nil + } + + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = nil + return true + } +} + +private actor PairingHTTPTransport: HTTPTransport { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url?.path { + case "/.well-known/t3/environment": + body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """ + case "/oauth/token": + body = """ + { + "access_token": "access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "orchestration:read orchestration:operate" + } + """ + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "")") + body = "{}" + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (Data(body.utf8), response) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/ProviderSetupTests.swift b/apps/swift-ios/Tests/CoreTests/ProviderSetupTests.swift new file mode 100644 index 000000000000..fe69cfb1e0e8 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/ProviderSetupTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import T3Code + +struct ProviderSetupTests { + @Test func enabledPatchPreservesOtherInstancesAndConfiguration() { + let settings: JSONValue = .object([ + "providerInstances": .object([ + "other": .object(["driver": .string("codex")]), + "google-work": .object([ + "driver": .string("antigravity"), "displayName": .string("Work"), + "config": .object(["enabled": .bool(false), "gcpProject": .string("work")]), + ]), + ]), + ]) + let patch = ProviderSettingsPatch.enabled(settings: settings, instanceID: "google-work", driver: "antigravity", enabled: true) + #expect(patch["providerInstances"]?["other"] == settings["providerInstances"]?["other"]) + #expect(patch["providerInstances"]?["google-work"]?["displayName"] == .string("Work")) + #expect(patch["providerInstances"]?["google-work"]?["enabled"] == .bool(true)) + #expect(patch["providerInstances"]?["google-work"]?["config"]?["enabled"] == nil) + #expect(patch["providerInstances"]?["google-work"]?["config"]?["gcpProject"] == .string("work")) + } + + @Test func callbackIsSentOnlyWithTheMatchingFlow() { + let action = ProviderSetupAction.completeSignIn(flowID: "flow", callbackURL: "https://example.test/callback?code=test") + #expect(action.method == "provider.auth.complete") + #expect(action.payload(instanceID: "work")["flowId"] == .string("flow")) + #expect(action.payload(instanceID: "work")["instanceId"] == .string("work")) + #expect(ProviderSetupAction.signIn.payload(instanceID: "work")["callbackUrl"] == nil) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/PullRequestContractTests.swift b/apps/swift-ios/Tests/CoreTests/PullRequestContractTests.swift new file mode 100644 index 000000000000..e9a63c07745b --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/PullRequestContractTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import T3Code + +final class PullRequestContractTests: XCTestCase { + func testThreadLinkedPullRequestDecodesCurrentWireShape() throws { + let linked = try JSONDecoder.t3.decode( + ThreadLinkedPullRequest.self, + from: Data( + #"{"projectId":"project-1","repository":"pingdotgg/t3code","number":5178,"url":"https://github.com/pingdotgg/t3code/pull/5178"}"#.utf8 + ) + ) + + XCTAssertEqual(linked.projectId, "project-1") + XCTAssertEqual(linked.repository, "pingdotgg/t3code") + XCTAssertEqual(linked.number, 5178) + } + + func testListResultDecodesCurrentWireShape() throws { + let data = Data( + #""" + { + "viewers":{"github.com":"theo"}, + "providers":[{ + "host":"github.com","kind":"github","searchesOnHost":true, + "projectCount":1,"configured":true,"detail":null + }], + "entries":[{ + "provider":"github","host":"github.com","projectId":"project-1", + "projectTitle":"T3 Code","repository":"pingdotgg/t3code","number":5178, + "title":"Native SwiftUI app","url":"https://github.com/pingdotgg/t3code/pull/5178", + "author":{"login":"theo","name":"Theo","avatarUrl":null}, + "headBranch":"native","baseBranch":"main","state":"open","isDraft":false, + "mergeability":"mergeable","additions":20,"deletions":4, + "createdAt":"2026-08-18T12:00:00.000Z","updatedAt":"2026-08-18T13:00:00.000Z", + "viewerReviewRequested":false,"labels":[],"reviewDecision":"approved", + "checksState":"passing" + }], + "errors":[],"truncated":false,"nextCursors":{} + } + """#.utf8 + ) + + let result = try JSONDecoder.t3.decode(PullRequestListResult.self, from: data) + + XCTAssertEqual(result.entries.first?.number, 5178) + XCTAssertEqual(result.entries.first?.reviewDecision, .approved) + XCTAssertEqual(result.providers.first?.kind, .github) + } + + func testReferenceEncodesExactRpcPayload() throws { + let reference = PullRequestRef( + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 5178 + ) + + XCTAssertEqual( + try JSONValue.encode(reference), + .object([ + "projectId": .string("project-1"), + "repository": .string("pingdotgg/t3code"), + "number": .number(5178), + ]) + ) + } + + func testListPagesPreserveRowsAndAdvanceCursors() { + let first = PullRequestListResult( + viewers: ["github.com": "theo"], + providers: [], + entries: [], + errors: [], + truncated: true, + nextCursors: ["github.com t3/repo": "first"] + ) + let second = PullRequestListResult( + viewers: ["gitlab.com": "maintainer"], + providers: [], + entries: [], + errors: [], + truncated: false, + nextCursors: [:] + ) + + let combined = first.appending(second) + + XCTAssertEqual(combined.viewers["github.com"], "theo") + XCTAssertEqual(combined.viewers["gitlab.com"], "maintainer") + XCTAssertFalse(combined.truncated) + XCTAssertTrue(combined.nextCursors.isEmpty) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/ServerSharedPreferencesTests.swift b/apps/swift-ios/Tests/CoreTests/ServerSharedPreferencesTests.swift new file mode 100644 index 000000000000..b1cc10d63f73 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/ServerSharedPreferencesTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import T3Code + +final class ServerSharedPreferencesTests: XCTestCase { + func testMachineDefaultModelDecodesWithoutBecomingACrossMachinePreference() throws { + let selection = ModelSelection(instanceId: "claude-work", model: "claude-opus-5") + let settings = try JSONValue.object([ + "defaultModelSelection": try JSONValue.encode(selection), + ]).decode(ServerSettingsSnapshot.self) + XCTAssertEqual(settings.defaultModelSelection, selection) + XCTAssertNil(settings.sharedPatch["defaultModelSelection"]) + XCTAssertNil(try JSONValue.object([:]).decode(ServerSettingsSnapshot.self).defaultModelSelection) + } + + func testRestartContinuationDefaultsToOffOnOlderServers() throws { + let settings = try JSONDecoder.t3.decode(ServerSettingsSnapshot.self, from: Data("{}".utf8)) + let capabilities = try JSONDecoder.t3.decode( + EnvironmentDescriptor.Capabilities.self, + from: Data("{}".utf8) + ) + + XCTAssertFalse(settings.continueThreadsAfterServerUpdate) + XCTAssertFalse(ServerSettingsSnapshot().continueThreadsAfterServerUpdate) + XCTAssertNil(capabilities.threadRestartContinuation) + XCTAssertNil(capabilities.usageLimitSources) + } + + func testRestartContinuationDecodesAndRetainsItsSavedValue() throws { + for enabled in [true, false] { + let settings = try JSONValue.object([ + "continueThreadsAfterServerUpdate": .bool(enabled), + ]).decode(ServerSettingsSnapshot.self) + let capabilities = try JSONValue.object([ + "threadRestartContinuation": .bool(enabled), + "usageLimitSources": .bool(enabled), + ]).decode(EnvironmentDescriptor.Capabilities.self) + + XCTAssertEqual(settings.continueThreadsAfterServerUpdate, enabled) + XCTAssertEqual(capabilities.threadRestartContinuation, enabled) + XCTAssertEqual(capabilities.usageLimitSources, enabled) + let encoded = try JSONValue.encode(settings) + XCTAssertEqual(encoded["continueThreadsAfterServerUpdate"], .bool(enabled)) + } + } + + func testSharedPatchIncludesRestartContinuationOnlyWhenSupported() { + let settings = ServerSettingsSnapshot(continueThreadsAfterServerUpdate: true) + let legacyPatch = settings.sharedPatch(supportsRestartContinuation: false) + let supportedPatch = settings.sharedPatch(supportsRestartContinuation: true) + + XCTAssertNil(legacyPatch["continueThreadsAfterServerUpdate"]) + XCTAssertEqual(settings.sharedPatch, legacyPatch) + XCTAssertEqual(supportedPatch["continueThreadsAfterServerUpdate"], .bool(true)) + XCTAssertEqual(supportedPatch["defaultThreadEnvMode"], legacyPatch["defaultThreadEnvMode"]) + XCTAssertEqual(supportedPatch["sidebarAutoSettleOnMerge"], legacyPatch["sidebarAutoSettleOnMerge"]) + XCTAssertEqual( + ServerSettingsChange.continueThreadsAfterServerUpdate(false).jsonValue, + .object(["continueThreadsAfterServerUpdate": .bool(false)]) + ) + } + + func testRestartPreferenceDoesNotCauseMismatchesForUnsupportedTargets() { + let source = ServerSettingsSnapshot(continueThreadsAfterServerUpdate: true) + let target = ServerSettingsSnapshot(continueThreadsAfterServerUpdate: false) + + XCTAssertEqual( + source.sharedPatch(supportsRestartContinuation: false), + target.sharedPatch(supportsRestartContinuation: false) + ) + XCTAssertNotEqual( + source.sharedPatch(supportsRestartContinuation: true), + target.sharedPatch(supportsRestartContinuation: true) + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/SourceControlDiscoveryTests.swift b/apps/swift-ios/Tests/CoreTests/SourceControlDiscoveryTests.swift new file mode 100644 index 000000000000..b4d71dccc544 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/SourceControlDiscoveryTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Source control discovery contracts") +struct SourceControlDiscoveryTests { + @Test + func decodesEffectOptionsFromTheServerContract() throws { + let data = Data( + #""" + { + "versionControlSystems": [ + { + "kind": "git", + "label": "Git", + "executable": "git", + "implemented": true, + "status": "available", + "version": {"_id":"Option","_tag":"Some","value":"git 2.50"}, + "installHint": "Install Git", + "detail": {"_id":"Option","_tag":"None"} + } + ], + "sourceControlProviders": [ + { + "kind": "github", + "label": "GitHub", + "executable": "gh", + "status": "available", + "version": {"_id":"Option","_tag":"Some","value":"2.76"}, + "installHint": "Install gh", + "detail": {"_id":"Option","_tag":"None"}, + "auth": { + "status": "authenticated", + "account": {"_id":"Option","_tag":"Some","value":"octocat"}, + "host": {"_id":"Option","_tag":"Some","value":"github.com"}, + "detail": {"_id":"Option","_tag":"None"} + } + } + ] + } + """#.utf8 + ) + + let result = try JSONDecoder.t3.decode(SourceControlDiscoveryResult.self, from: data) + + #expect(result.versionControlSystems.first?.version == "git 2.50") + #expect(result.versionControlSystems.first?.detail == nil) + #expect(result.sourceControlProviders.first?.kind == .github) + #expect(result.sourceControlProviders.first?.auth.account == "octocat") + #expect(result.sourceControlProviders.first?.auth.host == "github.com") + #expect(result.sourceControlProviders.first?.auth.detail == nil) + #expect(RPCMethod.serverDiscoverSourceControl.rawValue == "server.discoverSourceControl") + } + + @Test + func toleratesMissingAndPlainOptionalStrings() throws { + let data = Data( + #""" + { + "versionControlSystems": [], + "sourceControlProviders": [ + { + "kind": "gitlab", + "label": "GitLab", + "status": "missing", + "version": null, + "installHint": "Install glab", + "detail": "Not installed", + "auth": { + "status": "unknown", + "account": null, + "host": null, + "detail": "Not installed" + } + } + ] + } + """#.utf8 + ) + + let result = try JSONDecoder.t3.decode(SourceControlDiscoveryResult.self, from: data) + let provider = try #require(result.sourceControlProviders.first) + + #expect(provider.status == .missing) + #expect(provider.version == nil) + #expect(provider.detail == "Not installed") + #expect(provider.auth.account == nil) + #expect(provider.auth.detail == "Not installed") + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift b/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift new file mode 100644 index 000000000000..05eb2b149ac5 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift @@ -0,0 +1,588 @@ +import XCTest +@testable import T3Code + +@MainActor +final class T3ClientServerConfigTests: XCTestCase { + func testUnknownUsageUnavailableReasonKeepsTheProviderInConfig() throws { + let config = try JSONDecoder.t3.decode(ServerConfigSnapshot.self, from: Data( + #""" + { + "providers": [{ + "instanceId": "codex-work", "driver": "codex", + "enabled": true, "installed": true, "status": "ready", + "auth": {"status": "authenticated"}, + "checkedAt": "2026-09-05T12:00:00.000Z", "models": [], + "usageLimits": { + "checkedAt": "2026-09-05T12:00:00.000Z", + "windows": [{"id":"primary","kind":"session","label":"Session","usedPercent":25}], + "unavailable": {"reason":"future-reason"} + } + }] + } + """#.utf8 + )) + + XCTAssertEqual(config.providers.map(\.instanceId), ["codex-work"]) + let limits = try XCTUnwrap(config.providers.first?.usageLimits) + XCTAssertEqual(limits.windows.map(\.id), ["primary"]) + XCTAssertNil(limits.unavailable) + } + + func testBootstrapAndListenerShareSubscriptionThenReplayFoldedConfig() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + let events = await client.serverConfigEvents() + async let bootstrap = client.serverConfig() + var iterator = events.makeAsyncIterator() + + guard case let .snapshot(first)? = try await iterator.next() else { + return XCTFail("Expected the subscription snapshot.") + } + XCTAssertEqual(first.threadSnapshotPagination, true) + let bootstrapped = try await bootstrap + XCTAssertEqual(bootstrapped.providers.first?.instanceId, "codex-old") + XCTAssertEqual(bootstrapped.providers.first?.usageLimits?.windows.map(\.id), ["primary"]) + XCTAssertEqual(bootstrapped.environment?.capabilities.usageLimitSources, true) + let initialTags = await connection.tags() + XCTAssertEqual(initialTags, ["subscribeServerConfig"]) + let subscriptionPayloads = await connection.payloads(for: "subscribeServerConfig") + XCTAssertEqual(subscriptionPayloads, [.object(["usageLimitSources": .bool(true)])]) + + try await connection.pushUsageLimitSources(ids: ["proxy"]) + guard case let .usageLimitSourcesUpdated(sources)? = try await iterator.next() else { + return XCTFail("Expected the usage limit source update.") + } + XCTAssertEqual(sources.map(\.id), ["proxy"]) + + try await connection.pushProviderStatus(id: "codex-new") + guard case .providerStatuses? = try await iterator.next() else { + return XCTFail("Expected the provider status delta.") + } + let folded = try await client.serverConfig() + XCTAssertEqual(folded.providers.first?.instanceId, "codex-new") + XCTAssertEqual(folded.settings?.newWorktreesStartFromOrigin, false) + XCTAssertEqual(folded.threadSnapshotPagination, true) + XCTAssertEqual(folded.threadResumeCompletionMarker, true) + XCTAssertEqual(folded.environment?.environmentId, "environment-1") + XCTAssertEqual(folded.usageLimitSources, sources) + + try await connection.pushSettings(continueAfterUpdate: true) + guard case .settingsUpdated? = try await iterator.next() else { + return XCTFail("Expected the settings update.") + } + let updated = try await client.serverConfig() + XCTAssertEqual(updated.settings?.continueThreadsAfterServerUpdate, true) + XCTAssertEqual(updated.usageLimitSources, sources) + + let replay = await client.serverConfigEvents() + var replayIterator = replay.makeAsyncIterator() + guard case let .snapshot(replayed)? = try await replayIterator.next() else { + return XCTFail("Expected a cached snapshot replay.") + } + XCTAssertEqual(replayed, updated) + let replayTags = await connection.tags() + XCTAssertEqual(replayTags, ["subscribeServerConfig"]) + await client.disconnect() + } + + func testSourceUpdatesReplaceTheFullSetIncludingEmpty() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + var iterator = await client.serverConfigEvents().makeAsyncIterator() + _ = try await iterator.next() + + try await connection.pushUsageLimitSources(ids: ["first", "second"], includeFutureSource: true) + guard case let .usageLimitSourcesUpdated(initial)? = try await iterator.next() else { + return XCTFail("Expected the initial sources.") + } + XCTAssertEqual(initial.map(\.id), ["first", "second"]) + + try await connection.pushUsageLimitSources(ids: ["replacement"]) + _ = try await iterator.next() + let replaced = try await client.serverConfig() + XCTAssertEqual(replaced.usageLimitSources.map(\.id), ["replacement"]) + + try await connection.pushUsageLimitSources(ids: []) + guard case let .usageLimitSourcesUpdated(removed)? = try await iterator.next() else { + return XCTFail("Expected the empty source update.") + } + XCTAssertTrue(removed.isEmpty) + let cleared = try await client.serverConfig() + XCTAssertTrue(cleared.usageLimitSources.isEmpty) + + var replay = await client.serverConfigEvents().makeAsyncIterator() + guard case let .snapshot(replayed)? = try await replay.next() else { + return XCTFail("Expected the cached config.") + } + XCTAssertTrue(replayed.usageLimitSources.isEmpty) + await client.disconnect() + } + + func testCapableSnapshotPreservesSourcesUntilAnExplicitUpdate() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + var iterator = await client.serverConfigEvents().makeAsyncIterator() + _ = try await iterator.next() + try await connection.pushUsageLimitSources(ids: ["proxy"]) + _ = try await iterator.next() + + try await connection.pushSnapshot(id: "refreshed") + guard case let .snapshot(snapshot)? = try await iterator.next() else { + return XCTFail("Expected the replacement snapshot.") + } + XCTAssertEqual(snapshot.providers.first?.instanceId, "refreshed") + XCTAssertEqual(snapshot.usageLimitSources.map(\.id), ["proxy"]) + let cached = try await client.serverConfig() + XCTAssertEqual(cached, snapshot) + await client.disconnect() + } + + func testReconnectToOlderServerClearsSources() async throws { + let original = ServerConfigTestConnection(mode: .snapshot) + let older = ServerConfigTestConnection(mode: .snapshot, supportsUsageLimitSources: nil) + let client = makeClient(connection: original, reconnectConnection: older) + var iterator = await client.serverConfigEvents().makeAsyncIterator() + _ = try await iterator.next() + try await original.pushUsageLimitSources(ids: ["proxy"]) + _ = try await iterator.next() + let originalConnectionID = await client.currentConnectionID() + XCTAssertNotNil(originalConnectionID) + + await client.reconnect() + guard case let .snapshot(snapshot)? = try await iterator.next() else { + return XCTFail("Expected the older server's snapshot.") + } + XCTAssertNil(snapshot.environment?.capabilities.usageLimitSources) + XCTAssertTrue(snapshot.usageLimitSources.isEmpty) + let cached = try await client.serverConfig() + XCTAssertEqual(cached, snapshot) + let currentConnectionID = await client.currentConnectionID() + XCTAssertNotNil(currentConnectionID) + XCTAssertNotEqual(currentConnectionID, originalConnectionID) + let payloads = await older.payloads(for: "subscribeServerConfig") + XCTAssertEqual(payloads, [.object(["usageLimitSources": .bool(true)])]) + await client.disconnect() + } + + func testLateSourceUpdatesAreNotPublishedWithoutTheCapability() async throws { + let unsupportedCapabilities: [Bool?] = [nil, false] + for capability in unsupportedCapabilities { + let connection = ServerConfigTestConnection( + mode: .snapshot, + supportsUsageLimitSources: capability + ) + let client = makeClient(connection: connection) + var iterator = await client.serverConfigEvents().makeAsyncIterator() + _ = try await iterator.next() + + try await connection.pushUsageLimitSources(ids: ["late-source"]) + try await connection.pushProviderStatus(id: "after-source-update") + guard case let .providerStatuses(providers)? = try await iterator.next() else { + await client.disconnect() + return XCTFail("Unsupported source updates must not reach listeners.") + } + XCTAssertEqual(providers.first?.instanceId, "after-source-update") + let config = try await client.serverConfig() + XCTAssertTrue(config.usageLimitSources.isEmpty) + await client.disconnect() + } + } + + func testRefreshPreservesSourceAndSettingsUpdatesReceivedDuringTheRequest() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + var iterator = await client.serverConfigEvents().makeAsyncIterator() + _ = try await iterator.next() + try await connection.pushUsageLimitSources(ids: ["original"]) + _ = try await iterator.next() + + let refresh = Task { + try await client.refreshProviders(cwd: "/repo", instanceID: "codex-old", refreshModels: false) + } + await connection.waitForRequestCount(2) + try await connection.pushUsageLimitSources(ids: ["latest"]) + _ = try await iterator.next() + try await connection.pushSettings(continueAfterUpdate: true) + _ = try await iterator.next() + try await connection.finishRefresh(id: "codex-refreshed") + + let refreshed = try await refresh.value + XCTAssertEqual(refreshed.providers.first?.instanceId, "codex-refreshed") + XCTAssertEqual(refreshed.usageLimitSources.map(\.id), ["latest"]) + XCTAssertEqual(refreshed.settings?.continueThreadsAfterServerUpdate, true) + XCTAssertEqual(refreshed.threadSnapshotPagination, true) + XCTAssertEqual(refreshed.threadResumeCompletionMarker, true) + XCTAssertEqual(refreshed.environment?.environmentId, "environment-1") + guard case let .snapshot(emitted)? = try await iterator.next() else { + return XCTFail("Expected the refreshed config.") + } + XCTAssertEqual(emitted, refreshed) + let payloads = await connection.payloads(for: "server.refreshProviders") + XCTAssertEqual(payloads, [.object([ + "refreshModels": .bool(false), + "cwd": .string("/repo"), + "instanceId": .string("codex-old"), + ])]) + await client.disconnect() + } + + func testResetCreditUsesTheSelectedInstance() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + let result = try await client.consumeResetCredit(instanceID: "codex-work") + XCTAssertEqual(result.outcome, .reset) + let payloads = await connection.payloads(for: "provider.consumeResetCredit") + XCTAssertEqual(payloads, [.object(["instanceId": .string("codex-work")])]) + let tags = await connection.tags() + XCTAssertEqual(tags, ["provider.consumeResetCredit"]) + await client.disconnect() + } + + func testResetCreditIsNotReplayedAfterASocketFailure() async throws { + let original = ServerConfigTestConnection(mode: .snapshot, failResetCreditSend: true) + let recovered = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: original, reconnectConnection: recovered) + var iterator = await client.serverConfigEvents().makeAsyncIterator() + _ = try await iterator.next() + + do { + _ = try await client.consumeResetCredit(instanceID: "codex-work") + XCTFail("An uncertain reset must fail without retrying.") + } catch let error as RPCError { + guard case .disconnected = error else { + await client.disconnect() + return XCTFail("Unexpected reset error: \(error)") + } + } + + guard case .snapshot? = try await iterator.next() else { + return XCTFail("Expected the config subscription to reconnect.") + } + let originalTags = await original.tags() + XCTAssertEqual(originalTags, ["subscribeServerConfig", "provider.consumeResetCredit"]) + let recoveredTags = await recovered.tags() + XCTAssertEqual(recoveredTags, ["subscribeServerConfig"]) + await client.disconnect() + } + + func testDisconnectCancelsPendingBootstrapAndRejectsStaleStreamCallbacks() async throws { + let connection = ServerConfigTestConnection(mode: .silent) + let client = makeClient(connection: connection) + let pending = Task { try await client.serverConfig() } + await connection.waitForRequestCount(1) + await client.disconnect() + + do { + _ = try await pending.value + XCTFail("Disconnect must finish the pending bootstrap.") + } catch let error as RPCError { + guard case .disconnected = error else { + return XCTFail("Unexpected error: \(error)") + } + } + try await connection.pushSnapshot(id: "stale") + let tags = await connection.tags() + XCTAssertEqual(tags, ["subscribeServerConfig"]) + } + + func testSilentSubscriptionBootstrapTimesOutAndCancellationDoesNotLeaveAWaiter() async { + let connection = ServerConfigTestConnection(mode: .silent) + let client = makeClient(connection: connection, waitTimeout: .milliseconds(20)) + let cancelled = Task { try await client.serverConfig() } + await connection.waitForRequestCount(1) + cancelled.cancel() + do { + _ = try await cancelled.value + XCTFail("Cancellation must finish the bootstrap wait.") + } catch is CancellationError { + } catch { + XCTFail("Unexpected cancellation error: \(error)") + } + + do { + _ = try await client.serverConfig() + XCTFail("A silent config subscription must have a bounded wait.") + } catch let error as RPCError { + guard case .responseTimedOut = error else { + await client.disconnect() + return XCTFail("Unexpected error: \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + await client.disconnect() + } + + func testOnlyExplicitUnsupportedMethodFallsBackToUnaryConfig() async throws { + let unsupported = ServerConfigTestConnection( + mode: .failure("Unsupported method subscribeServerConfig") + ) + let legacyClient = makeClient(connection: unsupported) + let legacyConfig = try await legacyClient.serverConfig() + XCTAssertEqual(legacyConfig.providers.first?.instanceId, "codex-old") + let unsupportedTags = await unsupported.tags() + XCTAssertEqual(unsupportedTags, ["subscribeServerConfig", "server.getConfig"]) + await legacyClient.disconnect() + + let auth = ServerConfigTestConnection( + mode: .failure("Unsupported authentication scheme for subscribeServerConfig") + ) + let authClient = makeClient(connection: auth) + do { + _ = try await authClient.serverConfig() + XCTFail("Authentication errors must not use the legacy config fallback.") + } catch let error as RPCError { + guard case .remote = error else { return XCTFail("Unexpected error: \(error)") } + } + let authTags = await auth.tags() + XCTAssertEqual(authTags, ["subscribeServerConfig"]) + await authClient.disconnect() + } + + private func makeClient( + connection: ServerConfigTestConnection, + reconnectConnection: ServerConfigTestConnection? = nil, + waitTimeout: Duration = .seconds(4) + ) -> T3Client { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + return T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "token"), + ]), + httpTransport: ServerConfigTicketTransport(), + webSocketConnector: ServerConfigTestConnector( + connections: [connection] + (reconnectConnection.map { [$0] } ?? []) + ), + rpcConnectionWaitTimeout: waitTimeout + ) + } +} + +private struct ServerConfigTicketTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let data = Data(#"{"ticket":"ticket","expiresAt":"2026-09-01T12:05:00.000Z"}"#.utf8) + return (data, HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!) + } +} + +private actor ServerConfigTestConnector: WebSocketConnecting { + private var connections: [ServerConfigTestConnection] + + init(connections: [ServerConfigTestConnection]) { self.connections = connections } + + func connect(to _: URL) throws -> any WebSocketConnection { + guard !connections.isEmpty else { throw RPCError.connectionUnavailable } + return connections.removeFirst() + } +} + +private actor ServerConfigTestConnection: WebSocketConnection { + enum Mode { case snapshot, silent, failure(String) } + + private let mode: Mode + private let supportsUsageLimitSources: Bool? + private let failResetCreditSend: Bool + private var requestTags: [String] = [] + private var requestPayloads: [String: [JSONValue]] = [:] + private var subscriptionRequestID: Int? + private var refreshRequestID: Int? + private var responses: [Data] = [] + private var receiver: CheckedContinuation? + private var requestWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(mode: Mode, supportsUsageLimitSources: Bool? = true, failResetCreditSend: Bool = false) { + self.mode = mode + self.supportsUsageLimitSources = supportsUsageLimitSources + self.failResetCreditSend = failResetCreditSend + } + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard let tag = request["tag"]?.stringValue, + case let .number(rawID) = request["id"] else { return } + let id = Int(rawID) + requestTags.append(tag) + requestPayloads[tag, default: []].append(request["payload"] ?? .null) + for waiter in requestWaiters where requestTags.count >= waiter.count { + waiter.continuation.resume() + } + requestWaiters.removeAll { requestTags.count >= $0.count } + switch tag { + case "subscribeServerConfig": + subscriptionRequestID = id + switch mode { + case .snapshot: try enqueue(chunk(id: id, value: snapshot(id: "codex-old"))) + case .silent: break + case let .failure(message): try enqueue(failure(id: id, message: message)) + } + case "server.getConfig": + try enqueue(success(id: id, value: config(id: "codex-old"))) + case "server.refreshProviders": + refreshRequestID = id + case "provider.consumeResetCredit": + if failResetCreditSend { throw RPCError.disconnected } + try enqueue(success(id: id, value: .object(["outcome": .string("reset")]))) + default: break + } + } + + func receive() async throws -> Data { + if !responses.isEmpty { return responses.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func tags() -> [String] { requestTags } + func payloads(for tag: String) -> [JSONValue] { requestPayloads[tag] ?? [] } + + func waitForRequestCount(_ count: Int) async { + if requestTags.count >= count { return } + await withCheckedContinuation { requestWaiters.append((count, $0)) } + } + + func pushProviderStatus(id: String) throws { + guard let subscriptionRequestID else { return } + try enqueue(chunk(id: subscriptionRequestID, value: .object([ + "type": .string("providerStatuses"), + "payload": .object(["providers": .array([provider(id: id)])]), + ]))) + } + + func pushSnapshot(id: String) throws { + guard let subscriptionRequestID else { return } + try enqueue(chunk(id: subscriptionRequestID, value: snapshot(id: id))) + } + + func pushUsageLimitSources(ids: [String], includeFutureSource: Bool = false) throws { + guard let subscriptionRequestID else { return } + var sources = ids.map(source) + if includeFutureSource { + sources.append(.object(["id": .string("future"), "kind": .string("future-source")])) + } + try enqueue(chunk(id: subscriptionRequestID, value: .object([ + "type": .string("usageLimitSourcesUpdated"), + "payload": .object(["sources": .array(sources)]), + ]))) + } + + func pushSettings(continueAfterUpdate: Bool) throws { + guard let subscriptionRequestID else { return } + try enqueue(chunk(id: subscriptionRequestID, value: .object([ + "type": .string("settingsUpdated"), + "payload": .object(["settings": .object([ + "continueThreadsAfterServerUpdate": .bool(continueAfterUpdate), + ])]), + ]))) + } + + func finishRefresh(id: String) throws { + guard let refreshRequestID else { return } + self.refreshRequestID = nil + try enqueue(success(id: refreshRequestID, value: .object([ + "providers": .array([provider(id: id)]), + ]))) + } + + private func snapshot(id: String) -> JSONValue { + .object(["type": .string("snapshot"), "config": config(id: id)]) + } + + private func config(id: String) -> JSONValue { + .object([ + "providers": .array([provider(id: id)]), + "settings": .object([ + "defaultThreadEnvMode": .string("worktree"), + "newWorktreesStartFromOrigin": .bool(false), + ]), + "threadSnapshotPagination": .bool(true), + "threadResumeCompletionMarker": .bool(true), + "environment": .object([ + "environmentId": .string("environment-1"), + "label": .string("Studio"), + "platform": .object(["os": .string("darwin"), "arch": .string("arm64")]), + "serverVersion": .string("1.0.0"), + "capabilities": .object(supportsUsageLimitSources.map { + ["usageLimitSources": .bool($0)] + } ?? [:]), + ]), + ]) + } + + private func provider(id: String) -> JSONValue { + .object([ + "instanceId": .string(id), "driver": .string("codex"), + "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), + "auth": .object(["status": .string("authenticated")]), + "checkedAt": .string("2026-09-01T12:00:00.000Z"), "models": .array([]), + "usageLimits": .object([ + "checkedAt": .string("2026-09-01T12:00:00.000Z"), + "windows": .array([ + .object([ + "id": .string("primary"), "kind": .string("session"), + "label": .string("Session"), "usedPercent": .number(25), + ]), + .object([ + "id": .string("future"), "kind": .string("future-window"), + "label": .string("Future"), "usedPercent": .number(50), + ]), + ]), + ]), + ]) + } + + private func source(id: String) -> JSONValue { + .object([ + "id": .string(id), "kind": .string("cliproxy"), "label": .string(id), + "checkedAt": .string("2026-09-01T12:00:00.000Z"), "accounts": .array([]), + ]) + } + + private func chunk(id: Int, value: JSONValue) throws -> Data { + try JSONEncoder.t3.encode(JSONValue.object([ + "_tag": .string("Chunk"), "requestId": .number(Double(id)), "values": .array([value]), + ])) + } + + private func success(id: Int, value: JSONValue) throws -> Data { + try JSONEncoder.t3.encode(JSONValue.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object(["_tag": .string("Success"), "value": value]), + ])) + } + + private func failure(id: Int, message: String) throws -> Data { + try JSONEncoder.t3.encode(JSONValue.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Fail"), "error": .object(["message": .string(message)]), + ])]), + ]), + ])) + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + responses.append(data) + } + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ConnectDPoPTests.swift b/apps/swift-ios/Tests/CoreTests/T3ConnectDPoPTests.swift new file mode 100644 index 000000000000..7d5547ef196c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ConnectDPoPTests.swift @@ -0,0 +1,237 @@ +import CryptoKit +import XCTest +@testable import T3Code + +final class T3ConnectDPoPTests: XCTestCase { + func testCanonicalJWKThumbprintAndSignedProofMatchRFCShape() async throws { + var scalar = Data(repeating: 0, count: 32) + scalar[31] = 1 + let signer = try T3ConnectDPoPSigner(privateKeyRawRepresentation: scalar) + let jwk = try await signer.publicJWK() + + XCTAssertEqual( + jwk.canonicalThumbprintInput, + #"{"crv":"P-256","kty":"EC","x":"axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY","y":"T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU"}"# + ) + XCTAssertEqual(jwk.thumbprint, "xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M") + + let token = "access-token" + let proof = try await signer.proof( + method: "post", + url: URL(string: "https://relay.example/v1/connect?ignored=yes#fragment")!, + accessToken: token, + issuedAt: Date(timeIntervalSince1970: 1_800_000_000), + identifier: UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")! + ) + let components = proof.value.split(separator: ".").map(String.init) + XCTAssertEqual(components.count, 3) + + let header = try jsonObject(components[0]) + XCTAssertEqual(header["typ"] as? String, "dpop+jwt") + XCTAssertEqual(header["alg"] as? String, "ES256") + XCTAssertNil((header["jwk"] as? [String: Any])?["d"]) + + let payload = try jsonObject(components[1]) + XCTAssertEqual(payload["htm"] as? String, "POST") + XCTAssertEqual(payload["htu"] as? String, "https://relay.example/v1/connect") + XCTAssertEqual(payload["iat"] as? Int, 1_800_000_000) + XCTAssertEqual( + payload["ath"] as? String, + T3ConnectDPoPSigner.accessTokenHash(token) + ) + + let x = try decodeBase64URL(jwk.x) + let y = try decodeBase64URL(jwk.y) + let publicKey = try P256.Signing.PublicKey( + x963Representation: Data([0x04]) + x + y + ) + let signature = try P256.Signing.ECDSASignature( + rawRepresentation: decodeBase64URL(components[2]) + ) + XCTAssertTrue( + publicKey.isValidSignature( + signature, + for: Data("\(components[0]).\(components[1])".utf8) + ) + ) + } + + func testHTUNormalizationDropsDefaultPortQueryAndFragment() { + XCTAssertEqual( + T3ConnectDPoPSigner.normalizedHTU( + URL(string: "https://relay.example:443/path?query=one#fragment")! + )?.absoluteString, + "https://relay.example/path" + ) + XCTAssertEqual( + T3ConnectDPoPSigner.normalizedHTU( + URL(string: "https://relay.example:8443/path")! + )?.absoluteString, + "https://relay.example:8443/path" + ) + } + + func testCloudConfigurationRequiresAllPublicEndpoints() { + XCTAssertEqual( + T3ConnectConfiguration.resolve(infoDictionary: [:]), + .unavailable( + reason: "This build is missing Clerk publishable key, relay HTTP URL." + ) + ) + + let resolution = T3ConnectConfiguration.resolve(infoDictionary: [ + "T3ConnectClerkPublishableKey": "pk_test_example", + "T3ConnectClerkJWTTemplate": "relay-template", + "T3ConnectRelayHTTPURL": "https://relay.example/", + ]) + guard case let .available(configuration) = resolution else { + return XCTFail("Expected available T3 Connect configuration") + } + XCTAssertEqual(configuration.clerkJWTTemplate, "relay-template") + XCTAssertEqual(configuration.relayHTTPURL.absoluteString, "https://relay.example") + } + + func testBearerRejectionDoesNotGetDPoPClockAdvice() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "bearer-token"), + ]) + let api = EnvironmentAPI( + transport: DPoPErrorHTTPTransport( + body: #"{"code":"auth_invalid","reason":"invalid_credential","dpopFailureReason":"time_window","message":"The bearer token expired.","traceId":"trace-bearer"}"# + ), + credentials: credentials + ) + + do { + _ = try await api.session(for: environment) + XCTFail("A rejected bearer credential was accepted") + } catch let error as HTTPError { + XCTAssertEqual( + error.errorDescription, + "The bearer token expired. (trace trace-bearer)" + ) + XCTAssertFalse(error.errorDescription?.contains("clock") == true) + } + } + + func testManagedEnvironmentConfirmedClockFailureUsesClockAdvice() async throws { + let environment = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential.managedDPoP( + accessToken: "managed-token", + expiresAt: Date().addingTimeInterval(300), + scopes: ["orchestration:read"], + environmentID: environment.id, + proofKeyThumbprint: "proof-key" + ), + ]) + let transport = DPoPErrorHTTPTransport( + body: #"{"code":"auth_invalid","reason":"invalid_credential","dpopFailureReason":"time_window","traceId":"trace-clock"}"# + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: StaticDPoPAuthorizer() + ) + + do { + _ = try await api.session(for: environment) + XCTFail("A rejected managed credential was accepted") + } catch let error as HTTPError { + XCTAssertEqual( + error.errorDescription, + "The environment credential is invalid. \(DPoPFailurePresentation.clockHint) (trace trace-clock)" + ) + } + + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "DPoP") == "proof" + }) + } + + private func jsonObject(_ encoded: String) throws -> [String: Any] { + let data = try decodeBase64URL(encoded) + return try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + private func decodeBase64URL(_ value: String) throws -> Data { + var base64 = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + return try XCTUnwrap(Data(base64Encoded: base64)) + } +} + +private actor DPoPErrorHTTPTransport: HTTPTransport { + let body: String + private(set) var requests: [URLRequest] = [] + + init(body: String) { + self.body = body + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + return ( + Data(body.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} + +private struct StaticDPoPAuthorizer: ManagedEnvironmentAuthorizing { + func credentialRequiresRefresh( + _: EnvironmentCredential, + environment _: Environment + ) async throws -> Bool { + false + } + + func authorize( + _ request: URLRequest, + environment _: Environment, + credential: EnvironmentCredential + ) async throws -> URLRequest { + var authorized = request + authorized.setValue( + "DPoP \(credential.accessToken)", + forHTTPHeaderField: "Authorization" + ) + authorized.setValue("proof", forHTTPHeaderField: "DPoP") + return authorized + } + + func refreshCredential( + for environment: Environment, + replacing _: EnvironmentCredential + ) async throws -> EnvironmentCredential { + .managedDPoP( + accessToken: "refreshed-token", + expiresAt: Date().addingTimeInterval(300), + scopes: ["orchestration:read"], + environmentID: environment.id, + proofKeyThumbprint: "proof-key" + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ConnectRelayDecodingTests.swift b/apps/swift-ios/Tests/CoreTests/T3ConnectRelayDecodingTests.swift new file mode 100644 index 000000000000..463e0744f250 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ConnectRelayDecodingTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import T3Code + +final class T3ConnectRelayDecodingTests: XCTestCase { + func testRelayEnvironmentAndStatusDecodeCurrentContract() throws { + let environment = try JSONDecoder.t3.decode( + T3ConnectRelayEnvironment.self, + from: Data( + #"{"environmentId":"env-1","label":"Studio","endpoint":{"httpBaseUrl":"https://studio.example","wsBaseUrl":"wss://studio.example","providerKind":"cloudflare_tunnel"},"linkedAt":"2026-08-01T12:00:00.000Z"}"#.utf8 + ) + ) + XCTAssertEqual(environment.id, "env-1") + XCTAssertEqual(environment.endpoint.providerKind, .cloudflareTunnel) + + let status = try JSONDecoder.t3.decode( + T3ConnectRelayEnvironmentStatus.self, + from: Data( + #"{"environmentId":"env-1","endpoint":{"httpBaseUrl":"https://studio.example","wsBaseUrl":"wss://studio.example","providerKind":"cloudflare_tunnel"},"status":"offline","checkedAt":"2026-08-01T12:01:00.000Z","error":"connector unavailable","traceId":"trace-1"}"#.utf8 + ) + ) + XCTAssertEqual(status.status, .offline) + XCTAssertEqual(status.error, "connector unavailable") + XCTAssertEqual(status.traceId, "trace-1") + } + + func testRelayTokensAndLinkResponsesDecodeSnakeCaseAndOptionalRuntime() throws { + let token = try JSONDecoder.t3.decode( + T3ConnectRelayAccessToken.self, + from: Data( + #"{"access_token":"relay-token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"DPoP","expires_in":300,"scope":"environment:status environment:connect"}"#.utf8 + ) + ) + XCTAssertEqual(token.accessToken, "relay-token") + XCTAssertEqual(token.expiresIn, 300) + + let link = try JSONDecoder.t3.decode( + T3ConnectEnvironmentLinkResponse.self, + from: Data( + #"{"ok":true,"cloudUserId":"user-1","environmentId":"env-1","endpoint":{"httpBaseUrl":"https://studio.example","wsBaseUrl":"wss://studio.example","providerKind":"t3_relay"},"endpointRuntime":null,"relayIssuer":"https://relay.example","environmentCredential":"credential","cloudMintPublicKey":"public-key"}"#.utf8 + ) + ) + XCTAssertTrue(link.ok) + XCTAssertNil(link.endpointRuntime) + XCTAssertEqual(link.endpoint.providerKind, .t3Relay) + } + + func testConfirmedDPoPClockFailureHasClockHint() throws { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","dpopFailureReason":"time_window","traceId":"trace-clock"}"# + ) + + XCTAssertEqual(body.dpopFailureReason, .timeWindow) + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay rejected the DPoP proof. \(DPoPFailurePresentation.clockHint)" + ) + } + + func testOlderDPoPFailureTreatsClockSkewAsPossible() throws { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","traceId":"trace-old"}"# + ) + + XCTAssertNil(body.dpopFailureReason) + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay rejected the DPoP proof. \(DPoPFailurePresentation.unknownHint)" + ) + } + + func testNonClockAndUnknownDPoPFailuresUseNeutralHint() throws { + for reason in ["key_mismatch", "request_mismatch", "token_mismatch", "replay", + "invalid_proof", "future_reason"] + { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","dpopFailureReason":"\#(reason)","traceId":"trace-retry"}"# + ) + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay rejected the DPoP proof. \(DPoPFailurePresentation.retryHint)" + ) + } + } + + func testMissingEnvironmentLinkUsesSpecificMessage() throws { + let body = try relayErrorBody( + #"{"code":"environment_connect_not_authorized","reason":"environment_link_not_found","traceId":"trace-link"}"# + ) + + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay has no active link for this environment. The environment server may not have re-established its link yet." + ) + } + + func testPresentedRelayErrorPreservesTraceID() throws { + let body = try relayErrorBody( + #"{"code":"environment_endpoint_timed_out","traceId":"trace-timeout"}"# + ) + let error = T3ConnectRelayError.response( + status: 504, + message: T3ConnectRelayErrorPresentation.message( + for: body, + requestUsesDPoP: true + ), + traceID: body.traceId + ) + + XCTAssertEqual( + error.errorDescription, + "Relay timed out while contacting the environment endpoint. (trace trace-timeout)" + ) + } + + func testBearerRelayRequestDoesNotGetDPoPClockAdvice() throws { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","dpopFailureReason":"time_window","message":"The session was rejected.","traceId":"trace-bearer"}"# + ) + + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: false), + "The session was rejected." + ) + } + + private func relayErrorBody(_ json: String) throws -> T3ConnectRelayErrorBody { + try JSONDecoder.t3.decode(T3ConnectRelayErrorBody.self, from: Data(json.utf8)) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ConnectRuntimeTests.swift b/apps/swift-ios/Tests/CoreTests/T3ConnectRuntimeTests.swift new file mode 100644 index 000000000000..b667420a5dc7 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ConnectRuntimeTests.swift @@ -0,0 +1,1580 @@ +import CryptoKit +import XCTest +@testable import T3Code + +final class T3ConnectRuntimeTests: XCTestCase { + func testLegacyCredentialsRemainBearerAndManagedMetadataIsRedacted() async throws { + let legacy = Data(#"{"accessToken":"legacy-secret","scopes":["read"]}"#.utf8) + let decoded = try JSONDecoder.t3.decode(EnvironmentCredential.self, from: legacy) + XCTAssertEqual(decoded.authorizationMethod, .bearer) + XCTAssertEqual(decoded.accessToken, "legacy-secret") + XCTAssertThrowsError( + try JSONDecoder.t3.decode( + EnvironmentCredential.self, + from: Data(#"{"accessToken":"secret","authorizationMethod":"dpop"}"#.utf8) + ) + ) + + let managed = EnvironmentCredential.managedDPoP( + accessToken: "managed-secret", + expiresAt: Date(timeIntervalSince1970: 2_000_000_000), + scopes: ["orchestration:read"], + environmentID: "managed-1", + proofKeyThumbprint: "proof-key" + ) + XCTAssertFalse(String(describing: managed).contains("managed-secret")) + XCTAssertFalse(String(reflecting: managed).contains("managed-secret")) + XCTAssertEqual( + try JSONDecoder.t3.decode( + EnvironmentCredential.self, + from: JSONEncoder.t3.encode(managed) + ), + managed + ) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-redaction-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([ + managedEnvironment(descriptor: descriptor(environmentID: "managed-1")), + ]) + let catalog = try String(contentsOf: catalogURL, encoding: .utf8) + XCTAssertTrue(catalog.contains("managed-dpop")) + XCTAssertFalse(catalog.contains("managed-secret")) + XCTAssertFalse(catalog.contains("proof-key")) + } + + func testEveryManagedHTTPRequestGetsFreshDPoPProof() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let transport = T3ConnectScriptedHTTPTransport { request, _ in + XCTAssertEqual(request.url?.path, "/api/auth/session") + return (.authSession, 200) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { _ in throw T3ConnectTestError.unexpectedRefresh } + ) + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "bound-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ), + ]) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + + _ = try await api.session(for: environment) + _ = try await api.session(for: environment) + + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "DPoP bound-token" + }) + let proofs = requests.compactMap { $0.value(forHTTPHeaderField: "DPoP") } + XCTAssertEqual(proofs.count, 2) + XCTAssertNotEqual(proofs[0], proofs[1]) + XCTAssertFalse(requests.contains { + $0.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true + }) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Accept-Encoding") == "gzip" + }) + } + + func testManagedRoutesReplaceAdvertisedBasePath() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch ordinal { + case 1: + return (.descriptor, 200) + case 2: + return ( + .token( + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ") + ), + 200 + ) + case 3: + return (.webSocketTicket("canonical-ticket"), 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let endpoint = T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example/advertised/prefix?stale=true", + wsBaseUrl: "wss://managed.example/ws", + providerKind: .t3Relay + ) + let bootstrap = T3ConnectManagedEnvironmentCredential( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: endpoint, + bootstrapCredential: "one-use-bootstrap", + bootstrapExpiresAt: "2026-08-01T12:00:00.000Z", + proofKeyThumbprint: try await signer.thumbprint() + ) + + _ = try await authorizer.descriptor(at: try XCTUnwrap(endpoint.httpBaseURL)) + let authorization = try await authorizer.exchange(bootstrap) + _ = try await authorizer.webSocketURL(using: authorization) + + let requests = await transport.requests + XCTAssertEqual( + requests.map(\.url?.path), + [ + "/.well-known/t3/environment", + "/oauth/token", + "/api/auth/websocket-ticket", + ] + ) + XCTAssertTrue(requests.allSatisfy { $0.url?.query == nil }) + } + + func testSixtySecondMarginRefreshesAndPersistsBeforeFirstRequest() async throws { + let fixture = try await refreshFixture( + savedThumbprint: nil, + expiresAt: Date().addingTimeInterval(45) + ) + + _ = try await fixture.api.session(for: fixture.environment) + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let saved = await fixture.credentials.credential(for: fixture.environment.id) + XCTAssertEqual(saved?.accessToken, "fresh-environment-token") + XCTAssertEqual(saved?.authorizationMethod, .dpop) + let requests = await fixture.transport.requests + XCTAssertEqual( + requests.map(\.url?.path), + ["/.well-known/t3/environment", "/oauth/token", "/api/auth/session"] + ) + XCTAssertEqual( + requests.last?.value(forHTTPHeaderField: "Authorization"), + "DPoP fresh-environment-token" + ) + } + + func testConcurrentExpiryRefreshIsSingleFlight() async throws { + let fixture = try await refreshFixture( + savedThumbprint: nil, + expiresAt: Date().addingTimeInterval(-1) + ) + + async let first = fixture.api.session(for: fixture.environment) + async let second = fixture.secondAPI.session(for: fixture.environment) + _ = try await (first, second) + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let refreshRequests = await fixture.transport.requests + let paths = refreshRequests.map(\.url?.path) + XCTAssertEqual(paths.filter { $0 == "/oauth/token" }.count, 1) + XCTAssertEqual(paths.filter { $0 == "/api/auth/session" }.count, 2) + } + + func testCancellingRefreshWaiterDoesNotBreakSingleFlight() async throws { + let signer = try testSigner() + let environment = managedEnvironment(descriptor: descriptor()) + let replacing = EnvironmentCredential.managedDPoP( + accessToken: "expired-token", + expiresAt: Date().addingTimeInterval(-1), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: try await signer.thumbprint() + ) + let source = BlockingT3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + switch request.url?.path { + case "/.well-known/t3/environment": + return (.descriptor, 200) + case "/oauth/token": + return ( + .token( + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ") + ), + 200 + ) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let authorization = T3ConnectRuntimeAuthorization( + authorizer: T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ), + bootstrapProvider: { id in try await source.value(for: id) } + ) + + let first = Task { + try await authorization.refreshCredential( + for: environment, + replacing: replacing + ) + } + await source.waitUntilCallCount(1) + first.cancel() + let secondStarted = AsyncTestMarker() + let second = Task { + await secondStarted.mark() + return try await authorization.refreshCredential( + for: environment, + replacing: replacing + ) + } + await secondStarted.waitUntilMarked() + await Task.yield() + await source.release() + + let firstCredential = try await first.value + let secondCredential = try await second.value + XCTAssertEqual(firstCredential, secondCredential) + let calls = await source.calls + XCTAssertEqual(calls, 1) + } + + func testRevokedCredentialCannotBeRestoredByAnInFlightRefresh() async throws { + let signer = try testSigner() + let environment = managedEnvironment(descriptor: descriptor()) + let expired = EnvironmentCredential.managedDPoP( + accessToken: "expired-token", + expiresAt: Date().addingTimeInterval(-1), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: try await signer.thumbprint() + ) + let credentials = InMemoryCredentialStore(credentials: [environment.id: expired]) + let bootstrap = BlockingT3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + switch request.url?.path { + case "/.well-known/t3/environment": + return (.descriptor, 200) + case "/oauth/token": + return ( + .token( + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ") + ), + 200 + ) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let authorization = T3ConnectRuntimeAuthorization( + authorizer: T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ), + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: authorization + ) + + let request = Task { try await api.session(for: environment) } + await bootstrap.waitUntilCallCount(1) + await credentials.removeCredential(for: environment.id) + await bootstrap.release() + + do { + _ = try await request.value + XCTFail("A revoked environment credential was restored by an in-flight refresh") + } catch HTTPError.missingCredential { + // Removing the saved credential permanently invalidates this refresh. + } + let restoredCredential = await credentials.credential(for: environment.id) + XCTAssertNil(restoredCredential) + } + + func testFailedManagedSaveDoesNotOverwriteNewerCredential() async throws { + try await assertFailedManagedSavePreservesNewerCredential( + previousCredential: managedCredential(accessToken: "previous-managed-token"), + replacementTiming: .afterInstallation + ) + } + + func testFailedFirstManagedSaveDoesNotDeleteNewerCredential() async throws { + try await assertFailedManagedSavePreservesNewerCredential( + previousCredential: nil, + replacementTiming: .afterInstallation + ) + } + + func testFailedManagedSaveRestoresCredentialRefreshedBeforeInstallation() async throws { + try await assertFailedManagedSavePreservesNewerCredential( + previousCredential: managedCredential(accessToken: "previous-managed-token"), + replacementTiming: .beforeInstallation + ) + } + + func testRotatedProofKeyRebindsFreshCredential() async throws { + let fixture = try await refreshFixture( + savedThumbprint: "stale-proof-key", + expiresAt: Date().addingTimeInterval(300) + ) + + _ = try await fixture.api.session(for: fixture.environment) + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let currentThumbprint = try await fixture.signer.thumbprint() + let saved = await fixture.credentials.credential(for: fixture.environment.id) + XCTAssertEqual(saved?.proofKeyThumbprint, currentThumbprint) + XCTAssertEqual(saved?.accessToken, "fresh-environment-token") + } + + func testStaggered401ReusesNewerSavedCredentialWithoutSecondMint() async throws { + try await assertStaggeredRejectionReusesNewerCredential(status: 401) + } + + func testUnauthenticatedSessionReusesNewerSavedCredential() async throws { + try await assertStaggeredRejectionReusesNewerCredential(status: 200) + } + + private func assertStaggeredRejectionReusesNewerCredential(status: Int) async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let original = EnvironmentCredential.managedDPoP( + accessToken: "old-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ) + let newer = EnvironmentCredential.managedDPoP( + accessToken: "newer-token", + expiresAt: Date().addingTimeInterval(300), + scopes: original.scopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ) + let credentials = InMemoryCredentialStore(credentials: [environment.id: original]) + let transport = T3ConnectStaggeredRejectionTransport( + credentialStore: credentials, + environmentID: environment.id, + newerCredential: newer, + status: status + ) + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + + _ = try await api.session(for: environment) + + let refreshCalls = await bootstrap.calls + XCTAssertEqual(refreshCalls, 0) + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual( + requests.map { $0.value(forHTTPHeaderField: "Authorization") }, + ["DPoP old-token", "DPoP newer-token"] + ) + XCTAssertNotEqual( + requests[0].value(forHTTPHeaderField: "DPoP"), + requests[1].value(forHTTPHeaderField: "DPoP") + ) + } + + func testRejectedTokenRefreshesOnceAndRetriesWithANewProof() async throws { + try await assertRejectedTokenRefreshesWithANewProof(status: 401) + } + + func testUnauthenticatedSessionRefreshesOnceAndRetriesWithANewProof() async throws { + try await assertRejectedTokenRefreshesWithANewProof(status: 200) + } + + private func assertRejectedTokenRefreshesWithANewProof(status: Int) async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "rejected-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ), + ]) + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch (request.url?.path, ordinal) { + case ("/api/auth/session", 1): + return (.unauthenticatedSession, status) + case ("/.well-known/t3/environment", 2): + return (.descriptor, 200) + case ("/oauth/token", 3): + return (.token(scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ")), 200) + case ("/api/auth/session", 4): + return (.authSession, 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + + let session = try await api.session(for: environment) + XCTAssertTrue(session.authenticated) + + let refreshCalls = await bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let requests = await transport.requests + let sessionRequests = requests.filter { $0.url?.path == "/api/auth/session" } + XCTAssertEqual(sessionRequests.count, 2) + XCTAssertEqual( + sessionRequests.map { $0.value(forHTTPHeaderField: "Authorization") }, + ["DPoP rejected-token", "DPoP fresh-environment-token"] + ) + XCTAssertNotEqual( + sessionRequests[0].value(forHTTPHeaderField: "DPoP"), + sessionRequests[1].value(forHTTPHeaderField: "DPoP") + ) + } + + func testUnauthenticatedSessionRetryStopsAfterOneRefresh() async throws { + let fixture = try await refreshFixture( + savedThumbprint: nil, + expiresAt: Date().addingTimeInterval(300), + sessionResponse: .unauthenticatedSession + ) + + do { + _ = try await fixture.api.session(for: fixture.environment) + XCTFail("An unauthenticated renewed session was accepted") + } catch HTTPError.unauthenticatedSession { + // The second rejection ends the request without another refresh. + } + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let requests = await fixture.transport.requests + XCTAssertEqual(requests.filter { $0.url?.path == "/api/auth/session" }.count, 2) + XCTAssertEqual(requests.filter { $0.url?.path == "/oauth/token" }.count, 1) + } + + func testBearerSessionKeepsUnauthenticatedResponsesAndNetworkErrors() async throws { + var environment = managedEnvironment(descriptor: descriptor()) + environment.kind = .bearer + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "bearer-token"), + ]) + let transport = T3ConnectScriptedHTTPTransport { _, ordinal in + if ordinal == 1 { return (.unauthenticatedSession, 200) } + throw URLError(.cannotConnectToHost) + } + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + let session = try await api.session(for: environment) + XCTAssertFalse(session.authenticated) + do { + _ = try await api.session(for: environment) + XCTFail("The transport failure was ignored") + } catch let error as URLError { + XCTAssertEqual(error.code, .cannotConnectToHost) + XCTAssertFalse(error.localizedDescription.contains(T3ConnectNetworkError.hint)) + } + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "Bearer bearer-token" + }) + } + + func testManagedHTTPNetworkFailuresExplainPossibleBlocking() async throws { + for code in [ + URLError.Code.timedOut, + .cannotFindHost, + .cannotConnectToHost, + .dnsLookupFailed, + .networkConnectionLost, + .notConnectedToInternet, + ] { + let fixture = try await refreshFixture( + savedThumbprint: nil, + expiresAt: Date().addingTimeInterval(300), + sessionFailure: URLError(code) + ) + do { + _ = try await fixture.api.session(for: fixture.environment) + XCTFail("The transport failure was ignored") + } catch let error as T3ConnectNetworkError { + XCTAssertTrue(error.localizedDescription.contains(T3ConnectNetworkError.hint)) + } + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 0) + } + } + + func testRelayAndManagedAuthorizationNetworkFailuresExplainPossibleBlocking() async throws { + let transport = T3ConnectScriptedHTTPTransport { _, _ in + throw URLError(.cannotFindHost) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: try testSigner() + ) + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: try testSigner() + ) + do { + _ = try await authorizer.descriptor(at: URL(string: "https://managed.example")!) + XCTFail("The descriptor transport failure was ignored") + } catch let error as T3ConnectNetworkError { + XCTAssertTrue(error.localizedDescription.contains(T3ConnectNetworkError.hint)) + } + do { + _ = try await relay.listEnvironments(clerkToken: "clerk-token") + XCTFail("The relay transport failure was ignored") + } catch let error as T3ConnectNetworkError { + XCTAssertTrue(error.localizedDescription.contains(T3ConnectNetworkError.hint)) + } + } + + func testNetworkHintDoesNotChangeAuthenticationProtocolOrCancellationErrors() { + let errors: [any Error] = [ + HTTPError.status(401, message: "Invalid credential", traceID: "trace-1"), + HTTPError.unauthenticatedSession, + T3ConnectRelayError.response(status: 403, message: "Access denied", traceID: "trace-2"), + HTTPError.invalidResponse, + RPCError.remote("Command rejected"), + URLError(.cancelled), + URLError(.badServerResponse), + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid JSON")), + ] + for error in errors { + let presented = T3ConnectNetworkError.wrapping(error) + XCTAssertEqual(presented.localizedDescription, error.localizedDescription) + XCTAssertFalse(presented is T3ConnectNetworkError) + } + } + + func testRefreshDescriptorMismatchDoesNotReplaceSavedCredential() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let original = EnvironmentCredential.managedDPoP( + accessToken: "saved-token", + expiresAt: Date().addingTimeInterval(-1), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ) + let credentials = InMemoryCredentialStore(credentials: [environment.id: original]) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + guard request.url?.path == "/.well-known/t3/environment" else { + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + return (.descriptor("another-environment"), 200) + } + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + ) + + do { + _ = try await api.session(for: environment) + XCTFail("Refresh accepted a descriptor for another environment") + } catch T3ConnectRelayError.environmentMismatch { + // Expected identity rejection. + } + let saved = await credentials.credential(for: environment.id) + XCTAssertEqual(saved, original) + let requests = await transport.requests + XCTAssertEqual(requests.map(\.url?.path), ["/.well-known/t3/environment"]) + } + + func testWebSocketReconnectMintsAOneUseTicketEveryTime() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "socket-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ), + ]) + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + XCTAssertEqual(request.url?.path, "/api/auth/websocket-ticket") + return (.webSocketTicket("ticket-\(ordinal)"), 200) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { _ in throw T3ConnectTestError.unexpectedRefresh } + ) + let connector = T3ConnectReconnectConnector() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: connector, + managedAuthorization: runtimeAuthorization + ) + + await client.connect() + await connector.waitForConnectionCount(2) + await client.disconnect() + + let urls = await connector.urls + XCTAssertEqual(urls.prefix(2).map { ticket(in: $0) }, ["ticket-1", "ticket-2"]) + XCTAssertFalse(urls.prefix(2).contains(environment.webSocketBaseURL)) + XCTAssertFalse(urls.prefix(2).contains { $0.absoluteString.contains("socket-token") }) + let requests = await transport.requests + XCTAssertGreaterThanOrEqual(requests.count, 2) + XCTAssertNotEqual( + requests[0].value(forHTTPHeaderField: "DPoP"), + requests[1].value(forHTTPHeaderField: "DPoP") + ) + } + + func testManagedWebSocketRejectsUnencryptedEndpointBeforeMintingTicket() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, _ in + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let authorization = T3ConnectEnvironmentAccessToken( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "ws://managed.example/ws", + providerKind: .t3Relay + ), + accessToken: "access-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + proofKeyThumbprint: try await signer.thumbprint() + ) + + do { + _ = try await authorizer.webSocketURL(using: authorization) + XCTFail("An unencrypted managed WebSocket endpoint was accepted") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + func testManagedWebSocketRejectsDifferentHostBeforeMintingTicket() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, _ in + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let authorization = T3ConnectEnvironmentAccessToken( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://different.example/ws", + providerKind: .t3Relay + ), + accessToken: "access-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + proofKeyThumbprint: try await signer.thumbprint() + ) + + do { + _ = try await authorizer.webSocketURL(using: authorization) + XCTFail("A managed WebSocket ticket was sent to a different host") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + func testEnvironmentExchangeRejectsMalformedTokenContracts() async throws { + let signer = try testSigner() + let bootstrap = try await bootstrapCredential(signer: signer) + let validScopes = T3ConnectManagedEnvironmentAuthorizer.standardScopes.joined(separator: " ") + let invalidBodies = [ + Data.token(accessToken: "", scopes: validScopes), + Data.token(issuedTokenType: "wrong", scopes: validScopes), + Data.token(expiresIn: 0, scopes: validScopes), + Data.token(scopes: "orchestration:read"), + ] + + for body in invalidBodies { + let transport = T3ConnectScriptedHTTPTransport { _, _ in (body, 200) } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + do { + _ = try await authorizer.exchange(bootstrap) + XCTFail("Malformed environment token response was accepted") + } catch is T3ConnectRelayError { + // Expected contract rejection. + } + } + } + + func testRelayTokenCacheNeverCrossesClerkAccounts() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch (request.url?.path, ordinal) { + case ("/v1/client/dpop-token", 1): + return (.relayToken("relay-account-a"), 200) + case ("/v1/environments/managed-1/status", 2): + return (.relayStatus, 200) + case ("/v1/client/dpop-token", 3): + return (.relayToken("relay-account-b"), 200) + case ("/v1/environments/managed-1/status", 4): + return (.relayStatus, 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + let record = T3ConnectRelayEnvironment( + environmentId: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + linkedAt: "2026-08-01T12:00:00.000Z" + ) + + _ = try await relay.status(for: record, clerkToken: clerkJWT(subject: "account-a")) + _ = try await relay.status(for: record, clerkToken: clerkJWT(subject: "account-b")) + + let requests = await transport.requests + XCTAssertEqual(requests.filter { $0.url?.path == "/v1/client/dpop-token" }.count, 2) + XCTAssertEqual( + requests.filter { $0.url?.path.hasSuffix("/status") == true } + .map { $0.value(forHTTPHeaderField: "Authorization") }, + ["DPoP relay-account-a", "DPoP relay-account-b"] + ) + } + + func testRelayMobileDeliveryEndpointsUseBoundDPoPRequests() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch ordinal { + case 1: + XCTAssertEqual(request.url?.path, "/v1/client/dpop-token") + return (.relayToken("relay-mobile", scope: "mobile:registration"), 200) + case 2, 3, 4: + return (Data(#"{"ok":true}"#.utf8), 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + let clerkToken = clerkJWT(subject: "mobile-account") + let device = T3ConnectDeviceRegistration( + deviceID: "phone-1", + label: "Big O", + iosMajorVersion: 26, + bundleID: "com.t3tools.t3code.swiftui", + apsEnvironment: .sandbox, + pushToken: "apns-token", + pushToStartToken: "start-token" + ) + + try await relay.registerDevice(device, clerkToken: clerkToken) + try await relay.registerLiveActivity( + T3ConnectLiveActivityRegistration( + deviceID: "phone-1", + activityPushToken: "activity-token" + ), + clerkToken: clerkToken + ) + try await relay.unregisterDevice(deviceID: "phone-1", clerkToken: clerkToken) + + let requests = await transport.requests + XCTAssertEqual( + requests.dropFirst().map(\.url?.path), + [ + "/v1/mobile/devices", + "/v1/mobile/live-activities", + "/v1/mobile/devices/phone-1", + ] + ) + XCTAssertEqual(requests.last?.httpMethod, "DELETE") + let proofs = requests.dropFirst().compactMap { + $0.value(forHTTPHeaderField: "DPoP") + } + XCTAssertEqual(proofs.count, 3) + XCTAssertEqual(Set(proofs).count, 3) + XCTAssertTrue(requests.dropFirst().allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "DPoP relay-mobile" + }) + } + + func testRelayRoutesReplaceConfiguredBasePathAndQuery() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch ordinal { + case 1: + XCTAssertEqual(request.url?.path, "/v1/client/dpop-token") + XCTAssertNil(request.url?.query) + return (.relayToken("relay-mobile", scope: "mobile:registration"), 200) + case 2: + XCTAssertEqual(request.url?.path, "/v1/mobile/devices") + XCTAssertNil(request.url?.query) + return (Data(#"{"ok":true}"#.utf8), 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example/stale/base?old=true")! + ), + transport: transport, + signer: signer + ) + + try await relay.registerDevice( + testDeviceRegistration(), + clerkToken: clerkJWT(subject: "mobile-account") + ) + } + + func testRelayRejectsFalseOKResponse() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { _, ordinal in + switch ordinal { + case 1: + return (.relayToken("relay-mobile", scope: "mobile:registration"), 200) + case 2: + return (Data(#"{"ok":false}"#.utf8), 200) + default: + throw T3ConnectTestError.unexpectedPath(nil) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + + do { + try await relay.registerDevice( + testDeviceRegistration(), + clerkToken: clerkJWT(subject: "mobile-account") + ) + XCTFail("A false success envelope was accepted") + } catch T3ConnectRelayError.invalidResponse { + // Expected contract rejection. + } + } + + func testRelayRejectsMalformedAccessTokenContracts() async throws { + let invalidTokens = [ + Data.relayToken("", scope: "mobile:registration"), + Data( + #"{"access_token":"token","issued_token_type":"wrong","token_type":"DPoP","expires_in":300,"scope":"mobile:registration"}"#.utf8 + ), + Data( + #"{"access_token":"token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"DPoP","expires_in":0,"scope":"mobile:registration"}"#.utf8 + ), + ] + + for token in invalidTokens { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { _, _ in (token, 200) } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + do { + try await relay.registerDevice( + testDeviceRegistration(), + clerkToken: clerkJWT(subject: "mobile-account") + ) + XCTFail("A malformed relay access token was accepted") + } catch T3ConnectRelayError.invalidResponse { + // Expected contract rejection. + } + } + } + + func testRelayRejectsBlankBootstrapCredential() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { _, ordinal in + switch ordinal { + case 1: + return (.relayToken("relay-connect", scope: "environment:connect"), 200) + case 2: + return ( + Data( + #"{"environmentId":"managed-1","endpoint":{"httpBaseUrl":"https://managed.example","wsBaseUrl":"wss://managed.example","providerKind":"t3_relay"},"credential":"","expiresAt":""}"#.utf8 + ), + 200 + ) + default: + throw T3ConnectTestError.unexpectedPath(nil) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + let environment = T3ConnectRelayEnvironment( + environmentId: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + linkedAt: "2026-08-01T12:00:00.000Z" + ) + + do { + _ = try await relay.connect( + to: environment, + clerkToken: clerkJWT(subject: "mobile-account") + ) + XCTFail("A blank environment bootstrap credential was accepted") + } catch T3ConnectRelayError.invalidResponse { + // Expected contract rejection. + } + } + + private func testDeviceRegistration() -> T3ConnectDeviceRegistration { + T3ConnectDeviceRegistration( + deviceID: "phone-1", + label: "Big O", + iosMajorVersion: 26, + bundleID: "com.t3tools.t3code.swiftui", + apsEnvironment: .sandbox, + pushToken: "apns-token", + pushToStartToken: "start-token" + ) + } + + private func refreshFixture( + savedThumbprint: String?, + expiresAt: Date, + sessionResponse: Data = .authSession, + sessionFailure: URLError? = nil + ) async throws -> T3ConnectRefreshFixture { + let signer = try testSigner() + let currentThumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "saved-token", + expiresAt: expiresAt, + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: savedThumbprint ?? currentThumbprint + ), + ]) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + switch request.url?.path { + case "/.well-known/t3/environment": + return (.descriptor, 200) + case "/oauth/token": + return (.token(scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ")), 200) + case "/api/auth/session": + if let sessionFailure { throw sessionFailure } + return (sessionResponse, 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + return T3ConnectRefreshFixture( + signer: signer, + environment: environment, + credentials: credentials, + transport: transport, + bootstrap: bootstrap, + api: EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ), + secondAPI: EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + ) + } + + private func assertFailedManagedSavePreservesNewerCredential( + previousCredential: EnvironmentCredential?, + replacementTiming: ManagedPersistenceCredentialStore.ReplacementTiming + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-managed-credential-race-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + let environment = managedEnvironment(descriptor: descriptor()) + let newerCredential = managedCredential(accessToken: "newer-managed-token") + let credentials = ManagedPersistenceCredentialStore( + previousCredential: previousCredential, + newerCredential: newerCredential, + replacementTiming: replacementTiming + ) + let runtime = EnvironmentRuntime( + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: credentials + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + do { + _ = try await runtime.saveManagedEnvironment( + environment, + credential: managedCredential(accessToken: "pairing-managed-token") + ) + XCTFail("Managed pairing unexpectedly updated a read-only environment catalog") + } catch { + let saved = await credentials.credential(for: environment.id) + XCTAssertEqual(saved, newerCredential) + } + } + + private func managedCredential(accessToken: String) -> EnvironmentCredential { + .managedDPoP( + accessToken: accessToken, + expiresAt: Date(timeIntervalSince1970: 2_000_000_000), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: "managed-1", + proofKeyThumbprint: "proof-key" + ) + } + + private func testSigner() throws -> T3ConnectDPoPSigner { + var scalar = Data(repeating: 0, count: 32) + scalar[31] = 7 + return try T3ConnectDPoPSigner(privateKeyRawRepresentation: scalar) + } + + private func descriptor(environmentID: String = "managed-1") -> EnvironmentDescriptor { + try! JSONDecoder.t3.decode(EnvironmentDescriptor.self, from: .descriptor(environmentID)) + } + + private func managedEnvironment(descriptor: EnvironmentDescriptor) -> Environment { + Environment( + id: descriptor.environmentId, + label: descriptor.label, + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP, + descriptor: descriptor + ) + } + + private func bootstrapCredential( + signer: T3ConnectDPoPSigner + ) async throws -> T3ConnectManagedEnvironmentCredential { + T3ConnectManagedEnvironmentCredential( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + bootstrapCredential: "one-use-bootstrap", + bootstrapExpiresAt: "2026-08-01T12:00:00.000Z", + proofKeyThumbprint: try await signer.thumbprint() + ) + } + + private func ticket(in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first(where: { $0.name == "wsTicket" })?.value + } + + private func clerkJWT(subject: String) -> String { + let header = Data(#"{"alg":"none"}"#.utf8).testBase64URL() + let payload = Data(#"{"sub":"\#(subject)"}"#.utf8).testBase64URL() + return "\(header).\(payload).signature" + } +} + +private struct T3ConnectRefreshFixture { + let signer: T3ConnectDPoPSigner + let environment: Environment + let credentials: InMemoryCredentialStore + let transport: T3ConnectScriptedHTTPTransport + let bootstrap: T3ConnectBootstrapSource + let api: EnvironmentAPI + let secondAPI: EnvironmentAPI +} + +private enum T3ConnectTestError: Error { + case unexpectedRefresh + case unexpectedPath(String?) +} + +private actor ManagedPersistenceCredentialStore: CredentialStore { + enum ReplacementTiming { + case beforeInstallation + case afterInstallation + } + + private var storedCredential: EnvironmentCredential? + private let newerCredential: EnvironmentCredential + private let replacementTiming: ReplacementTiming + private var hasInsertedNewerCredential = false + + init( + previousCredential: EnvironmentCredential?, + newerCredential: EnvironmentCredential, + replacementTiming: ReplacementTiming + ) { + storedCredential = previousCredential + self.newerCredential = newerCredential + self.replacementTiming = replacementTiming + } + + func credential(for environmentID: String) -> EnvironmentCredential? { + let currentCredential = storedCredential + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + return currentCredential + } + + func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + storedCredential = credential + if replacementTiming == .afterInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + } + + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + let previousCredential = storedCredential + setCredential(credential, for: environmentID) + return previousCredential + } + + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = credential + return true + } + + func removeCredential(for environmentID: String) { + storedCredential = nil + } + + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = nil + return true + } +} + +private actor T3ConnectBootstrapSource { + private let credential: T3ConnectManagedEnvironmentCredential + private(set) var calls = 0 + + init(credential: T3ConnectManagedEnvironmentCredential) { + self.credential = credential + } + + func value(for environmentID: String) throws -> T3ConnectManagedEnvironmentCredential { + guard environmentID == credential.environmentID else { + throw T3ConnectTestError.unexpectedPath(environmentID) + } + calls += 1 + return credential + } +} + +private actor BlockingT3ConnectBootstrapSource { + private let credential: T3ConnectManagedEnvironmentCredential + private var released = false + private var releaseWaiters: [CheckedContinuation] = [] + private var callWaiters: [(Int, CheckedContinuation)] = [] + private(set) var calls = 0 + + init(credential: T3ConnectManagedEnvironmentCredential) { + self.credential = credential + } + + func value(for environmentID: String) async throws + -> T3ConnectManagedEnvironmentCredential + { + guard environmentID == credential.environmentID else { + throw T3ConnectTestError.unexpectedPath(environmentID) + } + calls += 1 + let ready = callWaiters.filter { calls >= $0.0 } + callWaiters.removeAll { calls >= $0.0 } + ready.forEach { $0.1.resume() } + if !released { + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + return credential + } + + func waitUntilCallCount(_ count: Int) async { + guard calls < count else { return } + await withCheckedContinuation { continuation in + callWaiters.append((count, continuation)) + } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor AsyncTestMarker { + private var marked = false + private var waiters: [CheckedContinuation] = [] + + func mark() { + marked = true + let pending = waiters + waiters.removeAll() + pending.forEach { $0.resume() } + } + + func waitUntilMarked() async { + guard !marked else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +private actor T3ConnectScriptedHTTPTransport: HTTPTransport { + typealias Handler = @Sendable (URLRequest, Int) throws -> (Data, Int) + + private let handler: Handler + private(set) var requests: [URLRequest] = [] + + init(handler: @escaping Handler) { + self.handler = handler + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + let (data, status) = try handler(request, requests.count) + return (data, response(request, status: status)) + } +} + +private actor T3ConnectStaggeredRejectionTransport: HTTPTransport { + private let credentialStore: InMemoryCredentialStore + private let environmentID: String + private let newerCredential: EnvironmentCredential + private let status: Int + private(set) var requests: [URLRequest] = [] + + init( + credentialStore: InMemoryCredentialStore, + environmentID: String, + newerCredential: EnvironmentCredential, + status: Int + ) { + self.credentialStore = credentialStore + self.environmentID = environmentID + self.newerCredential = newerCredential + self.status = status + } + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + if requests.count == 1 { + await credentialStore.setCredential(newerCredential, for: environmentID) + return (.unauthenticatedSession, response(request, status: status)) + } + return (.authSession, response(request, status: 200)) + } +} + +private actor T3ConnectReconnectConnector: WebSocketConnecting { + private(set) var urls: [URL] = [] + private var waiters: [(Int, CheckedContinuation)] = [] + + func connect(to url: URL) -> any WebSocketConnection { + urls.append(url) + let ready = waiters.filter { urls.count >= $0.0 } + waiters.removeAll { urls.count >= $0.0 } + ready.forEach { $0.1.resume() } + return T3ConnectFailingReceiveConnection() + } + + func waitForConnectionCount(_ count: Int) async { + guard urls.count < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor T3ConnectFailingReceiveConnection: WebSocketConnection { + func send(_: Data) {} + func receive() throws -> Data { throw URLError(.networkConnectionLost) } + func close() {} +} + +private extension Data { + static var unauthenticatedSession: Data { + Data(#"{"authenticated":false}"#.utf8) + } + + static var authSession: Data { + Data(#"{"authenticated":true,"scopes":[],"sessionMethod":"dpop","expiresAt":null}"#.utf8) + } + + static var descriptor: Data { descriptor("managed-1") } + + static func descriptor(_ environmentID: String) -> Data { + Data( + """ + { + "environmentId": "\(environmentID)", + "label": "Managed Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """.utf8 + ) + } + + static func token( + accessToken: String = "fresh-environment-token", + issuedTokenType: String = "urn:ietf:params:oauth:token-type:access_token", + expiresIn: Double = 300, + scopes: String + ) -> Data { + Data( + """ + { + "access_token": "\(accessToken)", + "issued_token_type": "\(issuedTokenType)", + "token_type": "DPoP", + "expires_in": \(expiresIn), + "scope": "\(scopes)" + } + """.utf8 + ) + } + + static func webSocketTicket(_ ticket: String) -> Data { + Data( + #"{"ticket":"\#(ticket)","expiresAt":"2026-08-01T12:05:00.000Z"}"#.utf8 + ) + } + + static func relayToken(_ token: String, scope: String = "environment:status") -> Data { + Data( + """ + { + "access_token": "\(token)", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "DPoP", + "expires_in": 300, + "scope": "\(scope)" + } + """.utf8 + ) + } + + static var relayStatus: Data { + Data( + """ + { + "environmentId": "managed-1", + "endpoint": { + "httpBaseUrl": "https://managed.example", + "wsBaseUrl": "wss://managed.example", + "providerKind": "t3_relay" + }, + "status": "online", + "checkedAt": "2026-08-01T12:00:00.000Z", + "descriptor": null, + "error": null, + "traceId": null + } + """.utf8 + ) + } + + func testBase64URL() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +private func response(_ request: URLRequest, status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! +} diff --git a/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift b/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift new file mode 100644 index 000000000000..7cd875c2627b --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift @@ -0,0 +1,1077 @@ +import XCTest +@testable import T3Code + +@MainActor +final class TransportReliabilityTests: XCTestCase { + func testMobileClientMetadataIncludesOSVersionAndDeviceModel() { + XCTAssertGreaterThan(MobileClientMetadata.osMajorVersion, 0) + XCTAssertFalse(MobileClientMetadata.deviceModel.isEmpty) + } + + func testHTTPPolicyOffersGzipWithoutOverwritingCallerPreference() { + var request = URLRequest(url: URL(string: "https://studio.example/api")!) + let prepared = HTTPRequestPolicy.prepare(request) + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Accept"), "application/json") + + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + XCTAssertEqual( + HTTPRequestPolicy.prepare(request).value(forHTTPHeaderField: "Accept-Encoding"), + "identity" + ) + } + + func testEnvironmentAPIDecodesURLSessionDecompressedGzipResponse() async throws { + let transport = RecordingHTTPTransport { request in + let body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """ + return ( + Data(body.utf8), + transportResponse( + request, + headers: [ + "Content-Type": "application/json", + // URLSession retains this response header while + // returning the already decompressed body. + "Content-Encoding": "gzip", + ] + ) + ) + } + let api = EnvironmentAPI( + transport: transport, + credentials: InMemoryCredentialStore() + ) + + let descriptor = try await api.descriptor( + at: URL(string: "https://studio.example")! + ) + XCTAssertEqual(descriptor.environmentId, "environment-1") + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") + } + + func testShellSnapshotAppliesBoundedStartupTimeout() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ] + ) + let transport = RecordingHTTPTransport { request in + let body = #"{"snapshotSequence":0,"projects":[],"threads":[],"updatedAt":"2026-08-05T12:00:00.000Z"}"# + return (Data(body.utf8), transportResponse(request)) + } + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + _ = try await api.shellSnapshot(for: environment, timeoutInterval: 6) + + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.timeoutInterval, 6) + } + + func testThreadSnapshotSendsPaginationWindowAndDecodesCursor() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let body = try JSONEncoder.t3.encode( + OrchestrationThreadDetailSnapshot( + snapshotSequence: 42, + thread: paginationThreadFixture(), + page: OrchestrationThreadDetailPage( + beforeCursor: "next-cursor", + hasMore: true, + snapshotSequence: 42, + threadSequence: 40 + ) + ) + ) + let transport = RecordingHTTPTransport { request in + (body, transportResponse(request)) + } + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + let snapshot = try await api.threadSnapshot( + id: "thread-1", + environment: environment, + turnLimit: 20, + beforeCursor: "current-cursor" + ) + + XCTAssertEqual(snapshot.page?.beforeCursor, "next-cursor") + XCTAssertEqual(snapshot.page?.threadSequence, 40) + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + let query = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)) + .queryItems + XCTAssertEqual( + Dictionary(uniqueKeysWithValues: (query ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }), + ["turnLimit": "20", "beforeCursor": "current-cursor"] + ) + } + + func testWebSocketHandshakeOffersPerMessageDeflate() { + let url = URL(string: "wss://studio.example/ws?wsTicket=secret")! + let compressed = WebSocketHandshakeRequest.make(url: url) + XCTAssertEqual( + compressed.value(forHTTPHeaderField: "Sec-WebSocket-Extensions"), + "permessage-deflate; client_max_window_bits" + ) + XCTAssertNil( + WebSocketHandshakeRequest.make( + url: url, + offersPerMessageDeflate: false + ).value(forHTTPHeaderField: "Sec-WebSocket-Extensions") + ) + } + + func testBootstrapUsesCanonicalWebSocketRPCDispatch() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ] + ) + let transport = RecordingHTTPTransport { request in + let body = """ + { + "ticket": "websocket-ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """ + return (Data(body.utf8), transportResponse(request)) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + + let result = try await client.createThreadAndSend( + threadID: "thread-first-send", + projectID: "project-1", + title: "Native first send", + text: "Start from this message", + model: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + runtimeMode: .fullAccess, + commandID: "stable-command", + messageID: "stable-message", + createdAt: "2026-07-30T12:00:00.000Z" + ) + await client.disconnect() + + XCTAssertEqual(result.sequence, 42) + let requests = await connection.requests() + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(requests.first?["tag"]?.stringValue, "orchestration.dispatchCommand") + XCTAssertEqual( + requests.first?["payload"]?["bootstrap"]?["createThread"]?["projectId"]?.stringValue, + "project-1" + ) + XCTAssertEqual(requests.first?["payload"]?["commandId"]?.stringValue, "stable-command") + XCTAssertEqual( + requests.first?["payload"]?["message"]?["messageId"]?.stringValue, + "stable-message" + ) + + let httpRequests = await transport.requests + XCTAssertEqual(httpRequests.map(\.url?.path), ["/api/auth/websocket-ticket"]) + + let socketURLs = await connection.connectionURLs() + let socketURL = try XCTUnwrap(socketURLs.first) + let metadata = Dictionary( + uniqueKeysWithValues: (URLComponents(url: socketURL, resolvingAgainstBaseURL: false)? + .queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + XCTAssertEqual(metadata["clientSurface"], "mobile") + XCTAssertEqual(metadata["clientOs"], "iOS") + XCTAssertEqual(metadata["clientOsMajorVersion"], String(MobileClientMetadata.osMajorVersion)) + XCTAssertEqual(metadata["clientDeviceModel"], MobileClientMetadata.deviceModel) + } + + func testModernServersUploadImageBytesBeforeDispatchingTheTurn() async throws { + let descriptor = try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"attachmentUploads": true} + } + """.utf8 + ) + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return (Data(), transportResponse(request, status: 204)) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + + _ = try await client.createThreadAndSend( + threadID: "thread-1", + projectID: "project-1", + title: "Image task", + text: "Inspect this image", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + attachments: [image] + ) + await client.disconnect() + + let socketRequests = await connection.requests() + XCTAssertEqual(socketRequests.map { $0["tag"]?.stringValue }, [ + "attachments.createUploadUrl", + "orchestration.dispatchCommand", + ]) + XCTAssertEqual( + socketRequests[0]["payload"]?["sizeBytes"], + .number(4) + ) + guard case let .array(attachments)? = socketRequests[1]["payload"]?["message"]?["attachments"], + let attachment = attachments.first else { + return XCTFail("Expected an uploaded attachment") + } + XCTAssertEqual(attachment["id"]?.stringValue, "uploaded-attachment-1") + XCTAssertNil(attachment["dataUrl"]) + + let httpRequests = await transport.requests + XCTAssertEqual(httpRequests.map(\.url?.path), [ + "/api/auth/websocket-ticket", + "/api/attachments/upload/signed-token", + ]) + XCTAssertEqual(httpRequests[1].httpBody, Data([0x89, 0x50, 0x4e, 0x47])) + XCTAssertNil(httpRequests[1].value(forHTTPHeaderField: "Authorization")) + } + + func testOlderServersKeepInlineImageAttachments() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let transport = RecordingHTTPTransport { request in + ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + + _ = try await client.createThreadAndSend( + threadID: "thread-1", + projectID: "project-1", + title: "Image task", + text: "Inspect this image", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + attachments: [image] + ) + await client.disconnect() + + let requests = await connection.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, ["orchestration.dispatchCommand"]) + guard case let .array(attachments)? = requests[0]["payload"]?["message"]?["attachments"] else { + return XCTFail("Expected an inline image") + } + XCTAssertEqual(attachments.first?["dataUrl"]?.stringValue, "data:image/png;base64,iVBORw==") + } + + func testExpiredSavedAttachmentIsUploadedAgain() async throws { + let descriptor = try attachmentDescriptor( + #"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":52428800}}"# + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return (Data(), transportResponse(request, status: 204)) + } + let socket = RecordingWebSocketConnection( + assetErrorMessage: "Attachment saved-id was not found." + ) + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: socket) + ) + let attachment = try UploadChatAttachment( + data: Data([1]), + name: "image.png", + mimeType: "image/png", + uploadedReference: .init( + environmentID: environment.id, + attachmentID: "saved-id" + ) + ) + + let reference = try await client.prepareAttachment(attachment) + await client.disconnect() + + XCTAssertEqual(reference?.attachmentID, "uploaded-attachment-1") + let requests = await socket.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, [ + "assets.createUrl", + "attachments.createUploadUrl", + ]) + } + + func testSavedAttachmentAuthErrorDoesNotUploadAgain() async throws { + let descriptor = try attachmentDescriptor(#"{"attachmentUploads":true}"#) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let transport = RecordingHTTPTransport { request in + ( + Data(#"{"ticket":"ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + let socket = RecordingWebSocketConnection(assetErrorMessage: "Unauthorized") + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: socket) + ) + let attachment = try UploadChatAttachment( + data: Data([1]), + name: "image.png", + mimeType: "image/png", + uploadedReference: .init( + environmentID: environment.id, + attachmentID: "saved-id" + ) + ) + + do { + _ = try await client.prepareAttachment(attachment) + XCTFail("Expected the authorization error") + } catch { + XCTAssertTrue(error.localizedDescription.contains("Unauthorized")) + } + await client.disconnect() + let requests = await socket.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, [ + "assets.createUrl", + ]) + } + + func testUploadFailureReturnsBeforeBestEffortCleanupCompletes() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: try attachmentDescriptor(#"{"attachmentUploads":true}"#) + ) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return ( + Data("Upload rejected".utf8), + transportResponse(request, status: 503) + ) + } + let socket = RecordingWebSocketConnection(holdAttachmentDeletion: true) + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: socket) + ) + let image = try UploadChatAttachment( + data: Data([1]), name: "image.png", mimeType: "image/png" + ) + let failed = XCTestExpectation(description: "Upload failure returned") + let preparation = Task { + do { + _ = try await client.prepareAttachment(image) + XCTFail("Expected the upload error") + } catch { + XCTAssertTrue(error.localizedDescription.contains("Upload rejected")) + } + failed.fulfill() + } + + await socket.waitForAttachmentDeletion() + let result = await XCTWaiter.fulfillment(of: [failed], timeout: 1) + // Disconnect releases the held cleanup request, including on failure. + await client.disconnect() + await preparation.value + XCTAssertEqual(result, .completed) + } + + func testGenericFileUsesTypedUploadAndRawPostBody() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("notes.txt") + let fileData = Data("review notes".utf8) + try fileData.write(to: fileURL) + let descriptor = try attachmentDescriptor( + #"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":52428800}}"# + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return (Data(), transportResponse(request, status: 204)) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let file = try UploadChatAttachment( + fileURL: fileURL, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: fileData.count + ) + + _ = try await client.createThreadAndSend( + threadID: "thread-file", + projectID: "project-1", + title: "File task", + text: "Review this file", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + attachments: [file] + ) + await client.disconnect() + + let socketRequests = await connection.requests() + XCTAssertEqual(socketRequests[0]["payload"]?["type"]?.stringValue, "file") + guard case let .array(sent)? = socketRequests[1]["payload"]?["message"]?["attachments"] else { + return XCTFail("Expected an uploaded file reference") + } + XCTAssertEqual(sent.first?["type"]?.stringValue, "file") + XCTAssertEqual(sent.first?["mimeType"]?.stringValue, "text/plain") + XCTAssertNil(sent.first?["dataUrl"]) + + let requests = await transport.requests + let upload = try XCTUnwrap(requests.last) + XCTAssertEqual(upload.httpMethod, "POST") + XCTAssertEqual(upload.httpBody, fileData) + XCTAssertEqual(upload.value(forHTTPHeaderField: "Content-Type"), "text/plain") + } + + func testGenericFileRejectsAnOlderServerBeforeDispatch() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("unsupported-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: fileURL) } + try Data("file".utf8).write(to: fileURL) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(), + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let file = try UploadChatAttachment( + fileURL: fileURL, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4 + ) + + do { + _ = try await client.sendTurn( + threadID: "thread-1", + text: "Review", + runtimeMode: .fullAccess, + attachments: [file] + ) + XCTFail("Expected unsupported file rejection") + } catch FileAttachmentError.unsupported { + let requests = await connection.requests() + XCTAssertTrue(requests.isEmpty) + } + } + + func testGenericFileUsesTheAdvertisedByteLimit() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("large-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: fileURL) } + try Data("four".utf8).write(to: fileURL) + let descriptor = try attachmentDescriptor( + #"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":3}}"# + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore() + ) + let file = try UploadChatAttachment( + fileURL: fileURL, + name: "large.txt", + mimeType: "text/plain", + sizeBytes: 4 + ) + + do { + _ = try await client.sendTurn( + threadID: "thread-1", + text: "Review", + runtimeMode: .fullAccess, + attachments: [file] + ) + XCTFail("Expected the advertised limit to reject the file") + } catch let FileAttachmentError.tooLarge(actualBytes, maximumBytes) { + XCTAssertEqual(actualBytes, 4) + XCTAssertEqual(maximumBytes, 3) + } + } + + func testFeedbackRPCUsesTheThreadAndOptionalReason() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let transport = RecordingHTTPTransport { request in + ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + + let withReason = try await client.uploadFeedback( + threadID: "thread-1", + reason: "The agent stopped early." + ) + let withoutReason = try await client.uploadFeedback(threadID: "thread-2") + await client.disconnect() + + XCTAssertEqual(withReason.feedbackId, "codex-thread-1") + XCTAssertEqual(withoutReason.feedbackId, "codex-thread-1") + let requests = await connection.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, [ + "provider.uploadFeedback", + "provider.uploadFeedback", + ]) + XCTAssertEqual(requests[0]["payload"]?["threadId"]?.stringValue, "thread-1") + XCTAssertEqual(requests[0]["payload"]?["reason"]?.stringValue, "The agent stopped early.") + XCTAssertEqual(requests[1]["payload"]?["threadId"]?.stringValue, "thread-2") + XCTAssertNil(requests[1]["payload"]?["reason"]) + } + + func testUnsentCommandsFallBackToHTTPButBootstrapDoesNot() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ] + ) + let transport = RecordingHTTPTransport { request in + let body = if request.url?.path == "/api/auth/websocket-ticket" { + """ + { + "ticket": "websocket-ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """ + } else { + """ + {"sequence": 9} + """ + } + return (Data(body.utf8), transportResponse(request)) + } + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: FailingWebSocketConnector(), + rpcConnectionWaitTimeout: .milliseconds(30) + ) + + let rename = try await client.rename(threadID: "thread-1", title: "Renamed") + XCTAssertEqual(rename.sequence, 9) + + do { + _ = try await client.createThreadAndSend( + threadID: "thread-first-send", + projectID: "project-1", + title: "Native first send", + text: "Start from this message", + model: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + runtimeMode: .fullAccess + ) + XCTFail("Bootstrap must not use the HTTP endpoint that cannot expand it.") + } catch let error as RPCError { + guard case .connectionUnavailable = error else { + return XCTFail("Unexpected RPC error: \(error)") + } + } + await client.disconnect() + + let requests = await transport.requests + let dispatchRequests = requests.filter { + $0.url?.path == "/api/orchestration/dispatch" + } + XCTAssertEqual(dispatchRequests.count, 1) + let command = try JSONDecoder.t3.decode( + JSONValue.self, + from: try XCTUnwrap(dispatchRequests.first?.httpBody) + ) + XCTAssertEqual(command["type"]?.stringValue, "thread.meta.update") + } + + /// Set `T3_SWIFT_WS_DEFLATE_ECHO_URL` to a WebSocket endpoint that rejects + /// non-deflate handshakes and echoes binary frames. This is intentionally + /// opt-in because XCTest does not own a Node process. A successful round + /// trip proves URLSession accepted the server's compressed frame. + func testLivePerMessageDeflateRoundTripWhenConfigured() async throws { + guard let value = ProcessInfo.processInfo.environment[ + "T3_SWIFT_WS_DEFLATE_ECHO_URL" + ], let url = URL(string: value) else { + throw XCTSkip("Set T3_SWIFT_WS_DEFLATE_ECHO_URL for live compression proof.") + } + let connection = try await URLSessionWebSocketConnector().connect(to: url) + defer { Task { await connection.close() } } + let payload = Data(repeating: 0x54, count: 64 * 1024) + try await connection.send(payload) + let echoed = try await connection.receive() + XCTAssertEqual(echoed, payload) + } + + func testPairingInputParsesClipboardQRHostedAndLooseFormats() throws { + let direct = try PairingURL.parseFields( + " https://studio.example:3773/pair#token=N735%4BQXJ " + ) + XCTAssertEqual(direct.host, "https://studio.example:3773") + XCTAssertEqual(direct.pairingCode, "N735KQXJ") + + let hosted = try PairingURL.parseFields( + "https://app.t3.codes/pair?host=http%3A%2F%2F192.168.1.7%3A18773" + + "&label=Big%20O#token=PAIRING" + ) + XCTAssertEqual(hosted.host, "http://192.168.1.7:18773") + XCTAssertEqual(hosted.pairingCode, "PAIRING") + XCTAssertEqual(hosted.label, "Big O") + + let loose = try PairingURL.parseFields("192.168.1.7:18773 N735KQXJ5SJW") + XCTAssertEqual(loose.host, "https://192.168.1.7:18773") + XCTAssertEqual(loose.pairingCode, "N735KQXJ5SJW") + + let wrapped = try PairingURL.pairingURL( + fromQRCode: "t3code://pair?pairingUrl=https%3A%2F%2Fstudio.example" + + "%2Fpair%23token%3DQR-CODE" + ) + XCTAssertEqual(wrapped, "https://studio.example/pair#token=QR-CODE") + XCTAssertEqual(try PairingURL.parseFields(wrapped).pairingCode, "QR-CODE") + } + + func testSplitPairingFieldsAcceptCompleteURLInHostField() throws { + let target = try PairingURL.resolve( + host: "http://192.168.1.7:18773/pair#token=FROM-URL", + pairingCode: "" + ) + XCTAssertEqual(target.credential, "FROM-URL") + XCTAssertEqual(target.httpBaseURL.absoluteString, "http://192.168.1.7:18773/") + XCTAssertEqual(target.webSocketBaseURL.absoluteString, "ws://192.168.1.7:18773/") + } + + func testLocalNetworkProbeClassificationDistinguishesFailureModes() { + XCTAssertTrue(LocalNetworkProbe.isLocalHost("192.168.20.4")) + XCTAssertTrue(LocalNetworkProbe.isLocalHost("studio.local")) + XCTAssertFalse(LocalNetworkProbe.isLocalHost("app.t3.codes")) + + let denied = NSError( + domain: NSURLErrorDomain, + code: URLError.notConnectedToInternet.rawValue, + userInfo: [ + NSUnderlyingErrorKey: NSError( + domain: NSPOSIXErrorDomain, + code: 13 + ), + ] + ) + XCTAssertEqual( + LocalNetworkProbe.classify(denied, host: "192.168.20.4", isLocal: true), + .likelyLocalNetworkDenied("192.168.20.4") + ) + XCTAssertEqual( + LocalNetworkProbe.classify( + URLError(.timedOut), + host: "studio.local", + isLocal: true + ), + .timeout("studio.local") + ) + XCTAssertEqual( + LocalNetworkProbe.classify( + URLError(.cannotConnectToHost), + host: "studio.local", + isLocal: true + ), + .unavailableHost("studio.local") + ) + } + + func testLocalNetworkProbeAcceptsWebSocketPairingSchemes() async throws { + let transport = RecordingHTTPTransport { request in + let body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {} + } + """ + return (Data(body.utf8), transportResponse(request)) + } + let result = try await LocalNetworkProbe(transport: transport).probe( + address: "wss://studio.example" + ) + + XCTAssertEqual(result.baseURL.absoluteString, "https://studio.example/") + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.url?.scheme, "https") + XCTAssertEqual(request.url?.path, "/.well-known/t3/environment") + } + +} + +private func paginationThreadFixture() -> OrchestrationThread { + OrchestrationThread( + id: "thread-1", + projectId: "project-1", + title: "Long native thread", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "main", + worktreePath: nil, + latestTurn: nil, + createdAt: "2026-08-06T12:00:00.000Z", + updatedAt: "2026-08-06T12:00:00.000Z", + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: [], + activities: [], + checkpoints: [], + session: nil + ) +} + +private func transportResponse( + _ request: URLRequest, + status: Int = 200, + headers: [String: String] = ["Content-Type": "application/json"] +) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: headers + )! +} + +private actor RecordingHTTPTransport: HTTPTransport { + typealias Handler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + private(set) var requests: [URLRequest] = [] + private let handler: Handler + + init(handler: @escaping Handler) { + self.handler = handler + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + return try handler(request) + } +} + +private func attachmentDescriptor(_ capabilities: String) throws -> EnvironmentDescriptor { + try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": \(capabilities) + } + """.utf8 + ) + ) +} + +private struct StaticWebSocketConnector: WebSocketConnecting { + let connection: RecordingWebSocketConnection + + func connect(to url: URL) async throws -> any WebSocketConnection { + await connection.recordConnectionURL(url) + return connection + } +} + +private struct FailingWebSocketConnector: WebSocketConnecting { + func connect(to _: URL) async throws -> any WebSocketConnection { + throw URLError(.cannotConnectToHost) + } +} + +private actor RecordingWebSocketConnection: WebSocketConnection { + private let assetErrorMessage: String? + private let holdAttachmentDeletion: Bool + private var attachmentDeletionStarted = false + private var attachmentDeletionWaiter: CheckedContinuation? + private var recordedRequests: [JSONValue] = [] + private var recordedConnectionURLs: [URL] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + init(assetErrorMessage: String? = nil, holdAttachmentDeletion: Bool = false) { + self.assetErrorMessage = assetErrorMessage + self.holdAttachmentDeletion = holdAttachmentDeletion + } + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + recordedRequests.append(request) + guard case let .number(rawID) = request["id"] else { return } + if request["tag"]?.stringValue == "attachments.delete", holdAttachmentDeletion { + attachmentDeletionStarted = true + attachmentDeletionWaiter?.resume() + attachmentDeletionWaiter = nil + return + } + if request["tag"]?.stringValue == "assets.createUrl", let assetErrorMessage { + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(rawID), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Fail"), + "error": .object(["message": .string(assetErrorMessage)]), + ])]), + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + return + } + let value: JSONValue + switch request["tag"]?.stringValue { + case "attachments.createUploadUrl": + value = .object([ + "attachmentId": .string("uploaded-attachment-1"), + "relativeUrl": .string("/api/attachments/upload/signed-token"), + "expiresAt": .number(1_785_466_800_000), + ]) + case "provider.uploadFeedback": + value = .object(["feedbackId": .string("codex-thread-1")]) + case "assets.createUrl": + value = .object([ + "relativeUrl": .string("/api/assets/attachment"), + "expiresAt": .number(1_785_466_800_000), + ]) + default: + value = .object(["sequence": .number(42)]) + } + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(rawID), + "exit": .object([ + "_tag": .string("Success"), + "value": value, + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func requests() -> [JSONValue] { + recordedRequests + } + + func waitForAttachmentDeletion() async { + guard !attachmentDeletionStarted else { return } + await withCheckedContinuation { attachmentDeletionWaiter = $0 } + } + + func recordConnectionURL(_ url: URL) { + recordedConnectionURLs.append(url) + } + + func connectionURLs() -> [URL] { + recordedConnectionURLs + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} diff --git a/apps/swift-ios/Tests/CoreTests/UsageContractTests.swift b/apps/swift-ios/Tests/CoreTests/UsageContractTests.swift new file mode 100644 index 000000000000..2cc7a01cbe23 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/UsageContractTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import T3Code + +final class UsageContractTests: XCTestCase { + func testUsageSummaryDecodesCurrentWireContract() throws { + let data = Data( + #""" + { + "contractVersion": 5, + "readAt": "2026-08-09T12:00:00.000Z", + "timeZone": "America/Los_Angeles", + "sinceDay": "2026-08-03", + "untilDay": "2026-08-09", + "buckets": [{ + "day": "2026-08-09", + "hourStart": "2026-08-09T12:00:00.000Z", + "provider": "grok", + "model": "grok-code-fast-1", + "totals": { + "uncachedInputTokens": 100, + "cachedInputTokens": 200, + "cacheCreationTokens": 30, + "outputTokens": 40, + "reasoningTokens": 10 + }, + "costUsd": 1.25, + "cacheSavingsUsd": 2.5, + "costSource": "modelPriced", + "records": 2, + "unpricedRecords": 0, + "sessions": 1 + }], + "sources": [{ + "fingerprint": { + "hostId": "mac-1", + "provider": "grok", + "resolvedHomePath": "/Users/theo/.grok", + "volumeId": "1:2" + }, + "status": "ok", + "scannedFiles": 3, + "skippedFiles": 0, + "malformedRecords": 0, + "distinctSessions": 1, + "message": null + }], + "pricing": { + "status": "fresh", + "source": "LiteLLM", + "fetchedAt": "2026-08-09T11:00:00.000Z", + "knownModels": 200 + }, + "scanDurationMs": 14 + } + """#.utf8 + ) + + let summary = try JSONDecoder.t3.decode(UsageSummary.self, from: data) + + XCTAssertEqual(summary.contractVersion, usageContractVersion) + XCTAssertEqual(summary.buckets.first?.provider, .grok) + XCTAssertEqual(summary.sources.first?.fingerprint.provider, .grok) + XCTAssertEqual(summary.buckets.first?.totals.cachedInputTokens, 200) + XCTAssertEqual(summary.sources.first?.fingerprint.volumeId, "1:2") + } +} diff --git a/apps/swift-ios/Tests/CoreTests/UsageLimitsContractTests.swift b/apps/swift-ios/Tests/CoreTests/UsageLimitsContractTests.swift new file mode 100644 index 000000000000..30f44017e11e --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/UsageLimitsContractTests.swift @@ -0,0 +1,283 @@ +import Foundation +import XCTest +@testable import T3Code + +final class UsageLimitsContractTests: XCTestCase { + func testUsageLimitsDecodeFractionalPercentWithoutOptionalFields() throws { + let data = Data( + #""" + { + "checkedAt": "2026-09-05T12:00:00.000Z", + "windows": [{ + "id": "primary", + "kind": "session", + "label": "Session", + "usedPercent": 12.5 + }] + } + """#.utf8 + ) + + let limits = try JSONDecoder.t3.decode(ServerProviderUsageLimits.self, from: data) + + XCTAssertEqual(limits, ServerProviderUsageLimits( + checkedAt: "2026-09-05T12:00:00.000Z", + windows: [ServerProviderUsageWindow( + id: "primary", kind: .session, label: "Session", usedPercent: 12.5 + )] + )) + } + + func testUsageLimitsSkipUnknownAndMalformedWindows() throws { + let data = Data( + #""" + { + "checkedAt": "2026-09-05T12:00:00.000Z", + "windows": [ + {"id":"primary","kind":"session","label":"Session","usedPercent":0}, + {"id":"future","kind":"yearly","label":"Yearly","usedPercent":10}, + null, + 42, + {"id":"bad-percent","kind":"weekly","label":"Weekly","usedPercent":"unknown"}, + {"kind":"weekly","label":"Missing ID","usedPercent":10}, + { + "id": "weekly", + "kind": "weekly", + "label": "Weekly", + "usedPercent": 100, + "resetsAt": "2026-09-12T12:00:00.000Z", + "windowDurationMins": 10080 + } + ], + "resetCredits": {"availableCount":2}, + "unavailable": {"reason":"probeFailed","message":"Provider did not respond."} + } + """#.utf8 + ) + + let limits = try JSONDecoder.t3.decode(ServerProviderUsageLimits.self, from: data) + + XCTAssertEqual(limits.windows.map(\.id), ["primary", "weekly"]) + XCTAssertEqual(limits.windows.last, ServerProviderUsageWindow( + id: "weekly", + kind: .weekly, + label: "Weekly", + usedPercent: 100, + resetsAt: "2026-09-12T12:00:00.000Z", + windowDurationMins: 10080 + )) + XCTAssertEqual(limits.resetCredits, ServerProviderResetCredits(availableCount: 2)) + XCTAssertEqual(limits.unavailable, ServerProviderUsageLimits.Unavailable( + reason: .probeFailed, message: "Provider did not respond." + )) + } + + func testResetCreditsAndUnavailableDecodeWithoutOptionalFields() throws { + let data = Data( + #""" + { + "checkedAt": "2026-09-05T12:00:00.000Z", + "windows": [], + "resetCredits": {"availableCount":0}, + "unavailable": {"reason":"unsupported"} + } + """#.utf8 + ) + + let limits = try JSONDecoder.t3.decode(ServerProviderUsageLimits.self, from: data) + + XCTAssertEqual(limits.resetCredits, ServerProviderResetCredits(availableCount: 0)) + XCTAssertEqual(limits.unavailable, ServerProviderUsageLimits.Unavailable(reason: .unsupported)) + } + + func testUsageLimitsKeepWindowsAndCreditsWithUnknownUnavailableReason() throws { + let data = Data( + #""" + { + "checkedAt": "2026-09-05T12:00:00.000Z", + "windows": [{ + "id": "primary", + "kind": "session", + "label": "Session", + "usedPercent": 12.5 + }], + "resetCredits": {"availableCount":2}, + "unavailable": {"reason":"futureReason","message":"A new provider notice."} + } + """#.utf8 + ) + + let limits = try JSONDecoder.t3.decode(ServerProviderUsageLimits.self, from: data) + + XCTAssertEqual(limits, ServerProviderUsageLimits( + checkedAt: "2026-09-05T12:00:00.000Z", + windows: [ServerProviderUsageWindow( + id: "primary", kind: .session, label: "Session", usedPercent: 12.5 + )], + resetCredits: ServerProviderResetCredits(availableCount: 2) + )) + } + + func testUsageLimitSourceSkipsMalformedAccounts() throws { + let data = Data( + #""" + { + "id": "source-1", + "kind": "cliproxy", + "label": "CLI Proxy", + "checkedAt": "2026-09-05T12:00:00.000Z", + "accounts": [ + { + "id": "codex-account", + "driver": "codex", + "usageLimits": {"checkedAt":"2026-09-05T12:00:00.000Z","windows":[]} + }, + null, + 42, + {"id":"missing-limits","driver":"claude"}, + {"id":"bad-limits","driver":"claude","usageLimits":{"windows":[]}}, + { + "id": "future-account", + "driver": "future-provider", + "usageLimits": {"checkedAt":"2026-09-05T12:00:00.000Z","windows":[]} + } + ] + } + """#.utf8 + ) + + let source = try JSONDecoder.t3.decode(UsageLimitSourceSnapshot.self, from: data) + + XCTAssertEqual(source.kind, .cliproxy) + XCTAssertEqual(source.accounts.map(\.id), ["codex-account", "future-account"]) + XCTAssertEqual(source.accounts.last?.driver, "future-provider") + XCTAssertNil(source.accounts.first?.email) + XCTAssertNil(source.accounts.first?.plan) + XCTAssertNil(source.error) + } + + func testUsageLimitSourceDecodesKnownFieldsAndIgnoresNewFields() throws { + let data = Data( + #""" + { + "id": "source-1", + "kind": "cliproxy", + "label": "CLI Proxy", + "checkedAt": "2026-09-05T12:00:00.000Z", + "futureSourceField": {"enabled":true}, + "accounts": [{ + "id": "codex-account", + "driver": "codex", + "email": "account@example.com", + "plan": "ChatGPT Pro", + "futureAccountField": true, + "usageLimits": { + "checkedAt": "2026-09-05T11:59:00.000Z", + "futureLimitsField": "ignored", + "windows": [ + { + "id": "monthly", + "kind": "monthly", + "label": "Monthly", + "usedPercent": 31.25, + "resetsAt": "2026-10-05T12:00:00.000Z", + "windowDurationMins": 43200, + "futureWindowField": 1 + }, + {"id":"other","kind":"other","label":"Other","usedPercent":0} + ], + "resetCredits": { + "availableCount": 2, + "nextExpiresAt": "2026-09-06T12:00:00.000Z", + "futureCreditField": [] + }, + "unavailable": { + "reason": "probeFailed", + "message": "Account refresh failed.", + "futureUnavailableField": null + } + } + }], + "error": "Some accounts could not be refreshed." + } + """#.utf8 + ) + + let source = try JSONDecoder.t3.decode(UsageLimitSourceSnapshot.self, from: data) + let expected = UsageLimitSourceSnapshot( + id: "source-1", + label: "CLI Proxy", + checkedAt: "2026-09-05T12:00:00.000Z", + accounts: [UsageLimitSourceAccount( + id: "codex-account", + driver: "codex", + email: "account@example.com", + plan: "ChatGPT Pro", + usageLimits: ServerProviderUsageLimits( + checkedAt: "2026-09-05T11:59:00.000Z", + windows: [ + ServerProviderUsageWindow( + id: "monthly", + kind: .monthly, + label: "Monthly", + usedPercent: 31.25, + resetsAt: "2026-10-05T12:00:00.000Z", + windowDurationMins: 43200 + ), + ServerProviderUsageWindow( + id: "other", kind: .other, label: "Other", usedPercent: 0 + ), + ], + resetCredits: ServerProviderResetCredits( + availableCount: 2, nextExpiresAt: "2026-09-06T12:00:00.000Z" + ), + unavailable: .init(reason: .probeFailed, message: "Account refresh failed.") + ) + )], + error: "Some accounts could not be refreshed." + ) + + XCTAssertEqual(source, expected) + XCTAssertEqual( + try JSONDecoder.t3.decode(UsageLimitSourceSnapshot.self, from: JSONEncoder.t3.encode(source)), + expected + ) + } + + func testUsageLimitSourceRejectsUnknownKind() { + let data = Data( + #""" + { + "id": "source-1", + "kind": "future-source", + "label": "New source", + "checkedAt": "2026-09-05T12:00:00.000Z", + "accounts": [] + } + """#.utf8 + ) + + XCTAssertThrowsError(try JSONDecoder.t3.decode(UsageLimitSourceSnapshot.self, from: data)) + } + + func testResetCreditOutcomesDecodeServerValues() throws { + let outcomes: [(String, ProviderConsumeResetCreditOutcome)] = [ + ("reset", .reset), + ("nothingToReset", .nothingToReset), + ("noCredit", .noCredit), + ("alreadyRedeemed", .alreadyRedeemed), + ] + + for (wireValue, outcome) in outcomes { + let data = Data(#"{"outcome":"\#(wireValue)","futureField":true}"#.utf8) + let result = try JSONDecoder.t3.decode(ProviderConsumeResetCreditResult.self, from: data) + XCTAssertEqual(result, ProviderConsumeResetCreditResult(outcome: outcome)) + } + } + + func testResetCreditOutcomeRejectsUnknownValue() { + let data = Data(#"{"outcome":"pending"}"#.utf8) + + XCTAssertThrowsError(try JSONDecoder.t3.decode(ProviderConsumeResetCreditResult.self, from: data)) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift b/apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift new file mode 100644 index 000000000000..b19ebad22769 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift @@ -0,0 +1,1540 @@ +import XCTest +@testable import T3Code + +@MainActor +final class WebSocketRPCRaceTests: XCTestCase { + func testColdSubscriptionRetainsFailedSocketIdentity() async throws { + let connection = SubscriptionTrafficConnection(sendsInvalidSubscriptionValue: true) + let connector = GatedConnector(connection: connection) + let client = WebSocketRPCClient( + connector: connector, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + let pending = Task { + try await client.subscribeOnCurrentConnection("thread.events", as: Int.self) + } + await connector.waitUntilConnectStarted() + let beforeConnection = await client.currentConnectionID() + XCTAssertNil(beforeConnection) + await connector.release() + let subscription = try await pending.value + var events = subscription.events.makeAsyncIterator() + do { + _ = try await events.next() + XCTFail("The first invalid value must terminate the cold subscription.") + } catch is DecodingError {} + let failedSocketID = await client.currentConnectionID() + XCTAssertEqual(subscription.connectionID, failedSocketID) + let waiting = Task { try await client.waitForConnection(after: subscription.connectionID) } + _ = try await client.request("server.stillHealthy", as: JSONValue.self) + waiting.cancel() + do { + _ = try await waiting.value + XCTFail("The failed socket must not satisfy the wait for its replacement.") + } catch is CancellationError {} + await client.stop() + } + + func testConnectionWaitResumesOnlyForAReplacementSocket() async throws { + let first = AutoReplyConnection() + let second = AutoReplyConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [first, second]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + _ = try await client.request("server.first", as: JSONValue.self) + let firstID = await client.currentConnectionID() + XCTAssertNotNil(firstID) + let waiting = Task { try await client.waitForConnection(after: firstID) } + _ = try await client.request("server.stillFirst", as: JSONValue.self) + await client.reconnect() + let nextID = try await waiting.value + XCTAssertNotEqual(nextID, firstID) + let currentID = await client.currentConnectionID() + XCTAssertEqual(nextID, currentID) + await client.stop() + } + + func testConnectionWaitEndsOnCancellationAndStop() async throws { + for cancel in [true, false] { + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [AutoReplyConnection()]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + _ = try await client.request("server.first", as: JSONValue.self) + let firstID = await client.currentConnectionID() + let waiting = Task { try await client.waitForConnection(after: firstID) } + _ = try await client.request("server.stillFirst", as: JSONValue.self) + if cancel { waiting.cancel() } + else { await client.stop() } + do { + _ = try await waiting.value + XCTFail("A canceled or stopped connection wait must finish.") + } catch is CancellationError { + XCTAssertTrue(cancel) + } catch let error as RPCError { + guard case .disconnected = error, !cancel else { + await client.stop() + return XCTFail("Unexpected connection wait failure: \(error)") + } + } + await client.stop() + } + } + + func testResponseDeadlineStartsAfterConnectionAndSend() async throws { + let connection = AutoReplyConnection() + let connector = GatedConnector(connection: connection) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(1), + responseTimeout: .milliseconds(40), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let request = Task { + try await client.request("server.repliesAfterConnect", as: JSONValue.self) + } + await connector.waitUntilConnectStarted() + try await Task.sleep(for: .milliseconds(80)) + await connector.release() + + let response = try await request.value + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testSendFailureDropsDeadSocketAndReconnects() async throws { + let failed = SendFailingConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [failed, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + do { + _ = try await client.request("server.firstSendFails", as: JSONValue.self) + XCTFail("A failed socket send must fail its unary request.") + } catch let error as RPCError { + guard case .disconnected = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + + await connector.waitUntilConnectionCount(2) + let discardedReceiveCount = await failed.receiveCallCount() + XCTAssertEqual( + discardedReceiveCount, + 0, + "Setup must not start receiving on a socket discarded by a queued send." + ) + let isConnected = await client.isConnected() + XCTAssertTrue(isConnected) + let response = try await client.request("server.afterReconnect", as: JSONValue.self) + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testUnansweredKeepaliveReconnectsAHalfOpenSocket() async throws { + let silent = BlockingReceiveConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [silent, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(1), + keepaliveInterval: .milliseconds(10), + reconnectBackoff: { _ in .zero }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connector.waitUntilConnectionCount(2) + + let response = try await client.request("server.afterKeepalive", as: JSONValue.self) + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testValidInboundTrafficSatisfiesKeepaliveWithoutPong() async { + let connection = SubscriptionTrafficConnection(respondsToPingsWithChunks: true) + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + keepaliveInterval: .milliseconds(10), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe("thread.events", as: JSONValue.self) + await connection.waitUntilPingCount(3) + + let isConnected = await client.isConnected() + XCTAssertTrue(isConnected, "Valid stream traffic proves that the socket is still alive.") + _ = stream + await client.stop() + } + + func testSubscriptionOverflowReconnectsAndRestoresLiveEventsWithoutAcknowledgingDroppedEvents() async throws { + let overflowing = OverflowingSubscriptionConnection() + let recovered = SubscriptionTrafficConnection() + let connector = SequencedConnector(connections: [overflowing, recovered]) + let client = WebSocketRPCClient( + connector: connector, + subscriptionBufferLimit: 2, + reconnectBackoff: { _ in .zero }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe("thread.events", as: JSONValue.self) + await connector.waitUntilConnectionCount(2) + await recovered.waitUntilSubscriptionStarted() + + var iterator = stream.makeAsyncIterator() + let firstValue = try await iterator.next() + let secondValue = try await iterator.next() + XCTAssertEqual(firstValue, .string("first")) + XCTAssertEqual(secondValue, .string("second")) + try await recovered.sendSubscriptionValue(.string("recovered")) + let recoveredValue = try await iterator.next() + XCTAssertEqual(recoveredValue, .string("recovered")) + + let acknowledgementCount = await overflowing.acknowledgementCount() + XCTAssertEqual(acknowledgementCount, 0) + + let response = try await client.request("server.afterOverflow", as: JSONValue.self) + XCTAssertEqual(response, .object(["ok": .bool(true)])) + let recoveredRequestTags = await recovered.requestTags() + XCTAssertEqual(recoveredRequestTags, ["thread.events", "server.afterOverflow"]) + await client.stop() + } + + func testOneShotSubscriptionOverflowFailsWithoutReplayingItsCommand() async throws { + let overflowing = OverflowingSubscriptionConnection() + let recovered = SubscriptionTrafficConnection() + let connector = SequencedConnector(connections: [overflowing, recovered]) + let client = WebSocketRPCClient( + connector: connector, + subscriptionBufferLimit: 2, + reconnectBackoff: { _ in .zero }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe( + "git.runAction", + reconnect: false, + as: JSONValue.self + ) + await connector.waitUntilConnectionCount(2) + + var iterator = stream.makeAsyncIterator() + let firstValue = try await iterator.next() + let secondValue = try await iterator.next() + XCTAssertEqual(firstValue, .string("first")) + XCTAssertEqual(secondValue, .string("second")) + do { + _ = try await iterator.next() + XCTFail("An overflowing command stream must finish with its protocol error.") + } catch let error as RPCError { + guard case .protocolViolation = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + + let acknowledgementCount = await overflowing.acknowledgementCount() + XCTAssertEqual(acknowledgementCount, 0) + + let response = try await client.request("server.afterOneShotOverflow", as: JSONValue.self) + XCTAssertEqual(response, .object(["ok": .bool(true)])) + let recoveredRequestTags = await recovered.requestTags() + XCTAssertEqual(recoveredRequestTags, ["server.afterOneShotOverflow"]) + await client.stop() + } + + func testTerminatedSubscriptionDoesNotDisconnectSharedSocket() async throws { + let connection = SubscriptionTrafficConnection(sendsInvalidSubscriptionValue: true) + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe("thread.events", as: Int.self) + var iterator = stream.makeAsyncIterator() + do { + _ = try await iterator.next() + XCTFail("An invalid stream event must terminate its subscription.") + } catch is DecodingError {} + + let wasInterrupted = await connection.waitUntilSubscriptionEnded() + XCTAssertTrue(wasInterrupted, "The subscription should end without closing the socket.") + + let response = try await client.request("server.afterSubscriptionFailure", as: JSONValue.self) + XCTAssertEqual(response, .object(["ok": .bool(true)])) + await client.stop() + } + + func testHungSendTimesOutAndReconnectsWithAFreshResponseWindow() async throws { + let hung = HungSendConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [hung, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + responseTimeout: .milliseconds(40), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let first = Task { + try await client.request("server.sendNeverReturns", as: JSONValue.self) + } + await hung.waitUntilSending() + do { + _ = try await first.value + XCTFail("A send that never completes must have an in-flight deadline.") + } catch let error as RPCError { + guard case .responseTimedOut = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + + await connector.waitUntilConnectionCount(2) + let response = try await client.request("server.afterHungSend", as: JSONValue.self) + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testSentUnaryTimesOutAndLateTrafficCannotCompleteItTwice() async throws { + let connection = DeadlineWebSocketConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + connectionWaitTimeout: .seconds(2), + responseTimeout: .milliseconds(40), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let first = Task { + try await client.request("server.neverReplies", as: JSONValue.self) + } + await connection.waitUntilRequestCount(1) + + do { + _ = try await first.value + XCTFail("A sent unary request must have a response deadline.") + } catch let error as RPCError { + guard case .responseTimedOut = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + await connection.waitUntilInterruptCount(1) + + // A response for the expired request is harmless, and the same socket + // remains able to serve a subsequent unary call. + try await connection.replyToRequest(at: 0) + let second = try await client.request("server.replies", as: JSONValue.self) + XCTAssertEqual(second, .object(["ok": .bool(true)])) + await client.stop() + } + + func testCancellingUnaryRemovesItAndInterruptsSentWork() async throws { + let connection = DeadlineWebSocketConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + connectionWaitTimeout: .seconds(2), + responseTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let request = Task { + try await client.request("server.cancelled", as: JSONValue.self) + } + await connection.waitUntilRequestCount(1) + request.cancel() + + do { + _ = try await request.value + XCTFail("Cancelling the caller must cancel its unary continuation.") + } catch is CancellationError {} + await connection.waitUntilInterruptCount(1) + + try await connection.replyToRequest(at: 0) + let next = try await client.request("server.stillHealthy", as: JSONValue.self) + XCTAssertEqual(next, .object(["ok": .bool(true)])) + await client.stop() + } + + func testRequestEnteringAlreadyCancelledNeverInstallsOrSends() async throws { + let connection = AutoReplyConnection() + let gate = RequestCancellationGate() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + let request = Task { + await gate.wait() + return try await client.request("server.cancelledBeforeInstall", as: JSONValue.self) + } + await gate.waitUntilEntered() + request.cancel() + await gate.release() + + do { + _ = try await request.value + XCTFail("An already-cancelled request must fail before installation") + } catch is CancellationError {} + let sentRequestCount = await connection.sentRequestCount() + XCTAssertEqual(sentRequestCount, 0) + await client.stop() + } + + func testDisconnectWhileUnarySendIsSuspendedFailsWithoutReplay() async throws { + let first = SuspendedSendConnection() + let second = AutoReplyConnection() + let connector = SequencedConnector(connections: [first, second]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await first.waitUntilReceiving() + + let request = Task { + do { + let value = try await client.request( + "thread.rename", + as: JSONValue.self + ) + return Result.success(value) + } catch { + return Result.failure(error) + } + } + + await first.waitUntilSending() + await first.failReceive() + + let outcome = await request.value + guard case let .failure(error) = outcome, + case .disconnected = error as? RPCError + else { + await client.stop() + await first.releaseSend() + return XCTFail("An ambiguous unary send must fail as disconnected.") + } + + await connector.waitUntilConnectionCount(2) + let replayedRequestCount = await second.sentRequestCount() + XCTAssertEqual( + replayedRequestCount, + 0, + "A unary request that crossed a broken socket must not be replayed." + ) + + await client.stop() + await first.releaseSend() + } + + func testStopDuringConnectClosesTheLateSocketWithoutPublishingIt() async { + let lateConnection = CloseTrackingConnection() + let connector = GatedConnector(connection: lateConnection) + let client = WebSocketRPCClient( + connector: connector, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connector.waitUntilConnectStarted() + await client.stop() + await connector.release() + await lateConnection.waitUntilClosed() + + let isConnected = await client.isConnected() + XCTAssertFalse(isConnected, "A socket returned after stop must never become active.") + let receiveCount = await lateConnection.receiveCallCount() + XCTAssertEqual(receiveCount, 0) + } + + func testConnectionLoopDoesNotRetainReleasedClient() async { + let connection = BlockingStopConnection() + let connector = SequencedConnector(connections: [connection]) + var client: WebSocketRPCClient? = WebSocketRPCClient( + connector: connector, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + weak var releasedClient: WebSocketRPCClient? + releasedClient = client + + await client?.start() + await connection.waitUntilReceiving() + client = nil + + XCTAssertNil(releasedClient, "The reconnect task must not own the RPC client.") + await connection.waitUntilCloseStarted() + await connection.releaseClose() + } + + func testRestartWhileOldSocketClosesKeepsTheNewConnection() async throws { + let closing = BlockingStopConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [closing, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await closing.waitUntilReceiving() + + let stop = Task { await client.stop() } + await closing.waitUntilCloseStarted() + let request = Task { + try await client.request("server.afterRestart", as: JSONValue.self) + } + await connector.waitUntilConnectionCount(2) + await closing.releaseClose() + await stop.value + + let response = try await request.value + XCTAssertEqual(response, .object([:])) + let isConnected = await client.isConnected() + XCTAssertTrue(isConnected, "Completing an old stop must not clear a newer socket.") + await client.stop() + } + + func testSubscriptionRoutesChunkBeforeSuspendedSendReturns() async throws { + let connection = SubscriptionSendRaceConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connection.waitUntilReceiveCount(1) + let stream = await client.subscribe("thread.events", as: JSONValue.self) + var iterator = stream.makeAsyncIterator() + await connection.waitUntilRequestSuspended() + await connection.waitUntilReceiveCount(2) + await connection.releaseRequest() + + let value = try await iterator.next() + XCTAssertEqual(value, .object(["event": .string("ready")])) + await client.stop() + } + + func testCancellingSubscriptionWhileSendIsSuspendedInterruptsWithoutResurrection() async { + let connection = SubscriptionSendRaceConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connection.waitUntilReceiveCount(1) + var stream: AsyncThrowingStream? = await client.subscribe( + "thread.events", + as: JSONValue.self + ) + let consumer = Task { + var iterator = stream!.makeAsyncIterator() + return try await iterator.next() + } + await connection.waitUntilRequestSuspended() + consumer.cancel() + stream = nil + _ = try? await consumer.value + + let observedInterrupt = await connection.observesInterrupt() + XCTAssertTrue(observedInterrupt) + await connection.releaseRequest() + await client.stop() + } + + func testConnectionSetupAndSubscribeRaceSendsOneWireRequest() async throws { + let connection = SetupSubscriptionRaceConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let request = Task { + try await client.request("server.setupBarrier", as: JSONValue.self) + } + await connection.waitUntilUnarySendSuspends() + + let stream = await client.subscribe("thread.events", as: JSONValue.self) + await connection.waitUntilSubscriptionSendSuspends() + await connection.releaseUnarySend() + _ = try await request.value + + let subscriptionRequestCount = await connection.subscriptionRequestCount() + XCTAssertEqual( + subscriptionRequestCount, + 1, + "Connection setup and subscribe() must not both own the same subscription send." + ) + _ = stream + await connection.releaseSubscriptionSend() + await client.stop() + } + + func testReconnectBackoffResetsOnlyAfterValidInboundTraffic() async { + let connector = BackoffSequenceConnector() + let recorder = BackoffRecorder() + let client = WebSocketRPCClient( + connector: connector, + reconnectBackoff: { failureCount in + recorder.record(failureCount) + return .zero + }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connector.waitUntilAttemptCount(4) + + XCTAssertEqual( + recorder.values, + [1, 2, 1], + "Merely opening a socket must not reset backoff; a decoded server frame should." + ) + await client.stop() + } +} + +private final class BackoffRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Int] = [] + + var values: [Int] { + lock.withLock { recorded } + } + + func record(_ value: Int) { + lock.withLock { recorded.append(value) } + } +} + +private actor BackoffSequenceConnector: WebSocketConnecting { + private let provenConnection = PongThenFailConnection() + private let finalConnection = BlockingReceiveConnection() + private var attemptCount = 0 + private var waiters: [(Int, CheckedContinuation)] = [] + + func connect(to _: URL) throws -> any WebSocketConnection { + attemptCount += 1 + let ready = waiters.filter { attemptCount >= $0.0 } + waiters.removeAll { attemptCount >= $0.0 } + ready.forEach { $0.1.resume() } + switch attemptCount { + case 1, 2: + throw URLError(.cannotConnectToHost) + case 3: + return provenConnection + default: + return finalConnection + } + } + + func waitUntilAttemptCount(_ count: Int) async { + guard attemptCount < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor PongThenFailConnection: WebSocketConnection { + private var sentPong = false + + func send(_: Data) {} + + func receive() throws -> Data { + guard !sentPong else { throw URLError(.networkConnectionLost) } + sentPong = true + return try JSONEncoder.t3.encode(JSONValue.object(["_tag": .string("Pong")])) + } + + func close() {} +} + +private actor BlockingReceiveConnection: WebSocketConnection { + private var continuation: CheckedContinuation? + + func send(_: Data) {} + + func receive() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + func close() { + continuation?.resume(throwing: CancellationError()) + continuation = nil + } +} + +private actor SubscriptionTrafficConnection: WebSocketConnection { + private let respondsToPingsWithChunks: Bool + private let sendsInvalidSubscriptionValue: Bool + private var subscriptionRequestID: Int? + private var sentRequestTags: [String] = [] + private var subscriptionStartWaiters: [CheckedContinuation] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var pingCount = 0 + private var pingWaiters: [(Int, CheckedContinuation)] = [] + private var subscriptionEnded: Bool? + private var subscriptionEndWaiters: [CheckedContinuation] = [] + + init( + respondsToPingsWithChunks: Bool = false, + sendsInvalidSubscriptionValue: Bool = false + ) { + self.respondsToPingsWithChunks = respondsToPingsWithChunks + self.sendsInvalidSubscriptionValue = sendsInvalidSubscriptionValue + } + + func send(_ data: Data) throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + switch envelope["_tag"]?.stringValue { + case "Request": + guard case let .number(rawID)? = envelope["id"], + let requestID = Int(exactly: rawID) else { return } + let requestTag = envelope["tag"]?.stringValue ?? "" + sentRequestTags.append(requestTag) + if requestTag == "thread.events" { + subscriptionRequestID = requestID + let waiters = subscriptionStartWaiters + subscriptionStartWaiters.removeAll() + waiters.forEach { $0.resume() } + if sendsInvalidSubscriptionValue { + enqueue(try chunk(requestID: requestID, value: .string("not an integer"))) + } + } else { + enqueue(try success(requestID: requestID)) + } + case "Ping": + pingCount += 1 + if respondsToPingsWithChunks, let subscriptionRequestID { + enqueue(try chunk(requestID: subscriptionRequestID, value: .string("alive"))) + } + let ready = pingWaiters.filter { pingCount >= $0.0 } + pingWaiters.removeAll { pingCount >= $0.0 } + ready.forEach { $0.1.resume() } + case "Interrupt": + finishSubscription(interrupted: true) + default: + break + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + finishSubscription(interrupted: false) + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilPingCount(_ count: Int) async { + guard pingCount < count else { return } + await withCheckedContinuation { continuation in + pingWaiters.append((count, continuation)) + } + } + + func waitUntilSubscriptionEnded() async -> Bool { + if let subscriptionEnded { return subscriptionEnded } + return await withCheckedContinuation { continuation in + subscriptionEndWaiters.append(continuation) + } + } + + func waitUntilSubscriptionStarted() async { + guard subscriptionRequestID == nil else { return } + await withCheckedContinuation { continuation in + subscriptionStartWaiters.append(continuation) + } + } + + func sendSubscriptionValue(_ value: JSONValue) throws { + guard let subscriptionRequestID else { return } + enqueue(try chunk(requestID: subscriptionRequestID, value: value)) + } + + func requestTags() -> [String] { + sentRequestTags + } + + private func finishSubscription(interrupted: Bool) { + guard subscriptionEnded == nil else { return } + subscriptionEnded = interrupted + let waiters = subscriptionEndWaiters + subscriptionEndWaiters.removeAll() + waiters.forEach { $0.resume(returning: interrupted) } + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func chunk(requestID: Int, value: JSONValue) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(Double(requestID)), + "values": .array([value]), + ]) + ) + } + + private func success(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .object(["ok": .bool(true)]), + ]), + ]) + ) + } +} + +private actor OverflowingSubscriptionConnection: WebSocketConnection { + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var acknowledgements = 0 + + func send(_ data: Data) throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if envelope["_tag"]?.stringValue == "Ack" { + acknowledgements += 1 + return + } + guard envelope["_tag"]?.stringValue == "Request", + case let .number(requestID)? = envelope["id"] else { return } + let chunk = JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(requestID), + "values": .array([ + .string("first"), + .string("second"), + .string("overflow"), + ]), + ]) + let response = try JSONEncoder.t3.encode(chunk) + if let receiver { + self.receiver = nil + receiver.resume(returning: response) + } else { + queuedResponses.append(response) + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func acknowledgementCount() -> Int { + acknowledgements + } +} + +private actor SetupSubscriptionRaceConnection: WebSocketConnection { + private var unaryRequestID: Int? + private var unarySendContinuation: CheckedContinuation? + private var unaryWaiters: [CheckedContinuation] = [] + private var subscriptionSends = 0 + private var subscriptionSendContinuation: CheckedContinuation? + private var subscriptionWaiters: [CheckedContinuation] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) async throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard envelope["_tag"]?.stringValue == "Request", + case let .number(rawID)? = envelope["id"], + let requestID = Int(exactly: rawID) + else { return } + + if envelope["tag"]?.stringValue == "server.setupBarrier" { + unaryRequestID = requestID + let waiters = unaryWaiters + unaryWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { unarySendContinuation = $0 } + enqueue(try success(requestID: requestID)) + } else { + subscriptionSends += 1 + let waiters = subscriptionWaiters + subscriptionWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { subscriptionSendContinuation = $0 } + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { return queuedResponses.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + unarySendContinuation?.resume() + unarySendContinuation = nil + subscriptionSendContinuation?.resume() + subscriptionSendContinuation = nil + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilUnarySendSuspends() async { + guard unaryRequestID == nil else { return } + await withCheckedContinuation { unaryWaiters.append($0) } + } + + func waitUntilSubscriptionSendSuspends() async { + guard subscriptionSends == 0 else { return } + await withCheckedContinuation { subscriptionWaiters.append($0) } + } + + func releaseUnarySend() { + unarySendContinuation?.resume() + unarySendContinuation = nil + } + + func releaseSubscriptionSend() { + subscriptionSendContinuation?.resume() + subscriptionSendContinuation = nil + } + + func subscriptionRequestCount() -> Int { subscriptionSends } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func success(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .object([:]), + ]), + ]) + ) + } +} + +private actor RequestCancellationGate { + private var entered = false + private var released = false + private var releaseContinuation: CheckedContinuation? + private var entryWaiters: [CheckedContinuation] = [] + + func wait() async { + entered = true + let waiters = entryWaiters + entryWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !released else { return } + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitUntilEntered() async { + guard !entered else { return } + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + + func release() { + released = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor SubscriptionSendRaceConnection: WebSocketConnection { + private var requestID: Int? + private var requestContinuation: CheckedContinuation? + private var requestWaiters: [CheckedContinuation] = [] + private var receiveContinuation: CheckedContinuation? + private var receiveCount = 0 + private var receiveWaiters: [(Int, CheckedContinuation)] = [] + private var queuedResponses: [Data] = [] + private var interruptCount = 0 + private var interruptWaiters: [CheckedContinuation] = [] + + func send(_ data: Data) async throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + switch envelope["_tag"]?.stringValue { + case "Request": + guard requestID == nil, + case let .number(rawID)? = envelope["id"], + let id = Int(exactly: rawID) else { return } + requestID = id + enqueue(try chunk(requestID: id)) + let waiters = requestWaiters + requestWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { continuation in + requestContinuation = continuation + } + enqueue(try exit(requestID: id)) + case "Interrupt": + interruptCount += 1 + let waiters = interruptWaiters + interruptWaiters.removeAll() + waiters.forEach { $0.resume() } + default: + return + } + } + + func receive() async throws -> Data { + receiveCount += 1 + let ready = receiveWaiters.filter { receiveCount >= $0.0 } + receiveWaiters.removeAll { receiveCount >= $0.0 } + ready.forEach { $0.1.resume() } + if !queuedResponses.isEmpty { return queuedResponses.removeFirst() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + requestContinuation?.resume() + requestContinuation = nil + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func waitUntilRequestSuspended() async { + guard requestID == nil else { return } + await withCheckedContinuation { continuation in + requestWaiters.append(continuation) + } + } + + func waitUntilReceiveCount(_ count: Int) async { + guard receiveCount < count else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append((count, continuation)) + } + } + + func releaseRequest() { + requestContinuation?.resume() + requestContinuation = nil + } + + func observesInterrupt() async -> Bool { + if interruptCount > 0 { return true } + return await withTaskGroup(of: Bool.self) { group in + group.addTask { + await self.waitUntilInterrupt() + return true + } + group.addTask { + try? await Task.sleep(for: .milliseconds(500)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + } + + private func waitUntilInterrupt() async { + guard interruptCount == 0 else { return } + await withCheckedContinuation { continuation in + interruptWaiters.append(continuation) + } + } + + private func enqueue(_ data: Data) { + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func chunk(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(Double(requestID)), + "values": .array([.object(["event": .string("ready")])]), + ]) + ) + } + + private func exit(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .null, + ]), + ]) + ) + } +} + +private actor CloseTrackingConnection: WebSocketConnection { + private var closed = false + private var closeWaiters: [CheckedContinuation] = [] + private var receives = 0 + + func send(_: Data) {} + + func receive() throws -> Data { + receives += 1 + throw URLError(.cannotLoadFromNetwork) + } + + func close() { + closed = true + let waiters = closeWaiters + closeWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func waitUntilClosed() async { + guard !closed else { return } + await withCheckedContinuation { continuation in + closeWaiters.append(continuation) + } + } + + func receiveCallCount() -> Int { + receives + } +} + +private actor BlockingStopConnection: WebSocketConnection { + private var receiveContinuation: CheckedContinuation? + private var receiveWaiters: [CheckedContinuation] = [] + private var closeStarted = false + private var closeReleased = false + private var closeStartWaiters: [CheckedContinuation] = [] + private var closeReleaseWaiters: [CheckedContinuation] = [] + + func send(_: Data) throws { + throw URLError(.networkConnectionLost) + } + + func receive() async throws -> Data { + let waiters = receiveWaiters + receiveWaiters.removeAll() + waiters.forEach { $0.resume() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() async { + if !closeStarted { + closeStarted = true + let waiters = closeStartWaiters + closeStartWaiters.removeAll() + waiters.forEach { $0.resume() } + } + if !closeReleased { + await withCheckedContinuation { continuation in + closeReleaseWaiters.append(continuation) + } + } + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func waitUntilReceiving() async { + guard receiveContinuation == nil else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func waitUntilCloseStarted() async { + guard !closeStarted else { return } + await withCheckedContinuation { continuation in + closeStartWaiters.append(continuation) + } + } + + func releaseClose() { + closeReleased = true + let waiters = closeReleaseWaiters + closeReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor DeadlineWebSocketConnection: WebSocketConnection { + private var requestIDs: [Int] = [] + private var interruptIDs: [Int] = [] + private var requestWaiters: [(Int, CheckedContinuation)] = [] + private var interruptWaiters: [(Int, CheckedContinuation)] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + switch envelope["_tag"]?.stringValue { + case "Request": + guard case let .number(rawID)? = envelope["id"], + let requestID = Int(exactly: rawID) else { + return + } + requestIDs.append(requestID) + resumeRequestWaiters() + if requestIDs.count > 1 { + enqueue(try response(requestID: requestID)) + } + case "Interrupt": + guard case let .number(rawID)? = envelope["requestId"], + let requestID = Int(exactly: rawID) else { + return + } + interruptIDs.append(requestID) + resumeInterruptWaiters() + default: + break + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilRequestCount(_ count: Int) async { + guard requestIDs.count < count else { return } + await withCheckedContinuation { continuation in + requestWaiters.append((count, continuation)) + } + } + + func waitUntilInterruptCount(_ count: Int) async { + guard interruptIDs.count < count else { return } + await withCheckedContinuation { continuation in + interruptWaiters.append((count, continuation)) + } + } + + func replyToRequest(at index: Int) throws { + enqueue(try response(requestID: requestIDs[index])) + } + + private func response(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .object(["ok": .bool(true)]), + ]), + ]) + ) + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func resumeRequestWaiters() { + let ready = requestWaiters.filter { requestIDs.count >= $0.0 } + requestWaiters.removeAll { requestIDs.count >= $0.0 } + ready.forEach { $0.1.resume() } + } + + private func resumeInterruptWaiters() { + let ready = interruptWaiters.filter { interruptIDs.count >= $0.0 } + interruptWaiters.removeAll { interruptIDs.count >= $0.0 } + ready.forEach { $0.1.resume() } + } +} + +private actor SequencedConnector: WebSocketConnecting { + private let connections: [any WebSocketConnection] + private var nextIndex = 0 + private var countWaiters: [(Int, CheckedContinuation)] = [] + + init(connections: [any WebSocketConnection]) { + self.connections = connections + } + + func connect(to _: URL) throws -> any WebSocketConnection { + guard nextIndex < connections.count else { + throw URLError(.cannotConnectToHost) + } + let connection = connections[nextIndex] + nextIndex += 1 + let completed = countWaiters.filter { nextIndex >= $0.0 } + countWaiters.removeAll { nextIndex >= $0.0 } + completed.forEach { $0.1.resume() } + return connection + } + + func waitUntilConnectionCount(_ count: Int) async { + guard nextIndex < count else { return } + await withCheckedContinuation { continuation in + countWaiters.append((count, continuation)) + } + } +} + +private actor GatedConnector: WebSocketConnecting { + private let connection: any WebSocketConnection + private var releaseContinuation: CheckedContinuation? + private var connectWaiters: [CheckedContinuation] = [] + private var connectStarted = false + private var released = false + + init(connection: any WebSocketConnection) { + self.connection = connection + } + + func connect(to _: URL) async -> any WebSocketConnection { + connectStarted = true + let waiters = connectWaiters + connectWaiters.removeAll() + waiters.forEach { $0.resume() } + if !released { + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + return connection + } + + func waitUntilConnectStarted() async { + guard !connectStarted else { return } + await withCheckedContinuation { continuation in + connectWaiters.append(continuation) + } + } + + func release() { + released = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor SendFailingConnection: WebSocketConnection { + private var closed = false + private var receives = 0 + private var receiver: CheckedContinuation? + + func send(_: Data) throws { + throw URLError(.networkConnectionLost) + } + + func receive() async throws -> Data { + receives += 1 + if closed { throw URLError(.networkConnectionLost) } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + closed = true + receiver?.resume(throwing: URLError(.networkConnectionLost)) + receiver = nil + } + + func receiveCallCount() -> Int { + receives + } +} + +private actor HungSendConnection: WebSocketConnection { + private var closed = false + private var sendContinuation: CheckedContinuation? + private var sendWaiters: [CheckedContinuation] = [] + + func send(_: Data) async throws { + let waiters = sendWaiters + sendWaiters.removeAll() + waiters.forEach { $0.resume() } + try await withCheckedThrowingContinuation { continuation in + sendContinuation = continuation + } + } + + func receive() throws -> Data { + throw URLError(closed ? .networkConnectionLost : .cannotLoadFromNetwork) + } + + func close() { + closed = true + sendContinuation?.resume(throwing: URLError(.networkConnectionLost)) + sendContinuation = nil + } + + func waitUntilSending() async { + guard sendContinuation == nil else { return } + await withCheckedContinuation { continuation in + sendWaiters.append(continuation) + } + } +} + +private actor SuspendedSendConnection: WebSocketConnection { + private var sendContinuation: CheckedContinuation? + private var receiveContinuation: CheckedContinuation? + private var sendWaiters: [CheckedContinuation] = [] + private var receiveWaiters: [CheckedContinuation] = [] + + func send(_: Data) async throws { + let waiters = sendWaiters + sendWaiters.removeAll() + waiters.forEach { $0.resume() } + try await withCheckedThrowingContinuation { continuation in + sendContinuation = continuation + } + } + + func receive() async throws -> Data { + let waiters = receiveWaiters + receiveWaiters.removeAll() + waiters.forEach { $0.resume() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func waitUntilSending() async { + guard sendContinuation == nil else { return } + await withCheckedContinuation { continuation in + sendWaiters.append(continuation) + } + } + + func waitUntilReceiving() async { + guard receiveContinuation == nil else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func failReceive() { + receiveContinuation?.resume(throwing: URLError(.networkConnectionLost)) + receiveContinuation = nil + } + + func releaseSend() { + sendContinuation?.resume() + sendContinuation = nil + } +} + +private actor AutoReplyConnection: WebSocketConnection { + private var sentRequests = 0 + private var queuedResponses: [Data] = [] + private var receiveContinuation: CheckedContinuation? + + func send(_ data: Data) throws { + sentRequests += 1 + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard case let .number(requestID) = request["id"] else { return } + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": .object([:]), + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func sentRequestCount() -> Int { + sentRequests + } + + private func enqueue(_ data: Data) { + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift new file mode 100644 index 000000000000..9155f8be8796 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift @@ -0,0 +1,169 @@ +import Foundation +import XCTest +@testable import T3Code + +final class WireFixtureContractTests: XCTestCase { + func testGeneratedContractFixturesDecodeInSwift() throws { + let shell = try decodeFixture( + "shell-snapshot", + as: OrchestrationShellSnapshot.self + ) + XCTAssertEqual(shell.snapshotSequence, 42) + XCTAssertEqual(shell.projects.map(\.id), ["project-fixture"]) + XCTAssertEqual(shell.threads.map(\.id), ["thread-fixture"]) + XCTAssertEqual(shell.threads.first?.modelSelection.instanceId, "codex") + XCTAssertEqual(shell.threads.first?.branchPullRequest?.number, 42) + XCTAssertEqual(shell.threads.first?.branchPullRequest?.repository, "fixture/repository") + XCTAssertEqual(shell.threads.first?.activeOrderKey, "nm") + + let detail = try decodeFixture( + "thread-detail-snapshot", + as: OrchestrationThreadDetailSnapshot.self + ) + XCTAssertEqual(detail.thread.messages.map(\.id), ["message-fixture"]) + XCTAssertEqual(detail.page?.beforeCursor, "fixture-cursor") + XCTAssertEqual(detail.page?.threadSequence, 40) + XCTAssertEqual(detail.thread.branchPullRequest, shell.threads.first?.branchPullRequest) + XCTAssertEqual(detail.thread.activeOrderKey, "nm") + + let shellItem = try decodeFixture( + "shell-stream-snapshot", + as: ShellStreamItem.self + ) + guard case let .snapshot(streamShell) = shellItem else { + return XCTFail("Expected a shell snapshot stream item") + } + XCTAssertEqual(streamShell.snapshotSequence, shell.snapshotSequence) + + let threadItem = try decodeFixture( + "thread-stream-snapshot", + as: ThreadStreamItem.self + ) + guard case let .snapshot(streamDetail) = threadItem else { + return XCTFail("Expected a thread snapshot stream item") + } + XCTAssertEqual(streamDetail.thread.id, detail.thread.id) + } + + func testSnapshotsDropOnlyUnknownArrayElements() throws { + let known = try fixtureObject("shell-snapshot") + var payload = try XCTUnwrap(known as? [String: Any]) + payload["projects"] = [ + ["id": "future-project", "kind": "not-yet-supported"], + try XCTUnwrap((payload["projects"] as? [Any])?.first), + ] + payload["threads"] = [ + ["id": "future-thread", "runtimeMode": "future-mode"], + try XCTUnwrap((payload["threads"] as? [Any])?.first), + ] + + let snapshot = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, + from: JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + ) + + XCTAssertEqual(snapshot.projects.map(\.id), ["project-fixture"]) + XCTAssertEqual(snapshot.threads.map(\.id), ["thread-fixture"]) + } + + func testThreadSnapshotsPreserveDurablePullRequestLinks() throws { + var shell = try XCTUnwrap(try fixtureObject("shell-snapshot") as? [String: Any]) + var shellThread = try XCTUnwrap((shell["threads"] as? [[String: Any]])?.first) + let link: [String: Any] = [ + "projectId": "project-fixture", + "repository": "pingdotgg/t3code", + "number": 5178, + "url": "https://github.com/pingdotgg/t3code/pull/5178", + ] + shellThread["linkedPullRequest"] = link + shell["threads"] = [shellThread] + let snapshot = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, + from: JSONSerialization.data(withJSONObject: shell) + ) + XCTAssertEqual(snapshot.threads.first?.linkedPullRequest?.number, 5178) + + var detail = try XCTUnwrap(try fixtureObject("thread-detail-snapshot") as? [String: Any]) + var detailThread = try XCTUnwrap(detail["thread"] as? [String: Any]) + detailThread["linkedPullRequest"] = link + detail["thread"] = detailThread + let threadSnapshot = try JSONDecoder.t3.decode( + OrchestrationThreadDetailSnapshot.self, + from: JSONSerialization.data(withJSONObject: detail) + ) + XCTAssertEqual(threadSnapshot.thread.linkedPullRequest?.repository, "pingdotgg/t3code") + } + + func testReopenTimestampsRoundTripAndRemainOptionalForOlderServers() throws { + var shell = try decodeFixture("shell-snapshot", as: OrchestrationShellSnapshot.self) + var detail = try decodeFixture("thread-detail-snapshot", as: OrchestrationThreadDetailSnapshot.self) + XCTAssertNil(shell.threads.first?.unsettledAt) + XCTAssertNil(detail.thread.unsettledAt) + + let timestamp = "2026-08-27T12:00:00.000Z" + shell.threads[0].unsettledAt = timestamp + var thread = detail.thread + thread.unsettledAt = timestamp + detail = OrchestrationThreadDetailSnapshot( + snapshotSequence: detail.snapshotSequence, + thread: thread, + page: detail.page + ) + let decodedShell = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, from: JSONEncoder.t3.encode(shell) + ) + let decodedDetail = try JSONDecoder.t3.decode( + OrchestrationThreadDetailSnapshot.self, from: JSONEncoder.t3.encode(detail) + ) + XCTAssertEqual(decodedShell.threads.first?.unsettledAt, timestamp) + XCTAssertEqual(decodedDetail.thread.unsettledAt, timestamp) + } + + func testUnknownStreamItemsRequestRefreshWithoutEndingDecoding() throws { + let shell = try JSONDecoder.t3.decode( + ShellStreamItem.self, + from: Data(#"{"kind":"future-shell-delta","sequence":43}"#.utf8) + ) + guard case .refreshRequired = shell else { + return XCTFail("Expected an authoritative shell refresh") + } + + let malformedKnownShell = try JSONDecoder.t3.decode( + ShellStreamItem.self, + from: Data(#"{"kind":"thread-upserted","sequence":43,"thread":{"id":"future"}}"#.utf8) + ) + guard case .refreshRequired = malformedKnownShell else { + return XCTFail("Expected malformed known deltas to refresh") + } + + let thread = try JSONDecoder.t3.decode( + ThreadStreamItem.self, + from: Data(#"{"kind":"future-thread-delta","sequence":43}"#.utf8) + ) + guard case let .event(event) = thread else { + return XCTFail("Expected the detail reducer compatibility path") + } + XCTAssertEqual(event, .null) + } + + private func decodeFixture( + _ name: String, + as type: Value.Type + ) throws -> Value { + try JSONDecoder.t3.decode(type, from: fixtureData(name)) + } + + private func fixtureObject(_ name: String) throws -> Any { + try JSONSerialization.jsonObject(with: fixtureData(name)) + } + + private func fixtureData(_ name: String) throws -> Data { + let testsDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + return try Data( + contentsOf: testsDirectory + .appendingPathComponent("Fixtures/Wire/\(name).json") + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift b/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift new file mode 100644 index 000000000000..39cc9653818a --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import T3Code + +@MainActor +final class WorkspaceContractTests: XCTestCase { + func testVCSStatusSnapshotDecodesTaggedEffectRPCShape() throws { + let data = Data( + """ + { + "_tag": "snapshot", + "local": { + "isRepo": true, + "sourceControlProvider": { + "kind": "github", + "name": "GitHub", + "baseUrl": "https://github.com" + }, + "hasPrimaryRemote": true, + "isDefaultRef": false, + "refName": "feat/swift", + "hasWorkingTreeChanges": true, + "workingTree": { + "files": [{"path":"Core/T3Client.swift","insertions":12,"deletions":2}], + "insertions": 12, + "deletions": 2 + } + }, + "remote": { + "hasUpstream": true, + "aheadCount": 1, + "behindCount": 0, + "aheadOfDefaultCount": 3, + "pr": null + } + } + """.utf8 + ) + + let event = try JSONDecoder.t3.decode(VCSStatusEvent.self, from: data) + guard case let .snapshot(local, remote) = event else { + return XCTFail("Expected snapshot") + } + XCTAssertEqual(local.refName, "feat/swift") + XCTAssertEqual(local.workingTree.files.first?.insertions, 12) + XCTAssertEqual(remote?.aheadCount, 1) + } + + func testTerminalAttachEventsDecodeSnapshotAndOutputShapes() throws { + let snapshotData = Data( + """ + { + "type": "snapshot", + "snapshot": { + "threadId": "thread-1", + "terminalId": "term-1", + "cwd": "/workspace", + "worktreePath": null, + "status": "running", + "pid": 42, + "history": "$ ", + "exitCode": null, + "exitSignal": null, + "label": "Shell", + "updatedAt": "2026-07-30T12:00:00.000Z", + "sequence": 4 + } + } + """.utf8 + ) + let outputData = Data( + """ + { + "type": "output", + "threadId": "thread-1", + "terminalId": "term-1", + "sequence": 5, + "data": "hello\\r\\n" + } + """.utf8 + ) + + let snapshot = try JSONDecoder.t3.decode(TerminalEvent.self, from: snapshotData) + let output = try JSONDecoder.t3.decode(TerminalEvent.self, from: outputData) + XCTAssertEqual(snapshot.snapshot?.pid, 42) + XCTAssertEqual(snapshot.snapshot?.sequence, 4) + XCTAssertEqual(output.data, "hello\r\n") + XCTAssertEqual(output.sequence, 5) + } + + func testReviewAndProjectFileResultsDecodeExactServerFields() throws { + let reviewData = Data( + """ + { + "cwd": "/workspace", + "generatedAt": "2026-07-30T12:00:00.000Z", + "sources": [{ + "id": "working-tree", + "kind": "working-tree", + "title": "Working tree", + "baseRef": null, + "headRef": null, + "diff": "diff --git a/file b/file", + "diffHash": "abc123", + "truncated": false + }] + } + """.utf8 + ) + let fileData = Data( + """ + { + "relativePath": "README.md", + "contents": "# T3", + "byteLength": 4, + "truncated": false + } + """.utf8 + ) + + let review = try JSONDecoder.t3.decode(ReviewDiffPreview.self, from: reviewData) + let file = try JSONDecoder.t3.decode(ProjectReadFileResult.self, from: fileData) + XCTAssertEqual(review.sources.first?.kind, "working-tree") + XCTAssertEqual(review.sources.first?.diffHash, "abc123") + XCTAssertEqual(file.relativePath, "README.md") + XCTAssertFalse(file.truncated) + } + + func testWorkspaceRPCMethodNamesMatchContractConstants() { + XCTAssertEqual(RPCMethod.projectsListEntries.rawValue, "projects.listEntries") + XCTAssertEqual(RPCMethod.vcsRefreshStatus.rawValue, "vcs.refreshStatus") + XCTAssertEqual(RPCMethod.reviewDiffPreview.rawValue, "review.getDiffPreview") + XCTAssertEqual( + RPCMethod.getArchivedShellSnapshot.rawValue, + "orchestration.getArchivedShellSnapshot" + ) + XCTAssertEqual(RPCMethod.terminalAttach.rawValue, "terminal.attach") + XCTAssertEqual(RPCMethod.subscribeTerminalEvents.rawValue, "subscribeTerminalEvents") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/AttachmentPreparationTests.swift b/apps/swift-ios/Tests/FeatureTests/AttachmentPreparationTests.swift new file mode 100644 index 000000000000..48d0b20151c8 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/AttachmentPreparationTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +import UniformTypeIdentifiers +@testable import T3Code + +@Suite("Attachment preparation") +struct AttachmentPreparationTests { + @Test + func providerLoaderReadsImageDataRepresentation() async throws { + let expected = Data([0x01, 0x02, 0x03]) + let provider = NSItemProvider( + item: expected as NSData, + typeIdentifier: UTType.jpeg.identifier + ) + + #expect(try await FeatureImageItemProviderLoader.data(from: provider) == expected) + } + + @Test + @MainActor + func providerLoadCanStartBeforeItsDataIsAwaited() async throws { + let expected = Data([0x01, 0x02, 0x03]) + let provider = NSItemProvider( + item: expected as NSData, + typeIdentifier: UTType.jpeg.identifier + ) + + let load = try FeatureImageItemProviderLoader.start(from: provider) + + #expect(try await load.data() == expected) + } + + @Test + func providerLoaderRejectsProvidersWithoutImageRepresentations() async { + await #expect(throws: FeatureImageAttachmentError.self) { + try await FeatureImageItemProviderLoader.data(from: NSItemProvider()) + } + } + + @Test + func overlappingPreparationOnlyFinishesAfterEveryOperation() { + var state = FeatureAttachmentPreparationState() + let firstID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let secondID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let first = state.begin(itemCount: 2, id: firstID) + let second = state.begin(itemCount: 1, id: secondID) + + #expect(state.isPreparing) + #expect(state.pendingItemCount == 3) + #expect(state.statusLabel == "Preparing 3 attachments…") + + state.finish(first) + + #expect(state.isPreparing) + #expect(state.pendingItemCount == 1) + #expect(state.statusLabel == "Preparing attachment…") + + state.finish(second) + + #expect(!state.isPreparing) + #expect(state.pendingItemCount == 0) + } + + @Test + func textOnlySubmissionWaitsForSelectedImagePreparation() { + var state = FeatureAttachmentPreparationState() + let operation = state.begin(itemCount: 1) + + #expect(!FeatureComposerSubmissionEligibility.canSend( + text: "Explain this screenshot", + attachmentCount: 0, + imagesAllowed: true, + isSending: false, + preparationState: state + )) + + state.finish(operation) + + #expect(FeatureComposerSubmissionEligibility.canSend( + text: "Explain this screenshot", + attachmentCount: 1, + imagesAllowed: true, + isSending: false, + preparationState: state + )) + } + + @Test + func attachmentSubmissionStillRequiresImageCapableModel() { + let state = FeatureAttachmentPreparationState() + + #expect(!FeatureComposerSubmissionEligibility.canSend( + text: "", + attachmentCount: 1, + imagesAllowed: false, + isSending: false, + preparationState: state + )) + #expect(FeatureComposerSubmissionEligibility.canSend( + text: "Text still works", + attachmentCount: 0, + imagesAllowed: false, + isSending: false, + preparationState: state + )) + } + + @Test + func fileSubmissionDoesNotRequireImageCapableModel() { + #expect(FeatureComposerSubmissionEligibility.canSend( + text: "", + attachmentCount: 1, + imagesAllowed: false, + filesAllowed: true, + containsImages: false, + containsFiles: true, + isSending: false, + preparationState: FeatureAttachmentPreparationState() + )) + } + + @Test + func unsupportedFileBlocksSubmission() { + #expect(!FeatureComposerSubmissionEligibility.canSend( + text: "Describe this file", + attachmentCount: 1, + imagesAllowed: true, + filesAllowed: false, + containsImages: false, + containsFiles: true, + isSending: false, + preparationState: FeatureAttachmentPreparationState() + )) + } + + @Test + func completionIdentityRejectsChangedOwnerOrEnvironment() { + let generation = UUID() + let identity = FeatureAttachmentOperationIdentity( + ownerID: "thread:one", + environmentID: "local", + generation: generation + ) + + #expect(identity.matches( + ownerID: "thread:one", + environmentID: "local", + generation: generation + )) + #expect(!identity.matches( + ownerID: "thread:two", + environmentID: "local", + generation: generation + )) + #expect(!identity.matches( + ownerID: "thread:one", + environmentID: "remote", + generation: generation + )) + } + + @Test + func attachmentPickerKeepsExistingThreadComposerMountedWhenFocusResigns() { + #expect(!FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: false, + textIsEmpty: true, + attachmentsAreEmpty: true, + isAttachmentFlowActive: true, + isPreparingAttachments: false + )) + + #expect(FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: false, + textIsEmpty: true, + attachmentsAreEmpty: true, + isAttachmentFlowActive: false, + isPreparingAttachments: false + )) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift new file mode 100644 index 000000000000..09183cafb726 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift @@ -0,0 +1,442 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer draft persistence") +struct ComposerDraftStoreTests { + @Test func staleComposerSavePreservesUploadedReferenceForSameContent() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:test:thread:stale-save" + let attachment = FeatureDraftAttachment( + data: Data([1, 2, 3]), + filename: "same.png", + mimeType: "image/png" + ) + try await store.setDraft( + FeatureComposerDraft(text: "before", attachments: [attachment]), + for: key + ) + let reference = FeatureUploadedAttachmentReference( + environmentID: "test", + attachmentID: "uploaded" + ) + #expect(try await store.setUploadedReference( + reference, + attachment: attachment, + for: key + )) + + try await store.setDraft( + FeatureComposerDraft(text: "after", attachments: [attachment]), + for: key + ) + + let saved = try #require(await store.draft(for: key)) + #expect(saved.text == "after") + #expect(saved.attachments.first?.uploadedReference == reference) + } + + @Test func uploadedReferenceCompareAndSetDoesNotRestoreRemovedAttachment() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:test:thread:removed" + let attachment = FeatureDraftAttachment( + data: Data([1]), + filename: "removed.png", + mimeType: "image/png" + ) + try await store.setDraft( + FeatureComposerDraft(text: "keep", attachments: [attachment]), + for: key + ) + try await store.setDraft(FeatureComposerDraft(text: "keep"), for: key) + + let didSave = try await store.setUploadedReference( + FeatureUploadedAttachmentReference( + environmentID: "test", + attachmentID: "late" + ), + attachment: attachment, + for: key + ) + + #expect(!didSave) + #expect(try await store.draft(for: key)?.attachments.isEmpty == true) + } + + @Test func roundTripsThreadTextImagesAndSelection() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("drafts.json") + let store = FeatureComposerDraftStore(fileURL: fileURL) + let attachment = FeatureDraftAttachment( + data: Data([0x01, 0x02, 0x03]), + thumbnailData: Data([0x04]), + filename: "reference.png", + mimeType: "image/png" + ) + let draft = FeatureComposerDraft( + text: "Keep this work", + attachments: [attachment], + selection: FeatureSelection(providerID: "openai", modelID: "gpt-5.6"), + workspace: FeatureComposerWorkspaceDraft( + mode: .worktree, + branch: "main", + worktreePath: nil, + startFromOrigin: true + ) + ) + + try await store.setDraft(draft, for: "environment:test:thread:one") + + let reloaded = FeatureComposerDraftStore(fileURL: fileURL) + #expect(try await reloaded.draft(for: "environment:test:thread:one") == draft) + } + + @Test func fileBackedDraftRoundTripUsesTheCurrentStorageRoot() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let sourceURL = directory.appendingPathComponent("provider-notes.txt") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("notes".utf8).write(to: sourceURL) + let attachmentID = UUID() + let firstRoot = directory.appendingPathComponent("first-root", isDirectory: true) + let firstFiles = ManagedAttachmentFileStore(rootURL: firstRoot) + let ownedFile = try firstFiles.copyOwnedFile( + from: sourceURL, + attachmentID: attachmentID, + originalFileName: "notes.txt" + ) + let fileURL = directory.appendingPathComponent("drafts.json") + let store = FeatureComposerDraftStore( + fileURL: fileURL, + attachmentStorageRootURL: firstRoot + ) + let reference = FeatureUploadedAttachmentReference( + environmentID: "environment-1", + attachmentID: "server-attachment-1" + ) + try await store.setDraft( + FeatureComposerDraft(attachments: [ + FeatureDraftAttachment( + id: attachmentID, + ownedFile: ownedFile, + filename: "notes.txt", + mimeType: "text/plain", + uploadedReference: reference + ), + ]), + for: "environment:test:thread:file" + ) + + let movedRoot = directory.appendingPathComponent("moved-root", isDirectory: true) + try FileManager.default.moveItem(at: firstRoot, to: movedRoot) + let restored = try await FeatureComposerDraftStore( + fileURL: fileURL, + attachmentStorageRootURL: movedRoot + ).draft(for: "environment:test:thread:file")?.attachments.first + + #expect(restored?.id == attachmentID) + #expect(restored?.ownedFile?.url.deletingLastPathComponent() == movedRoot) + #expect(restored?.byteCount == 5) + #expect(restored?.data.isEmpty == true) + #expect(restored?.uploadedReference == reference) + let json = try #require(String(data: Data(contentsOf: fileURL), encoding: .utf8)) + #expect(!json.contains(Data("notes".utf8).base64EncodedString())) + } + + @Test func ownedAttachmentPathsRejectTraversalAndUnknownNames() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let files = ManagedAttachmentFileStore(rootURL: root) + + #expect(throws: ManagedAttachmentFileError.invalidFileName) { + try files.resolvedFile(fileName: "../outside.txt", byteCount: 1) + } + #expect(throws: ManagedAttachmentFileError.invalidFileName) { + try files.removeOwnedFile(fileName: "not-a-uuid.txt") + } + } + + @Test func restoresImageAttachmentWrittenBeforeFileBacking() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("drafts.json") + let attachmentID = UUID() + try Data( + """ + { + "version": 2, + "drafts": { + "environment:test:thread:old": { + "text": "Old image", + "attachments": [{ + "id": "\(attachmentID.uuidString)", + "data": "AQID", + "filename": "old.png", + "mimeType": "image/png" + }] + } + } + } + """.utf8 + ).write(to: fileURL) + + let attachment = try await FeatureComposerDraftStore(fileURL: fileURL) + .draft(for: "environment:test:thread:old")?.attachments.first + + #expect(attachment?.id == attachmentID) + #expect(attachment?.data == Data([1, 2, 3])) + #expect(attachment?.ownedFile == nil) + } + + @Test func emptyDraftRemovesPersistedEntry() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("drafts.json") + let store = FeatureComposerDraftStore(fileURL: fileURL) + let key = "environment:test:thread:one" + + try await store.setDraft(FeatureComposerDraft(text: "hello"), for: key) + try await store.setDraft(FeatureComposerDraft(), for: key) + + #expect(try await store.draft(for: key) == nil) + } + + @Test func clearingDraftPreservesImportedShareIdempotency() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:test:thread:one" + + _ = try await store.importSharedContent( + shareID: "share-1", + text: "Imported once", + attachments: [], + for: key + ) + try await store.setDraft(FeatureComposerDraft(), for: key) + let replayed = try await store.importSharedContent( + shareID: "share-1", + text: "Imported once", + attachments: [], + for: key + ) + + #expect(replayed.isEmpty) + #expect(try await store.draft(for: key) == nil) + } + + @Test func environmentRemovalLeavesOtherDraftsAlone() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + try await store.setDraft( + FeatureComposerDraft(text: "remove"), + for: "environment:first:thread:one" + ) + try await store.setDraft( + FeatureComposerDraft(text: "keep"), + for: "environment:second:new-task:two" + ) + + try await store.removeDrafts(environmentID: "first") + + #expect(try await store.draft(for: "environment:first:thread:one") == nil) + #expect( + try await store.draft(for: "environment:second:new-task:two")?.text == "keep" + ) + } + + @Test func environmentRemovalClearsItsGroupedProjectDrafts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let removedKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/removed" + ) + let preservedKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/preserved" + ) + try await store.setDraft(FeatureComposerDraft(text: "remove"), for: removedKey) + try await store.setDraft(FeatureComposerDraft(text: "keep"), for: preservedKey) + + try await store.removeDrafts( + environmentID: "first", + logicalProjectIDs: ["github.com/t3/removed"] + ) + + #expect(try await store.draft(for: removedKey) == nil) + #expect(try await store.draft(for: preservedKey)?.text == "keep") + } + + @Test func migratesResolvedVersionOneNewTaskDefaultsBackToImplicit() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let fileURL = directory.appendingPathComponent("drafts.json") + try Data( + """ + { + "version": 1, + "drafts": { + "environment:test:new-task:project": { + "text": "Keep the prompt", + "attachments": [], + "selection": { + "providerID": "codex", + "modelID": "gpt-old", + "options": [] + }, + "workspace": { + "mode": "local", + "startFromOrigin": true + } + } + } + } + """.utf8 + ).write(to: fileURL) + + let store = FeatureComposerDraftStore(fileURL: fileURL) + let migrated = try await store.draft( + for: "environment:test:new-task:project" + ) + + #expect(migrated?.text == "Keep the prompt") + #expect(migrated?.selection == nil) + #expect(migrated?.workspace == nil) + let persisted = try JSONSerialization.jsonObject( + with: Data(contentsOf: fileURL) + ) as? [String: Any] + #expect(persisted?["version"] as? Int == 2) + } + + @Test func restorationPreservesLiveEditsAndRestoresUntouchedFields() { + let baseline = FeatureComposerDraft( + selection: FeatureSelection(providerID: "openai", modelID: "gpt-default"), + workspace: FeatureComposerWorkspaceDraft( + mode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: true + ) + ) + let liveAttachment = FeatureDraftAttachment( + data: Data([0x01]), + filename: "live.png", + mimeType: "image/png" + ) + let current = FeatureComposerDraft( + text: "Typed while loading", + attachments: [liveAttachment], + selection: baseline.selection, + workspace: FeatureComposerWorkspaceDraft( + mode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + let saved = FeatureComposerDraft( + text: "Older text", + attachments: [], + selection: FeatureSelection(providerID: "anthropic", modelID: "claude-opus"), + workspace: FeatureComposerWorkspaceDraft( + mode: .worktree, + branch: "main", + worktreePath: "/tmp/worktree", + startFromOrigin: true + ) + ) + + let merged = FeatureComposerDraftRestoration.merge( + saved: saved, + baseline: baseline, + current: current + ) + + #expect(merged.text == "Typed while loading") + #expect(merged.attachments == [liveAttachment]) + #expect(merged.selection == saved.selection) + #expect(merged.workspace?.mode == .worktree) + #expect(merged.workspace?.branch == "main") + #expect(merged.workspace?.worktreePath == "/tmp/worktree") + #expect(merged.workspace?.startFromOrigin == false) + } + + @Test func restorationUsesFallbacksWithoutOverwritingLiveChoices() { + let baseline = FeatureComposerDraft() + let liveSelection = FeatureSelection(providerID: "anthropic", modelID: "claude-sonnet") + let current = FeatureComposerDraft(selection: liveSelection) + let fallbackSelection = FeatureSelection(providerID: "openai", modelID: "gpt-default") + let fallbackWorkspace = FeatureComposerWorkspaceDraft( + mode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: true + ) + + let merged = FeatureComposerDraftRestoration.merge( + saved: nil, + baseline: baseline, + current: current, + fallbackSelection: fallbackSelection, + fallbackWorkspace: fallbackWorkspace + ) + + #expect(merged.selection == liveSelection) + #expect(merged.workspace == fallbackWorkspace) + } + + @Test func successfulSubmissionFenceWaitsForCancelledDraftWrites() async { + let started = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + let events = AsyncStream.makeStream() + let pendingWrite = Task { + started.continuation.yield() + for await _ in release.stream { break } + events.continuation.yield("write finished") + } + var startedIterator = started.stream.makeAsyncIterator() + _ = await startedIterator.next() + + let fencedRemoval = Task { + await NewTaskDraftWriteFence.cancelAndWait(pendingWrite) + events.continuation.yield("draft removed") + } + release.continuation.yield() + await fencedRemoval.value + + var eventIterator = events.stream.makeAsyncIterator() + #expect(await eventIterator.next() == "write finished") + #expect(await eventIterator.next() == "draft removed") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerImageIntakeTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerImageIntakeTests.swift new file mode 100644 index 000000000000..d77cb632d8a4 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ComposerImageIntakeTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer image intake") +struct ComposerImageIntakeTests { + private static func plan( + providerCount: Int, + attachmentCount: Int = 0, + pendingCount: Int = 0 + ) -> FeatureComposerImageIntakePlan? { + FeatureComposerImageIntakePlan.forProviders( + providerCount: providerCount, + attachmentCount: attachmentCount, + pendingCount: pendingCount + ) + } + + @Test + func emptyComposerAcceptsEveryIncomingImage() throws { + let plan = try #require(Self.plan(providerCount: 3)) + + #expect(plan.acceptedCount == 3) + #expect(plan.firstOrdinal == 1) + #expect(plan.droppedCount == 0) + } + + @Test + func ordinalsContinueAfterExistingAndInFlightAttachments() throws { + let plan = try #require( + Self.plan(providerCount: 1, attachmentCount: 2, pendingCount: 1) + ) + + // Two attached plus one still preparing means this image is number four. + #expect(plan.firstOrdinal == 4) + } + + @Test + func intakeIsRefusedOnceTheAttachmentCapIsReached() { + #expect(Self.plan(providerCount: 1, attachmentCount: 8) == nil) + } + + @Test + func inFlightPreparationCountsAgainstTheCap() { + // Seven attached plus one preparing already fills the eight-image budget. + #expect(Self.plan(providerCount: 1, attachmentCount: 7, pendingCount: 1) == nil) + } + + @Test + func overshootIsTruncatedAndCounted() throws { + let plan = try #require(Self.plan(providerCount: 5, attachmentCount: 6)) + + #expect(plan.acceptedCount == 2) + #expect(plan.droppedCount == 3) + } + + @Test + func anEmptyBatchYieldsNoPlan() { + #expect(Self.plan(providerCount: 0) == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift b/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift new file mode 100644 index 000000000000..9ec0dfab8447 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift @@ -0,0 +1,138 @@ +import Testing +@testable import T3Code + +@Suite("Connection details") +struct ConnectionDetailsTests { + @Test + func parsesRawPairingURL() throws { + let details = try ConnectionDetailsParser.parse( + "http://192.168.1.42:3773/pair#token=PAIRCODE" + ) + + #expect(details.endpoint == "http://192.168.1.42:3773") + #expect(details.pairingCode == "PAIRCODE") + } + + @Test + func parsesHostedPairingURL() throws { + let details = try ConnectionDetailsParser.parse( + "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=PAIRCODE" + ) + + #expect(details.endpoint == "https://desktop.tailnet.ts.net") + #expect(details.pairingCode == "PAIRCODE") + } + + @Test(arguments: [ + "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code:?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code-swiftui://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code-swiftui-dev://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + ]) + func unwrapsMobileQRCode(_ payload: String) throws { + let details = try ConnectionDetailsParser.parse(payload) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "pairing-token") + } + + @Test + func extractsPairingURLFromSurroundingText() throws { + let details = try ConnectionDetailsParser.parse( + "Pairing URL: http://10.0.0.8:18773/pair#token=ABC123\nOpen this on your phone." + ) + + #expect(details.endpoint == "http://10.0.0.8:18773") + #expect(details.pairingCode == "ABC123") + } + + @Test(arguments: [ + "token", + "pairing_token", + "pairingToken", + "pairing_code", + "pairingCode", + "code", + ]) + func acceptsEveryPairingCodeQueryAlias(_ alias: String) throws { + let details = try ConnectionDetailsParser.parse( + "https://remote.example.com/pair?\(alias)=ABC123" + ) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "ABC123") + } + + @Test + func removesPunctuationCopiedWithAProseLink() throws { + let details = try ConnectionDetailsParser.parse( + "Connect with (https://remote.example.com/pair?code=ABC123). Then return." + ) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "ABC123") + } + + @Test + func keepsBalancedIPv6BracketsWhileTrimmingProse() throws { + let details = try ConnectionDetailsParser.parse( + "Use http://[fe80::1]:3773/pair?code=ABC123!" + ) + + #expect(details.endpoint == "http://[fe80::1]:3773") + #expect(details.pairingCode == "ABC123") + } + + @Test + func splitsManualAddressAndCode() throws { + let details = try ConnectionDetailsParser.parse("192.168.20.2:3773 ABC123") + + #expect(details.endpoint == "http://192.168.20.2:3773") + #expect(details.pairingCode == "ABC123") + } + + @Test + func mapsCancellationToUsefulCopy() { + let message = ConnectionErrorCopy.message(for: "cancelled") + + #expect(!message.lowercased().contains("cancelled")) + #expect(message.contains("Make sure T3 Code is running")) + } +} + +@Suite("Local endpoint detection") +struct LocalEndpointDetectionTests { + @Test(arguments: [ + "localhost", + "studio.local", + "127.0.0.1", + "10.20.30.40", + "172.20.10.2", + "192.168.213.171", + "[::1]", + "::1", + "[fe80::aede:48ff:fe00:1122]:3773", + "fd12:3456:789a::1", + "fc00::1", + ]) + func recognizesLocalHosts(_ host: String) { + #expect(EndpointNetworkScope.isLocalHost(host)) + } + + @Test(arguments: [ + "8.8.8.8", + "172.32.0.1", + "example.com", + "2001:4860:4860::8888", + ]) + func rejectsPublicHosts(_ host: String) { + #expect(!EndpointNetworkScope.isLocalHost(host)) + } + + @Test + func bracketsBareIPv6DuringNormalization() throws { + let endpoint = try ConnectionDetailsParser.normalizedEndpoint("::1") + + #expect(endpoint == "http://[::1]") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ConnectionHubPresentationTests.swift b/apps/swift-ios/Tests/FeatureTests/ConnectionHubPresentationTests.swift new file mode 100644 index 000000000000..d48e10dfe76b --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ConnectionHubPresentationTests.swift @@ -0,0 +1,281 @@ +import Testing +@testable import T3Code + +@Suite("Environment management presentation") +struct ConnectionHubPresentationTests { + @Test + func directSectionContainsOnlyDirectConnections() { + let direct = environment(id: "direct", source: .direct) + let managed = environment(id: "managed", source: .t3Connect) + + #expect( + ConnectionHubPresentation.directEnvironments(in: [managed, direct]) == [direct] + ) + } + + @Test + func directSectionShowsEnabledConnectionsBeforeDisabledConnections() { + let disabledFirst = environment( + id: "disabled-first", + source: .direct, + isEnabled: false + ) + let enabledFirst = environment(id: "enabled-first", source: .direct) + let disabledSecond = environment( + id: "disabled-second", + source: .direct, + isEnabled: false + ) + let enabledSecond = environment(id: "enabled-second", source: .direct) + + #expect( + ConnectionHubPresentation.directEnvironments( + in: [disabledFirst, enabledFirst, disabledSecond, enabledSecond] + ).map(\.id) == [ + "enabled-first", + "enabled-second", + "disabled-first", + "disabled-second", + ] + ) + } + + @Test + func t3ConnectSectionJoinsSavedAndAccountMachinesWithoutDuplicates() { + let saved = [ + environment( + id: "shared", + name: "Old saved label", + source: .t3Connect, + isEnabled: true, + connectionState: .connected + ), + environment(id: "saved-only", source: .t3Connect, isEnabled: false), + environment(id: "direct", source: .direct), + ] + let linked = [ + cloudEnvironment(id: "linked-only", name: "Travel Mac", isOnline: true), + cloudEnvironment(id: "shared", name: "Big O", isOnline: false), + ] + + let rows = ConnectionHubPresentation.t3ConnectEnvironments( + saved: saved, + linked: linked + ) + + #expect(rows.map(\.id) == ["linked-only", "shared", "saved-only"]) + #expect(rows[0].name == "Travel Mac") + #expect(!rows[0].isEnabled) + #expect(rows[0].isOnline) + #expect(rows[1].name == "Big O") + #expect(rows[1].isEnabled) + #expect(rows[1].isOnline) + #expect(rows[2].savedEnvironment?.id == "saved-only") + #expect(rows[2].linkedEnvironment == nil) + } + + @Test + func directlySavedMachineDoesNotHideItsT3ConnectEntry() { + let direct = environment(id: "same-machine", source: .direct) + let linked = cloudEnvironment(id: "same-machine", name: "Big O", isOnline: true) + + let directRows = ConnectionHubPresentation.directEnvironments(in: [direct]) + let managedRows = ConnectionHubPresentation.t3ConnectEnvironments( + saved: [direct], + linked: [linked] + ) + + #expect(directRows.map(\.id) == ["same-machine"]) + #expect(managedRows.map(\.id) == ["same-machine"]) + #expect(managedRows.first?.savedEnvironment == nil) + } + + @Test( + arguments: [ + (FeatureConnection.State.connected, ConnectionHubStatus.online), + (.connecting, .connecting), + (.reconnecting, .connecting), + (.disconnected, .offline), + ] + ) + func savedEnvironmentStatusMatchesConnectionState( + connectionState: FeatureConnection.State, + expectedStatus: ConnectionHubStatus + ) { + let saved = environment( + id: "direct", + source: .direct, + connectionState: connectionState + ) + + #expect(ConnectionHubPresentation.status(for: saved) == expectedStatus) + } + + @Test + func disabledEnvironmentDoesNotAppearOfflineOrOnline() { + let saved = environment( + id: "disabled", + source: .direct, + isEnabled: false, + connectionState: .connected + ) + + #expect(ConnectionHubPresentation.status(for: saved) == .disabled) + #expect(ConnectionHubPresentation.status(for: saved, pendingEnabled: true) == .connecting) + } + + @Test + func environmentWithoutAReachabilityProbeIsChecking() { + let saved = environment(id: "pending", source: .direct) + + #expect(ConnectionHubPresentation.status(for: saved) == .checking) + #expect(ConnectionHubPresentation.status(for: saved, pendingEnabled: false) == .disabled) + } + + @Test + func managedEnvironmentUsesSavedConnectionStateBeforeCloudAvailability() { + let linked = cloudEnvironment(id: "managed", name: "Studio", isOnline: true) + let disabled = T3ConnectEnvironmentPresentation( + linkedEnvironment: linked, + savedEnvironment: environment( + id: "managed", + source: .t3Connect, + isEnabled: false, + connectionState: .connected + ) + ) + let offline = T3ConnectEnvironmentPresentation( + linkedEnvironment: linked, + savedEnvironment: environment( + id: "managed", + source: .t3Connect, + connectionState: .disconnected + ) + ) + + #expect(disabled.status == .disabled) + #expect(offline.status == .offline) + #expect(!disabled.isOnline) + #expect(!offline.isOnline) + } + + @Test + func linkedEnvironmentShowsCloudStatusUntilItIsSaved() { + let online = T3ConnectEnvironmentPresentation( + linkedEnvironment: cloudEnvironment(id: "online", name: "Studio", isOnline: true), + savedEnvironment: nil + ) + let offline = T3ConnectEnvironmentPresentation( + linkedEnvironment: cloudEnvironment(id: "offline", name: "Studio", isOnline: false), + savedEnvironment: nil + ) + + #expect(online.status == .online) + #expect(offline.status == .offline) + #expect(online.connectionStatus(pendingEnabled: true) == .connecting) + #expect(offline.connectionStatus(isConnecting: true) == .connecting) + } + + @Test + func linkedEnvironmentSeparatesUnknownStatusFromFailedReachability() { + let linked = cloudEnvironment(id: "linked", name: "Studio", isOnline: true) + let unchecked = T3ConnectEnvironmentPresentation( + linkedEnvironment: T3ConnectCloudEnvironment(environment: linked.environment), + savedEnvironment: nil + ) + let failed = T3ConnectEnvironmentPresentation( + linkedEnvironment: T3ConnectCloudEnvironment( + environment: linked.environment, + statusError: "Connection failed" + ), + savedEnvironment: nil + ) + + #expect(unchecked.status == .checking) + #expect(failed.status == .offline) + } + + @Test + func duplicateMachineNamesShowOnlyTheirSanitizedHostAndPort() { + let names = ["leftbook", "LeftBook", "studio"] + + #expect( + ConnectionHubPresentation.disambiguatingEndpoint( + "https://agent:secret@leftbook.tailnet.ts.net:8443/work?token=private#code", + for: "leftbook", + among: names + ) == "leftbook.tailnet.ts.net:8443" + ) + #expect( + ConnectionHubPresentation.disambiguatingEndpoint( + "https://second.tailnet.ts.net/private?token=hidden", + for: "LeftBook", + among: names + ) == "second.tailnet.ts.net" + ) + #expect( + ConnectionHubPresentation.disambiguatingEndpoint( + "https://studio.example/", + for: "studio", + among: names + ) == nil + ) + } + + @Test + func managedEnvironmentUsesLinkedEndpointForDisambiguation() { + let linked = cloudEnvironment(id: "linked", name: "leftbook", isOnline: true) + let row = T3ConnectEnvironmentPresentation( + linkedEnvironment: linked, + savedEnvironment: environment(id: "saved", name: "leftbook", source: .t3Connect) + ) + + #expect(row.endpoint == "https://linked.example") + } + + private func environment( + id: String, + name: String? = nil, + source: FeatureEnvironment.Source, + isEnabled: Bool = true, + connectionState: FeatureConnection.State? = nil + ) -> FeatureEnvironment { + FeatureEnvironment( + id: id, + name: name ?? id, + endpoint: "https://\(id).example", + isEnabled: isEnabled, + source: source, + connectionState: connectionState + ) + } + + private func cloudEnvironment( + id: String, + name: String, + isOnline: Bool + ) -> T3ConnectCloudEnvironment { + let endpoint = T3ConnectManagedEndpoint( + httpBaseUrl: "https://\(id).example", + wsBaseUrl: "wss://\(id).example", + providerKind: .t3Relay + ) + return T3ConnectCloudEnvironment( + environment: T3ConnectRelayEnvironment( + environmentId: id, + label: name, + endpoint: endpoint, + linkedAt: "2026-08-14T00:00:00.000Z" + ), + status: T3ConnectRelayEnvironmentStatus( + environmentId: id, + endpoint: endpoint, + status: isOnline ? .online : .offline, + checkedAt: "2026-08-14T00:00:00.000Z", + descriptor: nil, + error: nil, + traceId: nil + ) + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift new file mode 100644 index 000000000000..673b89842c79 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift @@ -0,0 +1,1298 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Model picker") +struct DailyUXModelPickerTests { + @Test + func providerBrandsResolveFromDriversAndFallbackIDs() { + #expect( + ProviderBrand.resolve(driver: "codex", providerID: "work-openai") == .openAI + ) + #expect( + ProviderBrand.resolve(driver: "claudeAgent", providerID: "work-claude") == .claude + ) + #expect(ProviderBrand.resolve(driver: "cursor", providerID: "cursor") == .cursor) + #expect(ProviderBrand.resolve(driver: "grok", providerID: "grok") == .grok) + #expect(ProviderBrand.resolve(driver: "opencode", providerID: "opencode") == .openCode) + #expect(ProviderBrand.resolve(driver: "", providerID: "claude") == .claude) + #expect(ProviderBrand.resolve(driver: "custom", providerID: "custom") == nil) + } + + @Test + func catalogPreservesFavoritesRecentsAndProviderGroups() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5", name: "GPT-5", supportsImages: true), + FeatureModel(id: "gpt-5-mini", name: "GPT-5 Mini"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "sonnet", name: "Sonnet", supportsReasoning: true)] + ), + ] + let favorite = DailyUXModelOption.key(providerID: "claude", modelID: "sonnet") + let recent = DailyUXModelOption.key(providerID: "codex", modelID: "gpt-5") + + let catalog = DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [favorite], + recentIDs: [recent] + ) + + #expect(catalog.favorites.map(\.id) == [favorite]) + #expect(catalog.recents.map(\.id) == [recent]) + #expect(catalog.providerGroups.map(\.provider.id) == ["codex", "claude"]) + } + + @Test + func catalogDeduplicatesRepeatedProviderAndModelIDs() { + let repeated = FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5.6-sol", name: "Sol"), + FeatureModel(id: "gpt-5.6-sol", name: "Sol again"), + ] + ) + let catalog = DailyUXModelCatalog( + providers: [repeated, repeated], + query: "", + favoriteIDs: [], + recentIDs: [] + ) + + #expect(catalog.all.map(\.id) == ["codex::gpt-5.6-sol"]) + #expect(catalog.providerGroups.map(\.provider.id) == ["codex"]) + #expect(catalog.providerGroups.first?.models.count == 1) + } + + @Test + func searchIncludesCapabilitiesAndProviderNames() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "vision", name: "Visual", supportsImages: true), + FeatureModel(id: "plain", name: "Plain"), + ] + ), + ] + + let catalog = DailyUXModelCatalog( + providers: providers, + query: "images", + favoriteIDs: [], + recentIDs: [] + ) + + #expect(catalog.all.map(\.model.id) == ["vision"]) + } + + @Test + func optionDefaultsUseTypedDescriptorDefaults() { + let model = FeatureModel( + id: "gpt-5", + name: "GPT-5", + options: [ + FeatureModelOptionDescriptor( + id: "effort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High", isDefault: true), + ] + ), + FeatureModelOptionDescriptor( + id: "fast", + label: "Fast mode", + kind: .boolean, + defaultValue: .boolean(true) + ), + FeatureModelOptionDescriptor( + id: "thinking", + label: "Thinking", + kind: .boolean, + defaultValue: .boolean(false) + ), + ] + ) + + let defaults = DailyUXModelOptions.defaults(for: model) + + #expect(defaults == [ + FeatureModelOptionSelection(id: "effort", value: .string("high")), + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + FeatureModelOptionSelection(id: "thinking", value: .boolean(false)), + ]) + } + + @Test + func additionalReasoningOptionsRemainAvailable() { + let effort = FeatureModelOptionDescriptor( + id: "effort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "high", label: "High", isDefault: true)] + ) + let thinking = FeatureModelOptionDescriptor(id: "thinking", label: "Thinking", kind: .boolean) + let fast = FeatureModelOptionDescriptor(id: "fastMode", label: "Fast mode", kind: .boolean) + let model = FeatureModel(id: "work-model", name: "Work model", options: [effort, thinking, fast]) + + #expect(DailyUXModelOptions.reasoningDescriptor(for: model) == effort) + #expect(DailyUXModelOptions.advancedDescriptors(for: model) == [thinking, fast]) + } + + @Test + func selectingAModelDoesNotInventOptionDefaults() { + let model = FeatureModel( + id: "work-model", + name: "Work model", + options: [ + .init( + id: "effort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "low", label: "Low"), .init(id: "high", label: "High")] + ), + .init(id: "thinking", label: "Thinking", kind: .boolean), + ] + ) + let provider = FeatureProvider(id: "work", name: "Work", models: [model]) + let selected = ProviderModelConfiguration.selection( + for: DailyUXModelOption(provider: provider, model: model), + preserving: nil + ) + + #expect(selected == FeatureSelection(providerID: "work", modelID: "work-model")) + #expect(DailyUXModelOptions.summary(for: model, selections: selected.options) == nil) + for descriptor in model.options { + #expect(DailyUXModelOptions.value(for: descriptor, in: selected.options) == nil) + } + } + + @Test + func returningToProviderDefaultsPreservesOtherSavedOptions() { + let model = FeatureModel( + id: "work-model", + name: "Work model", + options: [ + .init( + id: "effort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "high", label: "High")] + ), + .init(id: "thinking", label: "Thinking", kind: .boolean), + ] + ) + let provider = FeatureProvider(id: "work", name: "Work", models: [model]) + let saved = FeatureSelection( + providerID: provider.id, + modelID: model.id, + options: [ + .init(id: "effort", value: .string("unlisted")), + .init(id: "thinking", value: .boolean(false)), + .init(id: "providerFlag", value: .boolean(true)), + ] + ) + + #expect( + ProviderModelConfiguration.selection( + for: DailyUXModelOption(provider: provider, model: model), + preserving: saved + ) == saved + ) + + let withoutThinking = DailyUXModelOptions.updating(saved.options, id: "thinking", value: nil) + #expect( + ProviderModelConfiguration.materializedOptions( + for: model, + preserving: withoutThinking + ) == [ + .init(id: "effort", value: .string("unlisted")), + .init(id: "providerFlag", value: .boolean(true)), + ] + ) + + let withoutEffort = DailyUXModelOptions.updating(withoutThinking, id: "effort", value: nil) + #expect( + ProviderModelConfiguration.materializedOptions( + for: model, + preserving: withoutEffort + ) == [ + .init(id: "providerFlag", value: .boolean(true)), + ] + ) + } + + @Test + func updatingAnOptionReplacesOnlyItsPreviousValue() { + let initial = [ + FeatureModelOptionSelection(id: "effort", value: .string("low")), + FeatureModelOptionSelection(id: "fast", value: .boolean(false)), + ] + + let updated = DailyUXModelOptions.updating( + initial, + id: "effort", + value: .string("high") + ) + + #expect(updated.first { $0.id == "effort" }?.value == .string("high")) + #expect(updated.first { $0.id == "fast" }?.value == .boolean(false)) + #expect(updated.count == 2) + } + + @Test + func optionSummaryUsesChoiceLabelsAndEnabledBooleans() { + let model = FeatureModel( + id: "gpt-5", + name: "GPT-5", + options: [ + .init( + id: "effort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "high", label: "High")] + ), + .init(id: "fast", label: "Fast", kind: .boolean), + ] + ) + + let summary = DailyUXModelOptions.summary( + for: model, + selections: [ + .init(id: "effort", value: .string("high")), + .init(id: "fast", value: .boolean(true)), + ] + ) + + #expect(summary == "High · Fast") + } + + @Test + func compactReasoningSummaryIgnoresOtherModelOptions() { + let model = FeatureModel( + id: "gpt-5", + name: "A model name long enough to truncate", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "xhigh", label: "Extra high")] + ), + .init(id: "fast", label: "Fast mode", kind: .boolean), + ] + ) + + let summary = DailyUXModelOptions.reasoningSummary( + for: model, + selections: [ + .init(id: "reasoningEffort", value: .string("xhigh")), + .init(id: "fast", value: .boolean(true)), + ] + ) + + #expect(summary == "Extra high") + } + + @Test + func preferredSelectionFindsDefaultAcrossProvidersAndIncludesDefaults() { + let providers = [ + FeatureProvider( + id: "first", + name: "First", + models: [.init(id: "basic", name: "Basic")] + ), + FeatureProvider( + id: "second", + name: "Second", + models: [ + .init( + id: "preferred", + name: "Preferred", + isDefault: true, + options: [ + .init( + id: "fast", + label: "Fast", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ), + ] + ), + ] + + let selection = DailyUXModelOptions.preferredSelection(in: providers) + + #expect(selection?.providerID == "second") + #expect(selection?.modelID == "preferred") + #expect(selection?.options == [ + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + ]) + } + + @Test + func projectDefaultWinsBeforeAppAndCatalogDefaults() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init(id: "project", name: "Project"), + .init(id: "app", name: "App"), + .init(id: "catalog", name: "Catalog", isDefault: true), + ] + ), + ] + + let selection = DailyUXModelOptions.initialSelection( + projectDefault: .init(providerID: "codex", modelID: "project"), + appDefault: .init(providerID: "codex", modelID: "app"), + providers: providers + ) + + #expect(selection?.modelID == "project") + } + + @Test + func missingAndStaleSelectionsMaterializeTheConcretePreferredModel() { + let providers = [ + FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init( + id: "claude-sonnet-4", + name: "Sonnet 4", + isDefault: true, + isLegacy: true + ), + .init(id: "claude-opus-5", name: "Opus 5"), + ] + ), + ] + + #expect( + ProviderModelSelectionResolver.materialized(nil, in: providers)?.modelID + == "claude-opus-5" + ) + #expect( + ProviderModelSelectionResolver.materialized( + .init(providerID: "claude", modelID: "removed"), + in: providers + )?.modelID == "claude-opus-5" + ) + } + + @Test + func selectionWaitsForTheProviderCatalogBeforeMaterializing() { + let saved = FeatureSelection( + providerID: "claude", + modelID: "claude-opus-5" + ) + + #expect(ProviderModelSelectionResolver.materialized(saved, in: []) == saved) + #expect(ProviderModelSelectionResolver.materialized(nil, in: []) == nil) + } + + @Test + func threadComposerInheritsClaudeWithoutMaterializingTheCodexDefault() { + let inherited = FeatureSelection(providerID: "claude", modelID: "claude-opus-5") + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "Sol", isDefault: true)] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "claude-opus-5", name: "Opus 5")] + ), + ] + + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: providers + )?.providerID == "claude" + ) + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + nil, + inherited: inherited, + providers: providers + ) == nil + ) + } + + @Test + func unlockedThreadRejectsCrossProviderOverrideAndAllowsSameProviderModel() { + let inherited = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-sol") + let crossProvider = FeatureSelection(providerID: "claude", modelID: "claude-opus-5") + let sameProvider = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-terra") + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-sol", name: "Sol", isDefault: true), + .init(id: "gpt-5.6-terra", name: "Terra"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "claude-opus-5", name: "Opus 5")] + ), + ] + + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + crossProvider, + inherited: inherited, + providers: providers + ) == nil + ) + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + sameProvider, + inherited: inherited, + providers: providers + )?.modelID == "gpt-5.6-terra" + ) + #expect( + ProviderModelDraftPolicy.validated( + crossProvider, + providers: providers, + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + #expect( + ProviderModelDraftPolicy.validated( + sameProvider, + providers: providers, + inheriting: inherited, + allowsProviderChange: false + )?.modelID == "gpt-5.6-terra" + ) + } + + @Test + func lockedProviderRejectsAChangeToAnotherModel() { + let inherited = FeatureSelection(providerID: "claude", modelID: "opus") + let alternate = FeatureSelection(providerID: "claude", modelID: "sonnet") + let configured = FeatureSelection( + providerID: "claude", + modelID: "opus", + options: [.init(id: "reasoningEffort", value: .string("high"))] + ) + let provider = FeatureProvider( + id: "claude", + name: "Claude", + requiresNewThreadForModelChange: true, + models: [ + .init( + id: "opus", + name: "Opus", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [.init(id: "high", label: "High")] + ), + ] + ), + .init(id: "sonnet", name: "Sonnet"), + ] + ) + + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + alternate, + inherited: inherited, + providers: [provider] + ) == nil + ) + #expect( + ProviderModelDraftPolicy.validated( + alternate, + providers: [provider], + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + #expect( + ProviderModelDraftPolicy.validated( + configured, + providers: [provider], + inheriting: inherited, + allowsProviderChange: false + ) == configured + ) + } + + @Test + func pickerShowsAllProvidersForNewTasksAndOnlyTheThreadProviderOtherwise() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [.init(id: "sol", name: "Sol")] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "opus", name: "Opus")] + ), + ] + + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + providers, + inherited: nil, + allowsProviderChange: true + ).map(\.id) == ["codex", "claude"] + ) + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + providers, + inherited: .init(providerID: "codex", modelID: "sol"), + allowsProviderChange: false + ).map(\.id) == ["codex"] + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "claude", modelID: "opus"), + providers: providers, + inheriting: nil, + allowsProviderChange: true + )?.providerID == "claude" + ) + } + + @Test + func missingInheritedProviderDoesNotUseAnotherProviderCatalog() { + let inherited = FeatureSelection(providerID: "claude", modelID: "opus") + let codex = FeatureProvider( + id: "codex", + name: "Codex", + models: [.init(id: "sol", name: "Sol", isDefault: true)] + ) + + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + [codex], + inherited: inherited, + allowsProviderChange: false + ).isEmpty + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "codex", modelID: "sol"), + providers: [codex], + inheriting: nil, + allowsProviderChange: false + ) == nil + ) + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + [codex], + inherited: nil, + allowsProviderChange: false + ).isEmpty + ) + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: [codex] + ) == inherited + ) + } + + @Test + func threadCatalogUsesThreadEnvironmentWhenProjectIsMissing() { + let codex = FeatureProvider( + id: "codex-work", + name: "Codex", + driver: "codex", + models: [.init(id: "current", name: "Current")] + ) + let snapshot = FeatureSnapshot( + providersByEnvironment: ["remote": [codex]] + ) + let thread = FeatureThread( + id: "thread", + projectID: "missing-project", + environmentID: "remote", + title: "Task", + providerID: "codex-work", + providerName: "Codex", + modelID: "current" + ) + + let providers = ThreadComposerProviderCatalog.providers(for: thread, in: snapshot) + + #expect(providers == [codex]) + } + + @Test + func threadCatalogIgnoresAStaleProjectEnvironment() { + let local = FeatureProvider( + id: "claude-local", + name: "Claude", + driver: "claudeAgent", + models: [.init(id: "sonnet", name: "Sonnet")] + ) + let remote = FeatureProvider( + id: "codex-remote", + name: "Codex", + driver: "codex", + models: [.init(id: "current", name: "Current")] + ) + let snapshot = FeatureSnapshot( + projects: [ + .init( + id: "project", + environmentID: "local", + name: "Stale project", + path: "/tmp/project" + ), + ], + providersByEnvironment: [ + "local": [local], + "remote": [remote], + ] + ) + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "remote", + title: "Task", + providerID: "codex-remote", + providerName: "Codex", + modelID: "current" + ) + + let providers = ThreadComposerProviderCatalog.providers(for: thread, in: snapshot) + + #expect(providers == [remote]) + } + + @Test + func threadCatalogKeepsCustomModelMissingFromDiscovery() { + let savedOptions = [ + FeatureModelOptionSelection(id: "reasoningEffort", value: .string("high")), + ] + let discovered = FeatureProvider( + id: "codex-work", + name: "Codex", + driver: "codex", + models: [.init(id: "current", name: "Current")] + ) + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "remote", + title: "Task", + providerID: "codex-work", + providerName: "Codex", + modelID: "custom-model", + modelOptions: savedOptions + ) + let snapshot = FeatureSnapshot( + providersByEnvironment: ["remote": [discovered]] + ) + + let providers = ThreadComposerProviderCatalog.providers(for: thread, in: snapshot) + let inherited = FeatureSelection( + providerID: "codex-work", + modelID: "custom-model", + options: savedOptions + ) + + #expect(providers[0].models.map(\.id) == ["current", "custom-model"]) + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: providers + ) == inherited + ) + } + + @Test + func threadCatalogKeepsSavedSelectionWhenProviderIsUnavailable() { + let inherited = FeatureSelection( + providerID: "codex-work", + modelID: "custom-model", + options: [.init(id: "reasoningEffort", value: .string("high"))] + ) + let unavailable = FeatureProvider( + id: "codex-work", + name: "Codex", + isAvailable: false, + driver: "codex", + models: [.init(id: "custom-model", name: "Custom model")] + ) + + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: [unavailable] + ) == inherited + ) + #expect( + ProviderModelDraftPolicy.validated( + inherited, + providers: [unavailable], + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + } + + @Test + func threadCatalogLocksToExactProviderInstanceButAllowsItsModels() { + let inherited = FeatureSelection(providerID: "codex-work", modelID: "current") + let work = FeatureProvider( + id: "codex-work", + name: "Codex work", + driver: "codex", + models: [ + .init(id: "current", name: "Current"), + .init(id: "alternate", name: "Alternate"), + ] + ) + let personal = FeatureProvider( + id: "codex-personal", + name: "Codex personal", + driver: "codex", + models: [ + .init(id: "current", name: "Current"), + .init(id: "alternate", name: "Alternate"), + ] + ) + + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + [work, personal], + inherited: inherited, + allowsProviderChange: false + ).map(\.id) == ["codex-work"] + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "codex-work", modelID: "alternate"), + providers: [work, personal], + inheriting: inherited, + allowsProviderChange: false + )?.modelID == "alternate" + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "codex-personal", modelID: "alternate"), + providers: [work, personal], + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + } + + @Test + func configurationMaterializesDisplayedDefaultsWithoutOverwritingSelections() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "effort", + label: "Effort", + kind: .select, + choices: [ + .init(id: "high", label: "High", isDefault: true), + ] + ), + .init( + id: "fast", + label: "Fast", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ) + let existing = [ + FeatureModelOptionSelection(id: "effort", value: .string("custom")), + ] + + #expect( + ProviderModelConfiguration.materializedOptions( + for: model, + preserving: existing + ) == [ + .init(id: "effort", value: .string("custom")), + .init(id: "fast", value: .boolean(true)), + ] + ) + } + + @Test + func concreteSelectionIncludesTheOptionDefaultsShownByThePicker() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "effort", + label: "Effort", + kind: .select, + choices: [ + .init(id: "high", label: "High", isDefault: true), + ] + ), + ] + ), + ] + ), + ] + + #expect( + ProviderModelSelectionResolver.materialized( + .init(providerID: "codex", modelID: "gpt-5.6-sol"), + in: providers + )?.options == [ + .init(id: "effort", value: .string("high")), + ] + ) + } + + @Test + func configurationSeedsFromTheEffectiveSavedThreadSelection() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High"), + ] + ), + .init(id: "fast", label: "Fast", kind: .boolean), + ] + ) + let provider = FeatureProvider(id: "codex", name: "Codex", models: [model]) + let inherited = FeatureSelection( + providerID: "codex", + modelID: model.id, + options: [ + .init(id: "reasoningEffort", value: .string("high")), + .init(id: "fast", value: .boolean(true)), + ] + ) + let effective = ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: [provider] + ) + + let configured = ProviderModelConfiguration.selection( + for: DailyUXModelOption(provider: provider, model: model), + preserving: effective + ) + + #expect(configured == inherited) + } + + @Test + func explicitDraftOptionsWinOverTheInheritedThreadOptions() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High"), + ] + ), + ] + ) + let provider = FeatureProvider(id: "codex", name: "Codex", models: [model]) + let inherited = FeatureSelection( + providerID: "codex", + modelID: model.id, + options: [.init(id: "reasoningEffort", value: .string("low"))] + ) + let explicit = FeatureSelection( + providerID: "codex", + modelID: model.id, + options: [.init(id: "reasoningEffort", value: .string("high"))] + ) + + let effective = ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: explicit, + inherited: inherited, + providers: [provider] + ) + + #expect(effective == explicit) + } + + @Test + func unsupportedValuesAndUndescribedOptionsRemainVisibleAndPreserved() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High"), + ] + ), + ] + ) + let saved = [ + FeatureModelOptionSelection( + id: "reasoningEffort", + value: .string("environment-custom") + ), + FeatureModelOptionSelection(id: "providerFlag", value: .boolean(true)), + ] + let descriptor = DailyUXModelOptions.reasoningDescriptor(for: model) + + #expect(descriptor?.choices.map(\.id) == ["low", "high"]) + #expect( + descriptor.map { + DailyUXModelOptions.isSupportedValue(saved[0].value, for: $0) + } == false + ) + #expect( + DailyUXModelOptions.undescribedSelections( + for: model, + selections: saved + ).map(\.id) == ["providerFlag"] + ) + #expect( + DailyUXModelOptions.reasoningSummary(for: model, selections: saved) + == "environment-custom" + ) + } + + @Test + func changingReasoningPreservesNonreasoningAndUndescribedOptions() { + let selections = [ + FeatureModelOptionSelection(id: "reasoningEffort", value: .string("low")), + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + FeatureModelOptionSelection(id: "providerFlag", value: .string("keep")), + ] + + let updated = DailyUXModelOptions.updating( + selections, + id: "reasoningEffort", + value: .string("high") + ) + + #expect(updated.first { $0.id == "reasoningEffort" }?.value == .string("high")) + #expect(updated.first { $0.id == "fast" }?.value == .boolean(true)) + #expect(updated.first { $0.id == "providerFlag" }?.value == .string("keep")) + } + + @Test + func modelDraftCachePreservesOptionsWhenReturningToASelection() { + let reasoning = FeatureModelOptionDescriptor( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low", isDefault: true), + .init(id: "high", label: "High"), + ] + ) + let modelA = FeatureModel(id: "model-a", name: "Model A", options: [reasoning]) + let modelB = FeatureModel(id: "model-b", name: "Model B", options: [reasoning]) + let provider = FeatureProvider(id: "codex", name: "Codex", models: [modelA, modelB]) + let savedA = FeatureSelection( + providerID: provider.id, + modelID: modelA.id, + options: [ + .init(id: reasoning.id, value: .string("high")), + .init(id: "providerFlag", value: .boolean(true)), + ] + ) + let optionA = DailyUXModelOption(provider: provider, model: modelA) + let optionB = DailyUXModelOption(provider: provider, model: modelB) + let selectedB = ProviderModelDraftPolicy.selection( + for: optionB, + cached: nil, + current: savedA, + committed: savedA + ) + + let returnedA = ProviderModelDraftPolicy.selection( + for: optionA, + cached: savedA, + current: selectedB, + committed: savedA + ) + + #expect(returnedA == savedA) + } + + @Test + func liveSelectionChangesAndUnavailableModelsInvalidateEditedDrafts() { + let modelA = FeatureModel(id: "model-a", name: "Model A") + let modelB = FeatureModel(id: "model-b", name: "Model B") + let provider = FeatureProvider( + id: "codex", + name: "Codex", + requiresNewThreadForModelChange: true, + models: [modelA, modelB] + ) + let selectionA = FeatureSelection(providerID: provider.id, modelID: modelA.id) + let selectionB = FeatureSelection(providerID: provider.id, modelID: modelB.id) + + #expect( + ProviderModelDraftPolicy.canKeepEditedDraft( + base: selectionA, + currentCommitted: selectionB, + draft: selectionA, + providers: [provider], + inheriting: nil, + allowsProviderChange: true + ) == false + ) + #expect( + ProviderModelDraftPolicy.canKeepEditedDraft( + base: selectionA, + currentCommitted: selectionA, + draft: selectionA, + providers: [], + inheriting: nil, + allowsProviderChange: true + ) == false + ) + #expect( + ProviderModelDraftPolicy.validated( + selectionB, + providers: [provider], + inheriting: selectionA, + allowsProviderChange: false + ) == nil + ) + } + + @Test + func duplicateCatalogEntriesCollapseAndImplicitModelsDisappear() { + let normalized = ProviderModelCatalogNormalizer.normalized([ + FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init(id: "environment-auto", name: "Automatic (recommended)"), + .init(id: "opus", name: "Opus"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude duplicate", + models: [ + .init(id: "opus", name: "Opus duplicate"), + .init(id: "sonnet", name: "Sonnet"), + ] + ), + ]) + + #expect(normalized.map(\.id) == ["claude"]) + #expect(normalized[0].name == "Claude") + #expect(normalized[0].models.map(\.id) == ["opus", "sonnet"]) + #expect(normalized[0].models.map(\.name) == ["Opus", "Sonnet"]) + } + + @Test + func duplicateProvidersMergeComposerCommandsAndSkills() { + let normalized = ProviderModelCatalogNormalizer.normalized([ + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "opus", name: "Opus")], + slashCommands: [.init(name: "review")], + skills: [.init(name: "deploy")] + ), + FeatureProvider( + id: "claude", + name: "Claude", + driver: "claudeAgent", + models: [.init(id: "sonnet", name: "Sonnet")], + slashCommands: [.init(name: "review"), .init(name: "compact")], + skills: [.init(name: "deploy"), .init(name: "fix-ci")] + ), + ]) + + #expect(normalized[0].driver == "claudeAgent") + #expect(normalized[0].slashCommands?.map(\.name) == ["review", "compact"]) + #expect(normalized[0].skills?.map(\.name) == ["deploy", "fix-ci"]) + } + + @Test + func favoritesAndRecentsDoNotRepeatInProviderSections() { + let providers = [ + FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init(id: "claude-opus-5", name: "Opus 5"), + .init(id: "claude-sonnet-5", name: "Sonnet 5"), + .init(id: "claude-fable-5", name: "Fable 5"), + .init(id: "claude-haiku-3", name: "Haiku 3", isLegacy: true), + ] + ), + ] + let opus = DailyUXModelOption.key( + providerID: "claude", + modelID: "claude-opus-5" + ) + let sonnet = DailyUXModelOption.key( + providerID: "claude", + modelID: "claude-sonnet-5" + ) + let catalog = DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [opus], + recentIDs: [sonnet] + ) + + let remaining = ProviderModelDisplaySections(catalog: catalog) + + #expect( + remaining.currentProviderGroups.flatMap(\.models).map(\.model.id) + == ["claude-fable-5"] + ) + #expect(remaining.legacy.map(\.model.id) == ["claude-haiku-3"]) + } + + @Test + func legacyFavoritesStayPromotedAndReturnToLegacyWhenUnfavorited() { + let provider = FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init(id: "current", name: "Current"), + .init(id: "older", name: "Older", isLegacy: true), + ] + ) + let favoriteID = DailyUXModelOption.key(providerID: provider.id, modelID: "older") + for query in ["", "Older"] { + let sections = ProviderModelDisplaySections(catalog: DailyUXModelCatalog( + providers: [provider], query: query, favoriteIDs: [favoriteID], recentIDs: [favoriteID] + )) + #expect(sections.favorites.map(\.model.id) == ["older"]) + #expect(sections.recents.isEmpty) + #expect(sections.legacy.isEmpty) + } + let unfavorited = ProviderModelDisplaySections(catalog: DailyUXModelCatalog( + providers: [provider], query: "", favoriteIDs: [], recentIDs: [favoriteID] + )) + #expect(unfavorited.favorites.isEmpty) + #expect(unfavorited.legacy.map(\.model.id) == ["older"]) + } + + @Test + func serverLegacyMetadataIsAuthoritative() { + let codex = FeatureProvider(id: "work-openai", name: "Codex", driver: "codex") + let claude = FeatureProvider( + id: "work-claude", + name: "Anthropic", + driver: "claudeAgent" + ) + + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt-5.6-codex-luna", name: "Luna", isLegacy: false), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt_5_6_terra", name: "GPT 5.6 Terra", isLegacy: false), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt-5-6-sol", name: "Sol", isLegacy: false), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-fable-5-202607", name: "Fable 5", isLegacy: false), + provider: claude + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-opus-5", name: "OPUS 5", isLegacy: false), + provider: claude + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-sonnet-5", name: "Sonnet v5", isLegacy: false), + provider: claude + ) + ) + #expect( + !ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-opus-4-1", name: "Opus 4.1", isLegacy: true), + provider: claude + ) + ) + #expect( + !ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt-5.5-codex-sol", name: "GPT 5.5 Sol", isLegacy: true), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "custom-opus-4", name: "Custom Opus 4"), + provider: claude + ) + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift new file mode 100644 index 000000000000..1adcc159b668 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -0,0 +1,1558 @@ +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Message-first task creation") +struct DailyUXNewTaskTests { + @Test + func recentProjectRankingDrivesTheDefaultAndKeepsUnusedProjectsOut() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let unused = rankedProject("unused", name: "Unused") + let value = rankedSnapshot( + projects: [unused, beta, alpha], + threads: [ + rankedThread("older", projectID: alpha.id, activity: 10), + rankedThread("newer", projectID: beta.id, activity: 20), + ] + ) + + let ranking = DailyUXCreationContext.recentProjects(in: value) + + #expect(ranking.map(\.project.id) == [beta.id, alpha.id]) + #expect( + DailyUXCreationContext.initialProject(in: value, requestedProjectID: nil)?.id + == ranking.first?.project.id + ) + } + + @Test + func recentProjectRankingIsStableForTiesAndIgnoresMissingProjects() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot( + projects: [alpha, beta], + threads: [ + rankedThread("z-thread", projectID: alpha.id, activity: 20), + rankedThread("missing", projectID: "missing", activity: 30), + rankedThread("a-thread", projectID: beta.id, activity: 20), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value) + .map(\.project.id) == [beta.id, alpha.id] + ) + } + + @Test + func recentProjectRankingUsesActivityInsteadOfMetadataChanges() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot( + projects: [alpha, beta], + threads: [ + rankedThread( + "metadata-change", + projectID: alpha.id, + updatedAt: 100, + lastActivityAt: 10 + ), + rankedThread("actual-use", projectID: beta.id, activity: 20), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value) + .map(\.project.id) == [beta.id, alpha.id] + ) + } + + @Test + func archivedAndSettledThreadsStillRepresentProjectUse() { + let archived = rankedProject("archived", name: "Archived") + let settled = rankedProject("settled", name: "Settled") + let value = rankedSnapshot( + projects: [archived, settled], + threads: [ + rankedThread( + "archived-thread", + projectID: archived.id, + activity: 30, + isArchived: true + ), + rankedThread( + "settled-thread", + projectID: settled.id, + activity: 20, + isSettled: true + ), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value) + .map(\.project.id) == [archived.id, settled.id] + ) + } + + @Test + func explicitProjectWinsAndNoActivityFallsBackAlphabetically() { + let zulu = rankedProject("zulu", name: "Zulu") + let alpha = rankedProject("alpha", name: "Alpha") + let withActivity = rankedSnapshot( + projects: [zulu, alpha], + threads: [rankedThread("recent", projectID: zulu.id, activity: 20)] + ) + let withoutActivity = rankedSnapshot(projects: [zulu, alpha], threads: []) + + #expect( + DailyUXCreationContext.initialProject( + in: withActivity, + requestedProjectID: alpha.id + )?.id == alpha.id + ) + #expect(DailyUXCreationContext.recentProjects(in: withoutActivity).isEmpty) + #expect( + DailyUXCreationContext.initialProject( + in: withoutActivity, + requestedProjectID: nil + )?.id == alpha.id + ) + } + + @Test + func recentProjectRankingExcludesDisabledEnvironments() { + let enabled = rankedProject("enabled", name: "Enabled", environmentID: "enabled-env") + let disabled = rankedProject("disabled", name: "Disabled", environmentID: "disabled-env") + let value = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "enabled-env", + name: "Enabled", + endpoint: "http://enabled", + isEnabled: true + ), + FeatureEnvironment( + id: "disabled-env", + name: "Disabled", + endpoint: "http://disabled", + isEnabled: false + ), + ], + projects: [enabled, disabled], + threads: [ + rankedThread("enabled-thread", projectID: enabled.id, activity: 10), + rankedThread("disabled-thread", projectID: disabled.id, activity: 20), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value).map(\.project.id) == [enabled.id] + ) + } + + @Test + func recentProjectRankingDeduplicatesARepositoryAcrossEnvironments() { + let local = rankedProject( + "local", + name: "Project", + environmentID: "local-env", + repositoryKey: "github.com/example/project" + ) + let remote = rankedProject( + "remote", + name: "Project", + environmentID: "remote-env", + repositoryKey: "github.com/example/project" + ) + let value = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "local-env", + name: "Local", + endpoint: "http://local" + ), + FeatureEnvironment( + id: "remote-env", + name: "Remote", + endpoint: "http://remote" + ), + ], + projects: [local, remote], + threads: [ + rankedThread( + "local-thread", + projectID: local.id, + environmentID: "local-env", + activity: 10 + ), + rankedThread( + "remote-thread", + projectID: remote.id, + environmentID: "remote-env", + activity: 20 + ), + ] + ) + + let ranking = DailyUXCreationContext.recentProjects(in: value) + #expect(ranking.count == 1) + #expect(ranking.first?.project.id == remote.id) + } + + @Test + func recentProjectRankingKeepsTheExactWorktreeUsedByTheThread() { + let root = rankedProject( + "root", + name: "Project", + repositoryKey: "github.com/example/project" + ) + let worktree = rankedProject( + "worktree", + name: "Project", + repositoryKey: "github.com/example/project" + ) + let value = rankedSnapshot( + projects: [root, worktree], + threads: [rankedThread("recent", projectID: worktree.id, activity: 20)] + ) + + #expect(DailyUXCreationContext.recentProjects(in: value).first?.project.id == worktree.id) + #expect( + DailyUXCreationContext.initialProject( + in: value, + requestedProjectID: worktree.id + )?.id == worktree.id + ) + } + + @Test + func automaticProjectAdoptionWaitsForRestoreAndStopsAfterExplicitChoices() { + #expect( + DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + + for explicitChoice in 0..<3 { + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: explicitChoice == 0, + modelSelectionIsExplicit: explicitChoice == 1, + workspaceSelectionIsExplicit: explicitChoice == 2, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + } + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: false + ) + ) + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: true, + draftRestoreIsComplete: true + ) + ) + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "fallback", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: nil, + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + } + + @Test + func requestPreservesLegacyPermissionAndKeepsImageBytes() { + let image = FeatureDraftAttachment( + data: Data([1, 2, 3]), + filename: "Image 1.jpg", + mimeType: "image/jpeg" + ) + let request = NewTaskRequest( + projectID: "project", + prompt: " Build it \n", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5"), + runtimeMode: .approvalRequired, + interactionMode: .plan, + attachments: [image] + ) + + #expect(request.trimmedPrompt == "Build it") + #expect(request.runtimeMode == .approvalRequired) + #expect(request.interactionMode == .standard) + #expect(request.workspaceMode == .local) + #expect(request.branch == nil) + #expect(request.worktreePath == nil) + #expect(!request.startFromOrigin) + #expect(request.attachments.first?.byteCount == 3) + } + + @Test + func worktreeRequestKeepsBaseBranchAndDropsExistingCheckoutPath() { + let request = NewTaskRequest( + projectID: "project", + prompt: "Build it", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: .worktree, + branch: " main ", + worktreePath: "/existing/worktree", + startFromOrigin: true + ) + + #expect(request.branch == "main") + #expect(request.worktreePath == nil) + #expect(request.startFromOrigin) + } + + @Test + func workspaceDefaultsPreferCurrentCheckoutAndLocalDefaultBase() throws { + let branches = [ + FeatureWorkspaceBranch(name: "origin/main", isRemote: true, isDefault: true), + FeatureWorkspaceBranch(name: "feature", isCurrent: true), + FeatureWorkspaceBranch(name: "main", isDefault: true), + ] + + #expect(NewTaskWorkspaceDefaults.localBranch(in: branches)?.name == "feature") + #expect(NewTaskWorkspaceDefaults.worktreeBase(in: branches)?.name == "main") + + let root = FeatureWorkspaceBranch( + name: "feature", + worktreePath: "/repo/./" + ) + #expect( + NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: root, + projectPath: "/repo" + ) == nil + ) + + let linked = FeatureWorkspaceBranch( + name: "linked", + worktreePath: "/worktrees/linked" + ) + #expect( + NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: linked, + projectPath: "/repo" + ) == "/worktrees/linked" + ) + } + + @Test + func mobileModeChoicesOnlyExposeSupportedValues() { + #expect(FeatureRuntimeMode.allCases == [.automatic, .fullAccess]) + #expect(FeatureInteractionMode.allCases == [.standard]) + } + + @Test + func newTasksDefaultToFullAccessBuildMode() { + let request = NewTaskRequest( + projectID: "project", + prompt: "Build it", + selection: nil + ) + + #expect(request.runtimeMode == .fullAccess) + #expect(request.interactionMode == .standard) + } + + @Test + func projectDraftRestoreNeverOverwritesTypingMadeWhileLoading() { + let savedAttachment = FeatureDraftAttachment( + data: Data([0x01]), + filename: "saved.png", + mimeType: "image/png" + ) + let context = NewTaskDraftRestoreContext( + projectID: "second-project", + baseline: FeatureComposerDraft() + ) + + let merged = context.merging( + saved: FeatureComposerDraft( + text: "Old saved prompt", + attachments: [savedAttachment] + ), + current: FeatureComposerDraft(text: "Typed while loading") + ) + + #expect(context.projectID == "second-project") + #expect(merged.text == "Typed while loading") + #expect(merged.attachments == [savedAttachment]) + } + + @Test + func computerSwitchCarriesLocalContentAndDropsAnotherServersUpload() { + let oldUpload = FeatureUploadedAttachmentReference( + environmentID: "source", attachmentID: "old-upload" + ) + let currentUpload = FeatureUploadedAttachmentReference( + environmentID: "target", attachmentID: "target-upload" + ) + let localFile = FeatureOwnedAttachmentFile( + fileName: "screenshot.png", + url: URL(fileURLWithPath: "/drafts/screenshot.png"), + byteCount: 1_024 + ) + let source = FeatureComposerDraft( + text: "Fix this screenshot", + attachments: [ + FeatureDraftAttachment( + ownedFile: localFile, thumbnailData: Data([0x01]), + filename: "screenshot.png", mimeType: "image/png", + uploadedReference: oldUpload + ), + FeatureDraftAttachment( + data: Data([0x02]), filename: "target.png", mimeType: "image/png", + uploadedReference: currentUpload + ), + ], + selection: FeatureSelection(providerID: "source-provider", modelID: "source-model"), + workspace: FeatureComposerWorkspaceDraft( + mode: .worktree, branch: "source-branch", worktreePath: "/source/tree", + startFromOrigin: false + ) + ) + let content = NewTaskDraftRestoreContext.content(from: source, forEnvironment: "target") + let context = NewTaskDraftRestoreContext(projectID: "target-project", baseline: content) + let targetSelection = FeatureSelection(providerID: "target-provider", modelID: "target-model") + let targetWorkspace = FeatureComposerWorkspaceDraft( + mode: .local, branch: nil, worktreePath: nil, startFromOrigin: true + ) + let restored = context.merging( + saved: FeatureComposerDraft(selection: targetSelection, workspace: targetWorkspace), + current: content + ) + + #expect(context.shouldCarryContent(into: nil)) + #expect(restored.text == source.text) + #expect(restored.attachments.map(\.id) == source.attachments.map(\.id)) + #expect(restored.attachments[0].ownedFile == localFile) + #expect(restored.attachments[0].thumbnailData == Data([0x01])) + #expect(restored.attachments[0].uploadedReference == nil) + #expect(restored.attachments[1].uploadedReference == currentUpload) + #expect(restored.selection == targetSelection) + #expect(restored.workspace == targetWorkspace) + #expect(source.attachments[0].uploadedReference == oldUpload) + } + + @Test + func computerSwitchKeepsExistingTargetDraftAndLiveEdits() { + let content = FeatureComposerDraft(text: "Prompt from the first computer") + let context = NewTaskDraftRestoreContext(projectID: "target-project", baseline: content) + let targetAttachment = FeatureDraftAttachment( + data: Data([0x01]), filename: "saved.png", mimeType: "image/png" + ) + let saved = FeatureComposerDraft( + text: "Draft already on the target", attachments: [targetAttachment] + ) + + #expect(!context.shouldCarryContent(into: saved)) + #expect(context.merging(saved: saved, current: content) == saved) + #expect(!context.shouldCarryContent(into: FeatureComposerDraft(attachments: [targetAttachment]))) + + let edited = context.merging( + saved: saved, + current: FeatureComposerDraft(text: "Typed while the target draft loaded") + ) + #expect(edited.text == "Typed while the target draft loaded") + #expect(edited.attachments == [targetAttachment]) + + let cleared = context.merging(saved: nil, current: FeatureComposerDraft()) + #expect(cleared.text.isEmpty) + } + + @Test + func sharedProjectDraftRestorationDoesNotReuseAnotherEnvironmentsUpload() { + let source = FeatureComposerDraft( + text: "Shared repo draft", + attachments: [FeatureDraftAttachment( + data: Data([0x01]), filename: "screenshot.png", mimeType: "image/png", + uploadedReference: FeatureUploadedAttachmentReference( + environmentID: "source", attachmentID: "source-upload" + ) + )] + ) + let content = NewTaskDraftRestoreContext.content(from: source, forEnvironment: "target") + let context = NewTaskDraftRestoreContext( + projectID: "target-project", baseline: content, environmentID: "target" + ) + let restored = context.merging(saved: source, current: content) + + #expect(restored.text == source.text) + #expect(restored.attachments[0].id == source.attachments[0].id) + #expect(restored.attachments[0].data == source.attachments[0].data) + #expect(restored.attachments[0].uploadedReference == nil) + } + + @Test + func passiveProjectsExposeTheirFullEnvironmentModelCatalogAndDefault() throws { + let passiveDefault = FeatureSelection( + providerID: "claudeAgent", + modelID: "claude-opus-4-1" + ) + let activeProject = FeatureProject( + id: "active-project", + environmentID: "active", + name: "Active", + path: "/active" + ) + let passiveProject = FeatureProject( + id: "passive-project", + environmentID: "passive", + name: "Passive", + path: "/passive", + defaultSelection: passiveDefault + ) + let snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "active", + name: "Active", + endpoint: "https://active.example", + isActive: true, + connectionState: .connected + ), + .init( + id: "passive", + name: "Passive", + endpoint: "https://passive.example", + connectionState: .connected + ), + .init( + id: "offline", + name: "Offline", + endpoint: "https://offline.example", + connectionState: .disconnected + ), + ], + projects: [ + activeProject, + passiveProject, + .init( + id: "offline-project", + environmentID: "offline", + name: "Offline", + path: "/offline" + ), + ], + providers: [ + .init( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "GPT-5.6")] + ), + ], + providersByEnvironment: [ + "passive": [ + .init( + id: "claudeAgent", + name: "Claude", + models: [ + .init(id: "claude-opus-4-1", name: "Opus"), + .init(id: "claude-sonnet-4", name: "Sonnet"), + ] + ), + ], + ], + preferencesByEnvironment: [ + "active": .init( + defaultWorkspaceMode: .local, + newWorktreesStartFromOrigin: true + ), + "passive": .init( + defaultWorkspaceMode: .worktree, + newWorktreesStartFromOrigin: false + ), + ] + ) + + #expect( + DailyUXCreationContext.projects(in: snapshot).map(\.id) + == ["active-project", "passive-project", "offline-project"] + ) + let passiveProviders = DailyUXCreationContext.providers( + for: passiveProject, + in: snapshot + ) + #expect(passiveProviders.map(\.id) == ["claudeAgent"]) + #expect( + passiveProviders.first?.models.map(\.id) + == ["claude-opus-4-1", "claude-sonnet-4"] + ) + #expect( + DailyUXCreationContext.initialSelection(for: passiveProject, in: snapshot) + == passiveDefault + ) + #expect( + DailyUXCreationContext.environmentPreferences( + for: passiveProject, + in: snapshot + ) == FeatureEnvironmentPreferences( + defaultWorkspaceMode: .worktree, + newWorktreesStartFromOrigin: false + ) + ) + } + + @Test + func explicitEmptyProviderCatalogDoesNotRestoreAStaleProjectDefault() { + let project = FeatureProject( + id: "remote-project", + environmentID: "remote", + name: "Remote", + path: "/remote", + defaultSelection: .init(providerID: "claude", modelID: "old-model") + ) + let snapshot = FeatureSnapshot( + environments: [ + .init( + id: "remote", + name: "Remote", + endpoint: "https://remote.example", + isActive: false, + connectionState: .connected + ), + ], + projects: [project], + providers: [], + providersByEnvironment: ["remote": []] + ) + + #expect(DailyUXCreationContext.providers(for: project, in: snapshot).isEmpty) + } + + @Test + func projectGroupsOnlyOfferComputersThatContainTheSelectedRepository() throws { + let identity = FeatureRepositoryIdentity( + canonicalKey: "github.com/t3/example", + displayName: "Example" + ) + let studio = FeatureProject( + id: "example-studio", + environmentID: "studio", + name: "example", + path: "/code/example", + repositoryIdentity: identity + ) + let laptop = FeatureProject( + id: "example-laptop", + environmentID: "laptop", + name: "example-copy", + path: "/Users/test/example", + repositoryIdentity: identity + ) + let unrelated = FeatureProject( + id: "other-laptop", + environmentID: "laptop", + name: "other", + path: "/Users/test/other", + repositoryIdentity: .init(canonicalKey: "github.com/t3/other") + ) + + let groups = DailyUXProjectGrouping.groups(projects: [studio, unrelated, laptop]) + let group = try #require( + DailyUXProjectGrouping.group(containing: studio.id, in: groups) + ) + + #expect(group.name == "Example") + #expect(Set(group.projects.map(\.environmentID)) == ["studio", "laptop"]) + #expect(group.project(in: "laptop")?.id == laptop.id) + #expect(!group.memberProjectIDs.contains(unrelated.id)) + #expect(DailyUXProjectGrouping.logicalProjectID(for: studio) == group.id) + } + + @Test + func projectSelectionResolvesAgainstCurrentGroups() throws { + let identity = FeatureRepositoryIdentity( + canonicalKey: "github.com/t3/example", + displayName: "Example" + ) + let staleStudio = FeatureProject( + id: "stale-studio", + environmentID: "studio", + name: "example", + path: "/code/example", + repositoryIdentity: identity + ) + let currentStudio = FeatureProject( + id: "current-studio", + environmentID: "studio", + name: "example", + path: "/code/example", + repositoryIdentity: identity + ) + let laptop = FeatureProject( + id: "current-laptop", + environmentID: "laptop", + name: "example", + path: "/Users/test/example", + repositoryIdentity: identity + ) + let staleGroup = try #require( + DailyUXProjectGrouping.groups(projects: [staleStudio]).first + ) + let currentGroups = DailyUXProjectGrouping.groups( + projects: [currentStudio, laptop] + ) + + #expect( + DailyUXProjectGrouping.selectionTarget( + groupID: staleGroup.id, + preferredEnvironmentID: laptop.environmentID, + in: currentGroups + )?.id == laptop.id + ) + #expect( + DailyUXProjectGrouping.selectionTarget( + groupID: "removed-project", + preferredEnvironmentID: nil, + in: currentGroups + ) == nil + ) + } + + @Test + func projectsWithoutRepositoryIdentityNeverGroupAcrossComputers() { + let studio = FeatureProject( + id: "studio", + environmentID: "studio", + name: "Same name", + path: "/code/project" + ) + let laptop = FeatureProject( + id: "laptop", + environmentID: "laptop", + name: "Same name", + path: "/code/project" + ) + + let groups = DailyUXProjectGrouping.groups(projects: [studio, laptop]) + + #expect(groups.count == 2) + #expect(groups.allSatisfy { $0.projects.count == 1 }) + } + + @Test + func projectGroupingUsesFreshestPhysicalRowAndNormalizesTrailingSlash() throws { + let identity = FeatureRepositoryIdentity(canonicalKey: "github.com/t3/example") + let stale = FeatureProject( + id: "stale", + environmentID: "studio", + name: "stale", + path: "/code/example/", + repositoryIdentity: nil, + updatedAt: "2026-01-01T00:00:00.000Z" + ) + let current = FeatureProject( + id: "current", + environmentID: "studio", + name: "current", + path: "/code/example", + repositoryIdentity: identity, + updatedAt: "2026-01-02T00:00:00.000Z" + ) + let remote = FeatureProject( + id: "remote", + environmentID: "remote", + name: "remote", + path: "/srv/example", + repositoryIdentity: identity + ) + + let groups = DailyUXProjectGrouping.groups(projects: [stale, current, remote]) + let group = try #require( + DailyUXProjectGrouping.group(containing: stale.id, in: groups) + ) + + #expect(group.projects.map(\.id) == ["remote", "current"]) + #expect(group.memberProjectIDs == ["stale", "current", "remote"]) + + let snapshot = rankedSnapshot( + projects: [stale, current, remote], + threads: [rankedThread("recent", projectID: stale.id, activity: 20)] + ) + #expect( + DailyUXCreationContext.recentProjects(in: snapshot).first?.project.id + == current.id + ) + #expect( + DailyUXCreationContext.initialProject( + in: snapshot, + requestedProjectID: stale.id + )?.id == current.id + ) + } + + @Test + func logicalProjectDraftKeyDoesNotChangeWithComputer() { + let projectKey = "github.com/t3/example" + + #expect( + FeatureComposerDraftStore.newTaskKey(logicalProjectID: projectKey) + == "logical-project:github.com/t3/example:new-task" + ) + } + + @Test + func projectGroupingHonorsRepositoryPathAndSeparateModes() { + let identity = FeatureRepositoryIdentity( + canonicalKey: "github.com/t3/mono", + rootPath: "/code/mono" + ) + let app = FeatureProject( + id: "app", + environmentID: "studio", + name: "app", + path: "/code/mono/apps/app", + repositoryIdentity: identity + ) + let docs = FeatureProject( + id: "docs", + environmentID: "studio", + name: "docs", + path: "/code/mono/apps/docs", + repositoryIdentity: identity + ) + + #expect(DailyUXProjectGrouping.groups(projects: [app, docs]).count == 1) + #expect( + DailyUXProjectGrouping.groups( + projects: [app, docs], + mode: .repositoryPath + ).count == 2 + ) + #expect( + DailyUXProjectGrouping.groups( + projects: [app, docs], + mode: .separate + ).count == 2 + ) + } + + @Test + func projectDefaultWinsAndExplicitModelCarriesAcrossCompatibleProjects() throws { + let appDefault = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-sol") + let explicit = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-luna") + let project = FeatureProject( + id: "project", + environmentID: "studio", + name: "Project", + path: "/project", + defaultSelection: .init(providerID: "codex", modelID: "gpt-5.6-terra") + ) + let snapshot = FeatureSnapshot( + projects: [project], + providers: [ + .init( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-luna", name: "Luna"), + .init(id: "gpt-5.6-terra", name: "Terra"), + .init(id: "gpt-5.6-sol", name: "Sol"), + ] + ), + ], + providersByEnvironment: [ + "studio": [ + .init( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-luna", name: "Luna"), + .init(id: "gpt-5.6-terra", name: "Terra"), + .init(id: "gpt-5.6-sol", name: "Sol"), + ] + ), + ], + ], + settings: .init(defaultSelection: appDefault) + ) + + #expect( + DailyUXCreationContext.initialSelection(for: project, in: snapshot) + == FeatureSelection(providerID: "codex", modelID: "gpt-5.6-terra") + ) + #expect( + DailyUXCreationContext.selection( + carrying: explicit, + to: project, + in: snapshot + ) == explicit + ) + } + + @Test @MainActor + func imageProcessorDownsamplesUploadAndBuildsSmallThumbnail() throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 2_400, height: 1_200)) + .image { context in + UIColor.systemPink.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2_400, height: 1_200)) + } + let sourceData = try #require(source.pngData()) + + let attachment = try FeatureImageProcessor.attachment( + from: sourceData, + ordinal: 1 + ) + let prepared = try #require(UIImage(data: attachment.data)) + let thumbnail = try #require( + attachment.thumbnailData.flatMap(UIImage.init(data:)) + ) + + #expect(max(prepared.size.width, prepared.size.height) <= 2_048) + #expect(max(thumbnail.size.width, thumbnail.size.height) <= 160) + #expect(attachment.mimeType == "image/jpeg") + } + + @Test + func projectPickerLeadsWithRecentGroupsAndKeepsTheRestAlphabetical() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let gamma = rankedProject("gamma", name: "Gamma") + let delta = rankedProject("delta", name: "Delta") + let epsilon = rankedProject("epsilon", name: "Epsilon") + let value = rankedSnapshot( + projects: [gamma, alpha, epsilon, delta, beta], + threads: [ + rankedThread("delta-thread", projectID: delta.id, activity: 40), + rankedThread("beta-thread", projectID: beta.id, activity: 30), + rankedThread("epsilon-thread", projectID: epsilon.id, activity: 20), + rankedThread("alpha-thread", projectID: alpha.id, activity: 10), + ] + ) + let groups = DailyUXCreationContext.projectGroups(in: value) + + let sections = DailyUXProjectPickerSections( + groups: groups, + recentGroupIDs: DailyUXCreationContext.recentProjects(in: value).map(\.group.id) + ) + + #expect(groups.map(\.name) == ["Alpha", "Beta", "Delta", "Epsilon", "Gamma"]) + #expect(sections.recents.map(\.name) == ["Delta", "Beta", "Epsilon"]) + #expect(sections.others.map(\.name) == ["Alpha", "Gamma"]) + #expect( + Set(sections.recents.map(\.id)) + .isDisjoint(with: Set(sections.others.map(\.id))) + ) + #expect(sections.recents.count + sections.others.count == groups.count) + } + + @Test + func projectPickerKeepsTheAlphabeticalListWhenNoProjectHasBeenUsed() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot(projects: [beta, alpha], threads: []) + let groups = DailyUXCreationContext.projectGroups(in: value) + + let sections = DailyUXProjectPickerSections( + groups: groups, + recentGroupIDs: DailyUXCreationContext.recentProjects(in: value).map(\.group.id) + ) + + #expect(sections.recents.isEmpty) + #expect(sections.others.map(\.name) == ["Alpha", "Beta"]) + } + + @Test + func projectPickerRecentSectionIgnoresRepeatsAndProjectsThatAreGone() throws { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot(projects: [alpha, beta], threads: []) + let groups = DailyUXCreationContext.projectGroups(in: value) + let alphaGroup = try #require( + DailyUXProjectGrouping.group(containing: alpha.id, in: groups) + ) + let betaGroup = try #require( + DailyUXProjectGrouping.group(containing: beta.id, in: groups) + ) + + let sections = DailyUXProjectPickerSections( + groups: groups, + recentGroupIDs: [betaGroup.id, "removed-project-group", betaGroup.id, alphaGroup.id] + ) + + #expect(sections.recents.map(\.name) == ["Beta", "Alpha"]) + #expect(sections.others.isEmpty) + } + + @Test + func modelPickerSearchMatchesNamesAcrossSpacesAndPunctuation() { + let codex = FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-luna", name: "GPT 5.6 Luna"), + .init(id: "gpt-5.6-terra", name: "GPT 5.6 Terra"), + ] + ) + let openCode = FeatureProvider( + id: "opencode", + name: "OpenCode", + models: [.init(id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna")] + ) + + let matching = ProviderModelSearch.matching( + [codex, openCode], + query: "GPT 5.6 Luna" + ) + + #expect(matching.map(\.id) == ["codex", "opencode"]) + #expect(matching.flatMap { $0.models.map(\.id) } == [ + "gpt-5.6-luna", + "openai/gpt-5.6-luna", + ]) + } + + @Test + func modelPickerDisambiguatesModelsWithTheSameVisibleDetails() { + let provider = FeatureProvider( + id: "opencode", + name: "OpenCode", + models: [ + .init(id: "kilo/openai/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "kilo"), + .init(id: "kilo/xai/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "kilo"), + .init(id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "openai"), + ] + ) + let sections = ProviderModelDisplaySections( + catalog: DailyUXModelCatalog( + providers: [provider], + query: "", + favoriteIDs: [], + recentIDs: [] + ) + ) + + #expect(sections.disambiguatedModelIDs == Set([ + "opencode::kilo/openai/gpt-5.6-luna", + "opencode::kilo/xai/gpt-5.6-luna", + ])) + } + + @Test + func modelPickerDisambiguatesProvidersWithTheSameVisibleName() { + let providers = [ + FeatureProvider( + id: "opencode-work", + name: "OpenCode", + models: [ + .init(id: "work/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "openai"), + ] + ), + FeatureProvider( + id: "opencode-personal", + name: "OpenCode", + models: [ + .init(id: "personal/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "openai"), + ] + ), + ] + let sections = ProviderModelDisplaySections( + catalog: DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [], + recentIDs: [] + ) + ) + + #expect(sections.disambiguatedModelIDs == Set([ + "opencode-work::work/gpt-5.6-luna", + "opencode-personal::personal/gpt-5.6-luna", + ])) + } + + @Test + func projectPickerSearchMatchesNamesPathsAndEnvironmentNames() { + let studio = FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio" + ) + let laptop = FeatureEnvironment( + id: "laptop", + name: "Travel Laptop", + endpoint: "http://laptop" + ) + let alpha = rankedProject("ios-app", name: "Alpha", environmentID: studio.id) + let beta = rankedProject("web-client", name: "Beta", environmentID: laptop.id) + let groups = DailyUXProjectGrouping.groups(projects: [beta, alpha]) + + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: "ALPHA", + environments: [studio, laptop] + ).map(\.name) == ["Alpha"] + ) + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: "web-client", + environments: [studio, laptop] + ).map(\.name) == ["Beta"] + ) + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: "travel", + environments: [studio, laptop] + ).map(\.name) == ["Beta"] + ) + } + + @Test + func projectPickerSearchTrimsQueriesAndPreservesGroupOrder() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let groups = DailyUXProjectGrouping.groups(projects: [beta, alpha]) + + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: " \n ", + environments: [] + ).map(\.id) == groups.map(\.id) + ) + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: " BET ", + environments: [] + ).map(\.name) == ["Beta"] + ) + } + + @Test + func projectPickerSearchFindsSecondaryEnvironmentsAndKeepsRecentSections() throws { + let studio = FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio" + ) + let laptop = FeatureEnvironment( + id: "laptop", + name: "Travel Laptop", + endpoint: "http://laptop" + ) + let sharedOnStudio = rankedProject( + "studio-project", + name: "Shared", + environmentID: studio.id, + repositoryKey: "github.com/example/shared" + ) + let sharedOnLaptop = rankedProject( + "laptop-project", + name: "Shared", + environmentID: laptop.id, + repositoryKey: "github.com/example/shared" + ) + let unrelated = rankedProject( + "other-project", + name: "Other", + environmentID: studio.id + ) + let groups = DailyUXProjectGrouping.groups( + projects: [unrelated, sharedOnStudio, sharedOnLaptop] + ) + let sharedGroup = try #require( + DailyUXProjectGrouping.group(containing: sharedOnStudio.id, in: groups) + ) + let unrelatedGroup = try #require( + DailyUXProjectGrouping.group(containing: unrelated.id, in: groups) + ) + + let filtered = NewTaskProjectPickerSearch.matching( + groups, + query: "travel", + environments: [studio, laptop] + ) + let sections = DailyUXProjectPickerSections( + groups: filtered, + recentGroupIDs: [unrelatedGroup.id, sharedGroup.id] + ) + + #expect(filtered.map(\.id) == [sharedGroup.id]) + #expect(sections.recents.map(\.id) == [sharedGroup.id]) + #expect(sections.others.isEmpty) + } + + @Test + func newTaskAvailabilityOnlyTreatsEnabledDisconnectedEnvironmentsAsUnreachable() throws { + let environments = [ + FeatureEnvironment( + id: "disconnected", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + FeatureEnvironment( + id: "reconnecting", + name: "Travel Mac", + endpoint: "http://travel", + connectionState: .reconnecting + ), + FeatureEnvironment( + id: "connecting", + name: "New Mac", + endpoint: "http://new", + connectionState: .connecting + ), + FeatureEnvironment( + id: "connected", + name: "Desk Mac", + endpoint: "http://desk", + connectionState: .connected + ), + FeatureEnvironment( + id: "unknown", + name: "Unknown Mac", + endpoint: "http://unknown" + ), + FeatureEnvironment( + id: "disabled", + name: "Disabled Mac", + endpoint: "http://disabled", + isEnabled: false, + connectionState: .disconnected + ), + ] + + // A reconnecting environment can still serve work through HTTP. + #expect( + DailyUXCreationContext.unreachableEnvironments(in: environments).map(\.id) + == ["disconnected"] + ) + + let projects = environments.map { environment in + rankedProject( + "\(environment.id)-project", + name: environment.name, + environmentID: environment.id + ) + } + let snapshot = rankedSnapshot( + environments: environments, + projects: projects, + threads: [] + ) + + #expect( + DailyUXCreationContext.projects(in: snapshot).map(\.environmentID) + == ["disconnected", "reconnecting", "connecting", "connected", "unknown"] + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "disconnected-project", + in: snapshot + ) == nil + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "reconnecting-project", + in: snapshot + ) == nil + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "connected-project", + in: snapshot + ) == nil + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "disabled-project", + in: snapshot + ) == "Environment is off." + ) + + let disconnectedProject = try #require( + projects.first { $0.environmentID == "disconnected" } + ) + let recoveredSnapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "disconnected", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .reconnecting + ), + ], + projects: [disconnectedProject], + threads: [] + ) + + #expect( + DailyUXCreationContext.projects(in: recoveredSnapshot).map(\.id) + == [disconnectedProject.id] + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: disconnectedProject.id, + in: recoveredSnapshot + ) == nil + ) + + let legacySnapshot = rankedSnapshot( + environments: [], + projects: [disconnectedProject], + threads: [] + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: disconnectedProject.id, + in: legacySnapshot + ) == nil + ) + } + + @Test + func newTaskRouteOpensForUnreachableEnvironmentsWithoutProjects() { + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ], + projects: [], + threads: [] + ) + + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .newTask) + } + + @Test + func newTaskRouteStillUsesProjectCreationWhenNothingIsReachableOrKnownUnreachable() { + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "connecting", + name: "New Mac", + endpoint: "http://new", + connectionState: .connecting + ), + FeatureEnvironment( + id: "disabled", + name: "Disabled Mac", + endpoint: "http://disabled", + isEnabled: false, + connectionState: .disconnected + ), + ], + projects: [], + threads: [] + ) + + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .addProject) + } + + @Test + func newTaskRouteKeepsReachableProjectsWhenUnreachableEnvironmentsCoexist() { + let project = rankedProject( + "reachable-project", + name: "Reachable", + environmentID: "connected" + ) + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "connected", + name: "Desk Mac", + endpoint: "http://desk", + connectionState: .connected + ), + FeatureEnvironment( + id: "unreachable", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ], + projects: [project], + threads: [] + ) + + #expect(DailyUXCreationContext.projects(in: snapshot).map(\.id) == [project.id]) + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .newTask) + #expect( + DailyUXCreationContext.unreachableEnvironments(in: snapshot).map(\.name) + == ["Studio Mac"] + ) + } + + @Test + func unreachableRetryIsSingleFlightAndPresentsProgress() { + var retry = NewTaskRetryState() + + #expect(!retry.isInProgress) + #expect(retry.buttonTitle == "Try again") + let didBegin = retry.begin() + #expect(didBegin) + #expect(retry.isInProgress) + #expect(retry.buttonTitle == "Trying again…") + let duplicateBegin = retry.begin() + #expect(!duplicateBegin) + + retry.finish() + + #expect(!retry.isInProgress) + let didRestart = retry.begin() + #expect(didRestart) + } + + @Test + func zeroSearchMatchesKeepTheUnavailableEnvironmentNotice() { + let project = rankedProject("reachable", name: "Reachable") + let groups = DailyUXProjectGrouping.groups(projects: [project]) + let unavailable = [ + FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ] + let filtered = NewTaskProjectPickerSearch.matching( + groups, + query: "no result", + environments: unavailable + ) + + let presentation = NewTaskProjectPickerPresentation( + groups: groups, + filteredGroups: filtered, + unavailableEnvironments: unavailable + ) + + #expect(presentation.projectContent == .noMatches) + #expect(presentation.unavailableEnvironments.map(\.name) == ["Studio Mac"]) + } + + @Test + func boundedUnavailableNoticeExposesEveryEnvironmentNameToAccessibility() { + let unavailable = (1 ... 5).map { index in + FeatureEnvironment( + id: "environment-\(index)", + name: "Environment \(index)", + endpoint: "http://environment-\(index)", + connectionState: .disconnected + ) + } + let presentation = NewTaskProjectPickerPresentation( + groups: [], + filteredGroups: [], + unavailableEnvironments: unavailable + ) + + #expect( + presentation.visibleUnavailableEnvironments.map(\.name) + == ["Environment 1", "Environment 2", "Environment 3"] + ) + #expect(presentation.additionalUnavailableEnvironmentCount == 2) + for environment in unavailable { + #expect(presentation.unavailableAccessibilityLabel.contains(environment.name)) + } + } + + private func rankedProject( + _ id: String, + name: String, + environmentID: String = "environment", + repositoryKey: String? = nil + ) -> FeatureProject { + FeatureProject( + id: id, + environmentID: environmentID, + name: name, + path: "/\(id)", + repositoryIdentity: repositoryKey.map { + FeatureRepositoryIdentity(canonicalKey: $0) + } + ) + } + + private func rankedThread( + _ id: String, + projectID: String, + environmentID: String? = nil, + activity: TimeInterval? = nil, + updatedAt: TimeInterval? = nil, + lastActivityAt: TimeInterval? = nil, + isArchived: Bool = false, + isSettled: Bool = false + ) -> FeatureThread { + let updatedAt = updatedAt ?? activity ?? 0 + return FeatureThread( + id: id, + projectID: projectID, + environmentID: environmentID, + title: id, + updatedAt: Date(timeIntervalSince1970: updatedAt), + isArchived: isArchived, + isSettled: isSettled, + lastActivityAt: lastActivityAt.map(Date.init(timeIntervalSince1970:)) + ) + } + + private func rankedSnapshot( + environments: [FeatureEnvironment] = [], + projects: [FeatureProject], + threads: [FeatureThread] + ) -> FeatureSnapshot { + FeatureSnapshot(environments: environments, projects: projects, threads: threads) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift new file mode 100644 index 000000000000..368adfbd5803 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -0,0 +1,635 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Sidebar v2") +struct DailyUXSidebarTests { + private let now = Date(timeIntervalSince1970: 2_000_000) + + @Test + func snoozePresetsUseUsefulLocalClockBoundaries() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-18T17:00:00Z") + ) + + let presets = DailyUXSnoozePresets.resolve(now: now, calendar: calendar) + + #expect(presets.map(\.id) == [.hour, .threeHours, .evening, .tomorrow, .nextWeek]) + #expect(presets[0].until == now.addingTimeInterval(3_600)) + #expect(presets[1].until == now.addingTimeInterval(10_800)) + #expect(calendar.component(.hour, from: presets[2].until) == 18) + #expect(calendar.component(.hour, from: presets[3].until) == 9) + #expect(calendar.component(.weekday, from: presets[4].until) == 2) + #expect(calendar.component(.hour, from: presets[4].until) == 9) + } + + @Test + func snoozePresetsHideEveningWhenItIsTooClose() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-19T00:30:00Z") + ) + + let presets = DailyUXSnoozePresets.resolve(now: now, calendar: calendar) + + #expect(!presets.map(\.id).contains(.evening)) + } + + @Test + func sundaySnoozePresetsHaveUniqueWakeDates() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let sunday = try #require( + ISO8601DateFormatter().date(from: "2026-08-23T19:00:00Z") + ) + + let presets = DailyUXSnoozePresets.resolve(now: sunday, calendar: calendar) + + #expect(presets.map(\.id).contains(.tomorrow)) + #expect(!presets.map(\.id).contains(.nextWeek)) + #expect(Set(presets.map(\.until)).count == presets.count) + } + + @Test + func activeOrderUsesCreationTimeAndDoesNotJumpWithActivity() { + let olderCreationRecentActivity = thread( + id: "old", + created: -500, + updated: -5, + state: .working + ) + let newerCreationOlderActivity = thread( + id: "new", + created: -100, + updated: -80, + state: .working + ) + + let index = makeIndex([olderCreationRecentActivity, newerCreationOlderActivity]) + + #expect(index.active.map(\.id) == ["new", "old"]) + } + + @Test + func reopenedThreadsReturnToTheTopWithoutReorderingOnOrdinaryActivity() { + var reopened = thread(id: "old", created: -1_000, updated: -5) + reopened.unsettledAt = now.addingTimeInterval(-10) + let newer = thread(id: "new", created: -100, updated: 0, state: .working) + + #expect(makeIndex([newer, reopened]).active.map(\.id) == ["old", "new"]) + + reopened.unsettledAt = now.addingTimeInterval(-2_000) + #expect(makeIndex([newer, reopened]).active.map(\.id) == ["new", "old"]) + } + + @Test + func activeOrderKeepsNewThreadsAboveTheSavedManualOrder() { + var first = thread(id: "first", created: -500, updated: -500) + first.activeOrderKey = "bc" + var second = thread(id: "second", created: -100, updated: -100) + second.activeOrderKey = "mn" + let new = thread(id: "new", created: -200, updated: -200) + var reopened = thread(id: "reopened", created: -1_000, updated: -5) + reopened.unsettledAt = now.addingTimeInterval(-10) + + #expect(makeIndex([second, new, first, reopened]).active.map(\.id) + == ["reopened", "new", "first", "second"]) + + first.updatedAt = now + second.unsettledAt = now + #expect(makeIndex([second, new, first, reopened]).active.map(\.id) + == ["reopened", "new", "first", "second"]) + } + + @Test + func activeOrderTiesUseWireThreadIDBeforeEnvironment() { + var first = thread(id: "z-env:a-thread", created: -100, updated: -100) + first.wireID = "a-thread" + first.environmentID = "z-env" + var second = thread(id: "a-env:z-thread", created: -100, updated: -100) + second.wireID = "z-thread" + second.environmentID = "a-env" + var sameWire = thread(id: "a-env:a-thread", created: -100, updated: -100) + sameWire.wireID = "a-thread" + sameWire.environmentID = "a-env" + + let expected = [sameWire.id, first.id, second.id] + #expect(makeIndex([second, first, sameWire]).active.map(\.id) == expected) + + first.activeOrderKey = "nm" + second.activeOrderKey = "nm" + sameWire.activeOrderKey = "nm" + #expect(makeIndex([second, first, sameWire]).active.map(\.id) == expected) + } + + @Test + func settlementShelfUsesOnlyTheServerOverride() { + var explicitlySettled = thread( + id: "explicit", + created: -10, + updated: -10 + ) + explicitlySettled.settlementFacts = facts(override: .settled) + let resting = thread( + id: "resting", + created: -400_000, + updated: -300_000, + state: .idle + ) + let oldButWorking = thread( + id: "working", + created: -400_000, + updated: -300_000, + state: .working + ) + var settledButWorking = thread( + id: "settled-working", + created: -400_000, + updated: -300_000, + state: .working + ) + settledButWorking.settlementFacts = facts( + override: .settled, + sessionStatus: "running", + hasPendingApprovals: true + ) + let oldButWaiting = thread( + id: "waiting", + created: -400_000, + updated: -300_000, + state: .waitingForApproval + ) + + let index = makeIndex([ + explicitlySettled, + resting, + oldButWorking, + settledButWorking, + oldButWaiting, + ]) + + #expect(Set(index.settled.map(\.id)) == ["explicit", "settled-working"]) + #expect(Set(index.active.map(\.id)) == ["resting", "working", "waiting"]) + } + + @Test + func explicitActiveOverridePreventsAutoSettlement() { + var reopened = thread( + id: "reopened", + created: -400_000, + updated: -300_000, + state: .idle + ) + reopened.keepsActive = true + + let index = makeIndex([reopened]) + + #expect(index.active.map(\.id) == ["reopened"]) + #expect(index.settled.isEmpty) + } + + @Test + func serverSettlementIsShownDespiteStaleActivityFacts() { + let messageAt = now.addingTimeInterval(-30) + var queued = thread(id: "queued", created: -100, updated: -30, state: .queued) + queued.settlementFacts = facts( + override: .settled, + sessionStatus: "running", + hasPendingApprovals: true, + latestUserMessageAt: messageAt, + latestTurn: .init(requestedAt: now.addingTimeInterval(-90)) + ) + queued.isSettled = true + + #expect(queued.hasQueuedTurnStart(at: now)) + #expect(queued.isEffectivelySettled()) + #expect(!queued.canSettleNow(at: now)) + #expect(HomeThreadSwipeAction.trailingActions( + for: queued, + isArchived: false, + at: now + ).first == .reopen) + #expect(queued.queuedSettlementBoundary(after: now) == now.addingTimeInterval(90.001)) + } + + @Test + func mergedPullRequestsAndAgeCannotHideUnsettledThreads() { + let oldThread = thread(id: "old", created: -400_000, updated: -300_000) + let merged = HomeThreadPullRequestPresentation( + number: 42, + state: .merged, + updatedAt: now.addingTimeInterval(-400) + ) + let index = DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: [oldThread]), + query: "", + now: now, + pullRequestsByThreadID: [oldThread.id: merged] + ) + + #expect(!oldThread.isEffectivelySettled()) + #expect(index.active.map(\.id) == ["old"]) + #expect(index.settled.isEmpty) + #expect(DailyUXSidebarRefresh.nextBoundary(for: [oldThread], after: now) == nil) + } + + @Test + func settledAndSnoozedThreadsStayInTheirShelvesWhenPinned() { + var pinnedSettled = thread( + id: "pinned-settled", + created: -100, + updated: -400_000, + state: .idle + ) + pinnedSettled.settlementFacts = facts(override: .settled) + pinnedSettled.pinnedAt = now.addingTimeInterval(-20) + + var pinnedSnoozed = thread( + id: "pinned-snoozed", + created: -50, + updated: -10 + ) + pinnedSnoozed.pinnedAt = now.addingTimeInterval(-10) + pinnedSnoozed.snoozedUntil = now.addingTimeInterval(3_600) + + let index = makeIndex([pinnedSettled, pinnedSnoozed]) + + #expect(index.pinned.isEmpty) + #expect(index.snoozed.map(\.id) == ["pinned-snoozed"]) + #expect(index.active.isEmpty) + #expect(index.settled.map(\.id) == ["pinned-settled"]) + #expect(DailyUXSidebarRefresh.nextBoundary(for: [pinnedSettled], after: now) == nil) + } + + @Test + func pinActionsRequireCapabilitiesAndKeepPinsReversible() { + var legacyDescriptor = thread(id: "legacy", created: -20, updated: -10) + legacyDescriptor.supportsPinning = nil + #expect(!legacyDescriptor.canTogglePin) + + var explicitlyUnsupported = thread(id: "unsupported", created: -20, updated: -10) + explicitlyUnsupported.supportsPinning = false + #expect(!explicitlyUnsupported.canTogglePin) + + explicitlyUnsupported.pinnedAt = now + #expect(explicitlyUnsupported.canTogglePin) + } + + @Test + func lifecycleActionsHonorCapabilitiesAndKeepReverseActionsReachable() { + var capabilityThread = thread(id: "capabilities", created: -20, updated: -10) + capabilityThread.supportsSettlement = false + capabilityThread.supportsSnooze = false + #expect(!capabilityThread.canToggleSettlement) + #expect(!capabilityThread.canToggleSnooze) + + capabilityThread.isSettled = true + capabilityThread.snoozedUntil = now.addingTimeInterval(3_600) + #expect(capabilityThread.canToggleSettlement) + #expect(capabilityThread.canToggleSnooze) + + var legacy = thread(id: "legacy-capabilities", created: -20, updated: -10) + legacy.supportsSettlement = nil + legacy.supportsSnooze = nil + #expect(!legacy.canToggleSettlement) + #expect(!legacy.canToggleSnooze) + } + + @Test + func snoozedThreadsHaveAReachableReverseState() { + var snoozed = thread(id: "snoozed", created: -20, updated: -10) + snoozed.snoozedUntil = now.addingTimeInterval(3_600) + var archived = thread(id: "archived", created: -30, updated: -20) + archived.isArchived = true + let visible = thread(id: "visible", created: -10, updated: -5) + + let index = makeIndex([snoozed, archived, visible]) + + #expect(index.active.map(\.id) == ["visible"]) + #expect(index.snoozed.map(\.id) == ["snoozed"]) + #expect(index.settled.isEmpty) + } + + @Test + func snoozeExpiresAtTheClockBoundary() { + var thread = thread(id: "timed", created: -20, updated: -10) + thread.snoozedUntil = now.addingTimeInterval(30) + + #expect(makeIndex([thread]).snoozed.map(\.id) == ["timed"]) + let expired = DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: [thread]), + query: "", + now: now.addingTimeInterval(31) + ) + #expect(expired.active.map(\.id) == ["timed"]) + } + + @Test + func parentRefreshIgnoresWorkingTimersAndTargetsShelfBoundaries() { + var working = thread( + id: "working", + created: -20, + updated: -10, + state: .working + ) + working.workingStartedAt = now.addingTimeInterval(-90) + + #expect(DailyUXSidebarRefresh.nextBoundary(for: [working], after: now) == nil) + + var laterSnooze = thread(id: "later", created: -30, updated: -20) + laterSnooze.snoozedUntil = now.addingTimeInterval(600) + var earlierSnooze = thread(id: "earlier", created: -40, updated: -30) + earlierSnooze.snoozedUntil = now.addingTimeInterval(120) + + #expect( + DailyUXSidebarRefresh.nextBoundary( + for: [working, laterSnooze, earlierSnooze], + after: now + ) == earlierSnooze.snoozedUntil + ) + } + + @Test + func parentRefreshIncludesQueuedEligibilityBoundary() { + let messageAt = now.addingTimeInterval(-30) + var queued = thread(id: "queued", created: -100, updated: -30, state: .queued) + queued.settlementFacts = facts(latestUserMessageAt: messageAt) + + #expect( + DailyUXSidebarRefresh.nextBoundary(for: [queued], after: now) + == now.addingTimeInterval(90.001) + ) + } + + @Test + func onlyFailuresRaisedAfterSnoozingWakeTheThread() { + var acknowledged = thread( + id: "acknowledged", + created: -30, + updated: -10, + state: .failed + ) + acknowledged.snoozedUntil = now.addingTimeInterval(3_600) + acknowledged.snoozedAt = now.addingTimeInterval(-10) + acknowledged.attentionAt = now.addingTimeInterval(-20) + + var fresh = acknowledged + fresh = FeatureThread( + id: "fresh", + projectID: fresh.projectID, + title: fresh.title, + createdAt: fresh.createdAt, + updatedAt: fresh.updatedAt, + state: .failed, + lastActivityAt: fresh.lastActivityAt, + snoozedUntil: fresh.snoozedUntil, + snoozedAt: fresh.snoozedAt, + attentionAt: now.addingTimeInterval(-5) + ) + + let index = makeIndex([acknowledged, fresh]) + + #expect(index.snoozed.map(\.id) == ["acknowledged"]) + #expect(index.active.map(\.id) == ["fresh"]) + } + + @Test + func projectFilterAndSearchUseRepositoryContext() { + let projects = [ + FeatureProject(id: "p1", environmentID: "e", name: "Mobile", path: "/work/mobile"), + FeatureProject(id: "p2", environmentID: "e", name: "Server", path: "/work/server"), + ] + let mobile = thread(id: "mobile", projectID: "p1", title: "Polish picker", created: -10, updated: -5) + let server = thread(id: "server", projectID: "p2", title: "Compression", created: -20, updated: -5) + let snapshot = FeatureSnapshot(projects: projects, threads: [mobile, server]) + + let filtered = DailyUXSidebarIndex(snapshot: snapshot, query: "", projectID: "p1", now: now) + let searched = DailyUXSidebarIndex(snapshot: snapshot, query: "server", now: now) + + #expect(filtered.active.map(\.id) == ["mobile"]) + #expect(searched.searchResults.map(\.id) == ["server"]) + } + + @Test + func searchHandlesScopedClonesAndLegacyDuplicateProjectIDs() { + let localProjectID = FeatureScopedID.project( + environmentID: "local", + wireID: "project-shared" + ) + let remoteProjectID = FeatureScopedID.project( + environmentID: "remote", + wireID: "project-shared" + ) + let projects = [ + FeatureProject( + id: localProjectID, + wireID: "project-shared", + environmentID: "local", + name: "Mobile", + path: "/work/mobile" + ), + FeatureProject( + id: remoteProjectID, + wireID: "project-shared", + environmentID: "remote", + name: "Server", + path: "/work/server" + ), + ] + let local = thread( + id: "local-thread", + projectID: localProjectID, + title: "Polish", + created: -10, + updated: -5 + ) + let remote = thread( + id: "remote-thread", + projectID: remoteProjectID, + title: "Compression", + created: -20, + updated: -5 + ) + let scoped = FeatureSnapshot(projects: projects, threads: [local, remote]) + + #expect( + DailyUXSidebarIndex(snapshot: scoped, query: "server", now: now) + .searchResults.map(\.id) == ["remote-thread"] + ) + + let legacyDuplicates = FeatureSnapshot( + projects: projects.map { + FeatureProject( + id: "project-shared", + environmentID: $0.environmentID, + name: $0.name, + path: $0.path + ) + }, + threads: [ + thread( + id: "legacy", + projectID: "project-shared", + title: "Legacy", + created: -10, + updated: -5 + ), + ] + ) + #expect( + DailyUXSidebarIndex(snapshot: legacyDuplicates, query: "server", now: now) + .searchResults.map(\.id) == ["legacy"] + ) + } + + @Test + func attentionScopesRemainFocusedSubsetsOfActive() { + let approval = thread( + id: "approval", + title: "Approve schema", + created: -10, + updated: -5, + state: .waitingForApproval + ) + let input = thread( + id: "input", + title: "Answer migration question", + created: -20, + updated: -5, + state: .waitingForInput + ) + let failed = thread( + id: "failed", + title: "Failed build", + created: -30, + updated: -5, + state: .failed + ) + let working = thread( + id: "working", + title: "Build application", + created: -40, + updated: -5, + state: .working + ) + + let snapshot = FeatureSnapshot(threads: [approval, input, failed, working]) + let index = DailyUXSidebarIndex(snapshot: snapshot, query: "", now: now) + + #expect(index.active.map(\.id) == ["approval", "input", "failed", "working"]) + #expect(index.needsInput.map(\.id) == ["approval", "input"]) + #expect(index.failed.map(\.id) == ["failed"]) + #expect( + DailyUXSidebarIndex.matchingThreads( + index.failed, + snapshot: snapshot, + query: "build" + ).map(\.id) == ["failed"] + ) + #expect( + DailyUXSidebarIndex.matchingThreads( + index.needsInput, + snapshot: snapshot, + query: "build" + ).isEmpty + ) + } + + @Test + func largeWorkingCollectionKeepsStableOrderWithoutParentTimerRefresh() { + let threads = (0..<5_000).map { offset in + thread( + id: "thread-\(offset)", + created: -Double(offset), + updated: -Double(offset), + state: .working + ) + } + + let index = makeIndex(threads) + + #expect(index.active.count == threads.count) + #expect(index.active.prefix(3).map(\.id) == ["thread-0", "thread-1", "thread-2"]) + #expect(index.active.last?.id == "thread-4999") + #expect(DailyUXSidebarRefresh.nextBoundary(for: threads, after: now) == nil) + } + + @Test + func compactRelativeAgeClampsFutureDatesAndUsesStableUnits() { + #expect( + SidebarRelativeAge.compact( + since: now.addingTimeInterval(5), + now: now + ) == "now" + ) + #expect( + SidebarRelativeAge.compact( + since: now.addingTimeInterval(-125), + now: now + ) == "2m" + ) + #expect( + SidebarRelativeAge.compact( + since: now.addingTimeInterval(-7_300), + now: now + ) == "2h" + ) + #expect( + SidebarRelativeAge.accessibility( + since: now.addingTimeInterval(-3_600), + now: now + ) == "Updated 1 hour ago" + ) + } + + private func makeIndex(_ threads: [FeatureThread]) -> DailyUXSidebarIndex { + DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: threads), + query: "", + now: now + ) + } + + private func thread( + id: String, + projectID: String = "project", + title: String = "Task", + created: TimeInterval, + updated: TimeInterval, + state: FeatureThreadState = .idle + ) -> FeatureThread { + FeatureThread( + id: id, + projectID: projectID, + title: title, + createdAt: now.addingTimeInterval(created), + updatedAt: now.addingTimeInterval(updated), + state: state, + lastActivityAt: now.addingTimeInterval(updated), + supportsSettlement: true, + supportsSnooze: true, + supportsPinning: true + ) + } + + private func facts( + override: FeatureThreadSettlementOverride? = nil, + sessionStatus: String? = nil, + hasPendingApprovals: Bool = false, + hasPendingUserInput: Bool = false, + latestUserMessageAt: Date? = nil, + latestTurn: FeatureThreadSettlementFacts.LatestTurn? = nil + ) -> FeatureThreadSettlementFacts { + FeatureThreadSettlementFacts( + settlementOverride: override, + sessionStatus: sessionStatus, + hasPendingApprovals: hasPendingApprovals, + hasPendingUserInput: hasPendingUserInput, + latestUserMessageAt: latestUserMessageAt, + latestTurn: latestTurn + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift b/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift new file mode 100644 index 000000000000..a8811b44399f --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Device management") +struct DeviceManagementTests { + @Test + func sortsCurrentThenOnlineThenRecent() { + let now = Date() + let current = session( + id: "current", + at: now.addingTimeInterval(-300), + isCurrent: true + ) + let online = session( + id: "online", + at: now.addingTimeInterval(-600), + isConnected: true + ) + let recent = session(id: "recent", at: now.addingTimeInterval(-60)) + let older = session(id: "older", at: now.addingTimeInterval(-3_600)) + + let sorted = FeatureDeviceSession.sortedForDisplay([older, recent, online, current]) + + #expect(sorted.map(\.id) == ["current", "online", "recent", "older"]) + } + + @Test + func usesSafeFallbackLabels() { + let current = session(id: "current", at: .now, isCurrent: true) + let desktop = session(id: "desktop", at: .now, deviceType: .desktop) + + #expect(current.displayName == "This device") + #expect(desktop.displayName == "Desktop") + } + + @Test + func mapsT3ConnectDeviceAsCurrentInstallation() { + let relayDevice = T3ConnectRelayDevice( + deviceId: "phone-1", + label: "Theo’s iPhone", + platform: "ios", + iosMajorVersion: 27, + appVersion: "1.0 (24)", + notifications: .init( + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true + ), + liveActivities: .init(enabled: true), + updatedAt: "2026-08-10T18:30:00.000Z" + ) + + let session = FeatureDeviceSession( + relayDevice: relayDevice, + currentDeviceID: "phone-1" + ) + + #expect(session.id == "phone-1") + #expect(session.displayName == "Theo’s iPhone") + #expect(session.deviceType == .mobile) + #expect(session.operatingSystem == "iOS 27") + #expect(session.browser == "T3 Code 1.0 (24)") + #expect(session.isCurrent) + #expect(session.lastConnectedAt == session.issuedAt) + } + + @Test @MainActor + func loadsT3ConnectDevicesWithoutEnvironmentAdminScope() async throws { + let manager = T3ConnectDeviceManagerStub( + devices: [relayDevice(id: "phone-1")], + currentDeviceID: "phone-1" + ) + let client = NativeFeatureClient(t3ConnectDeviceManager: manager) + + let sessions = try await client.loadDeviceSessions() + + #expect(sessions.map(\.id) == ["phone-1"]) + #expect(sessions[0].isCurrent) + #expect(manager.loadCount == 1) + } + + private func relayDevice(id: String) -> T3ConnectRelayDevice { + T3ConnectRelayDevice( + deviceId: id, + label: "Theo’s iPhone", + platform: "ios", + iosMajorVersion: 27, + appVersion: "1.0 (24)", + notifications: .init( + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true + ), + liveActivities: .init(enabled: true), + updatedAt: "2026-08-10T18:30:00.000Z" + ) + } + + private func session( + id: String, + at date: Date, + deviceType: FeatureDeviceType = .mobile, + isConnected: Bool = false, + isCurrent: Bool = false + ) -> FeatureDeviceSession { + FeatureDeviceSession( + sessionID: id, + deviceType: deviceType, + issuedAt: date.addingTimeInterval(-100), + expiresAt: date.addingTimeInterval(86_400), + lastConnectedAt: date, + isConnected: isConnected, + isCurrent: isCurrent + ) + } +} + +@MainActor +private final class T3ConnectDeviceManagerStub: T3ConnectDeviceManaging { + let hasActiveAccount = true + let currentRegisteredDeviceID: String? + private let devices: [T3ConnectRelayDevice] + private(set) var loadCount = 0 + + init(devices: [T3ConnectRelayDevice], currentDeviceID: String?) { + self.devices = devices + self.currentRegisteredDeviceID = currentDeviceID + } + + func registeredDevices() async throws -> [T3ConnectRelayDevice] { + loadCount += 1 + return devices + } + + func unregisterDevice(id: String) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureAttachmentUploadCoordinatorTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureAttachmentUploadCoordinatorTests.swift new file mode 100644 index 000000000000..8e85b96c8572 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureAttachmentUploadCoordinatorTests.swift @@ -0,0 +1,413 @@ +import Foundation +import Observation +import Testing +@testable import T3Code + +@Suite("Attachment pre-upload coordinator") +@MainActor +struct FeatureAttachmentUploadCoordinatorTests { + @Test func canceledTransferKeepsConcurrencySlotUntilItReturns() async throws { + let uploads = CoordinatorUploadHarness() + let coordinator = makeCoordinator(limit: 3, uploads: uploads) + let attachments = (0..<4).map { attachment(byte: UInt8($0)) } + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: attachments) + let started = [await uploads.nextStart(), await uploads.nextStart(), await uploads.nextStart()] + let canceledID = started[0] + + coordinator.syncOwner( + draftKey: "draft", + environmentID: "one", + attachments: attachments.filter { $0.id != canceledID } + ) + #expect(uploads.startCount == 3) + #expect(uploads.maximumActive == 3) + + uploads.complete(canceledID, environmentID: "one") + _ = await uploads.nextStart() + #expect(uploads.maximumActive == 3) + uploads.completeAll() + } + + @Test func removedAndReaddedUUIDRejectsLateOldResult() async throws { + let uploads = CoordinatorUploadHarness() + let coordinator = makeCoordinator(limit: 2, uploads: uploads) + let id = UUID() + let old = attachment(id: id, byte: 1) + let replacement = attachment(id: id, byte: 2) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [old]) + _ = await uploads.nextStart() + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: []) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [replacement]) + _ = await uploads.nextStart() + + uploads.complete(id, environmentID: "one", attachmentID: "old") + #expect(coordinator.state(environmentID: "one", attachmentID: id) == .uploading) + + uploads.complete(id, environmentID: "one", attachmentID: "new") + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: id) + == .ready(.init(environmentID: "one", attachmentID: "new")) + } + } + + @Test func environmentSwitchDuringPersistenceCannotPublishOldReference() async throws { + let uploads = CoordinatorUploadHarness() + let persistence = CoordinatorPersistenceHarness(suspended: true) + let coordinator = FeatureAttachmentUploadCoordinator( + upload: uploads.upload, + persist: persistence.persist + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one") + await persistence.nextCall() + + coordinator.syncOwner(draftKey: "draft", environmentID: "two", attachments: [value]) + persistence.resume(result: true) + _ = await uploads.nextStart() + #expect(coordinator.state(environmentID: "one", attachmentID: value.id) == nil) + #expect(coordinator.state(environmentID: "two", attachmentID: value.id) == .uploading) + uploads.completeAll() + } + + @Test func persistenceFailureBlocksReadyAndRetryCanSucceed() async throws { + let uploads = CoordinatorUploadHarness() + let persistence = CoordinatorPersistenceHarness(error: TestFailure.disk) + let coordinator = FeatureAttachmentUploadCoordinator( + upload: uploads.upload, + persist: persistence.persist + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one") + await waitUntilObserved { + if case .failed = coordinator.state(environmentID: "one", attachmentID: value.id) { + return true + } + return false + } + + persistence.error = nil + coordinator.retry(environmentID: "one", attachmentID: value.id) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one", attachmentID: "retry") + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: value.id) + == .ready(.init(environmentID: "one", attachmentID: "retry")) + } + } + + @Test func rejectedCompareAndSetDoesNotStayUploading() async throws { + let uploads = CoordinatorUploadHarness() + let coordinator = FeatureAttachmentUploadCoordinator( + upload: uploads.upload, + persist: { _, _, _ in false } + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one") + + await waitUntilObserved { + if case .failed = coordinator.state(environmentID: "one", attachmentID: value.id) { + return true + } + return false + } + } + + @Test func timeoutShowsRetryWithoutReleasingAnActiveTransferSlot() async throws { + let uploads = CoordinatorUploadHarness() + let persistence = CoordinatorPersistenceHarness() + let deadlines = CoordinatorDeadlineHarness() + let coordinator = FeatureAttachmentUploadCoordinator( + maximumConcurrentUploads: 1, + upload: uploads.upload, + persist: persistence.persist, + waitForTimeout: deadlines.wait + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + deadlines.expire(await deadlines.nextStart()) + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: value.id) + == .failed("Upload timed out. Check the connection and retry.") + } + + coordinator.retry(environmentID: "one", attachmentID: value.id) + #expect(coordinator.state(environmentID: "one", attachmentID: value.id) == .queued) + #expect(uploads.startCount == 1) + + // This transport ignores cancellation until its callback arrives. + uploads.complete(value.id, environmentID: "one", attachmentID: "late") + _ = await uploads.nextStart() + #expect(uploads.maximumActive == 1) + #expect(persistence.callCount == 0) + uploads.complete(value.id, environmentID: "one", attachmentID: "retry") + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: value.id) + == .ready(.init(environmentID: "one", attachmentID: "retry")) + } + #expect(persistence.callCount == 1) + } + + @Test(arguments: [true, false]) + func timeoutDuringPersistenceStartsNextUploadAndRejectsLateCompletion(succeeds: Bool) async throws { + let uploads = CoordinatorUploadHarness() + let persistence = CoordinatorPersistenceHarness(suspended: true) + let deadlines = CoordinatorDeadlineHarness() + let coordinator = FeatureAttachmentUploadCoordinator( + maximumConcurrentUploads: 1, + upload: uploads.upload, + persist: persistence.persist, + waitForTimeout: deadlines.wait + ) + let values = [attachment(byte: 1), attachment(byte: 2)] + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: values) + let first = await uploads.nextStart() + let deadline = await deadlines.nextStart() + uploads.complete(first, environmentID: "one") + await persistence.nextCall() + + deadlines.expire(deadline) + let expected = FeatureAttachmentUploadState.failed( + "Upload timed out. Check the connection and retry." + ) + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: first) == expected + } + // A stalled disk save must not block the next network transfer. + let second = await uploads.nextStart() + if succeeds { + persistence.resume(result: true) + } else { + persistence.fail(error: CancellationError()) + } + + uploads.complete(second, environmentID: "one") + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: second) + == .ready(.init(environmentID: "one", attachmentID: "uploaded")) + } + #expect(coordinator.state(environmentID: "one", attachmentID: first) == expected) + } + + private func makeCoordinator( + limit: Int, + uploads: CoordinatorUploadHarness + ) -> FeatureAttachmentUploadCoordinator { + FeatureAttachmentUploadCoordinator( + maximumConcurrentUploads: limit, + upload: uploads.upload, + persist: { _, _, _ in true } + ) + } + + private func attachment(id: UUID = UUID(), byte: UInt8) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: id, + data: Data([byte]), + filename: "\(byte).png", + mimeType: "image/png" + ) + } + + private func waitUntilObserved(_ condition: @escaping @MainActor () -> Bool) async { + await CoordinatorObservationWaiter(condition: condition).wait() + } +} + +@MainActor +private final class CoordinatorObservationWaiter { + private let condition: @MainActor () -> Bool + private var continuation: CheckedContinuation? + + init(condition: @escaping @MainActor () -> Bool) { + self.condition = condition + } + + func wait() async { + guard !condition() else { return } + await withCheckedContinuation { continuation in + self.continuation = continuation + check() + } + } + + private func check() { + guard continuation != nil else { return } + withObservationTracking { + guard condition(), let continuation else { return } + self.continuation = nil + continuation.resume() + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.check() + } + } + } +} + +@MainActor +private final class CoordinatorUploadHarness { + private struct Pending { + let id: UUID + let environmentID: String + let continuation: CheckedContinuation + } + + private var pending: [Pending] = [] + private(set) var startCount = 0 + private(set) var maximumActive = 0 + private var active = 0 + private var startedIDs: [UUID] = [] + private var startWaiters: [CheckedContinuation] = [] + + lazy var upload: FeatureAttachmentUploadCoordinator.Upload = { [weak self] attachment, env in + guard let self else { return nil } + self.startCount += 1 + self.active += 1 + self.maximumActive = max(self.maximumActive, self.active) + if startWaiters.isEmpty { + startedIDs.append(attachment.id) + } else { + startWaiters.removeFirst().resume(returning: attachment.id) + } + let result = await withCheckedContinuation { continuation in + self.pending.append(Pending( + id: attachment.id, + environmentID: env, + continuation: continuation + )) + } + self.active -= 1 + return result + } + + func complete(_ id: UUID, environmentID: String, attachmentID: String = "uploaded") { + guard let index = pending.firstIndex(where: { + $0.id == id && $0.environmentID == environmentID + }) else { + Issue.record("Upload was not pending") + return + } + pending.remove(at: index).continuation.resume(returning: .init( + environmentID: environmentID, + attachmentID: attachmentID + )) + } + + func nextStart() async -> UUID { + if !startedIDs.isEmpty { return startedIDs.removeFirst() } + return await withCheckedContinuation { startWaiters.append($0) } + } + + func completeAll() { + let values = pending + pending.removeAll() + for value in values { + value.continuation.resume(returning: .init( + environmentID: value.environmentID, + attachmentID: "drained" + )) + } + } +} + +@MainActor +private final class CoordinatorPersistenceHarness { + var error: (any Error)? + private var suspended: Bool + private var continuation: CheckedContinuation? + private(set) var callCount = 0 + private var callReceipts = 0 + private var callWaiters: [CheckedContinuation] = [] + + init(suspended: Bool = false, error: (any Error)? = nil) { + self.suspended = suspended + self.error = error + } + + lazy var persist: FeatureAttachmentUploadCoordinator.Persist = { [weak self] _, _, _ in + guard let self else { return false } + self.callCount += 1 + if callWaiters.isEmpty { + callReceipts += 1 + } else { + callWaiters.removeFirst().resume() + } + if let error = self.error { throw error } + if self.suspended { + return try await withCheckedThrowingContinuation { self.continuation = $0 } + } + return true + } + + func resume(result: Bool) { + suspended = false + continuation?.resume(returning: result) + continuation = nil + } + + func fail(error: any Error) { + suspended = false + continuation?.resume(throwing: error) + continuation = nil + } + + func nextCall() async { + if callReceipts > 0 { + callReceipts -= 1 + return + } + await withCheckedContinuation { callWaiters.append($0) } + } +} + +@MainActor +private final class CoordinatorDeadlineHarness { + private var pending: [UUID: CheckedContinuation] = [:] + private var startedIDs: [UUID] = [] + private var startWaiters: [CheckedContinuation] = [] + + lazy var wait: FeatureAttachmentUploadCoordinator.WaitForTimeout = { [weak self] _ in + guard let self else { throw CancellationError() } + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + self.pending[id] = continuation + if self.startWaiters.isEmpty { + self.startedIDs.append(id) + } else { + self.startWaiters.removeFirst().resume(returning: id) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.pending.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + } + } + + func nextStart() async -> UUID { + if !startedIDs.isEmpty { return startedIDs.removeFirst() } + return await withCheckedContinuation { startWaiters.append($0) } + } + + func expire(_ id: UUID) { + pending.removeValue(forKey: id)?.resume() + } +} + +private enum TestFailure: LocalizedError { + case disk + + var errorDescription: String? { "Disk write failed." } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift new file mode 100644 index 000000000000..e6ec52b600db --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift @@ -0,0 +1,1385 @@ +import SwiftUI +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import T3Code + +@Suite("Composer power features") +struct FeatureComposerPowerTests { + @Test + func changingServiceTierPreservesAnUnlistedSavedReasoningValue() throws { + let control = try #require(FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", modelID: "gpt-5.6-sol", + options: [.init(id: "reasoningEffort", value: .string("future-effort"))] + ), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + )) + + let selected = control.selection(choosing: "priority", in: "serviceTier") + + #expect(control.sections.first { $0.id == "reasoningEffort" }?.currentChoiceID == "future-effort") + #expect(control.triggerLabel == "future-effort") + #expect(selected.options.first { $0.id == "reasoningEffort" }?.value == .string("future-effort")) + } + + @Test + func changingServiceTierPreservesOptionsWithoutDeclaredDefaults() throws { + let provider = FeatureProvider( + id: "codex", name: "Codex", driver: "codex", + models: [.init( + id: "test-model", name: "Test model", isDefault: true, + options: [ + .init( + id: "reasoningEffort", label: "Reasoning", kind: .select, + choices: [.init(id: "low", label: "Low"), .init(id: "high", label: "High")] + ), + .init(id: "fastMode", label: "Fast mode", kind: .boolean), + .init( + id: "serviceTier", label: "Service tier", kind: .select, + choices: [ + .init(id: "default", label: "Standard", isDefault: true), + .init(id: "priority", label: "Fast"), + ] + ), + ] + )] + ) + let control = try #require(FeatureComposerTraitsControl.resolve( + explicit: .init(providerID: "codex", modelID: "test-model"), + inherited: nil, providers: [provider], materializesDefaultSelection: true + )) + + let selected = control.selection(choosing: "priority", in: "serviceTier") + + #expect(control.sections.first { $0.id == "reasoningEffort" }?.currentChoiceID == nil) + #expect(control.sections.first { $0.id == "fastMode" }?.currentChoiceID == nil) + #expect(!selected.options.contains { $0.id == "reasoningEffort" }) + #expect(!selected.options.contains { $0.id == "fastMode" }) + } + + @Test func workspaceSkillsStayInTheirWorkspace() { + var provider = FeatureProvider(id: "claude", name: "Claude", skills: [.init(name: "global")]) + provider.workspaceSnapshots = [ + FeatureProviderWorkspace(cwd: "/a", slashCommands: [], skills: [.init(name: "a-only")]), + FeatureProviderWorkspace(cwd: "/b", slashCommands: [], skills: [.init(name: "b-only")]), + ] + #expect(provider.workspaceCatalog(cwd: "/a").skills.map(\.name) == ["a-only"]) + #expect(provider.workspaceCatalog(cwd: "/b").skills.map(\.name) == ["b-only"]) + #expect(provider.workspaceCatalog(cwd: "/c").skills.isEmpty) + provider.workspaceSnapshots = nil + #expect(provider.workspaceCatalog(cwd: "/c").skills.map(\.name) == ["global"]) + } + + @Test func skillInvocationHonorsProviderRules() { + var userOnly = FeatureProviderSkill(name: "deploy") + userOnly.userInvocationOnly = true + var agentOnly = FeatureProviderSkill(name: "internal") + agentOnly.userInvocable = false + #expect(userOnly.invocation == "/deploy ") + #expect(agentOnly.invocation == "$internal ") + let items = FeatureComposerMenuBuilder.items( + trigger: .init(kind: .slashCommand, query: "", range: 0..<1), + providers: [], currentSelection: nil, threadSelection: nil, + powerFeatures: .init(skills: [userOnly, agentOnly]), pathEntries: [] + ) + #expect(items.contains { $0.id == "skill:deploy" }) + #expect(!items.contains { $0.id == "skill:internal" }) + } + + @Test( + "Composer input grows past the former seven-line cap", + .bug("https://github.com/saphid/t3code-personal/issues/105") + ) + func composerTextInputGrowsBeyondSevenLines() { + let lineHeight: CGFloat = 22 + let sevenLines = FeatureComposerTextInputSizing.height( + fittingHeight: lineHeight * 7, + lineHeight: lineHeight + ) + let elevenLines = FeatureComposerTextInputSizing.height( + fittingHeight: lineHeight * 11, + lineHeight: lineHeight + ) + + #expect(sevenLines == lineHeight * 7) + #expect(elevenLines == lineHeight * 11) + } + + @Test( + "A very tall composer input caps at its line bound and scrolls inside", + .bug("https://github.com/saphid/t3code-personal/issues/105") + ) + func composerTextInputCapsAtItsLineBound() { + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 2_200, + lineHeight: 22 + ) == 22 * FeatureComposerTextInputSizing.maximumLines + ) + } + + @Test + func composerTextInputReservesRoomForControlsInAConstrainedViewport() { + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 440, + lineHeight: 22, + availableHeight: 150 + ) == 150 + ) + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 440, + lineHeight: 22, + availableHeight: 80 + ) == 80 + ) + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 440, + lineHeight: 22, + availableHeight: 0 + ) == 0 + ) + } + + @Test + @MainActor + func compressedComposerViewportKeepsItsLastLineAboveTheFooter() throws { + let textView = FeatureComposerUITextView( + frame: CGRect(x: 0, y: 0, width: 320, height: 1) + ) + textView.configureComposerViewport() + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.text = (1...30).map { "Attachment draft line \($0)" } + .joined(separator: "\n") + textView.selectedRange = NSRange(location: textView.text.utf16.count, length: 0) + + let measured = textView.sizeThatFits( + CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude) + ) + let font = try #require(textView.font) + let keyboardAndAttachmentBound: CGFloat = 80 + let viewportHeight = FeatureComposerTextInputSizing.height( + fittingHeight: measured.height, + lineHeight: font.lineHeight, + availableHeight: keyboardAndAttachmentBound + ) + textView.frame.size.height = viewportHeight + textView.setNeedsLayout() + textView.layoutIfNeeded() + textView.scrollSelectionIntoView() + + #expect(textView.bounds.height == keyboardAndAttachmentBound) + #expect(textView.contentOverflows) + let selection = try #require(textView.selectedTextRange) + let caret = textView.caretRect(for: selection.end) + let visibleTop = textView.contentOffset.y + let visibleBottom = visibleTop + textView.bounds.height + #expect(caret.minY >= visibleTop) + #expect(caret.maxY <= visibleBottom - 1) + } + + @Test + @MainActor + func uiTextViewMeasurementGrowsBeforeTheViewportCap() throws { + let textView = FeatureComposerUITextView( + frame: CGRect(x: 0, y: 0, width: 320, height: 1) + ) + textView.configureComposerViewport() + textView.font = UIFont.preferredFont(forTextStyle: .body) + let font = try #require(textView.font) + + textView.text = "First line\nSecond line" + let shortMeasurement = textView.sizeThatFits( + CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude) + ) + textView.text = (1...8).map { "Draft line \($0)" }.joined(separator: "\n") + let tallMeasurement = textView.sizeThatFits( + CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude) + ) + + let shortHeight = FeatureComposerTextInputSizing.height( + fittingHeight: shortMeasurement.height, + lineHeight: font.lineHeight, + availableHeight: 400 + ) + let tallHeight = FeatureComposerTextInputSizing.height( + fittingHeight: tallMeasurement.height, + lineHeight: font.lineHeight, + availableHeight: 400 + ) + + #expect(tallHeight > shortHeight) + #expect(tallHeight == tallMeasurement.height) + } + + @Test + func newTaskUsesCompactContextForDraftsAndAttachments() { + #expect(!NewThreadComposerLayout.usesCompactContext( + prompt: "", isFocused: false, hasAttachments: false + )) + #expect(NewThreadComposerLayout.usesCompactContext( + prompt: "", isFocused: true, hasAttachments: false + )) + #expect(NewThreadComposerLayout.usesCompactContext( + prompt: "A draft", isFocused: false, hasAttachments: false + )) + #expect(NewThreadComposerLayout.usesCompactContext( + prompt: "", isFocused: false, hasAttachments: true + )) + } + + @Test + @MainActor + func longComposerDraftStaysClippedAndScrollsToItsLastLine() { + let textView = FeatureComposerUITextView( + frame: CGRect(x: 0, y: 0, width: 320, height: 110) + ) + textView.configureComposerViewport() + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.text = (1...40).map { "A long pasted draft line \($0)" } + .joined(separator: "\n") + textView.layoutIfNeeded() + textView.selectedRange = NSRange(location: textView.text.utf16.count, length: 0) + textView.scrollSelectionIntoView() + textView.layoutIfNeeded() + + #expect(textView.clipsToBounds) + #expect(textView.contentOverflows) + if let selection = textView.selectedTextRange { + let caret = textView.caretRect(for: selection.end) + #expect(caret.maxY <= textView.contentOffset.y + textView.bounds.height) + } else { + Issue.record("Expected a visible selection at the end of the pasted draft") + } + } + + @Test + func replacementCursorLandsAfterInsertedTextInUTF16() { + // "🧪 " occupies three characters but four UTF-16 units; the caret + // location must count the latter or it drifts on emoji-bearing drafts. + let original = "🧪 Use $dep please" + let range = 6..<10 + + #expect( + FeatureComposerTextSelectionPolicy.cursorLocation( + afterReplacing: range, + in: original, + with: "$dependency " + ) == "🧪 Use $dependency ".utf16.count + ) + } + + @Test + func restoredDraftPlacesCaretAtUTF16End() { + #expect( + FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate( + previousText: "", + newText: "🧪 restored draft", + selectedLocation: 0 + ) == "🧪 restored draft".utf16.count + ) + } + + @Test + func externalRewriteClampsCaretIntoTheNewText() { + #expect( + FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate( + previousText: "a much longer draft", + newText: "short", + selectedLocation: 19 + ) == 5 + ) + } + + @Test + @MainActor + func imageCapableComposerAdvertisesImagesToTheNativePasteMenu() { + let textView = FeatureComposerUITextView() + + textView.acceptsImages = true + + #expect( + textView.pasteConfiguration?.acceptableTypeIdentifiers.contains( + UTType.image.identifier + ) == true + ) + #expect( + textView.pasteConfiguration?.acceptableTypeIdentifiers.contains( + UTType.text.identifier + ) == true + ) + + textView.acceptsImages = false + + #expect(textView.pasteConfiguration == nil) + } + + @Test + @MainActor + func textViewDeclinesImageDropsSoTheComposerSurfaceOwnsThem() { + let textView = FeatureComposerUITextView() + textView.acceptsImages = true + + let image = NSItemProvider() + image.registerDataRepresentation( + forTypeIdentifier: UTType.png.identifier, + visibility: .all + ) { completion in + completion(Data([0x89, 0x50, 0x4E, 0x47]), nil) + return nil + } + let text = NSItemProvider(object: "caption" as NSString) + + #expect(!textView.canPaste([image])) + #expect(!textView.canPaste([text, image])) + #expect(textView.canPaste([text])) + } + + @Test + func downwardDragDismissalRespectsDraftScrolling() { + #expect(FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 2, translationY: 20, isScrollable: false, isAtTop: true + )) + // Scrolling back through a capped draft must not drop the keyboard… + #expect(!FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 2, translationY: 20, isScrollable: true, isAtTop: false + )) + // …but a drag that begins at the top of the draft only rubber-bands, + // and is the capped composer's one escape hatch. + #expect(FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 2, translationY: 20, isScrollable: true, isAtTop: true + )) + // Mostly-horizontal drags are caret adjustments, not dismissals. + #expect(!FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 30, translationY: 12, isScrollable: false, isAtTop: true + )) + #expect(!FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 0, translationY: 8, isScrollable: false, isAtTop: true + )) + } + + @Test + func nativePasteDetectionUsesImageTypeConformance() { + let pasteboard = UIPasteboard.withUniqueName() + defer { UIPasteboard.remove(withName: pasteboard.name) } + pasteboard.items = [ + [UTType.heic.identifier: Data([0x00])], + ] + + #expect(!pasteboard.hasImages) + #expect(FeatureComposerPasteboardPolicy.containsImage(in: pasteboard)) + } + + @Test + func nativePasteDetectionChecksEveryPasteboardItem() { + let pasteboard = UIPasteboard.withUniqueName() + defer { UIPasteboard.remove(withName: pasteboard.name) } + pasteboard.items = [ + [UTType.plainText.identifier: "caption"], + [UTType.png.identifier: Data([0x89, 0x50, 0x4E, 0x47])], + ] + + #expect(FeatureComposerPasteboardPolicy.containsImage(in: pasteboard)) + } + + @Test( + "The traits menu renders every supported descriptor in catalog order", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func traitsMenuIncludesReasoningAndServiceTierWithDescriptorMetadata() throws { + let control = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [.init(id: "reasoningEffort", value: .string("high"))] + ), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + + #expect(control.sections.map(\.id) == ["reasoningEffort", "serviceTier"]) + #expect(control.sections.map(\.label) == ["Reasoning", "Service Tier"]) + let reasoning = try #require(control.sections.first) + #expect(reasoning.choices.map(\.id) == ["low", "medium", "high", "xhigh", "max", "ultra"]) + #expect(reasoning.choices.first?.isDefault == true) + #expect(reasoning.currentChoiceID == "high") + let serviceTier = try #require(control.sections.last) + #expect(serviceTier.choices.map(\.label) == ["Standard", "Fast"]) + #expect(serviceTier.choices.first?.isDefault == true) + #expect(serviceTier.currentChoiceID == "default") + #expect(serviceTier.choices.last?.detail == "1.5x speed, increased usage.") + } + + @Test( + "The traits trigger matches Electron's Standard and Fast display", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func traitsTriggerUsesReasoningTextAndFastModeBolt() throws { + let standard = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("high")), + .init(id: "serviceTier", value: .string("default")), + ] + ), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + let fastSelection = standard.selection(choosing: "priority", in: "serviceTier") + let fast = try #require( + FeatureComposerTraitsControl.resolve( + explicit: fastSelection, + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + + #expect(standard.triggerLabel == "High") + #expect(!standard.showsFastModeIcon) + #expect(fast.triggerLabel == "High") + #expect(fast.showsFastModeIcon) + } + + @Test( + "Trait choices materialize defaults and persist through subsequent turns", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func traitChoicesPersistBothEffectiveSelectionsOnTheSubmissionPath() throws { + let inherited = FeatureSelection( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [.init(id: "futureOption", value: .string("preserve-me"))] + ) + let initial = try #require( + FeatureComposerTraitsControl.resolve( + explicit: nil, + inherited: inherited, + providers: [Self.solProvider], + materializesDefaultSelection: false + ) + ) + let reasoningSelection = initial.selection(choosing: "xhigh", in: "reasoningEffort") + let afterReasoning = try #require( + FeatureComposerTraitsControl.resolve( + explicit: reasoningSelection, + inherited: inherited, + providers: [Self.solProvider], + materializesDefaultSelection: false + ) + ) + let effectiveSelection = afterReasoning.selection( + choosing: "priority", + in: "serviceTier" + ) + let submission = FeatureMessageSubmission( + threadID: "thread-1", + text: "Continue", + selection: effectiveSelection + ) + + #expect(submission.selection?.providerID == "codex") + #expect(submission.selection?.modelID == "gpt-5.6-sol") + #expect( + submission.selection?.options.first(where: { $0.id == "reasoningEffort" })?.value + == .string("xhigh") + ) + #expect( + submission.selection?.options.first(where: { $0.id == "serviceTier" })?.value + == .string("priority") + ) + #expect( + submission.selection?.options.first(where: { $0.id == "futureOption" })?.value + == .string("preserve-me") + ) + #expect(submission.selection?.options.filter { $0.id == "reasoningEffort" }.count == 1) + #expect(submission.selection?.options.filter { $0.id == "serviceTier" }.count == 1) + } + + @Test( + "Defaults, inherited selections, and provider changes resolve independently", + .bug("https://github.com/pingdotgg/t3code/pull/7344#discussion_r3826822638") + ) + func traitsFollowTheEffectiveModelSelection() throws { + let defaultControl = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init(providerID: "codex", modelID: "missing"), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + #expect(defaultControl.sections.map(\.currentChoiceID) == ["low", "default"]) + + let inheritedControl = try #require( + FeatureComposerTraitsControl.resolve( + explicit: nil, + inherited: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("max")), + .init(id: "serviceTier", value: .string("priority")), + ] + ), + providers: [Self.solProvider], + materializesDefaultSelection: false + ) + ) + #expect(inheritedControl.sections.map(\.currentChoiceID) == ["max", "priority"]) + + let plainProvider = FeatureProvider( + id: "plain", + name: "Plain", + driver: "grok", + models: [FeatureModel(id: "basic", name: "Basic")] + ) + #expect( + FeatureComposerTraitsControl.resolve( + explicit: .init(providerID: "plain", modelID: "basic"), + inherited: nil, + providers: [Self.solProvider, plainProvider], + materializesDefaultSelection: true + ) == nil + ) + } + + @Test( + "Unsupported descriptors hide while boolean descriptors remain selectable", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func unsupportedDescriptorsDoNotCreateSections() throws { + let provider = FeatureProvider( + id: "mixed", + name: "Mixed", + driver: "cursor", + models: [ + FeatureModel( + id: "mixed-model", + name: "Mixed model", + isDefault: true, + options: [ + .init(id: "empty", label: "Empty", kind: .select), + .init( + id: "promptEffort", + label: "Prompt effort", + kind: .select, + choices: [.init(id: "ultrathink", label: "Ultrathink")], + promptInjectedValues: ["ultrathink"] + ), + .init( + id: "thinking", + label: "Thinking", + kind: .boolean, + defaultValue: .boolean(false) + ), + ] + ), + ] + ) + let control = try #require( + FeatureComposerTraitsControl.resolve( + explicit: nil, + inherited: nil, + providers: [provider], + materializesDefaultSelection: true + ) + ) + + #expect(control.sections.map(\.id) == ["thinking"]) + #expect(control.sections[0].choices.map(\.id) == ["on", "off"]) + #expect(control.sections[0].currentChoiceID == "off") + #expect(control.sections[0].choices.allSatisfy { !$0.isDefault }) + #expect(control.triggerLabel == "Thinking Off") + } + + @Test( + "Changing a visible trait preserves a hidden prompt-injected selection", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func visibleTraitChangesPreservePromptInjectedSelections() throws { + var provider = Self.solProvider + provider.models[0].options[0].choices.append( + .init(id: "ultrathink", label: "Ultrathink") + ) + provider.models[0].options[0].promptInjectedValues = ["ultrathink"] + let control = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("ultrathink")), + .init(id: "serviceTier", value: .string("default")), + ] + ), + inherited: nil, + providers: [provider], + materializesDefaultSelection: true + ) + ) + + #expect(control.sections[0].choices.allSatisfy { $0.id != "ultrathink" }) + #expect(control.sections[0].currentChoiceID == "ultrathink") + #expect(control.triggerLabel == "Ultrathink") + let selection = control.selection(choosing: "priority", in: "serviceTier") + #expect( + selection.options.first(where: { $0.id == "reasoningEffort" })?.value + == .string("ultrathink") + ) + #expect( + selection.options.first(where: { $0.id == "serviceTier" })?.value + == .string("priority") + ) + } + + /// Mirrors the live Codex descriptors Alex supplied for `gpt-5.6-sol`. + private static let solProvider = FeatureProvider( + id: "codex", + name: "Codex", + driver: "codex", + models: [ + FeatureModel( + id: "gpt-5.6-sol", + name: "GPT-5.6-Sol", + isDefault: true, + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning", + kind: .select, + choices: [ + .init(id: "low", label: "Low", isDefault: true), + .init(id: "medium", label: "Medium"), + .init(id: "high", label: "High"), + .init(id: "xhigh", label: "Extra High"), + .init(id: "max", label: "Max"), + .init(id: "ultra", label: "Ultra"), + ] + ), + .init( + id: "serviceTier", + label: "Service Tier", + kind: .select, + choices: [ + .init(id: "default", label: "Standard", isDefault: true), + .init( + id: "priority", + label: "Fast", + detail: "1.5x speed, increased usage." + ), + ] + ), + ] + ), + ] + ) + + @Test + func detectsCommandsModelsSkillsAndPathsAtTheCursor() { + #expect( + FeatureComposerTriggerParser.detect(in: "/re") + == FeatureComposerTrigger(kind: .slashCommand, query: "re", range: 0..<3) + ) + #expect( + FeatureComposerTriggerParser.detect(in: "/model claude") + == FeatureComposerTrigger(kind: .model, query: "claude", range: 0..<13) + ) + #expect( + FeatureComposerTriggerParser.detect(in: "Use $dep") + == FeatureComposerTrigger(kind: .skill, query: "dep", range: 4..<8) + ) + #expect( + FeatureComposerTriggerParser.detect(in: "Read @Sources/App") + == FeatureComposerTrigger(kind: .path, query: "Sources/App", range: 5..<17) + ) + + let editedText = "Use @Sources/App then continue" + #expect( + FeatureComposerTriggerParser.detect(in: editedText, cursorOffset: 16) + == FeatureComposerTrigger(kind: .path, query: "Sources/App", range: 4..<16) + ) + } + + @Test + func replacementsPreserveTextOutsideTheActiveTrigger() { + let text = "Review @Sources/App please" + let result = FeatureComposerTriggerParser.replacing( + 7..<19, + in: text, + with: "[App](Sources/App) " + ) + #expect(result == "Review [App](Sources/App) please") + } + + @Test + func fileLinksMatchTheSharedComposerFormat() { + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "path/to/package.json") + == "[package.json](path/to/package.json)" + ) + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "docs/My File (draft).md") + == "[My File (draft).md](docs/My%20File%20%28draft%29.md)" + ) + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "C:\\repo\\src\\index.ts") + == "[index.ts](C:%5Crepo%5Csrc%5Cindex.ts)" + ) + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "@scope/package.json") + == "[package.json](@scope/package.json)" + ) + } + + @Test + func commandMenuIncludesProviderCommandsButNotRemovedMobileModes() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/")) + let powerFeatures = FeatureComposerPowerFeatures( + slashCommands: [ + FeatureProviderSlashCommand(name: "review", description: "Review changes"), + FeatureProviderSlashCommand(name: "plan", description: "Legacy mode"), + FeatureProviderSlashCommand(name: "default", description: "Legacy mode"), + ] + ) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: powerFeatures, + pathEntries: [] + ) + + #expect(items.map(\.label) == ["/model", "/review"]) + } + + @Test + func slashMenuIncludesEnabledSkillsAndSuppressesMatchingCommands() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/")) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + slashCommands: [ + FeatureProviderSlashCommand(name: "deploy", description: "Old command"), + FeatureProviderSlashCommand(name: "review", description: "Review changes"), + ], + skills: [ + FeatureProviderSkill(name: "deploy", displayName: "Deploy project"), + FeatureProviderSkill(name: "disabled", isEnabled: false), + ] + ), + pathEntries: [] + ) + + #expect(items.map(\.label) == ["/model", "/review", "Deploy project"]) + } + + @Test + func skillMenusDedupeEnabledNamesBeforeSearchAndSorting() throws { + let skills = [ + FeatureProviderSkill(name: " deploy ", displayName: "First deploy", isEnabled: false), + FeatureProviderSkill(name: "Deploy", displayName: "Enabled deploy"), + FeatureProviderSkill(name: " DEPLOY ", displayName: "Duplicate matching search"), + FeatureProviderSkill(name: "review", displayName: "Review"), + ] + let allSkillsTrigger = try #require(FeatureComposerTriggerParser.detect(in: "$")) + let searchedSkillsTrigger = try #require( + FeatureComposerTriggerParser.detect(in: "$matching") + ) + let searchedSlashTrigger = try #require( + FeatureComposerTriggerParser.detect(in: "/skill:matching") + ) + + let allItems = FeatureComposerMenuBuilder.items( + trigger: allSkillsTrigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures(skills: skills), + pathEntries: [] + ) + let searchedItems = FeatureComposerMenuBuilder.items( + trigger: searchedSkillsTrigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures(skills: skills), + pathEntries: [] + ) + let searchedSlashItems = FeatureComposerMenuBuilder.items( + trigger: searchedSlashTrigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures(skills: skills), + pathEntries: [] + ) + + #expect(allItems.map(\.label) == ["Enabled deploy", "Review"]) + #expect(searchedItems.isEmpty) + #expect(searchedSlashItems.isEmpty) + } + + @Test + func slashCommandsUseNormalizedNamesAndAllEnabledSkillsForSuppression() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/")) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + slashCommands: [ + FeatureProviderSlashCommand(name: " DEPLOY "), + FeatureProviderSlashCommand(name: " MODEL "), + ], + skills: [FeatureProviderSkill(name: "deploy", displayName: "Release project")] + ), + pathEntries: [] + ) + + #expect(items.map(\.label) == ["/model", "Release project"]) + } + + @Test + func slashSkillPrefixFiltersSkillsWithoutProviderCommands() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/skill:fix")) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + slashCommands: [FeatureProviderSlashCommand(name: "fix")], + skills: [ + FeatureProviderSkill(name: "gh-fix-ci", displayName: "Fix CI"), + FeatureProviderSkill(name: "deploy"), + ] + ), + pathEntries: [] + ) + + #expect(items.map(\.label) == ["Fix CI"]) + } + + @Test + func skillSourcesFollowProviderScopeAndPluginPaths() { + #expect(FeatureProviderSkill(name: "repo", scope: "repository").source == .repository) + #expect(FeatureProviderSkill(name: "local", scope: "workspace").source == .project) + #expect(FeatureProviderSkill(name: "mine", scope: "user").source == .personal) + #expect(FeatureProviderSkill(name: "built-in", scope: "system").source == .system) + #expect( + FeatureProviderSkill( + name: "plugin", + path: "/Users/theo/.codex/plugins/example/SKILL.md", + scope: "user" + ).source == .app + ) + } + + @Test + func appApprovalDecisionsKeepTheServerWireValues() { + let decisions: [(FeatureApprovalDecision, String)] = [ + (.allowOnce, "accept"), + (.allowForSession, "acceptForSession"), + (.allowAlways, "acceptAlways"), + (.deny, "decline"), + (.cancel, "cancel"), + ] + + for (decision, wireValue) in decisions { + #expect(decision.wireValue == wireValue) + #expect(FeatureApprovalDecision(wireValue: wireValue) == decision) + } + #expect(FeatureApprovalDecision(wireValue: "unsupported") == nil) + } + + @Test + func codexFeedbackCommandParsesOptionalReasonsWithoutMatchingOtherCommands() { + #expect(FeatureCodexFeedbackCommand.parse(" /feedback ")?.reason == nil) + #expect( + FeatureCodexFeedbackCommand.parse("/feedback The agent stopped early.")?.reason + == "The agent stopped early." + ) + #expect( + FeatureCodexFeedbackCommand.parse("/FEEDBACK First line\nSecond line")?.reason + == "First line\nSecond line" + ) + #expect(FeatureCodexFeedbackCommand.parse("/feedback-status") == nil) + #expect(FeatureCodexFeedbackCommand.parse("Please send /feedback") == nil) + } + + @Test + func modelAndSkillMenusFilterTheirCatalogs() throws { + let provider = FeatureProvider( + id: "claude", + name: "Claude", + models: [ + FeatureModel(id: "sonnet", name: "Sonnet"), + FeatureModel(id: "opus", name: "Opus"), + ] + ) + let modelTrigger = try #require( + FeatureComposerTriggerParser.detect(in: "/model op") + ) + let modelItems = FeatureComposerMenuBuilder.items( + trigger: modelTrigger, + providers: [provider], + currentSelection: nil, + threadSelection: nil, + powerFeatures: .disabled, + pathEntries: [] + ) + #expect(modelItems.map(\.label) == ["Opus"]) + + let skillTrigger = try #require(FeatureComposerTriggerParser.detect(in: "$fix")) + let skillItems = FeatureComposerMenuBuilder.items( + trigger: skillTrigger, + providers: [provider], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + skills: [ + FeatureProviderSkill( + name: "gh-fix-ci", + displayName: "Fix CI", + shortDescription: "Repair failing checks" + ), + FeatureProviderSkill(name: "deploy", displayName: "Deploy") + ] + ), + pathEntries: [] + ) + #expect(skillItems.map(\.label) == ["Fix CI"]) + } + + @Test + func modelCommandHonorsProvidersThatLockAThreadModel() throws { + let provider = FeatureProvider( + id: "locked", + name: "Locked provider", + requiresNewThreadForModelChange: true, + models: [ + FeatureModel(id: "current", name: "Current"), + FeatureModel(id: "other", name: "Other"), + ] + ) + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/model")) + let currentSelection = FeatureSelection( + providerID: "locked", + modelID: "current", + options: [FeatureModelOptionSelection(id: "reasoning", value: .string("high"))] + ) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [provider], + currentSelection: currentSelection, + threadSelection: currentSelection, + powerFeatures: .disabled, + pathEntries: [] + ) + + #expect(items.map(\.label) == ["Current"]) + if case let .model(selection, _, _) = try #require(items.first) { + #expect(selection.options == currentSelection.options) + } else { + Issue.record("Expected a model menu item") + } + } + + @Test + func establishedThreadsKeepModelChoicesOnTheirProvider() throws { + let currentProvider = FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "current", name: "Current"), + FeatureModel(id: "other", name: "Other"), + ] + ) + let otherProvider = FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "sonnet", name: "Sonnet")] + ) + let selection = FeatureSelection(providerID: "codex", modelID: "current") + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/model")) + + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [currentProvider, otherProvider], + currentSelection: selection, + threadSelection: selection, + powerFeatures: .disabled, + pathEntries: [] + ) + + #expect(items.map(\.label) == ["Current", "Other"]) + } + + @Test + func inlineSkillTokensUseProviderLabelsAndWebBoundaries() throws { + let skills = [ + FeatureProviderSkill(name: "file-pr", displayName: "File PR"), + FeatureProviderSkill(name: "review-follow-up"), + ] + + let completed = FeatureInlineSkillParser.descriptors( + in: "Use $file-pr then ", + skills: skills, + allowsEndBoundary: false + ) + #expect(completed.map(\.rawText) == ["$file-pr"]) + #expect(completed.map(\.displayName) == ["File PR"]) + + #expect( + FeatureInlineSkillParser.descriptors( + in: "$review-follow-up", + skills: skills, + allowsEndBoundary: true + ).map(\.displayName) == ["Review Follow Up"] + ) + #expect( + FeatureInlineSkillParser.descriptors( + in: "$file-pr", + skills: skills, + allowsEndBoundary: false + ).isEmpty + ) + #expect( + FeatureInlineSkillParser.descriptors( + in: "prefix$file-pr ", + skills: skills, + allowsEndBoundary: true + ).isEmpty + ) + } + + @Test + func composerInlineSkillsExcludeDisabledSkills() { + let powerFeatures = FeatureComposerPowerFeatures( + skills: [ + FeatureProviderSkill(name: "file-pr", displayName: "File PR"), + FeatureProviderSkill(name: "disabled", isEnabled: false), + ] + ) + + let descriptors = FeatureInlineSkillParser.descriptors( + in: "$file-pr $disabled ", + skills: powerFeatures.enabledSkills, + allowsEndBoundary: false + ) + + #expect(descriptors.map(\.rawText) == ["$file-pr"]) + } + + @Test @MainActor + func inlineSkillAttachmentsRoundTripPlainTextAndSelectionOffsets() throws { + let source = "Use $file-pr now" + let descriptors = FeatureInlineSkillParser.descriptors( + in: source, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")], + allowsEndBoundary: false + ) + let font = UIFont.preferredFont(forTextStyle: .body) + let attributed = FeatureInlineSkillPillRenderer.attributedText( + source: source, + descriptors: descriptors, + baseAttributes: [.font: font], + font: font, + traits: UITraitCollection(userInterfaceStyle: .dark) + ) + + #expect(FeatureInlineSkillProjection.plainText(from: attributed) == source) + #expect(FeatureInlineSkillProjection.signatures(in: attributed).count == 1) + #expect(attributed.string == "Use \u{FFFC} now") + + let plainAfterSkill = NSMaxRange(try #require(descriptors.first).range) + let displaySelection = FeatureInlineSkillProjection.displayRange( + for: NSRange(location: plainAfterSkill, length: 0), + in: attributed + ) + #expect(displaySelection == NSRange(location: 5, length: 0)) + #expect( + FeatureInlineSkillProjection.plainRange( + for: displaySelection, + in: attributed + ) == NSRange(location: plainAfterSkill, length: 0) + ) + } + + @Test @MainActor + func inlineSkillProjectionIgnoresPillMetadataInheritedByTypedText() throws { + let source = "$file-pr" + let descriptors = FeatureInlineSkillParser.descriptors( + in: source, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")], + allowsEndBoundary: true + ) + let font = UIFont.preferredFont(forTextStyle: .body) + let attributed = FeatureInlineSkillPillRenderer.attributedText( + source: source, + descriptors: descriptors, + baseAttributes: [.font: font], + font: font, + traits: UITraitCollection(userInterfaceStyle: .dark) + ) + let typedTextAttributes = attributed.attributes(at: 0, effectiveRange: nil) + .filter { $0.key != .attachment } + let afterTyping = NSMutableAttributedString(attributedString: attributed) + afterTyping.append(NSAttributedString(string: "x", attributes: typedTextAttributes)) + + #expect(FeatureInlineSkillProjection.plainText(from: afterTyping) == "$file-prx") + #expect(FeatureInlineSkillProjection.signatures(in: afterTyping).count == 1) + } + + @Test + func completedComposerPillSurvivesDeletingItsTrailingSpace() throws { + let skill = FeatureProviderSkill(name: "file-pr", displayName: "File PR") + let completed = try #require( + FeatureInlineSkillParser.descriptors( + in: "$file-pr ", + skills: [skill], + allowsEndBoundary: false + ).first + ) + #expect( + FeatureInlineSkillParser.descriptors( + in: "$file-pr", + skills: [skill], + allowsEndBoundary: false, + preservingTrailing: completed + ) == [completed] + ) + } + + @Test @MainActor + func skillCatalogUpdatePreservesMarkedComposerText() throws { + let input = FeatureComposerTextInput( + text: .constant("Use $file-pr に"), + focused: .constant(false), + placeholder: "", + acceptsImages: false, + isReadOnly: false, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")], + selectionRequest: nil, + onSelectionChange: { _ in }, + onPasteImages: { _ in }, + onDismissKeyboard: nil + ) + let coordinator = FeatureComposerTextInput.Coordinator(input) + let textView = FeatureComposerUITextView() + textView.delegate = coordinator + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.text = "Use $file-pr " + textView.selectedRange = NSRange(location: textView.text.utf16.count, length: 0) + + let viewController = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = viewController + viewController.view.addSubview(textView) + window.makeKeyAndVisible() + defer { window.isHidden = true } + #expect(textView.becomeFirstResponder()) + textView.setMarkedText("に", selectedRange: NSRange(location: 1, length: 0)) + let markedRange = try #require(textView.markedTextRange) + #expect(textView.text(in: markedRange) == "に") + let source = FeatureInlineSkillProjection.plainText(from: textView.attributedText) + let selection = textView.selectedRange + + #expect(!coordinator.synchronizeInlineSkills( + in: textView, + source: source, + selection: selection + )) + #expect(textView.markedTextRange != nil) + #expect(textView.selectedRange == selection) + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).isEmpty) + + textView.unmarkText() + coordinator.textViewDidChange(textView) + #expect(FeatureInlineSkillProjection.plainText(from: textView.attributedText) == source) + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).count == 1) + } + + @Test @MainActor + func inlineSkillSynchronizationPreservesComposerUndoHistory() throws { + let input = FeatureComposerTextInput( + text: .constant("Use"), + focused: .constant(false), + placeholder: "", + acceptsImages: false, + isReadOnly: false, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")], + selectionRequest: nil, + onSelectionChange: { _ in }, + onPasteImages: { _ in }, + onDismissKeyboard: nil + ) + let coordinator = FeatureComposerTextInput.Coordinator(input) + let textView = FeatureComposerUITextView() + textView.delegate = coordinator + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.attributedText = NSAttributedString(string: "Use") + textView.selectedRange = NSRange(location: 3, length: 0) + + let viewController = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = viewController + viewController.view.addSubview(textView) + window.makeKeyAndVisible() + defer { window.isHidden = true } + #expect(textView.becomeFirstResponder()) + + let undoManager = try #require(textView.undoManager) + undoManager.removeAllActions() + undoManager.groupsByEvent = false + + #expect(coordinator.textView( + textView, + shouldChangeTextIn: textView.selectedRange, + replacementText: " $file-pr" + )) + textView.insertText(" $file-pr") + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).isEmpty) + + #expect(coordinator.textView( + textView, + shouldChangeTextIn: textView.selectedRange, + replacementText: " " + )) + textView.insertText(" ") + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).count == 1) + + #expect(undoManager.canUndo) + undoManager.undo() + #expect( + FeatureInlineSkillProjection.plainText(from: textView.attributedText) + == "Use $file-pr" + ) + undoManager.undo() + #expect(FeatureInlineSkillProjection.plainText(from: textView.attributedText) == "Use") + + undoManager.redo() + #expect( + FeatureInlineSkillProjection.plainText(from: textView.attributedText) + == "Use $file-pr" + ) + undoManager.redo() + #expect( + FeatureInlineSkillProjection.plainText(from: textView.attributedText) + == "Use $file-pr " + ) + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).count == 1) + } + + @Test @MainActor + func composerRedoPreservesTrailingSkillPill() throws { + let input = FeatureComposerTextInput( + text: .constant("$file-pr "), + focused: .constant(false), + placeholder: "", + acceptsImages: false, + isReadOnly: false, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")], + selectionRequest: nil, + onSelectionChange: { _ in }, + onPasteImages: { _ in }, + onDismissKeyboard: nil + ) + let coordinator = FeatureComposerTextInput.Coordinator(input) + let textView = FeatureComposerUITextView() + textView.delegate = coordinator + textView.font = UIFont.preferredFont(forTextStyle: .body) + _ = coordinator.synchronizeInlineSkills( + in: textView, + source: "$file-pr ", + selection: NSRange(location: 9, length: 0) + ) + + let viewController = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = viewController + viewController.view.addSubview(textView) + window.makeKeyAndVisible() + defer { window.isHidden = true } + #expect(textView.becomeFirstResponder()) + + let undoManager = try #require(textView.undoManager) + undoManager.removeAllActions() + undoManager.groupsByEvent = false + + let trailingSpace = NSRange(location: 1, length: 1) + #expect(coordinator.textView( + textView, + shouldChangeTextIn: trailingSpace, + replacementText: "" + )) + textView.selectedRange = trailingSpace + textView.insertText("") + #expect(FeatureInlineSkillProjection.plainText(from: textView.attributedText) == "$file-pr") + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).count == 1) + + undoManager.undo() + #expect(FeatureInlineSkillProjection.plainText(from: textView.attributedText) == "$file-pr ") + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).count == 1) + + undoManager.redo() + #expect(FeatureInlineSkillProjection.plainText(from: textView.attributedText) == "$file-pr") + #expect(FeatureInlineSkillProjection.signatures(in: textView.attributedText).count == 1) + } + + @Test + func changingInputQuestionsKeepsAValidActiveQuestionAndDropsStaleAnswers() { + #expect( + FeatureComposerQuestionReconciliation.index( + current: 2, + previousQuestionIDs: ["one", "two", "three"], + currentQuestionIDs: ["one"] + ) == 0 + ) + #expect( + FeatureComposerQuestionReconciliation.index( + current: 1, + previousQuestionIDs: ["one", "two", "three"], + currentQuestionIDs: ["three", "two"] + ) == 1 + ) + + let reconciled = FeatureComposerQuestionReconciliation.answers( + [ + "one": .text("keep"), + "removed": .text("drop"), + ], + currentQuestionIDs: ["one"] + ) + #expect(reconciled == ["one": .text("keep")]) + } + + @Test + func onlyTheExplicitComposerButtonCanSend() { + #expect( + FeatureComposerSubmissionPolicy.allowsSend(for: .explicitButton) + ) + #expect( + !FeatureComposerSubmissionPolicy.allowsSend(for: .returnKey) + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureComposerUploadStatusTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureComposerUploadStatusTests.swift new file mode 100644 index 000000000000..776b3b353b21 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureComposerUploadStatusTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer upload status") +struct FeatureComposerUploadStatusTests { + @Test func anAttachmentWithoutAnUploadJobIsPreparingNotUploading() { + let status = FeatureComposerUploadStatus(states: [ + (UUID(), nil), + (UUID(), .queued), + ]) + #expect(status.preparingCount == 2) + #expect(status.uploadingCount == 0) + #expect(status.blocksSend) + } + + @Test func mixedBatchKeepsPendingTransfersAndFailuresVisible() { + let failedID = UUID() + let status = FeatureComposerUploadStatus(states: [ + (UUID(), .ready(nil)), + (UUID(), .uploading), + (failedID, .failed("The server did not respond.")), + ]) + #expect(status.preparingCount == 0) + #expect(status.uploadingCount == 1) + #expect(status.failures.first?.0 == failedID) + #expect(status.failures.first?.1 == "The server did not respond.") + #expect(status.blocksSend) + } + + @Test func readyAndInlineAttachmentsDoNotBlockSending() { + let status = FeatureComposerUploadStatus(states: [ + (UUID(), .ready(nil)), + (UUID(), .ready(.init(environmentID: "one", attachmentID: "image"))), + ]) + #expect(!status.blocksSend) + #expect(!FeatureComposerUploadStatus(states: []).blocksSend) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureContextCompactionTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureContextCompactionTests.swift new file mode 100644 index 000000000000..27b9ec191e20 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureContextCompactionTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Context compaction") +struct FeatureContextCompactionTests { + @Test + func commandRequiresExactTextWithoutAttachments() { + #expect(FeatureContextCompaction.isCommand(" /COMPACT\n", hasAttachments: false)) + #expect(!FeatureContextCompaction.isCommand("/compact", hasAttachments: true)) + #expect(!FeatureContextCompaction.isCommand("/compact the last turn", hasAttachments: false)) + #expect(!FeatureContextCompaction.isCommand("Explain /compact", hasAttachments: false)) + } + + @Test + func newThreadsAndUnsentMessagesHaveNoContextToCompact() { + #expect(!FeatureContextCompaction.canStart(in: nil, isBusy: false)) + let histories: [[FeatureMessage]] = [ + [], + [.init(id: "assistant", role: .assistant, text: "Ready")], + [.init(id: "compact", role: .user, text: "/compact")], + [.init(id: "blank", role: .user, text: " \n")], + [.init(id: "queued", role: .user, text: "Build the app", state: .queued)], + ] + for messages in histories { + #expect(!FeatureContextCompaction.canStart(in: detail(messages: messages), isBusy: false)) + } + } + + @Test + func priorTextAndAttachmentMessagesCanBeCompacted() { + #expect(FeatureContextCompaction.canStart(in: detail(), isBusy: false)) + let attachment = FeatureMessageAttachment( + id: "reference", + name: "reference.png", + mimeType: "image/png", + sizeBytes: 10 + ) + let imageMessage = FeatureMessage( + id: "image", + role: .user, + text: "", + attachments: [attachment] + ) + #expect(FeatureContextCompaction.canStart( + in: detail(messages: [imageMessage]), + isBusy: false + )) + } + + @Test + func busyThreadsCannotStartAnotherCompaction() { + #expect(!FeatureContextCompaction.canStart(in: detail(), isBusy: true)) + for state in [ + FeatureThreadState.queued, .working, .monitoring, .waitingForApproval, .waitingForInput, + ] { + var active = detail() + active.thread.state = state + #expect(!FeatureContextCompaction.canStart(in: active, isBusy: false)) + } + var compacting = detail() + compacting.isCompacting = true + #expect(!FeatureContextCompaction.canStart(in: compacting, isBusy: false)) + } + + @Test + func earlierConversationCountsWhenHistoryIsPaginated() { + var paginated = detail(messages: []) + paginated.page = FeatureThreadPage(beforeCursor: "older", hasMore: true) + #expect(!FeatureContextCompaction.canStart(in: paginated, isBusy: false)) + paginated.thread.settlementFacts = FeatureThreadSettlementFacts( + latestUserMessageAt: Date(timeIntervalSince1970: 10) + ) + #expect(FeatureContextCompaction.canStart(in: paginated, isBusy: false)) + } + + @Test + func commandMenuOnlyOffersCompactionForAnAvailableConversation() { + let commands = [ + FeatureProviderSlashCommand(name: "compact"), + FeatureProviderSlashCommand(name: "status"), + ] + let hidden = commandNames(in: FeatureComposerPowerFeatures(slashCommands: commands)) + #expect(hidden == ["status"]) + let available = commandNames(in: FeatureComposerPowerFeatures( + slashCommands: commands, + canCompactContext: FeatureContextCompaction.canStart(in: detail(), isBusy: false) + )) + #expect(available == ["compact", "status"]) + } + + private func detail( + messages: [FeatureMessage] = [.init(id: "user", role: .user, text: "Build the app")] + ) -> FeatureThreadDetail { + FeatureThreadDetail( + thread: FeatureThread(id: "thread", projectID: "project", title: "Task"), + messages: messages + ) + } + + private func commandNames(in features: FeatureComposerPowerFeatures) -> [String] { + FeatureComposerMenuBuilder.items( + trigger: .init(kind: .slashCommand, query: "", range: 0..<1), + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: features, + pathEntries: [] + ).compactMap { item in + guard case let .providerCommand(command) = item else { return nil } + return command.name + } + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureOutboxStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureOutboxStoreTests.swift new file mode 100644 index 000000000000..8ae6f354875c --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureOutboxStoreTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Durable mobile outbox") +struct FeatureOutboxStoreTests { + @Test + func roundTripPreservesStableWireIdentityAndAttachments() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity( + threadID: "thread-wire", + commandID: "command-wire", + messageID: "message-wire", + createdAt: Date(timeIntervalSince1970: 42) + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: "thread-scoped", + text: "Ship it", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .automatic, + interactionMode: .plan, + attachments: [ + .init(data: Data([0x01, 0x02]), name: "reference.png", mimeType: "image/png"), + ] + ) + + try await store.enqueue(submission) + let persistedURL = await store.fileURL + let restored = try await FeatureOutboxStore( + fileURL: persistedURL + ).submissions() + + #expect(restored.count == 1) + #expect(restored[0].identity == identity) + #expect(restored[0].runtimeMode == .automatic) + #expect(restored[0].interactionMode == .standard) + #expect(restored[0].attachments.first?.data == Data([0x01, 0x02])) + } + + @Test + func fileBackedRoundTripPreservesLocalAndUploadedIdentity() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-file-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let sourceURL = directory.appendingPathComponent("provider.json") + try Data("{}".utf8).write(to: sourceURL) + let attachmentID = UUID() + let storageRoot = directory.appendingPathComponent("attachments", isDirectory: true) + let ownedFile = try ManagedAttachmentFileStore(rootURL: storageRoot).copyOwnedFile( + from: sourceURL, + attachmentID: attachmentID, + originalFileName: "context.json" + ) + let uploadedReference = FeatureUploadedAttachmentReference( + environmentID: "environment-1", + attachmentID: "uploaded-1" + ) + let store = FeatureOutboxStore( + fileURL: directory.appendingPathComponent("outbox.json"), + attachmentStorageRootURL: storageRoot + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: "thread-1", + text: "Review", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + FeatureUploadAttachment( + id: attachmentID, + ownedFile: ownedFile, + name: "context.json", + mimeType: "application/json", + uploadedReference: uploadedReference + ), + ] + ) + + try await store.enqueue(submission) + let persistedURL = await store.fileURL + let restored = try await FeatureOutboxStore( + fileURL: persistedURL, + attachmentStorageRootURL: storageRoot + ).submissions().first + + #expect(restored?.uploads.first?.id == attachmentID) + #expect(restored?.uploads.first?.ownedFile?.url == ownedFile.url) + #expect(restored?.uploads.first?.byteCount == 2) + #expect(restored?.uploads.first?.uploadedReference == uploadedReference) + let json = try #require(String(data: Data(contentsOf: persistedURL), encoding: .utf8)) + #expect(!json.contains(Data("{}".utf8).base64EncodedString())) + } + + @Test + func restoreAcceptsImageAttachmentWithoutNewFileFields() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-old-image-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("outbox.json") + let identity = FeatureSubmissionIdentity( + threadID: "thread-1", + commandID: "command-1", + messageID: "message-1", + createdAt: Date(timeIntervalSince1970: 42) + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: "thread-1", + text: "Old image", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + FeatureUploadAttachment( + data: Data([1, 2, 3]), + name: "old.png", + mimeType: "image/png" + ), + ] + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let current = try JSONSerialization.jsonObject( + with: JSONEncoder.t3.encode(submission) + ) as! [String: Any] + var legacy = current + var attachments = legacy["attachments"] as! [[String: Any]] + attachments[0].removeValue(forKey: "id") + attachments[0].removeValue(forKey: "byteCount") + legacy["attachments"] = attachments + try JSONSerialization.data( + withJSONObject: ["version": 1, "submissions": [legacy]] + ).write(to: fileURL) + + let restored = try await FeatureOutboxStore(fileURL: fileURL).submissions().first + + #expect(restored?.uploads.first?.data == Data([1, 2, 3])) + #expect(restored?.uploads.first?.ownedFile == nil) + } + + @Test + func restorePreservesLegacyPermission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-legacy-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + var submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: "thread-scoped", + text: "Retry this", + selection: nil, + runtimeMode: .automatic, + interactionMode: .standard, + attachments: [] + ) + submission.runtimeMode = .autoAcceptEdits + try await store.enqueue(submission) + let persistedURL = await store.fileURL + + let restored = try await FeatureOutboxStore(fileURL: persistedURL).submissions() + + #expect(restored.first?.runtimeMode == .autoAcceptEdits) + } + + @Test + func failedLoadPreservesSavedMessagesAndRetriesBeforeWriting() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-retry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("outbox.json") + let saved = FeatureQueuedSubmission( + environmentID: "saved-environment", + identity: FeatureSubmissionIdentity(createdAt: Date(timeIntervalSince1970: 1)), + threadID: "saved-thread", + text: "Keep this message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + FeatureUploadAttachment( + data: Data([1, 2, 3]), + name: "reference.png", + mimeType: "image/png" + ), + ] + ) + let incoming = FeatureQueuedSubmission( + environmentID: "another-environment", + identity: FeatureSubmissionIdentity(createdAt: Date(timeIntervalSince1970: 2)), + threadID: "another-thread", + text: "Send after recovery", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + try await FeatureOutboxStore(fileURL: fileURL).enqueue(saved) + let savedData = try Data(contentsOf: fileURL) + let unreadableData = Data("{".utf8) + try unreadableData.write(to: fileURL, options: .atomic) + let store = FeatureOutboxStore(fileURL: fileURL) + + await #expect(throws: DecodingError.self) { + try await store.submissions() + } + await #expect(throws: DecodingError.self) { + try await store.enqueue(incoming) + } + await #expect(throws: DecodingError.self) { + try await store.remove(id: saved.id) + } + await #expect(throws: DecodingError.self) { + try await store.removeAll(environmentID: incoming.environmentID) + } + #expect(try Data(contentsOf: fileURL) == unreadableData) + + try savedData.write(to: fileURL, options: .atomic) + try await store.enqueue(incoming) + let restored = try await FeatureOutboxStore(fileURL: fileURL).submissions() + #expect(restored == [saved, incoming]) + } + + @Test + func policySendsFollowUpsWhileWorkingAndWaitsWhenOffline() { + let thread = FeatureThread( + id: "thread-scoped", + projectID: "project-1", + environmentID: "environment-1", + title: "Working", + state: .working + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: thread.id, + text: "Queue this next", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + let environment = FeatureEnvironment( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ) + let connected = FeatureSnapshot( + connection: .init(state: .connected), + environments: [environment], + threads: [thread] + ) + var offline = connected + offline.connection.state = .disconnected + offline.environments[0].connectionState = .disconnected + + #expect(FeatureOutboxPolicy.decision(for: submission, snapshot: connected) == .send) + #expect(FeatureOutboxPolicy.decision(for: submission, snapshot: offline) == .wait) + } + + @Test + func existingThreadDoesNotProveItsFirstMessageWasDelivered() { + let thread = FeatureThread( + id: "thread-scoped", + projectID: "project-1", + environmentID: "environment-1", + title: "Created" + ) + let creation = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: thread.id, + text: "Create it", + selection: .init(providerID: "claude", modelID: "claude-opus-5"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + var snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ], + threads: [thread] + ) + + #expect(FeatureOutboxPolicy.decision(for: creation, snapshot: snapshot) == .send) + + snapshot.environments[0].connectionState = .disconnected + #expect(FeatureOutboxPolicy.decision(for: creation, snapshot: snapshot) == .wait) + snapshot.environments[0].connectionState = .connected + + var followUp = creation + followUp.creation = nil + snapshot.threads = [] + #expect(FeatureOutboxPolicy.decision(for: followUp, snapshot: snapshot) == .discard) + #expect( + FeatureOutboxPolicy.decision( + for: followUp, + snapshot: snapshot, + pendingCreationThreadIDs: [creation.threadID] + ) == .wait + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift new file mode 100644 index 000000000000..94480998af51 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -0,0 +1,3827 @@ +import Foundation +import Observation +import SwiftUI +import Testing +import UIKit +import XCTest +@testable import T3Code + +@MainActor +@Suite("Feature root model") +struct FeatureRootModelTests { + @Test + func transcriptSkillPillsUseTheThreadWorkspaceCatalog() async { + let skill = FeatureProviderSkill(name: "project-only", displayName: "Project only") + var provider = FeatureProvider( + id: "codex", name: "Codex", driver: "codex", + models: [.init(id: "test-model", name: "Test model")], + skills: [.init(name: "global-only")] + ) + provider.workspaceSnapshots = [ + .init(cwd: "/workspace", slashCommands: [], skills: [skill]), + ] + let thread = FeatureThread( + id: "workspace-skills", projectID: "project", environmentID: "environment", + title: "Workspace skills", worktreePath: "/workspace", + providerID: "codex", modelID: "test-model" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + threads: [thread], providersByEnvironment: ["environment": [provider]] + ) + let model = testRootModel(client: client) + await model.reload() + let view = ThreadDetailView(model: model, thread: thread, submitMessage: { _ in true }) + + let pills = FeatureInlineSkillParser.descriptors( + in: "$project-only", skills: view.threadProviderSkills, allowsEndBoundary: true + ) + + #expect(pills.map(\.rawText) == ["$project-only"]) + #expect(pills.map(\.displayName) == ["Project only"]) + + let source = "$project-only $new-skill $global-only" + provider.workspaceSnapshots = [ + .init(cwd: "/workspace", slashCommands: [], skills: [ + .init(name: "project-only", displayName: "Updated name"), + .init(name: "new-skill", displayName: "New skill"), + ]), + ] + client.snapshot.providersByEnvironment = ["environment": [provider]] + await model.reload() + let updated = FeatureInlineSkillParser.descriptors( + in: source, skills: view.threadProviderSkills, allowsEndBoundary: true + ) + #expect(updated.map(\.rawText) == ["$project-only", "$new-skill"]) + #expect(updated.map(\.displayName) == ["Updated name", "New skill"]) + + provider.workspaceSnapshots = [ + .init(cwd: "/other-workspace", slashCommands: [], skills: [ + .init(name: "project-only"), + ]), + ] + client.snapshot.providersByEnvironment = ["environment": [provider]] + await model.reload() + let removed = FeatureInlineSkillParser.descriptors( + in: source, skills: view.threadProviderSkills, allowsEndBoundary: true + ) + #expect(removed.isEmpty) + } + + @Test + func foregroundRecoveryIgnoresInitialActivationAndReplacesLongSuspendedSockets() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + let start = Date(timeIntervalSince1970: 100) + await model.applicationDidBecomeActive(at: start) + #expect(client.foregroundReconnects.isEmpty) + model.applicationDidEnterBackground(at: start) + await model.applicationDidBecomeActive(at: start.addingTimeInterval(9)) + model.applicationDidEnterBackground(at: start) + await model.applicationDidBecomeActive(at: start.addingTimeInterval(10)) + await model.applicationDidBecomeActive(at: start.addingTimeInterval(11)) + #expect(client.foregroundReconnects == [false, true]) + } + + @Test + func connectedComputerDoesNotHideThreadCatchUpOrItsFailure() { + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: false, syncState: .catchingUp + ) == .catchingUp) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: false, syncState: .failed("Timeout") + ) == .failed) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: false, syncState: .live + ) == nil) + } + + @Test + func liveThreadSyncOutranksStaleLoadingAndEnvironmentReachability() { + for connectionState in [FeatureConnection.State.connected, .disconnected, .reconnecting] { + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: connectionState, isOpening: false, syncState: .live + ) == nil) + #expect(ThreadRefreshPresentation.resolve( + loadState: .loading, connectionState: connectionState, isOpening: false, syncState: .live + ) == nil) + #expect(ThreadRefreshPresentation.resolve( + loadState: .failed("Timeout"), connectionState: connectionState, isOpening: false, syncState: .live + ) == nil) + #expect(ThreadRefreshPresentation.resolve( + loadState: .loading, connectionState: connectionState, isOpening: true, syncState: .live + ) == nil) + } + } + + @Test + func repeatedCatchUpEventsDoNotInvalidateViewState() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + let run = Task { await model.start() } + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.threadSyncStates["thread"] + } onChange: { + continuation.resume() + } + client.emit(.threadSync(id: "thread", state: .catchingUp)) + } + let changes = AsyncStream.makeStream() + withObservationTracking { + _ = model.threadSyncStates["thread"] + } onChange: { + changes.continuation.yield() + } + client.emit(.threadSync(id: "thread", state: .catchingUp)) + client.finishEvents() + await run.value + changes.continuation.finish() + let didChange = await changes.stream.contains { _ in true } + #expect(!didChange) + #expect(model.threadSyncStates["thread"] == .catchingUp) + } + + @Test + func appearanceAppliesImmediatelyAndPersists() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + + let save = Task { await model.saveAppearance(.light) } + await Task.yield() + + #expect(model.snapshot.settings.appearance == .light) + #expect(await save.value) + #expect(client.savedSettings.last?.appearance == .light) + } + + @Test + func textSizesApplyImmediatelyAndPersist() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + + let save = Task { + await model.saveTextSizes( + textSize: FeatureTextSizeAdjustment(steps: 2), + codeSize: FeatureTextSizeAdjustment(steps: -1) + ) + } + await Task.yield() + + #expect(model.snapshot.settings.textSize.steps == 2) + #expect(model.snapshot.settings.codeSize.steps == -1) + #expect(await save.value) + #expect(client.savedSettings.last?.textSize.steps == 2) + #expect(client.savedSettings.last?.codeSize.steps == -1) + } + + @Test + func unchangedTextSizesDoNotWrite() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + + #expect(await model.saveTextSizes(textSize: .standard, codeSize: .standard)) + #expect(client.savedSettings.isEmpty) + } + + @Test + func preferenceAutosavesMergeDifferentFieldsDuringSnapshotRefresh() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { await gate.enter() } + let model = testRootModel(client: client) + + let firstSave = Task { await model.savePreference(\.hapticsEnabled, value: false) } + await gate.waitUntilCallCount(1) + let secondSave = Task { + await model.savePreference(\.textSize, value: FeatureTextSizeAdjustment(steps: 2)) + } + await Task.yield() + #expect(model.snapshot.settings.hapticsEnabled == false) + #expect(model.snapshot.settings.textSize.steps == 2) + let requested = model.snapshot.settings + + await model.reload() + #expect(model.snapshot.settings == requested) + gate.releaseFirst() + #expect(await firstSave.value) + #expect(await secondSave.value) + #expect(client.savedSettings.count == 2) + #expect(client.savedSettings.last == requested) + #expect(await model.savePreference(\.textSize, value: requested.textSize)) + #expect(client.savedSettings.count == 2) + } + + @Test + func preferenceAutosaveFailureRestoresTheLastSuccessfulWrite() async { + let firstGate = FeatureSettingsSaveGate() + let secondGate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + var callCount = 0 + client.beforeSaveSettings = { + callCount += 1 + if callCount == 1 { + await firstGate.enter() + throw URLError(.cannotConnectToHost) + } + if callCount == 2 { await secondGate.enter() } + } + let model = testRootModel(client: client) + + let firstSave = Task { await model.savePreference(\.appearance, value: .light) } + await firstGate.waitUntilCallCount(1) + let secondSave = Task { await model.savePreference(\.appearance, value: .dark) } + await Task.yield() + #expect(model.snapshot.settings.appearance == .dark) + let thirdSave = Task { await model.savePreference(\.appearance, value: .light) } + await Task.yield() + #expect(model.snapshot.settings.appearance == .light) + let requested = model.snapshot.settings + firstGate.releaseFirst() + + await secondGate.waitUntilCallCount(1) + #expect(await firstSave.value == false) + // Equal values belong to separate edits. The older failure must not undo the latest one. + #expect(model.snapshot.settings == requested) + #expect(model.errorMessage == nil) + secondGate.releaseFirst() + #expect(await secondSave.value) + #expect(await thirdSave.value) + #expect(model.snapshot.settings == requested) + #expect(client.savedSettings.map(\.appearance) == [.dark, .light]) + client.beforeSaveSettings = { throw URLError(.cannotConnectToHost) } + #expect(await model.savePreference(\.hapticsEnabled, value: false) == false) + #expect(model.snapshot.settings == requested) + #expect(client.savedSettings.last == requested) + } + + @Test + func preferenceWriteFinishesIfItsCallerIsCancelled() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { + await gate.enter() + try Task.checkCancellation() + } + let model = testRootModel(client: client) + + let save = Task { await model.savePreference(\.liveActivitiesEnabled, value: false) } + await gate.waitUntilCallCount(1) + save.cancel() + gate.releaseFirst() + + #expect(await save.value) + #expect(model.snapshot.settings.liveActivitiesEnabled == false) + #expect(client.savedSettings.last?.liveActivitiesEnabled == false) + } + + @Test + func snapshotRefreshPreservesPendingTextSizesAndLaterExternalSettings() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { await gate.enter() } + let model = testRootModel(client: client) + + let save = Task { + await model.saveTextSizes( + textSize: FeatureTextSizeAdjustment(steps: 2), + codeSize: FeatureTextSizeAdjustment(steps: -1) + ) + } + await gate.waitUntilCallCount(1) + let requestedSettings = model.snapshot.settings + client.snapshot.threads = [ + FeatureThread(id: "refreshed", projectID: "project", title: "Updated thread") + ] + await model.reload() + + #expect(model.snapshot.settings == requestedSettings) + #expect(model.snapshot.threads == client.snapshot.threads) + gate.releaseFirst() + #expect(await save.value) + #expect(model.snapshot.settings == requestedSettings) + + client.snapshot.settings.appearance = .light + await model.reload() + #expect(model.snapshot.settings == client.snapshot.settings) + } + + @Test + func staleSnapshotDuringFailedWriteKeepsLastSuccessfulSettings() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + let model = testRootModel(client: client) + var persisted = model.snapshot.settings + persisted.hapticsEnabled = false + #expect(await model.saveSettings(persisted)) + client.beforeSaveSettings = { + await gate.enter() + throw URLError(.cannotConnectToHost) + } + + var requested = persisted + requested.textSize = FeatureTextSizeAdjustment(steps: 2) + let save = Task { await model.saveSettings(requested) } + await gate.waitUntilCallCount(1) + await model.reload() + #expect(model.snapshot.settings == requested) + + gate.releaseFirst() + #expect(await save.value == false) + #expect(model.snapshot.settings == persisted) + #expect(client.savedSettings == [persisted]) + } + + @Test + func orderedSettingsWritesPreserveNewerValues() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { await gate.enter() } + let model = testRootModel(client: client) + var first = model.snapshot.settings + first.hapticsEnabled = false + + let firstSave = Task { await model.saveSettings(first) } + await gate.waitUntilCallCount(1) + let secondSave = Task { + await model.saveTextSizes( + textSize: FeatureTextSizeAdjustment(steps: 2), + codeSize: FeatureTextSizeAdjustment(steps: -1) + ) + } + await Task.yield() + #expect(model.snapshot.settings.textSize.steps == 2) + gate.releaseFirst() + await gate.waitUntilCallCount(2) + + #expect(await firstSave.value) + #expect(await secondSave.value) + #expect(client.savedSettings.count == 2) + #expect(client.savedSettings[1].hapticsEnabled == false) + #expect(client.savedSettings[1].textSize.steps == 2) + } + + @Test + func twoFailedWritesRollbackToTheLastDurableSnapshot() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { + await gate.enter() + throw URLError(.cannotConnectToHost) + } + let model = testRootModel(client: client) + let persisted = model.snapshot.settings + var first = persisted + first.hapticsEnabled = false + + let firstSave = Task { await model.saveSettings(first) } + await gate.waitUntilCallCount(1) + let secondSave = Task { + await model.saveTextSizes( + textSize: FeatureTextSizeAdjustment(steps: 2), + codeSize: FeatureTextSizeAdjustment(steps: -1) + ) + } + await Task.yield() + #expect(model.snapshot.settings.textSize.steps == 2) + gate.releaseFirst() + await gate.waitUntilCallCount(2) + + #expect(await firstSave.value == false) + #expect(await secondSave.value == false) + #expect(model.snapshot.settings == persisted) + #expect(client.savedSettings.isEmpty) + + client.beforeSaveSettings = nil + #expect( + await model.saveTextSizes( + textSize: FeatureTextSizeAdjustment(steps: 1), + codeSize: FeatureTextSizeAdjustment(steps: -1) + ) + ) + #expect(model.snapshot.settings.textSize.steps == 1) + #expect(client.savedSettings.count == 1) + } + + @Test + func successfulSuccessorSupersedesFailedPredecessor() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { + await gate.enter() + if gate.callCount == 1 { throw URLError(.cannotConnectToHost) } + } + let model = testRootModel(client: client) + var first = model.snapshot.settings + first.hapticsEnabled = false + let successor = FeatureSettings( + textSize: FeatureTextSizeAdjustment(steps: 2), + codeSize: FeatureTextSizeAdjustment(steps: -1), + hapticsEnabled: false + ) + + let firstSave = Task { await model.saveSettings(first) } + await gate.waitUntilCallCount(1) + let secondSave = Task { await model.saveSettings(successor) } + await Task.yield() + #expect(model.snapshot.settings == successor) + gate.releaseFirst() + await gate.waitUntilCallCount(2) + + #expect(await firstSave.value == false) + #expect(await secondSave.value) + #expect(model.snapshot.settings == successor) + #expect(client.savedSettings == [successor]) + } + + @Test + func failedSuccessorRollsBackToSuccessfulPredecessor() async { + let gate = FeatureSettingsSaveGate() + let client = FeatureClientStub() + client.beforeSaveSettings = { + await gate.enter() + if gate.callCount == 2 { throw URLError(.cannotConnectToHost) } + } + let model = testRootModel(client: client) + var predecessor = model.snapshot.settings + predecessor.hapticsEnabled = false + var successor = predecessor + successor.textSize = FeatureTextSizeAdjustment(steps: 2) + + let firstSave = Task { await model.saveSettings(predecessor) } + await gate.waitUntilCallCount(1) + let secondSave = Task { await model.saveSettings(successor) } + await Task.yield() + #expect(model.snapshot.settings == successor) + gate.releaseFirst() + await gate.waitUntilCallCount(2) + + #expect(await firstSave.value) + #expect(await secondSave.value == false) + #expect(model.snapshot.settings == predecessor) + #expect(client.savedSettings == [predecessor]) + } + + @Test + func localSettingsRestoreOnFailedSaveAndApplyOnSuccess() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + var updated = model.snapshot.settings + updated.hapticsEnabled = false + client.beforeSaveSettings = { + throw URLError(.cannotConnectToHost) + } + + #expect(await model.saveSettings(updated) == false) + #expect(model.snapshot.settings.hapticsEnabled) + + client.beforeSaveSettings = nil + #expect(await model.saveSettings(updated)) + #expect(!model.snapshot.settings.hapticsEnabled) + } + + @Test + func backgroundRefreshUsesTheBoundedClientPath() async { + let client = FeatureClientStub() + client.backgroundSnapshotValue = FeatureSnapshot( + connection: .init(state: .connected, environmentName: "Remote") + ) + let model = testRootModel(client: client) + + let succeeded = await model.refreshInBackground() + + #expect(succeeded) + #expect(client.backgroundSnapshotCallCount == 1) + #expect(client.initialSnapshotCallCount == 0) + #expect(model.snapshot.connection.environmentName == "Remote") + } + + @Test + func savedServersKeepWorkspaceNavigationAvailableWhileDisconnected() { + let savedEnvironment = FeatureEnvironment( + id: "offline-demo", + name: "Offline demo", + endpoint: "https://offline.example", + connectionState: .disconnected + ) + let snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [savedEnvironment] + ) + + #expect( + FeatureRootPresentation.showsWorkspace( + snapshot: snapshot, + isManagingConnections: true + ) + ) + #expect( + FeatureRootPresentation.showsWorkspace( + snapshot: snapshot, + isManagingConnections: false + ) + ) + #expect( + FeatureRootPresentation.showsWorkspace( + snapshot: FeatureSnapshot(connection: .init(state: .disconnected)), + isManagingConnections: true + ) + ) + #expect( + !FeatureRootPresentation.showsWorkspace( + snapshot: FeatureSnapshot(connection: .init(state: .disconnected)), + isManagingConnections: false + ) + ) + } + + @Test + func disconnectEndsConnectionManagement() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected, + connectionDetail: "Healthy" + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + model.setConnectionManagementPresented(true) + + await model.disconnect() + + #expect(!model.isManagingConnections) + #expect(model.snapshot.connection.state == .disconnected) + #expect(model.snapshot.environments.first?.connectionState == .disconnected) + #expect(model.snapshot.environments.first?.connectionDetail == nil) + } + + @Test + func restoredFollowUpWaitsForItsQueuedThreadCreation() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-dependent-outbox-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let threadID = "environment-1::thread::queued-thread" + let creationIdentity = FeatureSubmissionIdentity( + threadID: "queued-thread", + commandID: "create-command", + messageID: "create-message", + createdAt: Date(timeIntervalSince1970: 1) + ) + let creation = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: creationIdentity, + threadID: threadID, + text: "Create the task", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + let followUp = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: .init( + threadID: "queued-thread", + commandID: "follow-up-command", + messageID: "follow-up-message", + createdAt: Date(timeIntervalSince1970: 2) + ), + threadID: threadID, + text: "And add tests", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + try await store.enqueue(creation) + try await store.enqueue(followUp) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.startTaskError = URLError(.timedOut) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + + await model.start() + await model.disconnect() + + let restoredIDs = try await store.submissions().map(\.id) + #expect(restoredIDs.count == 2) + #expect(Set(restoredIDs) == Set([creation.id, followUp.id])) + #expect(client.sendMessageCallCount == 0) + } + + @Test + func restoredCreationWaitsForItsFirstMessageEvenWhenTheThreadExists() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-partial-creation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity( + threadID: "created-thread", + commandID: "create-command", + messageID: "missing-message", + createdAt: Date(timeIntervalSince1970: 1) + ) + let threadID = "environment-1::thread::created-thread" + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: threadID, + text: "Do not lose the first message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + .init(data: Data([0x01]), name: "reference.png", mimeType: "image/png"), + ], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + try await store.enqueue(submission) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ], + threads: [ + .init( + id: threadID, + wireID: identity.threadID, + projectID: "project-1", + environmentID: "environment-1", + title: "Created without a message" + ), + ] + ) + client.startTaskError = URLError(.timedOut) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + + await model.start() + await model.disconnect() + + #expect(try await store.submissions() == [submission]) + } + + @Test + func offlineQueuedTaskKeepsItsProjectAvailableInNewTask() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-offline-picker-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let project = FeatureProject( + id: "project-1", environmentID: "environment-1", name: "Native", path: "/native", + repositoryIdentity: .init(canonicalKey: "github.com/example/native") + ) + let otherProject = FeatureProject( + id: "project-2", environmentID: "environment-2", name: "Native", path: "/other/native", + repositoryIdentity: .init(canonicalKey: "github.com/example/native") + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", name: "Studio", endpoint: "https://studio.example", + isActive: true, connectionState: .connected + ), + .init( + id: "environment-2", name: "Laptop", endpoint: "https://laptop.example", + connectionState: .connected + ), + ], + projects: [project, otherProject] + ) + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + let selected = try #require(DailyUXCreationContext.initialProject( + in: model.snapshot, requestedProjectID: project.id + )) + let draftKey = FeatureComposerDraftStore.newTaskKey(project: selected, in: model.snapshot) + let projectGroups = DailyUXCreationContext.projectGroups(in: model.snapshot) + + client.snapshot.connection.state = .disconnected + client.snapshot.environments[0].connectionState = .disconnected + client.startTaskError = URLError(.notConnectedToInternet) + await model.reload() + let retained = try #require(DailyUXCreationContext.projects(in: model.snapshot).first { + $0.id == selected.id + }) + + #expect(DailyUXCreationContext.projectGroups(in: model.snapshot) == projectGroups) + #expect(DailyUXCreationContext.initialProject( + in: model.snapshot, requestedProjectID: selected.id + )?.id == project.id) + #expect(FeatureComposerDraftStore.newTaskKey(project: retained, in: model.snapshot) == draftKey) + #expect(DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: selected.id, in: model.snapshot + ) == nil) + + let thread = try #require(await model.startTask(NewTaskRequest( + projectID: project.id, + prompt: "Keep this task until the computer reconnects", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard + ))) + let queued = try await store.submissions() + + #expect(queued.count == 1) + #expect(queued.first?.threadID == thread.id) + #expect(queued.first?.creation?.projectID == project.id) + #expect( + DailyUXCreationContext.projects(in: model.snapshot).contains { $0.id == project.id }, + "New Task must retain the project that its durable outbox can queue while offline." + ) + } + + @Test + func cancellingAnOfflineTaskRemovesItsDurableSubmission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cancel-queued-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.startTaskError = URLError(.notConnectedToInternet) + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + let thread = try #require(await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: "Cancel this task", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard + ) + )) + + await model.cancelTurn(threadID: thread.id) + + #expect(try await store.submissions().isEmpty) + #expect(!model.snapshot.threads.contains(where: { $0.id == thread.id })) + #expect(client.cancelTurnCallCount == 0) + } + + @Test + func cancellingARestoredServerThreadAlsoInterruptsItsTurn() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cancel-restored-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity(threadID: "created-thread") + let threadID = "environment-1::thread::created-thread" + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: threadID, + text: "Already running on the server", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + try await store.enqueue(submission) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ], + threads: [ + .init( + id: threadID, + wireID: identity.threadID, + projectID: "project-1", + environmentID: "environment-1", + title: "Already running", + state: .working + ), + ] + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + await model.start() + + await model.cancelTurn(threadID: threadID) + + #expect(client.cancelTurnCallCount == 1) + #expect(try await store.submissions().isEmpty) + #expect(model.snapshot.threads.contains(where: { $0.id == threadID })) + } + + @Test(arguments: [false, true]) + func cancellingAnAcknowledgedQueuedThreadInterruptsItsTurn( + acknowledgedBySnapshot: Bool + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cancel-acknowledged-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity(threadID: "acknowledged-thread") + let threadID = "environment-1::thread::acknowledged-thread" + try await store.enqueue( + FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: threadID, + text: "Accepted before the outbox cleared", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + ) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + let acknowledged = FeatureThread( + id: threadID, + wireID: identity.threadID, + projectID: "project-1", + environmentID: "environment-1", + title: "Accepted on the server", + state: .working + ) + if acknowledgedBySnapshot { + var snapshot = client.snapshot + snapshot.threads = [acknowledged] + client.emit(.snapshot(snapshot)) + } else { + client.emit(.thread(acknowledged)) + } + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + await model.start() + + await model.cancelTurn(threadID: threadID) + + #expect(client.cancelTurnCallCount == 1) + #expect(try await store.submissions().isEmpty) + #expect(model.snapshot.threads == [acknowledged]) + } + + @Test + func retryableCreationFailureReturnsTheAcknowledgedServerThread() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-acknowledged-creation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.startTaskError = URLError(.notConnectedToInternet) + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + client.beforeStartTask = { + var acknowledged = try #require(model.snapshot.threads.first) + acknowledged.title = "Accepted on the server" + acknowledged.state = .working + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.snapshot.threads.first(where: { $0.id == acknowledged.id })?.state + } onChange: { + continuation.resume() + } + client.emit(.thread(acknowledged)) + } + } + let run = Task { await model.start() } + + let thread = await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: "Create this task once", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard + ) + ) + client.finishEvents() + await run.value + + #expect(thread?.title == "Accepted on the server") + #expect(thread?.state == .working) + #expect(try await store.submissions().count == 1) + } + + @Test + func testPairReloadsConnectedSnapshot() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot(connection: .init(state: .disconnected)) + client.snapshotAfterPair = FeatureSnapshot( + connection: .init( + state: .connected, + environmentName: "Studio", + endpoint: "https://studio.example" + ) + ) + let oldThread = FeatureThread(id: "same-id", projectID: "old-project", title: "Old") + client.threadDetail = FeatureThreadDetail( + thread: oldThread, + messages: [FeatureMessage(id: "old-message", role: .assistant, text: "Old")] + ) + let model = testRootModel(client: client) + _ = await model.detail(for: oldThread.id) + + let result = await model.pair(endpoint: "https://studio.example", token: "pair-token") + + #expect(result) + #expect(client.pairEndpoint == "https://studio.example") + #expect(client.pairToken == "pair-token") + #expect(model.snapshot.connection.state == .connected) + #expect(model.details.isEmpty) + } + + @Test + func togglingConnectionRefreshesItsIndependentEnabledState() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isEnabled: true, + connectionState: .connected + ), + ] + ) + client.snapshotAfterEnvironmentToggle = FeatureSnapshot( + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isEnabled: false, + connectionState: .disconnected + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + + let toggled = await model.setEnvironmentEnabled("studio", enabled: false) + + #expect(toggled) + #expect(client.enabledEnvironmentID == "studio") + #expect(client.environmentEnabledValue == false) + #expect(model.snapshot.environments.first?.isEnabled == false) + } + + @Test + func removingAnEnvironmentClearsItsPhysicalAndGroupedDrafts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-draft-cleanup-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let outbox = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let project = FeatureProject( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native", + repositoryIdentity: FeatureRepositoryIdentity(canonicalKey: "github.com/t3/native") + ) + let physicalKey = "environment:environment-1:thread:one" + let logicalKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/native" + ) + let otherKey = "environment:environment-2:thread:two" + try await drafts.setDraft(FeatureComposerDraft(text: "remove physical"), for: physicalKey) + try await drafts.setDraft(FeatureComposerDraft(text: "remove logical"), for: logicalKey) + try await drafts.setDraft(FeatureComposerDraft(text: "keep"), for: otherKey) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example" + ), + ], + projects: [project] + ) + client.snapshotAfterEnvironmentRemoval = FeatureSnapshot() + let model = FeatureRootModel( + client: client, + outboxStore: outbox, + draftStore: drafts + ) + await model.reload() + + await model.removeEnvironment("environment-1") + + #expect(try await drafts.draft(for: physicalKey) == nil) + #expect(try await drafts.draft(for: logicalKey) == nil) + #expect(try await drafts.draft(for: otherKey)?.text == "keep") + } + + @Test + func removingAnEnvironmentClearsDraftsWhenOutboxCleanupFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cleanup-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let draftKey = "environment:environment-1:thread:one" + try await drafts.setDraft(FeatureComposerDraft(text: "Clear this draft"), for: draftKey) + let outbox = FeatureOutboxStore(fileURL: directory) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example" + ), + ] + ) + client.snapshotAfterEnvironmentRemoval = FeatureSnapshot() + let model = FeatureRootModel(client: client, outboxStore: outbox, draftStore: drafts) + await model.reload() + + await model.removeEnvironment("environment-1") + + #expect(try await drafts.draft(for: draftKey) == nil) + #expect(model.errorMessage?.contains("queued messages or drafts") == true) + } + + @Test + func signingOutClearsManagedOutboxEntriesAndGroupedDrafts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-sign-out-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let outbox = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let project = FeatureProject( + id: "project-1", + environmentID: "managed-1", + name: "Native", + path: "/native", + repositoryIdentity: FeatureRepositoryIdentity(canonicalKey: "github.com/t3/native") + ) + let groupedDraftKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/native" + ) + try await drafts.setDraft(FeatureComposerDraft(text: "Private prompt"), for: groupedDraftKey) + try await outbox.enqueue( + FeatureQueuedSubmission( + environmentID: "managed-1", + identity: FeatureSubmissionIdentity(), + threadID: "managed-1::thread::queued", + text: "Private queued message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + ) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "managed-1", + name: "Managed", + endpoint: "https://managed.example", + source: .t3Connect + ), + .init( + id: "manual-1", + name: "Manual", + endpoint: "https://manual.example" + ), + ], + projects: [project] + ) + let model = FeatureRootModel( + client: client, + outboxStore: outbox, + draftStore: drafts + ) + await model.reload() + + await model.signOutT3Connect() + + #expect(client.signOutCallCount == 1) + #expect(model.snapshot.environments.map(\.id) == ["manual-1"]) + #expect(try await outbox.submissions().isEmpty) + #expect(try await drafts.draft(for: groupedDraftKey) == nil) + } + + @Test + func signingOutPreservesGroupedDraftsUsedByDirectEnvironments() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-shared-draft-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let outbox = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureRepositoryIdentity(canonicalKey: "github.com/t3/native") + let groupedDraftKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: identity.canonicalKey + ) + let managedDraftKey = "environment:managed-1:thread:one" + try await drafts.setDraft(FeatureComposerDraft(text: "Keep shared prompt"), for: groupedDraftKey) + try await drafts.setDraft(FeatureComposerDraft(text: "Remove managed prompt"), for: managedDraftKey) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "managed-1", + name: "Managed", + endpoint: "https://managed.example", + source: .t3Connect + ), + .init( + id: "manual-1", + name: "Manual", + endpoint: "https://manual.example" + ), + ], + projects: [ + .init( + id: "managed-project", + environmentID: "managed-1", + name: "Native", + path: "/managed/native", + repositoryIdentity: identity + ), + .init( + id: "manual-project", + environmentID: "manual-1", + name: "Native", + path: "/manual/native", + repositoryIdentity: identity + ), + ] + ) + let model = FeatureRootModel(client: client, outboxStore: outbox, draftStore: drafts) + await model.reload() + + await model.signOutT3Connect() + + #expect(try await drafts.draft(for: groupedDraftKey)?.text == "Keep shared prompt") + #expect(try await drafts.draft(for: managedDraftKey) == nil) + #expect(model.snapshot.projects.map(\.id) == ["manual-project"]) + } + + @Test + func signingOutClearsDraftsWhenOutboxCleanupFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-sign-out-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let draftKey = "environment:managed-1:thread:one" + try await drafts.setDraft(FeatureComposerDraft(text: "Clear this draft"), for: draftKey) + let outboxURL = directory.appendingPathComponent("outbox.json") + let outbox = FeatureOutboxStore(fileURL: outboxURL) + let threadID = "managed-1::thread::queued" + try await outbox.enqueue( + FeatureQueuedSubmission( + environmentID: "managed-1", + identity: FeatureSubmissionIdentity(threadID: "queued"), + threadID: threadID, + text: "Private queued message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "managed-project", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + ) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "managed-1", + name: "Managed", + endpoint: "https://managed.example", + source: .t3Connect, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "managed-project", + environmentID: "managed-1", + name: "Native", + path: "/native" + ), + ] + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: outbox, draftStore: drafts) + await model.start() + #expect(model.snapshot.threads.contains(where: { $0.id == threadID })) + #expect(model.details[threadID] != nil) + try FileManager.default.removeItem(at: outboxURL) + try FileManager.default.createDirectory( + at: outboxURL, + withIntermediateDirectories: false + ) + + await model.signOutT3Connect() + + #expect(try await drafts.draft(for: draftKey) == nil) + #expect(model.snapshot.threads.isEmpty) + #expect(model.details.isEmpty) + #expect(model.errorMessage?.contains("Could not clear saved T3 Connect data") == true) + } + + @Test + func disconnectedPairDoesNotReportConnectionSuccess() async { + let client = FeatureClientStub() + client.snapshotAfterPair = FeatureSnapshot( + connection: .init( + state: .disconnected, + environmentName: "New studio", + endpoint: "https://new.example" + ) + ) + let model = testRootModel(client: client) + + let paired = await model.pair(endpoint: "https://new.example", token: "pair-token") + + #expect(!paired) + #expect(model.snapshot.connection.state == .disconnected) + #expect(model.errorMessage?.contains("Could not connect") == true) + } + + @Test + func testCreateThreadOptimisticallyUpsertsIt() async { + let client = FeatureClientStub() + let created = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Build native app", + providerID: "codex", + modelID: "gpt-5" + ) + client.createdThread = created + let model = testRootModel(client: client) + + let result = await model.createThread( + projectID: "project-1", + title: created.title, + selection: .init(providerID: "codex", modelID: "gpt-5") + ) + + #expect(result == created) + #expect(model.snapshot.threads == [created]) + } + + @Test + func testSendAddsQueuedMessageBeforeServerEvent() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread" + ) + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + let model = testRootModel(client: client) + await model.reload() + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: " ship it ", + selection: nil + ) + + #expect(sent) + #expect(client.sentText == "ship it") + #expect(model.details[thread.id]?.messages.last?.text == "ship it") + #expect(model.details[thread.id]?.messages.last?.state == .complete) + } + + @Test + func sendPreservesTheThreadAutomaticPermission() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread", + runtimeMode: .automatic + ) + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + let model = testRootModel(client: client) + await model.reload() + + let sent = await model.sendMessage( + threadID: thread.id, + text: "Use the saved permission", + selection: nil + ) + + #expect(sent) + #expect(client.sentRuntimeModes == [.automatic]) + } + + @Test + func runtimeModeUpdatesAfterSuccessAndStaysPutAfterFailure() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread", + runtimeMode: .fullAccess + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + await model.reload() + + await model.setRuntimeMode(thread.id, mode: .automatic) + + #expect(client.setRuntimeModeCalls == [.automatic]) + #expect(model.snapshot.threads.first?.runtimeMode == .automatic) + + client.runtimeModeError = FeatureCapabilityUnavailable("Permission update failed") + await model.setRuntimeMode(thread.id, mode: .fullAccess) + + #expect(client.setRuntimeModeCalls == [.automatic, .fullAccess]) + #expect(model.snapshot.threads.first?.runtimeMode == .automatic) + } + + @Test + func restoredOutboxRetryPreservesAutomaticPermission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-permission-retry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let thread = FeatureThread( + id: "thread-1", + wireID: "thread-wire", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread", + runtimeMode: .fullAccess + ) + try await store.enqueue(FeatureQueuedSubmission( + environmentID: "environment-1", + identity: .init(threadID: "thread-wire"), + threadID: thread.id, + text: "Retry with Automatic", + selection: nil, + runtimeMode: .automatic, + interactionMode: .standard, + attachments: [] + )) + let delivery = AsyncStream.makeStream() + let client = FeatureClientStub() + client.beforeSendMessage = { delivery.continuation.yield() } + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + + await model.start() + _ = await delivery.stream.first { _ in true } + delivery.continuation.finish() + await model.disconnect() + + #expect(client.sentRuntimeModes == [.automatic]) + } + + @Test + func loadingEarlierTurnsPrependsHistoryAndClearsTheCursor() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Long thread" + ) + let recent = FeatureMessage( + id: "message-recent", + role: .assistant, + text: "Recent", + createdAt: Date(timeIntervalSince1970: 2) + ) + let older = FeatureMessage( + id: "message-older", + role: .user, + text: "Older", + createdAt: Date(timeIntervalSince1970: 1) + ) + client.threadDetail = FeatureThreadDetail( + thread: thread, + messages: [recent], + page: FeatureThreadPage(beforeCursor: "cursor-1", hasMore: true) + ) + client.earlierThreadDetail = FeatureThreadDetail( + thread: thread, + messages: [older, recent], + page: FeatureThreadPage(beforeCursor: nil, hasMore: false) + ) + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + await model.loadEarlierTurns(for: thread.id) + + #expect(model.details[thread.id]?.messages.map(\.id) == [older.id, recent.id]) + #expect(model.details[thread.id]?.page?.hasMore == false) + #expect(client.loadEarlierCallCount == 1) + } + + @Test + func failedDiscardKeepsTheDurableAndOptimisticSubmission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-discard-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + client.beforeSendMessage = { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + } + client.sendMessageError = FeatureCapabilityUnavailable("Rejected message") + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: "Keep this queued", + selection: nil + ) + + #expect(!sent) + #expect(try await store.submissions().count == 1) + #expect(model.details[thread.id]?.messages.last?.text == "Keep this queued") + #expect(model.details[thread.id]?.messages.last?.state == .queued) + } + + @Test + func failedDeliveryCleanupKeepsTheDurableAndOptimisticSubmission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-completion-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + client.beforeSendMessage = { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + } + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: "Already delivered", + selection: nil + ) + + #expect(sent) + #expect(client.sendMessageCallCount == 1) + #expect(try await store.submissions().count == 1) + #expect(model.details[thread.id]?.messages.last?.text == "Already delivered") + #expect(model.details[thread.id]?.messages.last?.state == .queued) + #expect(model.errorMessage?.contains("delivered") == true) + } + + @Test + func failedEnvironmentOutboxCleanupKeepsPendingState() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-environment-cleanup-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity( + threadID: "queued-thread", + commandID: "queued-command", + messageID: "queued-message" + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: "environment-1::thread::queued-thread", + text: "Create from the outbox", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + try await store.enqueue(submission) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.snapshotAfterEnvironmentRemoval = FeatureSnapshot( + connection: .init(state: .disconnected) + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + await model.start() + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + await model.removeEnvironment("environment-1") + + #expect(client.removedEnvironmentID == "environment-1") + #expect(model.snapshot.environments.isEmpty) + #expect(model.snapshot.threads.contains(where: { $0.id == submission.threadID })) + #expect(try await store.submissions() == [submission]) + #expect(model.errorMessage?.contains("queued messages") == true) + } + + @Test + func testNewTaskStartsThreadAndFirstTurnAtomically() async { + let client = FeatureClientStub() + let created = FeatureThread( + id: "thread-atomic", + projectID: "project-1", + title: "Ship the native app", + providerID: "codex", + modelID: "gpt-5.6-sol" + ) + client.createdThread = created + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + let attachment = FeatureDraftAttachment( + data: Data([0xFF, 0xD8, 0xFF]), + filename: "reference.jpg", + mimeType: "image/jpeg" + ) + + let result = await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: " Ship the native app ", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: .worktree, + branch: "main", + startFromOrigin: true, + attachments: [attachment] + ) + ) + + #expect(result == created) + #expect(client.startedPrompt == "Ship the native app") + #expect(client.startedAttachments.map(\.name) == ["reference.jpg"]) + #expect(client.startedWorkspaceMode == .worktree) + #expect(client.startedBranch == "main") + #expect(client.startedWorktreePath == nil) + #expect(client.startedFromOrigin) + #expect(client.createThreadCallCount == 0) + #expect(client.sendMessageCallCount == 0) + #expect(model.snapshot.threads == [created]) + } + + @Test( + "New-task composer grows beyond two lines with a software-keyboard viewport", + .bug("https://github.com/saphid/t3code-personal/issues/105") + ) + func newTaskComposerGrowsWithSoftwareKeyboardViewport() async throws { + let project = FeatureProject( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [project], + providersByEnvironment: [ + "environment-1": [ + .init( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "Sol")] + ), + ], + ] + ) + let model = testRootModel(client: client) + await model.reload() + + let draftURL = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-new-task-keyboard-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: draftURL) } + let draftStore = FeatureComposerDraftStore(fileURL: draftURL) + let longDraft = (1...20).map { "Composer keyboard proof line \($0)" } + .joined(separator: "\n") + try await draftStore.setDraft( + FeatureComposerDraft(text: longDraft), + for: FeatureComposerDraftStore.newTaskKey(project: project) + ) + + let controller = UIHostingController( + rootView: NewThreadView( + model: model, + submit: { _ in nil }, + onCreated: { _ in }, + initialProjectID: project.id, + draftStore: draftStore + ) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 540)) + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + + var textInput: UIView? + for _ in 0..<30 { + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + textInput = firstMultilineTextInput(in: controller.view) + if textInputText(textInput) == longDraft { break } + try await Task.sleep(for: .milliseconds(10)) + } + + let input = try #require(textInput) + #expect(textInputText(input) == longDraft) + #expect( + input.bounds.height >= 100, + "Expected room for more than two visible lines; got \(input.bounds.height) points" + ) + let inputFrame = input.convert(input.bounds, to: window) + #expect( + inputFrame.maxY <= window.bounds.height - 44, + "The text editor overlaps the composer controls: editor frame \(inputFrame), viewport \(window.bounds)" + ) + } + + @Test + func testArchiveAndDeleteKeepLocalListsConsistent() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setArchived(thread.id, archived: true) + #expect(model.snapshot.threads[0].isArchived) + + await model.deleteThread(thread.id) + #expect(model.snapshot.threads.isEmpty) + } + + @Test + func activeThreadsCannotBeArchivedOrSettled() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Running task", + state: .working, + supportsSettlement: true + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + await model.reload() + + await model.setArchived(thread.id, archived: true) + + #expect(model.snapshot.threads.first?.isArchived == false) + #expect(model.errorMessage?.contains("still active") == true) + + model.errorMessage = nil + await model.setSettled(thread.id, settled: true) + + #expect(model.snapshot.threads.first?.isSettled == false) + #expect(model.errorMessage?.contains("needs attention") == true) + } + + @Test + func cachedThreadRefreshShowsLoadingThenRetryWithoutHidingMessages() async throws { + let client = FeatureClientStub() + let thread = FeatureThread(id: "cached", projectID: "project", title: "Cached thread") + let cached = FeatureThreadDetail( + thread: thread, + messages: [.init(id: "user", role: .user, text: "Do the task")] + ) + client.threadDetail = cached + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.loadThreadHandler = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + let refresh = Task { await model.detail(for: thread.id, force: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + #expect(model.detailLoadStates[thread.id] == .loading) + #expect(model.details[thread.id] == cached) + + response?.resume(throwing: URLError(.notConnectedToInternet)) + #expect(await refresh.value == cached) + guard case .failed = model.detailLoadStates[thread.id] else { + Issue.record("Expected an inline retry state for the cached thread") + return + } + #expect(model.errorMessage == nil) + #expect(model.details[thread.id]?.messages.first?.text == "Do the task") + + client.loadThreadHandler = nil + _ = await model.detail(for: thread.id, force: true) + #expect(model.detailLoadStates[thread.id] == nil) + } + + @Test + func cachedThreadRestoresAttachmentsBeforeItsNetworkRefreshFinishes() async throws { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "cached-draft", projectID: "project", environmentID: "one", + title: "Cached draft" + ) + let cached = FeatureThreadDetail(thread: thread) + client.threadDetail = cached + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-thread-draft-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let draftStore = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let attachment = FeatureDraftAttachment( + data: Data([1]), filename: "example.png", mimeType: "image/png" + ) + try await draftStore.setDraft( + FeatureComposerDraft(text: "Keep this draft", attachments: [attachment]), + for: FeatureComposerDraftStore.threadKey(thread) + ) + let model = FeatureRootModel( + client: client, + outboxStore: FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")), + draftStore: draftStore + ) + _ = await model.detail(for: thread.id) + + let loads = AsyncStream.makeStream() + let uploads = AsyncStream.makeStream() + var pendingLoad: CheckedContinuation? + client.loadThreadHandler = { _ in + try await withCheckedThrowingContinuation { continuation in + pendingLoad = continuation + loads.continuation.yield() + } + } + client.preuploadHandler = { value, environmentID in + #expect(environmentID == "one") + uploads.continuation.yield(value.id) + return nil + } + defer { + pendingLoad?.resume(returning: cached) + loads.continuation.finish() + uploads.continuation.finish() + } + + let controller = UIHostingController(rootView: ThreadDetailView( + model: model, thread: thread, submitMessage: { _ in false }, draftStore: draftStore + )) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 800)) + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + controller.view.layoutIfNeeded() + + var loadEvents = loads.stream.makeAsyncIterator() + await loadEvents.next() + var uploadEvents = uploads.stream.makeAsyncIterator() + #expect(await uploadEvents.next() == attachment.id) + #expect(pendingLoad != nil) + #expect(model.detailLoadStates[thread.id] == .loading) + #expect(model.details[thread.id] == cached) + } + + @Test(.serialized, arguments: [false, true]) + func freshThreadImageSavesAndUploadsAfterDraftRestore(afterFullScreenCover: Bool) async throws { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "fresh-image", projectID: "project", environmentID: "one", title: "Fresh image" + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [.init( + id: "one", name: "Studio", endpoint: "https://studio.example", + connectionState: .connected + )], + threads: [thread], + preferencesByEnvironment: ["one": .init(supportsImageUploads: true)] + ) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-fresh-image-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let draftStore = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let draftKey = FeatureComposerDraftStore.threadKey(thread) + let initialText = "Attach this screenshot" + try await draftStore.setDraft(FeatureComposerDraft(text: initialText), for: draftKey) + let model = FeatureRootModel( + client: client, + outboxStore: FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")), + draftStore: draftStore + ) + await model.reload() + _ = await model.detail(for: thread.id) + + let uploaded = XCTestExpectation(description: "Fresh attachment saved and upload started") + var uploadedAttachment: FeatureUploadAttachment? + client.preuploadHandler = { attachment, environmentID in + let saved = try await draftStore.draft(for: draftKey) + #expect(environmentID == "one") + #expect(saved?.text == initialText) + #expect(saved?.attachments.map(\.id) == [attachment.id]) + uploadedAttachment = attachment + uploaded.fulfill() + return nil + } + let presentation = ThreadImageCoverPresentation() + let controller = UIHostingController(rootView: ThreadImageTestHost( + detail: ThreadDetailView( + model: model, thread: thread, submitMessage: { _ in false }, draftStore: draftStore + ), + presentation: presentation + )) + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let scene = try #require(scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first) + let previousKeyWindow = scene.windows.first(where: \.isKeyWindow) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: 402, height: 800) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + controller.dismiss(animated: false) + window.isHidden = true + previousKeyWindow?.makeKey() + } + // A representable can update its text without laying out the hosting + // controller. Drive UI frames until the restored editor is visible. + var renderedInput: UIView? + let layoutDeadline = ContinuousClock.now.advanced(by: .seconds(2)) + repeat { + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + renderedInput = firstMultilineTextInput(in: controller.view) + if textInputText(renderedInput) == initialText { break } + try await Task.sleep(for: .milliseconds(16)) + } while ContinuousClock.now < layoutDeadline + try #require( + textInputText(renderedInput) == initialText, + "Editor after layout: \(String(describing: textInputText(renderedInput)))" + ) + let input = try #require(renderedInput as? FeatureComposerUITextView) + let pasteImages = try #require(input.onPasteImages) + let imageFormat = UIGraphicsImageRendererFormat() + imageFormat.opaque = true + let image = UIGraphicsImageRenderer( + size: CGSize(width: 180, height: 320), format: imageFormat + ).image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 180, height: 320)) + } + let attachImage = { pasteImages([NSItemProvider(object: image)]) } + + if afterFullScreenCover { + presentation.onDismiss = attachImage + presentation.isPresented = true + try #require(await XCTWaiter.fulfillment(of: [presentation.appeared], timeout: 2) == .completed) + presentation.isPresented = false + try #require(await XCTWaiter.fulfillment(of: [presentation.dismissed], timeout: 2) == .completed) + } else { + attachImage() + } + try #require(await XCTWaiter.fulfillment(of: [uploaded], timeout: 2) == .completed) + let attachment = try #require(uploadedAttachment) + #expect(attachment.byteCount > 0) + #expect(attachment.mimeType.hasPrefix("image/")) + #expect(try await draftStore.draft(for: draftKey)?.attachments.map(\.id) == [attachment.id]) + } + + @Test + func threadRefreshPresentationShowsConnectionLossEvenWithCachedContent() { + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: true + ) == .loading) + #expect(ThreadRefreshPresentation.resolve( + loadState: .loading, connectionState: .connected, isOpening: false + ) == .loading) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .reconnecting, isOpening: false + ) == .reconnecting) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .disconnected, isOpening: false + ) == .offline) + #expect(ThreadRefreshPresentation.resolve( + loadState: .failed("Offline"), connectionState: .connected, isOpening: false + ) == .failed) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: false + ) == nil) + #expect(ThreadRefreshPresentation.failed.canRetry) + #expect(!ThreadRefreshPresentation.loading.canRetry) + } + + @Test + func testCancelledDetailRefreshKeepsCachedContentWithoutAlert() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let detail = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage(id: "message-1", role: .assistant, text: "Still here"), + ] + ) + client.threadDetail = detail + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.loadThreadError = CancellationError() + + let refreshed = await model.detail(for: thread.id, force: true) + + #expect(refreshed == detail) + #expect(model.errorMessage == nil) + #expect(model.detailLoadStates[thread.id] == nil) + } + + @Test + func testResnoozeRefreshesTheOptimisticSnoozeTimestamp() async { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread", + state: .failed + ) + let oldSnooze = Date.now.addingTimeInterval(-600) + thread.snoozedAt = oldSnooze + thread.attentionAt = Date.now.addingTimeInterval(-300) + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setSnoozed( + thread.id, + until: Date.now.addingTimeInterval(3_600) + ) + + let updated = model.snapshot.threads[0] + #expect(updated.snoozedAt != oldSnooze) + #expect(updated.snoozedAt! > updated.attentionAt!) + } + + @Test + func testPinOptimisticallyWakesWithoutInventingSettlementOverride() async { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread", + isSettled: true, + settledAt: .now, + snoozedUntil: Date.now.addingTimeInterval(3_600), + snoozedAt: .now + ) + thread.supportsPinning = true + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setPinned(thread.id, pinned: true) + + let updated = model.snapshot.threads[0] + #expect(updated.pinnedAt != nil) + #expect(updated.isSettled) + #expect(!updated.keepsActive) + #expect(updated.settledAt != nil) + #expect(updated.snoozedUntil == nil) + } + + @Test + func testPinDoesNotMakeAnOrdinaryThreadPermanentlyActive() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread" + ) + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setPinned(thread.id, pinned: true) + await model.setPinned(thread.id, pinned: false) + + let updated = model.snapshot.threads[0] + #expect(updated.pinnedAt == nil) + #expect(!updated.keepsActive) + } + + @Test + func failedSettlementKeepsNewerActivityFacts() async { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread", + supportsSettlement: true, + settlementFacts: FeatureThreadSettlementFacts() + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + client.settlementError = URLError(.notConnectedToInternet) + let model = testRootModel(client: client) + await model.reload() + + client.beforeSettlementReturn = { + thread.settlementFacts?.sessionStatus = "running" + thread.settlementFacts?.hasPendingApprovals = true + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.snapshot.threads.first?.settlementFacts?.sessionStatus + } onChange: { + continuation.resume() + } + client.emit(.thread(thread)) + } + } + let run = Task { await model.start() } + + #expect(!(await model.setSettled(thread.id, settled: true))) + client.finishEvents() + await run.value + + guard let restored = model.snapshot.threads.first else { + Issue.record("Expected the thread after settlement rollback") + return + } + #expect(restored.settlementFacts?.settlementOverride == nil) + #expect(restored.settlementFacts?.sessionStatus == "running") + #expect(restored.settlementFacts?.hasPendingApprovals == true) + } + + @Test + func testResolveUserInputForwardsTypedAnswersAndClearsTheRequest() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let request = FeatureUserInput( + id: "request-1", + threadID: thread.id, + questions: [] + ) + client.threadDetail = FeatureThreadDetail(thread: thread, userInputs: [request]) + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + let answers: [String: FeatureInputAnswer] = [ + "scope": .selections(["Server", "Web"]), + "note": .text("Ship it"), + ] + await model.resolveUserInput(request.id, answers: answers) + + #expect(client.resolvedInputID == request.id) + #expect(client.resolvedInputAnswers == answers) + #expect(model.details[thread.id]?.userInputs.isEmpty == true) + } + + @Test + func granularThreadEventsMaintainCountsAndCollectionRevision() async { + let client = FeatureClientStub() + let project = FeatureProject( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ) + client.snapshot = FeatureSnapshot(projects: [project]) + let model = testRootModel(client: client) + let thread = FeatureThread( + id: "thread-1", + projectID: project.id, + title: "Stream deltas" + ) + + let run = Task { await model.start() } + client.emit(.thread(thread)) + client.emit(.thread(thread)) + client.emit(.threadRemoved(id: thread.id)) + let connected = FeatureConnection(state: .connected, environmentName: "Native") + client.emit(.connection(connected)) + client.emit(.connection(connected)) + client.finishEvents() + await run.value + + #expect(model.snapshot.threads.isEmpty) + #expect(model.snapshot.projects[0].threadCount == 0) + #expect(model.threadCollectionRevision == 2) + #expect(model.homePresentationRevision == 4) + } + + @Test + func initialDetailLoadDoesNotOverwriteNewerLiveUpdate() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let initial = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let live = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Live")] + ) + client.threadDetail = initial + let model = testRootModel(client: client) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id] + } onChange: { + continuation.resume() + } + client.emit(.detail(live)) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded == live) + #expect(model.details[thread.id] == live) + } + + @Test( + "Later overlapping detail load wins for either completion order", + .bug("https://github.com/pingdotgg/t3code/pull/7206#discussion_r3816827717"), + arguments: [[1, 2], [2, 1]] + ) + func laterOverlappingDetailLoadWins(completionOrder: [Int]) async throws { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let initial = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let refreshed = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Refreshed")] + ) + let loadStarted = AsyncStream.makeStream() + var loadIndex = 0 + var loadContinuations: [Int: CheckedContinuation] = [:] + defer { + loadStarted.continuation.finish() + for continuation in loadContinuations.values { + continuation.resume(returning: refreshed) + } + } + client.loadThreadHandler = { _ in + loadIndex += 1 + let index = loadIndex + loadStarted.continuation.yield(index) + return await withCheckedContinuation { continuation in + loadContinuations[index] = continuation + } + } + let model = testRootModel(client: client) + var starts = loadStarted.stream.makeAsyncIterator() + + let initialLoad = Task { await model.detail(for: thread.id, force: true) } + let firstStart = await starts.next() + #expect(firstStart == 1) + let refresh = Task { await model.detail(for: thread.id, force: true) } + let secondStart = await starts.next() + #expect(secondStart == 2) + + for index in completionOrder { + let pendingContinuation = loadContinuations.removeValue(forKey: index) + let continuation = try #require(pendingContinuation) + continuation.resume(returning: index == 1 ? initial : refreshed) + if index == 1 { + _ = await initialLoad.value + if completionOrder.first == 1 { + #expect(model.detailLoadStates[thread.id] == .loading) + } + } else { + _ = await refresh.value + #expect(model.detailLoadStates[thread.id] == nil) + } + } + + let expectedInitialResult = completionOrder.first == 1 ? initial : refreshed + #expect(await initialLoad.value == expectedInitialResult) + #expect(await refresh.value == refreshed) + #expect(model.details[thread.id] == refreshed) + } + + @Test( + "Pagination does not cancel an overlapping detail refresh", + .bug("https://github.com/pingdotgg/t3code/pull/7206#discussion_r3816827717") + ) + func paginationDoesNotCancelOverlappingDetailRefresh() async throws { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let cached = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Cached")], + page: FeatureThreadPage(beforeCursor: "cursor-1", hasMore: true) + ) + let paginated = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage(id: "message-1", role: .user, text: "Earlier"), + cached.messages[0], + ], + page: FeatureThreadPage(beforeCursor: nil, hasMore: false) + ) + let refreshed = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-3", role: .assistant, text: "Refreshed")] + ) + client.threadDetail = cached + client.earlierThreadDetail = paginated + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + let loadStarted = AsyncStream.makeStream() + var refreshContinuation: CheckedContinuation? + defer { + loadStarted.continuation.finish() + refreshContinuation?.resume(returning: refreshed) + } + client.loadThreadHandler = { _ in + loadStarted.continuation.yield(()) + return await withCheckedContinuation { continuation in + refreshContinuation = continuation + } + } + var starts = loadStarted.stream.makeAsyncIterator() + + let refresh = Task { await model.detail(for: thread.id, force: true) } + _ = await starts.next() + await model.loadEarlierTurns(for: thread.id) + let continuation = try #require(refreshContinuation) + refreshContinuation = nil + continuation.resume(returning: refreshed) + + #expect(await refresh.value == refreshed) + #expect(model.details[thread.id]?.messages == refreshed.messages) + } + + @Test + func initialDetailLoadDoesNotRestoreRemovedThread() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let model = testRootModel(client: client) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.detailRevisions[thread.id] + } onChange: { + continuation.resume() + } + client.emit(.threadRemoved(id: thread.id)) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded == nil) + #expect(model.details[thread.id] == nil) + #expect(model.snapshot.threads.isEmpty) + } + + @Test + func initialDetailLoadMergesLatestThreadMetadata() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let intermediateThread = FeatureThread( + id: thread.id, + projectID: thread.projectID, + title: "Intermediate" + ) + let cached = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Cached")] + ) + let refreshed = FeatureThreadDetail( + thread: intermediateThread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Refreshed")] + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = cached + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.threadDetail = refreshed + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.thread(intermediateThread)) + } + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.thread(thread)) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == thread) + #expect(loaded?.messages == refreshed.messages) + #expect(model.details[thread.id] == loaded) + #expect(model.snapshot.threads == [thread]) + } + + @Test + func initialDetailLoadKeepsMetadataFromThreadCreatedDuringLoad() async { + let client = FeatureClientStub() + let original = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let live = FeatureThread(id: original.id, projectID: original.projectID, title: "Live") + let created = FeatureThread(id: original.id, projectID: original.projectID, title: "Created") + client.snapshot = FeatureSnapshot(threads: [original]) + client.threadDetail = FeatureThreadDetail(thread: original) + let model = testRootModel(client: client) + _ = await model.detail(for: original.id) + let run = Task { await model.start() } + client.createdThread = created + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[original.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.thread(live)) + } + _ = await model.createThread(projectID: original.projectID, title: nil, selection: nil) + } + + let loaded = await model.detail(for: original.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == created) + #expect(model.details[original.id]?.thread == created) + #expect(model.snapshot.threads == [created]) + } + + @Test + func duplicateThreadEventDuringRefreshDoesNotDiscardLoadedMetadata() async { + let client = FeatureClientStub() + let original = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let refreshed = FeatureThread(id: original.id, projectID: original.projectID, title: "Refreshed") + client.snapshot = FeatureSnapshot(threads: [original]) + client.threadDetail = FeatureThreadDetail(thread: original) + let model = testRootModel(client: client) + _ = await model.detail(for: original.id) + client.threadDetail = FeatureThreadDetail(thread: refreshed) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.snapshot.connection + } onChange: { + continuation.resume() + } + client.emit(.thread(original)) + client.emit(.connection(.init(state: .connected))) + } + } + + let loaded = await model.detail(for: original.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == refreshed) + #expect(model.details[original.id]?.thread == refreshed) + #expect(model.snapshot.threads == [refreshed]) + } + + @Test + func initialDetailLoadDoesNotRestoreResolvedApproval() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let approval = FeatureApproval( + id: "approval-1", + threadID: thread.id, + kind: .command, + title: "Run command", + detail: "swift test" + ) + let stale = FeatureThreadDetail(thread: thread, approvals: [approval]) + client.threadDetail = stale + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.beforeLoadThreadReturn = { + await model.resolveApproval(approval.id, decision: .allowOnce) + } + + let loaded = await model.detail(for: thread.id, force: true) + + #expect(loaded?.approvals.isEmpty == true) + #expect(model.details[thread.id]?.approvals.isEmpty == true) + } + + @Test + func initialDetailLoadDoesNotRestoreThreadRemovedBySnapshot() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let model = testRootModel(client: client) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.detailRevisions[thread.id] + } onChange: { + continuation.resume() + } + client.emit(.snapshot(FeatureSnapshot())) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded == nil) + #expect(model.details[thread.id] == nil) + #expect(model.snapshot.threads.isEmpty) + } + + @Test + func initialDetailLoadMergesLatestSnapshotMetadata() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let intermediateThread = FeatureThread( + id: thread.id, + projectID: thread.projectID, + title: "Intermediate" + ) + let cached = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Cached")] + ) + let refreshed = FeatureThreadDetail( + thread: intermediateThread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Refreshed")] + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = cached + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.threadDetail = refreshed + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.snapshot(FeatureSnapshot(threads: [intermediateThread]))) + } + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.snapshot(FeatureSnapshot(threads: [thread]))) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == thread) + #expect(loaded?.messages == refreshed.messages) + #expect(model.details[thread.id] == loaded) + #expect(model.snapshot.threads == [thread]) + } + + @Test + func environmentScopedCatalogAndPreferencesInvalidateHomePresentation() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + client.snapshot = FeatureSnapshot( + providersByEnvironment: [ + "studio": [ + .init( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "Sol")] + ), + ], + ] + ) + + await model.reload() + let catalogRevision = model.homePresentationRevision + #expect(catalogRevision == 1) + + client.snapshot.preferencesByEnvironment = [ + "studio": .init(defaultWorkspaceMode: .worktree), + ] + await model.reload() + + #expect(model.homePresentationRevision == catalogRevision + 1) + } + + @Test + func providerRefreshRoutesOnlyToChosenEnvironment() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot(providersByEnvironment: [ + "left": [.init(id: "old-left", name: "Old left")], + "right": [.init(id: "old-right", name: "Old right")], + ]) + client.refreshedProviders = [.init(id: "new-left", name: "New left")] + let model = testRootModel(client: client) + await model.reload() + + let didRefresh = await model.refreshProviders(environmentID: "left") + + #expect(didRefresh) + #expect(client.refreshedProviderEnvironmentID == "left") + #expect(model.snapshot.providersByEnvironment?["left"] == client.refreshedProviders) + #expect( + model.snapshot.providersByEnvironment?["right"] + == [.init(id: "old-right", name: "Old right")] + ) + } + + @Test + func automaticSettlementUpdatesRemainEnvironmentScopedAndFailuresKeepVisibleValues() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + preferencesByEnvironment: [ + "left": .init( + automaticSettlement: .init(onMerge: true, afterDays: 3) + ), + "right": .init( + automaticSettlement: .init(onMerge: false, afterDays: 7.5) + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + + client.automaticSettlementResult = .init(onMerge: false, afterDays: 3) + let didUpdate = await model.updateAutomaticSettlement( + environmentID: "left", + change: .onMerge(false) + ) + + #expect(didUpdate) + #expect(client.automaticSettlementEnvironmentID == "left") + #expect(client.automaticSettlementChange == .onMerge(false)) + #expect( + model.snapshot.preferencesByEnvironment?["left"]?.automaticSettlement + == FeatureAutomaticSettlementSettings(onMerge: false, afterDays: 3) + ) + #expect( + model.snapshot.preferencesByEnvironment?["right"]?.automaticSettlement + == FeatureAutomaticSettlementSettings(onMerge: false, afterDays: 7.5) + ) + + client.automaticSettlementError = FeatureCapabilityUnavailable( + "Automatic settlement settings" + ) + let didFail = await model.updateAutomaticSettlement( + environmentID: "right", + change: .afterDays(nil) + ) + + #expect(!didFail) + #expect( + model.snapshot.preferencesByEnvironment?["right"]?.automaticSettlement + == FeatureAutomaticSettlementSettings(onMerge: false, afterDays: 7.5) + ) + #expect( + model.errorMessage + == "Automatic settlement settings is not supported by this environment." + ) + } + + @Test + func stalePullRequestResponseCannotReplaceANewBranchIdentity() async throws { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "studio", + title: "Task", + branch: "feature/old", + worktreePath: "/repo" + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + await model.reload() + + let oldIdentity = try #require(thread.pullRequestObservationIdentity) + model.updatePullRequest( + HomeThreadPullRequestPresentation(number: 1, state: .merged, updatedAt: .now), + threadID: thread.id, + observationIdentity: oldIdentity + ) + #expect(model.pullRequestsByThreadID[thread.id]?.number == 1) + + thread.branch = "feature/new" + client.snapshot.threads = [thread] + await model.reload() + #expect(model.pullRequestsByThreadID[thread.id] == nil) + + model.updatePullRequest( + HomeThreadPullRequestPresentation(number: 1, state: .closed, updatedAt: .now), + threadID: thread.id, + observationIdentity: oldIdentity + ) + #expect(model.pullRequestsByThreadID[thread.id] == nil) + } + + @Test + func responseTimeoutKeepsDurableSubmissionQueued() { + let snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ] + ) + + #expect( + FeatureRootModel.shouldQueue( + RPCError.responseTimedOut, + environmentID: "studio", + snapshot: snapshot + ) + ) + } + + @Test + func detailEventsIgnoreDuplicatesAndAdvancePerThreadRevision() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Stream transcript" + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let first = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Hel")] + ) + let second = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage(id: "message-1", role: .assistant, text: "Hello"), + FeatureMessage(id: "message-2", role: .user, text: "Ship it"), + ] + ) + let model = testRootModel(client: client) + + let run = Task { await model.start() } + client.emit(.detail(first)) + client.emit(.detail(first)) + client.emit(.detail(second)) + client.finishEvents() + await run.value + + #expect(model.details[thread.id] == second) + #expect(model.detailRevision == 2) + #expect(model.detailRevisions[thread.id] == 2) + } + + @Test + func authoritativeAttachmentRetainsLocalPreviewUntilURLHydrates() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Image preview" + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let preview = Data([0x01, 0x02, 0x03]) + let local = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage( + id: "message-1", + role: .user, + text: "See image", + attachments: [ + FeatureMessageAttachment( + id: "local-attachment", + name: "image.jpg", + mimeType: "image/jpeg", + sizeBytes: 3, + previewData: preview + ), + ] + ), + ] + ) + let authoritative = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage( + id: "message-1", + role: .user, + text: "See image", + attachments: [ + FeatureMessageAttachment( + id: "server-attachment", + name: "image.jpg", + mimeType: "image/jpeg", + sizeBytes: 3 + ), + ] + ), + ] + ) + let model = testRootModel(client: client) + + let run = Task { await model.start() } + client.emit(.detail(local)) + client.emit(.detail(authoritative)) + client.finishEvents() + await run.value + + #expect(model.details[thread.id]?.messages[0].attachments[0].id == "server-attachment") + #expect(model.details[thread.id]?.messages[0].attachments[0].previewData == preview) + } + + @Test + func detailDeltaCarriesAContiguousRenderCursor() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Incremental transcript" + ) + let firstMessage = FeatureMessage(id: "message-1", role: .assistant, text: "Hel") + let completedMessage = FeatureMessage(id: "message-1", role: .assistant, text: "Hello") + let appendedMessage = FeatureMessage(id: "message-2", role: .user, text: "Ship it") + let first = FeatureThreadDetail(thread: thread, messages: [firstMessage]) + let second = FeatureThreadDetail( + thread: thread, + messages: [completedMessage, appendedMessage] + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + + let run = Task { await model.start() } + client.emit(.detail(first)) + client.emit(.detailDelta( + second, + FeatureDetailDelta( + changedMessages: [completedMessage, appendedMessage], + appendedMessageIDs: [appendedMessage.id] + ) + )) + client.finishEvents() + await run.value + + #expect(model.details[thread.id] == second) + #expect(model.detailRevisions[thread.id] == 2) + guard let update = model.detailRenderUpdates[thread.id] else { + Issue.record("Expected an incremental render update") + return + } + #expect(update.baseRevision == 1) + #expect(update.revision == 2) + guard case let .delta(delta) = update.change else { + Issue.record("Expected a detail delta") + return + } + #expect(delta.appendedMessageIDs == [appendedMessage.id]) + #expect(delta.changedMessages == [completedMessage, appendedMessage]) + } + + @Test + func detailReducerAppendsStreamingTailAndExposesRenderMutation() { + let startedAt = "2026-07-31T20:00:00Z" + let message = OrchestrationMessage( + id: "message-1", + role: "assistant", + text: "Hel", + attachments: nil, + turnId: "turn-1", + streaming: true, + createdAt: startedAt, + updatedAt: startedAt + ) + let thread = orchestrationThread(messages: [message]) + let event = orchestrationEvent( + type: "thread.message-sent", + sequence: 12, + payload: [ + "threadId": .string(thread.id), + "messageId": .string(message.id), + "role": .string("assistant"), + "text": .string("lo"), + "turnId": .string("turn-1"), + "streaming": .bool(true), + "createdAt": .string(startedAt), + "updatedAt": .string("2026-07-31T20:00:01Z"), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + #expect(reduction.sequence == 12) + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected a streaming message update") + return + } + #expect(updated.messages[0].text == "Hello") + #expect(updated.messages[0].updatedAt == startedAt) + guard case let .message(rendered) = reduction.renderMutation else { + Issue.record("Expected a message-only render mutation") + return + } + #expect(rendered.text == "Hello") + } + + @Test + func detailReducerBindsCheckpointThatArrivedBeforeAssistantMessage() { + let checkpoint = CheckpointSummary( + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoint-1", + status: "completed", + files: [], + assistantMessageId: nil, + completedAt: "2026-07-31T20:00:01Z" + ) + let thread = orchestrationThread(checkpoints: [checkpoint]) + let event = orchestrationEvent( + type: "thread.message-sent", + sequence: 12, + payload: [ + "threadId": .string(thread.id), + "messageId": .string("assistant-1"), + "role": .string("assistant"), + "text": .string("Done"), + "turnId": .string("turn-1"), + "streaming": .bool(false), + "createdAt": .string("2026-07-31T20:00:00Z"), + "updatedAt": .string("2026-07-31T20:00:02Z"), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected an assistant message update") + return + } + #expect(updated.checkpoints.first?.assistantMessageId == "assistant-1") + } + + @Test + func activityReducerKeepsLargeSnapshotHistorySharedAndExposesOnlyTheTail() throws { + let historical = (0..<1_000).map { (index: Int) in + OrchestrationActivity( + id: "history-\(index)", + tone: "info", + kind: "tool.completed", + summary: "Historical work", + payload: .object([:]), + turnId: "turn-1", + sequence: index, + createdAt: "2026-07-31T20:00:00Z" + ) + } + let appended = OrchestrationActivity( + id: "activity-new", + tone: "info", + kind: "tool.completed", + summary: "New work", + payload: .object([:]), + turnId: "turn-1", + sequence: historical.count, + createdAt: "2026-07-31T20:00:01Z" + ) + let thread = orchestrationThread(activities: historical) + let event = orchestrationEvent( + type: "thread.activity-appended", + sequence: 1_001, + payload: [ + "threadId": .string(thread.id), + "activity": try JSONValue.encode(appended), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected an activity update") + return + } + #expect(updated.activities.count == historical.count) + guard case let .activity(rendered) = reduction.renderMutation else { + Issue.record("Expected an activity-tail render mutation") + return + } + #expect(rendered == appended) + } + + @Test + func destructiveDetailEventRequestsAuthoritativeSnapshot() { + let thread = orchestrationThread() + let event = orchestrationEvent( + type: "thread.reverted", + sequence: 3, + payload: ["threadId": .string(thread.id)] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + #expect(reduction.result == .refresh) + #expect(reduction.renderMutation == .full) + } + + @Test + func detailReducerAppliesServerSettlementEvents() { + let thread = orchestrationThread() + let settled = orchestrationEvent( + type: "thread.settled", + sequence: 4, + payload: [ + "threadId": .string(thread.id), + "settledAt": .string("2026-07-31T20:00:03Z"), + "updatedAt": .string("2026-07-31T20:00:03Z"), + ] + ) + + guard case let .updated(settledThread) = NativeThreadDetailReducer + .apply(settled, to: thread).result else { + Issue.record("Expected a settled thread") + return + } + #expect(settledThread.settledOverride == "settled") + #expect(settledThread.settledAt == "2026-07-31T20:00:03Z") + #expect(settledThread.unsettledAt == nil) + + let unsettled = orchestrationEvent( + type: "thread.unsettled", + sequence: 5, + payload: [ + "threadId": .string(thread.id), + "reason": .string("user"), + "updatedAt": .string("2026-07-31T20:00:04Z"), + ] + ) + guard case let .updated(activeThread) = NativeThreadDetailReducer + .apply(unsettled, to: settledThread).result else { + Issue.record("Expected an active thread") + return + } + #expect(activeThread.settledOverride == "active") + #expect(activeThread.settledAt == nil) + #expect(activeThread.unsettledAt == "2026-07-31T20:00:04Z") + + let activityReset = orchestrationEvent( + type: "thread.unsettled", + sequence: 6, + payload: [ + "threadId": .string(thread.id), + "reason": .string("activity"), + "updatedAt": .string("2026-07-31T20:00:05Z"), + ] + ) + guard case let .updated(resetThread) = NativeThreadDetailReducer + .apply(activityReset, to: activeThread).result else { + Issue.record("Expected an activity reset") + return + } + #expect(resetThread.settledOverride == nil) + #expect(resetThread.unsettledAt == "2026-07-31T20:00:04Z") + } + + @Test + func linkedPullRequestUpdatesDoNotReloadTheEntireThread() throws { + let thread = orchestrationThread() + let link = ThreadLinkedPullRequest( + projectId: thread.projectId, + repository: "pingdotgg/t3code", + number: 5178, + url: "https://github.com/pingdotgg/t3code/pull/5178" + ) + let event = orchestrationEvent( + type: "thread.meta-updated", + sequence: 7, + payload: [ + "threadId": .string(thread.id), + "linkedPullRequest": try JSONValue.encode(link), + "updatedAt": .string("2026-08-25T12:00:00Z"), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected the linked pull request to update without a full refresh") + return + } + #expect(updated.linkedPullRequest == link) + #expect(reduction.renderMutation == .metadata) + + let unlink = orchestrationEvent( + type: "thread.meta-updated", + sequence: 8, + payload: [ + "threadId": .string(thread.id), + "linkedPullRequest": .null, + "updatedAt": .string("2026-08-25T12:01:00Z"), + ] + ) + guard case let .updated(unlinked) = NativeThreadDetailReducer.apply(unlink, to: updated).result else { + Issue.record("Expected the pull request link to clear") + return + } + #expect(unlinked.linkedPullRequest == nil) + } +} + +@MainActor +@Observable +private final class ThreadImageCoverPresentation { + var isPresented = false + var onDismiss: (() -> Void)? + let appeared = XCTestExpectation(description: "Full-screen attachment flow appeared") + let dismissed = XCTestExpectation(description: "Full-screen attachment flow dismissed") +} + +private struct ThreadImageTestHost: View { + let detail: ThreadDetailView + @Bindable var presentation: ThreadImageCoverPresentation + + var body: some View { + detail.fullScreenCover(isPresented: $presentation.isPresented, onDismiss: { + presentation.onDismiss?() + presentation.dismissed.fulfill() + }) { + ThreadImageAppearanceProbe(onAppear: { presentation.appeared.fulfill() }) + } + } +} + +private struct ThreadImageAppearanceProbe: UIViewControllerRepresentable { + let onAppear: () -> Void + + func makeUIViewController(context: Context) -> Controller { + let controller = Controller() + controller.onAppear = onAppear + return controller + } + + func updateUIViewController(_ controller: Controller, context: Context) {} + + final class Controller: UIViewController { + var onAppear: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onAppear?() + onAppear = nil + } + } +} + +@MainActor +private func firstMultilineTextInput(in view: UIView) -> UIView? { + if view is UITextView || view is UITextField { + return view + } + for subview in view.subviews { + if let input = firstMultilineTextInput(in: subview) { + return input + } + } + return nil +} + +@MainActor +private func textInputText(_ view: UIView?) -> String? { + if let textView = view as? UITextView { + return textView.text + } + if let textField = view as? UITextField { + return textField.text + } + return nil +} + +@MainActor +private func testRootModel(client: FeatureClientStub) -> FeatureRootModel { + FeatureRootModel( + client: client, + outboxStore: FeatureOutboxStore( + fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-outbox-\(UUID().uuidString).json") + ) + ) +} + +private func orchestrationEvent( + type: String, + sequence: Int, + payload: [String: JSONValue] +) -> JSONValue { + .object([ + "type": .string(type), + "sequence": .number(Double(sequence)), + "occurredAt": .string("2026-07-31T20:00:02Z"), + "payload": .object(payload), + ]) +} + +private func orchestrationThread( + messages: [OrchestrationMessage] = [], + activities: [OrchestrationActivity] = [], + checkpoints: [CheckpointSummary] = [] +) -> OrchestrationThread { + OrchestrationThread( + id: "thread-1", + projectId: "project-1", + title: "Native detail stream", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "main", + worktreePath: "/native", + latestTurn: nil, + createdAt: "2026-07-31T20:00:00Z", + updatedAt: "2026-07-31T20:00:00Z", + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: messages, + activities: activities, + checkpoints: checkpoints, + session: nil + ) +} + +@MainActor +private final class FeatureClientStub: FeatureClient, T3ConnectCapable { + var foregroundReconnects: [Bool] = [] + func resumeAfterBackground(reconnect: Bool) async { foregroundReconnects.append(reconnect) } + private let eventStream: AsyncStream + private let eventContinuation: AsyncStream.Continuation + var snapshot = FeatureSnapshot() + var backgroundSnapshotValue: FeatureSnapshot? + var snapshotAfterEnvironmentToggle: FeatureSnapshot? + var initialSnapshotCallCount = 0 + var backgroundSnapshotCallCount = 0 + var snapshotAfterPair: FeatureSnapshot? + var snapshotAfterEnvironmentRemoval: FeatureSnapshot? + var createdThread = FeatureThread(id: "created", projectID: "project", title: "Created") + var threadDetail: FeatureThreadDetail? + var earlierThreadDetail: FeatureThreadDetail? + var pairEndpoint: String? + var pairToken: String? + var sentText: String? + var startedPrompt: String? + var startedAttachments: [FeatureUploadAttachment] = [] + var startedWorkspaceMode: FeatureWorkspaceMode? + var startedBranch: String? + var startedWorktreePath: String? + var startedFromOrigin = false + var createThreadCallCount = 0 + var sendMessageCallCount = 0 + var sentRuntimeModes: [FeatureRuntimeMode] = [] + var setRuntimeModeCalls: [FeatureRuntimeMode] = [] + var cancelTurnCallCount = 0 + var signOutCallCount = 0 + var startTaskError: (any Error)? + var sendMessageError: (any Error)? + var runtimeModeError: (any Error)? + var settlementError: (any Error)? + var beforeSettlementReturn: (() async -> Void)? + var enabledEnvironmentID: String? + var environmentEnabledValue: Bool? + var removedEnvironmentID: String? + var beforeStartTask: (() async throws -> Void)? + var beforeSendMessage: (() throws -> Void)? + var beforeSaveSettings: (@MainActor () async throws -> Void)? + var loadThreadError: (any Error)? + var loadThreadHandler: ((String) async throws -> FeatureThreadDetail)? + var preuploadHandler: ((FeatureUploadAttachment, String) async throws -> FeatureUploadedAttachmentReference?)? + var beforeLoadThreadReturn: (() async -> Void)? + var loadEarlierCallCount = 0 + var resolvedInputID: String? + var resolvedInputAnswers: [String: FeatureInputAnswer]? + var savedSettings: [FeatureSettings] = [] + var refreshedProviderEnvironmentID: String? + var refreshedProviders: [FeatureProvider] = [] + var automaticSettlementEnvironmentID: String? + var automaticSettlementChange: FeatureAutomaticSettlementChange? + var automaticSettlementResult = FeatureAutomaticSettlementSettings( + onMerge: true, + afterDays: 3 + ) + var automaticSettlementError: (any Error)? + lazy var t3ConnectController = T3ConnectController( + resolution: .unavailable(reason: "T3 Connect is disabled in feature tests.") + ) + + init() { + let pair = AsyncStream.makeStream() + eventStream = pair.stream + eventContinuation = pair.continuation + } + + func events() -> AsyncStream { + eventStream + } + + func emit(_ event: FeatureEvent) { + eventContinuation.yield(event) + } + + func finishEvents() { + eventContinuation.finish() + } + + func initialSnapshot() async throws -> FeatureSnapshot { + initialSnapshotCallCount += 1 + if removedEnvironmentID != nil, let snapshotAfterEnvironmentRemoval { + return snapshotAfterEnvironmentRemoval + } + if pairEndpoint != nil, let snapshotAfterPair { + return snapshotAfterPair + } + if enabledEnvironmentID != nil, let snapshotAfterEnvironmentToggle { + return snapshotAfterEnvironmentToggle + } + return snapshot + } + + func backgroundSnapshot() async throws -> FeatureSnapshot { + backgroundSnapshotCallCount += 1 + return backgroundSnapshotValue ?? snapshot + } + + func pair(endpoint: String, token: String?) async throws { + pairEndpoint = endpoint + pairToken = token + } + + func setEnvironmentEnabled(id: String, enabled: Bool) async throws { + enabledEnvironmentID = id + environmentEnabledValue = enabled + } + + func removeEnvironment(id: String) async throws { + removedEnvironmentID = id + } + + func connectT3Environment( + _ credential: T3ConnectManagedEnvironmentCredential + ) async throws {} + + func signOutT3Connect() async { + signOutCallCount += 1 + let removedIDs = Set(snapshot.environments.filter { $0.source == .t3Connect }.map(\.id)) + snapshot.environments.removeAll { removedIDs.contains($0.id) } + snapshot.projects.removeAll { removedIDs.contains($0.environmentID) } + snapshot.threads.removeAll { + $0.environmentID.map(removedIDs.contains) ?? false + } + } + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + createThreadCallCount += 1 + return createdThread + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + if let startTaskError { throw startTaskError } + startedPrompt = prompt + startedAttachments = attachments + return createdThread + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await beforeStartTask?() + if let startTaskError { throw startTaskError } + startedPrompt = prompt + startedAttachments = attachments + startedWorkspaceMode = workspaceMode + startedBranch = branch + startedWorktreePath = worktreePath + startedFromOrigin = startFromOrigin + return createdThread + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { + setRuntimeModeCalls.append(mode) + if let runtimeModeError { throw runtimeModeError } + } + func deleteThread(id: String) async throws {} + + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? { + try await preuploadHandler?(attachment, environmentID) + } + + func loadThread(id: String) async throws -> FeatureThreadDetail { + if let loadThreadError { + throw loadThreadError + } + if let loadThreadHandler { + return try await loadThreadHandler(id) + } + await beforeLoadThreadReturn?() + if let threadDetail { + return threadDetail + } + return FeatureThreadDetail(thread: createdThread) + } + + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? { + loadEarlierCallCount += 1 + return earlierThreadDetail + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws { + sendMessageCallCount += 1 + try beforeSendMessage?() + if let sendMessageError { throw sendMessageError } + sentText = text + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments _: [FeatureUploadAttachment], + identity _: FeatureSubmissionIdentity + ) async throws { + sentRuntimeModes.append(runtimeMode) + try await sendMessage(threadID: threadID, text: text, selection: selection) + } + + func cancelTurn(threadID: String) async throws { + cancelTurnCallCount += 1 + } + func setThreadSettled(id: String, settled: Bool) async throws { + await beforeSettlementReturn?() + if let settlementError { throw settlementError } + } + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func resolveUserInput( + id: String, + answers: [String: FeatureInputAnswer] + ) async throws { + resolvedInputID = id + resolvedInputAnswers = answers + } + func saveSettings(_ settings: FeatureSettings) async throws { + try await beforeSaveSettings?() + savedSettings.append(settings) + } + func refreshProviders(environmentID: String) async throws -> [FeatureProvider] { + refreshedProviderEnvironmentID = environmentID + return refreshedProviders + } + func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings { + automaticSettlementEnvironmentID = environmentID + automaticSettlementChange = change + if let automaticSettlementError { throw automaticSettlementError } + return automaticSettlementResult + } +} + +@MainActor +private final class FeatureSettingsSaveGate { + private var calls = 0 + private var firstRelease: CheckedContinuation? + private var callWaiters: [(Int, CheckedContinuation)] = [] + + var callCount: Int { calls } + + func enter() async { + calls += 1 + let ready = callWaiters.filter { calls >= $0.0 } + callWaiters.removeAll { calls >= $0.0 } + ready.forEach { $0.1.resume() } + guard calls == 1 else { return } + await withCheckedContinuation { firstRelease = $0 } + } + + func waitUntilCallCount(_ count: Int) async { + guard calls < count else { return } + await withCheckedContinuation { callWaiters.append((count, $0)) } + } + + func releaseFirst() { + firstRelease?.resume() + firstRelease = nil + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureToolRecoveryTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureToolRecoveryTests.swift new file mode 100644 index 000000000000..cf7bef87d578 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureToolRecoveryTests.swift @@ -0,0 +1,281 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Tool failure recovery") +struct FeatureToolRecoveryTests { + private struct StubError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private func failedState( + _ operation: FeatureSourceControlOperation = .action(.push, message: nil), + message: String = "remote rejected: non-fast-forward" + ) -> FeatureToolFailureState { + var state = FeatureToolFailureState() + state.begin(operation) + state.recordFailure(operation, error: StubError(message: message)) + return state + } + + @Test + func failureRetainsContentAndNamesTheOperation() { + let state = failedState() + + #expect(state.failure?.title == "Push failed") + #expect(state.failure?.message == "remote rejected: non-fast-forward") + #expect(state.failure?.isRetrying == false) + #expect(state.retryOperation == .action(.push, message: nil)) + #expect(state.focusTarget == .failure) + } + + @Test + func retryKeepsFailureContentVisibleWhileItRuns() { + var state = failedState() + + state.begin(.action(.push, message: nil)) + + #expect(state.failure?.message == "remote rejected: non-fast-forward") + #expect(state.failure?.isRetrying == true) + #expect(state.focusTarget == .failure) + #expect(state.failure?.accessibilityLabel.hasSuffix("Retrying.") == true) + } + + @Test + func unrelatedWorkDoesNotMarkTheRetainedFailureAsRetrying() { + var state = failedState() + + state.begin(.load) + + #expect(state.failure?.message == "remote rejected: non-fast-forward") + #expect(state.failure?.isRetrying == false) + } + + @Test("An unrelated success preserves the failed operation", .bug(id: 3801994163)) + func unrelatedSuccessDoesNotConsumeTheRetainedFailure() { + var state = failedState() + + state.begin(.load) + state.recordSuccess(.load) + + #expect(state.failure?.message == "remote rejected: non-fast-forward") + #expect(state.retryOperation == .action(.push, message: nil)) + #expect(state.recoveryAnnouncement == nil) + } + + @Test + func repeatedFailureUpdatesContentAndPresentsANewFocusIdentity() { + var state = failedState() + let firstID = state.failure?.id + + state.begin(.action(.push, message: nil)) + state.recordFailure( + .action(.push, message: nil), + error: StubError(message: "remote rejected: still behind") + ) + + #expect(state.failure?.message == "remote rejected: still behind") + #expect(state.failure?.isRetrying == false) + #expect(state.failure?.id != firstID) + } + + @Test + func cancellationNeverCreatesAFailure() { + var state = FeatureToolFailureState() + state.begin(.load) + + state.recordFailure(.load, error: CancellationError()) + + #expect(state.failure == nil) + #expect(state.retryOperation == nil) + #expect(state.focusTarget == .recoveredContent) + } + + @Test + func cancellingARetryPreservesTheOriginalFailureContent() { + var state = failedState() + state.begin(.action(.push, message: nil)) + + state.recordFailure( + .action(.push, message: nil), + error: URLError(.cancelled) + ) + + #expect(state.failure?.message == "remote rejected: non-fast-forward") + #expect(state.failure?.isRetrying == false) + #expect(state.retryOperation == .action(.push, message: nil)) + } + + @Test + func cancellationIsRecognizedAcrossTheErrorsAThreadDismissalProduces() { + typealias State = FeatureToolFailureState + + #expect(State.isCancellation(CancellationError())) + #expect(State.isCancellation(URLError(.cancelled))) + #expect(State.isCancellation(CocoaError(.userCancelled))) + #expect(State.isCancellation(URLError(.timedOut)) == false) + #expect(State.isCancellation(StubError(message: "boom")) == false) + } + + @Test + func recoveryClearsTheFailureAndAnnouncesItOnce() { + var state = failedState() + + state.begin(.action(.push, message: nil)) + state.recordSuccess(.action(.push, message: nil)) + + #expect(state.failure == nil) + #expect(state.retryOperation == nil) + #expect(state.focusTarget == .recoveredContent) + #expect(state.takeRecoveryAnnouncement() == "Push succeeded. Repository status updated.") + #expect(state.takeRecoveryAnnouncement() == nil) + } + + @Test + func successWithoutAPriorFailureAnnouncesNothing() { + var state = FeatureToolFailureState() + + state.begin(.load) + state.recordSuccess(.load) + + #expect(state.takeRecoveryAnnouncement() == nil) + #expect(state.focusTarget == .recoveredContent) + } + + @Test + func startingAnotherAttemptDropsAStaleRecoveryAnnouncement() { + var state = failedState(.load) + state.recordSuccess(.load) + + state.begin(.action(.pull, message: nil)) + + #expect(state.recoveryAnnouncement == nil) + } + + @Test + func retryReplaysTheExactFailedOperationIncludingItsCommitMessage() { + let operation = FeatureSourceControlOperation.action(.commit, message: "fix: retry me") + var state = FeatureToolFailureState() + + state.begin(operation) + state.recordFailure(operation, error: StubError(message: "pre-commit hook failed")) + + #expect(state.retryOperation == operation) + #expect(state.failure?.retryAccessibilityLabel == "Retry commit changes") + } + + @Test("A post-action refresh failure retries only the refresh", .bug(id: 3801994206)) + func postActionRefreshFailureCannotRepeatTheCompletedAction() { + let completedAction = FeatureSourceControlOperation.action( + .commit, + message: "fix: do not run twice" + ) + var state = FeatureToolFailureState() + + state.begin(completedAction) + state.recordFollowUpFailure( + .load, + afterCompletionOf: completedAction, + error: StubError(message: "connection lost during refresh") + ) + + #expect(state.failure?.title == "Repository status failed to load") + #expect(state.retryOperation == .load) + #expect(state.retryOperation != completedAction) + } + + @Test("A successful action refresh consumes a retained load failure", .bug(id: 3826749394)) + func actionRefreshSuccessRecoversAnEarlierLoadFailure() { + let action = FeatureSourceControlOperation.action(.push, message: nil) + var state = failedState(.load) + + state.begin(action) + state.recordSuccess(action, .load) + + #expect(state.failure == nil) + #expect(state.retryOperation == nil) + #expect(state.takeRecoveryAnnouncement() == "Repository status loaded.") + } + + @Test("A cancelled post-action refresh cannot leave the action retryable") + func cancelledPostActionRefreshDropsTheCompletedActionFailure() { + let completedAction = FeatureSourceControlOperation.action(.push, message: nil) + var state = failedState(completedAction) + + state.begin(completedAction) + state.recordFollowUpFailure( + .load, + afterCompletionOf: completedAction, + error: CancellationError() + ) + + #expect(state.failure == nil) + #expect(state.retryOperation == nil) + #expect(state.recoveryAnnouncement == nil) + } + + @Test + func retryLabelIsStableAcrossRepeatedFailuresOfTheSameOperation() { + var state = failedState() + let firstLabel = state.failure?.retryAccessibilityLabel + + state.begin(.action(.push, message: nil)) + state.recordFailure(.action(.push, message: nil), error: StubError(message: "again")) + + #expect(firstLabel == "Retry push") + #expect(state.failure?.retryAccessibilityLabel == firstLabel) + } + + @Test + func everySourceControlOperationHasDistinctFailureAndRetryWording() { + let operations: [FeatureSourceControlOperation] = [.load] + + FeatureSourceControlAction.allCases.map { .action($0, message: nil) } + + let failureTitles = operations.map(\.failureTitle) + let retryLabels = operations.map(\.retryAccessibilityLabel) + + #expect(Set(failureTitles).count == operations.count) + #expect(Set(retryLabels).count == operations.count) + #expect(retryLabels.allSatisfy { $0.hasPrefix("Retry ") }) + #expect(failureTitles.contains("Repository status failed to load")) + #expect(retryLabels.contains("Retry loading repository status")) + } + + @Test + func emptyErrorTextStillLeavesReadableFailureContent() { + var state = FeatureToolFailureState() + + state.recordFailure(.load, error: StubError(message: " ")) + + #expect(state.failure?.message == "The operation could not be completed.") + #expect(state.failure?.accessibilityLabel.isEmpty == false) + } + + @Test + func loadOperationIsDistinguishedFromActions() { + #expect(FeatureSourceControlOperation.load.isLoad) + #expect(FeatureSourceControlOperation.action(.pull, message: nil).isLoad == false) + } + + @Test("Only one source-control request can own the recovery state", .bug(id: 3802036872)) + func runStateRejectsOverlappingOperations() { + var state = FeatureToolRunState() + let action = FeatureSourceControlOperation.action(.push, message: nil) + + let actionDidBegin = state.begin(action) + let overlappingLoadDidBegin = state.begin(.load) + + #expect(actionDidBegin) + #expect(state.isBusy) + #expect(overlappingLoadDidBegin == false) + #expect(state.operation == action) + + state.finish(.load) + #expect(state.operation == action) + + state.finish(action) + #expect(state.isBusy == false) + #expect(state.operation == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift new file mode 100644 index 000000000000..ca17a1aa23f0 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift @@ -0,0 +1,691 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Thread tool state") +struct FeatureToolStateTests { + @MainActor + @Test("A failed source-control action stops progress before recovery") + func failedSourceControlActionStopsProgressBeforeRecovery() async { + var phases: [String] = [] + + let result: Result = await runFeatureSourceControlAction( + setRunning: { phases.append($0 ? "running" : "stopped") } + ) { + throw FeatureCapabilityUnavailable("Source control") + } + + if case .failure = result { + phases.append("recovery") + } + + #expect(phases == ["running", "stopped", "recovery"]) + } + + private static func vcsLocal( + isRepo: Bool = true, + hasPrimaryRemote: Bool = true, + refName: String? = "feature/cached", + files: [String] = [] + ) -> VCSLocalStatus { + VCSLocalStatus( + isRepo: isRepo, + sourceControlProvider: nil, + hasPrimaryRemote: hasPrimaryRemote, + isDefaultRef: false, + refName: refName, + hasWorkingTreeChanges: files.isEmpty == false, + workingTree: VCSWorkingTree( + files: files.map { + VCSWorkingTreeFile(path: $0, insertions: 1, deletions: 0) + }, + insertions: files.count, + deletions: 0 + ) + ) + } + + private static func vcsRemote( + aheadCount: Int, + behindCount: Int = 0, + pullRequest: VCSChangeRequest? = nil + ) -> VCSRemoteStatus { + VCSRemoteStatus( + hasUpstream: true, + aheadCount: aheadCount, + behindCount: behindCount, + aheadOfDefaultCount: nil, + pr: pullRequest + ) + } + + @Test("Cached local status is available before remote status") + func cachedLocalStatusArrivesFirst() throws { + var accumulator = NativeSourceControlStatusAccumulator() + + let consumedLocal = accumulator.consume( + .snapshot( + local: Self.vcsLocal(files: ["App/NativeFeatureClient.swift"]), + remote: nil + ) + ) + let localOnly = try #require(consumedLocal) + + #expect(localOnly.branch == "feature/cached") + #expect(localOnly.files.map(\.path) == ["App/NativeFeatureClient.swift"]) + #expect(localOnly.aheadCount == 0) + #expect(localOnly.pullRequest == nil) + #expect(accumulator.isComplete == false) + // Ahead/behind are unknown rather than zero, so remote-dependent + // actions stay withheld until the remote half lands. + #expect(localOnly.isRemoteKnown == false) + #expect(localOnly.availableActions.contains(.createPullRequest) == false) + #expect(localOnly.availableActions.contains(.commitPushAndCreatePullRequest) == false) + #expect(localOnly.availableActions.contains(.commit)) + } + + @Test("Remote status combines with the latest local status") + func remoteStatusUsesLatestLocalState() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot(local: Self.vcsLocal(refName: "feature/old"), remote: nil) + ) + _ = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/new", files: ["new.swift"])) + ) + let pullRequest = VCSChangeRequest( + number: 42, + title: "Cached status", + url: "https://example.com/pull/42", + baseRef: "main", + headRef: "feature/new", + state: "OPEN" + ) + + let consumedRemote = accumulator.consume( + .remoteUpdated( + Self.vcsRemote( + aheadCount: 2, + behindCount: 1, + pullRequest: pullRequest + ) + ) + ) + let combined = try #require(consumedRemote) + + #expect(combined.branch == "feature/new") + #expect(combined.files.map(\.path) == ["new.swift"]) + #expect(combined.aheadCount == 2) + #expect(combined.behindCount == 1) + #expect(combined.pullRequest?.number == 42) + #expect(combined.isRemoteKnown) + #expect(accumulator.isComplete) + } + + @Test("Remote status is retained across a later local-only update") + func localUpdateKeepsKnownRemoteStatus() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(refName: "feature/one"), + remote: Self.vcsRemote(aheadCount: 4, behindCount: 2) + ) + ) + #expect(accumulator.isComplete) + + let consumed = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/one", files: ["later.swift"])) + ) + let updated = try #require(consumed) + + #expect(updated.files.map(\.path) == ["later.swift"]) + #expect(updated.aheadCount == 4) + #expect(updated.behindCount == 2) + #expect(updated.isRemoteKnown) + // Completion latches: a local-only update must not reopen the stream. + #expect(accumulator.isComplete) + } + + @Test("Remote-before-local ordering retains the remote status") + func remoteBeforeLocalIsRetained() throws { + var accumulator = NativeSourceControlStatusAccumulator() + + let remoteBeforeLocal = accumulator.consume( + .remoteUpdated(Self.vcsRemote(aheadCount: 9)) + ) + #expect(remoteBeforeLocal == nil) + #expect(accumulator.isComplete == false) + + let consumedLocal = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/local")) + ) + let withRetainedRemote = try #require(consumedLocal) + let consumedRemote = accumulator.consume( + .remoteUpdated(Self.vcsRemote(aheadCount: 3)) + ) + let combined = try #require(consumedRemote) + + #expect(withRetainedRemote.aheadCount == 9) + #expect(withRetainedRemote.isRemoteKnown) + #expect(combined.branch == "feature/local") + #expect(combined.aheadCount == 3) + #expect(accumulator.isComplete) + } + + @Test( + "Terminal local states do not wait for remote status", + arguments: [ + Self.vcsLocal(isRepo: false, hasPrimaryRemote: false, refName: nil), + Self.vcsLocal(hasPrimaryRemote: false, refName: "local-only"), + ] + ) + func terminalLocalStatesAreExplicit(local: VCSLocalStatus) throws { + var accumulator = NativeSourceControlStatusAccumulator() + + let consumed = accumulator.consume(.snapshot(local: local, remote: nil)) + let status = try #require(consumed) + + #expect(status.isRepository == local.isRepo) + #expect(status.branch == local.refName) + #expect(status.pullRequest == nil) + // No remote will ever arrive, so nothing is left pending. + #expect(status.isRemoteKnown) + #expect(accumulator.isComplete) + } + + @Test("A stream ending after only a cached local status is a protocol error") + func prematureStreamEndIsExplicit() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot(local: Self.vcsLocal(files: ["pending.swift"]), remote: nil) + ) + #expect(accumulator.isComplete == false) + + #expect(throws: RPCError.self) { try accumulator.validateEnd() } + } + + @Test("An absent remote half resolves the status instead of leaving it pending") + func nilRemotePayloadResolvesTheRemoteHalf() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume(.snapshot(local: Self.vcsLocal(), remote: nil)) + #expect(accumulator.isComplete == false) + + let consumed = accumulator.consume(.remoteUpdated(nil)) + let resolved = try #require(consumed) + + // "Known to be absent" is not "still pending": the stream is finished + // and the screen must stop claiming it is checking. + #expect(resolved.isRemoteKnown) + #expect(resolved.aheadCount == 0) + #expect(resolved.behindCount == 0) + #expect(resolved.pullRequest == nil) + #expect(accumulator.isComplete) + } + + @Test("A later snapshot replaces the remote half rather than merging into it") + func snapshotReplacesKnownRemoteStatus() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(refName: "feature/one"), + remote: Self.vcsRemote(aheadCount: 7) + ) + ) + + // Resubscribe after a reconnect: the server prepends a fresh snapshot + // carrying whatever its cache holds, so a stale ahead count must not + // survive and must not be reported as known. + let consumed = accumulator.consume( + .snapshot(local: Self.vcsLocal(refName: "feature/one"), remote: nil) + ) + let replaced = try #require(consumed) + + #expect(replaced.aheadCount == 0) + #expect(replaced.isRemoteKnown == false) + #expect(accumulator.isComplete == false) + } + + @Test("A fresh snapshot can return a reused monitor to pending") + func completionTracksTheCurrentSequence() { + var accumulator = NativeSourceControlStatusAccumulator() + let events: [VCSStatusEvent] = [ + .snapshot(local: Self.vcsLocal(refName: "feature/seq"), remote: nil), + .localUpdated(Self.vcsLocal(refName: "feature/seq", files: ["a.swift"])), + .remoteUpdated(Self.vcsRemote(aheadCount: 1)), + .localUpdated(Self.vcsLocal(refName: "feature/seq", files: ["a.swift", "b.swift"])), + .remoteUpdated(nil), + // A reused monitor can begin a fresh cached-local-first sequence. + .snapshot(local: Self.vcsLocal(refName: "feature/seq"), remote: nil), + .localUpdated(Self.vcsLocal(refName: "feature/seq")), + ] + + var completionStates: [Bool] = [] + for event in events { + _ = accumulator.consume(event) + completionStates.append(accumulator.isComplete) + } + + #expect(completionStates == [false, false, true, true, true, false, false]) + } + + @Test("Changing branches discards the prior branch remote status") + func branchChangeReturnsRemoteStatusToPending() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(refName: "feature/one"), + remote: Self.vcsRemote(aheadCount: 4) + ) + ) + + let consumed = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/two")) + ) + let changedBranch = try #require(consumed) + + #expect(changedBranch.branch == "feature/two") + #expect(changedBranch.aheadCount == 0) + #expect(changedBranch.pullRequest == nil) + #expect(changedBranch.isRemoteKnown == false) + #expect(accumulator.isComplete == false) + } + + @Test("A completed stream ends without error") + func completedStreamEndIsAccepted() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(), + remote: Self.vcsRemote(aheadCount: 1) + ) + ) + + try accumulator.validateEnd() + } + + @Test + func fileFilteringKeepsDirectoriesFirstAndHonorsHiddenFiles() { + let entries = [ + FeatureFileEntry(path: "z.swift", name: "z.swift", kind: .file), + FeatureFileEntry(path: ".env", name: ".env", kind: .file, isHidden: true), + FeatureFileEntry(path: "Sources", name: "Sources", kind: .directory), + FeatureFileEntry(path: "a.swift", name: "a.swift", kind: .file), + ] + + #expect(entries.featureFiltered(by: "", includesHidden: false).map(\.name) == [ + "Sources", "a.swift", "z.swift", + ]) + #expect(entries.featureFiltered(by: "env", includesHidden: true).map(\.name) == [".env"]) + } + + @Test + func filePreviewKindUsesImageMarkdownAndSourceSemantics() { + #expect(FeatureFilePreviewKind.infer(path: "art/hero.webp") == .image) + #expect(FeatureFilePreviewKind.infer(path: "docs/spec.pdf") == .pdf) + #expect(FeatureFilePreviewKind.infer(path: "demo.mov") == .video) + #expect(FeatureFilePreviewKind.infer(path: "brief.docx") == .document) + #expect(FeatureFilePreviewKind.infer(path: "README.md") == .markdown) + #expect(FeatureFilePreviewKind.infer(path: "Package.swift") == .source) + #expect(FeatureFilePreviewKind.infer(path: "LICENSE") == .plainText) + #expect(FeatureFilePreviewKind.infer(path: "template", language: "html") == .source) + } + + @Test + func previewFileNamesDropPathsAndRejectEmptyNames() throws { + #expect(try FeatureMediaPreviewFiles.safeFileName("reports/final.pdf") == "final.pdf") + #expect(try FeatureMediaPreviewFiles.safeFileName("clip:one.mov") == "clip_one.mov") + #expect(throws: FeatureMediaPreviewError.invalidFileName) { + try FeatureMediaPreviewFiles.safeFileName(" ") + } + } + + @Test + func previewDirectoriesHaveUniqueOwnership() throws { + let first = try FeatureMediaPreviewFiles.ownedDirectory() + let second = try FeatureMediaPreviewFiles.ownedDirectory() + defer { + try? FileManager.default.removeItem(at: first) + try? FileManager.default.removeItem(at: second) + } + #expect(first != second) + #expect(FileManager.default.fileExists(atPath: first.path)) + #expect(FileManager.default.fileExists(atPath: second.path)) + } + + @Test + func remotePreviewNeverSharesItsSignedSourceURL() { + let signedURL = URL(string: "https://example.com/file.pdf?token=secret")! + let downloadedURL = URL(fileURLWithPath: "/tmp/owned/file.pdf") + #expect( + FeatureMediaPreviewFiles.shareURL( + for: .remote(signedURL), + downloadedURL: nil + ) == nil + ) + #expect( + FeatureMediaPreviewFiles.shareURL( + for: .remote(signedURL), + downloadedURL: downloadedURL + ) == downloadedURL + ) + } + + @Test + func typedMediaPreviewRouteKeepsHostPathAndKind() { + var components = URLComponents() + components.scheme = "t3code" + components.host = "media-preview" + components.path = "/open" + components.queryItems = [ + URLQueryItem(name: "path", value: "/tmp/output/final image.png"), + URLQueryItem(name: "kind", value: "image"), + ] + #expect( + FeatureTypedMediaPreviewRoute.parse(components.url!) + == FeatureTypedMediaPreviewRoute( + path: "/tmp/output/final image.png", + kind: .image + ) + ) + #expect( + FeatureTypedMediaPreviewRoute.parse( + URL(string: "t3code://media-preview/open?path=/tmp/a.pdf&kind=pdf")! + ) == FeatureTypedMediaPreviewRoute(path: "/tmp/a.pdf", kind: .pdf) + ) + } + + @Test + func previewGenerationRejectsCompletionAfterDismissal() { + var generation = FeatureMediaPreviewGeneration() + let downloadGeneration = generation.begin() + #expect(generation.isCurrent(downloadGeneration)) + generation.invalidate() + #expect(!generation.isCurrent(downloadGeneration)) + } + + @Test + func sourceHighlighterPreservesTextAndClassifiesStableSpans() { + let source = """ + let count = 42 // total + /* first + second */ return "done" + """ + let lines = FeatureSourceHighlighter.lines(text: source, language: "swift") + + #expect(lines.map(\.text).joined(separator: "\n") == source) + #expect(lines[0].spans.contains { $0.text == "let" && $0.kind == .keyword }) + #expect(lines[0].spans.contains { $0.text == "42" && $0.kind == .number }) + #expect(lines[0].spans.last?.kind == .comment) + #expect(lines[1].spans.allSatisfy { $0.kind == .comment }) + #expect(lines[2].spans.first?.kind == .comment) + #expect(lines[2].spans.contains { $0.text.contains("return") && $0.kind == .keyword }) + #expect(lines[2].spans.last?.kind == .literal) + } + + @Test + func sourceHighlighterRecognizesJSONProperties() { + let line = FeatureSourceHighlighter.lines( + text: #"{"enabled": true, "count": 3}"#, + language: "json" + )[0] + + #expect(line.spans.contains { $0.text == #""enabled""# && $0.kind == .property }) + #expect(line.spans.contains { $0.text == "true" && $0.kind == .literal }) + #expect(line.spans.contains { $0.text == "3" && $0.kind == .number }) + } + + @Test + func sourceHighlighterBoundsWorkForLargeMinifiedLines() { + let source = String(repeating: #"{"value":42}"#, count: 3_000) + let line = FeatureSourceHighlighter.lines(text: source, language: "json")[0] + + #expect(line.text == source) + #expect(line.spans == [FeatureSourceSpan(text: source, kind: .plain)]) + } + + @Test + func reviewTotalsAggregateAcrossFiles() { + let review = FeatureReview(files: [ + FeatureReviewFile(path: "a.swift", change: .modified, additions: 4, deletions: 1), + FeatureReviewFile(path: "b.swift", change: .added, additions: 8, deletions: 0), + ]) + + #expect(review.additions == 12) + #expect(review.deletions == 1) + } + + @Test + func wordDiffHighlightsOnlyChangedTokens() { + let result = FeatureDiffWordHighlighter.spans( + old: "let color = blue", + new: "let color = green" + ) + + #expect(result.old.map(\.text).joined() == "let color = blue") + #expect(result.new.map(\.text).joined() == "let color = green") + #expect(result.old.filter { $0.kind == .changed }.map(\.text) == ["blue"]) + #expect(result.new.filter { $0.kind == .changed }.map(\.text) == ["green"]) + } + + @Test + func workspaceReviewMapperPairsReplacementLinesAndCarriesBaseReference() { + let preview = ReviewDiffPreview( + cwd: "/tmp/project", + generatedAt: "2026-08-01T00:00:00Z", + sources: [ + ReviewDiffSource( + id: "working-tree", + kind: "working-tree", + title: "Working tree", + baseRef: "main", + headRef: nil, + diff: """ + diff --git a/App.swift b/App.swift + --- a/App.swift + +++ b/App.swift + @@ -1,1 +1,1 @@ + -let color = blue + +let color = green + """, + diffHash: "hash", + truncated: false + ), + ] + ) + + let review = NativeWorkspaceMapper.review(preview) + let deletion = review.files[0].lines.first { $0.kind == .deletion } + let addition = review.files[0].lines.first { $0.kind == .addition } + + #expect(review.baseReference == "main") + #expect(review.files[0].sourceKind == "working-tree") + #expect(review.files[0].sourceBaseReference == "main") + #expect(deletion?.spans?.filter { $0.kind == .changed }.map(\.text) == ["blue"]) + #expect(addition?.spans?.filter { $0.kind == .changed }.map(\.text) == ["green"]) + } + + @Test + func fullDiffHydrationRestoresUnchangedRegionsWithoutLosingPatchRows() { + let file = FeatureReviewFile( + path: "App.swift", + change: .modified, + additions: 1, + deletions: 1, + lines: [ + .init(id: "hunk", kind: .hunk, text: "@@ -2,2 +2,2 @@"), + .init(id: "old", kind: .deletion, oldLine: 2, text: "let color = blue"), + .init(id: "new", kind: .addition, newLine: 2, text: "let color = green"), + .init(id: "after", kind: .context, oldLine: 3, newLine: 3, text: "render()"), + ] + ) + + let lines = FeatureFullDiffHydrator.lines( + for: file, + contents: FeatureReviewFileContents( + oldContents: "import SwiftUI\nlet color = blue\nrender()\nfinish()\n", + newContents: "import SwiftUI\nlet color = green\nrender()\nfinish()\n" + ) + ) + + #expect(lines.map(\.kind) == [.context, .deletion, .addition, .context, .context]) + #expect(lines.map(\.text) == [ + "import SwiftUI", + "let color = blue", + "let color = green", + "render()", + "finish()", + ]) + #expect(lines.last?.oldLine == 4) + #expect(lines.last?.newLine == 4) + } + + @Test + func fullDiffHydrationHandlesWholeAddedAndDeletedFiles() { + let added = FeatureFullDiffHydrator.lines( + for: FeatureReviewFile( + path: "Added.swift", + change: .added, + additions: 2, + deletions: 0 + ), + contents: FeatureReviewFileContents( + oldContents: "", + newContents: "one\ntwo\n" + ) + ) + let deleted = FeatureFullDiffHydrator.lines( + for: FeatureReviewFile( + path: "Deleted.swift", + change: .deleted, + additions: 0, + deletions: 1 + ), + contents: FeatureReviewFileContents( + oldContents: "gone\n", + newContents: "" + ) + ) + + #expect(added.map(\.kind) == [.addition, .addition]) + #expect(added.map(\.newLine) == [1, 2]) + #expect(deleted.map(\.kind) == [.deletion]) + #expect(deleted.map(\.oldLine) == [1]) + } + + @Test + func fullDiffHydrationKeepsDeletionAtItsPreviousAnchor() { + let file = FeatureReviewFile( + path: "App.swift", + change: .modified, + additions: 1, + deletions: 1, + lines: [ + .init(id: "anchor", kind: .context, oldLine: 2, newLine: 2, text: "two"), + .init(id: "deleted", kind: .deletion, oldLine: 3, text: "three"), + .init(id: "later", kind: .addition, newLine: 7, text: "added later"), + ] + ) + + let lines = FeatureFullDiffHydrator.lines( + for: file, + contents: FeatureReviewFileContents( + oldContents: "one\ntwo\nthree\nfour\nfive\nsix\nseven\n", + newContents: "one\ntwo\nfour\nfive\nsix\nseven\nadded later\n" + ) + ) + + #expect(lines.firstIndex { $0.id == "deleted" } == 2) + #expect(lines.prefix(3).map(\.text) == ["one", "two", "three"]) + } + + @Test + func reviewCommentPromptIncludesActionableFileAndLineContext() { + let draft = FeatureReviewCommentDraft( + filePath: "Sources/App.swift", + line: FeatureReviewLineSelection(side: .new, line: 42), + body: " Handle the nil case. " + ) + + #expect(draft.prompt.contains("`Sources/App.swift` at new line 42")) + #expect(draft.prompt.contains("Handle the nil case.")) + #expect(!draft.prompt.contains(" Handle the nil case. ")) + } + + @Test + func sourceControlActionsReflectRepositoryState() { + let clean = FeatureSourceControlStatus(branch: "main") + #expect(clean.availableActions == [.createPullRequest]) + + let changed = FeatureSourceControlStatus( + branch: "feature/native", + aheadCount: 2, + behindCount: 1, + files: [.init(path: "App.swift", state: .modified, isStaged: false)] + ) + #expect(changed.availableActions.contains(.commit)) + #expect(changed.availableActions.contains(.push)) + #expect(changed.availableActions.contains(.pull)) + #expect(changed.availableActions.contains(.commitPushAndCreatePullRequest)) + + var busy = changed + busy.isBusy = true + #expect(busy.availableActions.isEmpty) + } + + @Test + func terminalPlainTextDropsControlSequences() { + let prompt = "\u{1B}]0;workspace\u{7}\u{1B}[38;5;221mx\u{8}repo\u{1B}[39m ❯ " + #expect(TerminalText.plainText(from: prompt) == "repo ❯ ") + } + + @Test + func terminalSessionSelectionPrefersAndAllocatesStableIDs() { + let sessions = [ + FeatureTerminalSnapshot( + threadID: "thread", + terminalID: "term-2", + state: .running, + title: "Tests" + ), + FeatureTerminalSnapshot( + threadID: "thread", + terminalID: "default", + state: .running + ), + FeatureTerminalSnapshot( + threadID: "thread", + terminalID: "term-3", + state: .exited + ), + ] + + #expect(TerminalSessionList.initialID(in: sessions) == "default") + #expect(TerminalSessionList.nextID(occupiedIDs: ["default", "term-2", "term-4"]) == "term-3") + #expect(TerminalSessionList.displayTitle(for: sessions[0]) == "Terminal 2 · Tests") + #expect(TerminalSessionList.displayTitle(for: sessions[1]) == "Terminal 1") + #expect( + TerminalSessionList.fallbackID(in: sessions, excluding: "default") == "term-2" + ) + } + + @Test + func terminalSnapshotPreservesVTDataForGhostty() { + let history = "\u{1B}[31mred\u{1B}[0m\r\n" + let snapshot = TerminalSessionSnapshot( + threadId: "thread", + terminalId: "default", + cwd: "/repo", + worktreePath: nil, + status: .running, + pid: 123, + history: history, + exitCode: nil, + exitSignal: nil, + label: "Terminal", + updatedAt: "2026-08-07T00:00:00Z", + sequence: 1 + ) + + #expect(NativeWorkspaceMapper.terminal(snapshot).buffer == history) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureVoiceInputTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureVoiceInputTests.swift new file mode 100644 index 000000000000..20fb8ca0cdcb --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureVoiceInputTests.swift @@ -0,0 +1,282 @@ +import Foundation +import SwiftUI +import Testing +import UIKit +@testable import T3Code + +@Suite("Local voice input", .serialized) +@MainActor +struct FeatureVoiceInputTests { + @Test(arguments: [false, true]) + func lockingTheDraftPreservesTheExistingKeyboard(keyboardIsOpen: Bool) async throws { + var text = "Draft" + var focused = keyboardIsOpen + func input(readOnly: Bool) -> FeatureComposerTextInput { + FeatureComposerTextInput( + text: Binding(get: { text }, set: { text = $0 }), + focused: Binding(get: { focused }, set: { focused = $0 }), + placeholder: "Message", acceptsImages: true, isReadOnly: readOnly, + skills: [], + selectionRequest: nil, onSelectionChange: { _ in }, + onPasteImages: { _ in Issue.record("Pasted while dictating") }, + onDismissKeyboard: nil + ) + } + let scene = try #require(UIApplication.shared.connectedScenes.first as? UIWindowScene) + let previousKeyWindow = scene.keyWindow + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: input(readOnly: false)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { + window.isHidden = true + previousKeyWindow?.makeKey() + } + + let editor = try #require(await renderedEditor(in: host.view, readOnly: false)) + #expect(editor.isFirstResponder == keyboardIsOpen) + host.rootView = input(readOnly: true) + #expect(await renderedEditor(in: host.view, readOnly: true) === editor) + #expect(editor.isFirstResponder == keyboardIsOpen) + #expect(focused == keyboardIsOpen) + #expect(editor.isEditable) + #expect(!editor.canPerformAction(#selector(UIResponderStandardEditActions.paste(_:)), withSender: nil)) + editor.insertText("ignored") + editor.deleteBackward() + #expect(text == "Draft") + #expect(editor.text == "Draft") + + host.rootView = input(readOnly: false) + #expect(await renderedEditor(in: host.view, readOnly: false) === editor) + #expect(editor.isFirstResponder == keyboardIsOpen) + editor.insertText(" works") + #expect(text == "Draft works") + } + + private func renderedEditor(in view: UIView, readOnly: Bool) async -> FeatureComposerUITextView? { + func find(in view: UIView) -> FeatureComposerUITextView? { + if let editor = view as? FeatureComposerUITextView { return editor } + return view.subviews.lazy.compactMap { find(in: $0) }.first + } + for _ in 0..<30 { + view.setNeedsLayout() + view.layoutIfNeeded() + if let editor = find(in: view), editor.isReadOnly == readOnly { + return editor + } + try? await Task.sleep(for: .milliseconds(16)) + } + return nil + } + + @Test + func insertsAtUTF16SelectionsWithEnglishSpacing() { + let text = "Fix 🧪 then $review please" + let selectedRange = (text as NSString).range(of: "$review") + let selected = snapshot(text: text, selection: selectedRange) + + guard case let .commit(selectedCommit) = FeatureVoiceTranscriptResolver.resolve( + captured: selected, + current: selected, + transcript: "use the mobile skill", + localeIdentifier: "en-US" + ) else { + Issue.record("Expected a selected-text transcript commit") + return + } + #expect(selectedCommit.text == "Fix 🧪 then use the mobile skill please") + #expect( + selectedCommit.caretLocation + == selectedRange.location + "use the mobile skill".utf16.count + ) + + let atEnd = snapshot( + text: "Fix cache.", + selection: NSRange(location: "Fix cache.".utf16.count, length: 0) + ) + guard case let .commit(commit) = FeatureVoiceTranscriptResolver.resolve( + captured: atEnd, + current: atEnd, + transcript: "Also fix tests.", + localeIdentifier: "en_US" + ) else { + Issue.record("Expected a transcript commit") + return + } + #expect(commit.text == "Fix cache. Also fix tests.") + #expect(commit.caretLocation == commit.text.utf16.count) + } + + @Test + func preservesUnicodeBoundariesAndNonEnglishSpacing() { + let text = "修正🧪キャッシュ" + let caret = (text as NSString).range(of: "キャッシュ").location + let draft = snapshot( + text: text, + selection: NSRange(location: caret, length: 0) + ) + + guard case let .commit(commit) = FeatureVoiceTranscriptResolver.resolve( + captured: draft, + current: draft, + transcript: "テストも", + localeIdentifier: "ja-JP" + ) else { + Issue.record("Expected a transcript commit") + return + } + #expect(commit.text == "修正🧪テストもキャッシュ") + #expect(commit.caretLocation == caret + "テストも".utf16.count) + } + + @Test + func rejectsChangedDraftTextRevisionAndOwner() { + let captured = snapshot() + let changedText = snapshot(text: "newer") + let changedRevision = snapshot(revision: 2) + let changedOwner = snapshot(ownerID: "thread:other") + + #expect(resolve(captured, changedText) == .stale) + #expect(resolve(captured, changedRevision) == .stale) + #expect(resolve(captured, changedOwner) == .stale) + } + + @Test + func cancellationBeforeAndAfterPermissionCleansUpWithoutRecording() async { + let beforePermission = TestVoiceInputAdapter() + let beforeController = controller(adapter: beforePermission) + beforePermission.onPermissionRequest = { beforeController.cancel() } + beforeController.start() + await beforeController.waitForCurrentOperation() + + #expect(beforeController.phase == .idle) + #expect(beforePermission.startRecordingCount == 0) + #expect(beforePermission.cleanupCount == 1) + #expect(beforeController.pendingCommit == nil) + + let afterPermission = TestVoiceInputAdapter() + let afterController = controller(adapter: afterPermission) + afterPermission.onStartRecording = { afterController.cancel() } + afterController.start() + await afterController.waitForCurrentOperation() + + #expect(afterController.phase == .idle) + #expect(afterPermission.startRecordingCount == 1) + #expect(afterPermission.cleanupCount == 1) + #expect(afterController.pendingCommit == nil) + } + + @Test + func cancellationDuringTranscriptionDiscardsLateResultsAndOwnedAudio() async { + let adapter = TestVoiceInputAdapter() + let controller = controller(adapter: adapter) + adapter.onTranscribe = { controller.cancel() } + + controller.start() + await controller.waitForCurrentOperation() + #expect(controller.phase == .recording) + + controller.stop() + await controller.waitForCurrentOperation() + + #expect(controller.phase == .idle) + #expect(controller.pendingCommit == nil) + #expect(adapter.cleanupCount == 1) + #expect(adapter.ownedRecordingWasRemoved) + } + + @Test + func changedOwnerDuringTranscriptionNeverCommits() async { + let adapter = TestVoiceInputAdapter() + let controller = controller(adapter: adapter) + adapter.onTranscribe = { + controller.ownerChanged(to: FeatureVoiceDraftSnapshot( + ownerID: "thread:other", + text: "hello world", + revision: 1, + selection: NSRange(location: 6, length: 5) + )) + } + + controller.start() + await controller.waitForCurrentOperation() + controller.stop() + await controller.waitForCurrentOperation() + + #expect(controller.pendingCommit == nil) + #expect(adapter.cleanupCount == 1) + } + + private func controller(adapter: TestVoiceInputAdapter) -> FeatureVoiceInputController { + let controller = FeatureVoiceInputController(adapter: adapter) + controller.updateDraft(snapshot()) + return controller + } + + private func resolve( + _ captured: FeatureVoiceDraftSnapshot, + _ current: FeatureVoiceDraftSnapshot + ) -> FeatureVoiceTranscriptCommitResult { + FeatureVoiceTranscriptResolver.resolve( + captured: captured, + current: current, + transcript: "replacement", + localeIdentifier: "en-US" + ) + } + + private func snapshot( + ownerID: String = "thread:one", + text: String = "hello world", + revision: UInt64 = 1, + selection: NSRange = NSRange(location: 6, length: 5) + ) -> FeatureVoiceDraftSnapshot { + FeatureVoiceDraftSnapshot( + ownerID: ownerID, + text: text, + revision: revision, + selection: selection + ) + } +} + +@MainActor +private final class TestVoiceInputAdapter: FeatureVoiceInputAdapter { + let isSupported = true + let localeIdentifier = "en-US" + var onPermissionRequest: (() -> Void)? + var onStartRecording: (() -> Void)? + var onTranscribe: (() -> Void)? + private(set) var startRecordingCount = 0 + private(set) var cleanupCount = 0 + private(set) var ownedRecordingWasRemoved = false + + func prepare() async throws {} + + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission { + onPermissionRequest?() + return .granted + } + + func startRecording(maximumDuration: TimeInterval) throws { + startRecordingCount += 1 + #expect(maximumDuration == 5 * 60) + onStartRecording?() + } + + func stopRecording() async throws -> URL { + URL(fileURLWithPath: "/tmp/t3-owned-voice-test.m4a") + } + + func transcribe(recordingURL: URL) async throws -> String { + onTranscribe?() + return "late transcript" + } + + func cancelTranscription() async {} + + func cleanup() async { + cleanupCount += 1 + ownedRecordingWasRemoved = true + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift new file mode 100644 index 000000000000..edb7b4dc195d --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -0,0 +1,671 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Web V2 home thread metadata") +struct HomeThreadMetadataTests { + private let now = Date(timeIntervalSince1970: 10_000) + + @Test + func statusLabelsFollowTheWebV2RowVocabulary() { + let expected: [(FeatureThreadState, HomeThreadStatus, String?)] = [ + (.idle, .ready, nil), + (.queued, .working, "Working"), + (.working, .working, "Working"), + (.monitoring, .monitoring, "Monitoring"), + (.waitingForApproval, .approval, "Approval"), + (.waitingForInput, .input, "Input"), + (.failed, .failed, "Failed"), + (.completed, .done, "Done"), + ] + + for (state, status, label) in expected { + let thread = FeatureThread( + id: state.rawValue, + projectID: "project", + title: "Task", + state: state + ) + #expect(thread.homeStatus == status) + #expect(thread.homeStatusLabel == label) + } + } + + @Test + func completedAndIdleRowsUseQuietRelativeAges() { + let updatedAt = now.addingTimeInterval(-120) + let completed = FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + updatedAt: updatedAt, + state: .completed + ) + let idle = FeatureThread( + id: "idle", + projectID: "project", + title: "Idle task", + updatedAt: updatedAt, + state: .idle + ) + + #expect(completed.homeRowStatusLabel(at: now) == "2m") + #expect(idle.homeRowStatusLabel(at: now) == "2m") + } + + @Test + func completedDetailHeadersDoNotShowAStatusBadge() { + let completed = FeatureThread( + id: "completed", + projectID: "project", + title: "Completed task", + state: .completed + ) + let working = FeatureThread( + id: "working", + projectID: "project", + title: "Working task", + state: .working + ) + + #expect(completed.detailHeaderStatusLabel == nil) + #expect(completed.detailHeaderStatusIcon == nil) + #expect(working.detailHeaderStatusLabel == "Working") + #expect(working.detailHeaderStatusIcon == "circle.dotted") + } + + @Test + func workingDurationMatchesTheCompactWebFormatAndClampsFutureDates() { + let thread = FeatureThread( + id: "working", + projectID: "project", + title: "Build", + state: .working, + workingStartedAt: now.addingTimeInterval(-5_465) + ) + let future = FeatureThread( + id: "queued", + projectID: "project", + title: "Queue", + state: .queued, + workingStartedAt: now.addingTimeInterval(5) + ) + let idle = FeatureThread( + id: "idle", + projectID: "project", + title: "Rest", + state: .idle, + workingStartedAt: now.addingTimeInterval(-10) + ) + let monitoring = FeatureThread( + id: "monitoring", + projectID: "project", + title: "Watch", + state: .monitoring, + workingStartedAt: now.addingTimeInterval(-10) + ) + + #expect(thread.homeWorkingDuration(at: now) == "1h 31m") + #expect(future.homeWorkingDuration(at: now) == "0s") + #expect(idle.homeWorkingDuration(at: now) == nil) + #expect(monitoring.homeWorkingDuration(at: now) == nil) + } + + @Test + func accessibilityDurationSpellsOutUnitsAndClampsFutureDates() { + #expect(accessibilityDuration(startedAtOffset: 5) == "0 seconds") + #expect(accessibilityDuration(startedAtOffset: -1) == "1 second") + #expect(accessibilityDuration(startedAtOffset: -42) == "42 seconds") + #expect(accessibilityDuration(startedAtOffset: -60) == "1 minute") + #expect(accessibilityDuration(startedAtOffset: -120) == "2 minutes") + #expect(accessibilityDuration(startedAtOffset: -3_600) == "1 hour") + #expect(accessibilityDuration(startedAtOffset: -7_200) == "2 hours") + #expect(accessibilityDuration(startedAtOffset: -5_465) == "1 hour, 31 minutes") + } + + @Test + func accessibilityStatusDescribesOnlyLiveWorkingDurations() { + let working = thread(state: .working, startedAtOffset: -90) + let queuedWithoutStart = thread(state: .queued) + let monitoring = thread(state: .monitoring, startedAtOffset: -90) + let idle = thread(state: .idle) + + #expect(working.hasLiveWorkingDuration) + #expect(working.homeStatusAccessibilityLabel(at: now) == "Agent is working for 1 minute") + #expect(!queuedWithoutStart.hasLiveWorkingDuration) + #expect(queuedWithoutStart.homeStatusAccessibilityLabel(at: now) == "Agent is working") + #expect(!monitoring.hasLiveWorkingDuration) + #expect(monitoring.homeStatusAccessibilityLabel(at: now) == "Monitoring") + #expect(!idle.hasLiveWorkingDuration) + #expect(idle.homeStatusAccessibilityLabel(at: now) == "Ready") + } + + @Test + func completedRowsShowABareAgeMeasuredFromCompletion() { + let thread = FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + updatedAt: now.addingTimeInterval(-60), + state: .completed, + latestTurnCompletedAt: now.addingTimeInterval(-9_360) + ) + + #expect(thread.homeDoneDuration(at: now) == "2h 36m") + #expect(thread.homeRowStatusLabel(at: now) == "2h 36m") + #expect( + thread.homeRowAccessibilityStatus(rich: true, at: now) + == "Completed 2 hours, 36 minutes ago" + ) + #expect(thread.homeRowAccessibilityStatus(rich: false, at: now) == "Done") + } + + @Test + func doneDurationsAreMinuteGranularAndClampFutureCompletions() { + #expect(doneDuration(completedAtOffset: 30) == "now") + #expect(doneDuration(completedAtOffset: -30) == "now") + #expect(doneDuration(completedAtOffset: -59) == "now") + #expect(doneDuration(completedAtOffset: -60) == "1m") + #expect(doneDuration(completedAtOffset: -3_600) == "1h 0m") + #expect(doneDuration(completedAtOffset: -5_465) == "1h 31m") + #expect(doneDuration(completedAtOffset: -86_400) == "1d 0h") + #expect(doneDuration(completedAtOffset: -273_600) == "3d 4h") + #expect(doneDuration(completedAtOffset: -604_800) == "1w") + #expect(doneDuration(completedAtOffset: -31_449_600) == "52w") + #expect(doneDuration(completedAtOffset: -31_536_000) == "1y") + #expect(doneDuration(completedAtOffset: -63_072_000) == "2y") + } + + @Test + func doneAccessibilityLabelsSpeakTheAgeInWords() { + #expect(doneAccessibilityLabel(completedAtOffset: -30) == "Completed just now") + #expect(doneAccessibilityLabel(completedAtOffset: -60) == "Completed 1 minute ago") + #expect(doneAccessibilityLabel(completedAtOffset: -120) == "Completed 2 minutes ago") + #expect(doneAccessibilityLabel(completedAtOffset: -3_600) == "Completed 1 hour ago") + #expect( + doneAccessibilityLabel(completedAtOffset: -9_360) == "Completed 2 hours, 36 minutes ago" + ) + #expect(doneAccessibilityLabel(completedAtOffset: -86_400) == "Completed 1 day ago") + #expect( + doneAccessibilityLabel(completedAtOffset: -273_600) == "Completed 3 days, 4 hours ago" + ) + #expect(doneAccessibilityLabel(completedAtOffset: -604_800) == "Completed 1 week ago") + #expect(doneAccessibilityLabel(completedAtOffset: -31_449_600) == "Completed 52 weeks ago") + #expect(doneAccessibilityLabel(completedAtOffset: -31_536_000) == "Completed 1 year ago") + #expect(doneAccessibilityLabel(completedAtOffset: -63_072_000) == "Completed 2 years ago") + } + + @Test + func onlyCompletedThreadsWithACompletionTimeShowADoneDuration() { + let completedWithoutTime = FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + updatedAt: now.addingTimeInterval(-120), + state: .completed + ) + let working = FeatureThread( + id: "working", + projectID: "project", + title: "Working task", + state: .working, + latestTurnCompletedAt: now.addingTimeInterval(-300) + ) + + #expect(completedWithoutTime.homeDoneDuration(at: now) == nil) + #expect(completedWithoutTime.homeDoneAccessibilityLabel(at: now) == nil) + #expect(completedWithoutTime.homeRowStatusLabel(at: now) == "2m") + #expect( + completedWithoutTime.homeRowAccessibilityStatus(rich: true, at: now) + == "Done. Updated 2 minutes ago" + ) + #expect(completedWithoutTime.homeRowAccessibilityStatus(rich: false, at: now) == "Done") + #expect(working.homeDoneDuration(at: now) == nil) + #expect(working.homeRowStatusLabel(at: now) == "Working") + } + + private func doneDuration(completedAtOffset: TimeInterval) -> String? { + completedThread(completedAtOffset: completedAtOffset).homeDoneDuration(at: now) + } + + private func doneAccessibilityLabel(completedAtOffset: TimeInterval) -> String? { + completedThread(completedAtOffset: completedAtOffset) + .homeDoneAccessibilityLabel(at: now) + } + + private func completedThread(completedAtOffset: TimeInterval) -> FeatureThread { + FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + state: .completed, + latestTurnCompletedAt: now.addingTimeInterval(completedAtOffset) + ) + } + + private func accessibilityDuration(startedAtOffset: TimeInterval) -> String { + HomeWorkingDuration.accessibility( + since: now.addingTimeInterval(startedAtOffset), + now: now + ) + } + + private func thread( + state: FeatureThreadState, + startedAtOffset: TimeInterval? = nil + ) -> FeatureThread { + FeatureThread( + id: state.rawValue, + projectID: "project", + title: "Task", + state: state, + workingStartedAt: startedAtOffset.map(now.addingTimeInterval) + ) + } + + @Test + func rowAttributionPrefersCurrentEnvironmentNameAndWireProviderName() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "device", + environmentName: "Old device name", + title: "Build", + branch: "feat/web-v2-home", + worktreePath: "/worktrees/web-v2-home", + providerID: "codex-work", + providerName: "Codex Work" + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "leftbook", + endpoint: "https://leftbook.example" + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + providers: [FeatureProvider(id: "codex-work", name: "Config name")] + ) + + #expect(thread.homeEnvironmentLabel(in: snapshot) == "leftbook") + #expect(thread.homeProviderLabel(in: snapshot) == "Codex Work") + #expect(thread.branch == "feat/web-v2-home") + #expect(thread.worktreePath == "/worktrees/web-v2-home") + } + + @Test + func rowAttributionFallsBackThroughProjectAndProviderCatalog() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Build", + providerID: "claude" + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "steambox", + endpoint: "https://steambox.example" + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + providersByEnvironment: [ + "device": [FeatureProvider(id: "claude", name: "Claude")], + ] + ) + + #expect(thread.homeEnvironmentLabel(in: snapshot) == "steambox") + #expect(thread.homeProviderLabel(in: snapshot) == "Claude") + } + + @Test + func rowContextCarriesHarnessIdentityAndCustomProviderFallback() throws { + let knownThread = FeatureThread( + id: "known", + projectID: "project", + title: "Use Claude", + providerID: "work-claude" + ) + let customThread = FeatureThread( + id: "custom", + projectID: "project", + title: "Use a custom harness", + providerID: "acme-agent", + providerName: "Acme Agent" + ) + let snapshot = FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + threads: [knownThread, customThread], + providersByEnvironment: [ + "device": [ + FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), + FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + ], + ] + ) + + let contexts = HomeThreadRowContext.index(snapshot: snapshot) + let known = try #require(contexts[knownThread.id]) + let custom = try #require(contexts[customThread.id]) + + #expect(known.providerID == "work-claude") + #expect(known.projectEnvironmentID == "device") + #expect(known.projectWorkspaceRoot == "/work/t3code") + #expect(known.providerDriver == "custom") + #expect(known.providerName == "Claude Code") + #expect( + ProviderBrand.resolve( + driver: known.providerDriver, + providerID: known.providerID, + providerName: known.providerName + ) == .claude + ) + #expect(custom.providerID == "acme-agent") + #expect(custom.providerDriver == "custom") + #expect(custom.providerName == "Acme Agent") + #expect( + ProviderBrand.resolve( + driver: custom.providerDriver, + providerID: custom.providerID, + providerName: custom.providerName + ) == nil + ) + } + + @Test + func rowContextUsesRepositoryGroupNameInsteadOfStalePhysicalProjectTitle() throws { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Test T3 Code Functionality" + ) + let snapshot = FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "bb-1", + name: "wat", + path: "/work/t3code", + repositoryIdentity: FeatureRepositoryIdentity( + canonicalKey: "github.com/pingdotgg/t3code", + rootPath: "/work/t3code", + displayName: "pingdotgg/t3code", + name: "t3code" + ) + ), + ], + threads: [thread], + preferencesByEnvironment: [ + "bb-1": FeatureEnvironmentPreferences(projectGroupingMode: .repository), + ] + ) + + let context = try #require(HomeThreadRowContext.index(snapshot: snapshot)[thread.id]) + + #expect(context.projectName == "pingdotgg/t3code") + } + + @Test + func fallbackRowContextDoesNotOfferPlaceholderProjectCopy() { + let thread = FeatureThread( + id: "thread", + projectID: "missing-project", + title: "Unresolved project" + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: HomeThreadRowContext.fallback.copyContext + ) + + #expect(actions.contains { $0.kind == .project } == false) + } + + @Test + func rowContextFallsBackToProjectEnvironmentForBlankThreadEnvironment() throws { + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: " ", + title: "Blank environment" + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "Desk Mac", + endpoint: "http://device", + connectionState: .connected + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + threads: [thread] + ) + + let context = try #require(HomeThreadRowContext.index(snapshot: snapshot)[thread.id]) + + #expect(context.environmentLabel == "Desk Mac") + #expect(context.copyContext.environmentID == "device") + } + + @Test + func pullRequestIndicatorsUseTheCurrentThreadBranchAndPreserveTheirState() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Add native PR indicators", + branch: "feature/native-pull-requests" + ) + + for state in ["open", "merged", "closed"] { + let status = FeatureSourceControlStatus( + branch: "feature/native-pull-requests", + pullRequest: FeaturePullRequest( + number: 42, + title: "Add native PR indicators", + state: state, + updatedAt: "2026-08-28T12:30:45.123Z" + ) + ) + + let presentation = HomeThreadPullRequestPresentation.resolve( + thread: thread, + status: status + ) + + #expect(presentation?.label == "#42") + #expect(presentation?.state.rawValue == state) + #expect(presentation?.updatedAt != nil) + #expect(presentation?.accessibilityLabel == "Pull request #42, \(state)") + } + + let wholeSecond = FeatureSourceControlStatus( + branch: thread.branch, + pullRequest: FeaturePullRequest( + number: 42, + title: "Add native PR indicators", + state: "merged", + updatedAt: "2026-08-28T12:30:45Z" + ) + ) + #expect(HomeThreadPullRequestPresentation.resolve( + thread: thread, + status: wholeSecond + )?.updatedAt != nil) + } + + @Test + func pullRequestIndicatorsIgnoreOtherBranchesAndUnknownStates() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Task", + branch: "feature/current" + ) + let otherBranch = FeatureSourceControlStatus( + branch: "feature/other", + pullRequest: FeaturePullRequest(number: 42, title: "Other work", state: "open") + ) + let unsupportedState = FeatureSourceControlStatus( + branch: "feature/current", + pullRequest: FeaturePullRequest(number: 42, title: "Current work", state: "draft") + ) + let branchless = FeatureThread(id: "branchless", projectID: "project", title: "Task") + + #expect(HomeThreadPullRequestPresentation.resolve(thread: thread, status: otherBranch) == nil) + #expect(HomeThreadPullRequestPresentation.resolve(thread: thread, status: unsupportedState) == nil) + #expect(HomeThreadPullRequestPresentation.resolve(thread: branchless, status: otherBranch) == nil) + } + + @Test + func threadMenuOpensDurablePullRequestURL() throws { + let linked = ThreadLinkedPullRequest( + projectId: "project-wire", + repository: "pingdotgg/t3code", + number: 5178, + url: "https://github.com/pingdotgg/t3code/pull/5178" + ) + let thread = FeatureThread( + id: "thread", + projectID: "environment:project-wire", + environmentID: "studio", + environmentName: "Studio", + title: "Native client", + linkedPullRequest: linked + ) + + let destination = try #require(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: nil + )) + + #expect(destination.number == 5178) + #expect(destination.url.absoluteString == "https://github.com/pingdotgg/t3code/pull/5178") + } + + @Test + func threadMenuOpensBranchPullRequestsWithoutADurableLink() throws { + let project = FeatureProject( + id: "scoped-project", + wireID: "project-wire", + environmentID: "studio", + name: "T3 Code", + path: "/work/t3code" + ) + let thread = FeatureThread( + id: "thread", + projectID: project.id, + environmentID: "studio", + environmentName: "Studio", + title: "Native client", + branch: "feature/native" + ) + let pullRequest = FeaturePullRequest( + number: 42, + title: "Native client", + state: "open", + url: URL(string: "https://github.com/pingdotgg/t3code/pull/42") + ) + + let destination = try #require(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: pullRequest + )) + + #expect(destination.number == 42) + #expect(destination.url == pullRequest.url) + } + + @Test + func threadMenuRequiresPullRequestURL() throws { + let url = try #require(URL(string: "https://example.com/reviews/42")) + let thread = FeatureThread(id: "thread", projectID: "missing", title: "Task") + let pullRequest = FeaturePullRequest(number: 42, title: "Task", state: "open", url: url) + + let destination = try #require(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: pullRequest + )) + + #expect(destination.url == url) + #expect(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: nil + ) == nil) + #expect(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: FeaturePullRequest( + number: 42, + title: "Task", + state: "open" + ) + ) == nil) + } + + @Test + func liveSourceControlSnapshotsCarryPullRequestsAndClearMissingRemoteState() { + let local = VCSLocalStatus( + isRepo: true, + sourceControlProvider: nil, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/native-pull-requests", + hasWorkingTreeChanges: false, + workingTree: VCSWorkingTree(files: [], insertions: 0, deletions: 0) + ) + let remote = VCSRemoteStatus( + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + aheadOfDefaultCount: 2, + pr: VCSChangeRequest( + number: 42, + title: "Add native PR indicators", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/native-pull-requests", + state: "open" + ) + ) + + let status = NativeWorkspaceMapper.sourceControl(local: local, remote: remote) + let withoutRemote = NativeWorkspaceMapper.sourceControl(local: local, remote: nil) + + #expect(status.branch == "feature/native-pull-requests") + #expect(status.pullRequest?.number == 42) + #expect(status.pullRequest?.state == "open") + #expect(status.aheadCount == 2) + #expect(status.behindCount == 1) + #expect(withoutRemote.pullRequest == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift new file mode 100644 index 000000000000..bbbbeabbdc7d --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift @@ -0,0 +1,1319 @@ +import Foundation +import Observation +import Testing +import UIKit +@testable import T3Code + +@MainActor +@Suite("Home row trailing swipe actions") +struct HomeThreadSwipeActionTests { + private let now = Date(timeIntervalSince1970: 20_000) + + @Test + func backKeepsTheMostRecentlyOpenedThreadHighlighted() { + var selection = WorkspaceThreadSelection() + selection.open("first") + selection.close() + #expect(selection.selectedID == nil) + #expect(selection.highlightedID == "first") + + selection.open("second") + #expect(selection.selectedID == "second") + #expect(selection.highlightedID == "second") + selection.close() + #expect(selection.highlightedID == "second") + } + + @Test + func settlementOwnsTheEdgeSlotSoAFullSwipeSettles() { + let active = thread(id: "active") + let actions = HomeThreadSwipeAction.trailingActions( + for: active, + isArchived: false, + at: now + ) + + #expect(actions == [.settle, .delete]) + #expect(actions.first == .settle) + #expect(actions.first?.intent == .setSettled(true)) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: actions)) + #expect(actions.map(\.title) == ["Settle", "Delete"]) + } + + @Test + func pinnedRowsSettleFromTheEdgeAndKeepUnpinBesideIt() { + let pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + let actions = HomeThreadSwipeAction.trailingActions( + for: pinned, + isArchived: false, + at: now + ) + + #expect(actions == [.settle, .unpin, .delete]) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: actions)) + #expect(actions.map(\.title) == ["Settle", "Unpin", "Delete"]) + #expect(actions.map(\.systemImage) == ["checkmark", "pin.slash", "trash"]) + } + + /// The pinned shelf also holds settled threads, so the edge action has to be + /// able to reverse instead of settling a second time. + @Test + func settledRowsPutReopenAtTheEdge() { + var settled = thread(id: "settled") + settled.settlementFacts = .init(settlementOverride: .settled) + let settledActions = HomeThreadSwipeAction.trailingActions( + for: settled, + isArchived: false, + at: now + ) + #expect(settledActions == [.reopen, .delete]) + #expect(settledActions.first?.intent == .setSettled(false)) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: settledActions)) + + var pinnedSettled = thread(id: "pinned-settled", pinnedAt: now.addingTimeInterval(-30)) + pinnedSettled.settlementFacts = .init(settlementOverride: .settled) + #expect( + HomeThreadSwipeAction.trailingActions( + for: pinnedSettled, + isArchived: false, + at: now + ) == [.reopen, .unpin, .delete] + ) + + // Age alone does not settle a row. The server decides when it moves. + var resting = thread(id: "resting") + resting.lastActivityAt = now.addingTimeInterval(-4 * 24 * 60 * 60) + #expect( + HomeThreadSwipeAction.trailingActions( + for: resting, + isArchived: false, + at: now + ) == [.settle, .delete] + ) + } + + @Test + func rowsWithNothingToSettleKeepAReversibleEdgeActionAndNoFullSwipe() { + var unsupported = thread(id: "no-settlement") + unsupported.supportsSettlement = false + let unsupportedActions = HomeThreadSwipeAction.trailingActions( + for: unsupported, + isArchived: false, + at: now + ) + #expect(unsupportedActions == [.archive, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: unsupportedActions)) + + var pinnedUnsupported = thread( + id: "pinned-no-settlement", + pinnedAt: now.addingTimeInterval(-30) + ) + pinnedUnsupported.supportsSettlement = false + let pinnedActions = HomeThreadSwipeAction.trailingActions( + for: pinnedUnsupported, + isArchived: false, + at: now + ) + #expect(pinnedActions == [.unpin, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: pinnedActions)) + + // Archived rows stay restore-only, and restoring is not a full swipe. + var archived = thread(id: "archived", pinnedAt: now.addingTimeInterval(-30)) + archived.isArchived = true + archived.isSettled = true + let archivedActions = HomeThreadSwipeAction.trailingActions( + for: archived, + isArchived: true, + at: now + ) + #expect(archivedActions == [.restore, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: archivedActions)) + } + + @Test + func workingRowsNeverOfferSettlementOrAFullSwipe() { + for state in [ + FeatureThreadState.queued, + .working, + .monitoring, + .waitingForApproval, + .waitingForInput, + ] { + var active = thread(id: "active-\(state.rawValue)") + active.state = state + + let actions = HomeThreadSwipeAction.trailingActions( + for: active, + isArchived: false, + at: now + ) + + #expect(actions == [.archive, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: actions)) + } + } + + /// Delete must never reach the edge slot, because the edge slot is what a + /// full swipe runs. This sweeps every pinned/settled/capability/archived + /// combination rather than trusting the branch order. + @Test + func deleteIsNeverTheEdgeActionAndOnlySettlementArmsTheFullSwipe() { + for isSettled in [false, true] { + for isPinned in [false, true] { + for supportsSettlement in [nil, true, false] as [Bool?] { + for supportsPinning in [nil, true, false] as [Bool?] { + for isArchived in [false, true] { + var candidate = thread( + id: "row", + pinnedAt: isPinned ? now.addingTimeInterval(-30) : nil + ) + candidate.isSettled = isSettled + candidate.supportsSettlement = supportsSettlement + candidate.supportsPinning = supportsPinning + candidate.isArchived = isArchived + + let actions = HomeThreadSwipeAction.trailingActions( + for: candidate, + isArchived: isArchived, + at: now + ) + + #expect(actions.last == .delete) + #expect(actions.first != .delete) + #expect(actions.count == Set(actions).count) + #expect(actions.filter { $0.style == .destructive } == [.delete]) + #expect(actions.filter(\.isSettlement).count <= 1) + + let armsFullSwipe = HomeThreadSwipeAction.performsFullSwipe(with: actions) + #expect(armsFullSwipe == (actions.first?.isSettlement ?? false)) + if armsFullSwipe { + switch actions.first?.intent { + case .setSettled: + break + default: + Issue.record("A full swipe may only request settlement") + } + } + } + } + } + } + } + } + + @Test + func actionsRequestExactlyOneLifecycleMutationEach() { + #expect(HomeThreadSwipeAction.settle.intent == .setSettled(true)) + #expect(HomeThreadSwipeAction.reopen.intent == .setSettled(false)) + #expect(HomeThreadSwipeAction.unpin.intent == .setPinned(false)) + #expect(HomeThreadSwipeAction.archive.intent == .setArchived(true)) + #expect(HomeThreadSwipeAction.restore.intent == .setArchived(false)) + #expect(HomeThreadSwipeAction.delete.intent == .delete) + + #expect(HomeThreadSwipeAction.settle.isSettlement) + #expect(HomeThreadSwipeAction.reopen.isSettlement) + #expect(!HomeThreadSwipeAction.unpin.isSettlement) + #expect(!HomeThreadSwipeAction.delete.isSettlement) + + // The settlement actions keep the row's existing accent vocabulary and + // never inherit the destructive style that arms a destructive swipe. + #expect(HomeThreadSwipeAction.settle.style == .normal) + #expect(HomeThreadSwipeAction.settle.backgroundColor == .systemGreen) + #expect(HomeThreadSwipeAction.reopen.backgroundColor == .systemBlue) + #expect(HomeThreadSwipeAction.reopen.systemImage == "arrow.counterclockwise") + #expect(HomeThreadSwipeAction.delete.style == .destructive) + #expect(HomeThreadSwipeAction.delete.backgroundColor == nil) + } + + /// The full swipe carries no settlement logic of its own: its edge action is + /// applied through the same `FeatureRootModel.setSettled` call the context + /// menu uses, which reaches the client's real settlement request and clears + /// the pin, so one motion unpins and settles. + @Test + func aFullSwipeOnAPinnedRowSettlesThroughTheRealPathAndClearsThePin() async throws { + let client = SwipeSettlementClientStub() + var pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + pinned.lastActivityAt = now + client.snapshot = FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "environment", + name: "Studio", + path: "/studio" + ), + ], + threads: [pinned] + ) + let model = testRootModel(client: client) + await model.reload() + + #expect(presentation(for: model).pinned.map(\.id) == ["pinned"]) + + let actions = HomeThreadSwipeAction.trailingActions( + for: pinned, + isArchived: false, + at: now + ) + let edge = try #require(actions.first) + #expect(edge == .settle) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: actions)) + + // Applying the edge action the way the row's `onSettle` closure does. + guard case let .setSettled(settled) = edge.intent else { return } + await model.setSettled(pinned.id, settled: settled) + + #expect(client.settlementRequests == [SettlementRequest(id: "pinned", settled: true)]) + #expect(client.pinRequests.isEmpty) + let updated = try #require(model.snapshot.threads.first { $0.id == "pinned" }) + #expect(updated.isSettled) + #expect(!updated.keepsActive) + #expect(updated.settledAt != nil) + #expect(updated.pinnedAt == nil) + + // One motion: the row leaves the pinned shelf for Settled, where its + // edge action is now the reverse. + let shelves = presentation(for: model) + #expect(shelves.pinned.isEmpty) + #expect(shelves.settled.map(\.id) == ["pinned"]) + #expect( + HomeThreadSwipeAction.trailingActions(for: updated, isArchived: false, at: now) + == [.reopen, .delete] + ) + } + + @Test + func settlementLeavesTheActiveShelfBeforeTheServerResponds() async throws { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + + #expect(presentation(for: model).active.isEmpty) + #expect(presentation(for: model).settled.map(\.id) == [active.id]) + #expect(model.snapshot.threads.first?.isSettled == true) + + response?.resume() + #expect(await settlement.value) + + #expect(client.settlementRequests == [SettlementRequest(id: active.id, settled: true)]) + } + + @Test + func staleSnapshotsCannotRestoreThreadsWhileSettlementIsPending() async { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + + await model.reload() + + #expect(presentation(for: model).active.isEmpty) + #expect(model.snapshot.threads.first?.isSettled == true) + + response?.resume() + #expect(await settlement.value) + } + + @Test(arguments: PendingSettlementEvent.allCases) + func pendingSettlementSurvivesIncomingThreadAndDetailEvents( + event: PendingSettlementEvent + ) async { + let client = SwipeSettlementClientStub() + let active = thread(id: "active", pinnedAt: now.addingTimeInterval(-30)) + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + + let subscribed = AsyncStream.makeStream() + client.onEventsSubscribed = { subscribed.continuation.yield() } + let eventLoop = Task { await model.start() } + var subscriptions = subscribed.stream.makeAsyncIterator() + await subscriptions.next() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + let settledAt = model.snapshot.threads.first?.settledAt + + var authoritative = active + authoritative.title = "Updated on the server" + let changed = AsyncStream.makeStream() + withObservationTracking { + _ = model.snapshot.threads.first?.title + } onChange: { + changed.continuation.yield() + } + + switch event { + case .thread: + client.emit(.thread(authoritative)) + case .detail: + client.emit(.detail(FeatureThreadDetail(thread: authoritative))) + case .detailDelta: + client.emit(.detailDelta( + FeatureThreadDetail(thread: authoritative), + FeatureDetailDelta(changedMessages: []) + )) + } + + var changes = changed.stream.makeAsyncIterator() + await changes.next() + + let updated = model.snapshot.threads.first + #expect(updated?.title == "Updated on the server") + #expect(updated?.isSettled == true) + #expect(updated?.settledAt == settledAt) + #expect(updated?.pinnedAt == nil) + #expect(presentation(for: model).active.isEmpty) + if let detail = model.details[active.id] { + #expect(detail.thread.isSettled) + #expect(detail.thread.pinnedAt == nil) + } + + response?.resume() + #expect(await settlement.value) + client.finishEvents() + await eventLoop.value + } + + @Test + func failedSettlementPreservesNewerServerMetadataWhenRestoringItsFields() async { + let client = SwipeSettlementClientStub() + let pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + client.snapshot = snapshot(threads: [pinned]) + let model = testRootModel(client: client) + await model.reload() + + client.beforeSettlementResponse = { _ in + var authoritative = pinned + authoritative.title = "Updated on the server" + client.snapshot = snapshot(threads: [authoritative]) + await model.reload() + throw SwipeSettlementFailure.offline + } + + #expect(!(await model.setSettled(pinned.id, settled: true))) + let updated = model.snapshot.threads.first + #expect(updated?.title == "Updated on the server") + #expect(updated?.isSettled == false) + #expect(updated?.pinnedAt == pinned.pinnedAt) + } + + @Test + func settlementRejectedAfterAThreadStartsWorkingReturnsFailure() async { + let client = SwipeSettlementClientStub() + var working = thread(id: "working") + working.state = .working + client.snapshot = snapshot(threads: [working]) + let model = testRootModel(client: client) + await model.reload() + + #expect(!(await model.setSettled(working.id, settled: true))) + #expect(client.settlementRequests.isEmpty) + #expect(model.snapshot.threads == [working]) + } + + @Test + func consecutiveSettlementsLeaveTheInboxWithoutWaitingForEarlierRequests() async throws { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let remaining = thread(id: "remaining") + client.snapshot = snapshot(threads: [first, second, remaining]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var responses: [String: CheckedContinuation] = [:] + client.beforeSettlementResponse = { request in + try await withCheckedThrowingContinuation { continuation in + responses[request.id] = continuation + started.continuation.yield(request.id) + } + } + + var requests = started.stream.makeAsyncIterator() + let firstSettlement = Task { await model.setSettled(first.id, settled: true) } + #expect(await requests.next() == first.id) + #expect(!presentation(for: model).active.contains { $0.id == first.id }) + + let secondSettlement = Task { await model.setSettled(second.id, settled: true) } + #expect(await requests.next() == second.id) + #expect(presentation(for: model).active.map(\.id) == [remaining.id]) + + responses[second.id]?.resume() + #expect(await secondSettlement.value) + responses[first.id]?.resume() + #expect(await firstSettlement.value) + + #expect(Set(presentation(for: model).settled.map(\.id)) == [first.id, second.id]) + } + + @Test + func failedSettlementRestoresTheOriginalPinnedThread() async { + let client = SwipeSettlementClientStub() + let pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + client.snapshot = snapshot(threads: [pinned]) + client.beforeSettlementResponse = { _ in + throw SwipeSettlementFailure.offline + } + let model = testRootModel(client: client) + await model.reload() + + await model.setSettled(pinned.id, settled: true) + + #expect(model.snapshot.threads == [pinned]) + #expect(presentation(for: model).pinned.map(\.id) == [pinned.id]) + #expect(presentation(for: model).settled.isEmpty) + #expect(model.errorMessage == "The test environment is offline.") + } + + @Test + func reopeningImmediatelyMovesTheThreadToTheTopAndRestoresItsOrderOnFailure() async throws { + let client = SwipeSettlementClientStub() + var older = thread(id: "older") + older.createdAt = now.addingTimeInterval(-1_000) + older.isSettled = true + older.settledAt = now.addingTimeInterval(-20) + let newer = thread(id: "newer") + client.snapshot = snapshot(threads: [older, newer]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let reopening = Task { await model.setSettled(older.id, settled: false) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + + #expect(presentation(for: model).active.map(\.id) == ["older", "newer"]) + #expect(model.snapshot.threads.first(where: { $0.id == older.id })?.unsettledAt != nil) + response?.resume(throwing: SwipeSettlementFailure.offline) + #expect(!(await reopening.value)) + #expect(presentation(for: model).active.map(\.id) == ["newer"]) + let restored = try #require(model.snapshot.threads.first(where: { $0.id == older.id })) + #expect(restored.unsettledAt == older.unsettledAt) + #expect(restored.settledAt == older.settledAt) + } + + @Test + func anOlderFailedSettlementCannotUndoANewerReopen() async { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var delayedResponse: CheckedContinuation? + client.beforeSettlementResponse = { request in + guard request.settled else { return } + try await withCheckedThrowingContinuation { continuation in + delayedResponse = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + #expect(model.snapshot.threads.first?.isSettled == true) + + await model.setSettled(active.id, settled: false) + #expect(model.snapshot.threads.first?.isSettled == false) + let reopenedAt = model.snapshot.threads.first?.unsettledAt + #expect(reopenedAt != nil) + + delayedResponse?.resume(throwing: SwipeSettlementFailure.offline) + #expect(!(await settlement.value)) + + #expect(model.snapshot.threads.first?.isSettled == false) + #expect(model.snapshot.threads.first?.keepsActive == true) + #expect(model.snapshot.threads.first?.unsettledAt == reopenedAt) + #expect(client.settlementRequests == [ + SettlementRequest(id: active.id, settled: true), + SettlementRequest(id: active.id, settled: false), + ]) + } + + @Test(arguments: [false, true]) + func settlingReplacesTheInboxCellInsteadOfMovingAndResizingIt(forceRichRows: Bool) throws { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + let remaining = thread(id: "remaining") + var previouslySettled = thread(id: "previously-settled") + previouslySettled.isSettled = true + previouslySettled.settledAt = now.addingTimeInterval(-60) + let initial = threadList( + client: client, + snapshot: snapshot(threads: [active, remaining, previouslySettled]), + isSettledExpanded: true, + forceRichRows: forceRichRows + ) + var settled = active + settled.isSettled = true + settled.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settled, remaining, previouslySettled]), + isSettledExpanded: true, + forceRichRows: forceRichRows + ) + + let before = initial.collectionItems.map(\.id) + let after = updated.collectionItems.map(\.id) + let changes = after.difference(from: before).inferringMoves() + let changedThread = changes.filter { change in + switch change { + case let .remove(_, identifier, _), let .insert(_, identifier, _): + identifier.threadID == active.id + } + } + #expect(changedThread.count == 2) + for change in changedThread { + switch change { + case let .remove(_, _, associatedWith), let .insert(_, _, associatedWith): + #expect(associatedWith == nil) + } + } + let remainingID = try #require(before.first { $0.threadID == remaining.id }) + #expect(after.contains(remainingID)) + #expect(Set(after.compactMap(\.threadID)).count == 3) + + let reopened = before.difference(from: after).inferringMoves() + #expect(reopened.count == 2) + } + + @Test + func settlingKeepsTheSameCellInSearchResults() throws { + let client = SwipeSettlementClientStub() + let active = thread(id: "search") + var settled = active + settled.isSettled = true + let initial = threadList(client: client, snapshot: snapshot(threads: [active]), query: "Task") + let updated = threadList(client: client, snapshot: snapshot(threads: [settled]), query: "Task") + + #expect(initial.collectionItems.map(\.id) == updated.collectionItems.map(\.id)) + } + + @Test + func repeatedSwipeOnTheSameRowDoesNotSendAnotherSettlement() { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + var requests = 0 + let initial = threadList(client: client, snapshot: snapshot(threads: [active])) { _, _, _ in + requests += 1 + } + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + var firstResult: Bool? + var secondResult: Bool? + coordinator.performSwipe(.settle, for: active) { firstResult = $0 } + coordinator.performSwipe(.settle, for: active) { secondResult = $0 } + + #expect(requests == 1) + #expect(firstResult == nil) + #expect(secondResult == false) + coordinator.cancelPendingSwipeActions() + #expect(firstResult == false) + } + + @Test + func cancellationFinishesEachSwipeOnlyOnce() { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + var respond: ((Bool) -> Void)? + let initial = threadList( + client: client, + snapshot: snapshot(threads: [active]), + settlementResult: nil + ) { _, _, completion in + respond = completion + } + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { coordinator.invalidateTimer() } + var results: [Bool] = [] + coordinator.performSwipe(.settle, for: active) { results.append($0) } + + coordinator.cancelPendingSwipeActions() + respond?(false) + coordinator.cancelPendingSwipeActions() + + #expect(results == [false]) + } + + @Test + func swipeCompletionWaitsUntilTheCollectionHasRemovedTheThread() async { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let remaining = thread(id: "remaining") + let initial = snapshot(threads: [first, remaining]) + var requests: [SettlementRequest] = [] + let initialList = threadList(client: client, snapshot: initial) { thread, settled, _ in + requests.append(SettlementRequest(id: thread.id, settled: settled)) + } + let coordinator = initialList.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let completions = AsyncStream.makeStream() + var finished = false + coordinator.performSwipe(.settle, for: first) { succeeded in + finished = true + completions.continuation.yield(succeeded) + } + + #expect(requests == [SettlementRequest(id: first.id, settled: true)]) + #expect(!finished) + + var settled = first + settled.isSettled = true + settled.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settled, remaining]) + ) + coordinator.update(parent: updated, collectionView: collectionView) + + var results = completions.stream.makeAsyncIterator() + #expect(await results.next() == true) + #expect(finished) + } + + @Test + func settledSearchRowsFinishTheSwipeWithoutLeavingTheSearchResults() async throws { + let client = SwipeSettlementClientStub() + let active = thread(id: "search") + let initialList = threadList( + client: client, + snapshot: snapshot(threads: [active]), + query: "Task" + ) + let coordinator = initialList.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let completions = AsyncStream.makeStream() + coordinator.performSwipe(.settle, for: active) { + completions.continuation.yield($0) + } + + var settled = active + // Modern servers can report only the authoritative override. + settled.settlementFacts = .init(settlementOverride: .settled) + settled.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settled]), + query: "Task" + ) + coordinator.update(parent: updated, collectionView: collectionView) + + var results = completions.stream.makeAsyncIterator() + #expect(await results.next() == true) + #expect(collectionView.numberOfItems(inSection: 0) == 1) + collectionView.layoutIfNeeded() + let cell = try #require(collectionView.cellForItem(at: IndexPath(item: 0, section: 0))) + #expect(!cell.contentView.isHidden) + } + + @Test + func expandedSettledShelfKeepsAdjacentRowsAtTheirFullHeight() async throws { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + let remaining = thread(id: "remaining") + let initial = threadList( + client: client, + snapshot: snapshot(threads: [active, remaining]), + isSettledExpanded: true + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + let original = try #require(collectionView.visibleCells.first { $0.accessibilityLabel == remaining.title }) + let originalHeight = original.bounds.height + let completions = AsyncStream.makeStream() + coordinator.performSwipe(.settle, for: active) { completions.continuation.yield($0) } + + var settled = active + settled.isSettled = true + settled.settledAt = now + coordinator.update( + parent: threadList( + client: client, + snapshot: snapshot(threads: [settled, remaining]), + isSettledExpanded: true + ), + collectionView: collectionView + ) + var results = completions.stream.makeAsyncIterator() + #expect(await results.next() == true) + collectionView.layoutIfNeeded() + + let remainingCell = try #require(collectionView.visibleCells.first { $0.accessibilityLabel == remaining.title }) + let settledCell = try #require(collectionView.visibleCells.first { $0.accessibilityLabel == active.title }) + #expect(abs(remainingCell.bounds.height - originalHeight) < 0.5) + #expect(remainingCell.frame.maxY <= settledCell.frame.minY) + #expect(settledCell.bounds.height < remainingCell.bounds.height) + #expect(collectionView.visibleCells.filter { $0.accessibilityLabel == active.title }.count == 1) + } + + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func streamUpdatesAndRollbackWaitForTheRemovalAnimation(rollbackFirst: Bool) async throws { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let remaining = thread(id: "remaining") + var respondToFirst: ((Bool) -> Void)? + let initial = threadList( + client: client, + snapshot: snapshot(threads: [first, second, remaining]), + isSettledExpanded: true, + settlementResult: nil + ) { _, _, completion in respondToFirst = completion } + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + + let controller = UIViewController() + controller.view = collectionView + let window = UIWindow(frame: collectionView.frame) + window.rootViewController = controller + window.isHidden = false + defer { + window.isHidden = true + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + #expect(collectionView.window === window) + let retainedCell = try #require(collectionView.visibleCells.first { + $0.accessibilityLabel == remaining.title + }) + + let completions = AsyncStream.makeStream() + var swipeResults: [String: [Bool]] = [:] + coordinator.performSwipe(.settle, for: first) { succeeded in + swipeResults[first.id, default: []].append(succeeded) + completions.continuation.yield(first.id) + } + var settledFirst = first + settledFirst.isSettled = true + settledFirst.settledAt = now + coordinator.update( + parent: threadList( + client: client, + snapshot: snapshot(threads: [settledFirst, second, remaining]), + isSettledExpanded: true + ), + collectionView: collectionView + ) + + var latest = remaining + for update in 0..<100 { + latest.title = "Latest server title \(update)" + coordinator.update( + parent: threadList( + client: client, + snapshot: snapshot(threads: [settledFirst, second, latest]), + isSettledExpanded: true + ), + collectionView: collectionView + ) + } + if !UIAccessibility.isReduceMotionEnabled, UIView.areAnimationsEnabled { + #expect(retainedCell.accessibilityLabel == remaining.title) + } + + if rollbackFirst { respondToFirst?(false) } + coordinator.performSwipe(.settle, for: second) { succeeded in + swipeResults[second.id, default: []].append(succeeded) + completions.continuation.yield(second.id) + } + var settledSecond = second + settledSecond.isSettled = true + settledSecond.settledAt = now + coordinator.update( + parent: threadList( + client: client, + snapshot: snapshot(threads: [rollbackFirst ? first : settledFirst, settledSecond, latest]), + isSettledExpanded: true + ), + collectionView: collectionView + ) + + var results = completions.stream.makeAsyncIterator() + let finished = await [results.next(), results.next()].compactMap { $0 } + #expect(Set(finished) == [first.id, second.id]) + #expect(swipeResults[first.id]?.count == 1) + #expect(swipeResults[second.id] == [true]) + collectionView.layoutIfNeeded() + #expect(retainedCell.accessibilityLabel == latest.title) + let firstCell = try #require(collectionView.visibleCells.first { $0.accessibilityLabel == first.title }) + #expect(firstCell.accessibilityValue?.contains("Settled") == !rollbackFirst) + #expect(!firstCell.contentView.isHidden) + #expect(!retainedCell.contentView.isHidden) + let threadCells = collectionView.visibleCells.filter { $0.accessibilityTraits.contains(.button) } + let frames = threadCells.map(\.frame).sorted { $0.minY < $1.minY } + for (before, after) in zip(frames, frames.dropFirst()) { + #expect(before.maxY <= after.minY + 0.5) + } + } + + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func departingTextIsHiddenBeforeNeighborFramesChange(isSettledExpanded: Bool) async throws { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let remaining = thread(id: "remaining") + let initial = threadList( + client: client, + snapshot: snapshot(threads: [first, remaining]), + isSettledExpanded: isSettledExpanded + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + let outgoing = try #require(collectionView.visibleCells.first { $0.accessibilityLabel == first.title }) + let neighbor = try #require(collectionView.visibleCells.first { $0.accessibilityLabel == remaining.title }) + + let controller = UIViewController() + controller.view = collectionView + let window = UIWindow(frame: collectionView.frame) + window.rootViewController = controller + window.isHidden = false + defer { + window.isHidden = true + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let originalIndexPath = try #require(collectionView.indexPath(for: outgoing)) + let originalNeighborFrame = neighbor.frame + var hidBeforeSnapshot = false + // XCTest can complete UIKit animations without rendering any frames. + // Observe the visibility change before the collection starts its update. + let observation = outgoing.contentView.observe(\.isHidden, options: [.new]) { _, change in + MainActor.assumeIsolated { + guard change.newValue == true else { return } + if collectionView.indexPath(for: outgoing) == originalIndexPath, + neighbor.frame == originalNeighborFrame, + !neighbor.contentView.isHidden { + hidBeforeSnapshot = true + } + } + } + defer { observation.invalidate() } + let completions = AsyncStream.makeStream() + coordinator.performSwipe(.settle, for: first) { succeeded in + completions.continuation.yield(succeeded) + } + var settled = first + settled.isSettled = true + settled.settledAt = now + coordinator.update( + parent: threadList( + client: client, + snapshot: snapshot(threads: [settled, remaining]), + isSettledExpanded: isSettledExpanded + ), + collectionView: collectionView + ) + + var results = completions.stream.makeAsyncIterator() + #expect(await results.next() == true) + #expect(hidBeforeSnapshot == !UIAccessibility.isReduceMotionEnabled) + collectionView.layoutIfNeeded() + #expect(!neighbor.contentView.isHidden) + if isSettledExpanded { + let settledCell = try #require(collectionView.visibleCells.first { + $0.accessibilityLabel == first.title + }) + #expect(!settledCell.contentView.isHidden) + } + } + + @Test + func failedSettlementClosesTheSwipeWithoutACollectionUpdate() { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + let initial = threadList( + client: client, + snapshot: snapshot(threads: [active]), + settlementResult: false + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + var result: Bool? + coordinator.performSwipe(.settle, for: active) { result = $0 } + + #expect(result == false) + } + + @Test + func consecutiveSwipeCompletionsResolveFromTheSameCollectionUpdate() async { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let remaining = thread(id: "remaining") + let initial = threadList( + client: client, + snapshot: snapshot(threads: [first, second, remaining]) + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let completions = AsyncStream.makeStream() + coordinator.performSwipe(.settle, for: first) { succeeded in + if succeeded { completions.continuation.yield(first.id) } + } + coordinator.performSwipe(.settle, for: second) { succeeded in + if succeeded { completions.continuation.yield(second.id) } + } + + var settledFirst = first + settledFirst.isSettled = true + settledFirst.settledAt = now + var settledSecond = second + settledSecond.isSettled = true + settledSecond.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settledFirst, settledSecond, remaining]) + ) + coordinator.update(parent: updated, collectionView: collectionView) + + var results = completions.stream.makeAsyncIterator() + let completed = await [results.next(), results.next()].compactMap { $0 } + #expect(Set(completed) == [first.id, second.id]) + } + + @Test + func threadCellsClipContentWhileTheirRowsCollapse() throws { + let client = SwipeSettlementClientStub() + let initial = threadList( + client: client, + snapshot: snapshot(threads: [thread(id: "visible")]) + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let cell = try #require(collectionView.cellForItem(at: IndexPath(item: 0, section: 0))) + + #expect(cell.clipsToBounds) + #expect(cell.contentView.clipsToBounds) + } + + @Test + func selectionUpdatesWhenOtherRowsArriveInTheSameSnapshot() throws { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let initial = threadList( + client: client, snapshot: snapshot(threads: [first, second]), selectedThreadID: first.id + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + let firstCell = try #require(collectionView.visibleCells.first { + $0.accessibilityLabel == first.title + }) + #expect(firstCell.accessibilityTraits.contains(.selected)) + + let updated = threadList( + client: client, + snapshot: snapshot(threads: [first, second, thread(id: "arrived")]), + selectedThreadID: second.id + ) + coordinator.update(parent: updated, collectionView: collectionView) + collectionView.layoutIfNeeded() + let selected = collectionView.visibleCells.filter { $0.accessibilityTraits.contains(.selected) } + #expect(selected.count == 1) + #expect(selected.first?.accessibilityLabel == second.title) + } + + private func presentation(for model: FeatureRootModel) -> HomePresentation { + HomePresentation( + snapshot: model.snapshot, + query: "", + projectID: nil, + now: now + ) + } + + private func snapshot(threads: [FeatureThread]) -> FeatureSnapshot { + FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "environment", + name: "Studio", + path: "/studio" + ), + ], + threads: threads + ) + } + + private func threadList( + client: SwipeSettlementClientStub, + snapshot: FeatureSnapshot, + query: String = "", + selectedThreadID: String? = nil, + isSettledExpanded: Bool = false, + forceRichRows: Bool = false, + settlementResult: Bool? = true, + onSettle: @escaping (FeatureThread, Bool, @escaping (Bool) -> Void) -> Void = { _, _, _ in } + ) -> HomeThreadCollectionView { + HomeThreadCollectionView( + presentation: HomePresentation( + snapshot: snapshot, + query: query, + projectID: nil, + now: now + ), + projectFaviconClient: client, + query: query, + selectedThreadID: selectedThreadID, + forceRichRows: forceRichRows, + hapticsEnabled: false, + settings: snapshot.settings, + pullRequestsByThreadID: [:], + isSnoozedExpanded: false, + isSettledExpanded: isSettledExpanded, + isArchiveExpanded: false, + settledLimit: 12, + onOpen: { _ in }, + onToggleSnoozed: {}, + onToggleSettled: {}, + onToggleArchive: {}, + onShowMoreSettled: {}, + onRename: { _ in }, + onRegenerateTitle: { _ in }, + onArchive: { _, _ in }, + onSettle: { thread, settled, completion in + onSettle(thread, settled, completion) + if let settlementResult { completion(settlementResult) } + }, + onSnooze: { _, _ in }, + onPin: { _, _ in }, + onDelete: { _ in }, + onPullRequestChange: { _, _, _ in } + ) + } + + private func testCollectionView() -> UICollectionView { + UICollectionView( + frame: CGRect(x: 0, y: 0, width: 390, height: 844), + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .plain) + ) + ) + } + + private func thread( + id: String, + pinnedAt: Date? = nil + ) -> FeatureThread { + FeatureThread( + id: id, + projectID: "project", + title: "Task \(id)", + createdAt: now.addingTimeInterval(-100), + updatedAt: now.addingTimeInterval(-50), + state: .idle, + lastActivityAt: now.addingTimeInterval(-50), + pinnedAt: pinnedAt, + supportsSettlement: true, + supportsPinning: true + ) + } +} + +enum PendingSettlementEvent: CaseIterable { + case thread + case detail + case detailDelta +} + +@MainActor +private func testRootModel(client: SwipeSettlementClientStub) -> FeatureRootModel { + FeatureRootModel( + client: client, + outboxStore: FeatureOutboxStore( + fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swipe-settlement-outbox-\(UUID().uuidString).json") + ) + ) +} + +private struct SettlementRequest: Equatable { + let id: String + let settled: Bool +} + +private enum SwipeSettlementFailure: LocalizedError { + case offline + + var errorDescription: String? { + "The test environment is offline." + } +} + +/// Records the settlement requests the feature client actually receives, so the +/// swipe action's wiring is proved against the real client call rather than a +/// view-local shortcut. +@MainActor +private final class SwipeSettlementClientStub: FeatureClient { + private let eventStream: AsyncStream + private let eventContinuation: AsyncStream.Continuation + var snapshot = FeatureSnapshot() + var settlementRequests: [SettlementRequest] = [] + var pinRequests: [String] = [] + var beforeSettlementResponse: ((SettlementRequest) async throws -> Void)? + var onEventsSubscribed: (() -> Void)? + + init() { + let events = AsyncStream.makeStream() + eventStream = events.stream + eventContinuation = events.continuation + } + + func initialSnapshot() async throws -> FeatureSnapshot { snapshot } + + func events() -> AsyncStream { + onEventsSubscribed?() + return eventStream + } + + func emit(_ event: FeatureEvent) { + eventContinuation.yield(event) + } + + func finishEvents() { + eventContinuation.finish() + } + + func setThreadSettled(id: String, settled: Bool) async throws { + let request = SettlementRequest(id: id, settled: settled) + settlementRequests.append(request) + try await beforeSettlementResponse?(request) + } + + func setThreadPinned(id: String, pinned: Bool) async throws { + pinRequests.append(id) + } + + func pair(endpoint: String, token: String?) async throws {} + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + FeatureThread(id: "created", projectID: projectID, title: title ?? "Created") + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func deleteThread(id: String) async throws {} + + func loadThread(id: String) async throws -> FeatureThreadDetail { + FeatureThreadDetail( + thread: snapshot.threads.first { $0.id == id } + ?? FeatureThread(id: id, projectID: "project", title: "Task") + ) + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws {} + func cancelTurn(threadID: String) async throws {} + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func saveSettings(_ settings: FeatureSettings) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/MainParityTests.swift b/apps/swift-ios/Tests/FeatureTests/MainParityTests.swift new file mode 100644 index 000000000000..6367a9bc710f --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MainParityTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import T3Code + +struct MainParityTests { + @Test func unknownImageSupportDoesNotChangeTheRestOfTheCatalog() { + var saved = FeatureModel(id: "custom", name: "Custom") + saved.imageSupportIsUnknown = true + var provider = FeatureProvider(id: "codex", name: "Codex", models: [ + .init(id: "old-server-model", name: "Old server model"), saved, + ]) + #expect(DailyUXModelOptions.supportsImages(selection: .init(providerID: "codex", modelID: "old-server-model"), providers: [provider])) + provider.models.append(.init(id: "vision", name: "Vision", supportsImages: true)) + #expect(DailyUXModelOptions.supportsImages(selection: .init(providerID: "codex", modelID: "custom"), providers: [provider])) + } + + @Test func sharedPreferencesExcludeMachineSettings() throws { + let settings = try JSONDecoder().decode(ServerSettingsSnapshot.self, from: Data(#"{"defaultThreadEnvMode":"worktree","newWorktreesStartFromOrigin":false,"sidebarAutoSettleAfterDays":null,"sidebarAutoSettleOnMerge":false,"environmentIcon":"mac-mini","sourceControlWritingStyle":{"mode":"conventional_commits","customInstructions":"","followChangeRequestTemplates":true}}"#.utf8)) + #expect(settings.sharedPatch["environmentIcon"] == nil) + #expect(settings.sharedPatch["defaultThreadEnvMode"] == .string("worktree")) + #expect(settings.sharedPatch["sidebarAutoSettleAfterDays"] == .null) + #expect(settings.sharedPatch["sourceControlWritingStyle"]?["mode"] == .string("conventional_commits")) + } + + @Test func toolIconsRejectLocalURLsAndResolveNativeApps() { + let presentation = ToolActivityPresentation(payload: .object([ + "toolSurface": .string("computer"), + "toolIcon": .object(["_tag": .string("native-app"), "app": .object([ + "_tag": .string("app-id"), "appId": .string("com.apple.Safari"), + ])]), + ])) + #expect(presentation?.nativeApp?.appId == "com.apple.Safari") + let invalid = ToolActivityPresentation(payload: .object([ + "toolIcon": .object(["_tag": .string("themed-logo"), "logoUrl": .string("file:///private/icon.png")]), + ])) + #expect(invalid == nil) + } + + @Test func externalSchemesAreNotWorkspaceFiles() throws { + for raw in ["mailto:user@example.com", "ftp://example.com/file.txt", "custom://host/file.txt"] { + #expect(MarkdownWorkspaceFileLink.relativePath(for: try #require(URL(string: raw)), workspaceRoot: "/repo") == nil) + } + #expect(MarkdownWorkspaceFileLink.relativePath(for: try #require(URL(string: "C:/repo/file.txt")), workspaceRoot: "C:/repo") == "file.txt") + } + + @Test func machineIconsHaveASafeFallback() throws { + var environment = FeatureEnvironment(id: "a", name: "A", endpoint: "https://example.test") + environment.machineIcon = "mac-mini" + #expect(environment.systemImage == "macmini") + let data = try JSONEncoder().encode(environment) + #expect(try JSONDecoder().decode(FeatureEnvironment.self, from: data).machineIcon == "mac-mini") + environment.machineIcon = "future-machine" + #expect(environment.systemImage == "server.rack") + } + + @Test func projectIconsRetainServerMetadata() throws { + let icon = try JSONDecoder().decode(ProjectIconOverride.self, from: Data(#"{"kind":"emoji","emoji":"🐈"}"#.utf8)) + var project = FeatureProject(id: "a", environmentID: "b", name: "Project", path: "/repo") + project.projectIcon = icon + #expect(try JSONDecoder().decode(FeatureProject.self, from: JSONEncoder().encode(project)).projectIcon == icon) + #expect(ProjectIconPresentation.symbol("future-icon") == "folder") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift new file mode 100644 index 000000000000..b1a723a4229c --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift @@ -0,0 +1,833 @@ +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Chat Markdown") +struct MarkdownDocumentTests { + @Test + func ordinaryMarkdownKeepsItsTextAndLineEndingsWithoutCitations() { + let source = """ + # A long response + + \(String(repeating: "[unfinished `code **text** café 日本語 👩🏽‍💻 ", count: 300)) + + [docs](https://t3.gg) and :codex-file-citation + """.replacingOccurrences(of: "\n", with: "\r\n") + + #expect(CodexMarkdownDirectives.replacingFileCitations(in: source) == source) + } + + @Test + func sparseCitationsKeepFenceStateAndUnrelatedLines() { + let directive = #":codex-file-citation{path="src/main.swift" line_range_start="12"}"# + let unfinishedLinks = String(repeating: "[unfinished ", count: 1_000) + let source = """ + \(unfinishedLinks) + + ```text + This fence opens on a line without a citation. + \(directive) + ``` + + See \(directive). + """ + + #expect( + CodexMarkdownDirectives.replacingFileCitations(in: source) == """ + \(unfinishedLinks) + + ```text + This fence opens on a line without a citation. + \(directive) + ``` + + See [main.swift](). + """ + ) + } + + @Test + func blockPrefixChecksKeepThreeSpaceIndentationAndRejectFourSpaces() { + for indent in ["", " ", " ", " "] { + #expect( + MarkdownDocument(parsing: "\(indent)# Heading").blocks + == [.heading(level: 1, text: "Heading")] + ) + #expect( + MarkdownDocument(parsing: "\(indent)> Body").blocks + == [.blockquote(MarkdownDocument(parsing: "Body"))] + ) + #expect( + MarkdownDocument(parsing: "\(indent)- Item").blocks + == [.unorderedList([MarkdownListItem(task: nil, blocks: [.paragraph("Item")])])] + ) + #expect( + MarkdownDocument(parsing: "\(indent)```swift\nlet value = 1\n\(indent)```").blocks + == [.codeBlock(language: "swift", code: "let value = 1")] + ) + } + + for source in [" # Heading", " > Body", " - Item", " ```swift"] { + #expect(MarkdownDocument(parsing: source).blocks == [.paragraph(source)]) + } + } + + @Test + func codexFileCitationsBecomeWorkspaceLinks() { + let document = MarkdownDocument( + parsing: #"See :codex-file-citation{path="docs/My file%#?.md" line_range_start="12"}."# + ) + + #expect( + document.blocks == [ + .paragraph("See [My file%#?.md]()."), + ] + ) + } + + @Test + func codexFileCitationsEscapeLabelsAndDestinations() { + let document = MarkdownDocument( + parsing: #":codex-file-citation{path=" reports/*draft*_[copy]`<&).txt "}"# + ) + + #expect( + document.blocks == [ + .paragraph( + #"[\*draft\*\_\[copy\]\`\<\&).txt]()"# + ), + ] + ) + + #expect( + CodexMarkdownDirectives.fileCitation( + from: ":codex-file-citation{path=\"reports/a\r\n.txt\"}" + ) == "[a\\\r\n.txt]()" + ) + } + + @Test + func codexFileCitationEndIsQuoteAwareAndSupportsSingleQuotes() { + let document = MarkdownDocument( + parsing: #":codex-file-citation{path='reports/a}b file.md' line_range_start=' 9 '}"# + ) + + #expect(document.blocks == [.paragraph("[a}b file.md]()")]) + + #expect( + MarkdownDocument( + parsing: ":codex-file-citation{path=reports/unquoted.md line_range_start=3}" + ).blocks == [.paragraph("[unquoted.md]()")] + ) + } + + @Test + func codexFileCitationsStayLiteralInEscapedCodeAndReferenceLinks() { + let directive = #":codex-file-citation{path="src/file.swift"}"# + let document = MarkdownDocument( + parsing: """ + \\`\(directive)\\` + + [outer [nested \(directive)] label][ref] + + [ref]: docs/reference.md + """ + ) + + #expect(document.blocks[0] == .paragraph("\\`[file.swift]()\\`")) + #expect(document.blocks[1] == .paragraph("[outer [nested \(directive)] label][ref]")) + } + + @Test + func codexFileCitationsStayLiteralInCodeAndLinks() { + let directive = #":codex-file-citation{path="src/file.swift" line_range_start="2"}"# + let document = MarkdownDocument( + parsing: """ + `\(directive)` + + \(directive) + + ```text + \(directive) + ``` + + [existing \(directive)](docs/existing.md) + """ + ) + + #expect(document.blocks[0] == .paragraph("`\(directive)`")) + #expect(document.blocks[1] == .paragraph(" \(directive)")) + #expect(document.blocks[2] == .codeBlock(language: "text", code: directive)) + #expect(document.blocks[3] == .paragraph("[existing \(directive)](docs/existing.md)")) + } + + @Test + func malformedAndIncompleteCodexDirectivesStayLiteral() { + let missingPath = #":codex-file-citation{line_range_start="4"}"# + let incomplete = #":codex-file-citation{path="src/file.swift""# + let invalidLine = #":codex-file-citation{path="src/file.swift" line_range_start="zero"}"# + let document = MarkdownDocument(parsing: "\(missingPath)\n\(incomplete)\n\(invalidLine)") + + #expect( + document.blocks == [ + .paragraph("\(missingPath)\n\(incomplete)\n[file.swift]()"), + ] + ) + } + + @Test + func parsesArtifactTemplatesInsideNestedLists() { + let directive = #"::artifact-template{artifact_kind="document" display_name="Release notes template" skill_directory="/templates/release notes" skill_name="artifact-template-release" gallery_kind="imagegen"}"# + let document = MarkdownDocument(parsing: "- Templates\n - \(directive)") + guard case let .unorderedList(items) = document.blocks.first, + case let .unorderedList(children) = items.first?.blocks.last, + case let .artifactTemplate(template) = children.first?.blocks.first else { + Issue.record("Expected a nested artifact template") + return + } + + #expect(template.displayName == "Release notes template") + #expect(template.kind == .document) + #expect(template.usePrompt == "Create a document using this $artifact-template-release about…") + #expect(template.useURL?.scheme == "t3code") + } + + @Test + func invalidArtifactTemplateAttributesStayLiteral() { + for directive in [ + #"::artifact-template{artifact_kind="video" display_name="Demo" skill_directory="/tmp" skill_name="artifact-template-demo"}"#, + #"::artifact-template{artifact_kind="image" display_name="Demo" skill_directory="relative" skill_name="artifact-template-demo"}"#, + #"::artifact-template{artifact_kind="image" display_name="Demo" skill_directory="C:\\templates" skill_name="wrong"}"#, + #"::artifact-template{artifact_kind="image" display_name="Demo" skill_directory="/tmp" skill_name="artifact-template-demo" gallery_kind="unknown"}"#, + ] { + #expect(MarkdownDocument(parsing: directive).blocks == [.paragraph(directive)]) + } + + let validButIndented = #" ::artifact-template{artifact_kind="document" display_name="Demo" skill_directory="/tmp" skill_name="artifact-template-demo"}"# + #expect( + MarkdownDocument(parsing: validButIndented).blocks == [.paragraph(validButIndented)] + ) + } + + @Test + func relativeImagesCanResolveFromTheViewedSourceFile() { + #expect( + MarkdownImageSource.classify( + "images/preview.png", + workspaceRoot: "/workspace/project/docs" + ) == .workspaceFile("/workspace/project/docs/images/preview.png") + ) + } + + @Test + func workspaceFileLinksResolveRelativeAbsoluteAndSpacedPaths() throws { + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "docs/My%20Folder/checklist.xml")), + workspaceRoot: "/workspace/project" + ) == "docs/My Folder/checklist.xml" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "Updated%20cutover%20checklist.md")), + workspaceRoot: "/workspace/project" + ) == "Updated cutover checklist.md" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "file:///workspace/project/src/main.swift#L18")), + workspaceRoot: "/workspace/project" + ) == "src/main.swift" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "/workspace/project/src/a%23b%3Fc%25.swift#L2")), + workspaceRoot: "/workspace/project" + ) == "src/a#b?c%.swift" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require( + URL(string: "file:///workspace/project/src/a%23b%3Fc%25.swift#L2") + ), + workspaceRoot: "/workspace/project" + ) == "src/a#b?c%.swift" + ) + } + + @Test + func workspaceFileLinksRejectExternalAndEscapedPaths() throws { + for value in ["https://example.com/file.md", "javascript:alert(1)", "../private.md", + "file:///other/project/file.md"] { + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: value)), + workspaceRoot: "/workspace/project" + ) == nil + ) + } + } + + @Test + func separatesHeadingsParagraphsAndListKinds() { + let document = MarkdownDocument( + parsing: """ + # Release notes + + Includes **important** details. + + - First + - [x] Shipped + - [ ] Follow up + + 3. Third + 4. Fourth + """ + ) + + #expect( + document.blocks == [ + .heading(level: 1, text: "Release notes"), + .paragraph("Includes **important** details."), + .unorderedList([ + MarkdownListItem(task: nil, blocks: [.paragraph("First")]), + MarkdownListItem(task: .complete, blocks: [.paragraph("Shipped")]), + MarkdownListItem(task: .incomplete, blocks: [.paragraph("Follow up")]), + ]), + .orderedList( + start: 3, + items: [ + MarkdownListItem(task: nil, blocks: [.paragraph("Third")]), + MarkdownListItem(task: nil, blocks: [.paragraph("Fourth")]), + ] + ), + ] + ) + } + + @Test + func separatesMarkdownImagesFromSurroundingParagraphText() { + let document = MarkdownDocument( + parsing: "Before ![Build result](images/result.png) after\n\n![Preview]( \"Title\")" + ) + + #expect( + document.blocks == [ + .paragraph("Before"), + .image(MarkdownImage(source: "images/result.png", alternativeText: "Build result")), + .paragraph("after"), + .image(MarkdownImage(source: "", alternativeText: "Preview")), + ] + ) + } + + @Test + func markdownImageSourcesDistinguishRemoteAndWorkspaceImages() { + #expect( + MarkdownImageSource.classify("https://example.com/image.png", workspaceRoot: "/repo") + == .direct(URL(string: "https://example.com/image.png")!) + ) + #expect( + MarkdownImageSource.classify("//cdn.example.com/image.png") + == .direct(URL(string: "https://cdn.example.com/image.png")!) + ) + #expect( + MarkdownImageSource.classify("images/result.png", workspaceRoot: "/workspace/project") + == .workspaceFile("/workspace/project/images/result.png") + ) + #expect( + MarkdownImageSource.classify( + "images/result.png", + workspaceRoot: #"C:\Users\theo\project"# + ) == .workspaceFile(#"C:\Users\theo\project\images\result.png"#) + ) + #expect( + MarkdownImageSource.classify("file:///workspace/project/image%20one.png") + == .workspaceFile("/workspace/project/image one.png") + ) + #expect( + MarkdownImageSource.classify("file://server/share/image.png") + == .workspaceFile(#"\\server\share\image.png"#) + ) + #expect( + MarkdownImageSource.classify("/C:/Users/theo/image.png") + == .workspaceFile("C:/Users/theo/image.png") + ) + } + + @Test + func markdownImageSourcesRejectUnsafeAndUnresolvedDestinations() { + for source in ["", "#image", "?image=1", "image.png", "~/image.png", + "javascript:alert(1)", "ftp://example.com/image.png", + "content://media/image/1"] { + #expect(MarkdownImageSource.classify(source) == .blocked) + } + } + + @Test + func preservesNestedStructureInsideQuotesAndLists() { + let document = MarkdownDocument( + parsing: """ + > ## Heads up + > Read this first. + > + > - Quoted item + + - Parent + - Nested child + """ + ) + + guard case let .blockquote(quote) = document.blocks.first else { + Issue.record("Expected a block quote") + return + } + #expect( + quote.blocks == [ + .heading(level: 2, text: "Heads up"), + .paragraph("Read this first."), + .unorderedList([ + MarkdownListItem(task: nil, blocks: [.paragraph("Quoted item")]), + ]), + ] + ) + + guard case let .unorderedList(items) = document.blocks.last else { + Issue.record("Expected an unordered list") + return + } + #expect( + items == [ + MarkdownListItem( + task: nil, + blocks: [ + .paragraph("Parent"), + .unorderedList([ + MarkdownListItem(task: nil, blocks: [.paragraph("Nested child")]), + ]), + ] + ), + ] + ) + } + + @Test + func parsesTablesWithAlignmentEscapesAndNormalizedRows() { + let document = MarkdownDocument( + parsing: """ + | Name | Status | Notes | + | :--- | :---: | ---: | + | Parser | Ready | **Fast** | + | Escaped \\| pipe | ``a|b`` | [Docs](https://example.com) | + | Short | Row | + | Extra | cells | stay | ignored | + """ + ) + + #expect( + document.blocks == [ + .table( + MarkdownTable( + header: ["Name", "Status", "Notes"], + alignments: [.leading, .center, .trailing], + rows: [ + ["Parser", "Ready", "**Fast**"], + ["Escaped \\| pipe", "``a|b``", "[Docs](https://example.com)"], + ["Short", "Row", ""], + ["Extra", "cells", "stay"], + ] + ) + ), + ] + ) + } + + @Test + func unmatchedBacktickDoesNotHideLaterTableSeparators() { + let document = MarkdownDocument( + parsing: """ + Left | Middle | Right + --- | --- | --- + x | `y | z + """ + ) + + #expect( + document.blocks == [ + .table( + MarkdownTable( + header: ["Left", "Middle", "Right"], + alignments: [.natural, .natural, .natural], + rows: [["x", "`y", "z"]] + ) + ), + ] + ) + } + + @Test + func rejectsTableDelimiterCellsWithFewerThanThreeDashes() { + let document = MarkdownDocument( + parsing: """ + Name | Status + -- | --- + Parser | Ready + """ + ) + + #expect( + document.blocks == [ + .paragraph("Name | Status\n-- | ---\nParser | Ready"), + ] + ) + } + + @Test + func rendersTableCellsThroughTheInlineMarkdownCache() throws { + let source = """ + Label | Value + --- | --- + **Build** | `green` + """ + let revision = MarkdownContentRevision(source) + let rendered = try #require( + MarkdownRenderCache.shared.documentImmediately(for: revision) + ) + guard case let .table(table) = rendered.blocks.first else { + Issue.record("Expected a rendered table") + return + } + + #expect(String(table.header[0].attributedText.characters) == "Label") + #expect(String(table.rows[0][0].attributedText.characters) == "Build") + #expect( + table.rows[0][0].attributedText.runs.contains { + $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true + } + ) + #expect( + table.rows[0][1].attributedText.runs.contains { + $0.inlinePresentationIntent?.contains(.code) == true + } + ) + } + + @Test + func fencedCodeKeepsLanguageAndContentsLiteral() { + let document = MarkdownDocument( + parsing: """ + ```swift + let value = "**not emphasis**" + print(value) + ``` + """ + ) + + #expect( + document.blocks == [ + .codeBlock( + language: "swift", + code: "let value = \"**not emphasis**\"\n print(value)" + ), + ] + ) + } + + @Test + func unclosedFenceConsumesTheRemainingMessage() { + let document = MarkdownDocument( + parsing: """ + ~~~console + pnpm test + no closing fence + """ + ) + + #expect( + document.blocks == [ + .codeBlock(language: "console", code: "pnpm test\nno closing fence"), + ] + ) + } + + @Test + func plaintextCodeBlocksWrapByDefault() { + for language in ["text", "TEXT", "txt", "plaintext", "plain", "md", "markdown"] { + #expect(MarkdownCodeBlockWrapping.wrapsByDefault(language: language)) + } + + for language in [nil, "swift", "typescript", "console"] { + #expect(!MarkdownCodeBlockWrapping.wrapsByDefault(language: language)) + } + } + + @Test + func parsesSetextHeadingsAndNormalizesWindowsNewlines() { + let document = MarkdownDocument(parsing: "Heading\r\n=======\r\n\r\nBody") + + #expect( + document.blocks == [ + .heading(level: 1, text: "Heading"), + .paragraph("Body"), + ] + ) + } + + @Test + func inlineFormatterRetainsEmphasisCodeAndLinks() { + let formatted = MarkdownInlineFormatter.format( + "Use **bold**, *emphasis*, `code`, and [docs](https://example.com)." + ) + let runs = Array(formatted.runs) + + #expect(String(formatted.characters) == "Use bold, emphasis, code, and docs.") + #expect(runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + #expect(runs.contains { $0.inlinePresentationIntent?.contains(.emphasized) == true }) + #expect(runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }) + #expect(runs.contains { $0.link == URL(string: "https://example.com") }) + } + + @Test @MainActor + func selectableTextAttributesPreserveInlineFormatting() throws { + let revision = MarkdownContentRevision( + "Use **bold**, *emphasis*, `code`, ~~removed~~, and [docs](https://example.com)." + ) + let document = try #require( + MarkdownRenderCache.shared.documentImmediately(for: revision) + ) + guard case let .paragraph(inline) = document.blocks.first else { + Issue.record("Expected a rendered paragraph") + return + } + + let attributed = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + foregroundColor: T3Colors.uiTextSecondary + ) + let text = attributed.string as NSString + let boldIndex = try #require(index(of: "bold", in: text)) + let emphasisIndex = try #require(index(of: "emphasis", in: text)) + let codeIndex = try #require(index(of: "code", in: text)) + let removedIndex = try #require(index(of: "removed", in: text)) + let linkIndex = try #require(index(of: "docs", in: text)) + + let boldFont = try #require( + attributed.attribute(.font, at: boldIndex, effectiveRange: nil) as? UIFont + ) + let emphasisFont = try #require( + attributed.attribute(.font, at: emphasisIndex, effectiveRange: nil) as? UIFont + ) + let codeFont = try #require( + attributed.attribute(.font, at: codeIndex, effectiveRange: nil) as? UIFont + ) + let paragraphStyle = try #require( + attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle + ) + + #expect(boldFont.fontDescriptor.symbolicTraits.contains(.traitBold)) + #expect(emphasisFont.fontDescriptor.symbolicTraits.contains(.traitItalic)) + #expect(codeFont.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)) + #expect( + attributed.attribute(.backgroundColor, at: codeIndex, effectiveRange: nil) + as? UIColor == T3Colors.uiSurfaceRaised + ) + #expect( + attributed.attribute(.strikethroughStyle, at: removedIndex, effectiveRange: nil) + as? Int == NSUnderlineStyle.single.rawValue + ) + #expect( + attributed.attribute(.foregroundColor, at: boldIndex, effectiveRange: nil) + as? UIColor == T3Colors.uiTextSecondary + ) + #expect( + attributed.attribute(.link, at: linkIndex, effectiveRange: nil) as? URL + == URL(string: "https://example.com") + ) + #expect( + attributed.string + == "Use bold, emphasis, code, removed, and docs." + ) + #expect(paragraphStyle.lineSpacing == 4) + } + + @Test @MainActor + func selectableTextAttributesHonorDynamicTypeSize() throws { + let document = try #require( + MarkdownRenderCache.shared.documentImmediately( + for: MarkdownContentRevision("Readable body text") + ) + ) + guard case let .paragraph(inline) = document.blocks.first else { + Issue.record("Expected a rendered paragraph") + return + } + + let small = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + dynamicTypeSize: .small + ) + let accessibility = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + dynamicTypeSize: .accessibility1 + ) + let smallFont = try #require( + small.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + ) + let accessibilityFont = try #require( + accessibility.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + ) + + #expect(accessibilityFont.pointSize > smallFont.pointSize) + } + + @Test @MainActor + func selectableTextRendersKnownSkillsAsPillsOutsideCodeAndLinks() throws { + let document = try #require( + MarkdownRenderCache.shared.documentImmediately( + for: MarkdownContentRevision( + "Use $file-pr now, not `$file-pr` or [$file-pr](https://example.com)." + ) + ) + ) + guard case let .paragraph(inline) = document.blocks.first else { + Issue.record("Expected a rendered paragraph") + return + } + + let attributed = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")] + ) + + #expect(FeatureInlineSkillProjection.signatures(in: attributed).count == 1) + #expect( + FeatureInlineSkillProjection.plainText(from: attributed) + == "Use $file-pr now, not $file-pr or $file-pr." + ) + } + + @Test @MainActor + func selectableTextKeepsSkillsLiteralInsideFencedCode() throws { + let document = try #require( + MarkdownRenderCache.shared.documentImmediately( + for: MarkdownContentRevision("```text\n$file-pr\n```") + ) + ) + guard case let .codeBlock(_, code, inline) = document.blocks.first else { + Issue.record("Expected a rendered code block") + return + } + + let attributed = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 3, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")] + ) + + #expect(attributed.string == code) + #expect(FeatureInlineSkillProjection.signatures(in: attributed).isEmpty) + } + + @Test @MainActor + func selectableTextKeepsSkillBoundariesAcrossFormattedRuns() throws { + let document = try #require( + MarkdownRenderCache.shared.documentImmediately( + for: MarkdownContentRevision( + "prefix.**$file-pr** then **$file-pr** and **$file-pr**suffix" + ) + ) + ) + guard case let .paragraph(inline) = document.blocks.first else { + Issue.record("Expected a rendered paragraph") + return + } + + let attributed = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + skills: [FeatureProviderSkill(name: "file-pr", displayName: "File PR")] + ) + + #expect(FeatureInlineSkillProjection.signatures(in: attributed).count == 1) + #expect( + FeatureInlineSkillProjection.plainText(from: attributed) + == "prefix.$file-pr then $file-pr and $file-prsuffix" + ) + } + + @Test @MainActor + func codeBlocksReuseSelectableInlineRendering() throws { + let literalCode = "x = arr[i](fn)\na **b** c\nprintf(\\\"a\\\\tb\\\");" + let cache = MarkdownRenderCache() + let first = try #require( + cache.documentImmediately( + for: MarkdownContentRevision("```swift\n\(literalCode)\n```") + ) + ) + let second = try #require( + cache.documentImmediately( + for: MarkdownContentRevision("Before\n\n```swift\n\(literalCode)\n```") + ) + ) + + guard case let .codeBlock(_, firstCode, firstInline) = first.blocks.first, + case let .codeBlock(_, secondCode, secondInline) = second.blocks.last + else { + Issue.record("Expected rendered code blocks") + return + } + + #expect(firstCode == literalCode) + #expect(secondCode == firstCode) + #expect(firstInline === secondInline) + #expect(firstInline.style == .code) + + let attributed = MarkdownSelectableTextAttributes.make( + from: firstInline, + lineSpacing: 3 + ) + let font = try #require( + attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + ) + #expect(attributed.string == firstCode) + #expect(font.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)) + } + + @Test + func restoresSelectionOnlyWhenTextIsExtended() { + let selection = NSRange(location: 7, length: 5) + + #expect( + MarkdownSelectionRestoration.range( + previousText: "Hello, world", + previousRange: selection, + newText: "Hello, world!" + ) == selection + ) + #expect( + MarkdownSelectionRestoration.range( + previousText: "Hello, world", + previousRange: selection, + newText: "Different text" + ) == NSRange(location: 0, length: 0) + ) + #expect( + MarkdownSelectionRestoration.range( + previousText: "Hello, world", + previousRange: NSRange(location: 7, length: 20), + newText: "Hello, world!" + ) == NSRange(location: 0, length: 0) + ) + } + + private func index(of substring: String, in text: NSString) -> Int? { + let range = text.range(of: substring) + return range.location == NSNotFound ? nil : range.location + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownImageRenderingTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownImageRenderingTests.swift new file mode 100644 index 000000000000..45b3318ef26b --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownImageRenderingTests.swift @@ -0,0 +1,82 @@ +import ImageIO +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import T3Code + +@Suite("Markdown image rendering") +@MainActor +struct MarkdownImageRenderingTests { + @Test + func signedAssetDimensionsReserveTheSameFrameAfterDecode() throws { + let result = try JSONDecoder.t3.decode(AssetCreateURLResult.self, from: Data(#"{"relativeUrl":"/api/assets/image.png","expiresAt":1785466800000,"imageDimensions":{"width":1600,"height":900}}"#.utf8)) + let data = try makeImage(width: 1_600, height: 900) + let decoded = try MarkdownImageDecoder.decode(data, maximumPixelSize: 600) + let waitingFrame = MarkdownImageGeometry.displaySize( + sourceSize: MarkdownImageGeometry.sourceSize(result.imageDimensions), + availableWidth: 360 + ) + let loadedFrame = MarkdownImageGeometry.displaySize(sourceSize: decoded.sourceSize, availableWidth: 360) + + #expect(waitingFrame == CGSize(width: 360, height: 202.5)) + #expect(loadedFrame == waitingFrame) + #expect(decoded.image.size.width == 600) + #expect((337...338).contains(decoded.image.size.height)) + } + + @Test + func olderServersAndInvalidDimensionsKeepAUsablePlaceholder() throws { + let result = try JSONDecoder.t3.decode(AssetCreateURLResult.self, from: Data(#"{"relativeUrl":"/api/assets/image.png","expiresAt":1785466800000}"#.utf8)) + #expect(result.imageDimensions == nil) + #expect(MarkdownImageGeometry.sourceSize(AssetImageDimensions(width: 0, height: 300)) == nil) + #expect(MarkdownImageGeometry.sourceSize(AssetImageDimensions(width: 300, height: -1)) == nil) + #expect(MarkdownImageGeometry.displaySize(sourceSize: nil, availableWidth: 240) == CGSize(width: 240, height: 140)) + } + + @Test + func portraitFramesStayBoundedAndNestedContentUsesItsOwnWidth() { + let portrait = CGSize(width: 900, height: 1_600) + #expect(MarkdownImageGeometry.displaySize(sourceSize: portrait, availableWidth: 360) == CGSize(width: 270, height: 480)) + #expect(MarkdownImageGeometry.displaySize(sourceSize: portrait, availableWidth: 180) == CGSize(width: 180, height: 320)) + } + + @Test + func largeImagesDecodeAtDisplayResolution() throws { + let data = try makeImage(width: 2_400, height: 1_600) + let decoded = try MarkdownImageDecoder.decode(data, maximumPixelSize: 600) + let cgImage = try #require(decoded.image.cgImage) + #expect(decoded.sourceSize == CGSize(width: 2_400, height: 1_600)) + #expect(cgImage.width == 600) + #expect(cgImage.height == 400) + #expect(cgImage.bytesPerRow * cgImage.height < 1_100_000) + } + + @Test + func rotatedPhotosUseTheirDisplayedOrientation() throws { + let data = try makeImage(width: 1_200, height: 800, orientation: 6) + let decoded = try MarkdownImageDecoder.decode(data, maximumPixelSize: 600) + #expect(decoded.sourceSize == CGSize(width: 800, height: 1_200)) + #expect(decoded.image.size == CGSize(width: 400, height: 600)) + } + + @Test + func malformedImageFailsWithoutAllocatingAThumbnail() { + #expect(throws: MarkdownImageLoadingError.self) { + try MarkdownImageDecoder.decode(Data("not an image".utf8), maximumPixelSize: 600) + } + } + + private func makeImage(width: Int, height: Int, orientation: Int = 1) throws -> Data { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + } + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, try #require(image.cgImage), [kCGImagePropertyOrientation: orientation] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift new file mode 100644 index 000000000000..772f860073f9 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift @@ -0,0 +1,102 @@ +import Testing +@testable import T3Code + +@Suite("Markdown render cache") +struct MarkdownRenderCacheTests { + @Test + func contentRevisionUsesExactSourceIdentity() { + let first = MarkdownContentRevision("Same text") + let again = MarkdownContentRevision("Same text") + let changed = MarkdownContentRevision("Same text.") + + #expect(first == again) + #expect(first != changed) + #expect(first.fingerprint == again.fingerprint) + } + + @Test + func reusesAnExactRenderedDocument() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("# Heading\n\nBody with **emphasis**.") + + #expect(cache.cachedDocument(for: revision) == nil) + guard let first = await cache.document(for: revision), + let second = await cache.document(for: revision) else { + Issue.record("Expected Markdown documents") + return + } + + #expect(first === second) + #expect(cache.cachedDocument(for: revision) === first) + #expect(first.blocks.count == 2) + } + + @Test + func immediatelyRendersAndCachesCompletedContent() { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("# Stable heading\n\n- First\n- Second") + + guard let document = cache.documentImmediately(for: revision) else { + Issue.record("Expected an immediate Markdown document") + return + } + + #expect(cache.cachedDocument(for: revision) === document) + #expect(document.blocks.count == 2) + } + + @Test + func coalescesConcurrentRequestsForOneRevision() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("A paragraph with `code` and [a link](https://t3.gg).") + + async let first = cache.document(for: revision) + async let second = cache.document(for: revision) + let documents = await (first, second) + + guard let first = documents.0, let second = documents.1 else { + Issue.record("Expected coalesced Markdown documents") + return + } + #expect(first === second) + } + + @Test + func reusesUnchangedInlineRunsAcrossStreamingRevisions() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + guard let first = await cache.document( + for: MarkdownContentRevision("Shared **paragraph**.\n\nFirst ending") + ), let second = await cache.document( + for: MarkdownContentRevision("Shared **paragraph**.\n\nSecond ending") + ) else { + Issue.record("Expected streaming Markdown documents") + return + } + + guard let firstBlock = first.blocks.first, + let secondBlock = second.blocks.first, + case let .paragraph(firstInline) = firstBlock, + case let .paragraph(secondInline) = secondBlock else { + Issue.record("Expected a shared leading paragraph") + return + } + + #expect(firstInline === secondInline) + #expect(firstInline.style == .body) + #expect(String(firstInline.attributedText.characters) == "Shared paragraph.") + } + + @Test + func canceledRequestDoesNotRenderOrCache() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision(String(repeating: "Paragraph.\n\n", count: 2_000)) + let render = Task { + await Task.yield() + return await cache.document(for: revision) + } + + render.cancel() + #expect(await render.value == nil) + #expect(cache.cachedDocument(for: revision) == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift new file mode 100644 index 000000000000..56fdcabb2227 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -0,0 +1,1870 @@ +import Foundation +import Testing +import XCTest +@testable import T3Code + +@MainActor +final class NativeMultiEnvironmentTests: XCTestCase { + func testProviderCatalogueUsesStableProviderAndModelIdentities() { + let normalized = NativeFeatureClient.normalizedProviders([ + FeatureProvider( + id: "codex-work", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5.6", name: "GPT-5.6"), + FeatureModel(id: "gpt-5.6", name: "Duplicate GPT-5.6"), + ] + ), + FeatureProvider( + id: "codex-work", + name: "Duplicate provider", + models: [ + FeatureModel(id: "gpt-5.6", name: "Duplicate again"), + FeatureModel(id: "gpt-5.6-mini", name: "GPT-5.6 mini"), + ] + ), + ]) + + XCTAssertEqual(normalized.map(\.id), ["codex-work"]) + XCTAssertEqual(normalized[0].models.map(\.id), ["gpt-5.6", "gpt-5.6-mini"]) + } + + func testClientReplacementIsSharedWhileStaleClientDisconnects() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-runtime-race-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let originalEnvironment = Environment( + id: "shared-environment", + label: "Old endpoint", + httpBaseURL: URL(string: "https://old.example")!, + webSocketBaseURL: URL(string: "wss://old.example")! + ) + let updatedEnvironment = Environment( + id: originalEnvironment.id, + label: "New endpoint", + httpBaseURL: URL(string: "https://new.example")!, + webSocketBaseURL: URL(string: "wss://new.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([updatedEnvironment]) + let staleConnection = BlockingRuntimeCloseConnection() + let connector = RuntimeReplacementConnector(connection: staleConnection) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + originalEnvironment.id: EnvironmentCredential(accessToken: "token"), + ] + ), + httpTransport: RuntimeReplacementHTTPTransport(), + webSocketConnector: connector + ) + let original = await runtime.client(for: originalEnvironment) + await original.connect() + await staleConnection.waitUntilReceiving() + + let firstLookup = Task { await runtime.client(for: updatedEnvironment) } + await staleConnection.waitUntilCloseStarted() + let concurrentLookup = await runtime.client(for: updatedEnvironment) + await staleConnection.releaseClose() + let replacement = await firstLookup.value + + XCTAssertTrue( + replacement === concurrentLookup, + "Concurrent lookups must share the replacement cached before stale disconnect." + ) + } + + func testSnapshotMergesEnvironmentsAndRoutesThreadWorkToItsOwner() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + + XCTAssertEqual(Set(snapshot.projects.map(\.environmentID)), ["one", "two"]) + XCTAssertEqual(Set(snapshot.threads.compactMap(\.wireID)), ["thread-one", "thread-two"]) + let remoteThread = try XCTUnwrap( + snapshot.threads.first(where: { $0.environmentID == "two" }) + ) + XCTAssertEqual( + remoteThread.environmentName, + "Steam Box" + ) + XCTAssertEqual( + snapshot.environments.first(where: { $0.id == "two" })?.connectionState, + .connected + ) + + let detail = try await fixture.client.loadThread(id: remoteThread.id) + XCTAssertEqual(detail.thread.environmentID, "two") + XCTAssertEqual(detail.thread.environmentName, "Steam Box") + + try await fixture.client.renameThread(id: remoteThread.id, title: "Remote rename") + let selection = FeatureSelection( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("xhigh")), + .init(id: "serviceTier", value: .string("priority")), + ] + ) + try await fixture.client.sendMessage( + threadID: remoteThread.id, + text: "Run this on Steam Box", + selection: selection + ) + + let records = await fixture.transport.dispatchRecords() + XCTAssertEqual(records.map(\.host), ["two.example", "two.example"]) + let turnSelection = try XCTUnwrap( + records.last?.command["modelSelection"]?.decode(ModelSelection.self) + ) + XCTAssertEqual(turnSelection.instanceId, selection.providerID) + XCTAssertEqual(turnSelection.model, selection.modelID) + XCTAssertEqual( + turnSelection.options, + [ + .init(id: "reasoningEffort", value: .string("xhigh")), + .init(id: "serviceTier", value: .string("priority")), + ] + ) + await fixture.client.disconnect() + } + + func testPassiveProviderRefreshKeepsActiveThreadsAndAcceptsTheirNextSequence() async throws { + let server = MultiEnvironmentConfigurationServer() + let fixture = try await Self.makeFixture( + passiveSequence: 5_000, + webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), + rpcConnectionWaitTimeout: .seconds(1), + fallbackPollingInitialDelay: .seconds(60), + aggregateRefreshInterval: .seconds(60) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + let providers = try await fixture.client.refreshProviders(environmentID: "two") + XCTAssertEqual(providers.map(\.id), ["codex-two.example"]) + + let refreshed = try await fixture.client.initialSnapshot() + XCTAssertEqual( + refreshed.threads.filter { $0.environmentID == "one" }.compactMap(\.wireID), + ["thread-one"] + ) + XCTAssertEqual( + refreshed.threads.filter { $0.environmentID == "two" }.compactMap(\.wireID), + ["thread-two"] + ) + + let current = multiEnvironmentShell( + projectID: "project-one", threadID: "thread-one", title: "Updated local work" + ) + let added = multiEnvironmentShell( + projectID: "project-one", threadID: "thread-new", title: "New local work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 2, + projects: current.projects, + threads: current.threads + added.threads, + updatedAt: current.updatedAt + ), + host: "one.example" + ) + + let updated = try await fixture.client.initialSnapshot() + XCTAssertEqual( + Set(updated.threads.filter { $0.environmentID == "one" }.compactMap(\.wireID)), + ["thread-one", "thread-new"] + ) + XCTAssertEqual(updated.threads.first { $0.wireID == "thread-one" }?.title, "Updated local work") + XCTAssertEqual(updated.threads.first { $0.wireID == "thread-new" }?.projectID, + FeatureScopedID.project(environmentID: "one", wireID: "project-one")) + await fixture.client.disconnect() + } + + func testPassiveEnvironmentSettingsDoNotReplaceActiveThreads() async throws { + let server = MultiEnvironmentConfigurationServer() + let fixture = try await Self.makeFixture( + passiveSequence: 5_000, + webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), + rpcConnectionWaitTimeout: .seconds(1), + fallbackPollingInitialDelay: .seconds(60), + aggregateRefreshInterval: .seconds(60) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + try await fixture.client.updateServerPreferences( + environmentID: "two", change: .environmentIcon("mac-mini") + ) + + let snapshot = try await fixture.client.initialSnapshot() + XCTAssertEqual( + snapshot.threads.filter { $0.environmentID == "one" }.compactMap(\.wireID), + ["thread-one"] + ) + XCTAssertEqual( + snapshot.threads.filter { $0.environmentID == "two" }.compactMap(\.wireID), + ["thread-two"] + ) + let updatedHosts = await server.updatedHosts() + XCTAssertEqual(updatedHosts, ["two.example"]) + await fixture.client.disconnect() + } + + func testMachineModelDefaultsRefreshCachedProjectsWithoutChangingOtherEnvironments() async throws { + let server = MultiEnvironmentConfigurationServer() + let fixture = try await Self.makeFixture( + webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), + rpcConnectionWaitTimeout: .seconds(1), + fallbackPollingInitialDelay: .seconds(60), + aggregateRefreshInterval: .seconds(60) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let source = multiEnvironmentShell(projectID: "project-two", threadID: "thread-two", title: "Remote work") + var projectFields = try JSONValue.encode(source.projects[0]).decode([String: JSONValue].self) + projectFields["defaultModelSelection"] = .null + let project = try JSONValue.object(projectFields).decode(OrchestrationProject.self) + await fixture.transport.setShell(OrchestrationShellSnapshot( + snapshotSequence: source.snapshotSequence, projects: [project], + threads: source.threads, updatedAt: source.updatedAt + ), host: "two.example") + let initial = try await fixture.client.initialSnapshot() + XCTAssertNil(initial.projects.first { $0.environmentID == "two" }?.defaultSelection) + let localDefault = initial.projects.first { $0.environmentID == "one" }?.defaultSelection + + for model in ["claude-opus-5", "claude-sonnet-5"] { + let selection = ModelSelection(instanceId: "claude-work", model: model) + try await fixture.client.updateServerPreferences(environmentID: "two", change: .sharedPreferences(.object([ + "defaultModelSelection": try JSONValue.encode(selection), + ]))) + let snapshot = try await fixture.client.initialSnapshot() + XCTAssertEqual(snapshot.projects.first { $0.environmentID == "two" }?.defaultSelection?.modelID, model) + XCTAssertEqual(snapshot.projects.first { $0.environmentID == "one" }?.defaultSelection, localDefault) + } + + await fixture.transport.setShell(source, host: "two.example") + let overridden = try await fixture.client.initialSnapshot() + XCTAssertEqual(overridden.projects.first { $0.environmentID == "two" }?.defaultSelection?.modelID, "gpt-5.6-sol") + await fixture.client.disconnect() + } + + func testSharedSettingsFanOutDoesNotReplaceActiveThreads() async throws { + let server = MultiEnvironmentConfigurationServer() + let fixture = try await Self.makeFixture( + passiveSequence: 5_000, + webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), + rpcConnectionWaitTimeout: .seconds(1), + fallbackPollingInitialDelay: .seconds(60), + aggregateRefreshInterval: .seconds(60) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + try await fixture.client.updateServerPreferences( + environmentID: "one", change: .defaultThreadEnvMode(.worktree) + ) + + let snapshot = try await fixture.client.initialSnapshot() + XCTAssertEqual( + snapshot.threads.filter { $0.environmentID == "one" }.compactMap(\.wireID), + ["thread-one"] + ) + XCTAssertEqual( + snapshot.threads.filter { $0.environmentID == "two" }.compactMap(\.wireID), + ["thread-two"] + ) + let updatedHosts = await server.updatedHosts() + XCTAssertEqual(updatedHosts, ["one.example", "two.example"]) + await fixture.client.disconnect() + } + + func testRestartPreferenceOnlyReachesComputersThatSupportIt() async throws { + let server = MultiEnvironmentConfigurationServer(restartSupportHosts: ["one.example"]) + let fixture = try await Self.makeFixture( + webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), + rpcConnectionWaitTimeout: .seconds(1), + fallbackPollingInitialDelay: .seconds(60), + aggregateRefreshInterval: .seconds(60) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let snapshot = try await fixture.client.initialSnapshot() + XCTAssertEqual(snapshot.preferencesByEnvironment?["one"]?.continueThreadsAfterServerUpdate, false) + XCTAssertNil(snapshot.preferencesByEnvironment?["two"]?.continueThreadsAfterServerUpdate) + + try await fixture.client.updateServerPreferences( + environmentID: "one", change: .continueThreadsAfterServerUpdate(true) + ) + let updatedHosts = await server.updatedHosts() + XCTAssertEqual(updatedHosts, ["one.example"]) + XCTAssertTrue(fixture.client.sharedPreferenceMismatches(environmentID: "one").isEmpty) + + let all = ServerSettingsSnapshot(continueThreadsAfterServerUpdate: true) + try await fixture.client.updateServerPreferences( + environmentID: "one", + change: .sharedPreferences(all.sharedPatch(supportsRestartContinuation: true)) + ) + let supportedSettings = await server.settings(host: "one.example") + let legacySettings = await server.settings(host: "two.example") + XCTAssertEqual(supportedSettings["continueThreadsAfterServerUpdate"], .bool(true)) + XCTAssertNil(legacySettings["continueThreadsAfterServerUpdate"]) + XCTAssertEqual(legacySettings["defaultThreadEnvMode"], .string("local")) + do { + try await fixture.client.updateServerPreferences( + environmentID: "two", change: .continueThreadsAfterServerUpdate(true) + ) + XCTFail("An older computer must not receive the restart preference.") + } catch is FeatureCapabilityUnavailable { + // The unsupported action must fail before sending a settings command. + } + await fixture.client.disconnect() + } + + func testBackgroundLivenessKeepsASettledThreadWorking() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Local work", + backgroundLiveness: .working + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap( + snapshot.threads.first(where: { $0.wireID == "thread-one" }) + ) + XCTAssertEqual(thread.state, .working) + + let detail = try await fixture.client.loadThread(id: thread.id) + XCTAssertEqual(detail.thread.state, .working) + XCTAssertTrue(detail.backgroundWorkIsActive) + await fixture.client.disconnect() + } + + func testNewerDetailSettlementBeatsOlderShellForNonActiveEnvironment() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: "Remote work", + snapshotSequence: 90, + settledOverride: "settled", + settledAt: "2026-07-31T12:01:00.000Z" + ), + host: "two.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 100 + ), + host: "two.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) + let detail = try await fixture.client.loadThread(id: thread.id) + + XCTAssertFalse(detail.thread.isSettled) + XCTAssertNil(detail.thread.settlementFacts?.settlementOverride) + await fixture.client.disconnect() + } + + func testNewerShellSettlementBeatsStaleDetailForNonActiveEnvironment() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: "Remote work", + snapshotSequence: 100, + settledOverride: "settled", + settledAt: "2026-07-31T12:01:00.000Z" + ), + host: "two.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 90 + ), + host: "two.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) + let detail = try await fixture.client.loadThread(id: thread.id) + + XCTAssertTrue(detail.thread.isSettled) + XCTAssertEqual(detail.thread.settlementFacts?.settlementOverride, .settled) + + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 95 + ), + host: "two.example" + ) + let refreshed = try await fixture.client.loadThread(id: thread.id) + XCTAssertTrue(refreshed.thread.isSettled) + XCTAssertEqual(refreshed.thread.settlementFacts?.settlementOverride, .settled) + await fixture.client.disconnect() + } + + func testSnapshotKeepsRepositoryIdentityForCrossComputerProjectGrouping() async throws { + let identity = RepositoryIdentity( + canonicalKey: "github.com/t3/example", + locator: .init( + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3/example.git" + ), + rootPath: "/work/example", + displayName: "Example", + provider: "github", + owner: "t3", + name: "example" + ) + let fixture = try await Self.makeFixture(repositoryIdentity: identity) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + let groups = DailyUXCreationContext.projectGroups(in: snapshot) + + XCTAssertEqual(Set(snapshot.projects.compactMap(\.repositoryIdentity?.canonicalKey)), [ + identity.canonicalKey, + ]) + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(Set(groups[0].projects.map(\.environmentID)), ["one", "two"]) + await fixture.client.disconnect() + } + + func testFailedEnvironmentKeepsItsLastKnownRowsWithoutHidingHealthyDevices() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + _ = try await fixture.client.initialSnapshot() + await fixture.transport.setReachable(false, host: "two.example") + + let passiveFailure = try await fixture.client.initialSnapshot() + XCTAssertEqual( + Set(passiveFailure.threads.compactMap(\.wireID)), + ["thread-one", "thread-two"] + ) + XCTAssertEqual(passiveFailure.connection.state, .connected) + XCTAssertEqual( + passiveFailure.environments.first(where: { $0.id == "two" })?.connectionState, + .disconnected + ) + + await fixture.transport.setReachable(false, host: "one.example") + await fixture.transport.setReachable(true, host: "two.example") + + let activeFailure = try await fixture.client.initialSnapshot() + XCTAssertEqual( + Set(activeFailure.threads.compactMap(\.wireID)), + ["thread-one", "thread-two"] + ) + XCTAssertEqual(activeFailure.connection.state, .disconnected) + XCTAssertEqual(activeFailure.connection.environmentName, "Left Book") + XCTAssertEqual( + activeFailure.environments.first(where: { $0.id == "two" })?.connectionState, + .connected + ) + await fixture.client.disconnect() + } + + func testCachedShellRowsApplySettlementAndRemoveDeletedRoutes() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let initial = try await fixture.client.initialSnapshot() + let original = try XCTUnwrap(initial.threads.first { $0.environmentID == "two" }) + let updated = multiEnvironmentShell( + projectID: "project-two", threadID: "thread-two", title: "Remote work", + providerID: "claudeAgent", modelID: "claude-opus-4-1", + backgroundLiveness: .monitoring, snapshotSequence: 2, + settledOverride: "settled", settledAt: "2026-07-31T12:01:00.000Z" + ) + await fixture.transport.setShell(updated, host: "two.example") + let refreshed = try await fixture.client.initialSnapshot() + let settled = try XCTUnwrap(refreshed.threads.first { $0.id == original.id }) + XCTAssertEqual(settled.updatedAt, original.updatedAt) + XCTAssertTrue(settled.isSettled) + XCTAssertEqual(settled.state, .monitoring) + XCTAssertEqual(refreshed.threads.first { $0.environmentID == "one" }, + initial.threads.first { $0.environmentID == "one" }) + + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 3, projects: updated.projects, threads: [], updatedAt: updated.updatedAt + ), + host: "two.example" + ) + let removed = try await fixture.client.initialSnapshot() + XCTAssertFalse(removed.threads.contains { $0.id == original.id }) + XCTAssertEqual(removed.projects.first { $0.environmentID == "two" }?.threadCount, 0) + do { + _ = try await fixture.client.loadThread(id: original.id) + XCTFail("Removed threads must no longer have a route.") + } catch { + XCTAssertEqual(error.localizedDescription, "The selected thread is no longer available.") + } + await fixture.client.disconnect() + } + + func testOlderHTTPSnapshotCannotReplaceNewerEnvironmentState() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + let newer = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Newer work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 3, + projects: newer.projects, + threads: newer.threads, + updatedAt: newer.updatedAt + ), + host: "one.example" + ) + _ = try await fixture.client.initialSnapshot() + + let older = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Stale work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 2, + projects: older.projects, + threads: older.threads, + updatedAt: older.updatedAt + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + + XCTAssertEqual( + snapshot.threads.first(where: { $0.environmentID == "one" })?.title, + "Newer work" + ) + await fixture.client.disconnect() + } + + func testThreadCreationCannotReplaceNewerEnvironmentStateWithAnOlderShell() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + let newer = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Newer work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 3, + projects: newer.projects, + threads: newer.threads, + updatedAt: newer.updatedAt + ), + host: "one.example" + ) + let current = try await fixture.client.initialSnapshot() + let project = try XCTUnwrap( + current.projects.first(where: { $0.environmentID == "one" }) + ) + + let older = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Stale work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 2, + projects: older.projects, + threads: older.threads, + updatedAt: older.updatedAt + ), + host: "one.example" + ) + + _ = try await fixture.client.createThread( + projectID: project.id, + title: "Another task", + selection: nil + ) + let snapshot = try await fixture.client.initialSnapshot() + + XCTAssertEqual( + snapshot.threads.first(where: { $0.wireID == "thread-one" })?.title, + "Newer work" + ) + await fixture.client.disconnect() + } + + func testPullRequestPagesPreserveCursorsAndTargetOnlyTheRequestedEnvironment() async throws { + let recorder = PullRequestPageRecorder() + let fixture = try await Self.makeFixture( + pullRequestsAvailable: true, + webSocketConnector: PullRequestPageWebSocketConnector(recorder: recorder), + rpcConnectionWaitTimeout: .seconds(2) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let firstPages = try await fixture.client.pullRequestLists(PullRequestListInput()) + + XCTAssertEqual(Set(firstPages.map(\.environmentID)), ["one", "two"]) + XCTAssertTrue(firstPages.allSatisfy { $0.result?.truncated == true }) + XCTAssertTrue(firstPages.allSatisfy { $0.result?.nextCursors.isEmpty == false }) + let initialRequests = await recorder.recordedRequests() + XCTAssertEqual(initialRequests.count, 2) + XCTAssertEqual(Set(initialRequests.map(\.host)), ["one.example", "two.example"]) + + let cursor = try XCTUnwrap( + firstPages.first(where: { $0.environmentID == "two" })?.result?.nextCursors + ) + let nextPage = try await fixture.client.pullRequestLists( + PullRequestListInput(cursors: cursor), + environmentID: "two" + ) + + XCTAssertEqual(nextPage.map(\.environmentID), ["two"]) + let requests = await recorder.recordedRequests() + XCTAssertEqual(requests.count, 3) + XCTAssertEqual(requests.last?.host, "two.example") + XCTAssertEqual(requests.last?.input.cursors, cursor) + await fixture.client.disconnect() + } + + func testBackgroundSnapshotDoesNotStartAggregateRefreshLoops() async throws { + let loader = CountingAggregateEnvironmentLoader() + let fixture = try await Self.makeFixture( + aggregateEnvironmentLoader: { runtime in + await loader.recordLoad() + return try await runtime.environments() + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.backgroundSnapshot() + + XCTAssertEqual(snapshot.connection.state, .connected) + let aggregateLoadCount = await loader.callCount + XCTAssertEqual(aggregateLoadCount, 0) + await fixture.client.disconnect() + } + + func testAggregateRefreshRetriesTransientEnvironmentLoadFailures() async throws { + let loader = FailOnceAggregateEnvironmentLoader() + let fixture = try await Self.makeFixture( + aggregateRefreshInterval: .milliseconds(5), + aggregateFailureRefreshInterval: .milliseconds(5), + aggregateEnvironmentLoader: { runtime in + try await loader.load(from: runtime) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + _ = try await fixture.client.initialSnapshot() + + await loader.waitForCallCount(2) + let retryCallCount = await loader.callCount + XCTAssertGreaterThanOrEqual(retryCallCount, 2) + await fixture.client.disconnect() + } + + func testSameClientSnapshotRestartsAggregateRefresh() async throws { + let loader = BlockingFirstAggregateEnvironmentLoader() + let fixture = try await Self.makeFixture( + aggregateRefreshInterval: .milliseconds(5), + aggregateEnvironmentLoader: { runtime in + try await loader.load(from: runtime) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + _ = try await fixture.client.initialSnapshot() + await loader.waitForCallCount(1) + + _ = try await fixture.client.initialSnapshot() + + await loader.waitForFirstLoadCancellation() + await loader.waitForCallCount(2) + let restartedCallCount = await loader.callCount + XCTAssertGreaterThanOrEqual(restartedCallCount, 2) + await fixture.client.disconnect() + } + + func testDuplicateWireIDsRemainDistinctAndRouteByEnvironment() async throws { + let fixture = try await Self.makeFixture(duplicateIDs: true) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + XCTAssertEqual(snapshot.projects.count, 2) + XCTAssertEqual(snapshot.threads.count, 2) + XCTAssertEqual(Set(snapshot.projects.map(\.id)).count, 2) + XCTAssertEqual(Set(snapshot.threads.map(\.id)).count, 2) + XCTAssertEqual(Set(snapshot.projects.compactMap(\.wireID)), ["project-shared"]) + XCTAssertEqual(Set(snapshot.threads.compactMap(\.wireID)), ["thread-shared"]) + + let remote = try XCTUnwrap( + snapshot.threads.first(where: { $0.environmentID == "two" }) + ) + _ = try await fixture.client.loadThread(id: remote.id) + try await fixture.client.renameThread(id: remote.id, title: "Remote only") + + let hosts = await fixture.transport.dispatchHosts() + XCTAssertEqual(hosts, ["two.example"]) + await fixture.client.disconnect() + } + + func testPassiveCreateUsesOwningProjectDefaultAndFallbackRemainsRoutable() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + let remoteProject = try XCTUnwrap( + snapshot.projects.first(where: { $0.environmentID == "two" }) + ) + let created = try await fixture.client.createThread( + projectID: remoteProject.id, + title: "Passive task", + selection: nil + ) + + XCTAssertEqual(created.environmentID, "two") + XCTAssertEqual(created.projectID, remoteProject.id) + XCTAssertNotNil(created.wireID) + try await fixture.client.renameThread(id: created.id, title: "Fallback routed") + + let records = await fixture.transport.dispatchRecords() + XCTAssertEqual(records.map(\.host), ["two.example", "two.example"]) + XCTAssertEqual(records[0].command["type"]?.stringValue, "thread.create") + XCTAssertEqual(records[0].command["projectId"]?.stringValue, "project-two") + XCTAssertEqual( + records[0].command["modelSelection"]?["instanceId"]?.stringValue, + "claudeAgent" + ) + XCTAssertEqual( + records[1].command["threadId"]?.stringValue, + created.wireID + ) + await fixture.client.disconnect() + } + + func testPassiveCreateRecoversACommittedThreadAfterItsReplyIsLost() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let snapshot = try await fixture.client.initialSnapshot() + let project = try XCTUnwrap( + snapshot.projects.first(where: { $0.environmentID == "two" }) + ) + await fixture.transport.dropNextCreateReply(host: "two.example") + + let created = try await fixture.client.createThread( + projectID: project.id, + title: "Recovered task", + selection: nil + ) + + XCTAssertEqual(created.title, "Recovered task") + XCTAssertEqual(created.environmentID, "two") + let creates = await fixture.transport.dispatchRecords().filter { + $0.command["type"]?.stringValue == "thread.create" + } + XCTAssertEqual(creates.count, 1) + XCTAssertEqual(creates.first?.command["threadId"]?.stringValue, created.wireID) + await fixture.client.disconnect() + } + + func testUnarchiveImmediatelyRestoresLiveThreadWhenRefreshIsUnavailable() async throws { + let fixture = try await Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let initial = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap( + initial.threads.first(where: { $0.environmentID == "one" }) + ) + let events = fixture.client.events() + var iterator = events.makeAsyncIterator() + await fixture.transport.setShellReadsEnabled(false, host: "one.example") + + try await fixture.client.setThreadArchived(id: thread.id, archived: true) + while let event = await iterator.next() { + if case let .thread(candidate) = event, + candidate.id == thread.id, + candidate.isArchived { + break + } + } + try await fixture.client.setThreadArchived(id: thread.id, archived: false) + var restored: FeatureThread? + while let event = await iterator.next() { + if case let .thread(candidate) = event, + candidate.id == thread.id, + !candidate.isArchived { + restored = candidate + break + } + } + + XCTAssertEqual(restored?.id, thread.id) + XCTAssertEqual(restored?.isArchived, false) + await fixture.client.disconnect() + } + + func testHTTPFallbackKeepsLiveConnectionReconnecting() async throws { + let fixture = try await Self.makeFixture( + fallbackPollingInitialDelay: .milliseconds(40), + fallbackPollingInterval: .seconds(2) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + let current = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Local work" + ) + let addedProject = OrchestrationProject( + id: "project-fallback", + title: "Fallback project", + workspaceRoot: "/work/fallback", + repositoryIdentity: nil, + defaultModelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + scripts: [], + createdAt: current.updatedAt, + updatedAt: current.updatedAt, + deletedAt: nil + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: current.snapshotSequence + 1, + projects: current.projects + [addedProject], + threads: current.threads, + updatedAt: current.updatedAt + ), + host: "one.example" + ) + let events = fixture.client.events() + var iterator = events.makeAsyncIterator() + var refreshed: FeatureSnapshot? + while let event = await iterator.next() { + if case let .snapshot(snapshot) = event, + snapshot.projects.contains(where: { $0.wireID == addedProject.id }) { + refreshed = snapshot + break + } + } + + XCTAssertEqual(refreshed?.connection.state, .reconnecting) + await fixture.client.disconnect() + } + + fileprivate static func makeFixture( + duplicateIDs: Bool = false, + passiveSequence: Int = 1, + includeThirdEnvironment: Bool = false, + repositoryIdentity: RepositoryIdentity? = nil, + pullRequestsAvailable: Bool = false, + webSocketConnector: any WebSocketConnecting = UnavailableMultiEnvironmentWebSocketConnector(), + rpcConnectionWaitTimeout: Duration = .milliseconds(5), + fallbackPollingInitialDelay: Duration = .seconds(3), + fallbackPollingInterval: Duration = .seconds(2), + aggregateRefreshInterval: Duration = NativeFeatureClient.defaultAggregateRefreshInterval, + aggregateIdleRefreshInterval: Duration = NativeFeatureClient.defaultAggregateIdleRefreshInterval, + aggregateFailureRefreshInterval: Duration = NativeFeatureClient.defaultAggregateFailureRefreshInterval, + aggregateRefreshSleep: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + }, + aggregateEnvironmentLoader: @escaping @Sendable (EnvironmentRuntime) async throws -> [Environment] = { + try await $0.environments() + } + ) async throws -> MultiEnvironmentFixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-multi-\(UUID().uuidString)", isDirectory: true) + var environments = [ + Environment( + id: "one", + label: "Left Book", + httpBaseURL: URL(string: "https://one.example")!, + webSocketBaseURL: URL(string: "wss://one.example")!, + descriptor: try multiEnvironmentDescriptor( + environmentID: "one", + label: "Left Book", + pullRequestsAvailable: pullRequestsAvailable + ) + ), + Environment( + id: "two", + label: "Steam Box", + httpBaseURL: URL(string: "https://two.example")!, + webSocketBaseURL: URL(string: "wss://two.example")!, + descriptor: try multiEnvironmentDescriptor( + environmentID: "two", + label: "Steam Box", + pullRequestsAvailable: pullRequestsAvailable + ) + ), + ] + if includeThirdEnvironment { + environments.append( + Environment( + id: "three", + label: "Third Box", + httpBaseURL: URL(string: "https://three.example")!, + webSocketBaseURL: URL(string: "wss://three.example")!, + descriptor: try multiEnvironmentDescriptor( + environmentID: "three", + label: "Third Box", + pullRequestsAvailable: pullRequestsAvailable + ) + ) + ) + } + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save(environments) + try await store.setActiveEnvironment(id: "one") + var shells = [ + "one.example": multiEnvironmentShell( + projectID: duplicateIDs ? "project-shared" : "project-one", + threadID: duplicateIDs ? "thread-shared" : "thread-one", + title: "Local work", + repositoryIdentity: repositoryIdentity + ), + "two.example": multiEnvironmentShell( + projectID: duplicateIDs ? "project-shared" : "project-two", + threadID: duplicateIDs ? "thread-shared" : "thread-two", + title: "Remote work", + providerID: "claudeAgent", + modelID: "claude-opus-4-1", + repositoryIdentity: repositoryIdentity, + snapshotSequence: passiveSequence + ), + ] + if includeThirdEnvironment { + shells["three.example"] = multiEnvironmentShell( + projectID: "project-three", + threadID: "thread-three", + title: "Third work", + providerID: "codex", + modelID: "gpt-5.6-sol" + ) + } + let transport = MultiEnvironmentHTTPTransport(shells: shells) + var environmentCredentials = [ + "one": EnvironmentCredential(accessToken: "one-token"), + "two": EnvironmentCredential(accessToken: "two-token"), + ] + if includeThirdEnvironment { + environmentCredentials["three"] = EnvironmentCredential(accessToken: "three-token") + } + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore(credentials: environmentCredentials), + httpTransport: transport, + webSocketConnector: webSocketConnector, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + let settings = UserDefaults( + suiteName: "t3-native-multi-\(UUID().uuidString)" + )! + return MultiEnvironmentFixture( + directory: directory, + transport: transport, + client: NativeFeatureClient( + runtime: runtime, + settingsStore: settings, + fallbackPollingInitialDelay: fallbackPollingInitialDelay, + fallbackPollingInterval: fallbackPollingInterval, + aggregateRefreshInterval: aggregateRefreshInterval, + aggregateIdleRefreshInterval: aggregateIdleRefreshInterval, + aggregateFailureRefreshInterval: aggregateFailureRefreshInterval, + aggregateRefreshSleep: aggregateRefreshSleep, + aggregateEnvironmentLoader: aggregateEnvironmentLoader + ) + ) + } +} + +@Suite("Native passive thread refresh") +@MainActor +struct NativePassiveThreadRefreshTests { + @Test( + "Passive thread events arrive within five seconds and stay fast after changes", + .timeLimit(.minutes(1)) + ) + func passiveThreadEventsArriveWithinFiveSecondsAndStayFastAfterChanges() async throws { + let refreshSleep = ControllableAggregateRefreshSleep() + let fixture = try await NativeMultiEnvironmentTests.makeFixture( + aggregateRefreshSleep: { + try await refreshSleep.sleep(for: $0) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let initial = try await fixture.client.initialSnapshot() + let thread = try #require( + initial.threads.first(where: { $0.environmentID == "two" }) + ) + let updatedTitle = "Passive work updated automatically" + let eventProbe = ThreadTitleEventProbe( + events: fixture.client.events(), + threadID: thread.id, + title: updatedTitle + ) + eventProbe.start() + + let firstCadence = await refreshSleep.waitUntilRequested(count: 1) + #expect(firstCadence == .seconds(5)) + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: updatedTitle, + providerID: "claudeAgent", + modelID: "claude-opus-4-1" + ), + host: "two.example" + ) + await refreshSleep.resume() + + await eventProbe.waitUntilObserved() + #expect(eventProbe.didObserveTitle()) + let changedCadence = await refreshSleep.waitUntilRequested(count: 2) + #expect(changedCadence == .seconds(5)) + await fixture.client.disconnect() + } + + @Test("Passive refresh uses ten seconds when work is unchanged") + func passiveRefreshUsesTenSecondsWhenWorkIsUnchanged() async throws { + let refreshSleep = ControllableAggregateRefreshSleep() + let fixture = try await NativeMultiEnvironmentTests.makeFixture( + aggregateRefreshSleep: { + try await refreshSleep.sleep(for: $0) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + let firstCadence = await refreshSleep.waitUntilRequested(count: 1) + #expect(firstCadence == .seconds(5)) + await refreshSleep.resume() + let idleCadence = await refreshSleep.waitUntilRequested(count: 2) + #expect(idleCadence == .seconds(10)) + await fixture.client.disconnect() + } + + @Test("A failed passive environment backs off without slowing an active peer") + func failedPassiveEnvironmentBacksOffWithoutSlowingActivePeer() async throws { + let refreshSleep = ControllableAggregateRefreshSleep() + let fixture = try await NativeMultiEnvironmentTests.makeFixture( + includeThirdEnvironment: true, + aggregateRefreshSleep: { + try await refreshSleep.sleep(for: $0) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: "Remote work", + providerID: "claudeAgent", + modelID: "claude-opus-4-1", + backgroundLiveness: .working + ), + host: "two.example" + ) + await fixture.transport.setReachable(false, host: "three.example") + + let firstCadence = await refreshSleep.waitUntilRequested(count: 1) + #expect(firstCadence == .seconds(5)) + await refreshSleep.resume() + let secondCadence = await refreshSleep.waitUntilRequested(count: 2) + #expect(secondCadence == .seconds(5)) + let initialFailedReadCount = await fixture.transport.shellReadCount(host: "three.example") + #expect(initialFailedReadCount == 2) + + for requestCount in 2...4 { + await refreshSleep.resume() + let cadence = await refreshSleep.waitUntilRequested(count: requestCount + 1) + #expect(cadence == .seconds(5)) + let failedReadCount = await fixture.transport.shellReadCount(host: "three.example") + #expect(failedReadCount == 2) + } + + await refreshSleep.resume() + _ = await refreshSleep.waitUntilRequested(count: 6) + let retriedReadCount = await fixture.transport.shellReadCount(host: "three.example") + #expect(retriedReadCount == 3) + await fixture.client.disconnect() + } +} + +private actor FailOnceAggregateEnvironmentLoader { + private(set) var callCount = 0 + private var callCountWaiters: [( + target: Int, + continuation: CheckedContinuation + )] = [] + + func load(from runtime: EnvironmentRuntime) async throws -> [Environment] { + callCount += 1 + resumeSatisfiedWaiters() + if callCount == 1 { + throw URLError(.cannotOpenFile) + } + return try await runtime.environments() + } + + func waitForCallCount(_ target: Int) async { + guard callCount < target else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((target, continuation)) + } + } + + private func resumeSatisfiedWaiters() { + let satisfied = callCountWaiters.filter { callCount >= $0.target } + callCountWaiters.removeAll { callCount >= $0.target } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +private actor CountingAggregateEnvironmentLoader { + private(set) var callCount = 0 + + func recordLoad() { + callCount += 1 + } +} + +private actor BlockingFirstAggregateEnvironmentLoader { + private(set) var callCount = 0 + private var callCountWaiters: [( + target: Int, + continuation: CheckedContinuation + )] = [] + private var firstLoadContinuation: CheckedContinuation? + private var firstLoadCancellationObserved = false + private var firstLoadCancellationWaiters: [CheckedContinuation] = [] + + func load(from runtime: EnvironmentRuntime) async throws -> [Environment] { + callCount += 1 + resumeSatisfiedWaiters() + if callCount == 1 { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + firstLoadContinuation = continuation + if Task.isCancelled { + firstLoadContinuation = nil + continuation.resume() + } + } + } onCancel: { + Task { await self.recordFirstLoadCancellation() } + } + try Task.checkCancellation() + } + return try await runtime.environments() + } + + func waitForCallCount(_ target: Int) async { + guard callCount < target else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((target, continuation)) + } + } + + func waitForFirstLoadCancellation() async { + guard !firstLoadCancellationObserved else { return } + await withCheckedContinuation { continuation in + firstLoadCancellationWaiters.append(continuation) + } + } + + private func recordFirstLoadCancellation() { + firstLoadCancellationObserved = true + firstLoadContinuation?.resume() + firstLoadContinuation = nil + let waiters = firstLoadCancellationWaiters + firstLoadCancellationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func resumeSatisfiedWaiters() { + let satisfied = callCountWaiters.filter { callCount >= $0.target } + callCountWaiters.removeAll { callCount >= $0.target } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +private actor RuntimeReplacementConnector: WebSocketConnecting { + let connection: BlockingRuntimeCloseConnection + + init(connection: BlockingRuntimeCloseConnection) { + self.connection = connection + } + + func connect(to _: URL) -> any WebSocketConnection { + connection + } +} + +private actor BlockingRuntimeCloseConnection: WebSocketConnection { + private var receiveContinuation: CheckedContinuation? + private var receiveWaiters: [CheckedContinuation] = [] + private var closeContinuation: CheckedContinuation? + private var closeWaiters: [CheckedContinuation] = [] + + func send(_: Data) {} + + func receive() async throws -> Data { + let waiters = receiveWaiters + receiveWaiters.removeAll() + waiters.forEach { $0.resume() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() async { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + let waiters = closeWaiters + closeWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { continuation in + closeContinuation = continuation + } + } + + func waitUntilReceiving() async { + guard receiveContinuation == nil else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func waitUntilCloseStarted() async { + guard closeContinuation == nil else { return } + await withCheckedContinuation { continuation in + closeWaiters.append(continuation) + } + } + + func releaseClose() { + closeContinuation?.resume() + closeContinuation = nil + } +} + +private actor RuntimeReplacementHTTPTransport: HTTPTransport { + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + guard request.url?.path == "/api/auth/websocket-ticket" else { + throw URLError(.unsupportedURL) + } + return ( + Data("{\"ticket\":\"ticket\",\"expiresAt\":\"2026-08-01T12:05:00.000Z\"}".utf8), + multiEnvironmentResponse(request) + ) + } +} + +@MainActor +private final class ThreadTitleEventProbe { + private let events: AsyncStream + private let threadID: String + private let title: String + private var observed = false + private var observedWaiters: [CheckedContinuation] = [] + private var task: Task? + + init(events: AsyncStream, threadID: String, title: String) { + self.events = events + self.threadID = threadID + self.title = title + } + + func start() { + task = Task { [weak self] in + guard let self else { return } + for await event in events { + switch event { + case let .thread(thread): + observed = thread.id == threadID && thread.title == title + case let .snapshot(snapshot): + observed = snapshot.threads.contains { + $0.id == self.threadID && $0.title == self.title + } + case .connection, .threadRemoved, .detail, .detailDelta, .threadSync, .failure: + observed = false + } + if observed { + observedWaiters.forEach { $0.resume() } + observedWaiters.removeAll() + return + } + } + } + } + + func didObserveTitle() -> Bool { + observed + } + + func waitUntilObserved() async { + guard observed == false else { return } + await withCheckedContinuation { continuation in + observedWaiters.append(continuation) + } + } + + deinit { + task?.cancel() + } +} + +private actor ControllableAggregateRefreshSleep { + private var requestedCadences: [Duration] = [] + private var requestWaiters: [( + count: Int, + continuation: CheckedContinuation + )] = [] + private var sleepContinuation: CheckedContinuation? + + func sleep(for cadence: Duration) async throws { + requestedCadences.append(cadence) + let satisfied = requestWaiters.filter { requestedCadences.count >= $0.count } + requestWaiters.removeAll { requestedCadences.count >= $0.count } + satisfied.forEach { + $0.continuation.resume(returning: requestedCadences[$0.count - 1]) + } + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume() + } else { + sleepContinuation = continuation + } + } + } onCancel: { + Task { await self.resume() } + } + try Task.checkCancellation() + } + + func waitUntilRequested(count: Int) async -> Duration { + if requestedCadences.count >= count { return requestedCadences[count - 1] } + return await withCheckedContinuation { continuation in + requestWaiters.append((count, continuation)) + } + } + + func resume() { + sleepContinuation?.resume() + sleepContinuation = nil + } +} + +private struct MultiEnvironmentFixture { + let directory: URL + let transport: MultiEnvironmentHTTPTransport + let client: NativeFeatureClient +} + +private actor MultiEnvironmentHTTPTransport: HTTPTransport { + private let shells: [String: OrchestrationShellSnapshot] + private var shellData: [String: Data] + private var detailData: [String: [String: Data]] = [:] + private var reachableHosts: Set + private var shellReadsEnabledHosts: Set + private var shellReadCounts: [String: Int] = [:] + private var dispatched: [MultiEnvironmentDispatchRecord] = [] + private var hostsDroppingNextCreateReply = Set() + + init(shells: [String: OrchestrationShellSnapshot]) { + self.shells = shells + shellData = shells.mapValues { try! JSONEncoder.t3.encode($0) } + reachableHosts = Set(shells.keys) + shellReadsEnabledHosts = Set(shells.keys) + } + + func setReachable(_ reachable: Bool, host: String) { + if reachable { + reachableHosts.insert(host) + } else { + reachableHosts.remove(host) + } + } + + func setShellReadsEnabled(_ enabled: Bool, host: String) { + if enabled { + shellReadsEnabledHosts.insert(host) + } else { + shellReadsEnabledHosts.remove(host) + } + } + + func setShell(_ shell: OrchestrationShellSnapshot, host: String) { + shellData[host] = try! JSONEncoder.t3.encode(shell) + } + + func setDetail( + _ detail: OrchestrationThreadDetailSnapshot, + host: String + ) { + detailData[host, default: [:]][detail.thread.id] = try! JSONEncoder.t3.encode(detail) + } + + func dispatchHosts() -> [String] { + dispatched.map(\.host) + } + + func dispatchRecords() -> [MultiEnvironmentDispatchRecord] { + dispatched + } + + func shellReadCount(host: String) -> Int { + shellReadCounts[host, default: 0] + } + + func dropNextCreateReply(host: String) { + hostsDroppingNextCreateReply.insert(host) + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let host = request.url?.host ?? "" + let path = request.url?.path ?? "" + if path == "/api/orchestration/shell" { + shellReadCounts[host, default: 0] += 1 + } + guard reachableHosts.contains(host) else { + throw URLError(.cannotConnectToHost) + } + if path == "/api/orchestration/shell", + shellReadsEnabledHosts.contains(host), + let data = shellData[host] { + return (data, multiEnvironmentResponse(request)) + } + if path.hasPrefix("/api/orchestration/threads/") { + let threadID = request.url?.lastPathComponent.removingPercentEncoding ?? "thread" + if let data = detailData[host]?[threadID] { + return (data, multiEnvironmentResponse(request)) + } + let projectID = shells[host]?.threads + .first(where: { $0.id == threadID })? + .projectId ?? shells[host]?.projects.first?.id ?? "project" + return ( + try JSONEncoder.t3.encode( + multiEnvironmentDetail(projectID: projectID, threadID: threadID) + ), + multiEnvironmentResponse(request) + ) + } + if path == "/api/orchestration/dispatch" { + guard let body = request.httpBody else { throw URLError(.badServerResponse) } + let command = try JSONDecoder.t3.decode(JSONValue.self, from: body) + dispatched.append( + MultiEnvironmentDispatchRecord(host: host, command: command) + ) + if command["type"]?.stringValue == "thread.create", + hostsDroppingNextCreateReply.remove(host) != nil, + let projectID = command["projectId"]?.stringValue, + let threadID = command["threadId"]?.stringValue { + let model = command["modelSelection"] + shellData[host] = try JSONEncoder.t3.encode( + multiEnvironmentShell( + projectID: projectID, + threadID: threadID, + title: command["title"]?.stringValue ?? "New thread", + providerID: model?["instanceId"]?.stringValue ?? "codex", + modelID: model?["model"]?.stringValue ?? "gpt-5.6-sol" + ) + ) + throw URLError(.networkConnectionLost) + } + return ( + try JSONEncoder.t3.encode(DispatchResult(sequence: 2)), + multiEnvironmentResponse(request) + ) + } + if path == "/api/auth/websocket-ticket" { + return ( + Data( + """ + {"ticket":"ticket","expiresAt":"2026-07-31T12:05:00.000Z"} + """.utf8 + ), + multiEnvironmentResponse(request) + ) + } + throw URLError(.unsupportedURL) + } +} + +private struct MultiEnvironmentDispatchRecord: Sendable { + let host: String + let command: JSONValue +} + +private struct UnavailableMultiEnvironmentWebSocketConnector: WebSocketConnecting { + func connect(to _: URL) async throws -> any WebSocketConnection { + throw URLError(.cannotConnectToHost) + } +} + +private actor MultiEnvironmentConfigurationServer { + private var settingsByHost: [String: [String: JSONValue]] = [:] + private var settingsUpdateHosts: [String] = [] + private let restartSupportHosts: Set + + init(restartSupportHosts: Set = []) { + self.restartSupportHosts = restartSupportHosts + } + + func updatedHosts() -> [String] { settingsUpdateHosts } + func settings(host: String) -> [String: JSONValue] { settingsByHost[host] ?? [:] } + + func response(to request: JSONValue, host: String) throws -> JSONValue? { + guard let tag = request["tag"]?.stringValue, + case let .number(id)? = request["id"] else { return nil } + let value: JSONValue + switch tag { + case RPCMethod.subscribeServerConfig.rawValue: + return .object([ + "_tag": .string("Chunk"), "requestId": .number(id), + "values": .array([.object([ + "type": .string("snapshot"), "config": config(host: host), + ])]), + ]) + case RPCMethod.serverRefreshProviders.rawValue: + value = .object(["providers": .array([.object([ + "instanceId": .string("codex-\(host)"), "driver": .string("codex"), + "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), + "auth": .object(["status": .string("authenticated")]), + "checkedAt": .string("2026-09-04T12:00:00.000Z"), "models": .array([]), + ])])]) + case RPCMethod.serverUpdateSettings.rawValue: + guard case let .object(patch)? = request["payload"]?["patch"] else { + throw URLError(.badServerResponse) + } + settingsUpdateHosts.append(host) + settingsByHost[host, default: [:]].merge(patch) { _, next in next } + value = .object(settingsByHost[host] ?? [:]) + case RPCMethod.getArchivedShellSnapshot.rawValue: + value = try JSONValue.encode(OrchestrationShellSnapshot( + snapshotSequence: 0, projects: [], threads: [], updatedAt: "2026-09-04T12:00:00.000Z" + )) + default: + return nil + } + return .object([ + "_tag": .string("Exit"), "requestId": .number(id), + "exit": .object(["_tag": .string("Success"), "value": value]), + ]) + } + + private func config(host: String) -> JSONValue { + let environmentID = host == "one.example" ? "one" : "two" + return .object([ + "providers": .array([]), "settings": .object(settingsByHost[host] ?? [:]), + "environment": .object([ + "environmentId": .string(environmentID), "label": .string(host), + "platform": .object(["os": .string("darwin"), "arch": .string("arm64")]), + "serverVersion": .string("1.0.0"), + "capabilities": .object([ + "threadAutoSettlement": .bool(true), "environmentIcon": .bool(true), + "threadRestartContinuation": .bool(restartSupportHosts.contains(host)), + ]), + ]), + ]) + } +} + +private struct MultiEnvironmentConfigurationConnector: WebSocketConnecting { + let server: MultiEnvironmentConfigurationServer + + func connect(to url: URL) -> any WebSocketConnection { + MultiEnvironmentConfigurationConnection(host: url.host ?? "", server: server) + } +} + +private actor MultiEnvironmentConfigurationConnection: WebSocketConnection { + private let host: String + private let server: MultiEnvironmentConfigurationServer + private var responses: [Data] = [] + private var receiver: CheckedContinuation? + private var closed = false + + init(host: String, server: MultiEnvironmentConfigurationServer) { + self.host = host + self.server = server + } + + func send(_ data: Data) async throws { + guard !closed else { throw URLError(.networkConnectionLost) } + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard let response = try await server.response(to: request, host: host) else { return } + let data = try JSONEncoder.t3.encode(response) + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + responses.append(data) + } + } + + func receive() async throws -> Data { + guard !closed else { throw URLError(.networkConnectionLost) } + if !responses.isEmpty { return responses.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + closed = true + receiver?.resume(throwing: CancellationError()) + receiver = nil + } +} + +private struct PullRequestPageRequest: Sendable { + let host: String + let input: PullRequestListInput +} + +private actor PullRequestPageRecorder { + private var requests: [PullRequestPageRequest] = [] + + func record(host: String, input: PullRequestListInput) { + requests.append(PullRequestPageRequest(host: host, input: input)) + } + + func recordedRequests() -> [PullRequestPageRequest] { + requests + } +} + +private struct PullRequestPageWebSocketConnector: WebSocketConnecting { + let recorder: PullRequestPageRecorder + + func connect(to url: URL) -> any WebSocketConnection { + PullRequestPageWebSocketConnection(host: url.host ?? "", recorder: recorder) + } +} + +private actor PullRequestPageWebSocketConnection: WebSocketConnection { + private let host: String + private let recorder: PullRequestPageRecorder + private var queuedResponses: [Data] = [] + private var receiveContinuation: CheckedContinuation? + + init(host: String, recorder: PullRequestPageRecorder) { + self.host = host + self.recorder = recorder + } + + func send(_ data: Data) async throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard request["tag"]?.stringValue == RPCMethod.pullRequestsList.rawValue, + case let .number(requestID)? = request["id"], + let payload = request["payload"] else { return } + + let input = try payload.decode(PullRequestListInput.self) + await recorder.record(host: host, input: input) + let page = PullRequestListResult( + viewers: ["github.com": "theo"], + providers: [], + entries: [], + errors: [], + truncated: true, + nextCursors: ["github.com t3/repo": "cursor-\(host)"] + ) + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": try JSONValue.encode(page), + ]), + ]) + let responseData = try JSONEncoder.t3.encode(response) + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: responseData) + } else { + queuedResponses.append(responseData) + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } +} + +private func multiEnvironmentDescriptor( + environmentID: String, + label: String, + pullRequestsAvailable: Bool +) throws -> EnvironmentDescriptor? { + guard pullRequestsAvailable else { return nil } + let value = JSONValue.object([ + "environmentId": .string(environmentID), + "label": .string(label), + "platform": .object([ + "os": .string("darwin"), + "arch": .string("arm64"), + ]), + "serverVersion": .string("0.1.0"), + "capabilities": .object([ + "repositoryIdentity": .bool(true), + "pullRequests": .bool(true), + ]), + ]) + return try value.decode(EnvironmentDescriptor.self) +} + +func multiEnvironmentShell( + projectID: String, + threadID: String, + title: String, + providerID: String = "codex", + modelID: String = "gpt-5.6-sol", + repositoryIdentity: RepositoryIdentity? = nil, + backgroundLiveness: OrchestrationBackgroundLiveness? = nil, + snapshotSequence: Int = 1, + settledOverride: String? = nil, + settledAt: String? = nil +) -> OrchestrationShellSnapshot { + let timestamp = "2026-07-31T12:00:00.000Z" + let model = ModelSelection(instanceId: providerID, model: modelID) + return OrchestrationShellSnapshot( + snapshotSequence: snapshotSequence, + projects: [ + OrchestrationProject( + id: projectID, + title: title, + workspaceRoot: "/work/\(projectID)", + repositoryIdentity: repositoryIdentity, + defaultModelSelection: model, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: nil + ), + ], + threads: [ + OrchestrationThreadShell( + id: threadID, + projectId: projectID, + title: title, + modelSelection: model, + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "feat/multi-device", + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: settledOverride, + settledAt: settledAt, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + session: nil, + latestUserMessageAt: nil, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: backgroundLiveness + ), + ], + updatedAt: timestamp + ) +} + +func multiEnvironmentDetail( + projectID: String, + threadID: String, + snapshotSequence: Int = 2, + settledOverride: String? = nil, + settledAt: String? = nil, + messages: [OrchestrationMessage] = [] +) -> OrchestrationThreadDetailSnapshot { + let timestamp = "2026-07-31T12:00:00.000Z" + return OrchestrationThreadDetailSnapshot( + snapshotSequence: snapshotSequence, + thread: OrchestrationThread( + id: threadID, + projectId: projectID, + title: threadID, + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "feat/multi-device", + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: settledOverride, + settledAt: settledAt, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: messages, + activities: [], + checkpoints: [], + session: nil + ) + ) +} + +private func multiEnvironmentResponse(_ request: URLRequest) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift new file mode 100644 index 000000000000..9dc8cd3a3f17 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift @@ -0,0 +1,831 @@ +import Foundation +import XCTest +@testable import T3Code + +@MainActor +final class NativeRetryIdentityTests: XCTestCase { + func testSavedSettingsSurviveAConnectionRepublish() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-settings-republish-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let environment = Environment( + id: "environment-settings-republish", + label: "Settings republish", + httpBaseURL: URL(string: "https://settings-republish.example")!, + webSocketBaseURL: URL(string: "wss://settings-republish.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + let transport = ConcurrentBootstrapHTTPTransport(shell: retryShellSnapshot()) + let connection = ConcurrentBootstrapWebSocketConnection() + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [environment.id: EnvironmentCredential(accessToken: "token")] + ), + httpTransport: transport, + webSocketConnector: ConcurrentBootstrapWebSocketConnector(connection: connection) + ) + let settingsSuite = "t3-native-settings-republish-\(UUID().uuidString)" + let settingsStore = UserDefaults(suiteName: settingsSuite)! + defer { settingsStore.removePersistentDomain(forName: settingsSuite) } + let client = NativeFeatureClient(runtime: runtime, settingsStore: settingsStore) + let initial = try await client.initialSnapshot() + await connection.waitUntilConnected() + var updated = initial.settings + updated.textSize = FeatureTextSizeAdjustment(steps: 2) + updated.codeSize = FeatureTextSizeAdjustment(steps: -1) + try await client.saveSettings(updated) + var events = client.events().makeAsyncIterator() + + await connection.failReceive() + + var receivedRepublish = false + while let event = await events.next() { + guard case let .snapshot(snapshot) = event, + snapshot.connection.state == .reconnecting else { + continue + } + XCTAssertEqual(snapshot.settings.textSize.steps, 2) + XCTAssertEqual(snapshot.settings.codeSize.steps, -1) + receivedRepublish = true + break + } + XCTAssertTrue(receivedRepublish) + await client.disconnect() + } + + func testConcurrentBootstrapRetriesKeepIndependentStableIdentities() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-concurrent-retry-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let environment = Environment( + id: "environment-concurrent-retry", + label: "Concurrent retry", + httpBaseURL: URL(string: "https://concurrent-retry.example")!, + webSocketBaseURL: URL(string: "wss://concurrent-retry.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + let transport = ConcurrentBootstrapHTTPTransport(shell: retryShellSnapshot()) + let connection = ConcurrentBootstrapWebSocketConnection() + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [environment.id: EnvironmentCredential(accessToken: "token")] + ), + httpTransport: transport, + webSocketConnector: ConcurrentBootstrapWebSocketConnector(connection: connection) + ) + let client = NativeFeatureClient( + runtime: runtime, + settingsStore: UserDefaults( + suiteName: "t3-native-concurrent-retry-\(UUID().uuidString)" + )! + ) + _ = try await client.initialSnapshot() + await connection.waitUntilConnected() + await transport.rejectShellReads() + + async let firstAttempt = failedBootstrap(client: client, prompt: "First task") + async let secondAttempt = failedBootstrap(client: client, prompt: "Second task") + _ = await (firstAttempt, secondAttempt) + await connection.waitUntilDispatchCount(2) + + await failedBootstrap(client: client, prompt: "First task") + await failedBootstrap(client: client, prompt: "Second task") + + let commands = await connection.dispatchCommands() + XCTAssertEqual(commands.count, 4) + for prompt in ["First task", "Second task"] { + let matching = commands.filter { + $0["message"]?["text"]?.stringValue == prompt + } + XCTAssertEqual(matching.count, 2, "Expected an initial attempt and one retry.") + XCTAssertEqual(matching.first?["threadId"], matching.last?["threadId"]) + XCTAssertEqual(matching.first?["commandId"], matching.last?["commandId"]) + XCTAssertEqual( + matching.first?["message"]?["messageId"], + matching.last?["message"]?["messageId"] + ) + } + await client.disconnect() + } + + func testTurnRetriesStayStableAndConfirmedBootstrapFailureResetsIdentity() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-retry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = Environment( + id: "environment-retry", + label: "Retry", + httpBaseURL: URL(string: "https://retry.example")!, + webSocketBaseURL: URL(string: "wss://retry.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + + let connection = AmbiguousDispatchWebSocketConnection() + let transport = RetryIdentityHTTPTransport(shell: retryShellSnapshot()) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "token"), + ] + ), + httpTransport: transport, + webSocketConnector: RetryIdentityWebSocketConnector(connection: connection) + ) + let settings = UserDefaults( + suiteName: "t3-native-retry-\(UUID().uuidString)" + )! + let client = NativeFeatureClient(runtime: runtime, settingsStore: settings) + let initial = try await client.initialSnapshot() + XCTAssertEqual(initial.threads.first?.runtimeMode, .approvalRequired) + XCTAssertEqual(initial.threads.first?.interactionMode, .standard) + await connection.waitUntilConnected() + + let turnIdentity = FeatureSubmissionIdentity( + threadID: "thread-existing", + commandID: "persisted-turn-command", + messageID: "persisted-turn-message", + createdAt: Date(timeIntervalSince1970: 1_750_000_000) + ) + for _ in 0..<2 { + do { + try await client.sendMessage( + threadID: "thread-existing", + text: "Retry without duplicating", + selection: nil, + attachments: [], + identity: turnIdentity + ) + XCTFail("The synthetic dispatch should fail ambiguously.") + } catch {} + } + + for _ in 0..<2 { + do { + _ = try await client.createThreadAndSend( + projectID: "project-1", + prompt: "Create exactly one task", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5.4"), + runtimeMode: .autoAcceptEdits, + interactionMode: .plan, + attachments: [] + ) + XCTFail("The synthetic bootstrap should fail ambiguously.") + } catch {} + } + + let commands = await connection.dispatchCommands() + + transport.dispatchCommands() + XCTAssertEqual(commands.count, 4) + let turnCommands = commands.filter { + $0["message"]?["text"]?.stringValue == "Retry without duplicating" + } + XCTAssertEqual(turnCommands.count, 2) + let initialTurn = try XCTUnwrap(turnCommands.first) + let retriedTurn = try XCTUnwrap(turnCommands.dropFirst().first) + assertStableIdentity(initialTurn, retriedTurn, includesThreadID: false) + XCTAssertEqual(initialTurn["commandId"]?.stringValue, turnIdentity.commandID) + XCTAssertEqual( + initialTurn["message"]?["messageId"]?.stringValue, + turnIdentity.messageID + ) + let bootstrapCommands = commands.filter { + $0["message"]?["text"]?.stringValue == "Create exactly one task" + } + XCTAssertEqual(bootstrapCommands.count, 2) + let initialBootstrap = try XCTUnwrap(bootstrapCommands.first) + let retriedBootstrap = try XCTUnwrap(bootstrapCommands.dropFirst().first) + XCTAssertNotEqual(initialBootstrap["commandId"], retriedBootstrap["commandId"]) + XCTAssertNotEqual( + initialBootstrap["message"]?["messageId"], + retriedBootstrap["message"]?["messageId"] + ) + XCTAssertNotEqual(initialBootstrap["threadId"], retriedBootstrap["threadId"]) + for command in turnCommands { + XCTAssertEqual(command["runtimeMode"]?.stringValue, "approval-required") + XCTAssertEqual(command["interactionMode"]?.stringValue, "default") + } + for command in bootstrapCommands { + XCTAssertEqual(command["runtimeMode"]?.stringValue, "auto-accept-edits") + XCTAssertEqual(command["interactionMode"]?.stringValue, "default") + } + await client.disconnect() + } + + func testPartialBootstrapRecoversBySendingOnlyTheStableFinalTurn() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-partial-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = Environment( + id: "environment-partial", + label: "Partial", + httpBaseURL: URL(string: "https://partial.example")!, + webSocketBaseURL: URL(string: "wss://partial.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + + let connection = PartialBootstrapWebSocketConnection() + let transport = PartialBootstrapHTTPTransport(shell: retryShellSnapshot()) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "token"), + ] + ), + httpTransport: transport, + webSocketConnector: PartialBootstrapWebSocketConnector(connection: connection) + ) + let settings = UserDefaults( + suiteName: "t3-native-partial-\(UUID().uuidString)" + )! + let client = NativeFeatureClient(runtime: runtime, settingsStore: settings) + _ = try await client.initialSnapshot() + await connection.waitUntilConnected() + + let identity = FeatureSubmissionIdentity( + threadID: "persisted-bootstrap-thread", + commandID: "persisted-bootstrap-command", + messageID: "persisted-bootstrap-message", + createdAt: Date(timeIntervalSince1970: 1_750_000_000) + ) + let created = try await client.createThreadAndSend( + projectID: "project-1", + prompt: "Recover the first turn", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5.4"), + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false, + attachments: [], + identity: identity + ) + + let commands = await connection.dispatchCommands() + + transport.dispatchCommands() + XCTAssertEqual(commands.count, 2) + let bootstrap = try XCTUnwrap(commands.first { $0["bootstrap"] != nil }) + let finalTurn = try XCTUnwrap(commands.first { $0["bootstrap"] == nil }) + assertStableIdentity(bootstrap, finalTurn, includesThreadID: true) + XCTAssertEqual(bootstrap["threadId"]?.stringValue, identity.threadID) + XCTAssertEqual(bootstrap["commandId"]?.stringValue, identity.commandID) + XCTAssertEqual( + bootstrap["message"]?["messageId"]?.stringValue, + identity.messageID + ) + let wireID = try XCTUnwrap(bootstrap["threadId"]?.stringValue) + XCTAssertEqual(created.wireID, wireID) + XCTAssertEqual( + created.id, + FeatureScopedID.thread(environmentID: environment.id, wireID: wireID) + ) + await client.disconnect() + } + + private func assertStableIdentity( + _ first: JSONValue, + _ second: JSONValue, + includesThreadID: Bool + ) { + XCTAssertEqual(first["commandId"], second["commandId"]) + XCTAssertEqual(first["message"]?["messageId"], second["message"]?["messageId"]) + XCTAssertEqual(first["createdAt"], second["createdAt"]) + if includesThreadID { + XCTAssertEqual(first["threadId"], second["threadId"]) + } + } + + private func failedBootstrap(client: NativeFeatureClient, prompt: String) async { + do { + _ = try await client.createThreadAndSend( + projectID: "project-1", + prompt: prompt, + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5.4"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + XCTFail("The synthetic dispatch should fail ambiguously.") + } catch {} + } +} + +private struct ConcurrentBootstrapWebSocketConnector: WebSocketConnecting { + let connection: ConcurrentBootstrapWebSocketConnection + + func connect(to _: URL) -> any WebSocketConnection { + connection + } +} + +private actor ConcurrentBootstrapWebSocketConnection: WebSocketConnection { + private var commands: [JSONValue] = [] + private var initialFailures: [CheckedContinuation] = [] + private var dispatchWaiters: [(Int, CheckedContinuation)] = [] + private var didConnect = false + private var connectionWaiters: [CheckedContinuation] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var shouldFailNextReceive = false + + func send(_ data: Data) async throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if !didConnect { + didConnect = true + connectionWaiters.forEach { $0.resume() } + connectionWaiters.removeAll() + } + if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, + let response = try retryConfigResponse(for: request) { + enqueue(response) + return + } + guard request["tag"]?.stringValue == RPCMethod.dispatchCommand.rawValue, + let payload = request["payload"] else { + return + } + commands.append(payload) + let ready = dispatchWaiters.filter { commands.count >= $0.0 } + dispatchWaiters.removeAll { commands.count >= $0.0 } + ready.forEach { $0.1.resume() } + guard commands.count <= 2 else { + throw URLError(.networkConnectionLost) + } + return try await withCheckedThrowingContinuation { continuation in + initialFailures.append(continuation) + guard initialFailures.count == 2 else { return } + let failures = initialFailures + initialFailures.removeAll() + failures.forEach { $0.resume(throwing: URLError(.networkConnectionLost)) } + } + } + + func receive() async throws -> Data { + if shouldFailNextReceive { + shouldFailNextReceive = false + throw URLError(.networkConnectionLost) + } + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func failReceive() { + let error = URLError(.networkConnectionLost) + if let receiver { + self.receiver = nil + receiver.resume(throwing: error) + } else { + shouldFailNextReceive = true + } + } + + func waitUntilConnected() async { + guard !didConnect else { return } + await withCheckedContinuation { continuation in + connectionWaiters.append(continuation) + } + } + + func waitUntilDispatchCount(_ count: Int) async { + guard commands.count < count else { return } + await withCheckedContinuation { continuation in + dispatchWaiters.append((count, continuation)) + } + } + + func dispatchCommands() -> [JSONValue] { + commands + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} + +private actor ConcurrentBootstrapHTTPTransport: HTTPTransport { + private let shellData: Data + private var acceptsShellReads = true + + init(shell: OrchestrationShellSnapshot) { + shellData = try! JSONEncoder.t3.encode(shell) + } + + func rejectShellReads() { + acceptsShellReads = false + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + switch request.url?.path { + case "/api/orchestration/shell" where acceptsShellReads: + (shellData, retryHTTPResponse(request)) + case "/api/auth/websocket-ticket": + ( + Data( + "{\"ticket\":\"ticket\",\"expiresAt\":\"2026-08-01T12:05:00.000Z\"}".utf8 + ), + retryHTTPResponse(request) + ) + default: + throw URLError(.networkConnectionLost) + } + } +} + +private func retryShellSnapshot() -> OrchestrationShellSnapshot { + let timestamp = "2026-07-30T12:00:00.000Z" + let model = ModelSelection(instanceId: "codex", model: "gpt-5.4") + return OrchestrationShellSnapshot( + snapshotSequence: 1, + projects: [ + OrchestrationProject( + id: "project-1", + title: "T3 Code", + workspaceRoot: "/work/t3", + repositoryIdentity: nil, + defaultModelSelection: model, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: nil + ), + ], + threads: [ + OrchestrationThreadShell( + id: "thread-existing", + projectId: "project-1", + title: "Existing", + modelSelection: model, + runtimeMode: .approvalRequired, + interactionMode: .plan, + branch: nil, + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + session: nil, + latestUserMessageAt: nil, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: nil + ), + ], + updatedAt: timestamp + ) +} + +private actor RetryIdentityHTTPTransport: HTTPTransport { + private let shellData: Data + private var commands: [JSONValue] = [] + + init(shell: OrchestrationShellSnapshot) { + shellData = try! JSONEncoder.t3.encode(shell) + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let path = request.url?.path ?? "" + if path == "/api/orchestration/shell" { + return (shellData, retryHTTPResponse(request)) + } + if path == "/api/auth/websocket-ticket" { + return ( + Data( + """ + { + "ticket": "ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """.utf8 + ), + retryHTTPResponse(request) + ) + } + if path.hasPrefix("/api/orchestration/threads/") { + throw URLError(.networkConnectionLost) + } + if path == "/api/orchestration/dispatch" { + commands.append(try retryDispatchCommand(from: request)) + throw URLError(.networkConnectionLost) + } + throw URLError(.unsupportedURL) + } + + func dispatchCommands() -> [JSONValue] { + commands + } +} + +private struct RetryIdentityWebSocketConnector: WebSocketConnecting { + let connection: AmbiguousDispatchWebSocketConnection + + func connect(to _: URL) async throws -> any WebSocketConnection { + connection + } +} + +private actor PartialBootstrapHTTPTransport: HTTPTransport { + private let shellData: Data + private var commands: [JSONValue] = [] + + init(shell: OrchestrationShellSnapshot) { + shellData = try! JSONEncoder.t3.encode(shell) + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let path = request.url?.path ?? "" + if path == "/api/orchestration/shell" { + return (shellData, retryHTTPResponse(request)) + } + if path == "/api/auth/websocket-ticket" { + return ( + Data( + """ + { + "ticket": "ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """.utf8 + ), + retryHTTPResponse(request) + ) + } + if path.hasPrefix("/api/orchestration/threads/") { + let threadID = request.url?.lastPathComponent.removingPercentEncoding ?? "thread" + let snapshot = retryEmptyThreadDetail(id: threadID) + return (try JSONEncoder.t3.encode(snapshot), retryHTTPResponse(request)) + } + if path == "/api/orchestration/dispatch" { + commands.append(try retryDispatchCommand(from: request)) + return ( + Data("{\"sequence\":42}".utf8), + retryHTTPResponse(request) + ) + } + throw URLError(.unsupportedURL) + } + + func dispatchCommands() -> [JSONValue] { + commands + } +} + +private struct PartialBootstrapWebSocketConnector: WebSocketConnecting { + let connection: PartialBootstrapWebSocketConnection + + func connect(to _: URL) async throws -> any WebSocketConnection { + connection + } +} + +private actor AmbiguousDispatchWebSocketConnection: WebSocketConnection { + private var commands: [JSONValue] = [] + private var queuedResponses: [Data] = [] + private var didConnect = false + private var connectionWaiters: [CheckedContinuation] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if !didConnect { + didConnect = true + connectionWaiters.forEach { $0.resume() } + connectionWaiters.removeAll() + } + if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, + let response = try retryConfigResponse(for: request) { + enqueue(response) + return + } + if request["tag"]?.stringValue == RPCMethod.dispatchCommand.rawValue, + let payload = request["payload"] { + commands.append(payload) + throw URLError(.networkConnectionLost) + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilConnected() async { + guard !didConnect else { return } + await withCheckedContinuation { continuation in + connectionWaiters.append(continuation) + } + } + + func dispatchCommands() -> [JSONValue] { + commands + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} + +private actor PartialBootstrapWebSocketConnection: WebSocketConnection { + private var commands: [JSONValue] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var didConnect = false + private var connectionWaiters: [CheckedContinuation] = [] + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if !didConnect { + didConnect = true + connectionWaiters.forEach { $0.resume() } + connectionWaiters.removeAll() + } + guard request["tag"]?.stringValue == RPCMethod.dispatchCommand.rawValue, + let payload = request["payload"] else { + if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, + let response = try retryConfigResponse(for: request) { + enqueue(response) + } + return + } + commands.append(payload) + if payload["bootstrap"] != nil { + throw URLError(.networkConnectionLost) + } + guard case let .number(requestID) = request["id"] else { return } + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": .object(["sequence": .number(42)]), + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilConnected() async { + guard !didConnect else { return } + await withCheckedContinuation { continuation in + connectionWaiters.append(continuation) + } + } + + func dispatchCommands() -> [JSONValue] { + commands + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} + +private func retryConfigResponse(for request: JSONValue) throws -> Data? { + guard case let .number(requestID)? = request["id"] else { return nil } + let config = JSONValue.object(["providers": .array([])]) + if request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue { + return try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(requestID), + "values": .array([.object([ + "type": .string("snapshot"), + "config": config, + ])]), + ]) + ) + } + return try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": config, + ]), + ]) + ) +} + +private func retryEmptyThreadDetail(id: String) -> OrchestrationThreadDetailSnapshot { + let timestamp = "2026-07-30T12:00:00.000Z" + return OrchestrationThreadDetailSnapshot( + snapshotSequence: 2, + thread: OrchestrationThread( + id: id, + projectId: "project-1", + title: "Recover the first turn", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: nil, + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: [], + activities: [], + checkpoints: [], + session: nil + ) + ) +} + +private func retryHTTPResponse(_ request: URLRequest) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! +} + +private func retryDispatchCommand(from request: URLRequest) throws -> JSONValue { + guard let body = request.httpBody else { + throw URLError(.cannotDecodeContentData) + } + return try JSONDecoder.t3.decode(JSONValue.self, from: body) +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeRuntimeParityTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeRuntimeParityTests.swift new file mode 100644 index 000000000000..bea230bb468e --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeRuntimeParityTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Native runtime parity") +struct NativeRuntimeParityTests { + @Test(arguments: ["runtime.warning", "runtime.error"]) + func runtimeNoticesKeepTheFullMessageAndMetadata(kind: String) throws { + let createdAt = Date(timeIntervalSince1970: 100) + let fullMessage = "The provider could not complete the request.\n" + + String(repeating: "Connection details remain available here.\n", count: 60) + let event = activity( + kind: kind, + tone: kind == "runtime.error" ? "error" : "info", + summary: "Short summary", + payload: [ + "message": .string(fullMessage), + "detail": .string("Do not append this fallback detail."), + ] + ) + + let notice = try #require(NativeActivityNotice.message(event, createdAt: createdAt)) + + #expect(notice.text == fullMessage) + #expect(notice.role == .system) + #expect(notice.createdAt == createdAt) + #expect(notice.toolName == kind) + } + + @Test + func compactionResultsUseAStandaloneSummary() throws { + let createdAt = Date(timeIntervalSince1970: 110) + let event = activity( + kind: "context-compaction", + summary: "Context compacted.", + payload: [ + "message": .string("Provider response details"), + "detail": .string("Internal compaction details"), + ] + ) + + let notice = try #require(NativeActivityNotice.message(event, createdAt: createdAt)) + + #expect(notice.text == "Context compacted.") + #expect(notice.role == .system) + #expect(notice.createdAt == createdAt) + #expect(notice.toolName == "context-compaction") + } + + @Test(arguments: ["context-compaction", "provider.turn.start.failed"]) + func compactionSettlesOnlyForTheLatestMatchingRequest(kind: String) { + let earlierDate = Date(timeIntervalSince1970: 100) + let requestedAt = earlierDate.addingTimeInterval(10) + let earlier = message(id: "earlier", createdAt: earlierDate) + let current = message(id: "current", createdAt: requestedAt) + var state = NativeContextCompactionState() + state.apply(earlier, createdAt: earlierDate) + state.apply(current, createdAt: requestedAt) + + #expect(state.isActive( + sessionStatus: "running", latestTurnState: "running", latestTurnRequestedAt: requestedAt + )) + + state.apply(earlier, createdAt: earlierDate) + state.apply(activity(kind: kind, payload: ["requestId": .string("earlier")])) + #expect(state.isActive( + sessionStatus: "running", latestTurnState: "running", latestTurnRequestedAt: requestedAt + )) + + state.apply(activity(kind: kind, payload: ["requestId": .string("current")])) + #expect(!state.isActive( + sessionStatus: "running", latestTurnState: "running", latestTurnRequestedAt: requestedAt + )) + + state.apply(current, createdAt: requestedAt) + #expect(!state.isActive( + sessionStatus: "running", latestTurnState: "running", latestTurnRequestedAt: requestedAt + )) + + let nextDate = requestedAt.addingTimeInterval(10) + state.apply(message(id: "next", createdAt: nextDate), createdAt: nextDate) + #expect(state.isActive( + sessionStatus: "running", latestTurnState: "running", latestTurnRequestedAt: nextDate + )) + } + + @Test + func compactionStopsForANewerTurnOrAnEndedSession() { + let requestedAt = Date(timeIntervalSince1970: 100) + var state = NativeContextCompactionState() + state.apply(message(id: "compact", createdAt: requestedAt), createdAt: requestedAt) + + #expect(state.isActive( + sessionStatus: "starting", + latestTurnState: "completed", + latestTurnRequestedAt: requestedAt.addingTimeInterval(-10) + )) + #expect(!state.isActive( + sessionStatus: "running", latestTurnState: "completed", latestTurnRequestedAt: requestedAt + )) + + let nextTurnDate = requestedAt.addingTimeInterval(10) + state.apply( + message(id: "normal-turn", createdAt: nextTurnDate, text: "Explain this function."), + createdAt: nextTurnDate + ) + #expect(!state.isActive( + sessionStatus: "running", latestTurnState: "running", latestTurnRequestedAt: nextTurnDate + )) + + let endedStatuses: [String?] = ["ready", "stopped", "error", nil] + for status in endedStatuses { + #expect(!state.isActive( + sessionStatus: status, latestTurnState: "running", latestTurnRequestedAt: requestedAt + )) + } + } + + @Test + func unsupportedRestartPreferenceLeavesOtherSharedValuesIntact() throws { + let sharedValues: [String: JSONValue] = [ + "defaultThreadEnvMode": .string("worktree"), + "sidebarAutoSettleAfterDays": .null, + "sourceControlWritingStyle": .object([ + "mode": .string("conventional_commits"), + "customInstructions": .string("Keep messages short."), + ]), + ] + var valuesWithRestart = sharedValues + valuesWithRestart["continueThreadsAfterServerUpdate"] = .bool(true) + let change = ServerSettingsChange.sharedPreferences(.object(valuesWithRestart)) + + let filtered = try #require(NativeSharedPreferenceChange.filter( + change, supportsRestartContinuation: false + )) + + #expect(filtered.jsonValue == .object(sharedValues)) + #expect(NativeSharedPreferenceChange.filter( + change, supportsRestartContinuation: true + ) == change) + #expect(NativeSharedPreferenceChange.filter( + .defaultThreadEnvMode(.worktree), supportsRestartContinuation: false + ) == .defaultThreadEnvMode(.worktree)) + } + + @Test + func unsupportedRestartOnlyChangesAreNotSent() { + let changes: [ServerSettingsChange] = [ + .continueThreadsAfterServerUpdate(true), + .sharedPreferences(.object(["continueThreadsAfterServerUpdate": .bool(false)])), + ] + + for change in changes { + #expect(NativeSharedPreferenceChange.filter( + change, supportsRestartContinuation: false + ) == nil) + #expect(NativeSharedPreferenceChange.filter( + change, supportsRestartContinuation: true + ) == change) + } + } + + private func message( + id: String, + createdAt: Date, + text: String = "/compact" + ) -> OrchestrationMessage { + OrchestrationMessage( + id: id, + role: "user", + text: text, + attachments: nil, + turnId: nil, + streaming: false, + createdAt: createdAt.ISO8601Format(), + updatedAt: createdAt.ISO8601Format() + ) + } + + private func activity( + kind: String, + tone: String = "info", + summary: String = "Context compacted.", + payload: [String: JSONValue] = [:] + ) -> OrchestrationActivity { + OrchestrationActivity( + id: "notice-\(kind)", + tone: tone, + kind: kind, + summary: summary, + payload: .object(payload), + turnId: nil, + sequence: nil, + createdAt: "2026-09-05T12:00:00Z" + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeShellProjectionTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeShellProjectionTests.swift new file mode 100644 index 000000000000..c2e3ebe73cbe --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeShellProjectionTests.swift @@ -0,0 +1,120 @@ +import Foundation +import XCTest +@testable import T3Code + +@MainActor +final class NativeShellProjectionTests: XCTestCase { + func testOneChangedThreadMapsOnlyOneOfOneThousandRows() throws { + var projection = NativeShellRowProjection() + var sources = (0..<1_000).map { thread(id: "thread-\($0)") } + var mappedCount = 0 + let map: (OrchestrationThreadShell) -> String = { + mappedCount += 1 + return $0.title + } + _ = projection.map(sources, transform: map) + XCTAssertEqual(mappedCount, 1_000) + + // An HTTP refresh has a new allocation but equal records. + let refreshed = try JSONDecoder.t3.decode( + [OrchestrationThreadShell].self, + from: JSONEncoder.t3.encode(sources) + ) + _ = projection.map(refreshed, transform: map) + XCTAssertEqual(mappedCount, 1_000) + + sources[500] = thread(id: "thread-500", title: "New title") + let rows = projection.map(sources, transform: map) + XCTAssertEqual(mappedCount, 1_001) + XCTAssertEqual(rows.count, 1_000) + XCTAssertEqual(rows[500], "New title") + XCTAssertEqual(rows[499], "Original title") + } + + func testReorderRemovalAndInsertionKeepRowsMatchedToTheirSource() { + var projection = NativeShellRowProjection() + let first = thread(id: "first", title: "First") + let second = thread(id: "second", title: "Second") + let third = thread(id: "third", title: "Third") + var mappedCount = 0 + let map: (OrchestrationThreadShell) -> String = { + mappedCount += 1 + return $0.title + } + XCTAssertEqual(projection.map([first, second, third], transform: map), ["First", "Second", "Third"]) + XCTAssertEqual(projection.map([third, first], transform: map), ["Third", "First"]) + XCTAssertEqual(mappedCount, 3) + + let added = thread(id: "added", title: "Added") + XCTAssertEqual(projection.map([added, third, first], transform: map), ["Added", "Third", "First"]) + XCTAssertEqual(mappedCount, 4) + XCTAssertEqual(projection.map([], transform: map), []) + XCTAssertEqual(projection.map([first], transform: map), ["First"]) + XCTAssertEqual(mappedCount, 5, "Removed rows must not remain retained in the cache.") + } + + func testSettlementAndBackgroundWorkChangesDoNotNeedANewUpdatedAt() { + var projection = NativeShellRowProjection() + let initial = thread(id: "first") + let settled = multiEnvironmentShell( + projectID: "project", threadID: "first", title: initial.title, + backgroundLiveness: .monitoring, + settledOverride: "settled", settledAt: "2026-07-31T12:01:00.000Z" + ).threads[0] + XCTAssertEqual(initial.updatedAt, settled.updatedAt) + var mappedCount = 0 + let map: (OrchestrationThreadShell) -> String = { + mappedCount += 1 + return "\($0.settledOverride ?? "active"):\($0.backgroundLiveness?.rawValue ?? "idle")" + } + XCTAssertEqual(projection.map([initial], transform: map), ["active:idle"]) + XCTAssertEqual(projection.map([settled], transform: map), ["settled:monitoring"]) + XCTAssertEqual(mappedCount, 2) + } + + func testEnvironmentAndProviderNamesInvalidateOnlyTheirEnvironment() { + var one = NativeShellProjection() + var two = NativeShellProjection() + var firstEnvironment = environment(id: "one") + let otherEnvironment = environment(id: "two") + let source = [thread(id: "shared-wire-id")] + var names = ["codex": "Codex work"] + var mappedCount = 0 + let map: (OrchestrationThreadShell) -> FeatureThread = { + mappedCount += 1 + return FeatureThread( + id: $0.id, projectID: $0.projectId, + environmentName: firstEnvironment.label, title: $0.title, + providerName: names[$0.modelSelection.instanceId] + ) + } + _ = one.mapThreads(source, environment: firstEnvironment, providerNames: names, transform: map) + _ = two.mapThreads(source, environment: otherEnvironment, providerNames: names, transform: map) + XCTAssertEqual(mappedCount, 2) + + names["codex"] = "Codex personal" + let renamedProvider = one.mapThreads(source, environment: firstEnvironment, providerNames: names, transform: map) + XCTAssertEqual(renamedProvider[0].providerName, "Codex personal") + XCTAssertEqual(mappedCount, 3) + + firstEnvironment.label = "Renamed computer" + let renamedEnvironment = one.mapThreads(source, environment: firstEnvironment, providerNames: names, transform: map) + XCTAssertEqual(renamedEnvironment[0].environmentName, "Renamed computer") + XCTAssertEqual(mappedCount, 4) + + _ = two.mapThreads(source, environment: otherEnvironment, providerNames: ["codex": "Codex work"], transform: map) + XCTAssertEqual(mappedCount, 4, "Another environment's unchanged rows must stay cached.") + } + + private func thread(id: String, title: String = "Original title") -> OrchestrationThreadShell { + multiEnvironmentShell(projectID: "project", threadID: id, title: title).threads[0] + } + + private func environment(id: String) -> Environment { + Environment( + id: id, label: "Computer \(id)", + httpBaseURL: URL(string: "https://\(id).example")!, + webSocketBaseURL: URL(string: "wss://\(id).example")! + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeThreadCatchUpTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeThreadCatchUpTests.swift new file mode 100644 index 000000000000..f394590e1f2c --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeThreadCatchUpTests.swift @@ -0,0 +1,1307 @@ +import Foundation +import XCTest +@testable import T3Code + +@MainActor +@available(iOS 18.0, *) +final class NativeThreadCatchUpTests: XCTestCase { + func testRequestSnapshotsKeepTerminalRequestsClosedAndOtherFailuresRetryable() async throws { + var activities: [OrchestrationActivity] = [] + for kind in ["approval", "user-input"] { + activities += [ + requestActivity("\(kind).resolved", id: "resolved-\(kind)"), + requestActivity("\(kind).requested", id: "resolved-\(kind)"), + requestActivity("\(kind).requested", id: "retry-\(kind)"), + requestActivity( + "provider.\(kind).respond.failed", id: "retry-\(kind)", + detail: "Unknown network failure with stale connection metadata" + ), + ] + } + let failures = [ + "approval": [ + "stale pending approval request", "unknown pending approval request", + "unknown pending permission request", "unknown pending codex approval request", + ], + "user-input": [ + "stale pending user-input request", "unknown pending user-input request", + "unknown pending user input request", "unknown pending codex user input request", + ], + ] + for (kind, fragments) in failures { + for (index, fragment) in fragments.enumerated() { + let id = "stale-\(kind)-\(index)" + activities += [ + requestActivity("provider.\(kind).respond.failed", id: id, detail: fragment.uppercased()), + requestActivity("\(kind).requested", id: id), + ] + } + } + activities += [ + requestActivity("approval.requested", id: "legacy-file", requestType: "apply_patch_approval"), + requestActivity("approval.requested", id: "legacy-input", requestType: "tool_user_input"), + requestActivity("approval.requested", id: "legacy-auth", requestType: "auth_tokens_refresh"), + ] + let fixture = try await CatchUpFixture.make(activities: activities) + defer { fixture.cleanUp() } + let detail = try await fixture.client.loadThread(id: fixture.firstID) + XCTAssertEqual(Set(detail.approvals.compactMap(\.wireID)), ["retry-approval", "legacy-file"]) + XCTAssertEqual(detail.approvals.first { $0.wireID == "legacy-file" }?.kind, .fileChange) + XCTAssertEqual(detail.userInputs.compactMap(\.wireID), ["retry-user-input"]) + await fixture.client.disconnect() + } + + func testLiveRequestsKeepTerminalStateAcrossBatchesAndResetWithSnapshots() async throws { + let resolved = ["approval", "user-input"].map { + requestActivity("\($0).resolved", id: "closed-\($0)") + } + let fixture = try await CatchUpFixture.make(activities: resolved) + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + + var activities: [OrchestrationActivity] = [] + for kind in ["approval", "user-input"] { + activities += [ + requestActivity("\(kind).requested", id: "closed-\(kind)"), + requestActivity("\(kind).resolved", id: "live-\(kind)"), + requestActivity("\(kind).requested", id: "retry-\(kind)"), + requestActivity( + "provider.\(kind).respond.failed", id: "retry-\(kind)", + detail: "Unknown transport error" + ), + ] + } + try await stream.sendActivities(activities, startingAt: 3) + try await stream.synchronize() + let first = try await requestsBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(first.approvals.compactMap(\.wireID), ["retry-approval"]) + XCTAssertEqual(first.userInputs.compactMap(\.wireID), ["retry-user-input"]) + + let lateRequests = ["approval", "user-input"].map { + requestActivity("\($0).requested", id: "live-\($0)") + } + try await stream.sendActivities(lateRequests, startingAt: 20) + try await stream.synchronize() + let second = try await requestsBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(second.approvals.compactMap(\.wireID), ["retry-approval"]) + XCTAssertEqual(second.userInputs.compactMap(\.wireID), ["retry-user-input"]) + + // A replacement snapshot is authoritative, including its request history. + await fixture.http.setActivities(lateRequests) + let replaced = try await fixture.client.loadThread(id: fixture.firstID, fresh: true) + XCTAssertEqual(replaced.approvals.compactMap(\.wireID), ["live-approval"]) + XCTAssertEqual(replaced.userInputs.compactMap(\.wireID), ["live-user-input"]) + await fixture.client.disconnect() + } + + private func requestActivity( + _ kind: String, id: String, detail: String? = nil, requestType: String? = nil + ) -> OrchestrationActivity { + var payload: [String: JSONValue] = ["requestId": .string(id)] + payload["detail"] = detail.map(JSONValue.string) + payload["requestType"] = requestType.map(JSONValue.string) + if kind == "user-input.requested" { + payload["questions"] = .array([.object([ + "id": .string("choice"), "header": .string("Choice"), + "question": .string("Which option?"), "options": .array([ + .object(["label": .string("First"), "description": .string("First option")]), + ]), + ])]) + } + return OrchestrationActivity( + id: UUID().uuidString, tone: "info", kind: kind, summary: kind, + payload: .object(payload), turnId: nil, sequence: nil, + createdAt: "2026-09-02T12:00:00Z" + ) + } + + private func requestsBeforeLive( + _ iterator: inout AsyncStream.Iterator, threadID: String + ) async throws -> FeatureThreadDetail { + var latest: FeatureThreadDetail? + while let event = await iterator.next(isolation: #isolation) { + switch event { + case let .detail(detail), let .detailDelta(detail, _): + if detail.thread.id == threadID { latest = detail } + case .threadSync(threadID, .live): return try XCTUnwrap(latest) + case let .threadSync(id, .failed(message)) where id == threadID: + XCTFail("Request stream failed: \(message)") + throw CancellationError() + default: break + } + } + throw CancellationError() + } + + func testDomainFailureBacksOffAndKeepsItsErrorUntilTheStreamRecovers() async throws { + let retry = CatchUpRetryGate() + let fixture = try await CatchUpFixture.make(threadRetryDelay: { try await retry.wait($0) }) + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var attempts = retry.attempts.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + var current = try await nextThreadRequest(&requests) + for expectedAttempt in 1...3 { + try await current.socket.fail(id: current.id, message: "Thread is temporarily unavailable.") + while let event = await events.next(isolation: #isolation) { + if case let .threadSync(id, .failed(message)) = event, id == fixture.firstID { + XCTAssertEqual(message, "Thread is temporarily unavailable.") + break + } + } + let attempt = await attempts.next(isolation: #isolation) + XCTAssertEqual(attempt, expectedAttempt) + await retry.release() + let next = try await nextThreadRequest(&requests) + XCTAssertTrue(next.socket === current.socket) + current = next + } + + try await current.sendMessage(text: "Recovered without reconnecting", sequence: 3) + try await current.synchronize() + let nextState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(nextState, .catchingUp, "A rejected retry must retain its failure until real data arrives.") + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["Recovered without reconnecting"]) + + try await current.socket.fail(id: current.id, message: "Temporarily unavailable again.") + let resetAttempt = await attempts.next(isolation: #isolation) + XCTAssertEqual(resetAttempt, 1, "Valid stream data resets the retry backoff.") + await fixture.client.disconnect() + } + + func testTerminatedStreamsKeepBufferedTextAndRecoverOnConnectionOrForeground() async throws { + for failure in CatchUpStreamFailure.allCases { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.sendMessage(text: "Received before the failure", sequence: 3) + try await first.terminate(failure) + + var bufferedMessages: [String] = [] + while let event = await events.next(isolation: #isolation) { + switch event { + case let .detail(detail), let .detailDelta(detail, _): + if detail.thread.id == fixture.firstID { + bufferedMessages = detail.messages.map(\.text) + } + case let .threadSync(id, .failed(message)) where id == fixture.firstID: + XCTAssertEqual(message, "Could not synchronize the thread. Try again.") + default: continue + } + if case .threadSync(fixture.firstID, .failed) = event { break } + } + XCTAssertEqual(bufferedMessages, ["Received before the failure"]) + + if failure == .malformed { + await first.socket.close() + } else { + await fixture.client.resumeAfterBackground(reconnect: false) + } + let resumed = try await nextThreadRequest(&requests) + XCTAssertEqual(resumed.payload["afterSequence"], .number(3)) + XCTAssertEqual(resumed.socket === first.socket, failure != .malformed) + let resumedState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(resumedState, .catchingUp) + try await resumed.sendMessage(text: "Recovered", sequence: 4) + try await resumed.synchronize() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["Received before the failure", "Recovered"]) + let reads = await fixture.http.threadRequests + XCTAssertEqual(reads.count, 1, "A failed stream must retain its usable snapshot.") + await fixture.client.disconnect() + } + } + + func testLeavingFailedThreadCancelsItsConnectionWait() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.terminate(.malformed) + while let event = await events.next(isolation: #isolation) { + if case .threadSync(fixture.firstID, .failed) = event { break } + } + + fixture.client.releaseThread(id: fixture.firstID) + _ = try await fixture.client.loadThread(id: fixture.secondID) + let second = try await nextThreadRequest(&requests) + try await second.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.secondID) + await second.socket.close() + let resumed = try await nextThreadRequest(&requests) + XCTAssertEqual(resumed.payload["threadId"], .string("second")) + try await resumed.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.secondID) + await fixture.client.disconnect() + } + + func testExplicitRetryReadsFreshSnapshotInsteadOfWarmResume() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + _ = try await nextThreadRequest(&requests) + fixture.client.releaseThread(id: fixture.firstID) + await fixture.http.setResponse(text: "Fresh retry", sequence: 20) + let detail = try await fixture.client.loadThread(id: fixture.firstID, fresh: true) + XCTAssertTrue(detail.messages.contains { $0.text == "Fresh retry" }) + let reads = await fixture.http.threadRequests + XCTAssertEqual(reads.count, 2) + await fixture.client.disconnect() + } + + func testWarmNavigationResumesAfterAppliedMessagesWithoutAnotherHTTPRead() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + XCTAssertEqual(first.payload["afterSequence"], .number(2)) + try await first.sendMessage(text: "Finished on the computer", sequence: 3) + try await first.synchronize() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertTrue(messages.contains("Finished on the computer")) + + fixture.client.releaseThread(id: fixture.firstID) + _ = try await fixture.client.loadThread(id: fixture.secondID) + _ = try await nextThreadRequest(&requests) + fixture.client.releaseThread(id: fixture.secondID) + let restored = try await fixture.client.loadThread(id: fixture.firstID) + XCTAssertTrue(restored.messages.contains { $0.text == "Finished on the computer" }) + let resumed = try await nextThreadRequest(&requests) + XCTAssertEqual(resumed.payload["afterSequence"], .number(3)) + XCTAssertEqual(resumed.payload["turnLimit"], .number(10)) + XCTAssertEqual(resumed.payload["requestCompletionMarker"], .bool(true)) + let resumedState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(resumedState, .live, "A completed warm thread must not flash catch-up status.") + try await resumed.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + let reads = await fixture.http.threadRequests + XCTAssertEqual(reads.count, 2, "Only the two cold opens should fetch HTTP snapshots.") + await fixture.client.disconnect() + } + + func testWarmReplayShowsCatchUpOnlyAfterReceivingNewerData() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + + fixture.client.releaseThread(id: fixture.firstID) + _ = try await fixture.client.loadThread(id: fixture.firstID) + let resumed = try await nextThreadRequest(&requests) + let initialState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(initialState, .live) + + try await resumed.sendMessage(text: "Finished while away", sequence: 3) + try await resumed.synchronize() + var states: [FeatureThreadSyncState] = [] + var messages: [String] = [] + while let event = await events.next(isolation: #isolation) { + if case let .threadSync(id, state) = event, + id == fixture.firstID, let state { + states.append(state) + if state == .live { break } + if case .failed = state { break } + } + switch event { + case let .detail(detail), let .detailDelta(detail, _): + if detail.thread.id == fixture.firstID { messages = detail.messages.map(\.text) } + default: break + } + } + XCTAssertEqual(states, [.catchingUp, .live]) + XCTAssertTrue(messages.contains("Finished while away")) + await fixture.client.disconnect() + } + + func testIncompleteWarmCacheStillShowsCatchUp() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + _ = try await nextThreadRequest(&requests) + fixture.client.releaseThread(id: fixture.firstID) + while let event = await events.next(isolation: #isolation) { + if case .threadSync(fixture.firstID, nil) = event { break } + } + + _ = try await fixture.client.loadThread(id: fixture.firstID) + let resumed = try await nextThreadRequest(&requests) + let initialState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(initialState, .catchingUp) + try await resumed.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.client.disconnect() + } + + func testWarmCacheShowsCatchUpAfterSocketReplacement() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + fixture.client.releaseThread(id: fixture.firstID) + + await first.socket.close() + while let request = await requests.next(isolation: #isolation) { + if request.socket !== first.socket { break } + } + _ = try await fixture.client.loadThread(id: fixture.firstID) + let resumed = try await nextThreadRequest(&requests) + XCTAssertFalse(resumed.socket === first.socket) + let resumedState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(resumedState, .catchingUp) + try await resumed.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.client.disconnect() + } + + func testForegroundReplacesSuspendedConnectionAndUsesLatestCursor() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.sendMessage(text: "Before background", sequence: 7) + try await first.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + + await fixture.client.resumeAfterBackground(reconnect: true) + let resumed = try await nextThreadRequest(&requests) + XCTAssertFalse(resumed.socket === first.socket) + XCTAssertEqual(resumed.payload["afterSequence"], .number(7)) + let resumedState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(resumedState, .catchingUp) + try await resumed.sendMessage(text: "Completed while away", sequence: 8) + try await resumed.synchronize() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertTrue(messages.contains("Completed while away")) + let reads = await fixture.http.threadRequests + XCTAssertEqual(reads.count, 1) + await fixture.client.disconnect() + } + + func testSocketLossResubscribesFromAppliedCursor() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.sendMessage(text: "Last applied message", sequence: 12) + try await first.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await first.socket.close() + let resumed = try await nextThreadRequest(&requests) + XCTAssertEqual(resumed.payload["afterSequence"], .number(12)) + XCTAssertFalse(resumed.socket === first.socket) + let resumedState = await nextSyncState(&events, threadID: fixture.firstID) + XCTAssertEqual(resumedState, .reconnecting) + await fixture.client.disconnect() + } + + func testStalledResumeFetchesBoundedHTTPFallbackWithoutWaitingForHeartbeat() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let first = try await nextThreadRequest(&requests) + try await first.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + fixture.client.releaseThread(id: fixture.firstID) + _ = try await fixture.client.loadThread(id: fixture.firstID) + let resumed = try await nextThreadRequest(&requests) + await fixture.http.setResponse(text: "HTTP caught up", sequence: 20) + await fixture.delay.release() + var sawUpdatedMessage = false + while let event = await events.next(isolation: #isolation) { + if case let .detail(detail) = event { + sawUpdatedMessage = detail.messages.contains { $0.text == "HTTP caught up" } + } + if case .threadSync(fixture.firstID, .reconnecting) = event { break } + } + XCTAssertTrue(sawUpdatedMessage) + let reads = await fixture.http.threadRequests + XCTAssertEqual(reads.count, 2) + XCTAssertEqual(reads.last?.timeoutInterval, 8) + try await resumed.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.client.disconnect() + } + + func testLegacyServerStillRevalidatesWarmThreadOverHTTP() async throws { + let fixture = try await CatchUpFixture.make(completionMarker: false) + defer { fixture.cleanUp() } + _ = try await fixture.client.loadThread(id: fixture.firstID) + fixture.client.releaseThread(id: fixture.firstID) + _ = try await fixture.client.loadThread(id: fixture.firstID) + let reads = await fixture.http.threadRequests + XCTAssertEqual(reads.count, 2) + await fixture.client.disconnect() + } + + func testCompletionMarkerWaitsForRequiredSnapshotReplacement() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let request = try await nextThreadRequest(&requests) + try await request.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.setResponse(text: "Authoritative replacement", sequence: 20) + try await request.socket.chunk(id: request.id, values: [ + .object(["kind": .string("event"), "event": .object([ + "type": .string("thread.reverted"), "sequence": .number(19), + "occurredAt": .string("2026-09-02T12:00:00Z"), + "payload": .object(["threadId": .string("first")]), + ])]), + .object(["kind": .string("synchronized")]), + ]) + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertTrue(messages.contains("Authoritative replacement")) + await fixture.client.disconnect() + } + + func testFailedRequiredSnapshotKeepsCachedTextAndOffersFreshRetry() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + await fixture.http.setResponse(text: "Cached answer", sequence: 2) + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + + await fixture.http.holdThreadReads(true) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + let read = try await nextHeldRead(&reads) + read.fail() + while let event = await events.next(isolation: #isolation) { + if case .threadSync(fixture.firstID, .failed) = event { break } + if case .threadSync(fixture.firstID, .live) = event { + XCTFail("A failed snapshot must not mark cached content current.") + } + } + + await fixture.http.holdThreadReads(false) + await fixture.http.setResponse(text: "Fresh answer", sequence: 11) + let detail = try await fixture.client.loadThread(id: fixture.firstID, fresh: true) + XCTAssertEqual(detail.messages.map(\.text), ["Fresh answer"]) + await fixture.client.disconnect() + } + + func testRequiredSnapshotsCoverEveryEventReceivedDuringReplacement() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + + await fixture.http.setResponse(text: "Before new messages", sequence: 10) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + let first = try await nextHeldRead(&reads) + try await stream.sendMessage(text: "Eleven", sequence: 11) + await nextCatchUp(&events, threadID: fixture.firstID) + await fixture.http.setResponse(text: "Eleven", sequence: 11) + first.succeed() + + let second = try await nextHeldRead(&reads) + await nextCatchUp(&events, threadID: fixture.firstID) + try await stream.sendMessage(text: "Twelve", sequence: 12) + await nextCatchUp(&events, threadID: fixture.firstID) + await fixture.http.setResponse(texts: ["Eleven", "Twelve"], sequence: 12) + second.succeed() + + let third = try await nextHeldRead(&reads) + await nextCatchUp(&events, threadID: fixture.firstID) + third.succeed() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["Eleven", "Twelve"]) + let count = await fixture.http.threadRequests.count + XCTAssertEqual(count, 4, "Each stale response needs one coalesced follow-up, not one read per event.") + await fixture.client.disconnect() + } + + func testEventWithoutCursorNeedsSnapshotStartedAfterIt() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + await fixture.http.setResponse(text: "Before unknown event", sequence: 10) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + let first = try await nextHeldRead(&reads) + try await stream.socket.chunk(id: stream.id, values: [.object(["kind": .string("unknown")])]) + await nextCatchUp(&events, threadID: fixture.firstID) + await fixture.http.setResponse(text: "After unknown event", sequence: 11) + first.succeed() + let second = try await nextHeldRead(&reads) + await nextCatchUp(&events, threadID: fixture.firstID) + second.succeed() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["After unknown event"]) + await fixture.client.disconnect() + } + + func testSocketSnapshotCancelsFailedHTTPReplacement() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + let read = try await nextHeldRead(&reads) + try await stream.snapshot(texts: ["Recovered over the socket"], sequence: 11) + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["Recovered over the socket"]) + read.fail() + let cancelled = await read.finished.first { _ in true } + XCTAssertEqual(cancelled, true, "The obsolete HTTP read must not replace live state with an error.") + await fixture.client.disconnect() + } + + func testOldSocketSnapshotDoesNotCancelReadAfterCursorlessEvent() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + await fixture.http.setResponse(text: "Fresh after unknown event", sequence: 20) + try await stream.socket.chunk(id: stream.id, values: [.object(["kind": .string("unknown")])]) + await nextCatchUp(&events, threadID: fixture.firstID) + let read = try await nextHeldRead(&reads) + + try await stream.snapshot(texts: ["Requested before unknown event"], sequence: 3) + try await stream.synchronize() + // This next event is a receipt that the old snapshot was handled first. + try await stream.sendMessage(text: "After old snapshot", sequence: 19) + await nextCatchUp(&events, threadID: fixture.firstID) + read.succeed() + let cancelled = await read.finished.first { _ in true } + XCTAssertEqual(cancelled, false, "The required post-event read must still run.") + guard cancelled == false else { + await fixture.client.disconnect() + return + } + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["Fresh after unknown event"]) + await fixture.client.disconnect() + } + + func testNewSubscriptionSnapshotCanRecoverAfterCursorlessEvent() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + try await stream.socket.chunk(id: stream.id, values: [.object(["kind": .string("unknown")])]) + await nextCatchUp(&events, threadID: fixture.firstID) + let read = try await nextHeldRead(&reads) + + await stream.socket.close() + let resumed = try await nextThreadRequest(&requests) + try await resumed.snapshot(texts: ["New socket snapshot"], sequence: 20) + try await resumed.synchronize() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["New socket snapshot"]) + read.fail() + let cancelled = await read.finished.first { _ in true } + XCTAssertEqual(cancelled, true, "The new subscription can replace the required HTTP read.") + await fixture.client.disconnect() + } + + func testLeavingThreadCancelsHeldReplacementAndItsPendingFollowUp() async throws { + for disconnect in [false, true] { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + let read = try await nextHeldRead(&reads) + try await stream.sendMessage(text: "Do not publish after leaving", sequence: 11) + await nextCatchUp(&events, threadID: fixture.firstID) + + if disconnect { + await fixture.client.disconnect() + } else { + fixture.client.releaseThread(id: fixture.firstID) + await fixture.http.holdThreadReads(false) + await fixture.http.setResponse(text: "Second thread", sequence: 20) + let detail = try await fixture.client.loadThread(id: fixture.secondID) + XCTAssertEqual(detail.thread.id, fixture.secondID) + XCTAssertEqual(detail.messages.map(\.text), ["Second thread"]) + } + read.succeed() + let cancelled = await read.finished.first { _ in true } + XCTAssertEqual(cancelled, true) + let count = await fixture.http.threadRequests.count + XCTAssertEqual(count, disconnect ? 2 : 3, "A closed thread must not start its pending read.") + await fixture.client.disconnect() + } + } + + func testAttachmentLookupDoesNotBlockRequiredTextRefresh() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + await fixture.http.holdThreadReads(true) + await fixture.http.setResponse(texts: ["Text ready"], sequence: 11, withImage: true) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + let first = try await nextHeldRead(&reads) + try await stream.sendMessage(text: "Text ready", sequence: 11) + await nextCatchUp(&events, threadID: fixture.firstID) + first.succeed() + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["Text ready"]) + let resolving = Task { + try await fixture.client.attachmentAssetURL( + threadID: fixture.firstID, + attachment: .init(id: "image-0", name: "test.png", mimeType: "image/png", sizeBytes: 20) + ) + } + defer { resolving.cancel() } + while let request = await requests.next(isolation: #isolation) { + if request.tag == RPCMethod.assetsCreateURL.rawValue { break } + } + let firstReadCount = await fixture.http.threadRequests.count + XCTAssertEqual(firstReadCount, 2, "The first snapshot already includes the skipped message.") + + // Leave the asset RPC unanswered. A later text refresh must start anyway. + await fixture.http.setResponse(texts: ["New text ready"], sequence: 12, withImage: true) + try await stream.invalidate(sequence: 12) + await nextCatchUp(&events, threadID: fixture.firstID) + let second = try await nextHeldRead(&reads) + second.succeed() + let updated = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(updated, ["New text ready"]) + await fixture.client.disconnect() + } + + func testVisibleNonImageAttachmentsResolveWithoutRepublishingThread() async throws { + for (name, mimeType) in [("document.pdf", "application/pdf"), ("clip.mp4", "video/mp4")] { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + _ = try await fixture.client.loadThread(id: fixture.firstID) + let stream = try await nextThreadRequest(&requests) + try await stream.synchronize() + _ = await messagesBeforeLive(&events, threadID: fixture.firstID) + + await fixture.http.setResponse(text: "File ready", sequence: 10, attachment: .init( + type: "file", id: "file", name: name, mimeType: mimeType, sizeBytes: 20 + )) + try await stream.invalidate(sequence: 10) + await nextCatchUp(&events, threadID: fixture.firstID) + // Text must become current before the asset URL request completes. + let messages = await messagesBeforeLive(&events, threadID: fixture.firstID) + XCTAssertEqual(messages, ["File ready"]) + + let resolving = Task { + try await fixture.client.attachmentAssetURL( + threadID: fixture.firstID, + attachment: .init(id: "file", name: name, mimeType: mimeType, sizeBytes: 20) + ) + } + while let request = await requests.next(isolation: #isolation) { + guard request.tag == RPCMethod.assetsCreateURL.rawValue else { continue } + XCTAssertEqual(request.payload["resource"]?["mimeType"], .string(mimeType)) + try await request.socket.succeed(id: request.id, value: .object([ + "relativeUrl": .string("/assets/\(name)"), + "expiresAt": .number(Date.now.addingTimeInterval(3_600).timeIntervalSince1970 * 1_000), + ])) + break + } + let resolved = try await resolving.value + XCTAssertEqual(resolved, URL(string: "https://one.example/assets/\(name)")) + await fixture.client.disconnect() + } + } + + func testOpeningAttachmentHistoryDoesNotResolveOffscreenURLsAndVisibleURLIsReused() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + await fixture.http.setResponse(texts: (0..<100).map { "Image \($0)" }, sequence: 2, withImage: true) + let detail = try await fixture.client.loadThread(id: fixture.firstID) + let subscription = try await nextThreadRequest(&requests) + try await subscription.synchronize() + let attachment = try XCTUnwrap(detail.messages.last?.attachments.first) + let resolving = Task { + try await fixture.client.attachmentAssetURL(threadID: fixture.firstID, attachment: attachment) + } + while let request = await requests.next(isolation: #isolation) { + guard request.tag == RPCMethod.assetsCreateURL.rawValue else { continue } + XCTAssertEqual(request.payload["resource"]?["attachmentId"], .string(attachment.id)) + try await request.socket.succeed(id: request.id, value: .object([ + "relativeUrl": .string("/assets/visible.png"), + "expiresAt": .number(Date.now.addingTimeInterval(3_600).timeIntervalSince1970 * 1_000), + ])) + break + } + let firstURL = try await resolving.value + let cachedURL = try await fixture.client.attachmentAssetURL(threadID: fixture.firstID, attachment: attachment) + XCTAssertEqual(cachedURL, firstURL) + let assetReads = await subscription.socket.assetRequestCount + XCTAssertEqual(assetReads, 1, "Only the visible attachment needs a signed URL.") + await fixture.client.disconnect() + } + + func testRequiredReadReplacesColdFallbackWithoutHidingItsOwnFailure() async throws { + let fixture = try await CatchUpFixture.make() + defer { fixture.cleanUp() } + var requests = fixture.requests.makeAsyncIterator() + var events = fixture.client.events().makeAsyncIterator() + var reads = fixture.http.heldRequests.makeAsyncIterator() + await fixture.http.holdThreadReads(true) + + // Fail the cold open so catch-up starts without a base snapshot. + let opening = Task { try await fixture.client.loadThread(id: fixture.firstID) } + let initial = try await nextHeldRead(&reads) + initial.fail() + do { + _ = try await opening.value + XCTFail("The initial snapshot should fail.") + } catch {} + let stream = try await nextThreadRequest(&requests) + while let event = await events.next(isolation: #isolation) { + if case .threadSync(fixture.firstID, .failed) = event { break } + } + await nextCatchUp(&events, threadID: fixture.firstID) + await fixture.delay.release() + let fallback = try await nextHeldRead(&reads) + + // The event needs a newer snapshot than the fallback captured. + await fixture.http.setResponse(text: "New message", sequence: 3) + try await stream.sendMessage(text: "New message", sequence: 3) + await nextCatchUp(&events, threadID: fixture.firstID) + let replacement = try await nextHeldRead(&reads) + fallback.succeed() + let wasCancelled = await fallback.finished.first { _ in true } + XCTAssertEqual(wasCancelled, true, "The older fallback must stop when the required read takes over.") + + replacement.fail() + var failure: String? + while let event = await events.next(isolation: #isolation) { + guard case let .threadSync(id, .failed(message)) = event, + id == fixture.firstID else { continue } + failure = message + break + } + XCTAssertEqual(failure, URLError(.notConnectedToInternet).localizedDescription) + await fixture.client.disconnect() + } + + private func nextHeldRead( + _ iterator: inout AsyncStream.Iterator + ) async throws -> CatchUpHTTPRead { + let read = await iterator.next(isolation: #isolation) + return try XCTUnwrap(read) + } + + private func nextCatchUp( + _ iterator: inout AsyncStream.Iterator, threadID: String + ) async { + while let event = await iterator.next(isolation: #isolation) { + if case .threadSync(threadID, .catchingUp) = event { return } + if case .threadSync(threadID, .live) = event { + XCTFail("The thread became live before its required snapshot was complete.") + return + } + } + XCTFail("The thread did not report its pending refresh.") + } + + private func nextThreadRequest( + _ iterator: inout AsyncStream.Iterator + ) async throws -> CatchUpRequest { + while let request = await iterator.next(isolation: #isolation) { + if request.tag == RPCMethod.subscribeThread.rawValue { return request } + } + throw CancellationError() + } + + private func nextSyncState( + _ iterator: inout AsyncStream.Iterator, threadID: String + ) async -> FeatureThreadSyncState? { + while let event = await iterator.next(isolation: #isolation) { + if case let .threadSync(id, state) = event, id == threadID, let state { + return state + } + } + XCTFail("The thread did not report its sync state.") + return nil + } + + private func messagesBeforeLive( + _ iterator: inout AsyncStream.Iterator, threadID: String + ) async -> [String] { + var messages: [String] = [] + while let event = await iterator.next(isolation: #isolation) { + switch event { + case let .detail(detail), let .detailDelta(detail, _): + if detail.thread.id == threadID { messages = detail.messages.map(\.text) } + case .threadSync(threadID, .live): return messages + case let .threadSync(id, .failed(error)) where id == threadID: + XCTFail("The thread failed synchronization: \(error)") + return messages + default: break + } + } + XCTFail("The thread did not finish synchronization.") + return messages + } +} + +@MainActor +private struct CatchUpFixture { + let client: NativeFeatureClient + let http: CatchUpHTTPTransport + let requests: AsyncStream + let delay: CatchUpDelay + let directory: URL + var firstID: String { FeatureScopedID.thread(environmentID: "one", wireID: "first") } + var secondID: String { FeatureScopedID.thread(environmentID: "one", wireID: "second") } + + static func make( + completionMarker: Bool = true, + activities: [OrchestrationActivity] = [], + threadRetryDelay: @escaping @Sendable (Int) async throws -> Void = { _ in + try await Task.sleep(for: .milliseconds(250)) + } + ) async throws -> Self { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = EnvironmentStore(fileURL: directory.appendingPathComponent("environments.json")) + try await store.save([Environment( + id: "one", label: "Computer", httpBaseURL: URL(string: "https://one.example")!, + webSocketBaseURL: URL(string: "wss://one.example/ws")! + )]) + try await store.setActiveEnvironment(id: "one") + let http = CatchUpHTTPTransport() + await http.setActivities(activities) + let requests = AsyncStream.makeStream() + let delay = CatchUpDelay() + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore(credentials: ["one": .init(accessToken: "test")]), + httpTransport: http, + webSocketConnector: CatchUpConnector( + requests: requests.continuation, completionMarker: completionMarker + ) + ) + let client = NativeFeatureClient( + runtime: runtime, settingsStore: UserDefaults(suiteName: UUID().uuidString)!, + fallbackPollingInitialDelay: .seconds(3_600), + aggregateRefreshInterval: .seconds(3_600), + catchUpDelay: { try await delay.wait() }, + threadRetryDelay: threadRetryDelay + ) + _ = try await client.initialSnapshot() + return Self(client: client, http: http, requests: requests.stream, delay: delay, directory: directory) + } + + func cleanUp() { try? FileManager.default.removeItem(at: directory) } +} + +private actor CatchUpHTTPTransport: HTTPTransport { + private(set) var threadRequests: [URLRequest] = [] + private var messages: [OrchestrationMessage] = [] + private var activities: [OrchestrationActivity] = [] + private var sequence = 2 + private var holdsThreadReads = false + private let heldReadContinuation: AsyncStream.Continuation + nonisolated let heldRequests: AsyncStream + + init() { + let reads = AsyncStream.makeStream() + heldRequests = reads.stream + heldReadContinuation = reads.continuation + } + + func setResponse(text: String, sequence: Int, attachment: ChatAttachment? = nil) { + messages = [catchUpMessage(text, index: 0, attachment: attachment)] + self.sequence = sequence + } + + func setResponse(texts: [String], sequence: Int, withImage: Bool = false) { + messages = texts.enumerated().map { index, text in + catchUpMessage(text, index: index, withImage: withImage) + } + self.sequence = sequence + } + + func holdThreadReads(_ hold: Bool) { holdsThreadReads = hold } + + func setActivities(_ activities: [OrchestrationActivity]) { self.activities = activities } + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let value: JSONValue + switch request.url!.path { + case "/api/auth/websocket-ticket": + value = .object(["ticket": .string("test"), "expiresAt": .string("2027-01-01T00:00:00Z")]) + case "/api/orchestration/shell": + let first = multiEnvironmentShell(projectID: "project", threadID: "first", title: "First") + let second = multiEnvironmentShell(projectID: "project", threadID: "second", title: "Second") + value = try .encode(OrchestrationShellSnapshot( + snapshotSequence: 1, projects: first.projects, + threads: first.threads + second.threads, updatedAt: first.updatedAt + )) + default: + guard request.url!.path.hasPrefix("/api/orchestration/threads/") else { + throw URLError(.unsupportedURL) + } + threadRequests.append(request) + let snapshot = multiEnvironmentDetail( + projectID: "project", threadID: request.url!.lastPathComponent, + snapshotSequence: sequence, messages: messages + ) + var thread = snapshot.thread + thread.activities = activities + value = try .encode(OrchestrationThreadDetailSnapshot( + snapshotSequence: snapshot.snapshotSequence, thread: thread, page: snapshot.page + )) + } + let response = (try JSONEncoder.t3.encode(value), HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil + )!) + if holdsThreadReads, request.url!.path.hasPrefix("/api/orchestration/threads/") { + let finished = AsyncStream.makeStream() + defer { + finished.continuation.yield(Task.isCancelled) + finished.continuation.finish() + } + return try await withCheckedThrowingContinuation { continuation in + heldReadContinuation.yield(CatchUpHTTPRead( + response: response, continuation: continuation, finished: finished.stream + )) + } + } + return response + } +} + +private struct CatchUpHTTPRead: Sendable { + let response: (Data, HTTPURLResponse) + let continuation: CheckedContinuation<(Data, HTTPURLResponse), any Error> + let finished: AsyncStream + func succeed() { continuation.resume(returning: response) } + func fail() { continuation.resume(throwing: URLError(.notConnectedToInternet)) } +} + +private func catchUpMessage( + _ text: String, index: Int, withImage: Bool = false, attachment: ChatAttachment? = nil +) -> OrchestrationMessage { + OrchestrationMessage( + id: "answer-\(index)", role: "assistant", text: text, + attachments: attachment.map { [$0] } ?? (withImage ? [.init( + type: "image", id: "image-\(index)", name: "test.png", mimeType: "image/png", sizeBytes: 20 + )] : []), + turnId: nil, streaming: false, createdAt: "2026-09-02T12:00:00Z", + updatedAt: "2026-09-02T12:00:00Z" + ) +} + +private struct CatchUpConnector: WebSocketConnecting { + let requests: AsyncStream.Continuation + let completionMarker: Bool + func connect(to url: URL) async throws -> any WebSocketConnection { + CatchUpSocket(requests: requests, completionMarker: completionMarker) + } +} + +private enum CatchUpStreamFailure: CaseIterable { + case malformed, defect, ended +} + +private struct CatchUpRequest: Sendable { + let tag: String + let id: Int + let payload: JSONValue + let socket: CatchUpSocket + + func terminate(_ failure: CatchUpStreamFailure) async throws { + switch failure { + case .malformed: + try await socket.chunk(id: id, values: [.object([ + "kind": .number(42), + ])]) + case .defect: + try await socket.failWithDefect(id: id) + case .ended: + try await socket.succeed(id: id, value: .null) + } + } + + func synchronize() async throws { + try await socket.chunk(id: id, values: [.object(["kind": .string("synchronized")])]) + } + + func sendActivities(_ activities: [OrchestrationActivity], startingAt sequence: Int) async throws { + let values = try activities.enumerated().map { index, activity in + JSONValue.object([ + "kind": .string("event"), "event": .object([ + "type": .string("thread.activity-appended"), + "sequence": .number(Double(sequence + index)), + "occurredAt": .string(activity.createdAt), + "payload": .object([ + "threadId": payload["threadId"]!, "activity": try .encode(activity), + ]), + ]), + ]) + } + try await socket.chunk(id: id, values: values) + } + + func invalidate(sequence: Int) async throws { + try await socket.chunk(id: id, values: [.object([ + "kind": .string("event"), "event": .object([ + "type": .string("thread.reverted"), "sequence": .number(Double(sequence)), + "occurredAt": .string("2026-09-02T12:00:00Z"), + "payload": .object(["threadId": payload["threadId"]!]), + ]), + ])]) + } + + func snapshot(texts: [String], sequence: Int) async throws { + let snapshot = multiEnvironmentDetail( + projectID: "project", threadID: payload["threadId"]!.stringValue!, + snapshotSequence: sequence, + messages: texts.enumerated().map { catchUpMessage($0.element, index: $0.offset) } + ) + try await socket.chunk(id: id, values: [.object([ + "kind": .string("snapshot"), "snapshot": try .encode(snapshot), + ])]) + } + + func sendMessage(text: String, sequence: Int) async throws { + try await socket.chunk(id: id, values: [.object([ + "kind": .string("event"), "event": .object([ + "type": .string("thread.message-sent"), "sequence": .number(Double(sequence)), + "occurredAt": .string("2026-09-02T12:00:00Z"), "payload": .object([ + "threadId": payload["threadId"]!, "messageId": .string("message-\(sequence)"), + "role": .string("assistant"), "text": .string(text), "streaming": .bool(false), + "createdAt": .string("2026-09-02T12:00:00Z"), + "updatedAt": .string("2026-09-02T12:00:00Z"), + ]), + ]), + ])]) + } +} + +private actor CatchUpSocket: WebSocketConnection { + let requests: AsyncStream.Continuation + let completionMarker: Bool + private(set) var assetRequestCount = 0 + private var pending: [Data] = [] + private var receiver: CheckedContinuation? + private var closed = false + + init(requests: AsyncStream.Continuation, completionMarker: Bool) { + self.requests = requests + self.completionMarker = completionMarker + } + + func send(_ data: Data) throws { + guard !closed else { throw URLError(.networkConnectionLost) } + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if request["_tag"]?.stringValue == "Ping" { + try enqueue(.object(["_tag": .string("Pong")])) + } + guard let tag = request["tag"]?.stringValue, case let .number(id) = request["id"] else { return } + if tag == RPCMethod.assetsCreateURL.rawValue { assetRequestCount += 1 } + if tag == RPCMethod.subscribeServerConfig.rawValue { + try chunk(id: Int(id), values: [.object([ + "type": .string("snapshot"), "config": .object([ + "providers": .array([]), "threadSnapshotPagination": .bool(true), + "threadResumeCompletionMarker": .bool(completionMarker), + ]), + ])]) + } + requests.yield(.init(tag: tag, id: Int(id), payload: request["payload"]!, socket: self)) + } + + func receive() async throws -> Data { + guard !closed else { throw URLError(.networkConnectionLost) } + if !pending.isEmpty { return pending.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + closed = true + receiver?.resume(throwing: URLError(.networkConnectionLost)) + receiver = nil + } + + func chunk(id: Int, values: [JSONValue]) throws { + try enqueue(.object([ + "_tag": .string("Chunk"), "requestId": .number(Double(id)), "values": .array(values), + ])) + } + + func succeed(id: Int, value: JSONValue) throws { + try enqueue(.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object(["_tag": .string("Success"), "value": value]), + ])) + } + + func failWithDefect(id: Int) throws { + try enqueue(.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Die"), + "defect": .string("RAW_SERVER_DEFECT_MUST_NOT_REACH_THREAD_UI"), + ])]), + ]), + ])) + } + + func fail(id: Int, message: String) throws { + try enqueue(.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Fail"), "error": .object(["message": .string(message)]), + ])]), + ]), + ])) + } + + private func enqueue(_ value: JSONValue) throws { + let data = try JSONEncoder.t3.encode(value) + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { pending.append(data) } + } +} + +private actor CatchUpRetryGate { + nonisolated let attempts: AsyncStream + private let continuation: AsyncStream.Continuation + private var waiters: [UUID: CheckedContinuation] = [:] + + init() { + let stream = AsyncStream.makeStream() + attempts = stream.stream + continuation = stream.continuation + } + + func wait(_ attempt: Int) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (waiter: CheckedContinuation) in + if Task.isCancelled { waiter.resume(throwing: CancellationError()) } + else { + waiters[id] = waiter + continuation.yield(attempt) + } + } + } onCancel: { Task { await self.cancel(id) } } + } + + func release() { + let pending = waiters.values + waiters.removeAll() + pending.forEach { $0.resume() } + } + + private func cancel(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } +} + +private actor CatchUpDelay { + private var opened = false + private var waiters: [UUID: CheckedContinuation] = [:] + func wait() async throws { + if opened { return } + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { continuation.resume(throwing: CancellationError()) } + else { waiters[id] = continuation } + } + } onCancel: { Task { await self.cancel(id) } } + } + func release() { + opened = true + let waiting = waiters.values + waiters.removeAll() + waiting.forEach { $0.resume() } + } + private func cancel(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift new file mode 100644 index 000000000000..732840a4a8fe --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing +@testable import T3Code + +@MainActor +@Suite("Native thread metadata") +struct NativeThreadMetadataTests { + @Test + func snapshotsDecodeServerPRAndOrderWithoutRequiringThemFromOlderServers() throws { + let base = try JSONValue.encode(thread()) + guard case var .object(fields) = base else { + Issue.record("Expected an encoded thread") + return + } + fields.removeValue(forKey: "branchPullRequest") + fields.removeValue(forKey: "activeOrderKey") + let older = try JSONValue.object(fields).decode(OrchestrationThread.self) + #expect(older.branchPullRequest == nil) + #expect(older.activeOrderKey == nil) + + fields["branchPullRequest"] = try JSONValue.encode(reference()) + fields["activeOrderKey"] = .string("nm") + let current = try JSONValue.object(fields).decode(OrchestrationThread.self) + #expect(current.branchPullRequest == reference()) + #expect(current.activeOrderKey == "nm") + + fields["latestUserMessageAt"] = .null + fields["hasPendingApprovals"] = .bool(false) + fields["hasPendingUserInput"] = .bool(false) + fields["hasActionableProposedPlan"] = .bool(false) + let shell = try JSONValue.object(fields).decode(OrchestrationThreadShell.self) + #expect(shell.branchPullRequest == reference()) + #expect(shell.activeOrderKey == "nm") + } + + @Test + func branchPRAndActiveOrderUpdatesDoNotReloadTheThread() throws { + let branchUpdate = try reduce( + ["branchPullRequest": JSONValue.encode(reference())], + thread: thread() + ) + #expect(branchUpdate.branchPullRequest == reference()) + #expect(branchUpdate.linkedPullRequest == nil) + + let reordered = try reduce(["activeOrderKey": .string("nm")], thread: branchUpdate) + #expect(reordered.branchPullRequest == reference()) + #expect(reordered.activeOrderKey == "nm") + + let cleared = try reduce([ + "branchPullRequest": .null, + "activeOrderKey": .null, + ], thread: reordered) + #expect(cleared.branchPullRequest == nil) + #expect(cleared.activeOrderKey == nil) + } + + @Test + func settlingClearsManualOrderButKeepsPRMetadata() throws { + var source = thread() + source.branchPullRequest = reference() + source.activeOrderKey = "nm" + let result = NativeThreadDetailReducer.apply(event( + type: "thread.settled", + payload: ["settledAt": .string("2026-09-06T20:00:00Z")] + ), to: source) + guard case let .updated(settled) = result.result else { + Issue.record("Expected settlement without a reload") + return + } + #expect(settled.activeOrderKey == nil) + #expect(settled.branchPullRequest == reference()) + } + + @Test + func ordinaryThreadEventsPreservePRAndManualOrder() throws { + var source = thread() + source.branchPullRequest = reference() + source.activeOrderKey = "nm" + let result = NativeThreadDetailReducer.apply(event( + type: "thread.message-sent", + payload: [ + "messageId": .string("message"), + "role": .string("assistant"), + "text": .string("Complete"), + "streaming": .bool(false), + "createdAt": .string("2026-09-06T20:00:00Z"), + ] + ), to: source) + guard case let .updated(updated) = result.result else { + Issue.record("Expected the message to update without a reload") + return + } + #expect(updated.branchPullRequest == reference()) + #expect(updated.activeOrderKey == "nm") + #expect(updated.messages.last?.text == "Complete") + } + + @Test + func invalidMetadataStillRequestsAnAuthoritativeSnapshot() { + let payloads: [[String: JSONValue]] = [ + ["branchPullRequest": .string("invalid")], + ["activeOrderKey": .number(10)], + ["activeOrderKey": .string(" ")], + ["branchPullRequest": .null, "title": .string("Renamed")], + ] + for payload in payloads { + let result = NativeThreadDetailReducer.apply(event(payload: payload), to: thread()) + #expect(result.result == .refresh) + } + } + + @Test + func serverPRLinksOpenDirectlyAndExplicitLinksTakePriority() throws { + var source = FeatureThread( + id: "thread", projectID: "project", title: "Task", + branchPullRequest: reference() + ) + let branchIdentity = source.pullRequestObservationIdentity + #expect(branchIdentity != nil) + let branchDestination = try #require(ThreadPullRequestDestination.resolve( + thread: source, branchPullRequest: nil + )) + #expect(branchDestination.url.absoluteString == reference().url) + #expect(branchDestination.number == 1) + + source.linkedPullRequest = reference(number: 2) + #expect(source.effectivePullRequest == reference(number: 2)) + #expect(source.pullRequestObservationIdentity != branchIdentity) + let linkedDestination = try #require(ThreadPullRequestDestination.resolve( + thread: source, branchPullRequest: nil + )) + #expect(linkedDestination.number == 2) + } + + private func reduce( + _ payload: [String: JSONValue], + thread: OrchestrationThread + ) throws -> OrchestrationThread { + let reduction = NativeThreadDetailReducer.apply(event(payload: payload), to: thread) + #expect(reduction.renderMutation == .metadata) + guard case let .updated(updated) = reduction.result else { + throw MetadataTestError.expectedLocalUpdate + } + return updated + } + + private func event( + type: String = "thread.meta-updated", + payload: [String: JSONValue] + ) -> JSONValue { + .object([ + "type": .string(type), + "sequence": .number(2), + "occurredAt": .string("2026-09-06T20:00:00Z"), + "payload": .object(payload.merging([ + "threadId": .string("thread"), + "updatedAt": .string("2026-09-06T20:00:00Z"), + ]) { _, value in value }), + ]) + } + + private func reference(number: Int = 1) -> ThreadLinkedPullRequest { + ThreadLinkedPullRequest( + projectId: "project", repository: "test/repo", number: number, + url: "https://example.com/pull/\(number)" + ) + } + + private func thread() -> OrchestrationThread { + OrchestrationThread( + id: "thread", projectId: "project", title: "Task", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, interactionMode: .default, + branch: "task", worktreePath: nil, latestTurn: nil, + createdAt: "2026-09-06T19:00:00Z", updatedAt: "2026-09-06T19:00:00Z", + archivedAt: nil, settledOverride: nil, settledAt: nil, + snoozedUntil: nil, snoozedAt: nil, pinnedAt: nil, deletedAt: nil, + messages: [], activities: [], checkpoints: [], session: nil + ) + } +} + +private enum MetadataTestError: Error { + case expectedLocalUpdate +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeTimestampParserTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeTimestampParserTests.swift new file mode 100644 index 000000000000..570e75e7ea55 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeTimestampParserTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import T3Code + +@MainActor +@Suite("Native timestamps") +struct NativeTimestampParserTests { + @Test + func standardServerTimestampsKeepExactDateValues() throws { + for date in ["1970-01-01", "2000-02-29", "2001-01-01", "2026-09-04", "9999-12-31"] { + for fraction in ["", ".000", ".001", ".123", ".333", ".789", ".999"] { + let source = "\(date)T12:34:56\(fraction)Z" + let expected = try #require(legacyDate(source)) + #expect(NativeTimestampParser.parse(source) == expected, "\(source)") + } + } + } + + @Test + func offsetsAndFractionalPrecisionKeepLegacyBehavior() throws { + for source in [ + "2026-09-04T12:34:56.123+05:30", + "2026-09-04T12:34:56-07:00", + "2026-09-04T12:34:56+0530", + "2026-09-04T12:34:56+05", + "2026-09-04T12:34:56.1Z", + "2026-09-04T12:34:56.12Z", + "2026-09-04T12:34:56.123456Z", + "2026-09-04T12:34:56.999999999Z", + "2026-09-04T12:34:56.000001Z", + ] { + let expected = try #require(legacyDate(source)) + #expect(NativeTimestampParser.parse(source) == expected, "\(source)") + } + } + + @Test + func unusualFormsStillUseLegacyRules() { + for source in [ + "2026-9-4T1:2:3Z", + "2026-09-04T12:34:56z", + "2026-09-04T12:34:56Z trailing text", + "2026-09-04T24:00:00Z", + "2026-02-30T12:00:00.123Z", + "0000-01-01T00:00:00Z", + "+012026-09-04T12:34:56Z", + "2026-09-04T12:34:56Z", + ] { + #expect(NativeTimestampParser.parse(source) == legacyDate(source), "\(source)") + } + } + + @Test + func invalidClockValuesAndMalformedTimestampsStayRejected() { + for source in [ + "", " ", "not a date", "2026-09-04", "2026-09-04T12:34:56", + "2026-13-01T00:00:00Z", "2026-00-01T00:00:00.000Z", + "2026-09-04T25:00:00.000Z", "2026-09-04T12:60:00Z", + "2026-09-04T12:99:00.123Z", "2016-12-31T23:59:60Z", + "2016-12-31T23:59:60.000Z", "2026-09-04T12:34:99.123Z", + "2026-09-04T12:34:56.123X", + ] { + #expect(legacyDate(source) == nil, "\(source)") + #expect(NativeTimestampParser.parse(source) == nil, "\(source)") + } + } + + private func legacyDate(_ value: String) -> Date? { + Self.fractionalFormatter.date(from: value) ?? Self.wholeSecondFormatter.date(from: value) + } + + private static let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let wholeSecondFormatter = ISO8601DateFormatter() +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeUsageStreamingTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeUsageStreamingTests.swift new file mode 100644 index 000000000000..235695dc95d4 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeUsageStreamingTests.swift @@ -0,0 +1,215 @@ +import Foundation +import XCTest +@testable import T3Code + +@MainActor +final class NativeUsageStreamingTests: XCTestCase { + func testFastUsageAppearsWhileAnotherComputerIsPendingAndSurvivesItsFailure() async throws { + let fixture = try await UsageStreamingFixture.make() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + var requests = fixture.connector.requests.makeAsyncIterator() + var updates = fixture.client.usageSummaryUpdates( + UsageSummaryInput(sinceDay: "2026-09-01", untilDay: "2026-09-05", timeZone: "UTC"), + refreshPricing: false + ).makeAsyncIterator() + + let initial = try await updates.next() + XCTAssertEqual(initial?.map(\.environmentID), ["fast", "slow"]) + XCTAssertTrue(initial?.allSatisfy(\.isPending) == true) + let firstRequest = await requests.next() + let first = try XCTUnwrap(firstRequest) + let secondRequest = await requests.next() + let second = try XCTUnwrap(secondRequest) + let fast = first.host == "fast.example" ? first : second + let slow = first.host == "slow.example" ? first : second + XCTAssertEqual(fast.tag, "server.getUsageSummary") + XCTAssertEqual(slow.tag, "server.getUsageSummary") + + try await fast.succeed(.object([ + "contractVersion": .number(5), + "readAt": .string("2026-09-05T12:00:00.000Z"), + "timeZone": .string("UTC"), + "sinceDay": .string("2026-09-01"), + "untilDay": .string("2026-09-05"), + "buckets": .array([]), "sources": .array([]), + "pricing": .object([ + "status": .string("fresh"), "source": .string("LiteLLM"), + "knownModels": .number(1), + ]), + "scanDurationMs": .number(1), + ])) + let partial = try await updates.next() + XCTAssertNotNil(partial?.first?.summary) + XCTAssertEqual(partial?.first?.isPending, false) + XCTAssertEqual(partial?.last?.isPending, true) + + try await slow.fail("The computer is offline.") + let final = try await updates.next() + XCTAssertNotNil(final?.first?.summary) + XCTAssertNotNil(final?.last?.errorMessage) + XCTAssertTrue(final?.allSatisfy { !$0.isPending } == true) + let completed = try await updates.next() + XCTAssertNil(completed) + } + + func testCreditRedemptionUsesTheChosenComputerEvenWithSharedProviderIDs() async throws { + let fixture = try await UsageStreamingFixture.make() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + var requests = fixture.connector.requests.makeAsyncIterator() + let redemption = Task { + try await fixture.client.consumeResetCredit(environmentID: "slow", instanceID: "codex") + } + let nextRequest = await requests.next() + let request = try XCTUnwrap(nextRequest) + XCTAssertEqual(request.host, "slow.example") + XCTAssertEqual(request.tag, "provider.consumeResetCredit") + XCTAssertEqual(request.payload, .object(["instanceId": .string("codex")])) + try await request.succeed(.object(["outcome": .string("alreadyRedeemed")])) + let result = try await redemption.value + XCTAssertEqual(result.outcome, .alreadyRedeemed) + await fixture.client.disconnect() + await fixture.connector.closeConnections() + } +} + +private struct UsageStreamingFixture { + let directory: URL + let client: NativeFeatureClient + let connector: UsageStreamingConnector + + @MainActor + static func make() async throws -> Self { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-usage-stream-\(UUID().uuidString)", isDirectory: true) + let store = EnvironmentStore(fileURL: directory.appendingPathComponent("environments.json")) + let environments = ["fast", "slow"].map { id in + Environment( + id: id, label: id, + httpBaseURL: URL(string: "https://\(id).example")!, + webSocketBaseURL: URL(string: "wss://\(id).example")! + ) + } + try await store.save(environments) + let connector = UsageStreamingConnector() + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore(credentials: [ + "fast": EnvironmentCredential(accessToken: "test-fast"), + "slow": EnvironmentCredential(accessToken: "test-slow"), + ]), + httpTransport: UsageStreamingHTTPTransport(), + webSocketConnector: connector + ) + return Self(directory: directory, client: NativeFeatureClient(runtime: runtime), connector: connector) + } +} + +private struct UsageStreamingHTTPTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + guard let url = request.url, url.path == "/api/auth/websocket-ticket" else { + throw URLError(.unsupportedURL) + } + let data = try JSONEncoder.t3.encode(JSONValue.object([ + "ticket": .string("test-ticket"), + "expiresAt": .string("2026-09-05T23:59:00.000Z"), + ])) + return (data, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private struct UsageStreamingRequest: Sendable { + let host: String + let tag: String + let payload: JSONValue + let id: Int + let connection: UsageStreamingConnection + + func succeed(_ value: JSONValue) async throws { + try await connection.deliver(.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object(["_tag": .string("Success"), "value": value]), + ])) + } + + func fail(_ message: String) async throws { + try await connection.deliver(.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Fail"), "error": .object(["message": .string(message)]), + ])]), + ]), + ])) + } +} + +private actor UsageStreamingConnector: WebSocketConnecting { + nonisolated let requests: AsyncStream + private let continuation: AsyncStream.Continuation + private var connections: [UsageStreamingConnection] = [] + + init() { + let pair = AsyncStream.makeStream() + requests = pair.stream + continuation = pair.continuation + } + + func connect(to url: URL) -> any WebSocketConnection { + let connection = UsageStreamingConnection(host: url.host ?? "", continuation: continuation) + connections.append(connection) + return connection + } + + func closeConnections() async { + for connection in connections { await connection.close() } + continuation.finish() + } +} + +private actor UsageStreamingConnection: WebSocketConnection { + private let host: String + private let continuation: AsyncStream.Continuation + private var responses: [Data] = [] + private var receiver: CheckedContinuation? + private var closed = false + + init(host: String, continuation: AsyncStream.Continuation) { + self.host = host + self.continuation = continuation + } + + func send(_ data: Data) throws { + guard !closed else { throw RPCError.disconnected } + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard request["_tag"]?.stringValue == "Request", + let tag = request["tag"]?.stringValue, + case let .number(id)? = request["id"] else { return } + continuation.yield(UsageStreamingRequest( + host: host, tag: tag, payload: request["payload"] ?? .object([:]), + id: Int(id), connection: self + )) + } + + func receive() async throws -> Data { + guard !closed else { throw RPCError.disconnected } + if !responses.isEmpty { return responses.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func deliver(_ value: JSONValue) throws { + let data = try JSONEncoder.t3.encode(value) + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + responses.append(data) + } + } + + func close() { + closed = true + receiver?.resume(throwing: CancellationError()) + receiver = nil + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeWorkLogAccumulatorTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeWorkLogAccumulatorTests.swift new file mode 100644 index 000000000000..ed45301d8840 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeWorkLogAccumulatorTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Native work log accumulator") +struct NativeWorkLogAccumulatorTests { + @Test + func lifecycleUpdatesReplaceActiveWorkAndCompletionAddsOneLine() { + var accumulator = NativeWorkLogAccumulator() + accumulator.append( + activity( + id: "started", + kind: "tool.started", + summary: "Run tests", + payload: ["toolCallId": .string("call-1"), "title": .string("Run tests")] + ), + preview: nil, + createdAt: Date(timeIntervalSince1970: 1) + ) + accumulator.append( + activity( + id: "updated", + kind: "tool.updated", + summary: "Run focused tests", + payload: [ + "data": .object(["toolCallId": .string("call-1")]), + "title": .string("Run focused tests"), + ] + ), + preview: nil, + createdAt: Date(timeIntervalSince1970: 2) + ) + + var message = accumulator.message(groupID: "turn-1") + #expect(message.activeWorkLabel == "Run focused tests") + #expect(message.text.isEmpty) + + accumulator.append( + activity( + id: "completed", + kind: "tool.completed", + summary: "Run focused tests completed", + payload: ["toolCallId": .string("call-1")] + ), + preview: "2 tests passed", + createdAt: Date(timeIntervalSince1970: 3) + ) + message = accumulator.message(groupID: "turn-1") + #expect(message.activeWorkLabel == nil) + #expect(message.toolName == "Work log · 1") + #expect(message.text == "• 2 tests passed") + } + + @Test + func fallbackLifecycleMatchAndTurnEndClearActiveWork() { + var accumulator = NativeWorkLogAccumulator() + accumulator.append( + activity( + id: "started", + kind: "tool.started", + summary: "Read file", + payload: [ + "itemType": .string("dynamic_tool_call"), + "title": .string("Read file"), + "detail": .string("/tmp/screenshot.png"), + ] + ), + preview: nil, + createdAt: .now + ) + accumulator.append( + activity( + id: "completed", + kind: "tool.completed", + summary: "Read file completed", + payload: [ + "itemType": .string("dynamic_tool_call"), + "title": .string("Read file completed"), + "detail": .string("/tmp/screenshot.png"), + ] + ), + preview: "/tmp/screenshot.png", + createdAt: .now + ) + #expect(!accumulator.hasActiveWork) + + accumulator.append( + activity(id: "next", kind: "tool.started", summary: "Old task"), + preview: nil, + createdAt: .now + ) + accumulator.clearActiveWork() + #expect(accumulator.message(groupID: "turn-1").activeWorkLabel == nil) + } + + @Test + func viewedImagesExcludeMultilineAndNonImageDetails() { + var accumulator = NativeWorkLogAccumulator() + for (id, detail) in [ + ("image", "/tmp/image one.PNG"), + ("multiline", "/tmp/image.png\nextra"), + ("text", "/tmp/readme.txt"), + ] { + accumulator.append( + activity( + id: id, + kind: "tool.completed", + summary: "Read file", + payload: [ + "requestKind": .string("file-read"), + "detail": .string(detail), + ] + ), + preview: detail, + createdAt: .now + ) + } + #expect(accumulator.message(groupID: "turn-1").workLogImagePaths == [ + "/tmp/image one.PNG", + ]) + } + + @Test + func mediaRendersOnlyWhileExpandedAndEscapesMarkdownPaths() { + let paths = ["/tmp/image one(2).png"] + #expect(!FeatureWorkLogMedia.shouldRenderImages(isExpanded: false, paths: paths)) + #expect(FeatureWorkLogMedia.shouldRenderImages(isExpanded: true, paths: paths)) + #expect(FeatureWorkLogMedia.markdownSource(for: paths) == "![](/tmp/image%20one%282%29.png)") + } + + private func activity( + id: String, + kind: String, + summary: String, + payload: [String: JSONValue] = [:] + ) -> OrchestrationActivity { + OrchestrationActivity( + id: id, + tone: "info", + kind: kind, + summary: summary, + payload: .object(payload), + turnId: "turn-1", + sequence: nil, + createdAt: "2026-09-01T00:00:00Z" + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ProjectCreationModelsTests.swift b/apps/swift-ios/Tests/FeatureTests/ProjectCreationModelsTests.swift new file mode 100644 index 000000000000..7b72fcbc1e75 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ProjectCreationModelsTests.swift @@ -0,0 +1,208 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Native project creation") +struct ProjectCreationModelsTests { + @Test + func repositoryNamesCoverHttpsSshAndProviderPaths() { + #expect( + ProjectCreationPath.repositoryName( + from: "https://github.com/pingdotgg/t3code.git" + ) == "t3code" + ) + #expect( + ProjectCreationPath.repositoryName( + from: "git@github.com:pingdotgg/t3code.git" + ) == "t3code" + ) + #expect(ProjectCreationPath.repositoryName(from: "pingdotgg/t3code") == "t3code") + #expect(ProjectCreationPath.repositoryName(from: "") == "repository") + } + + @Test + func githubRepositoryShorthandUsesHttpsWithoutChangingExplicitRemotes() { + #expect( + ProjectCreationPath.normalizedCloneURL(" pingdotgg/t3code ") + == "https://github.com/pingdotgg/t3code.git" + ) + #expect( + ProjectCreationPath.normalizedCloneURL("pingdotgg/t3code.git") + == "https://github.com/pingdotgg/t3code.git" + ) + #expect( + ProjectCreationPath.normalizedCloneURL("git@github.com:pingdotgg/t3code.git") + == "git@github.com:pingdotgg/t3code.git" + ) + #expect( + ProjectCreationPath.normalizedCloneURL("https://gitlab.com/team/project.git") + == "https://gitlab.com/team/project.git" + ) + } + + @Test + func discoveredGitHubRepositoriesDefaultToTheirHttpsCloneURL() { + let github = SourceControlRepositoryInfo( + provider: .github, + nameWithOwner: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code", + sshUrl: "git@github.com:pingdotgg/t3code.git" + ) + let gitlab = SourceControlRepositoryInfo( + provider: .gitlab, + nameWithOwner: "team/project", + url: "https://gitlab.com/team/project", + sshUrl: "git@gitlab.com:team/project.git" + ) + + #expect(ProjectCreationPath.defaultCloneURL(for: github) == github.url) + #expect(ProjectCreationPath.defaultCloneURL(for: gitlab) == gitlab.sshUrl) + } + + @Test + func pathsRequireServerAbsoluteOrHomeRelativeInput() throws { + #expect(try ProjectCreationPath.validated(" ~/work/t3code ").get() == "~/work/t3code") + #expect(try ProjectCreationPath.validated("/srv/t3code").get() == "/srv/t3code") + #expect(try ProjectCreationPath.validated(#"C:\work\t3code"#).get() == #"C:\work\t3code"#) + #expect(ProjectCreationPath.validated("relative/project").isFailure) + #expect(ProjectCreationPath.validated(" ").isFailure) + } + + @Test + func destinationSuggestionsRespectUnixAndWindowsSeparators() { + #expect(ProjectCreationPath.appending("t3code", to: "~/work") == "~/work/t3code") + #expect(ProjectCreationPath.appending("t3code", to: "~/work/") == "~/work/t3code") + #expect( + ProjectCreationPath.appending("t3code", to: #"C:\work"#) == #"C:\work\t3code"# + ) + #expect( + ProjectCreationPath.normalizedForComparison(#"C:\Work\T3Code\"#) + == "c:/work/t3code" + ) + #expect(ProjectCreationPath.normalizedForComparison("/srv/App") == "/srv/App") + #expect(ProjectCreationPath.normalizedForComparison(#"/srv/a\b"#) == #"/srv/a\b"#) + } + + @Test + func folderBrowseQueriesNavigateDirectoriesInsteadOfPrefixSearching() { + #expect(ProjectCreationPath.directoryBrowsePath("~/work") == "~/work/") + #expect(ProjectCreationPath.directoryBrowsePath("/srv/t3code/") == "/srv/t3code/") + #expect( + ProjectCreationPath.directoryBrowsePath(#"C:\work\t3code"#) + == #"C:\work\t3code\"# + ) + + #expect(ProjectCreationPath.parentBrowsePath(of: "~/work/t3code/") == "~/work/") + #expect(ProjectCreationPath.parentBrowsePath(of: "~/") == nil) + #expect(ProjectCreationPath.parentBrowsePath(of: "/srv/t3code/") == "/srv/") + #expect(ProjectCreationPath.parentBrowsePath(of: "/") == nil) + #expect( + ProjectCreationPath.parentBrowsePath(of: #"C:\work\t3code\"#) + == #"C:\work\"# + ) + #expect(ProjectCreationPath.parentBrowsePath(of: #"C:\"#) == nil) + #expect( + ProjectCreationPath.parentBrowsePath(of: #"\\server\share\folder\"#) + == #"\\server\share\"# + ) + #expect(ProjectCreationPath.parentBrowsePath(of: #"\\server\share\"#) == nil) + #expect( + ProjectCreationPath.directoryBrowsePath("//server/share/folder") + == #"\\server\share\folder\"# + ) + } + + @Test + func projectTitlesHandleServerPathStyles() { + #expect(ProjectCreationPath.lastPathComponent("/srv/t3code/") == "t3code") + #expect(ProjectCreationPath.lastPathComponent(#"C:\work\t3code\"#) == "t3code") + #expect(ProjectCreationPath.lastPathComponent(#"\\server\share\t3code"#) == "t3code") + } + + @Test + func explicitPathsMatchTheConnectedServerFilesystemStyle() { + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + "/srv/t3code", + serverPath: "/srv" + ) + ) + #expect( + !ProjectCreationPath.isCompatibleWithServerPath( + #"C:\work\t3code"#, + serverPath: "/srv" + ) + ) + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + #"C:\work\t3code"#, + serverPath: #"C:\work"# + ) + ) + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + "//server/share/t3code", + serverPath: #"C:\work"# + ) + ) + #expect( + !ProjectCreationPath.isCompatibleWithServerPath( + "/srv/t3code", + serverPath: #"C:\work"# + ) + ) + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + "~/work/t3code", + serverPath: #"C:\Users\theo"# + ) + ) + } + + @Test + func discoveryKeepsGitUrlReadyAndGatesProviderAuthentication() { + let discovery = SourceControlDiscoveryResult( + versionControlSystems: [], + sourceControlProviders: [ + SourceControlProviderDiscoveryItem( + kind: .github, + label: "GitHub", + status: .available, + version: "2.76", + installHint: "Install gh", + auth: SourceControlProviderAuth( + status: .authenticated, + account: "octocat" + ) + ), + SourceControlProviderDiscoveryItem( + kind: .gitlab, + label: "GitLab", + status: .available, + installHint: "Install glab", + auth: SourceControlProviderAuth( + status: .unauthenticated, + detail: "Run glab auth login" + ) + ), + ] + ) + + let options = ProjectRemoteSourceOptions.options(discovery: discovery) + let bySource = Dictionary(uniqueKeysWithValues: options.map { ($0.source, $0) }) + + #expect(bySource[.url]?.isReady == true) + #expect(bySource[.github]?.isReady == true) + #expect(bySource[.github]?.detail == "Signed in as octocat") + #expect(bySource[.gitlab]?.isReady == false) + #expect(bySource[.gitlab]?.detail == "Run glab auth login") + #expect(bySource[.bitbucket]?.isReady == false) + } +} + +private extension Result { + var isFailure: Bool { + if case .failure = self { return true } + return false + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ProjectFaviconStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/ProjectFaviconStoreTests.swift new file mode 100644 index 000000000000..b9527519801d --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ProjectFaviconStoreTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Project favicon cache") +struct ProjectFaviconStoreTests { + @Test + func persistsLastKnownIconAcrossStoreInstances() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let key = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/t3code/" + ) + let data = Data("known-icon".utf8) + let checkedAt = Date(timeIntervalSince1970: 1_000) + + let writer = FeatureProjectFaviconStore(directoryURL: directory) + try await writer.record( + data: data, + revision: "v1-favicon.svg", + for: key, + checkedAt: checkedAt + ) + + let reader = FeatureProjectFaviconStore(directoryURL: directory) + let value = try #require(try await reader.value(for: key)) + #expect(value.data == data) + #expect(value.revision == "v1-favicon.svg") + #expect(value.lastCheckedAt == checkedAt) + } + + @Test + func missingRemoteIconKeepsLastKnownProjectRelationship() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureProjectFaviconStore(directoryURL: directory) + let key = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/t3code" + ) + let data = Data("cached-icon".utf8) + + try await store.record( + data: data, + revision: "v1-favicon.svg", + for: key, + checkedAt: Date(timeIntervalSince1970: 1_000) + ) + try await store.record( + data: nil, + revision: nil, + for: key, + checkedAt: Date(timeIntervalSince1970: 2_000) + ) + + let value = try #require(try await store.value(for: key)) + #expect(value.data == data) + #expect(value.revision == "v1-favicon.svg") + #expect(value.lastCheckedAt == Date(timeIntervalSince1970: 2_000)) + } + + @Test + func projectKeysSeparateEnvironmentsAndNormalizePaths() { + let first = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/./t3code/" + ) + let sameProject = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/t3code" + ) + let otherEnvironment = FeatureProjectFaviconCacheKey( + environmentID: "big-o", + workspaceRoot: "/work/t3code" + ) + + #expect(first == sameProject) + #expect(first.fingerprint == sameProject.fingerprint) + #expect(first != otherEnvironment) + #expect(first.fingerprint != otherEnvironment.fingerprint) + } + + @Test + @MainActor + func svgFaviconsAreRasterizedBeforeCaching() async throws { + let data = Data( + ##""##.utf8 + ) + + let renderable = try #require( + await FeatureProjectFaviconImageDecoder.renderableData(from: data) + ) + #expect(UIImage(data: renderable) != nil) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("project-favicon-cache-\(UUID().uuidString)") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/PullRequestDiffTests.swift b/apps/swift-ios/Tests/FeatureTests/PullRequestDiffTests.swift new file mode 100644 index 000000000000..45e4eac43884 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/PullRequestDiffTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Pull request diff") +struct PullRequestDiffTests { + @Test + func parsesFilesAndReviewPositions() throws { + let patch = """ + diff --git a/Sources/App.swift b/Sources/App.swift + --- a/Sources/App.swift + +++ b/Sources/App.swift + @@ -10,2 +10,3 @@ + let old = true + -let value = 1 + +let value = 2 + +let extra = true + """ + + let files = PullRequestDiffParser.parse(patch) + let file = try #require(files.first) + + #expect(file.path == "Sources/App.swift") + #expect(file.lines.count == 5) + #expect(file.lines[1].oldLine == 10) + #expect(file.lines[2].position == .deleted(11)) + #expect(file.lines[3].position == .added(11)) + #expect(file.lines[4].position == .added(12)) + } + + @Test + func keepsRenamedFileContext() throws { + let patch = """ + diff --git a/Old.swift b/New.swift + --- a/Old.swift + +++ b/New.swift + @@ -1 +1 @@ + -old + +new + """ + + let file = try #require(PullRequestDiffParser.parse(patch).first) + + #expect(file.oldPath == "Old.swift") + #expect(file.path == "New.swift") + } + + @Test + func missingNewlineMarkersDoNotChangeReviewLineNumbers() throws { + let patch = """ + diff --git a/App.swift b/App.swift + --- a/App.swift + +++ b/App.swift + @@ -1,2 +1,2 @@ + -old + \\ No newline at end of file + +new + \\ No newline at end of file + context + """ + + let file = try #require(PullRequestDiffParser.parse(patch).first) + + #expect(file.lines.count == 4) + #expect(file.lines[1].position == .deleted(1)) + #expect(file.lines[2].position == .added(1)) + #expect(file.lines[3].oldLine == 2) + #expect(file.lines[3].newLine == 2) + } + + @Test + func repeatedDiffCursorsStopPaginationAndMarkDiffIncomplete() { + var pagination = PullRequestDiffPagination() + + #expect(pagination.append( + PullRequestDiffResult( + patch: "first", + truncated: false, + nextCursor: "first-cursor", + omittedFileStats: nil + ) + ) == "first-cursor") + #expect(pagination.append( + PullRequestDiffResult( + patch: "second", + truncated: false, + nextCursor: "second-cursor", + omittedFileStats: nil + ) + ) == "second-cursor") + #expect(pagination.append( + PullRequestDiffResult( + patch: "third", + truncated: false, + nextCursor: "first-cursor", + omittedFileStats: nil + ) + ) == nil) + + #expect(pagination.patch == "firstsecondthird") + #expect(pagination.isIncomplete) + } +} + +@MainActor +@Suite("Pull request pagination") +struct PullRequestPaginationTests { + @Test + func initialLoadStopsAfterTheFirstPage() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [page( + environmentID: "studio", + numbers: [3], + nextCursor: "studio-page-two" + )] + let model = PullRequestsModel(client: client) + + await model.load() + + #expect(model.rows.map(\.entry.number) == [3]) + #expect(model.hasMorePages) + #expect(client.initialRequests.count == 1) + #expect(client.targetedRequests.isEmpty) + } + + @Test + func additionalPagesKeepCursorsSeparateAndRemoveDuplicateRows() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [ + page(environmentID: "first", numbers: [3], nextCursor: "first-page-two"), + page(environmentID: "second", numbers: [4], nextCursor: "second-page-two"), + ] + client.targetedPages = [ + "first": page(environmentID: "first", numbers: [3, 2]), + "second": page(environmentID: "second", numbers: [4, 1]), + ] + let model = PullRequestsModel(client: client) + model.state = .closed + model.involvement = .authored + model.draftFilter = "only" + model.query = "Fix" + + await model.load() + await model.loadMore() + + #expect(model.rows.map(\.entry.number) == [4, 3, 2, 1]) + #expect(!model.hasMorePages) + #expect(client.targetedRequests.map(\.environmentID) == ["first", "second"]) + #expect(client.targetedRequests[0].input.cursors == [ + "github.com pingdotgg/t3code": "first-page-two", + ]) + #expect(client.targetedRequests[1].input.cursors == [ + "github.com pingdotgg/t3code": "second-page-two", + ]) + #expect(client.targetedRequests.allSatisfy { + $0.input.state == .closed + && $0.input.involvement == .authored + && $0.input.filters?.draft == "only" + && $0.input.query == "Fix" + }) + } + + @Test + func failedComputerKeepsItsRowsWhileOtherComputersContinue() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [ + page(environmentID: "offline", numbers: [3], nextCursor: "offline-page-two"), + page(environmentID: "online", numbers: [2], nextCursor: "online-page-two"), + ] + client.failedEnvironmentIDs = ["offline"] + client.targetedPages = [ + "online": page(environmentID: "online", numbers: [1]), + ] + let model = PullRequestsModel(client: client) + + await model.load() + await model.loadMore() + + #expect(model.rows.map(\.entry.number) == [3, 2, 1]) + #expect(model.environments.first?.errorMessage == "This computer is offline.") + #expect(model.environments.last?.errorMessage == nil) + #expect(model.hasMorePages) + #expect(client.targetedRequests.map(\.environmentID) == ["offline", "online"]) + } + + @Test + func stalePaginationCannotReplaceANewerReload() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [page( + environmentID: "studio", + numbers: [3], + nextCursor: "studio-page-two" + )] + let model = PullRequestsModel(client: client) + await model.load() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation<[FeaturePullRequestEnvironmentList], any Error>? + client.beforeTargetedResponse = { _, _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let pagination = Task { await model.loadMore() } + var requests = started.stream.makeAsyncIterator() + await requests.next() + await model.loadMore() + #expect(client.targetedRequests.count == 1) + + client.firstPages = [page(environmentID: "studio", numbers: [5])] + await model.load() + response?.resume(returning: [page(environmentID: "studio", numbers: [2])]) + await pagination.value + + #expect(model.rows.map(\.entry.number) == [5]) + #expect(!model.isLoadingMore) + #expect(!model.hasMorePages) + } + + private func page( + environmentID: String, + numbers: [Int], + nextCursor: String? = nil + ) -> FeaturePullRequestEnvironmentList { + FeaturePullRequestEnvironmentList( + environmentID: environmentID, + environmentName: environmentID.capitalized, + result: PullRequestListResult( + viewers: [:], + providers: [], + entries: numbers.map(entry(number:)), + errors: [], + truncated: nextCursor != nil, + nextCursors: nextCursor.map { + ["github.com pingdotgg/t3code": $0] + } ?? [:] + ), + errorMessage: nil + ) + } + + private func entry(number: Int) -> PullRequestListEntry { + PullRequestListEntry( + provider: .github, + host: "github.com", + projectId: "project", + projectTitle: "T3 Code", + repository: "pingdotgg/t3code", + number: number, + title: "Fix issue \(number)", + url: "https://github.com/pingdotgg/t3code/pull/\(number)", + author: nil, + headBranch: "fix-\(number)", + baseBranch: "main", + state: .open, + isDraft: false, + mergeability: .mergeable, + additions: 1, + deletions: 0, + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-2\(number)T00:00:00Z", + viewerReviewRequested: false, + labels: [], + reviewDecision: nil, + checksState: nil + ) + } +} + +@MainActor +private final class PullRequestPaginationClientStub: FeatureClient { + struct TargetedRequest { + let environmentID: String + let input: PullRequestListInput + } + + enum Failure: LocalizedError { + case offline + + var errorDescription: String? { "This computer is offline." } + } + + var firstPages: [FeaturePullRequestEnvironmentList] = [] + var targetedPages: [String: FeaturePullRequestEnvironmentList] = [:] + var failedEnvironmentIDs: Set = [] + var initialRequests: [PullRequestListInput] = [] + var targetedRequests: [TargetedRequest] = [] + var beforeTargetedResponse: + ((String, PullRequestListInput) async throws -> [FeaturePullRequestEnvironmentList])? + + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + { + initialRequests.append(input) + return firstPages + } + + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] { + targetedRequests.append(TargetedRequest(environmentID: environmentID, input: input)) + if let beforeTargetedResponse { + return try await beforeTargetedResponse(environmentID, input) + } + if failedEnvironmentIDs.contains(environmentID) { + throw Failure.offline + } + return targetedPages[environmentID].map { [$0] } ?? [] + } + + func initialSnapshot() async throws -> FeatureSnapshot { FeatureSnapshot() } + func pair(endpoint: String, token: String?) async throws {} + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + FeatureThread(id: "created", projectID: projectID, title: title ?? "Created") + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func deleteThread(id: String) async throws {} + + func loadThread(id: String) async throws -> FeatureThreadDetail { + FeatureThreadDetail(thread: FeatureThread(id: id, projectID: "project", title: "Task")) + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws {} + func cancelTurn(threadID: String) async throws {} + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func saveSettings(_ settings: FeatureSettings) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/SubagentStatusTests.swift b/apps/swift-ios/Tests/FeatureTests/SubagentStatusTests.swift new file mode 100644 index 000000000000..12651552cfab --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/SubagentStatusTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Subagent status") +struct SubagentStatusTests { + @Test + func countsOnlyExplicitLiveSubagents() { + var tracker = FeatureActiveSubagentTracker() + + tracker.apply(activity( + id: "agent-start", + kind: "task.started", + payload: ["taskId": .string("agent-1"), "agentKind": .string("agent")] + )) + tracker.apply(activity( + id: "background-start", + kind: "task.started", + payload: ["taskId": .string("monitor-1"), "agentKind": .string("background")] + )) + tracker.apply(activity( + id: "legacy-start", + kind: "task.started", + payload: ["taskId": .string("legacy-1")] + )) + + #expect(tracker.activeCount == 1) + } + + @Test + func terminalRowsInheritKnownAgentMembership() { + var tracker = FeatureActiveSubagentTracker() + tracker.apply(activity( + id: "start", + kind: "task.started", + payload: ["taskId": .string("agent-1"), "agentKind": .string("agent")] + )) + tracker.apply(activity( + id: "complete", + kind: "task.completed", + payload: ["taskId": .string("agent-1"), "status": .string("completed")] + )) + + #expect(tracker.activeCount == 0) + } + + @Test + func idleAgentsCanStartAgainButTerminalAgentsDoNotReopenFromLateStarts() { + var tracker = FeatureActiveSubagentTracker() + tracker.apply(activity( + id: "start", + kind: "task.started", + payload: ["taskId": .string("agent-1"), "agentKind": .string("agent")] + )) + tracker.apply(activity( + id: "idle", + kind: "task.updated", + payload: ["taskId": .string("agent-1"), "status": .string("idle")] + )) + #expect(tracker.activeCount == 0) + + tracker.apply(activity( + id: "restart", + kind: "task.started", + payload: ["taskId": .string("agent-1")] + )) + #expect(tracker.activeCount == 1) + + tracker.apply(activity( + id: "failed", + kind: "task.updated", + payload: ["taskId": .string("agent-1"), "status": .string("failed")] + )) + tracker.apply(activity( + id: "late-start", + kind: "task.started", + payload: ["taskId": .string("agent-1")] + )) + #expect(tracker.activeCount == 0) + } + + @Test + func monitoringRemainsDistinctFromActiveAgentWork() { + let working = NativeFeatureClient.resolveThreadState( + latestTurn: nil, + session: nil, + hasApprovals: false, + hasUserInput: false, + backgroundLiveness: .working + ) + let monitoring = NativeFeatureClient.resolveThreadState( + latestTurn: nil, + session: nil, + hasApprovals: false, + hasUserInput: false, + backgroundLiveness: .monitoring + ) + + #expect(working == .working) + #expect(monitoring == .monitoring) + } + + @Test(arguments: [ + OrchestrationBackgroundLiveness.working, + OrchestrationBackgroundLiveness.monitoring, + ]) + func threadShellDecodesBackgroundLiveness( + _ backgroundLiveness: OrchestrationBackgroundLiveness + ) throws { + let shell = OrchestrationThreadShell( + id: "thread-1", + projectId: "project-1", + title: "Subagents", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: nil, + worktreePath: nil, + latestTurn: nil, + createdAt: "2026-08-08T00:00:00Z", + updatedAt: "2026-08-08T00:00:00Z", + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + session: nil, + latestUserMessageAt: nil, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: backgroundLiveness + ) + + let decoded = try JSONDecoder.t3.decode( + OrchestrationThreadShell.self, + from: JSONEncoder.t3.encode(shell) + ) + + #expect(decoded.backgroundLiveness == backgroundLiveness) + } + + private func activity( + id: String, + kind: String, + payload: [String: JSONValue] + ) -> OrchestrationActivity { + OrchestrationActivity( + id: id, + tone: "info", + kind: kind, + summary: kind, + payload: .object(payload), + turnId: "turn-1", + sequence: nil, + createdAt: "2026-08-08T00:00:00Z" + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/T3ConnectNativeCapabilityTests.swift b/apps/swift-ios/Tests/FeatureTests/T3ConnectNativeCapabilityTests.swift new file mode 100644 index 000000000000..259fdca781fa --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/T3ConnectNativeCapabilityTests.swift @@ -0,0 +1,513 @@ +import CryptoKit +import XCTest +@testable import T3Code + +@MainActor +final class T3ConnectNativeCapabilityTests: XCTestCase { + func testNativeConnectValidatesPersistsActivatesAndPublishesImmediately() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-native-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let store = EnvironmentStore(fileURL: catalogURL) + let credentials = InMemoryCredentialStore() + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: "managed-1") + let controller = T3ConnectController( + resolution: .unavailable(reason: "Authentication is injected by this test."), + transport: transport, + signer: signer + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient( + runtime: runtime, + t3ConnectController: controller, + fallbackPollingInitialDelay: .seconds(30) + ) + var events = client.events().makeAsyncIterator() + let managed = try await bootstrapCredential(signer: signer) + + try await client.connectT3Environment(managed) + + let event = await events.next() + guard case let .snapshot(snapshot)? = event else { + return XCTFail("Managed connect did not publish its initial Home snapshot") + } + XCTAssertEqual(snapshot.connection.state, .connected) + XCTAssertEqual(snapshot.connection.environmentName, "Managed Studio") + + let saved = try await store.load() + XCTAssertEqual(saved.count, 1) + XCTAssertEqual(saved[0].id, "managed-1") + XCTAssertEqual(saved[0].kind, .managedDPoP) + let activeEnvironmentID = try await store.activeEnvironmentID() + XCTAssertEqual(activeEnvironmentID, "managed-1") + let credential = await credentials.credential(for: "managed-1") + XCTAssertEqual(credential?.authorizationMethod, .dpop) + XCTAssertEqual(credential?.accessToken, "native-access-token") + XCTAssertNotEqual(credential?.accessToken, managed.bootstrapCredential) + + let catalog = try String(contentsOf: catalogURL, encoding: .utf8) + XCTAssertFalse(catalog.contains("one-use-bootstrap")) + XCTAssertFalse(catalog.contains("native-access-token")) + XCTAssertFalse(catalog.contains("ws-ticket")) + + let requests = await transport.requests + XCTAssertEqual( + Array(requests.prefix(3).map(\.url?.path)), + ["/.well-known/t3/environment", "/oauth/token", "/api/orchestration/shell"] + ) + let shellRequest = try XCTUnwrap( + requests.first(where: { $0.url?.path == "/api/orchestration/shell" }) + ) + XCTAssertEqual( + shellRequest.value(forHTTPHeaderField: "Authorization"), + "DPoP native-access-token" + ) + XCTAssertNotNil(shellRequest.value(forHTTPHeaderField: "DPoP")) + await client.disconnect() + } + + func testDescriptorMismatchLeavesManualEnvironmentAndCredentialsUntouched() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-mismatch-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let manual = Environment( + id: "manual-1", + label: "Big O", + httpBaseURL: URL(string: "https://big-o.example")!, + webSocketBaseURL: URL(string: "wss://big-o.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([manual]) + try await store.setActiveEnvironment(id: manual.id) + let manualCredential = EnvironmentCredential(accessToken: "manual-secret") + let credentials = InMemoryCredentialStore(credentials: [manual.id: manualCredential]) + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: "wrong-server") + let controller = T3ConnectController( + resolution: .unavailable(reason: "Authentication is injected by this test."), + transport: transport, + signer: signer + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient(runtime: runtime, t3ConnectController: controller) + + do { + try await client.connectT3Environment( + try await bootstrapCredential(signer: signer) + ) + XCTFail("A descriptor for another environment was accepted") + } catch T3ConnectRelayError.environmentMismatch { + // Expected identity rejection. + } + + let savedEnvironments = try await store.load() + let activeEnvironmentID = try await store.activeEnvironmentID() + let savedManualCredential = await credentials.credential(for: manual.id) + let savedManagedCredential = await credentials.credential(for: "managed-1") + XCTAssertEqual(savedEnvironments, [manual]) + XCTAssertEqual(activeEnvironmentID, manual.id) + XCTAssertEqual(savedManualCredential, manualCredential) + XCTAssertNil(savedManagedCredential) + let requests = await transport.requests + XCTAssertEqual(requests.map(\.url?.path), ["/.well-known/t3/environment"]) + } + + func testInjectedRuntimeWithoutManagedAuthorizationReportsUnavailable() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-unavailable-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let runtime = EnvironmentRuntime( + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: InMemoryCredentialStore() + ) + let client = NativeFeatureClient(runtime: runtime) + XCTAssertNotNil(client.t3ConnectController.unavailableReason) + + let signer = try testSigner() + do { + try await client.connectT3Environment( + try await bootstrapCredential(signer: signer) + ) + XCTFail("An unconfigured runtime presented a working managed connection") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + } + + func testSignOutRemovesManagedStateWhenClerkFailsAndPreservesManualPairing() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-sign-out-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let manual = Environment( + id: "manual-1", + label: "Big O", + httpBaseURL: URL(string: "http://100.64.0.1:3773")!, + webSocketBaseURL: URL(string: "ws://100.64.0.1:3773/ws")! + ) + let managed = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([managed, manual]) + try await store.setActiveEnvironment(id: managed.id) + let manualCredential = EnvironmentCredential(accessToken: "manual-secret") + let managedCredential = EnvironmentCredential.managedDPoP( + accessToken: "managed-secret", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: managed.id, + proofKeyThumbprint: "proof-key" + ) + let credentials = InMemoryCredentialStore( + credentials: [ + manual.id: manualCredential, + managed.id: managedCredential, + ] + ) + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: managed.id) + let controller = T3ConnectController( + resolution: .available( + T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ) + ), + transport: transport, + signer: signer, + signOutOperation: { throw T3ConnectNativeTestError.clerkSignOutFailed } + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient( + runtime: runtime, + t3ConnectController: controller + ) + + await client.signOutT3Connect() + + let remainingEnvironments = try await runtime.environments() + let activeEnvironmentID = try await store.activeEnvironmentID() + let remainingManualCredential = await credentials.credential(for: manual.id) + let remainingManagedCredential = await credentials.credential(for: managed.id) + XCTAssertEqual(remainingEnvironments, [manual]) + XCTAssertEqual(activeEnvironmentID, manual.id) + XCTAssertEqual(remainingManualCredential, manualCredential) + XCTAssertNil(remainingManagedCredential) + XCTAssertEqual( + controller.errorMessage, + T3ConnectNativeTestError.clerkSignOutFailed.localizedDescription + ) + } + + func testSignOutRevokesManagedCredentialWhenCatalogRemovalFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-revoke-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let managed = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([managed]) + let credentials = InMemoryCredentialStore(credentials: [ + managed.id: .managedDPoP( + accessToken: "managed-secret", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: managed.id, + proofKeyThumbprint: "proof-key" + ), + ]) + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: managed.id) + let controller = T3ConnectController( + resolution: .available( + T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ) + ), + transport: transport, + signer: signer, + signOutOperation: {} + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient(runtime: runtime, t3ConnectController: controller) + + try FileManager.default.removeItem(at: catalogURL) + try FileManager.default.createDirectory(at: catalogURL, withIntermediateDirectories: true) + await client.signOutT3Connect() + + let remainingCredential = await credentials.credential(for: managed.id) + XCTAssertNil(remainingCredential) + XCTAssertNotNil(controller.errorMessage) + } + + func testManagedEnvironmentRemovalRevokesCredentialWhenCatalogRemovalFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-account-change-revoke-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let managed = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([managed]) + let credentials = InMemoryCredentialStore(credentials: [ + managed.id: .managedDPoP( + accessToken: "managed-secret", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: managed.id, + proofKeyThumbprint: "proof-key" + ), + ]) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials + ) + let client = NativeFeatureClient(runtime: runtime) + + try FileManager.default.removeItem(at: catalogURL) + try FileManager.default.createDirectory(at: catalogURL, withIntermediateDirectories: true) + + do { + try await client.removeEnvironment(id: managed.id) + XCTFail("Managed environment removal succeeded with an unwritable catalog") + } catch { + let remainingCredential = await credentials.credential(for: managed.id) + XCTAssertNil(remainingCredential) + } + } + + func testManualEnvironmentRemovalKeepsCredentialWhenCatalogRemovalFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-manual-removal-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let manual = Environment( + id: "manual-1", + label: "Manual Studio", + httpBaseURL: URL(string: "https://manual.example")!, + webSocketBaseURL: URL(string: "wss://manual.example")! + ) + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([manual]) + let credential = EnvironmentCredential(accessToken: "manual-secret") + let credentials = InMemoryCredentialStore(credentials: [manual.id: credential]) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials + ) + let client = NativeFeatureClient(runtime: runtime) + + try FileManager.default.removeItem(at: catalogURL) + try FileManager.default.createDirectory(at: catalogURL, withIntermediateDirectories: true) + + do { + try await client.removeEnvironment(id: manual.id) + XCTFail("Manual environment removal succeeded with an unwritable catalog") + } catch { + let remainingCredential = await credentials.credential(for: manual.id) + XCTAssertEqual(remainingCredential, credential) + } + } + + func testInjectedManagedRuntimeRequiresItsMatchingController() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-controller-mismatch-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: "managed-1") + let runtimeController = T3ConnectController( + resolution: .unavailable(reason: "Authentication is injected by this test."), + transport: transport, + signer: signer + ) + let runtime = EnvironmentRuntime( + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: InMemoryCredentialStore(), + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: runtimeController) + ) + let client = NativeFeatureClient(runtime: runtime) + + XCTAssertEqual( + client.t3ConnectController.unavailableReason, + "This client runtime requires its matching T3 Connect controller." + ) + do { + try await client.connectT3Environment( + try await bootstrapCredential(signer: signer) + ) + XCTFail("A managed runtime accepted an unrelated controller") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + private func testSigner() throws -> T3ConnectDPoPSigner { + var scalar = Data(repeating: 0, count: 32) + scalar[31] = 11 + return try T3ConnectDPoPSigner(privateKeyRawRepresentation: scalar) + } + + private func bootstrapCredential( + signer: T3ConnectDPoPSigner + ) async throws -> T3ConnectManagedEnvironmentCredential { + T3ConnectManagedEnvironmentCredential( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + bootstrapCredential: "one-use-bootstrap", + bootstrapExpiresAt: "2030-08-01T12:00:00.000Z", + proofKeyThumbprint: try await signer.thumbprint() + ) + } +} + +private actor T3ConnectNativeHTTPTransport: HTTPTransport { + private let descriptorEnvironmentID: String + private(set) var requests: [URLRequest] = [] + + init(descriptorEnvironmentID: String) { + self.descriptorEnvironmentID = descriptorEnvironmentID + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: Data + switch request.url?.path { + case "/.well-known/t3/environment": + body = Data( + """ + { + "environmentId": "\(descriptorEnvironmentID)", + "label": "Managed Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """.utf8 + ) + case "/oauth/token": + body = Data( + """ + { + "access_token": "native-access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "DPoP", + "expires_in": 300, + "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read" + } + """.utf8 + ) + case "/api/orchestration/shell": + body = Data( + #"{"snapshotSequence":1,"projects":[],"threads":[],"updatedAt":"2026-08-01T12:00:00.000Z"}"#.utf8 + ) + case "/api/auth/websocket-ticket": + body = Data( + #"{"ticket":"ws-ticket","expiresAt":"2026-08-01T12:05:00.000Z"}"#.utf8 + ) + default: + throw T3ConnectNativeTestError.unexpectedPath(request.url?.path) + } + return ( + body, + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} + +private enum T3ConnectNativeTestError: Error { + case unexpectedPath(String?) + case clerkSignOutFailed +} + +private actor T3ConnectBlockingConnector: WebSocketConnecting { + private let connection = T3ConnectBlockingConnection() + + func connect(to _: URL) -> any WebSocketConnection { + connection + } +} + +private actor T3ConnectBlockingConnection: WebSocketConnection { + private var receiveContinuation: CheckedContinuation? + + func send(_: Data) {} + + func receive() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/TerminalInputTests.swift b/apps/swift-ios/Tests/FeatureTests/TerminalInputTests.swift new file mode 100644 index 000000000000..6b8062dc4863 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/TerminalInputTests.swift @@ -0,0 +1,248 @@ +import Testing +import UIKit +@testable import T3Code + +@MainActor +struct TerminalInputTests { + @Test func pasteNormalizesLineBreaksAndKeepsTextAndTabs() { + #expect(TerminalInputEncoder.paste("") == "") + #expect(TerminalInputEncoder.paste("git status\t🔧") == "git status\t🔧") + #expect(TerminalInputEncoder.paste("one\ntwo\r\nthree\rfour\n\n") == "one\rtwo\rthree\rfour\r\r") + } + + @Test func pasteReplacesControlsAndBracketedPasteMarkers() { + let text = "safe\u{1B}[201~\u{00}\u{08}\u{0B}\u{0C}\u{0E}\u{1F}\u{7F}\tend" + #expect(TerminalInputEncoder.paste(text) == "safe [201~ \tend") + } + + @Test func chunksFitTheWireLimit() { + let limit = TerminalInputEncoder.maximumWriteLength + #expect(TerminalInputEncoder.chunks("").isEmpty) + #expect(TerminalInputEncoder.chunks("ls") == ["ls"]) + #expect(TerminalInputEncoder.chunks(String(repeating: "x", count: limit)).count == 1) + + let data = String(repeating: "y", count: limit * 2 + 5) + let chunks = TerminalInputEncoder.chunks(data) + #expect(chunks.map { $0.utf16.count } == [limit, limit, 5]) + #expect(chunks.joined() == data) + } + + @Test func chunksKeepSurrogatePairsWhole() { + let limit = TerminalInputEncoder.maximumWriteLength + let data = String(repeating: "z", count: limit - 1) + "😀tail" + let chunks = TerminalInputEncoder.chunks(data) + #expect(chunks.map { $0.utf16.count } == [limit - 1, 6]) + #expect(chunks.last == "😀tail") + #expect(chunks.joined() == data) + } + + @Test func oneLongGraphemeCannotExceedTheWireLimit() { + let limit = TerminalInputEncoder.maximumWriteLength + let data = "a" + String(repeating: "\u{0301}", count: limit) + #expect(data.count == 1) + let chunks = TerminalInputEncoder.chunks(data) + #expect(chunks.map { $0.utf16.count } == [limit, 1]) + #expect(chunks.joined() == data) + } + + @Test func toolbarPasteChordUsesTheReportedHostOS() { + for os in ["windows", "linux", "unknown"] { + let host = TerminalHostPlatform(os: os) + #expect(TerminalInputEncoder.modified("v", modifier: .control, hostPlatform: host) == .paste) + #expect(TerminalInputEncoder.modified("V", modifier: .control, hostPlatform: host) == .paste) + #expect(TerminalInputEncoder.modified("v", modifier: .command, hostPlatform: host) == .write("\u{1B}v")) + } + let mac = TerminalHostPlatform(os: "darwin") + #expect(mac == .mac) + #expect(TerminalHostPlatform(os: nil) == .unknown) + #expect(TerminalHostPlatform(os: "My MacBook") == .unknown) + #expect(TerminalInputEncoder.modified("v", modifier: .command, hostPlatform: mac) == .paste) + #expect(TerminalInputEncoder.modified("v", modifier: .control, hostPlatform: mac) == .write("\u{16}")) + #expect(TerminalInputEncoder.modified("C", modifier: .control, hostPlatform: mac) == .write("\u{03}")) + #expect(TerminalInputEncoder.modified("[A", modifier: .command, hostPlatform: mac) == .write("\u{1B}[A")) + } + + @Test func hardwareCommandVPastesOnEveryHost() { + for host in [TerminalHostPlatform.mac, .linux, .windows, .unknown] { + #expect(TerminalHardwareKeyEncoder.sequence(input: "v", modifiers: .command, hostPlatform: host) == "paste") + #expect(TerminalHardwareKeyEncoder.sequence(input: "c", modifiers: .command, hostPlatform: host) == "copy") + #expect( + TerminalHardwareKeyEncoder.sequence(input: "v", modifiers: .control, hostPlatform: host) + == (host == .mac ? "\u{16}" : "paste") + ) + } + #expect(TerminalHardwareKeyEncoder.sequence(input: "\t", modifiers: .shift, hostPlatform: .mac) == "\u{1B}[Z") + } + + @Test func queuedKeysWaitForEveryPasteChunk() async throws { + let session = TerminalInputSession() + let target = makeTarget() + session.attach(to: target) + let writer = TerminalWriteProbe() + let paste = try #require(session.enqueue( + String(repeating: "a", count: TerminalInputEncoder.maximumWriteLength) + "tail\n", + target: target, + isPaste: true, + write: writer.write + )) + await writer.waitForFirstWrite() + let key = try #require(session.enqueue("\u{03}", target: target, write: writer.write)) + session.updateTarget(target) + writer.finishFirstWrite() + + #expect(await paste.value) + #expect(await key.value) + #expect(writer.writes.map { $0.utf16.count } == [65_536, 5, 1]) + #expect(Array(writer.writes.suffix(2)) == ["tail\r", "\u{03}"]) + #expect(writer.maximumActiveWrites == 1) + } + + @Test func pendingKeysShareOneWriteAndStopAtPasteBoundaries() async throws { + let session = TerminalInputSession() + let target = makeTarget() + session.attach(to: target) + let writer = TerminalWriteProbe() + let first = try #require(session.enqueue("a", target: target, write: writer.write)) + await writer.waitForFirstWrite() + + for key in ["b", "c", "d"] { + #expect(session.enqueue(key, target: target, write: writer.write) != nil) + } + let paste = try #require(session.enqueue("paste\n", target: target, isPaste: true, write: writer.write)) + #expect(session.enqueue("e", target: target, write: writer.write) != nil) + let last = try #require(session.enqueue("f", target: target, write: writer.write)) + writer.finishFirstWrite() + + #expect(await first.value) + #expect(await paste.value) + #expect(await last.value) + #expect(writer.writes == ["a", "bcd", "paste\r", "ef"]) + #expect(writer.maximumActiveWrites == 1) + } + + @Test func newerPasteDropsTheUnsentPartOfAnOlderPaste() async throws { + let session = TerminalInputSession() + let target = makeTarget() + session.attach(to: target) + let writer = TerminalWriteProbe() + let older = try #require(session.enqueue( + String(repeating: "a", count: TerminalInputEncoder.maximumWriteLength + 1), + target: target, + isPaste: true, + write: writer.write + )) + await writer.waitForFirstWrite() + let newer = try #require(session.enqueue("newer", target: target, isPaste: true, write: writer.write)) + writer.finishFirstWrite() + + #expect(await older.value == false) + #expect(await newer.value) + #expect(writer.writes.map { $0.utf16.count } == [65_536, 5]) + #expect(writer.writes.last == "newer") + #expect(writer.maximumActiveWrites == 1) + } + + @Test func sessionChangesDropUnsentInput() async throws { + for change in ["restart", "terminal", "thread", "stop", "dismiss"] { + let session = TerminalInputSession() + let original = makeTarget() + session.attach(to: original) + let writer = TerminalWriteProbe() + let paste = try #require(session.enqueue( + String(repeating: "a", count: TerminalInputEncoder.maximumWriteLength + 1), + target: original, + isPaste: true, + write: writer.write + )) + await writer.waitForFirstWrite() + let staleKey = try #require(session.enqueue("stale", target: original, write: writer.write)) + + let next: TerminalInputSession.Target + switch change { + case "restart": next = makeTarget(lifecycleVersion: 1) + case "terminal": next = makeTarget(terminalID: "term-2") + case "thread": next = makeTarget(threadID: "other-thread") + case "stop": + session.updateTarget(nil) + next = original + default: + session.detach() + session.updateTarget(original) + #expect(session.enqueue("hidden", target: original, write: writer.write) == nil) + session.attach(to: original) + next = original + } + session.updateTarget(next) + let fresh = try #require(session.enqueue("fresh", target: next, write: writer.write)) + writer.finishFirstWrite() + + #expect(await paste.value == false) + #expect(await staleKey.value == false) + #expect(await fresh.value) + #expect(writer.writes.map { $0.utf16.count } == [65_536, 5]) + #expect(writer.writes.last == "fresh") + } + } + + @Test func failedWriteStopsThePasteWithoutRetrying() async throws { + let session = TerminalInputSession() + let target = makeTarget() + session.attach(to: target) + let writer = TerminalWriteProbe() + let paste = try #require(session.enqueue( + String(repeating: "a", count: TerminalInputEncoder.maximumWriteLength + 1), + target: target, + isPaste: true, + write: writer.write + )) + await writer.waitForFirstWrite() + writer.finishFirstWrite(accepted: false) + #expect(await paste.value == false) + #expect(writer.writes.count == 1) + + let next = try #require(session.enqueue("next", target: target, isPaste: true, write: writer.write)) + #expect(await next.value) + #expect(writer.writes.map { $0.utf16.count } == [65_536, 4]) + #expect(writer.writes.last == "next") + } + + private func makeTarget( + threadID: String = "thread", + terminalID: String = "default", + lifecycleVersion: Int = 0 + ) -> TerminalInputSession.Target { + .init(threadID: threadID, terminalID: terminalID, lifecycleVersion: lifecycleVersion) + } +} + +@MainActor +private final class TerminalWriteProbe { + private(set) var writes = [String]() + private(set) var maximumActiveWrites = 0 + private var activeWrites = 0 + private var firstWrite: CheckedContinuation? + private var firstWriteStarted: CheckedContinuation? + + func write(_ data: String) async -> Bool { + writes.append(data) + activeWrites += 1 + maximumActiveWrites = max(maximumActiveWrites, activeWrites) + defer { activeWrites -= 1 } + guard writes.count == 1 else { return true } + return await withCheckedContinuation { continuation in + firstWrite = continuation + firstWriteStarted?.resume() + firstWriteStarted = nil + } + } + + func waitForFirstWrite() async { + guard writes.isEmpty else { return } + await withCheckedContinuation { firstWriteStarted = $0 } + } + + func finishFirstWrite(accepted: Bool = true) { + firstWrite?.resume(returning: accepted) + firstWrite = nil + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/TextSizePreferenceTests.swift b/apps/swift-ios/Tests/FeatureTests/TextSizePreferenceTests.swift new file mode 100644 index 000000000000..d1113e88e5b0 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/TextSizePreferenceTests.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftUI +import Testing +import UIKit +@testable import T3Code + +@Suite("Text and code size preferences") +struct TextSizePreferenceTests { + @Test + func shiftsComposeWithDynamicTypeAndSaturateAtTheEnds() { + #expect(DynamicTypeSize.large.t3Shifted(by: 1) == .xLarge) + #expect(DynamicTypeSize.small.t3Shifted(by: 1) == .medium) + #expect(DynamicTypeSize.large.t3Shifted(by: -2) == .small) + #expect(DynamicTypeSize.xSmall.t3Shifted(by: -2) == .xSmall) + #expect(DynamicTypeSize.accessibility5.t3Shifted(by: 3) == .accessibility5) + } + + @Test + func windowCategoryShiftUsesTheSameSaturatingScale() { + #expect(T3TextSizing.contentSizeCategory(system: .large, steps: 3) == .extraExtraExtraLarge) + #expect(T3TextSizing.contentSizeCategory(system: .large, steps: -2) == .small) + #expect(T3TextSizing.contentSizeCategory(system: .extraSmall, steps: -2) == .extraSmall) + #expect( + T3TextSizing.contentSizeCategory( + system: .accessibilityExtraExtraExtraLarge, + steps: 3 + ) == .accessibilityExtraExtraExtraLarge + ) + #expect(T3TextSizing.contentSizeCategory(system: .unspecified, steps: 2) == .unspecified) + } + + @Test + func adjustmentClampsAndRoundTripsAsAStepCount() throws { + #expect(FeatureTextSizeAdjustment(steps: 99).steps == 3) + #expect(FeatureTextSizeAdjustment(steps: -99).steps == -2) + let encoded = try JSONEncoder.t3.encode(FeatureTextSizeAdjustment(steps: 2)) + #expect(String(decoding: encoded, as: UTF8.self) == "2") + let decoded = try JSONDecoder.t3.decode( + FeatureTextSizeAdjustment.self, + from: Data("9".utf8) + ) + #expect(decoded.steps == 3) + } + + @Test + func legacySettingsDecodeAtSystemSizes() throws { + let legacy = Data( + #"{"appearance":"dark","hapticsEnabled":false,"notificationsEnabled":true}"#.utf8 + ) + let decoded = try JSONDecoder.t3.decode(FeatureSettings.self, from: legacy) + + #expect(decoded.appearance == .dark) + #expect(decoded.textSize == .standard) + #expect(decoded.codeSize == .standard) + #expect(!decoded.hapticsEnabled) + } + + @Test + func settingsRoundTripBothSizeChoices() throws { + var settings = FeatureSettings() + settings.textSize = FeatureTextSizeAdjustment(steps: 2) + settings.codeSize = FeatureTextSizeAdjustment(steps: -1) + + let decoded = try JSONDecoder.t3.decode( + FeatureSettings.self, + from: JSONEncoder.t3.encode(settings) + ) + + #expect(decoded.textSize.steps == 2) + #expect(decoded.codeSize.steps == -1) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift new file mode 100644 index 000000000000..e9f851b0f752 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift @@ -0,0 +1,266 @@ +import XCTest +@testable import T3Code + +final class ThreadCopyActionsTests: XCTestCase { + func testActionsMatchElectronOrderAndCopyExactRawValues() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: " thread-1 ", + projectID: "project-1", + title: "Copy values", + branch: " feature/copy-values ", + worktreePath: " /worktrees/copy-values " + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ) + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: " /worktrees/copy-values "), + ThreadCopyAction(kind: .branch, value: " feature/copy-values "), + ThreadCopyAction(kind: .threadID, value: " thread-1 "), + ] + ) + XCTAssertTrue(actions.allSatisfy(\.isAvailable)) + } + + func testMenuActionsMatchElectronCopyItems() { + let thread = FeatureThread( + id: "thread-1", + wireID: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Copy values", + branch: "feature/copy-values", + worktreePath: "/worktrees/copy-values" + ) + + XCTAssertEqual( + ThreadCopyModel.menuActions( + for: thread, + context: context( + projectName: "pingdotgg/t3code", + projectWorkspaceRoot: "/work/t3code", + environmentName: "Studio Mac", + environmentID: "environment-1" + ) + ).map(\.kind), + [.path, .branch, .threadID] + ) + } + + func testPathFallsBackToProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Local checkout" + ) + + XCTAssertEqual( + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ), + [ + ThreadCopyAction(kind: .path, value: "/work/t3code"), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ] + ) + } + + func testBlankWorktreePathFallsBackToProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Local checkout", + worktreePath: " " + ) + + XCTAssertEqual( + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ).first, + ThreadCopyAction(kind: .path, value: "/work/t3code") + ) + } + + func testUnavailablePathRemainsVisibleAndBlankBranchIsOmitted() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "No path", + branch: " \n " + ) + + let actions = ThreadCopyModel.actions(for: thread, context: context()) + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: nil), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ] + ) + XCTAssertFalse(actions[0].isAvailable) + XCTAssertTrue(actions[1].isAvailable) + } + + func testWorktreePathTakesPrecedenceOverProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Worktree", + worktreePath: "/worktrees/feature" + ) + + XCTAssertEqual( + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ).first, + ThreadCopyAction(kind: .path, value: "/worktrees/feature") + ) + } + + func testThreadIDUsesWireIdentityThenFallsBackToLocalIdentity() { + let wireThread = FeatureThread( + id: "scoped-thread-id", + wireID: "wire-thread-id", + projectID: "project-1", + title: "Wire identity" + ) + let localThread = FeatureThread( + id: "scoped-thread-id", + wireID: " ", + projectID: "project-1", + title: "Local identity" + ) + + XCTAssertEqual( + ThreadCopyModel.actions(for: wireThread, context: context()).last, + ThreadCopyAction(kind: .threadID, value: "wire-thread-id") + ) + XCTAssertEqual( + ThreadCopyModel.actions(for: localThread, context: context()).last, + ThreadCopyAction(kind: .threadID, value: "scoped-thread-id") + ) + } + + func testActionLabelsAndAnnouncementsAreDistinct() { + XCTAssertEqual(ThreadCopyActionKind.path.title, "Path") + XCTAssertEqual(ThreadCopyActionKind.path.copyAnnouncement, "Path copied") + XCTAssertEqual(ThreadCopyActionKind.branch.title, "Branch") + XCTAssertEqual(ThreadCopyActionKind.branch.copyAnnouncement, "Branch copied") + XCTAssertEqual(ThreadCopyActionKind.threadID.title, "Thread ID") + XCTAssertEqual(ThreadCopyActionKind.threadID.copyAnnouncement, "Thread ID copied") + XCTAssertEqual(ThreadCopyActionKind.project.title, "Project") + XCTAssertEqual(ThreadCopyActionKind.project.copyAnnouncement, "Project copied") + XCTAssertEqual(ThreadCopyActionKind.environment.title, "Environment") + XCTAssertEqual(ThreadCopyActionKind.environment.copyAnnouncement, "Environment copied") + XCTAssertEqual(ThreadCopyActionKind.url.title, "URL") + XCTAssertEqual(ThreadCopyActionKind.url.copyAnnouncement, "URL copied") + XCTAssertEqual( + ThreadCopyAction(kind: .path, value: nil).announcement, + "Path unavailable" + ) + XCTAssertEqual( + ThreadCopyAction(kind: .path, value: "/work/t3code").announcement, + "Path copied" + ) + } + + func testAvailableMetadataProducesOnlyIndividualActionsAndValues() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Copy values", + branch: "feature/copy-values", + worktreePath: "/worktrees/copy-values" + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: context( + projectName: "pingdotgg/t3code", + projectWorkspaceRoot: "/work/t3code", + environmentName: "Studio Mac", + environmentID: "fallback-environment" + ) + ) + + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: "/worktrees/copy-values"), + ThreadCopyAction(kind: .branch, value: "feature/copy-values"), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ThreadCopyAction(kind: .project, value: "pingdotgg/t3code"), + ThreadCopyAction(kind: .environment, value: "Studio Mac"), + ThreadCopyAction( + kind: .url, + value: "https://app.t3.codes/environment-1/thread-1" + ), + ] + ) + XCTAssertTrue(actions.allSatisfy { action in + action.value?.contains("\n") == false + }) + } + + func testUnavailableMetadataDoesNotProduceIndividualActions() { + let thread = FeatureThread( + id: "thread-1", + wireID: nil, + projectID: "project-1", + environmentID: nil, + title: "Sparse values" + ) + + let actions = ThreadCopyModel.actions(for: thread, context: context()) + + XCTAssertEqual( + actions.map(\.kind), + [.path, .threadID] + ) + XCTAssertFalse(actions.contains { action in + [.project, .environment, .url].contains(action.kind) + }) + } + + func testURLPercentEncodesRouteValuesAsSinglePathSegments() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: "thread/one", + projectID: "project-1", + environmentID: "studio one", + title: "Encoded route" + ) + + let url = ThreadCopyModel.actions(for: thread, context: context()) + .first { $0.kind == .url } + + XCTAssertEqual( + url?.value, + "https://app.t3.codes/studio%20one/thread%2Fone" + ) + } + + private func context( + projectName: String? = nil, + projectWorkspaceRoot: String? = nil, + environmentName: String? = nil, + environmentID: String? = nil + ) -> ThreadCopyContext { + ThreadCopyContext( + projectName: projectName, + projectWorkspaceRoot: projectWorkspaceRoot, + environmentName: environmentName, + environmentID: environmentID + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift new file mode 100644 index 000000000000..4f52764f2fc8 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ThreadKeyboardDismissTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import T3Code + +@Suite("Thread keyboard dismissal") +struct ThreadKeyboardDismissTests { + @Test + func threadDismissControlAppearsWhileTheKeyboardIsUp() { + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: true, + isEnabled: true, + canDismiss: true + )) + } + + @Test + func dismissControlStaysHiddenWithoutEveryRequirement() { + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: false, + isEnabled: true, + canDismiss: true + ) == false) + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: true, + isEnabled: false, + canDismiss: true + ) == false) + #expect(FeatureComposerKeyboardDismissPolicy.showsDismissControl( + isFocused: true, + isEnabled: true, + canDismiss: false + ) == false) + } + + @Test + func dismissingTheKeyboardLeavesADraftedComposerExpanded() { + // Dismissal only drops focus. A composer holding a long draft must stay + // expanded so the draft is still visible and still editable. + #expect(FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: false, + textIsEmpty: false, + attachmentsAreEmpty: true, + isAttachmentFlowActive: false, + isPreparingAttachments: false + ) == false) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift new file mode 100644 index 000000000000..f34e528286f6 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -0,0 +1,227 @@ +import CoreGraphics +import Testing +import UIKit +@testable import T3Code + +@Suite("Transcript viewport anchoring") +struct TranscriptViewportGeometryTests { + @Test + func firstLoadedTranscriptAnchorsToLatestMessage() { + let empty = TranscriptViewportGeometry( + contentHeight: 0, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let loaded = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + + #expect( + loaded.restoredBottomOffset( + after: empty, + maintainsBottomAnchor: true, + isInteracting: false + ) == 500 + ) + } + + @Test + func keyboardViewportChangeKeepsLatestMessageVisible() { + let beforeKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let afterKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 400, + topInset: 0, + bottomInset: 0 + ) + + #expect( + afterKeyboard.restoredBottomOffset( + after: beforeKeyboard, + maintainsBottomAnchor: true, + isInteracting: false + ) == 800 + ) + } + + @Test + func readerPositionIsUntouchedAwayFromLatestMessage() { + let beforeKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let afterKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 400, + topInset: 0, + bottomInset: 0 + ) + + #expect( + afterKeyboard.restoredBottomOffset( + after: beforeKeyboard, + maintainsBottomAnchor: false, + isInteracting: false + ) == nil + ) + } + + @Test + func activeTranscriptGestureOwnsItsScrollPosition() { + let before = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let after = TranscriptViewportGeometry( + contentHeight: 1_260, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + + #expect( + after.restoredBottomOffset( + after: before, + maintainsBottomAnchor: true, + isInteracting: true + ) == nil + ) + } + + @Test + func verticalPanFailsBeforeItCanCompeteWithTranscriptScrolling() { + #expect(!ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: 40, y: 120))) + #expect(!ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: -120, y: 0))) + } + + @Test + func horizontalPanCanLeaveTheThreadFromAnywhereOnTheSurface() { + #expect(ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: 120, y: 20))) + #expect( + ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 96, y: 16) + ) + ) + } + + @Test + func slowHorizontalPanUsesTranslationWhenVelocityIsUnavailable() { + #expect( + ThreadBackSwipeGesture.shouldBegin( + with: .zero, + translation: CGPoint(x: 16, y: 2) + ) + ) + #expect( + !ThreadBackSwipeGesture.shouldBegin( + with: .zero, + translation: CGPoint(x: 4, y: 16) + ) + ) + } + + @Test + func shortOrDiagonalPanDoesNotLeaveTheThread() { + #expect( + !ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 71, y: 0) + ) + ) + #expect( + !ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 96, y: 80) + ) + ) + } + + @Test + @MainActor + func horizontalScrollContentSharesOnlyAtItsLeadingEdge() { + let transcript = UIScrollView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + transcript.contentSize = CGSize(width: 120, height: 480) + #expect(ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: transcript)) + + let codeBlock = UIScrollView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + codeBlock.contentSize = CGSize(width: 480, height: 120) + codeBlock.alwaysBounceVertical = true + #expect(ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: codeBlock)) + codeBlock.contentOffset = CGPoint(x: 100, y: 0) + #expect(!ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: codeBlock)) + } + + @Test + @MainActor + func horizontalScrollAncestorsCanReceiveBackPanAtLeadingEdge() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let codeBlock = UIScrollView(frame: host.bounds) + codeBlock.contentSize = CGSize(width: 480, height: 240) + let label = UILabel(frame: .zero) + codeBlock.addSubview(label) + host.addSubview(codeBlock) + + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: label, host: host)) + codeBlock.contentOffset = CGPoint(x: 100, y: 0) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: label, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) + + let detachedHost = UIView(frame: host.bounds) + let detachedCodeBlock = UIScrollView(frame: detachedHost.bounds) + detachedCodeBlock.contentSize = CGSize(width: 480, height: 240) + let detachedLabel = UILabel(frame: .zero) + detachedCodeBlock.addSubview(detachedLabel) + detachedHost.addSubview(detachedCodeBlock) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: detachedLabel, host: host)) + } + + @Test + @MainActor + func activeTextInteractionsKeepHorizontalDrags() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let textField = UITextField(frame: .zero) + let textView = UITextView(frame: .zero) + textView.text = "Selectable transcript text" + let textViewContent = UIView(frame: .zero) + textView.addSubview(textViewContent) + host.addSubview(textField) + host.addSubview(textView) + + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textField, host: host)) + textView.isEditable = false + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + + textView.selectedRange = NSRange(location: 0, length: 1) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + + textView.selectedRange = NSRange(location: 0, length: 0) + textView.isEditable = true + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + + let window = UIWindow(frame: host.bounds) + let rootViewController = UIViewController() + window.rootViewController = rootViewController + rootViewController.view.addSubview(host) + window.makeKeyAndVisible() + textView.isEditable = false + #expect(textView.becomeFirstResponder()) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + textView.resignFirstResponder() + window.isHidden = true + + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/UsageLimitsPresentationTests.swift b/apps/swift-ios/Tests/FeatureTests/UsageLimitsPresentationTests.swift new file mode 100644 index 000000000000..7b4d2fe02aac --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/UsageLimitsPresentationTests.swift @@ -0,0 +1,261 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Subscription limits") +struct UsageLimitsPresentationTests { + @Test + func providersMustBeEnabledInstalledAndAvailable() throws { + let providers = try [ + provider(id: "ready"), + provider(id: "disabled", enabled: false), + provider(id: "missing", installed: false), + provider(id: "unavailable", available: false), + provider(id: "unsupported", limits: nil), + ] + + #expect(UsageLimitsPresentation.providersWithLimits(providers).map(\.instanceId) == ["ready"]) + } + + @Test + func nativeAccountHidesOnlyTheMatchingHubAccountAcrossEnvironments() throws { + let native = try provider(id: "codex", email: " ACCOUNT@example.com ") + let source = UsageLimitSourceSnapshot( + id: "shared-hub", + label: "Hub", + checkedAt: checkedAt, + accounts: [ + account(id: "duplicate", email: "account@EXAMPLE.com"), + account(id: "other-driver", driver: "claudeAgent", email: "account@example.com"), + account(id: "unnamed", email: nil), + ] + ) + let groups = UsageLimitsPresentation.groups([ + .init(environmentID: "left", label: "Left", providers: [native], sources: [source]), + .init(environmentID: "right", label: "Right", providers: [native], sources: [source]), + ]) + + #expect(groups.map(\.id) == ["left", "right"]) + for group in groups { + #expect(group.providers.map(\.instanceId) == ["codex"]) + #expect(group.sources.first?.accounts.map(\.id) == ["other-driver", "unnamed"]) + #expect(group.sources.first?.hiddenAccountCount == 1) + } + } + + @Test + func unusableNativeLimitsDoNotHideAUsableHubAccount() throws { + let cases: [(limits: ServerProviderUsageLimits, isConnected: Bool)] = [ + (.init(checkedAt: checkedAt, windows: []), true), + (.init(checkedAt: checkedAt, windows: [], unavailable: .init(reason: .unsupported)), true), + (.init(checkedAt: checkedAt, windows: [], unavailable: .init(reason: .probeFailed)), true), + (limits(), false), + ] + for testCase in cases { + let native = try provider(email: "account@example.com", limits: testCase.limits) + let groups = UsageLimitsPresentation.groups([ + .init( + environmentID: "native", label: "Native", providers: [native], + isConnected: testCase.isConnected + ), + .init( + environmentID: "hub", label: "Hub", + sources: [.init( + id: "hub", label: "Hub", checkedAt: checkedAt, + accounts: [account(id: "account", email: "account@example.com")] + )] + ), + ]) + + #expect(groups.last?.sources.first?.accounts.map(\.id) == ["account"]) + #expect(groups.last?.sources.first?.hiddenAccountCount == 0) + } + } + + @Test + func paceUsesFivePercentagePointTolerance() throws { + let now = try #require(ISO8601DateFormatter().date(from: checkedAt)) + let cases: [(Double, UsageLimitPace)] = [ + (44, .under), (45, .on), (50, .on), (55, .on), (56, .ahead), + ] + for (percent, expected) in cases { + let window = ServerProviderUsageWindow( + id: "session", kind: .session, label: "Session", usedPercent: percent, + resetsAt: "2026-09-05T15:00:00.000Z", windowDurationMins: 360 + ) + #expect(UsageLimitsMath.elapsedShare(window, now: now) == 0.5) + #expect(UsageLimitsMath.pace(window, now: now) == expected) + #expect(UsageLimitsMath.resetsIn(window, now: now) == "Resets in 3h 0m") + } + } + + @Test + func remainingQuotaRoundsAfterSubtractingAndClampsServerValues() { + let cases: [(Double, Double)] = [ + (0, 100), (25, 75), (25.5, 75), (89.6, 10), (100, 0), (-10, 100), (110, 0), + ] + for (used, expected) in cases { + let window = ServerProviderUsageWindow( + id: "session", kind: .session, label: "Session", usedPercent: used + ) + #expect(UsageLimitsMath.remainingPercent(window) == expected) + } + } + + @Test + func reconnectKeepsBarsUntilThatEnvironmentAnswersAndThenClearsOldSources() throws { + let previous = FeatureEnvironmentUsageLimits( + environmentID: "environment", label: "Environment", providers: [try provider()], + sources: [.init(id: "hub", label: "Hub", checkedAt: checkedAt, accounts: [])], + isConnected: false, errorMessage: "Disconnected" + ) + let pending = FeatureEnvironmentUsageLimits( + environmentID: "environment", label: "Environment", isPending: true + ) + let retained = UsageLimitsPresentation.retainingPendingRows([pending], previous: [previous]) + #expect(retained.first?.providers == previous.providers) + #expect(retained.first?.sources == previous.sources) + #expect(retained.first?.isPending == true) + #expect(retained.first?.errorMessage == nil) + + let connected = FeatureEnvironmentUsageLimits( + environmentID: "environment", label: "Environment", providers: [try provider()] + ) + let current = UsageLimitsPresentation.retainingPendingRows([connected], previous: retained) + #expect(current == [connected]) + #expect(current.first?.isConnected == true) + #expect(current.first?.sources.isEmpty == true) + } + + @Test + func unknownWindowTimingHasNoPaceAndExpiredWindowsClamp() throws { + let now = try #require(ISO8601DateFormatter().date(from: checkedAt)) + let unknown = ServerProviderUsageWindow( + id: "unknown", kind: .other, label: "Other", usedPercent: 10 + ) + #expect(UsageLimitsMath.pace(unknown, now: now) == nil) + #expect(UsageLimitsMath.elapsedShare(unknown, now: now) == nil) + #expect(UsageLimitsMath.resetsIn(unknown, now: now) == nil) + + let expired = ServerProviderUsageWindow( + id: "expired", kind: .session, label: "Session", usedPercent: 110, + resetsAt: "2026-09-05T11:00:00Z", windowDurationMins: 300 + ) + #expect(UsageLimitsMath.usedPercent(expired) == 100) + #expect(UsageLimitsMath.elapsedShare(expired, now: now) == 1) + #expect(UsageLimitsMath.resetsIn(expired, now: now) == "Resets now") + + let invalid = ServerProviderUsageWindow( + id: "invalid", kind: .session, label: "Session", usedPercent: -10, + resetsAt: "invalid", windowDurationMins: 0 + ) + #expect(UsageLimitsMath.usedPercent(invalid) == 0) + #expect(UsageLimitsMath.elapsedShare(invalid, now: now) == nil) + #expect(UsageLimitsMath.resetsIn(invalid, now: now) == nil) + } + + @Test + func unsupportedAndFailedProbesHaveDifferentNotices() { + #expect(UsageLimitsPresentation.limitsNotice(limits()) == nil) + #expect(UsageLimitsPresentation.limitsNotice(.init(checkedAt: checkedAt, windows: [])) + == "No limits reported.") + #expect(UsageLimitsPresentation.limitsNotice(.init( + checkedAt: checkedAt, windows: [], unavailable: .init(reason: .unsupported) + )) == "This account has no subscription limits.") + #expect(UsageLimitsPresentation.limitsNotice(.init( + checkedAt: checkedAt, windows: [], unavailable: .init(reason: .probeFailed) + )) == "Could not read limits.") + + let previous = limits() + let failed = ServerProviderUsageLimits( + checkedAt: checkedAt, windows: previous.windows, unavailable: .init(reason: .probeFailed) + ) + #expect(UsageLimitsPresentation.limitsNotice(failed) != nil) + #expect(UsageLimitsPresentation.visibleWindows(failed) == previous.windows) + #expect(UsageLimitsPresentation.visibleWindows(.init( + checkedAt: checkedAt, windows: previous.windows, unavailable: .init(reason: .unsupported) + )).isEmpty) + } + + @Test + func resetCreditActionBlocksRepeatSubmissionsAndKeepsOutcomes() { + var state = UsageResetCreditState() + let offline = state.begin(availableCount: 1, isConnected: false) + let noCredit = state.begin(availableCount: 0, isConnected: true) + #expect(!offline) + #expect(!noCredit) + #expect(!state.isPending) + + let began = state.begin(availableCount: 1, isConnected: true) + let duplicate = state.begin(availableCount: 1, isConnected: true) + #expect(began) + #expect(!duplicate) + #expect(state.isPending) + + state.fail(UsageCreditTestError.confirmationFailed) + #expect(!state.isPending) + #expect(state.statusMessage == "Reset applied, but the new limits could not be confirmed.") + + let outcomes: [(ProviderConsumeResetCreditOutcome, String)] = [ + (.reset, "Reset applied. Your current limits are cleared."), + (.nothingToReset, "Nothing to reset right now."), + (.noCredit, "No reset credit left."), + (.alreadyRedeemed, "That credit was already redeemed."), + ] + for (outcome, message) in outcomes { + let started = state.begin(availableCount: 1, isConnected: true) + #expect(started) + state.finish(outcome) + #expect(!state.isPending) + #expect(state.statusMessage == message) + } + } + + private var checkedAt: String { "2026-09-05T12:00:00Z" } + + private func limits() -> ServerProviderUsageLimits { + .init(checkedAt: checkedAt, windows: [ + .init(id: "session", kind: .session, label: "Session", usedPercent: 25) + ]) + } + + private func account(id: String, driver: String = "codex", email: String?) -> UsageLimitSourceAccount { + .init(id: id, driver: driver, email: email, usageLimits: limits()) + } + + private func provider( + id: String = "provider", + email: String? = nil, + enabled: Bool = true, + installed: Bool = true, + available: Bool = true, + limits: ServerProviderUsageLimits? = .init(checkedAt: "2026-09-05T12:00:00Z", windows: [ + .init(id: "session", kind: .session, label: "Session", usedPercent: 25) + ]) + ) throws -> ServerProviderSnapshot { + let value = JSONValue.object([ + "instanceId": .string(id), + "driver": .string("codex"), + "enabled": .bool(enabled), + "installed": .bool(installed), + "status": .string("ready"), + "auth": .object([ + "status": .string("authenticated"), + "email": email.map(JSONValue.string) ?? .null, + ]), + "checkedAt": .string(checkedAt), + "availability": .string(available ? "available" : "unavailable"), + "models": .array([]), + "usageLimits": try limits.map { try JSONValue.encode($0) } ?? .null, + ]) + return try JSONDecoder.t3.decode(ServerProviderSnapshot.self, from: JSONEncoder.t3.encode(value)) + } +} + +private enum UsageCreditTestError: LocalizedError { + case confirmationFailed + + var errorDescription: String? { + "Reset applied, but the new limits could not be confirmed." + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/UsageModelsTests.swift b/apps/swift-ios/Tests/FeatureTests/UsageModelsTests.swift new file mode 100644 index 000000000000..1ca440df435f --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/UsageModelsTests.swift @@ -0,0 +1,592 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Usage reporting") +struct UsageModelsTests { + @Test + func pastDayRequestsTwentyFourMinuteAlignedHourlyBuckets() throws { + let timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-18T12:34:56Z") + ) + + let input = UsageWindow.make(days: 1, now: now, timeZone: timeZone) + let since = try #require(input.sinceTime) + let until = try #require(input.untilTime) + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let sinceDate = try #require(parser.date(from: since)) + let untilDate = try #require(parser.date(from: until)) + + #expect(input.resolution == .hour) + #expect(untilDate.timeIntervalSince(sinceDate) == 24 * 60 * 60) + #expect(Calendar.current.component(.second, from: untilDate) == 0) + #expect(UsageWindow.hours(in: input).count == 24) + } + + @Test + func hourlyBucketsMergeAcrossEnvironmentsAndProviders() { + let hour = "2026-08-18T12:00:00.000Z" + let first = FeatureEnvironmentUsage( + environmentID: "a", + label: "First", + summary: summary(provider: .codex, costUsd: 2, hourStart: hour), + errorMessage: nil + ) + let second = FeatureEnvironmentUsage( + environmentID: "b", + label: "Second", + summary: summary(provider: .claude, costUsd: 3, hourStart: hour), + errorMessage: nil + ) + + let merged = UsageMerger.merge([first, second]) + + #expect(merged.hourly.count == 1) + #expect(merged.hourly[0].hourStart == hour) + #expect(merged.hourly[0].costUsd == 5) + #expect(merged.hourly[0].byProvider[.codex]?.costUsd == 2) + #expect(merged.hourly[0].byProvider[.claude]?.costUsd == 3) + } + + @Test + func mergeDoesNotCountReasoningTokensTwice() { + let report = FeatureEnvironmentUsage( + environmentID: "environment-a", + label: "Studio", + summary: summary( + provider: .codex, + costUsd: 12, + uncachedInput: 100, + cachedInput: 200, + cacheCreation: 30, + output: 40, + reasoning: 10 + ), + errorMessage: nil + ) + + let merged = UsageMerger.merge([report]) + + #expect(merged.totalTokens == 370) + #expect(merged.reasoningTokens == 10) + #expect(merged.providers.first?.totalTokens == 370) + #expect(merged.sessions == 1) + } + + @Test + func duplicateTranscriptSourcesAreCountedOnce() { + let first = FeatureEnvironmentUsage( + environmentID: "a", + label: "First", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let duplicate = FeatureEnvironmentUsage( + environmentID: "b", + label: "Second", + summary: summary(provider: .codex, costUsd: 50), + errorMessage: nil + ) + + let merged = UsageMerger.merge([duplicate, first]) + + #expect(merged.costUsd == 10) + #expect(merged.contributingEnvironments == ["a"]) + #expect(merged.duplicateSources == ["Second: /Users/theo/.codex"]) + } + + @Test + func healthySourceOwnsFingerprintInsteadOfEarlierFailedSource() { + let failed = FeatureEnvironmentUsage( + environmentID: "a", + label: "Failed", + summary: summary(provider: .codex, costUsd: 50, sourceStatus: .failed), + errorMessage: nil + ) + let healthy = FeatureEnvironmentUsage( + environmentID: "b", + label: "Healthy", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + + let merged = UsageMerger.merge([failed, healthy]) + + #expect(merged.costUsd == 10) + #expect(merged.contributingEnvironments == ["b"]) + #expect(merged.duplicateSources == ["Failed: /Users/theo/.codex"]) + } + + @Test + func staleContractsDoNotChangeTotals() { + let current = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let stale = FeatureEnvironmentUsage( + environmentID: "stale", + label: "Stale", + summary: summary(contractVersion: 2, provider: .claude, costUsd: 25), + errorMessage: nil + ) + + let merged = UsageMerger.merge([current, stale]) + + #expect(merged.costUsd == 10) + #expect(merged.staleEnvironments == ["stale"]) + } + + @Test + func calendarWindowStaysInclusiveAcrossDaylightSavingTime() throws { + let timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2024-03-10T19:00:00Z") + ) + + let window = UsageWindow.make(days: 7, now: now, timeZone: timeZone) + + #expect(window.sinceDay == "2024-03-04") + #expect(window.untilDay == "2024-03-10") + #expect(UsageWindow.days(in: window).count == 7) + } + + @Test + func refreshRecomputesTheSelectedWindowAfterMidnight() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let beforeMidnight = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T13:59:00Z") + ) + let afterMidnight = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T14:01:00Z") + ) + var state = UsageLoadState(days: 30, now: beforeMidnight, timeZone: timeZone) + let initial = state.begin(days: 30, now: beforeMidnight, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let receivedInitial = state.receive([previous], for: initial) + #expect(receivedInitial) + + let refresh = state.begin(days: 30, now: afterMidnight, timeZone: timeZone) + + #expect(refresh.input.untilDay == "2026-08-11") + #expect(refresh.input.sinceDay == "2026-07-13") + #expect(state.windowInput.untilDay == "2026-08-10") + #expect(state.merged.costUsd == 10) + + let current = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 11), + errorMessage: nil + ) + let receivedRefresh = state.receive([current], for: refresh) + #expect(receivedRefresh) + #expect(state.windowInput.untilDay == "2026-08-11") + #expect(state.merged.costUsd == 11) + } + + @Test(arguments: [7, 30, 90]) + func refreshAndRetryRecomputeEveryWindowLength(days: Int) throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let firstDay = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + let nextDay = try #require( + ISO8601DateFormatter().date(from: "2026-08-11T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: firstDay, timeZone: timeZone) + + let refresh = state.begin(days: days, now: firstDay, timeZone: timeZone) + #expect(UsageWindow.days(in: refresh.input).count == days) + let recordedRefreshFailure = state.fail(TestUsageError.unavailable, for: refresh) + #expect(recordedRefreshFailure) + + let retry = state.begin(days: days, now: nextDay, timeZone: timeZone) + #expect(UsageWindow.days(in: retry.input).count == days) + #expect(retry.input.untilDay == "2026-08-11") + #expect(state.errorMessage == nil) + } + + @Test + func failedRefreshKeepsTheLastTruthfulTotalsAndWindow() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let initial = state.begin(days: 30, now: now, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let receivedInitial = state.receive([previous], for: initial) + #expect(receivedInitial) + let truthfulWindow = state.windowInput + + let refresh = state.begin(days: 30, now: now, timeZone: timeZone) + let recordedRefreshFailure = state.fail(TestUsageError.unavailable, for: refresh) + #expect(recordedRefreshFailure) + + #expect(state.windowInput == truthfulWindow) + #expect(state.environments == [previous]) + #expect(state.merged.costUsd == 10) + #expect(state.errorMessage != nil) + } + + @Test + func staleOrCancelledLoadCannotOverwriteTheNewestLoad() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let stale = state.begin(days: 30, now: now, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "previous", + label: "Previous", + summary: summary(provider: .codex, costUsd: 30), + errorMessage: nil + ) + let receivedPrevious = state.receive([previous], for: stale) + #expect(receivedPrevious) + state.selectWindow(days: 7, now: now, timeZone: timeZone) + #expect(state.environments.isEmpty) + #expect(state.merged == MergedUsage()) + #expect(state.errorMessage == nil) + #expect(state.isLoading) + #expect(UsageWindow.days(in: state.windowInput).count == 7) + let staleResult = FeatureEnvironmentUsage( + environmentID: "stale", + label: "Stale", + summary: summary(provider: .codex, costUsd: 99), + errorMessage: nil + ) + let currentResult = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 7), + errorMessage: nil + ) + + let receivedStale = state.receive([staleResult], for: stale) + #expect(!receivedStale) + let recordedStaleFailure = state.fail(TestUsageError.unavailable, for: stale) + #expect(!recordedStaleFailure) + #expect(state.errorMessage == nil) + state.finish(stale) + #expect(state.isLoading) + + let current = state.begin(days: 7, now: now, timeZone: timeZone) + let receivedCurrent = state.receive([currentResult], for: current) + #expect(receivedCurrent) + state.finish(current) + + #expect(!state.isLoading) + #expect(state.environments == [currentResult]) + #expect(state.merged.costUsd == 7) + #expect(UsageWindow.days(in: state.windowInput).count == 7) + } + + @Test + func sameWindowOverlapOnlyLetsTheNewestLoadCommitOrFinish() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let superseded = state.begin(days: 30, now: now, timeZone: timeZone) + let current = state.begin(days: 30, now: now, timeZone: timeZone) + let result = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 30), + errorMessage: nil + ) + + let receivedSuperseded = state.receive([result], for: superseded) + #expect(!receivedSuperseded) + let recordedSupersededFailure = state.fail( + TestUsageError.unavailable, + for: superseded + ) + #expect(!recordedSupersededFailure) + state.finish(superseded) + #expect(state.isLoading) + + let receivedCurrent = state.receive([result], for: current) + #expect(receivedCurrent) + state.finish(current) + #expect(!state.isLoading) + #expect(state.environments == [result]) + } + + @Test + func partialEnvironmentFailureRemainsVisibleBesideTruthfulTotals() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let request = state.begin(days: 30, now: now, timeZone: timeZone) + let available = FeatureEnvironmentUsage( + environmentID: "available", + label: "Available", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let unavailable = FeatureEnvironmentUsage( + environmentID: "unavailable", + label: "Unavailable", + summary: nil, + errorMessage: "This environment could not report usage." + ) + + let receivedPartial = state.receive([available, unavailable], for: request) + #expect(receivedPartial) + + #expect(state.merged.costUsd == 10) + #expect(state.environments.filter { $0.errorMessage != nil } == [unavailable]) + #expect(state.errorMessage == nil) + } + + @Test + func rollingServerVersionsLoadWhenAnotherEnvironmentIsOffline() throws { + let timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-18T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let request = state.begin(days: 30, now: now, timeZone: timeZone) + let currentServer = FeatureEnvironmentUsage( + environmentID: "current-server", + label: "Current server", + summary: summary( + contractVersion: usageContractVersion, + provider: .codex, + costUsd: 10 + ), + errorMessage: nil + ) + let previousServer = FeatureEnvironmentUsage( + environmentID: "previous-server", + label: "Previous server", + summary: summary( + contractVersion: minimumCompatibleUsageContractVersion, + provider: .claude, + costUsd: 20 + ), + errorMessage: nil + ) + let offlineServer = FeatureEnvironmentUsage( + environmentID: "offline-server", + label: "Offline server", + summary: nil, + errorMessage: "This environment could not report usage." + ) + + let received = state.receive( + [currentServer, previousServer, offlineServer], + for: request + ) + #expect(received) + + #expect(state.merged.costUsd == 30) + #expect( + state.merged.contributingEnvironments == ["current-server", "previous-server"] + ) + #expect(state.merged.staleEnvironments.isEmpty) + #expect(state.environments.filter { $0.errorMessage != nil } == [offlineServer]) + } + + @Test + func grokUsageMergesWithOlderServersAndAppearsInCharts() { + let environments = [ + FeatureEnvironmentUsage( + environmentID: "new", label: "New", + summary: summary(contractVersion: 5, provider: .grok, costUsd: 15), + errorMessage: nil + ), + FeatureEnvironmentUsage( + environmentID: "older", label: "Older", + summary: summary(contractVersion: 4, provider: .codex, costUsd: 10), + errorMessage: nil + ), + FeatureEnvironmentUsage( + environmentID: "legacy", label: "Legacy", + summary: summary(contractVersion: 3, provider: .claude, costUsd: 5), + errorMessage: nil + ), + ] + let merged = UsageMerger.merge(environments) + #expect(merged.costUsd == 30) + #expect(Set(merged.providers.map(\.provider)) == [.grok, .codex, .claude]) + #expect(merged.daily.first?.byProvider[.grok]?.costUsd == 15) + #expect(merged.models.contains { $0.provider == .grok }) + #expect(merged.staleEnvironments.isEmpty) + #expect(!isCompatibleUsageContractVersion(2)) + #expect(!isCompatibleUsageContractVersion(6)) + } + + @Test + func versionThreeKeepsDailyUsageButCannotEnterHourlyTotals() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-18T12:00:00Z")) + let environment = FeatureEnvironmentUsage( + environmentID: "legacy", label: "Legacy", + summary: summary(contractVersion: 3, provider: .claude, costUsd: 20) + ) + var state = UsageLoadState(days: 1, now: now) + let hourly = state.begin(days: 1, now: now) + state.receive([environment], for: hourly) + + #expect(state.merged.costUsd == 0) + #expect(state.merged.totalTokens == 0) + #expect(state.merged.staleEnvironments == ["legacy"]) + #expect(!isCompatibleUsageContractVersion(3, resolution: .hour)) + #expect(isCompatibleUsageContractVersion(4, resolution: .hour)) + #expect(isCompatibleUsageContractVersion(5, resolution: .hour)) + + let daily = state.begin(days: 7, now: now) + state.receive([environment], for: daily) + #expect(state.merged.costUsd == 20) + #expect(state.merged.staleEnvironments.isEmpty) + #expect(isCompatibleUsageContractVersion(3, resolution: .day)) + } + + @Test + func firstEnvironmentRendersWhileAnotherIsStillPending() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-18T12:00:00Z")) + var state = UsageLoadState(now: now) + let request = state.begin(days: 30, now: now) + let pending = FeatureEnvironmentUsage( + environmentID: "slow", label: "Slow", summary: nil, isPending: true + ) + let ready = FeatureEnvironmentUsage( + environmentID: "ready", label: "Ready", summary: summary(provider: .codex, costUsd: 10) + ) + + state.receive([ready, pending], for: request) + #expect(state.merged.costUsd == 10) + #expect(state.isLoading) + #expect(state.isPartial) + #expect(!state.hasPendingCachedTotals) + + state.receive([ + ready, + .init(environmentID: "slow", label: "Slow", summary: nil, errorMessage: "Offline") + ], for: request) + state.finish(request) + #expect(state.merged.costUsd == 10) + #expect(!state.isLoading) + #expect(!state.isPartial) + #expect(state.environments.last?.errorMessage == "Offline") + } + + @Test + func pendingRefreshRetainsOnlyTheSameWindowsLastScan() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-18T12:00:00Z")) + let timeZone = try #require(TimeZone(identifier: "UTC")) + var state = UsageLoadState(now: now, timeZone: timeZone) + let initial = state.begin(days: 30, now: now, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "current", label: "Current", summary: summary(provider: .codex, costUsd: 10) + ) + state.receive([previous], for: initial) + state.finish(initial) + let pending = FeatureEnvironmentUsage( + environmentID: "current", label: "Current", summary: nil, isPending: true + ) + + let refresh = state.begin(days: 30, now: now, timeZone: timeZone) + state.receive([pending], for: refresh) + #expect(state.merged.costUsd == 10) + #expect(state.environments.first?.isPending == true) + #expect(state.hasPendingCachedTotals) + #expect(!state.isPartial) + + let nextDay = state.begin(days: 30, now: now.addingTimeInterval(24 * 60 * 60), timeZone: timeZone) + state.receive([pending], for: nextDay) + #expect(state.merged.costUsd == 0) + #expect(state.environments.first?.summary == nil) + #expect(!state.hasPendingCachedTotals) + } + + private func summary( + contractVersion: Int = usageContractVersion, + provider: UsageProviderKind, + costUsd: Double, + uncachedInput: Int = 100, + cachedInput: Int = 0, + cacheCreation: Int = 0, + output: Int = 20, + reasoning: Int = 0, + sourceStatus: UsageSourceStatus = .ok, + hourStart: String? = nil + ) -> UsageSummary { + let path = "/Users/theo/.\(provider.rawValue)" + return UsageSummary( + contractVersion: contractVersion, + readAt: "2026-08-09T12:00:00.000Z", + timeZone: "America/Los_Angeles", + sinceDay: "2026-08-03", + untilDay: "2026-08-09", + buckets: [ + UsageBucket( + day: "2026-08-09", + hourStart: hourStart, + provider: provider, + model: "\(provider.rawValue)-test-model", + totals: UsageTokenTotals( + uncachedInputTokens: uncachedInput, + cachedInputTokens: cachedInput, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning + ), + costUsd: costUsd, + cacheSavingsUsd: 0, + costSource: .modelPriced, + records: 1, + unpricedRecords: 0, + sessions: 1 + ), + ], + sources: [ + UsageSource( + fingerprint: UsageSourceFingerprint( + hostId: "host", + provider: provider, + resolvedHomePath: path, + volumeId: "1:2" + ), + status: sourceStatus, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: nil + ), + ], + pricing: UsagePricing( + status: .fresh, + source: "LiteLLM", + fetchedAt: nil, + knownModels: 1 + ), + scanDurationMs: 1 + ) + } +} + +private enum TestUsageError: Error { + case unavailable +} diff --git a/apps/swift-ios/Tests/FeatureTests/UserInputAnswerTests.swift b/apps/swift-ios/Tests/FeatureTests/UserInputAnswerTests.swift new file mode 100644 index 000000000000..354e7a4dbfc3 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/UserInputAnswerTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("User input answers") +struct UserInputAnswerTests { + @Test + func testCodableShapeMatchesProviderWireValues() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let textData = try encoder.encode(FeatureInputAnswer.text("Deploy")) + let selectionsData = try encoder.encode( + FeatureInputAnswer.selections(["Server", "Web"]) + ) + + #expect(try decoder.decode(JSONValue.self, from: textData) == .string("Deploy")) + #expect( + try decoder.decode(JSONValue.self, from: selectionsData) + == .array([.string("Server"), .string("Web")]) + ) + #expect(try decoder.decode(FeatureInputAnswer.self, from: textData) == .text("Deploy")) + #expect( + try decoder.decode(FeatureInputAnswer.self, from: selectionsData) + == .selections(["Server", "Web"]) + ) + } + + @Test + func testNativeJSONMappingPreservesStringAndArrayTypes() { + #expect(FeatureInputAnswer.text("Deploy").jsonValue == .string("Deploy")) + #expect( + FeatureInputAnswer.selections(["Server", "Web"]).jsonValue + == .array([.string("Server"), .string("Web")]) + ) + } + + @Test + func testMultiSelectTogglesWithoutFlatteningSelections() { + let first = FeatureInputAnswer.selections([]) + .togglingOption("Server", allowsMultiple: true) + let second = first.togglingOption("Web", allowsMultiple: true) + let deselected = second.togglingOption("Server", allowsMultiple: true) + + #expect(first == .selections(["Server"])) + #expect(second == .selections(["Server", "Web"])) + #expect(deselected == .selections(["Web"])) + #expect( + second.togglingOption("CLI", allowsMultiple: false) + == .text("CLI") + ) + } + + @Test + func testAnswersNormalizeBeforeSubmission() { + #expect(FeatureInputAnswer.text(" ship it ").normalized == .text("ship it")) + #expect( + FeatureInputAnswer.selections([" Server ", "", "Server", "Web"]).normalized + == .selections(["Server", "Web"]) + ) + #expect(FeatureInputAnswer.text(" ").normalized == nil) + #expect(FeatureInputAnswer.selections([]).normalized == nil) + } + + @Test + func testMultiSelectCustomTextStaysInTheSelectionArray() { + let question = FeatureInputQuestion( + id: "surfaces", + header: "Surfaces", + question: "Where should this ship?", + options: [ + .init(label: "Server", detail: "Backend"), + .init(label: "Web", detail: "Browser"), + ], + allowsMultiple: true + ) + let selected = FeatureInputAnswer.selections(["Server"]) + let withCustom = FeatureComposerCustomAnswer.replacingText( + in: selected, + with: "CLI", + for: question + ) + + #expect(withCustom == .selections(["Server", "CLI"])) + #expect(FeatureComposerCustomAnswer.text(in: withCustom, for: question) == "CLI") + #expect( + FeatureComposerCustomAnswer.replacingText( + in: withCustom, + with: "", + for: question + ) == .selections(["Server"]) + ) + } +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/shell-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/shell-snapshot.json new file mode 100644 index 000000000000..335a5fc88a40 --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/shell-snapshot.json @@ -0,0 +1,70 @@ +{ + "snapshotSequence": 42, + "projects": [ + { + "id": "project-fixture", + "title": "Fixture project", + "workspaceRoot": "/workspace/fixture", + "repositoryIdentity": null, + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "scripts": [], + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "threads": [ + { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "branchPullRequest": { + "projectId": "project-fixture", + "repository": "fixture/repository", + "number": 42, + "url": "https://example.com/fixture/repository/pull/42" + }, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "activeOrderKey": "nm", + "titleRegeneration": null, + "session": null, + "latestUserMessageAt": "2026-08-07T12:00:00.000Z", + "hasPendingApprovals": false, + "hasPendingUserInput": false, + "hasActionableProposedPlan": false, + "backgroundLiveness": null, + "planProgress": null + } + ], + "updatedAt": "2026-08-07T12:00:00.000Z" +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/shell-stream-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/shell-stream-snapshot.json new file mode 100644 index 000000000000..735514bb31c7 --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/shell-stream-snapshot.json @@ -0,0 +1,73 @@ +{ + "kind": "snapshot", + "snapshot": { + "snapshotSequence": 42, + "projects": [ + { + "id": "project-fixture", + "title": "Fixture project", + "workspaceRoot": "/workspace/fixture", + "repositoryIdentity": null, + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "scripts": [], + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "threads": [ + { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "branchPullRequest": { + "projectId": "project-fixture", + "repository": "fixture/repository", + "number": 42, + "url": "https://example.com/fixture/repository/pull/42" + }, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "activeOrderKey": "nm", + "titleRegeneration": null, + "session": null, + "latestUserMessageAt": "2026-08-07T12:00:00.000Z", + "hasPendingApprovals": false, + "hasPendingUserInput": false, + "hasActionableProposedPlan": false, + "backgroundLiveness": null, + "planProgress": null + } + ], + "updatedAt": "2026-08-07T12:00:00.000Z" + } +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/thread-detail-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/thread-detail-snapshot.json new file mode 100644 index 000000000000..3448cea13ed5 --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/thread-detail-snapshot.json @@ -0,0 +1,62 @@ +{ + "snapshotSequence": 42, + "thread": { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "branchPullRequest": { + "projectId": "project-fixture", + "repository": "fixture/repository", + "number": 42, + "url": "https://example.com/fixture/repository/pull/42" + }, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "activeOrderKey": "nm", + "titleRegeneration": null, + "deletedAt": null, + "messages": [ + { + "id": "message-fixture", + "role": "user", + "text": "Verify the native wire contract", + "attachments": [], + "turnId": "turn-fixture", + "streaming": false, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "proposedPlans": [], + "activities": [], + "checkpoints": [], + "session": null + }, + "page": { + "beforeCursor": "fixture-cursor", + "hasMore": true, + "snapshotSequence": 42, + "threadSequence": 40 + } +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/thread-stream-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/thread-stream-snapshot.json new file mode 100644 index 000000000000..9b1a09972348 --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/thread-stream-snapshot.json @@ -0,0 +1,65 @@ +{ + "kind": "snapshot", + "snapshot": { + "snapshotSequence": 42, + "thread": { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "branchPullRequest": { + "projectId": "project-fixture", + "repository": "fixture/repository", + "number": 42, + "url": "https://example.com/fixture/repository/pull/42" + }, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "activeOrderKey": "nm", + "titleRegeneration": null, + "deletedAt": null, + "messages": [ + { + "id": "message-fixture", + "role": "user", + "text": "Verify the native wire contract", + "attachments": [], + "turnId": "turn-fixture", + "streaming": false, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "proposedPlans": [], + "activities": [], + "checkpoints": [], + "session": null + }, + "page": { + "beforeCursor": "fixture-cursor", + "hasMore": true, + "snapshotSequence": 42, + "threadSequence": 40 + } + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformAgentAwarenessTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformAgentAwarenessTests.swift new file mode 100644 index 000000000000..a8f7659847da --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformAgentAwarenessTests.swift @@ -0,0 +1,326 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Agent awareness projection") +struct PlatformAgentAwarenessTests { + @Test + func settingsDefaultToSystemAppearanceAndRoundTripLightMode() throws { + #expect(FeatureSettings().appearance == .system) + + var settings = FeatureSettings() + settings.appearance = .light + let roundTrip = try JSONDecoder.t3.decode( + FeatureSettings.self, + from: JSONEncoder.t3.encode(settings) + ) + + #expect(roundTrip.appearance == .light) + } + + @Test + func legacySettingsEnableLiveActivitiesWithoutResettingOtherPreferences() throws { + let legacy = Data( + #"{"appearance":"system","hapticsEnabled":false,"notificationsEnabled":false,"autoSettleOnMerge":false,"autoSettleAfterDays":14}"#.utf8 + ) + let decoded = try JSONDecoder.t3.decode(FeatureSettings.self, from: legacy) + + #expect(decoded.appearance == .system) + #expect(!decoded.hapticsEnabled) + #expect(!decoded.notificationsEnabled) + #expect(decoded.liveActivitiesEnabled) + + var disabled = decoded + disabled.liveActivitiesEnabled = false + let encoded = try JSONEncoder.t3.encode(disabled) + let roundTrip = try JSONDecoder.t3.decode(FeatureSettings.self, from: encoded) + #expect(!roundTrip.liveActivitiesEnabled) + let encodedSettings = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + #expect(encodedSettings["autoSettleOnMerge"] == nil) + #expect(encodedSettings["autoSettleAfterDays"] == nil) + } + + @Test + func ranksAttentionThenFailuresThenWorkAndDropsOldTerminalRows() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let project = FeatureProject( + id: "project", + wireID: "project-wire", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let snapshot = FeatureSnapshot( + projects: [project], + threads: [ + Self.thread( + id: "working", + state: .working, + updatedAt: now.addingTimeInterval(-10) + ), + Self.thread( + id: "approval", + state: .waitingForApproval, + updatedAt: now.addingTimeInterval(-5) + ), + Self.thread( + id: "failure", + state: .failed, + updatedAt: now.addingTimeInterval(-20) + ), + Self.thread( + id: "old-complete", + state: .completed, + updatedAt: now.addingTimeInterval(-3_600) + ), + ], + providersByEnvironment: [ + "environment": [ + FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "claude-opus-5", name: "Opus 5")] + ), + ], + ] + ) + + let aggregate = PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: now + ) + + #expect(aggregate.activeCount == 2) + #expect(aggregate.subtitle == "1 task needs attention") + #expect(aggregate.activities.map(\.threadId) == ["approval", "failure", "working"]) + #expect(aggregate.activities.first?.modelTitle == "Opus 5") + #expect( + aggregate.activities.first?.nativeDeepLinkURL?.scheme + == PlatformRoute.nativeScheme + ) + } + + @Test + func widgetSnapshotUsesTheSameBoundedRowsAsTheLiveActivity() { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let project = FeatureProject( + id: "project", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let threads = (0..<8).map { index in + Self.thread( + id: "thread-\(index)", + state: .working, + updatedAt: now.addingTimeInterval(TimeInterval(-index)) + ) + } + let snapshot = FeatureSnapshot(projects: [project], threads: threads) + + let aggregate = PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: now + ) + let widget = PlatformAgentAwarenessProjection.widgetSnapshot( + snapshot: snapshot, + now: now + ) + + #expect(aggregate.activities.count == PlatformAgentAwarenessProjection.maximumRows) + #expect(widget.tasks == aggregate.activities) + #expect(widget.updatedAt == aggregate.updatedAt) + } + + @Test + func terminalRowsExposeTheirExpiryAndDisappearAtTheBoundary() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let updatedAt = now.addingTimeInterval(-60) + let expiry = updatedAt.addingTimeInterval( + PlatformAgentAwarenessProjection.terminalVisibilityWindow + ) + let project = FeatureProject( + id: "project", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let snapshot = FeatureSnapshot( + projects: [project], + threads: [Self.thread(id: "done", state: .completed, updatedAt: updatedAt)] + ) + + #expect( + PlatformAgentAwarenessProjection.nextTerminalExpiry( + snapshot: snapshot, + now: now + ) == expiry + ) + #expect( + PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: expiry.addingTimeInterval(-0.001) + ).activities.count == 1 + ) + #expect( + PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: expiry + ).activities.isEmpty + ) + } + + @Test + @MainActor + func signOutEndsActivitiesWithoutRepublishingTheCachedProjection() async { + let recorder = PlatformAgentAwarenessOperationRecorder() + let coordinator = PlatformAgentAwarenessCoordinator( + updateLiveActivity: { _, _, _ in + recorder.recordUpdate() + }, + endLiveActivities: { + recorder.recordEnd() + } + ) + + coordinator.synchronize(snapshot: FeatureSnapshot(), liveActivitiesEnabled: true) + await recorder.waitForUpdateCount(1) + + coordinator.resetAndResynchronizeLiveActivity() + await recorder.waitForEndCount(1) + await Task.yield() + + #expect(recorder.updateCount == 1) + #expect(recorder.endCount == 1) + } + + @Test + @MainActor + func latestSnapshotCancelsAnInFlightReversionToOlderState() async { + let recorder = GatedPlatformAgentAwarenessRecorder() + let coordinator = PlatformAgentAwarenessCoordinator( + updateLiveActivity: { _, _, _ in + await recorder.recordUpdate() + }, + endLiveActivities: {} + ) + let ready = FeatureSnapshot() + let project = FeatureProject( + id: "project", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let working = FeatureSnapshot( + projects: [project], + threads: [Self.thread(id: "working", state: .working, updatedAt: .now)] + ) + + coordinator.synchronize(snapshot: ready, liveActivitiesEnabled: true) + await recorder.waitForUpdateCount(1) + await Task.yield() + + coordinator.synchronize(snapshot: working, liveActivitiesEnabled: true) + await recorder.waitForUpdateCount(2) + coordinator.synchronize(snapshot: ready, liveActivitiesEnabled: true) + recorder.releaseSecondUpdate() + await Task.yield() + await Task.yield() + + coordinator.synchronize(snapshot: ready, liveActivitiesEnabled: true) + await Task.yield() + #expect(recorder.updateCount == 2) + } + + private static func thread( + id: String, + state: FeatureThreadState, + updatedAt: Date + ) -> FeatureThread { + FeatureThread( + id: id, + wireID: id, + projectID: "project", + environmentID: "environment", + title: "Task \(id)", + updatedAt: updatedAt, + state: state, + providerID: "claude", + modelID: "claude-opus-5" + ) + } +} + +@MainActor +private final class GatedPlatformAgentAwarenessRecorder { + private(set) var updateCount = 0 + private var secondUpdateContinuation: CheckedContinuation? + private var updateWaiters: [(Int, CheckedContinuation)] = [] + + func recordUpdate() async { + updateCount += 1 + let ready = updateWaiters.filter { updateCount >= $0.0 } + updateWaiters.removeAll { updateCount >= $0.0 } + ready.forEach { $0.1.resume() } + if updateCount == 2 { + await withCheckedContinuation { continuation in + secondUpdateContinuation = continuation + } + } + } + + func waitForUpdateCount(_ count: Int) async { + guard updateCount < count else { return } + await withCheckedContinuation { continuation in + updateWaiters.append((count, continuation)) + } + } + + func releaseSecondUpdate() { + secondUpdateContinuation?.resume() + secondUpdateContinuation = nil + } +} + +@MainActor +private final class PlatformAgentAwarenessOperationRecorder { + private(set) var updateCount = 0 + private(set) var endCount = 0 + private var updateWaiters: [(Int, CheckedContinuation)] = [] + private var endWaiters: [(Int, CheckedContinuation)] = [] + + func recordUpdate() { + updateCount += 1 + resumeReadyWaiters(&updateWaiters, count: updateCount) + } + + func recordEnd() { + endCount += 1 + resumeReadyWaiters(&endWaiters, count: endCount) + } + + func waitForUpdateCount(_ count: Int) async { + guard updateCount < count else { return } + await withCheckedContinuation { continuation in + updateWaiters.append((count, continuation)) + } + } + + func waitForEndCount(_ count: Int) async { + guard endCount < count else { return } + await withCheckedContinuation { continuation in + endWaiters.append((count, continuation)) + } + } + + private func resumeReadyWaiters( + _ waiters: inout [(Int, CheckedContinuation)], + count: Int + ) { + let ready = waiters.filter { count >= $0.0 } + waiters.removeAll { count >= $0.0 } + ready.forEach { $0.1.resume() } + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformBackgroundRefreshTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformBackgroundRefreshTests.swift new file mode 100644 index 000000000000..357a5904e9b3 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformBackgroundRefreshTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Background refresh") +struct PlatformBackgroundRefreshTests { + @Test + @MainActor + func usesThePermittedIdentifierAndAConservativeRetryWindow() { + #expect( + PlatformBackgroundRefreshCoordinator.identifier + == "\(Bundle.main.bundleIdentifier ?? "com.t3tools.t3code.swiftui").refresh" + ) + #expect(PlatformBackgroundRefreshPolicy.minimumDelay == 15 * 60) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformCloudDeliveryTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformCloudDeliveryTests.swift new file mode 100644 index 000000000000..86c74aa643a2 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformCloudDeliveryTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Cloud delivery registration") +struct PlatformCloudDeliveryTests { + @Test + func installationIdentityIsStableWithinAnInstall() throws { + let suiteName = "cloud-delivery-\(UUID())" + let suite = try #require(UserDefaults(suiteName: suiteName)) + defer { suite.removePersistentDomain(forName: suiteName) } + + let first = PlatformInstallationIdentity.value(defaults: suite) + let second = PlatformInstallationIdentity.value(defaults: suite) + + #expect(!first.isEmpty) + #expect(first == second) + } + + @Test + func registrationCarriesRoutingAndUserPreferences() { + var settings = FeatureSettings() + settings.notificationsEnabled = false + settings.liveActivitiesEnabled = true + + let registration = PlatformCloudDeliveryRegistrationFactory.registration( + deviceID: "device-1", + deviceName: "Big O", + systemVersion: OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0), + appVersion: "1.2.3", + bundleID: "com.t3tools.t3code.swiftui", + pushToken: "apns-token", + pushToStartToken: "activity-token", + settings: settings, + apsEnvironment: .sandbox + ) + + #expect(registration.platform == "ios") + #expect(registration.iosMajorVersion == 26) + #expect(registration.bundleId == "com.t3tools.t3code.swiftui") + #expect(registration.apsEnvironment == .sandbox) + #expect(registration.pushToken == "apns-token") + #expect(registration.pushToStartToken == "activity-token") + #expect(!registration.preferences.notificationsEnabled) + #expect(registration.preferences.liveActivitiesEnabled) + } + + @Test + @MainActor + func installingNewControllerReleasesPreviousController() throws { + let suiteName = "cloud-delivery-controller-\(UUID())" + let suite = try #require(UserDefaults(suiteName: suiteName)) + defer { suite.removePersistentDomain(forName: suiteName) } + suite.set("existing-registration", forKey: "swift-ios.cloud-delivery-device.v1") + + let coordinator = PlatformCloudDeliveryCoordinator( + defaults: suite, + deviceID: "test-device" + ) + let currentController = T3ConnectController( + resolution: .unavailable(reason: "Authentication is not needed for this test.") + ) + weak var previousController: T3ConnectController? + + do { + let controller = T3ConnectController( + resolution: .unavailable(reason: "Authentication is not needed for this test.") + ) + previousController = controller + coordinator.install(controller: controller) + #expect( + suite.string(forKey: "swift-ios.cloud-delivery-device.v1") + == "existing-registration" + ) + coordinator.install(controller: currentController) + } + + #expect(previousController == nil) + #expect( + suite.string(forKey: "swift-ios.cloud-delivery-device.v1") + == "existing-registration" + ) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift new file mode 100644 index 000000000000..576836f75522 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift @@ -0,0 +1,317 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Platform deep links") +struct PlatformDeepLinkTests { + @Test + func parsesWidgetThreadRoute() throws { + let route = try PlatformDeepLinkParser.parse( + "t3code://threads/environment-1/thread-7" + ) + + #expect(route == .thread(environmentID: "environment-1", threadID: "thread-7")) + } + + @Test + func parsesProjectAndEnvironmentQueryRoutes() throws { + #expect( + try PlatformDeepLinkParser.parse("t3code://projects/project-7?environment=environment-1") + == .project(environmentID: "environment-1", projectID: "project-7") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code://environments/environment-2") + == .environment(id: "environment-2") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code://new-task?environment=environment-2&project=project-8") + == .newTask(environmentID: "environment-2", projectID: "project-8") + ) + } + + @Test + func unwrapsPairingURL() throws { + let route = try PlatformDeepLinkParser.parse( + "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3DPAIR" + ) + + #expect(route == .connection(endpoint: "https://remote.example.com", token: "PAIR")) + } + + @Test + func parsesTrustedWebThreadRoute() throws { + let route = try PlatformDeepLinkParser.parse( + "https://app.t3.codes/environment-1/thread-7" + ) + + #expect(route == .thread(environmentID: "environment-1", threadID: "thread-7")) + } + + @Test + func rejectsUntrustedWebNavigationRoute() { + #expect(throws: PlatformDeepLinkError.unsupportedURL) { + try PlatformDeepLinkParser.parse("https://malicious.example/threads/env/thread") + } + } + + @Test + func rejectsConnectionParametersFromUntrustedWebHosts() { + #expect(throws: PlatformDeepLinkError.unsupportedURL) { + try PlatformDeepLinkParser.parse( + "https://malicious.example/connect?endpoint=https%3A%2F%2Fattacker.example&token=x" + ) + } + } + + @Test + func routeURLsRoundTrip() throws { + let routes: [PlatformRoute] = [ + .environment(id: "environment 1"), + .project(environmentID: "environment 1", projectID: "project/1"), + .thread(environmentID: "environment 1", threadID: "thread 1"), + .newTask(environmentID: "environment 1", projectID: "project 1"), + .connection(endpoint: "https://remote.example.com", token: "PAIR"), + ] + + for route in routes { + let url = try #require(route.url) + #expect(url.scheme == PlatformRoute.nativeScheme) + let parsed = try PlatformDeepLinkParser.parse(url) + #expect(parsed == route, "Failed to round-trip \(route) through \(url.absoluteString)") + } + } + + @Test + func acceptsLinksFromBothSwiftUIIdentitiesAndLegacyRoutes() throws { + #expect( + try PlatformDeepLinkParser.parse("t3code-swiftui://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code-swiftui-dev://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + #expect( + try PlatformDeepLinkParser.parse("t3://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + } + + @Test + func clerkCallbackUsesCurrentAppIdentity() { + #expect(T3ConnectAuthCallback.scheme == PlatformRoute.nativeScheme) + #expect( + T3ConnectAuthCallback.redirectURL + == "\(PlatformRoute.nativeScheme)://clerk-callback" + ) + } + + @Test + func mailboxConsumesExactlyOnce() throws { + let suiteName = "PlatformDeepLinkTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let mailbox = PlatformRouteMailbox(defaults: defaults, key: "pending") + let route = PlatformRoute.thread(environmentID: "env", threadID: "thread") + + mailbox.put(route) + + #expect(mailbox.peek() == route) + #expect(mailbox.take() == route) + #expect(mailbox.take() == nil) + } + + @Test + func opensNativeThreadLinksInsideTheApp() throws { + let snapshot = Self.linkedSnapshot() + let expected = PlatformRoute.thread(environmentID: "environment-1", threadID: "thread-7") + + for link in [ + "\(PlatformRoute.nativeScheme)://threads/environment-1/thread-7", + "t3code://threads/environment-1/thread-7", + "t3://threads/environment-1/thread-7", + "https://app.t3.codes/environment-1/thread-7", + "https://app.t3.codes/threads/thread-7?environment=environment-1", + ] { + let url = try #require(URL(string: link)) + #expect( + PlatformInAppLinkRouter.route(for: url, in: snapshot) == expected, + "Expected \(link) to open in the app" + ) + } + } + + @Test + func opensThreadLinksByWireIdentifierWithoutAnEnvironment() throws { + let snapshot = Self.linkedSnapshot() + let url = try #require(URL(string: "t3code://threads/wire-thread-7")) + + #expect( + PlatformInAppLinkRouter.route(for: url, in: snapshot) + == .thread(environmentID: nil, threadID: "wire-thread-7") + ) + } + + @Test + func leavesLinksThisDeviceCannotShowToTheSystem() throws { + let snapshot = Self.linkedSnapshot() + + for link in [ + // Nothing on this device matches the destination. + "t3code://threads/environment-1/thread-missing", + "t3code://threads/environment-missing/thread-7", + "t3code://projects/environment-1/project-missing", + "t3code://environments/environment-missing", + "t3code://new-task?environment=environment-1&project=project-missing", + // Trusted web pages that are not thread destinations. + "https://app.t3.codes/docs/getting-started", + "https://app.t3.codes/settings", + // Ordinary links inside message content. + "https://example.com/environment-1/thread-7", + "mailto:someone@example.com", + ] { + let url = try #require(URL(string: link)) + #expect( + PlatformInAppLinkRouter.route(for: url, in: snapshot) == nil, + "Expected \(link) to keep its system behavior" + ) + } + } + + @Test + func leavesPairingLinksToOnboarding() throws { + let snapshot = Self.linkedSnapshot() + let url = try #require( + URL(string: "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3DPAIR") + ) + + #expect(PlatformInAppLinkRouter.route(for: url, in: snapshot) == nil) + } + + @Test + func opensProjectEnvironmentAndNewTaskLinksInsideTheApp() throws { + let snapshot = Self.linkedSnapshot() + + let project = try #require(URL(string: "t3code://projects/environment-1/project-3")) + #expect( + PlatformInAppLinkRouter.route(for: project, in: snapshot) + == .project(environmentID: "environment-1", projectID: "project-3") + ) + + let environment = try #require(URL(string: "t3code://environments/environment-1")) + #expect( + PlatformInAppLinkRouter.route(for: environment, in: snapshot) + == .environment(id: "environment-1") + ) + + let newTask = try #require( + URL(string: "t3code://new-task?environment=environment-1&project=project-3") + ) + #expect( + PlatformInAppLinkRouter.route(for: newTask, in: snapshot) + == .newTask(environmentID: "environment-1", projectID: "project-3") + ) + } + + private static func linkedSnapshot() -> FeatureSnapshot { + let environment = FeatureEnvironment( + id: "environment-1", + name: "Environment 1", + endpoint: "https://environment-1.example", + isActive: true + ) + let project = FeatureProject( + id: "project-3", + wireID: "wire-project-3", + environmentID: environment.id, + name: "Project 3", + path: "/project-3" + ) + let thread = FeatureThread( + id: "thread-7", + wireID: "wire-thread-7", + projectID: project.id, + environmentID: environment.id, + title: "Thread 7" + ) + return FeatureSnapshot( + environments: [environment], + projects: [project], + threads: [thread] + ) + } + + @Test + func resolverRequiresEnvironmentForDuplicateWireIDs() throws { + let active = FeatureEnvironment( + id: "active", + name: "Active", + endpoint: "https://active.example", + isActive: true + ) + let passive = FeatureEnvironment( + id: "passive", + name: "Passive", + endpoint: "https://passive.example" + ) + let activeProject = FeatureProject( + id: "project-active", + wireID: "shared-project", + environmentID: active.id, + name: "Active project", + path: "/active" + ) + let passiveProject = FeatureProject( + id: "project-passive", + wireID: "shared-project", + environmentID: passive.id, + name: "Passive project", + path: "/passive" + ) + let activeThread = FeatureThread( + id: "thread-active", + wireID: "shared-thread", + projectID: activeProject.id, + environmentID: active.id, + title: "Active thread" + ) + let passiveThread = FeatureThread( + id: "thread-passive", + wireID: "shared-thread", + projectID: passiveProject.id, + environmentID: passive.id, + title: "Passive thread" + ) + let snapshot = FeatureSnapshot( + environments: [active, passive], + projects: [passiveProject, activeProject], + threads: [passiveThread, activeThread] + ) + + #expect( + PlatformRouteResolver.thread( + in: snapshot, + environmentID: nil, + id: "shared-thread" + ) == nil + ) + #expect( + PlatformRouteResolver.thread( + in: snapshot, + environmentID: passive.id, + id: "shared-thread" + )?.id == passiveThread.id + ) + #expect( + PlatformRouteResolver.project( + in: snapshot, + environmentID: passive.id, + id: "shared-project" + )?.id == passiveProject.id + ) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift new file mode 100644 index 000000000000..eb71e845b2bf --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Platform feedback") +struct PlatformFeedbackTests { + @Test + func initialSnapshotDoesNotEmitSignals() { + let current = [thread(id: "one", state: .waitingForApproval)] + + #expect(PlatformThreadTransitionClassifier.signals(previous: nil, current: current).isEmpty) + } + + @Test + func classifiesAttentionFailureAndCompletionTransitions() { + let previous: [String: FeatureThreadState] = [ + "approval": .working, + "failure": .working, + "complete": .working, + "monitor-complete": .monitoring, + "idle-complete": .idle, + ] + let current = [ + thread(id: "approval", state: .waitingForApproval), + thread(id: "failure", state: .failed), + thread(id: "complete", state: .completed), + thread(id: "monitor-complete", state: .completed), + thread(id: "idle-complete", state: .completed), + ] + + let signals = PlatformThreadTransitionClassifier.signals(previous: previous, current: current) + + #expect(signals.map(\.thread.id) == ["approval", "failure", "complete", "monitor-complete"]) + #expect(signals.map(\.kind) == [.warning, .error, .success, .success]) + } + + @Test + func notificationPayloadSupportsURLAndServerFields() { + let routeFromURL = PlatformNotificationPayload.route(from: [ + "deep_link": "t3code://threads/environment/thread", + ]) + let routeFromFields = PlatformNotificationPayload.route(from: [ + "environmentId": "environment", + "threadId": "thread", + ]) + + #expect(routeFromURL == .thread(environmentID: "environment", threadID: "thread")) + #expect(routeFromFields == .thread(environmentID: "environment", threadID: "thread")) + } + + @Test + func recentThreadStoreSortsLimitsAndSkipsArchived() throws { + let suiteName = "PlatformFeedbackTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = PlatformRecentThreadStore(defaults: defaults, key: "recent") + var threads = (0 ..< 14).map { index in + thread( + id: "thread-\(index)", + state: .idle, + updatedAt: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + threads[13].isArchived = true + + store.update(from: threads) + let records = store.records() + + #expect(records.count == 12) + #expect(records.first?.id == "thread-12") + #expect(!records.contains { $0.id == "thread-13" }) + } + + private func thread( + id: String, + state: FeatureThreadState, + updatedAt: Date = .now + ) -> FeatureThread { + FeatureThread( + id: id, + wireID: "wire-\(id)", + projectID: "project", + environmentID: "environment", + title: "Thread \(id)", + updatedAt: updatedAt, + state: state + ) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift new file mode 100644 index 000000000000..730d97109a52 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift @@ -0,0 +1,448 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Incoming share import") +struct PlatformIncomingShareTests { + @Test + func decodesSchemaOneImageEnvelopeWithoutFiles() throws { + let json = #"{"schemaVersion":1,"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","createdAt":"1970-01-01T00:01:40Z","text":"old","images":[{"id":"12345678-1234-1234-1234-123456789abc","fileName":"reference.png","typeIdentifier":"public.png","relativePath":"image.png","byteCount":2}],"warnings":[]}"# + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let envelope = try decoder.decode(T3IncomingShareEnvelope.self, from: Data(json.utf8)) + + #expect(envelope.schemaVersion == 1) + #expect(envelope.images.count == 1) + #expect(envelope.files.isEmpty) + } + + @Test + func rejectsSharedFilePathOutsideTheInbox() throws { + let root = URL(fileURLWithPath: "/tmp/t3-share-root", isDirectory: true) + + #expect(T3IncomingShareStore.fileURL( + relativePath: "../outside.txt", + rootURL: root + ) == nil) + } + + @Test + func genericFileImportRetriesWithoutReplacingTheOwnedCopy() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let sourceURL = directory.appendingPathComponent("report.txt") + try Data("report".utf8).write(to: sourceURL) + let attachmentID = try #require(UUID(uuidString: "12345678-1234-1234-1234-123456789abc")) + let envelope = Self.envelope(files: [Self.file( + id: attachmentID.uuidString, + byteCount: 6 + )]) + let recorder = IncomingShareTestRecorder() + let ownedRoot = directory.appendingPathComponent("owned", isDirectory: true) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in await recorder.record("remove") }, + fileURL: { _ in sourceURL } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, attachments, _, _ in + await recorder.record("import") + if await recorder.events.count == 1 { + throw IncomingShareTestError.saveFailed + } + return FeatureComposerDraft(attachments: attachments) + } + ), + attachmentFileStore: ManagedAttachmentFileStore(rootURL: ownedRoot) + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected the first draft write to fail") + } catch { + #expect(error as? IncomingShareTestError == .saveFailed) + } + let draft = try await pipeline.importEnvelope(envelope, into: Self.project()) + + #expect(draft.attachments.first?.id == attachmentID) + #expect(draft.attachments.first?.ownedFile?.byteCount == 6) + #expect(await recorder.events == ["import", "import", "remove"]) + } + + @Test + func persistsMergedDraftBeforeRemovingInboxEnvelope() async throws { + let recorder = IncomingShareTestRecorder() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let selection = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-sol") + let workspace = FeatureComposerWorkspaceDraft( + mode: .worktree, + branch: "main", + worktreePath: nil, + startFromOrigin: true + ) + let existingAttachment = Self.attachment(id: UUID(), value: 1) + let existing = FeatureComposerDraft( + text: "Existing prompt", + attachments: [existingAttachment], + selection: selection, + workspace: workspace + ) + let imageID = try #require(UUID(uuidString: "12345678-1234-1234-1234-123456789abc")) + let envelope = Self.envelope( + text: "Shared context", + images: [Self.image(id: imageID.uuidString)] + ) + let project = Self.project() + let expectedKey = FeatureComposerDraftStore.newTaskKey(project: project) + try await store.setDraft(existing, for: expectedKey) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { image in + await recorder.record("read:\(image.id)") + return Data([0xCA, 0xFE]) + }, + remove: { id in await recorder.record("remove:\(id)") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + let draft = try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + await recorder.capture(draft: draft, key: key) + await recorder.record("import:\(key)") + return draft + } + ), + prepareImage: { data, ordinal in + await recorder.record("prepare:\(ordinal)") + return FeatureDraftAttachment( + data: data, + filename: "Image \(ordinal).jpg", + mimeType: "image/jpeg" + ) + } + ) + + let merged = try await pipeline.importEnvelope(envelope, into: project) + let captured = await recorder.capturedDraft + let events = await recorder.events + + #expect(merged.text == "Existing prompt\n\nShared context") + #expect(merged.selection == selection) + #expect(merged.workspace == workspace) + #expect(merged.attachments.count == 2) + #expect(merged.attachments.last?.id == imageID) + #expect(captured?.key == expectedKey) + #expect(captured?.draft == merged) + #expect(events.suffix(2) == ["import:\(expectedKey)", "remove:\(envelope.id)"]) + #expect(try await store.draft(for: expectedKey) == merged) + } + + @Test + func failedDraftSaveLeavesTheInboxUntouched() async { + let recorder = IncomingShareTestRecorder() + let envelope = Self.envelope(text: "Keep me") + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in await recorder.record("remove") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in + await recorder.record("import") + throw IncomingShareTestError.saveFailed + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected the draft write to fail") + } catch { + #expect(error as? IncomingShareTestError == .saveFailed) + } + + #expect(await recorder.events == ["import"]) + } + + @Test + func groupedProjectImportUsesTheSameDraftKeyAsTheComposer() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let project = Self.project( + repositoryIdentity: FeatureRepositoryIdentity(canonicalKey: "github.com/t3/example") + ) + let snapshot = FeatureSnapshot(projects: [project]) + let draftKey = FeatureComposerDraftStore.newTaskKey(project: project, in: snapshot) + let envelope = Self.envelope(text: "Keep shared context") + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + _ = try await pipeline.importEnvelope(envelope, into: project, draftKey: draftKey) + + #expect(draftKey == "logical-project:github.com/t3/example:new-task") + #expect(try await store.draft(for: draftKey)?.text == "Keep shared context") + #expect( + try await store.draft(for: FeatureComposerDraftStore.newTaskKey(project: project)) == nil + ) + } + + @Test + func imageFailureDoesNotPersistOrRemoveTheEnvelope() async { + let recorder = IncomingShareTestRecorder() + let envelope = Self.envelope( + images: [Self.image(id: UUID().uuidString)] + ) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in throw IncomingShareTestError.imageFailed }, + remove: { _ in await recorder.record("remove") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in + await recorder.record("import") + return FeatureComposerDraft() + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected image loading to fail") + } catch { + #expect(error as? IncomingShareTestError == .imageFailed) + } + + #expect(await recorder.events.isEmpty) + } + + @Test + func attachmentLimitPreservesTheWholeEnvelope() async throws { + let recorder = IncomingShareTestRecorder() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let existing = FeatureComposerDraft( + attachments: (0..<7).map { Self.attachment(id: UUID(), value: UInt8($0)) } + ) + let envelope = Self.envelope( + images: [ + Self.image(id: UUID().uuidString), + Self.image(id: UUID().uuidString), + ] + ) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in + await recorder.record("read") + return Data() + }, + remove: { _ in await recorder.record("remove") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + await recorder.record("import") + return try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + let key = FeatureComposerDraftStore.newTaskKey(project: Self.project()) + try await store.setDraft(existing, for: key) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected the attachment limit to reject the import") + } catch let error as FeatureComposerDraftImportError { + if case let .attachmentLimitExceeded(available) = error { + #expect(available == 1) + } + } + + #expect(!(await recorder.events.contains("remove"))) + #expect(try await store.draft(for: key) == existing) + } + + @Test + func repeatedAtomicImportIsIdempotent() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:one:new-task:project" + let attachment = Self.attachment(id: UUID(), value: 1) + try await store.setDraft(FeatureComposerDraft(text: "Existing"), for: key) + + let once = try await store.importSharedContent( + shareID: "share-id", + text: "Shared", + attachments: [attachment], + for: key + ) + var edited = once + edited.text += "\nUser edit" + try await store.setDraft(edited, for: key) + let twice = try await store.importSharedContent( + shareID: "share-id", + text: "Shared", + attachments: [attachment], + for: key + ) + + #expect(once.text == "Existing\n\nShared") + #expect(once.attachments == [attachment]) + #expect(twice == edited) + } + + @Test + @MainActor + func noProjectNoticeKeepsEnvelopePendingAndOnlyReportsOnce() async { + let envelope = Self.envelope(text: "Pending") + let coordinator = PlatformIncomingShareCoordinator( + pipeline: PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in FeatureComposerDraft() } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + ) + + #expect(await coordinator.refresh(hasProjects: false)) + #expect(coordinator.pendingEnvelope == envelope) + #expect(!(await coordinator.refresh(hasProjects: false))) + #expect(coordinator.pendingEnvelope == envelope) + } + + private static func envelope( + text: String = "", + images: [T3IncomingShareImage] = [], + files: [T3IncomingShareFile] = [] + ) -> T3IncomingShareEnvelope { + T3IncomingShareEnvelope( + schemaVersion: T3IncomingShareEnvelope.schemaVersion, + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + createdAt: Date(timeIntervalSince1970: 100), + text: text, + images: images, + files: files, + warnings: [] + ) + } + + private static func file(id: String, byteCount: Int) -> T3IncomingShareFile { + T3IncomingShareFile( + id: id, + fileName: "report.txt", + mimeType: "text/plain", + relativePath: "report.txt", + byteCount: byteCount + ) + } + + private static func image(id: String) -> T3IncomingShareImage { + T3IncomingShareImage( + id: id, + fileName: "reference.png", + typeIdentifier: "public.png", + relativePath: "image.png", + byteCount: 2 + ) + } + + private static func attachment(id: UUID, value: UInt8) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: id, + data: Data([value]), + filename: "Image.jpg", + mimeType: "image/jpeg" + ) + } + + private static func project( + repositoryIdentity: FeatureRepositoryIdentity? = nil + ) -> FeatureProject { + FeatureProject( + id: "project:environment:project", + wireID: "project", + environmentID: "environment", + name: "t3code", + path: "/repo", + repositoryIdentity: repositoryIdentity + ) + } +} + +private enum IncomingShareTestError: Error, Equatable { + case imageFailed + case saveFailed +} + +private actor IncomingShareTestRecorder { + private(set) var events: [String] = [] + private(set) var capturedDraft: (draft: FeatureComposerDraft, key: String)? + + func record(_ event: String) { + events.append(event) + } + + func capture(draft: FeatureComposerDraft, key: String) { + capturedDraft = (draft, key) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformNotificationPreferenceTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformNotificationPreferenceTests.swift new file mode 100644 index 000000000000..237cc8140613 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformNotificationPreferenceTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing +import UserNotifications +@testable import T3Code + +@MainActor +@Suite("Notification preferences", .serialized) +struct PlatformNotificationPreferenceTests { + @Test + func disablingNotificationsIgnoresAnOlderPermissionResult() async { + let permission = NotificationPreferenceGate() + let recorder = NotificationPreferenceRecorder() + let service = PlatformNotificationService( + tokenSink: recorder, + authorizationStatus: { .notDetermined }, + authorizationRequest: { await permission.enter() }, + updateRemoteRegistration: { recorder.registrations.append($0) } + ) + let previousDelegate = UNUserNotificationCenter.current().delegate + defer { UNUserNotificationCenter.current().delegate = previousDelegate } + + let request = Task { await service.requestAuthorization() } + await permission.waitUntilEntered() + #expect(await service.synchronize(enabled: false) == false) + permission.release(true) + + #expect(await request.value == nil) + #expect(!service.enabled) + #expect(recorder.registrations == [false]) + #expect(recorder.invalidations == 1) + service.didRegisterForRemoteNotifications(deviceToken: Data([1, 2])) + #expect(recorder.tokens.isEmpty) + } + + @Test + func disablingNotificationsBeforeStatusLoadsDoesNotPrompt() async { + let status = NotificationPreferenceGate() + let recorder = NotificationPreferenceRecorder() + var permissionRequests = 0 + let service = PlatformNotificationService( + tokenSink: recorder, + authorizationStatus: { await status.enter() }, + authorizationRequest: { + permissionRequests += 1 + return true + }, + updateRemoteRegistration: { recorder.registrations.append($0) } + ) + let previousDelegate = UNUserNotificationCenter.current().delegate + defer { UNUserNotificationCenter.current().delegate = previousDelegate } + + let request = Task { await service.requestAuthorization() } + await status.waitUntilEntered() + #expect(await service.synchronize(enabled: false) == false) + status.release(.notDetermined) + + #expect(await request.value == nil) + #expect(permissionRequests == 0) + #expect(!service.enabled) + #expect(recorder.registrations == [false]) + } + + @Test + func oldStatusDenialDoesNotReplaceNewerAuthorization() async { + let status = NotificationPreferenceGate() + let recorder = NotificationPreferenceRecorder() + var statusRequests = 0 + let service = PlatformNotificationService( + tokenSink: recorder, + authorizationStatus: { + statusRequests += 1 + if statusRequests == 1 { return await status.enter() } + return .authorized + }, + authorizationRequest: { false }, + updateRemoteRegistration: { recorder.registrations.append($0) } + ) + let previousDelegate = UNUserNotificationCenter.current().delegate + defer { UNUserNotificationCenter.current().delegate = previousDelegate } + + let staleCheck = Task { await service.synchronize(enabled: true) } + await status.waitUntilEntered() + #expect(await service.requestAuthorization() == true) + status.release(.denied) + + #expect(await staleCheck.value == nil) + #expect(service.enabled) + #expect(recorder.registrations == [true]) + service.didRegisterForRemoteNotifications(deviceToken: Data([1, 2])) + #expect(recorder.tokens == ["0102"]) + } + + @Test + func oldPermissionDenialDoesNotReplaceNewerAuthorization() async { + let permission = NotificationPreferenceGate() + let recorder = NotificationPreferenceRecorder() + var statusRequests = 0 + let service = PlatformNotificationService( + tokenSink: recorder, + authorizationStatus: { + statusRequests += 1 + return statusRequests == 1 ? .notDetermined : .authorized + }, + authorizationRequest: { await permission.enter() }, + updateRemoteRegistration: { recorder.registrations.append($0) } + ) + let previousDelegate = UNUserNotificationCenter.current().delegate + defer { UNUserNotificationCenter.current().delegate = previousDelegate } + + let staleRequest = Task { await service.requestAuthorization() } + await permission.waitUntilEntered() + #expect(await service.requestAuthorization() == true) + permission.release(false) + + #expect(await staleRequest.value == nil) + #expect(service.enabled) + #expect(recorder.registrations == [true]) + } +} + +@MainActor +private final class NotificationPreferenceRecorder: PlatformDeviceTokenSink { + var registrations: [Bool] = [] + var tokens: [String] = [] + var invalidations = 0 + + func registered(token: String) { tokens.append(token) } + func registrationFailed(_ error: any Error) {} + func invalidated() { invalidations += 1 } +} + +@MainActor +private final class NotificationPreferenceGate { + private var result: CheckedContinuation? + private var entered: CheckedContinuation? + + func enter() async -> Value { + await withCheckedContinuation { continuation in + result = continuation + entered?.resume() + entered = nil + } + } + + func waitUntilEntered() async { + guard result == nil else { return } + await withCheckedContinuation { entered = $0 } + } + + func release(_ value: Value) { + result?.resume(returning: value) + result = nil + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformRootViewTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformRootViewTests.swift new file mode 100644 index 000000000000..dc30dc1b461a --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformRootViewTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import T3Code + +@MainActor +@Suite("Platform account session transitions") +struct PlatformRootViewTests { + @Test + func accountSignOutRemovesManagedEnvironments() { + #expect(PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: nil, + isSigningOut: false + )) + } + + @Test + func changingAccountsRemovesManagedEnvironments() { + #expect(PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: "account-2", + isSigningOut: false + )) + } + + @Test + func loadingAnExistingAccountKeepsManagedEnvironments() { + #expect(!PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: nil, + accountID: "account-1", + isSigningOut: false + )) + } + + @Test + func unchangedAccountsKeepManagedEnvironments() { + #expect(!PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: "account-1", + isSigningOut: false + )) + } + + @Test + func explicitSignOutOwnsItsManagedEnvironmentCleanup() { + #expect(!PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: nil, + isSigningOut: true + )) + } +} diff --git a/docs/operations/swiftui-testflight.md b/docs/operations/swiftui-testflight.md new file mode 100644 index 000000000000..d043e4f2700d --- /dev/null +++ b/docs/operations/swiftui-testflight.md @@ -0,0 +1,123 @@ +# SwiftUI TestFlight releases + +This workflow releases `apps/swift-ios`, not the React Native app in `apps/mobile`. +The release bundle identifier is `com.t3tools.t3code.swiftui`. Development builds +use a separate identity and do not replace TestFlight. + +## One-time API access + +Create a dedicated **App Manager** team key in App Store Connect under +**Users and Access > Integrations > App Store Connect API**. This is the minimum +role for managing external TestFlight testing. Do not use an Admin key. + +Apple team keys cover all apps in the team. A key's name does not restrict its +access. Get the account owner's approval for that scope before creating it. +The release helper verifies the SwiftUI bundle identifier and the app that owns +each tester group before it changes anything. + +Download the private key once. Keep its contents in a private, uncommitted +`~/.config/t3code/.env.testflight` file. This location survives worktree cleanup. +Give the directory mode `700` and the env file mode `600`. Do not put the private +key or generated tokens in Git, build output, or pull request comments. + +```dotenv +T3_SWIFT_ASC_KEY_ID=KEY_ID +T3_SWIFT_ASC_ISSUER_ID=ISSUER_UUID +T3_SWIFT_ASC_PRIVATE_KEY_BASE64=BASE64_OF_THE_ENTIRE_DOWNLOADED_P8_FILE +T3_SWIFT_ASC_APP_ID=SWIFTUI_APP_ID +T3_SWIFT_ASC_PUBLIC_GROUP_ID=PUBLIC_BETA_GROUP_UUID +T3_SWIFT_ASC_INTERNAL_GROUP_ID=INTERNAL_GROUP_UUID +``` + +Base64 is only an encoding, not encryption. Import the downloaded key without +printing its contents or the encoded value. Verify the saved value before +removing the separate download. + +Use the app and groups belonging to the SwiftUI app. Do not copy the React Native +app ID from EAS. `T3_SWIFT_TESTFLIGHT_ENV_FILE` or `--env-file` can select another +env file. If you keep it in a checkout, use an ignored `.env` file and verify +that it is not tracked. The helper reads the file without exporting its values +to other processes or loading the app's general `.env`. + +REST requests use the key in memory to sign short-lived tokens. Xcode uploads +need a key file, so the helper creates a private temporary `.p8` and removes it +after the upload finishes or fails. No permanent `.p8` or JSON config is needed. +The helper does not read Safari cookies or use an Apple ID password. + +## Signing preflight + +API authentication does not supply a distribution signing identity. The current +API-key upload path needs a matching local Apple Distribution certificate and +private key. Check available identities before uploading: + +```sh +security find-identity -v -p codesigning +``` + +A cloud-managed distribution certificate will not appear as a local identity. +Existing cloud signing may need the signed-in Xcode Apple-ID session instead. +Do not create or revoke certificates to fix this difference. + +## Release + +1. Update the project's build number, commit, and push. Keep the existing version + unless the release needs a new one. +2. Verify the affected native behavior. Archive the `T3Code` scheme in `Release` + with the production Clerk and relay settings. Confirm that the host, widget, + and share extension use the production App Group. +3. Upload the archive with API credentials: + + ```sh + node scripts/swift-testflight.ts upload \ + --archive /absolute/path/T3Code.xcarchive \ + --export-options /absolute/path/TestFlightExportOptions.plist + ``` + + Use the existing App Store Connect export options with `destination=upload`, + `testFlightInternalTestingOnly=false`, and + `manageAppVersionAndBuildNumber=false`. Keep the existing signing assets. + The helper does not enable provisioning updates or fall back to Apple ID auth. + + If this machine uses existing cloud signing, an explicit one-time export can + use its Xcode Apple-ID session. First check API status for the exact version + and build, and check the previous upload log. Proceed only if no build was + uploaded and no upload is still running. Use the same archive and options: + + ```sh + xcodebuild -exportArchive \ + -archivePath /absolute/path/T3Code.xcarchive \ + -exportOptionsPlist /absolute/path/TestFlightExportOptions.plist + ``` + + Do not add provisioning-update flags. Keep status and publishing API-based. + +4. Check Apple's processing state: + + ```sh + node scripts/swift-testflight.ts status --version 0.1.0 --build 46 + ``` + +5. Once processing completes, publish to the configured existing groups: + + ```sh + node scripts/swift-testflight.ts publish --version 0.1.0 --build 46 \ + --notes-file /absolute/path/release-notes.txt + ``` + + The publish command checks existing group membership and review state before + making changes. A rerun must not resubmit an existing review or send another + notification for a build that is already testing. + +6. Check status again. An uploaded or approved build is not enough. Confirm that + the public group has the build and that external testing has started. If Apple + is still reviewing it, report that state without claiming the beta is live. + +Do not expire older builds as part of a normal release. Do not create or revoke +signing certificates or profiles to fix an API login error. API authentication +and Apple code signing are separate. + +## Apple references + +- [API key types and scope](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) +- [Create and download an API key](https://developer.apple.com/help/app-store-connect/get-started/app-store-connect-api/) +- [External testing roles and review](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers/) diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 0300b8fd9946..918894a0fb46 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -4,8 +4,9 @@ Open **Settings → Appearance** to choose a theme and follow the system appeara or dark mode. To use different themes for light and dark mode, select the corresponding preview within each theme. Appearance preferences are saved separately on each device or browser. -Mobile has its own themes and text, code, and terminal preferences. It does not follow environment -themes or defaults. +React Native mobile has its own themes and text, code, and terminal preferences. SwiftUI mobile +supports system, light, and dark appearance without custom themes. Neither mobile app follows +environment themes or defaults. ## Motion diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 3d37850c5e20..ea5df93ca62c 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -28,4 +28,10 @@ actions still require approval. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. +On SwiftUI mobile, open **Thread actions**, then **Permissions**, to choose +**Automatic** or **Full access** for that thread. New threads start in **Full access**. +Existing threads keep their saved mode. If a thread uses **Supervised** or +**Auto-accept edits**, SwiftUI mobile shows that saved mode as the current value. +Choose **Automatic** or **Full access** to replace it. + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/swiftui-mobile.md b/docs/user/swiftui-mobile.md new file mode 100644 index 000000000000..e0fa8fde9fb9 --- /dev/null +++ b/docs/user/swiftui-mobile.md @@ -0,0 +1,83 @@ +# SwiftUI mobile + +The native SwiftUI app connects to one or more T3 Code computers. Each server owns the settled +state of its threads. Change automatic settlement in an environment's connection details. +These user preferences also apply to other connected environments that support them. Offline +environments keep their existing settings. Open **Preferences** in connection details to see +differences and apply one environment's preferences to the others. + +Use **Refresh models** in the model picker for a new or existing task to reload models for the +selected computer. Other connected computers are not refreshed. + +Star a model to keep it in **Favorites**, including legacy models. Removing a star from a legacy +model returns it to **Legacy models**. + +## Providers and skills + +Open **Settings > Providers**, or **Providers** in the model picker, to manage a provider on its +computer. Supported providers offer runtime installation and sign-in. Antigravity can be enabled +here. Runtime files and credentials stay on that computer. API keys and enterprise authentication +settings must be configured on the computer. + +The composer uses the skills and slash commands for the selected project or worktree. Skills that +require direct user invocation insert a slash command. Agent-only skills do not appear in the slash +menu. + +## Icons and usage + +Project icons set on the computer appear in the inbox. Environment icons follow each computer's +settings. Supported environments offer an icon picker in their connection preferences. + +Usage loads each computer separately. A slow or offline computer does not prevent the others +from reporting. Use **Refresh prices** to fetch new model rates without waiting for the daily refresh. + +Open **Usage > Limits** for subscription limits and reset times reported by your computers. +Supported Codex accounts also let you use a reset credit after confirmation. This uses a credit +on that account and cannot be undone. Computers that do not support limits can still report usage. + +Older computers that report daily totals cannot supply the 24-hour view. Select 7d or 30d to +include their data. + +## Thread connection state + +Cached messages stay readable while a thread catches up. A status above the composer shows when +the content is incomplete or the computer cannot be reached. Use **Retry** if an update fails. +Working indicators return after the thread is current. File previews can finish loading after +the text is ready. Saved drafts load without waiting for the thread to catch up. + +## Attachments and sharing + +One message can contain up to eight photos, videos, or files. Images can be up to 10 MB. Other +files can be up to 50 MB, or the lower limit reported by the connected server. Older servers accept +images only. + +You can also paste an image from the message field's edit menu or drag an image onto the composer. + +Attachments start uploading while you compose. **Preparing** means the attachment is waiting to +start. Failed or timed-out uploads show **Retry** and keep the local copy. If the app cannot save +the draft, it shows that error instead of reporting an upload in progress. Tap an image, PDF, +video, or other file to preview it with native controls when iOS supports that format. + +Links to images, videos, PDFs and HTML files can also open files outside the workspace when the +server supports them. Failed previews show an error and a retry action. + +You can share text, links, photos, videos, and files from another app into T3 Code. Choose a project +to add the shared content to a new-task draft. The share extension never sends the draft. + +## Voice input + +On supported devices with iOS 26 or later, the composer can transcribe up to five minutes of audio +on the device. Voice input needs microphone permission. The first use can also require Apple's +speech model download. + +Tap Stop to finish recording. T3 Code inserts editable text into the draft and never +sends it automatically. + +Starting voice input keeps an open keyboard in place. Editing pauses until voice input finishes +or is canceled. + +## Codex content + +Codex file citations open the cited file when available. Artifact templates include a +**Use** action. **Use** inserts an editable prompt into the composer. Review or change it before +you send it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b1598d0dcea..db87140f1125 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -972,6 +972,9 @@ importers: '@electron/osx-sign': specifier: 2.7.0 version: 2.7.0 + '@t3tools/contracts': + specifier: workspace:* + version: link:../packages/contracts '@t3tools/shared': specifier: workspace:* version: link:../packages/shared diff --git a/scripts/generate-swift-wire-fixtures.ts b/scripts/generate-swift-wire-fixtures.ts new file mode 100644 index 000000000000..4db2d83b22ef --- /dev/null +++ b/scripts/generate-swift-wire-fixtures.ts @@ -0,0 +1,174 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { + OrchestrationShellSnapshot, + OrchestrationShellStreamItem, + OrchestrationThreadDetailSnapshot, + OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; + +const check = process.argv.includes("--check"); +const timestamp = "2026-08-07T12:00:00.000Z"; + +const project = { + id: "project-fixture", + title: "Fixture project", + workspaceRoot: "/workspace/fixture", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + options: [{ id: "effort", value: "high" }], + }, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, +}; + +const threadShell = { + id: "thread-fixture", + projectId: project.id, + title: "Fixture thread", + modelSelection: project.defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + branchPullRequest: { + projectId: project.id, + repository: "fixture/repository", + number: 42, + url: "https://example.com/fixture/repository/pull/42", + }, + activeOrderKey: "nm", + latestTurn: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + titleRegeneration: null, + session: null, + latestUserMessageAt: timestamp, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: null, + planProgress: null, +}; + +const shellSnapshotInput = { + snapshotSequence: 42, + projects: [project], + threads: [threadShell], + updatedAt: timestamp, +}; + +const threadDetailInput = { + snapshotSequence: 42, + thread: { + ...threadShell, + deletedAt: null, + messages: [ + { + id: "message-fixture", + role: "user", + text: "Verify the native wire contract", + attachments: [], + turnId: "turn-fixture", + streaming: false, + createdAt: timestamp, + updatedAt: timestamp, + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + }, + page: { + beforeCursor: "fixture-cursor", + hasMore: true, + snapshotSequence: 42, + threadSequence: 40, + }, +}; + +const decodeShellSnapshot = Schema.decodeUnknownSync(OrchestrationShellSnapshot); +const encodeShellSnapshot = Schema.encodeSync(OrchestrationShellSnapshot); +const decodeThreadDetail = Schema.decodeUnknownSync(OrchestrationThreadDetailSnapshot); +const encodeThreadDetail = Schema.encodeSync(OrchestrationThreadDetailSnapshot); +const decodeShellStreamItem = Schema.decodeUnknownSync(OrchestrationShellStreamItem); +const encodeShellStreamItem = Schema.encodeSync(OrchestrationShellStreamItem); +const decodeThreadStreamItem = Schema.decodeUnknownSync(OrchestrationThreadStreamItem); +const encodeThreadStreamItem = Schema.encodeSync(OrchestrationThreadStreamItem); + +const shellSnapshot = encodeShellSnapshot(decodeShellSnapshot(shellSnapshotInput)); +const threadDetail = encodeThreadDetail(decodeThreadDetail(threadDetailInput)); +const serializeFixture = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; +const fixtures = new Map([ + ["shell-snapshot.json", serializeFixture(shellSnapshot)], + ["thread-detail-snapshot.json", serializeFixture(threadDetail)], + [ + "shell-stream-snapshot.json", + serializeFixture( + encodeShellStreamItem(decodeShellStreamItem({ kind: "snapshot", snapshot: shellSnapshot })), + ), + ], + [ + "thread-stream-snapshot.json", + serializeFixture( + encodeThreadStreamItem(decodeThreadStreamItem({ kind: "snapshot", snapshot: threadDetail })), + ), + ], +]); + +class StaleWireFixturesError extends Schema.TaggedErrorClass()( + "StaleWireFixturesError", + { + staleFixtures: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return "Run `node scripts/generate-swift-wire-fixtures.ts` and commit the result."; + } +} + +const generateSwiftWireFixtures = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const root = path.resolve(import.meta.dirname, ".."); + const outputDirectory = path.resolve(root, "apps/swift-ios/Tests/Fixtures/Wire"); + + const staleFixtures: string[] = []; + for (const [name, contents] of fixtures) { + const filePath = path.resolve(outputDirectory, name); + if (check) { + const current = yield* fs + .readFileString(filePath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (current !== contents) { + yield* Effect.logError(`[swift-wire-fixtures] stale: ${name}`); + staleFixtures.push(name); + } + } else { + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); + yield* Effect.log(`[swift-wire-fixtures] wrote ${name}`); + } + } + + if (staleFixtures.length > 0) { + return yield* new StaleWireFixturesError({ staleFixtures }); + } +}); + +generateSwiftWireFixtures.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/scripts/package.json b/scripts/package.json index 0d080008ead3..167cbf6e34b7 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -10,6 +10,7 @@ "@effect/platform-node": "catalog:", "@electron/asar": "^3.4.1", "@electron/osx-sign": "2.7.0", + "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", diff --git a/scripts/swift-testflight.test.ts b/scripts/swift-testflight.test.ts new file mode 100644 index 000000000000..1d370a78b3d0 --- /dev/null +++ b/scripts/swift-testflight.test.ts @@ -0,0 +1,516 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - This tests the standalone Node release tool with a fake HTTP server. +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + createTestFlightClient, + makeAppStoreToken, + parseTestFlightArgs, + parseTestFlightEnv, + readTestFlightEnv, + validateUploadMetadata, + withTemporaryPrivateKey, +} from "./swift-testflight.ts"; + +const keys = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); +const encodedKey = Buffer.from(keys.privateKey.export({ type: "pkcs8", format: "pem" })).toString( + "base64", +); +const envSource = `# Synthetic test credentials, never used with Apple. +T3_SWIFT_ASC_KEY_ID="TESTKEY123" +T3_SWIFT_ASC_ISSUER_ID=00000000-0000-0000-0000-000000000001 +T3_SWIFT_ASC_PRIVATE_KEY_BASE64="${encodedKey}" +T3_SWIFT_ASC_APP_ID=12345 +T3_SWIFT_ASC_PUBLIC_GROUP_ID=public +T3_SWIFT_ASC_INTERNAL_GROUP_ID=internal +`; +const { config } = parseTestFlightEnv(envSource); +const selection = { build: "46", version: "0.1.0" }; +const notes = "Fix attachment imports and keep the keyboard open during dictation."; +const linkage = (type: string, id: string) => ({ data: { type, id } }); + +function mockServer( + options: { + bundleId?: string; + groupAppId?: string; + buildAppId?: string; + buildExists?: boolean; + processingState?: string; + externalState?: string; + reviewState?: string; + assigned?: string[]; + autoNotifyEnabled?: boolean; + existingNotes?: string | null; + failAfterGroupAssignment?: boolean; + approveImmediately?: boolean; + sparseBetaRelations?: boolean; + } = {}, +) { + const state = { + externalState: options.externalState ?? "IN_BETA_TESTING", + reviewState: options.reviewState, + assigned: new Set(options.assigned ?? ["internal", "public"]), + autoNotifyEnabled: options.autoNotifyEnabled ?? true, + notes: options.existingNotes === undefined ? notes : options.existingNotes, + }; + const writes: { method: string; path: string; body: unknown }[] = []; + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + if (url.origin !== "https://api.appstoreconnect.apple.com") { + throw new Error("Unexpected origin"); + } + const path = url.pathname; + const detail = { + type: "buildBetaDetails", + id: "detail-46", + attributes: { + externalBuildState: state.externalState, + internalBuildState: "IN_BETA_TESTING", + autoNotifyEnabled: state.autoNotifyEnabled, + }, + }; + const reviews = state.reviewState + ? [ + { + type: "betaAppReviewSubmissions", + id: "review-46", + attributes: { betaReviewState: state.reviewState }, + }, + ] + : []; + const groupResource = (id: string) => ({ + type: "betaGroups", + id, + attributes: { + name: id, + isInternalGroup: id === "internal", + hasAccessToAllBuilds: false, + publicLinkEnabled: id === "public", + }, + relationships: { app: linkage("apps", options.groupAppId ?? config.appId) }, + }); + if (method === "GET") { + if (path === `/v1/apps/${config.appId}`) { + return Response.json({ + data: { + type: "apps", + id: config.appId, + attributes: { + name: "T3 Code SwiftUI", + bundleId: options.bundleId ?? "com.t3tools.t3code.swiftui", + primaryLocale: "en-US", + }, + }, + }); + } + if (path === "/v1/betaGroups/public" || path === "/v1/betaGroups/internal") { + expect(url.searchParams.get("include")).toBe("app"); + return Response.json({ + data: groupResource(path.endsWith("public") ? "public" : "internal"), + }); + } + if (path === "/v1/builds") { + expect(url.searchParams.get("filter[app]")).toBe(config.appId); + expect(url.searchParams.get("filter[version]")).toBe("46"); + expect(url.searchParams.get("filter[preReleaseVersion.version]")).toBe("0.1.0"); + const build = { + type: "builds", + id: "build-46", + attributes: { + version: "46", + processingState: options.processingState ?? "VALID", + expired: false, + buildAudienceType: "APP_STORE_ELIGIBLE", + }, + relationships: { + app: url.searchParams.get("include")?.split(",").includes("app") + ? linkage("apps", options.buildAppId ?? config.appId) + : { links: { related: "/v1/builds/build-46/app" } }, + preReleaseVersion: linkage("preReleaseVersions", "version-1"), + buildBetaDetail: options.sparseBetaRelations + ? {} + : linkage("buildBetaDetails", "detail-46"), + betaAppReviewSubmission: options.sparseBetaRelations + ? {} + : state.reviewState + ? linkage("betaAppReviewSubmissions", "review-46") + : { data: null }, + }, + }; + return Response.json({ + data: options.buildExists === false ? [] : [build], + included: [ + { + type: "preReleaseVersions", + id: "version-1", + attributes: { version: "0.1.0", platform: "IOS" }, + }, + ...(options.sparseBetaRelations ? [] : [detail, ...reviews]), + ], + }); + } + if (path === "/v1/buildBetaDetails" || path === "/v1/betaAppReviewSubmissions") { + expect(url.searchParams.get("filter[build]")).toBe("build-46"); + return Response.json({ data: path === "/v1/buildBetaDetails" ? [detail] : reviews }); + } + if (path === "/v1/betaGroups") { + expect(url.searchParams.has("filter[app]")).toBe(false); + expect(url.searchParams.get("filter[builds]")).toBe("build-46"); + return Response.json({ data: [...state.assigned].map(groupResource) }); + } + if (path === "/v1/builds/build-46/betaBuildLocalizations") { + return Response.json({ + data: + state.notes === null + ? [] + : [ + { + type: "betaBuildLocalizations", + id: "notes-46", + attributes: { locale: "en-US", whatsNew: state.notes }, + }, + ], + }); + } + } else { + const body: unknown = JSON.parse(String(init?.body)); + writes.push({ method, path, body }); + if (path === "/v1/betaBuildLocalizations" || path === "/v1/betaBuildLocalizations/notes-46") { + state.notes = notes; + return Response.json({ data: { type: "betaBuildLocalizations", id: "notes-46" } }); + } + if (path === "/v1/buildBetaDetails/detail-46") { + state.autoNotifyEnabled = true; + return Response.json({ data: { type: "buildBetaDetails", id: "detail-46" } }); + } + if ( + path === "/v1/betaGroups/public/relationships/builds" || + path === "/v1/betaGroups/internal/relationships/builds" + ) { + state.assigned.add(path.includes("/public/") ? "public" : "internal"); + if (options.failAfterGroupAssignment) throw new Error("Lost response after write"); + return new Response(null, { status: 204 }); + } + if (path === "/v1/betaAppReviewSubmissions") { + state.externalState = options.approveImmediately + ? "BETA_APPROVED" + : "WAITING_FOR_BETA_REVIEW"; + state.reviewState = options.approveImmediately ? "APPROVED" : "WAITING_FOR_REVIEW"; + return Response.json( + { data: { type: "betaAppReviewSubmissions", id: "review-46" } }, + { status: 201 }, + ); + } + if (path === "/v1/buildBetaNotifications") { + state.externalState = "IN_BETA_TESTING"; + return Response.json( + { data: { type: "buildBetaNotifications", id: "notification-46" } }, + { status: 201 }, + ); + } + } + throw new Error(`Unexpected request: ${method} ${path}`); + }); + return { + client: createTestFlightClient(config, keys.privateKey, fetchMock), + fetchMock, + state, + writes, + }; +} + +describe("App Store Connect authentication", () => { + it("does not print a token echoed in an API error", async () => { + let capturedToken = ""; + const client = createTestFlightClient(config, keys.privateKey, async (_input, init) => { + capturedToken = new Headers(init?.headers).get("Authorization")?.slice(7) ?? ""; + return Response.json({ errors: [{ detail: `Rejected ${capturedToken}` }] }, { status: 401 }); + }); + await expect(client.status(selection)).rejects.toThrow("Rejected [redacted token]"); + expect(capturedToken).not.toBe(""); + }); + + it("parses a quoted env file and restores the downloaded PEM key", () => { + const parsed = parseTestFlightEnv(envSource); + expect(parsed.config).toEqual({ + keyId: "TESTKEY123", + issuerId: "00000000-0000-0000-0000-000000000001", + appId: "12345", + publicGroupId: "public", + internalGroupId: "internal", + }); + expect( + NodeCrypto.createPublicKey(parsed.privateKey).export({ type: "spki", format: "pem" }), + ).toBe(keys.publicKey.export({ type: "spki", format: "pem" })); + }); + + it("does not export parsed env variables to the process", () => { + const before = process.env.T3_SWIFT_ENV_PARSE_TEST_SENTINEL; + parseTestFlightEnv(`${envSource}T3_SWIFT_ENV_PARSE_TEST_SENTINEL="local only"\n`); + expect(process.env.T3_SWIFT_ENV_PARSE_TEST_SENTINEL).toBe(before); + }); + + it("requires private permissions on the env file", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-swift-env-test-")); + const path = NodePath.join(directory, ".env.testflight.local"); + try { + await NodeFSP.writeFile(path, envSource, { mode: 0o600 }); + expect((await readTestFlightEnv(path)).config).toEqual(config); + await NodeFSP.chmod(path, 0o644); + await expect(readTestFlightEnv(path)).rejects.toThrow("mode 600"); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }); + + it.each([false, true])( + "removes the temporary Xcode key after success or failure: %s", + async (fail) => { + let temporaryPath: string | undefined; + const operation = withTemporaryPrivateKey(keys.privateKey, async (path) => { + temporaryPath = path; + expect((await NodeFSP.stat(path)).mode & 0o777).toBe(0o600); + expect((await NodeFSP.stat(NodePath.dirname(path))).mode & 0o777).toBe(0o700); + const savedKey = NodeCrypto.createPrivateKey(await NodeFSP.readFile(path)); + expect(NodeCrypto.createPublicKey(savedKey).export({ type: "spki", format: "pem" })).toBe( + keys.publicKey.export({ type: "spki", format: "pem" }), + ); + if (fail) throw new Error("Upload failed"); + }); + if (fail) await expect(operation).rejects.toThrow("Upload failed"); + else await operation; + if (!temporaryPath) throw new Error("The upload callback did not run"); + await expect(NodeFSP.stat(NodePath.dirname(temporaryPath))).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + + it("signs a ten-minute ES256 token with the API audience and P1363 signature", () => { + const token = makeAppStoreToken(config, keys.privateKey, 1_000); + const [header, payload, signature] = token.split("."); + if (!header || !payload || !signature) throw new Error("Invalid token"); + expect(JSON.parse(Buffer.from(header, "base64url").toString())).toEqual({ + alg: "ES256", + kid: config.keyId, + typ: "JWT", + }); + expect(JSON.parse(Buffer.from(payload, "base64url").toString())).toEqual({ + iss: config.issuerId, + iat: 1_000, + exp: 1_600, + aud: "appstoreconnect-v1", + }); + expect(Buffer.from(signature, "base64url")).toHaveLength(64); + expect( + NodeCrypto.verify( + "sha256", + Buffer.from(`${header}.${payload}`), + { + key: keys.publicKey, + dsaEncoding: "ieee-p1363", + }, + Buffer.from(signature, "base64url"), + ), + ).toBe(true); + }); + + it("rejects a key that cannot sign ES256", () => { + const wrongKey = NodeCrypto.generateKeyPairSync("ed25519"); + expect(() => makeAppStoreToken(config, wrongKey.privateKey)).toThrow("P-256 private key"); + }); +}); + +describe("SwiftUI TestFlight publication", () => { + it.each([ + { bundleId: "com.t3tools.t3code.swiftui.dev" }, + { groupAppId: "other-app" }, + { buildAppId: "other-app" }, + ])("refuses wrong-app targets before any write: %j", async (options) => { + const server = mockServer(options); + await expect(server.client.publish(selection, notes)).rejects.toThrow( + /Refusing|different app/u, + ); + expect(server.writes).toEqual([]); + }); + + it("does not duplicate group assignment, review, or notifications for a testing build", async () => { + const server = mockServer(); + const result = await server.client.publish(selection, notes); + expect(result.publicTesting).toBe(true); + expect(server.writes).toEqual([]); + }); + + it("sets notes, assigns both groups, and submits once without claiming review is complete", async () => { + const server = mockServer({ + externalState: "READY_FOR_BETA_SUBMISSION", + assigned: [], + autoNotifyEnabled: false, + existingNotes: null, + }); + const result = await server.client.publish(selection, notes); + expect(result.publicTesting).toBe(false); + expect(result.externalState).toBe("WAITING_FOR_BETA_REVIEW"); + expect(server.writes.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "POST /v1/betaBuildLocalizations", + "PATCH /v1/buildBetaDetails/detail-46", + "POST /v1/betaGroups/internal/relationships/builds", + "POST /v1/betaGroups/public/relationships/builds", + "POST /v1/betaAppReviewSubmissions", + ]); + expect(server.writes.at(-1)?.body).toEqual({ + data: { + type: "betaAppReviewSubmissions", + relationships: { build: linkage("builds", "build-46") }, + }, + }); + await server.client.publish(selection, notes); + expect(server.writes).toHaveLength(5); + }); + + it("notifies an approved build once and rereads the resulting testing state", async () => { + const server = mockServer({ externalState: "BETA_APPROVED", reviewState: "APPROVED" }); + const result = await server.client.publish(selection, notes); + expect(result.publicTesting).toBe(true); + expect(server.writes.map((item) => item.path)).toEqual(["/v1/buildBetaNotifications"]); + await server.client.publish(selection, notes); + expect(server.writes).toHaveLength(1); + }); + + it("leaves automatic notification alone when a new review immediately becomes approved", async () => { + const server = mockServer({ + externalState: "READY_FOR_BETA_SUBMISSION", + approveImmediately: true, + }); + const result = await server.client.publish(selection, notes); + expect(result.externalState).toBe("BETA_APPROVED"); + expect(result.publicTesting).toBe(false); + expect(server.writes.map((item) => item.path)).toEqual(["/v1/betaAppReviewSubmissions"]); + }); + + it("checks missing review linkage before deciding whether a submission exists", async () => { + const server = mockServer({ + externalState: "READY_FOR_BETA_SUBMISSION", + reviewState: "WAITING_FOR_REVIEW", + sparseBetaRelations: true, + }); + const result = await server.client.publish(selection, notes); + expect(result.reviewState).toBe("WAITING_FOR_REVIEW"); + expect(server.writes).toEqual([]); + }); + + it.each([ + { processingState: "PROCESSING" }, + { externalState: "MISSING_EXPORT_COMPLIANCE" }, + { externalState: "BETA_REJECTED", reviewState: "REJECTED" }, + { buildExists: false }, + ])("fails before writes for a build that is not ready: %j", async (options) => { + const server = mockServer(options); + await expect(server.client.publish(selection, notes)).rejects.toThrow(); + expect(server.writes).toEqual([]); + }); + + it("does not retry a write when the response is lost", async () => { + const server = mockServer({ assigned: ["public"], failAfterGroupAssignment: true }); + await expect(server.client.publish(selection, notes)).rejects.toThrow( + "The write was not retried", + ); + expect(server.writes).toHaveLength(1); + expect(server.state.assigned.has("internal")).toBe(true); + await server.client.publish(selection, notes); + expect(server.writes).toHaveLength(1); + }); + + it("refuses another upload of a build that Apple already has", async () => { + const server = mockServer(); + await expect(server.client.verifyUpload(selection)).rejects.toThrow("already exists"); + expect(server.writes).toEqual([]); + }); + + it("verifies app and groups before allowing a new upload", async () => { + const server = mockServer({ buildExists: false }); + await expect(server.client.verifyUpload(selection)).resolves.toBeUndefined(); + expect(server.writes).toEqual([]); + }); +}); + +describe("release command input", () => { + const info = { + CFBundleIdentifier: "com.t3tools.t3code.swiftui", + DTPlatformName: "iphoneos", + CFBundleVersion: "46", + CFBundleShortVersionString: "0.1.0", + }; + const exportOptions = { + method: "app-store-connect", + destination: "upload", + manageAppVersionAndBuildNumber: false, + }; + + it("reads an exact Release build from archive metadata", () => { + expect(validateUploadMetadata(info, exportOptions)).toEqual(selection); + }); + + it.each([ + { ...info, CFBundleIdentifier: "com.t3tools.t3code.swiftui.dev" }, + { ...info, DTPlatformName: "iphonesimulator" }, + ])("rejects a dev or simulator archive", (metadata) => { + expect(() => validateUploadMetadata(metadata, exportOptions)).toThrow("Release SwiftUI app"); + }); + + it.each([ + { ...exportOptions, destination: "export" }, + { ...exportOptions, manageAppVersionAndBuildNumber: true }, + { ...exportOptions, testFlightInternalTestingOnly: true }, + ])("rejects export options that would change or restrict the release", (options) => { + expect(() => validateUploadMetadata(info, options)).toThrow(); + }); + + it("requires explicit release notes for publication", () => { + expect(() => parseTestFlightArgs(["publish", "--build", "46", "--version", "0.1.0"])).toThrow( + "--notes-file", + ); + expect( + parseTestFlightArgs([ + "status", + "--build", + "46", + "--version", + "0.1.0", + "--env-file", + "/tmp/.env.testflight.local", + ]), + ).toMatchObject({ command: "status", ...selection, envFile: "/tmp/.env.testflight.local" }); + }); + + it("requires supplied export options and reads upload versions only from the archive", () => { + expect( + parseTestFlightArgs([ + "upload", + "--archive", + "/tmp/Test.xcarchive", + "--export-options", + "/tmp/ExportOptions.plist", + ]), + ).toMatchObject({ + command: "upload", + archive: "/tmp/Test.xcarchive", + exportOptions: "/tmp/ExportOptions.plist", + }); + expect(() => + parseTestFlightArgs([ + "upload", + "--archive", + "/tmp/Test.xcarchive", + "--export-options", + "/tmp/ExportOptions.plist", + "--build", + "47", + ]), + ).toThrow("reads the build and version from the archive"); + }); +}); diff --git a/scripts/swift-testflight.ts b/scripts/swift-testflight.ts new file mode 100644 index 000000000000..2209f7cb511b --- /dev/null +++ b/scripts/swift-testflight.ts @@ -0,0 +1,680 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalFetch:off preferSchemaOverJson:off - This standalone release tool uses Node APIs and validates JSON at the API boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +const API_ORIGIN = "https://api.appstoreconnect.apple.com"; +const BUNDLE_ID = "com.t3tools.t3code.swiftui"; +const TOKEN_SECONDS = 600; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function record(value: unknown, label: string) { + if (!isRecord(value)) throw new Error(`Expected an object for ${label}.`); + return value; +} + +function text(value: unknown, label: string) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Expected a nonempty string for ${label}.`); + } + return value; +} + +function boolean(value: unknown, label: string) { + if (typeof value !== "boolean") throw new Error(`Expected a boolean for ${label}.`); + return value; +} + +function items(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`Expected a list for ${label}.`); + return value; +} + +function resource(value: unknown, type: string) { + const data = record(value, type); + if (data.type !== type) throw new Error(`Expected an App Store Connect ${type} resource.`); + return { + id: text(data.id, `${type}.id`), + attributes: record(data.attributes ?? {}, `${type}.attributes`), + relationships: record(data.relationships ?? {}, `${type}.relationships`), + }; +} + +type Resource = ReturnType; + +function relationship(value: Resource, name: string, type: string) { + const relation = record(value.relationships[name], `${name} relationship`); + return relation.data === null ? undefined : resource(relation.data, type).id; +} + +export function parseTestFlightEnv(source: string) { + const env = NodeUtil.parseEnv(source); + const config = { + keyId: text(env.T3_SWIFT_ASC_KEY_ID, "T3_SWIFT_ASC_KEY_ID"), + issuerId: text(env.T3_SWIFT_ASC_ISSUER_ID, "T3_SWIFT_ASC_ISSUER_ID"), + appId: text(env.T3_SWIFT_ASC_APP_ID, "T3_SWIFT_ASC_APP_ID"), + publicGroupId: text(env.T3_SWIFT_ASC_PUBLIC_GROUP_ID, "T3_SWIFT_ASC_PUBLIC_GROUP_ID"), + internalGroupId: text(env.T3_SWIFT_ASC_INTERNAL_GROUP_ID, "T3_SWIFT_ASC_INTERNAL_GROUP_ID"), + }; + if (config.publicGroupId === config.internalGroupId) { + throw new Error("Public and internal groups must be different."); + } + const encodedKey = text(env.T3_SWIFT_ASC_PRIVATE_KEY_BASE64, "T3_SWIFT_ASC_PRIVATE_KEY_BASE64"); + let privateKey: NodeCrypto.KeyObject; + try { + const decoded = Buffer.from(encodedKey, "base64"); + if (decoded.toString("base64") !== encodedKey) throw new Error("invalid base64"); + privateKey = NodeCrypto.createPrivateKey(decoded); + } catch { + throw new Error("T3_SWIFT_ASC_PRIVATE_KEY_BASE64 must contain the base64-encoded .p8 file."); + } + requireAppStorePrivateKey(privateKey); + return { config, privateKey }; +} + +type TestFlightConfig = ReturnType["config"]; +type BuildSelection = { build: string; version: string }; + +export async function readTestFlightEnv(path: string) { + let file: NodeFSP.FileHandle | undefined; + let source: string; + try { + file = await NodeFSP.open(expandHome(path), "r"); + const metadata = await file.stat(); + if (!metadata.isFile() || (metadata.mode & 0o077) !== 0) { + throw new Error("unsafe permissions"); + } + source = await file.readFile("utf8"); + } catch { + throw new Error( + `Cannot read TestFlight env file at ${path}. Use a private file with mode 600.`, + ); + } finally { + await file?.close(); + } + return parseTestFlightEnv(source); +} + +function validateBuildSelection(selection: BuildSelection) { + if ( + !/^\d+(?:\.\d+){0,2}$/u.test(selection.build) || + !/^\d+\.\d+(?:\.\d+)?$/u.test(selection.version) + ) { + throw new Error("Use an exact numeric build number and version."); + } + return selection; +} + +function requireAppStorePrivateKey(privateKey: NodeCrypto.KeyObject) { + if ( + privateKey.type !== "private" || + privateKey.asymmetricKeyType !== "ec" || + privateKey.asymmetricKeyDetails?.namedCurve !== "prime256v1" + ) { + throw new Error("App Store Connect requires a P-256 private key."); + } +} + +export function makeAppStoreToken( + config: Pick, + privateKey: NodeCrypto.KeyObject, + nowSeconds = Math.floor(Date.now() / 1_000), +) { + requireAppStorePrivateKey(privateKey); + const header = Buffer.from( + JSON.stringify({ alg: "ES256", kid: config.keyId, typ: "JWT" }), + ).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + iss: config.issuerId, + iat: nowSeconds, + exp: nowSeconds + TOKEN_SECONDS, + aud: "appstoreconnect-v1", + }), + ).toString("base64url"); + const input = `${header}.${payload}`; + const signature = NodeCrypto.sign("sha256", Buffer.from(input), { + key: privateKey, + dsaEncoding: "ieee-p1363", + }); + return `${input}.${signature.toString("base64url")}`; +} + +// All writes use the app and groups verified by status(). No credentials go into command output. +export function createTestFlightClient( + config: TestFlightConfig, + privateKey: NodeCrypto.KeyObject, + fetchImpl: typeof fetch = fetch, +) { + async function request(path: string, method = "GET", body?: unknown): Promise { + const url = new URL(path, API_ORIGIN); + if (url.origin !== API_ORIGIN || !url.pathname.startsWith("/v1/")) { + throw new Error("Refusing an App Store Connect request to an unexpected URL."); + } + const operation = `${method} ${url.pathname}`; + const uncertainWrite = + method === "GET" ? "" : " The write was not retried. Run status before trying again."; + const token = makeAppStoreToken(config, privateKey); + let response: Response; + try { + response = await fetchImpl(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: "error", + signal: AbortSignal.timeout(30_000), + }); + } catch { + throw new Error(`App Store Connect ${operation} failed or timed out.${uncertainWrite}`); + } + if (response.status === 204 && response.ok) return undefined; + const result: unknown = await response.json().catch(() => undefined); + if (!response.ok) { + const details = + isRecord(result) && Array.isArray(result.errors) + ? result.errors + .filter(isRecord) + .map((error) => + [error.code, error.title, error.detail] + .filter((part): part is string => typeof part === "string") + .join(": "), + ) + .join("; ") + .replaceAll(token, "[redacted token]") + : ""; + throw new Error( + `App Store Connect ${operation}: HTTP ${response.status}${details ? ` (${details.slice(0, 1_000)})` : ""}.${uncertainWrite}`, + ); + } + return result; + } + + async function getResource(path: string, type: string) { + const response = record(await request(path), type); + return resource(response.data, type); + } + + async function list(path: string, type: string) { + const result: Resource[] = []; + const visited = new Set(); + let next: string | undefined = path; + while (next) { + if (visited.has(next)) throw new Error("App Store Connect repeated a page URL."); + visited.add(next); + const response = record(await request(next), `${type} response`); + result.push(...items(response.data, type).map((item) => resource(item, type))); + const links = record(response.links ?? {}, `${type} links`); + next = + links.next === undefined || links.next === null ? undefined : text(links.next, "next page"); + } + return result; + } + + async function group(id: string, internal: boolean) { + const value = await getResource( + `/v1/betaGroups/${encodeURIComponent(id)}?include=app`, + "betaGroups", + ); + if (value.id !== id || relationship(value, "app", "apps") !== config.appId) { + throw new Error("Refusing a beta group that does not belong to the configured SwiftUI app."); + } + if (boolean(value.attributes.isInternalGroup, "isInternalGroup") !== internal) { + throw new Error("The configured beta group has the wrong internal/external type."); + } + return { + id, + name: text(value.attributes.name, "group name"), + internal, + allBuilds: value.attributes.hasAccessToAllBuilds === true, + publicLinkEnabled: value.attributes.publicLinkEnabled === true, + }; + } + + async function verifyApp() { + const app = await getResource(`/v1/apps/${encodeURIComponent(config.appId)}`, "apps"); + if (app.id !== config.appId || app.attributes.bundleId !== BUNDLE_ID) { + throw new Error(`Refusing to use an app other than ${BUNDLE_ID}. Check appId.`); + } + return app; + } + + function buildQuery(selection: BuildSelection) { + validateBuildSelection(selection); + return new URLSearchParams({ + "filter[app]": config.appId, + "filter[version]": selection.build, + "filter[preReleaseVersion.version]": selection.version, + "filter[preReleaseVersion.platform]": "IOS", + limit: "2", + }); + } + + async function verifyUpload(selection: BuildSelection) { + await Promise.all([ + verifyApp(), + group(config.internalGroupId, true), + group(config.publicGroupId, false), + ]); + const existing = await list(`/v1/builds?${buildQuery(selection)}`, "builds"); + if (existing.length > 0) { + throw new Error( + `Build ${selection.version} (${selection.build}) already exists. Run status; refusing another upload.`, + ); + } + } + + async function status(selection: BuildSelection) { + const [app, internalGroup, publicGroup] = await Promise.all([ + verifyApp(), + group(config.internalGroupId, true), + group(config.publicGroupId, false), + ]); + const query = buildQuery(selection); + query.set("include", "app,preReleaseVersion,buildBetaDetail,betaAppReviewSubmission"); + const response = record(await request(`/v1/builds?${query}`), "builds response"); + const builds = items(response.data, "builds"); + if (builds.length !== 1) { + throw new Error( + `Expected one SwiftUI build ${selection.version} (${selection.build}); found ${builds.length}.`, + ); + } + const build = resource(builds[0], "builds"); + if ( + relationship(build, "app", "apps") !== app.id || + build.attributes.version !== selection.build + ) { + throw new Error("App Store Connect returned a different app or build. Nothing was changed."); + } + const included = items(response.included ?? [], "included resources").map((value) => + record(value, "included resource"), + ); + function includedResource(name: string, type: string) { + const id = relationship(build, name, type); + if (!id) return undefined; + const value = included.find((item) => item.type === type && item.id === id); + if (!value) throw new Error(`App Store Connect omitted the build's ${name}.`); + return resource(value, type); + } + const version = includedResource("preReleaseVersion", "preReleaseVersions"); + if ( + version?.attributes.version !== selection.version || + version.attributes.platform !== "IOS" + ) { + throw new Error( + "App Store Connect returned a different version or platform. Nothing was changed.", + ); + } + async function optionalBuildResource(name: string, type: string) { + const relation = build.relationships[name]; + if (isRecord(relation) && relation.data !== undefined) return includedResource(name, type); + // A missing linkage is not proof that no review exists. Read the filtered collection. + const query = new URLSearchParams({ "filter[build]": build.id, limit: "2" }); + const values = await list(`/v1/${type}?${query}`, type); + if (values.length > 1) + throw new Error(`App Store Connect returned multiple ${name} resources.`); + return values[0]; + } + const [detail, review] = await Promise.all([ + optionalBuildResource("buildBetaDetail", "buildBetaDetails"), + optionalBuildResource("betaAppReviewSubmission", "betaAppReviewSubmissions"), + ]); + // Apple accepts one relationship filter. The build's app was checked above. + const membershipQuery = new URLSearchParams({ + "filter[builds]": build.id, + limit: "200", + }); + const memberships = await list(`/v1/betaGroups?${membershipQuery}`, "betaGroups"); + const groups = [internalGroup, publicGroup].map((item) => ({ + ...item, + assigned: + memberships.some((member) => member.id === item.id) || (item.internal && item.allBuilds), + })); + const externalState = detail + ? text(detail.attributes.externalBuildState, "externalBuildState") + : undefined; + return { + appId: app.id, + appName: text(app.attributes.name, "app name"), + bundleId: BUNDLE_ID, + primaryLocale: text(app.attributes.primaryLocale, "primaryLocale"), + buildId: build.id, + ...selection, + processingState: text(build.attributes.processingState, "processingState"), + expired: boolean(build.attributes.expired, "expired"), + audience: text(build.attributes.buildAudienceType, "buildAudienceType"), + detailId: detail?.id, + externalState, + internalState: detail + ? text(detail.attributes.internalBuildState, "internalBuildState") + : undefined, + autoNotifyEnabled: detail + ? boolean(detail.attributes.autoNotifyEnabled, "autoNotifyEnabled") + : false, + reviewState: review ? text(review.attributes.betaReviewState, "betaReviewState") : undefined, + groups, + publicTesting: + externalState === "IN_BETA_TESTING" && + groups.some((item) => !item.internal && item.assigned && item.publicLinkEnabled), + }; + } + + function requirePublishable(current: Awaited>) { + if (current.expired) throw new Error("This build has expired. Upload a new build."); + if (current.processingState !== "VALID") { + throw new Error(`Build processing is ${current.processingState}. Nothing was published.`); + } + if (current.audience !== "APP_STORE_ELIGIBLE") { + throw new Error("This build is internal-only and cannot be released to the public beta."); + } + if (!current.groups.some((item) => !item.internal && item.publicLinkEnabled)) { + throw new Error("The configured public beta group does not have an enabled public link."); + } + const allowed = [ + "READY_FOR_BETA_SUBMISSION", + "WAITING_FOR_BETA_REVIEW", + "IN_BETA_REVIEW", + "BETA_APPROVED", + "READY_FOR_BETA_TESTING", + "IN_BETA_TESTING", + ]; + if ( + !current.externalState || + !allowed.includes(current.externalState) || + current.reviewState === "REJECTED" + ) { + throw new Error( + `Build is not ready for publication: ${current.externalState ?? "no beta details"}${current.reviewState ? ` (${current.reviewState})` : ""}.`, + ); + } + return text(current.detailId, "build beta detail ID"); + } + + async function publish(selection: BuildSelection, notes: string) { + const whatsNew = notes.trim(); + if (!whatsNew || whatsNew.length > 4_000) { + throw new Error("TestFlight notes must contain between 1 and 4,000 characters."); + } + let current = await status(selection); + const detailId = requirePublishable(current); + const localizations = await list( + `/v1/builds/${encodeURIComponent(current.buildId)}/betaBuildLocalizations?limit=200`, + "betaBuildLocalizations", + ); + const localization = localizations.find( + (item) => item.attributes.locale === current.primaryLocale, + ); + if (localization && localization.attributes.whatsNew !== whatsNew) { + await request(`/v1/betaBuildLocalizations/${encodeURIComponent(localization.id)}`, "PATCH", { + data: { type: "betaBuildLocalizations", id: localization.id, attributes: { whatsNew } }, + }); + } else if (!localization) { + await request("/v1/betaBuildLocalizations", "POST", { + data: { + type: "betaBuildLocalizations", + attributes: { locale: current.primaryLocale, whatsNew }, + relationships: { build: { data: { type: "builds", id: current.buildId } } }, + }, + }); + } + if (!current.autoNotifyEnabled) { + await request(`/v1/buildBetaDetails/${encodeURIComponent(detailId)}`, "PATCH", { + data: { type: "buildBetaDetails", id: detailId, attributes: { autoNotifyEnabled: true } }, + }); + } + for (const id of [config.internalGroupId, config.publicGroupId]) { + current = await status(selection); + requirePublishable(current); + if (current.groups.some((item) => item.id === id && item.assigned)) continue; + await request(`/v1/betaGroups/${encodeURIComponent(id)}/relationships/builds`, "POST", { + data: [{ type: "builds", id: current.buildId }], + }); + } + current = await status(selection); + requirePublishable(current); + if (current.externalState === "READY_FOR_BETA_SUBMISSION" && !current.reviewState) { + await request("/v1/betaAppReviewSubmissions", "POST", { + data: { + type: "betaAppReviewSubmissions", + relationships: { build: { data: { type: "builds", id: current.buildId } } }, + }, + }); + } else if ( + current.externalState === "READY_FOR_BETA_TESTING" || + current.externalState === "BETA_APPROVED" + ) { + await request("/v1/buildBetaNotifications", "POST", { + data: { + type: "buildBetaNotifications", + relationships: { build: { data: { type: "builds", id: current.buildId } } }, + }, + }); + } + return status(selection); + } + + return { status, publish, verifyUpload }; +} + +export function validateUploadMetadata(infoPlist: unknown, exportOptions: unknown) { + const info = record(infoPlist, "archived app Info.plist"); + if (info.CFBundleIdentifier !== BUNDLE_ID || info.DTPlatformName !== "iphoneos") { + throw new Error("Upload requires an iPhoneOS archive of the Release SwiftUI app."); + } + const options = record(exportOptions, "export options"); + if (options.method !== "app-store-connect" || options.destination !== "upload") { + throw new Error("Export options must use method app-store-connect and destination upload."); + } + if (options.manageAppVersionAndBuildNumber !== false) { + throw new Error("Export options must set manageAppVersionAndBuildNumber to false."); + } + if (options.testFlightInternalTestingOnly === true) { + throw new Error("Refusing an internal-only export for the public TestFlight app."); + } + return validateBuildSelection({ + build: text(info.CFBundleVersion, "CFBundleVersion"), + version: text(info.CFBundleShortVersionString, "CFBundleShortVersionString"), + }); +} + +async function readPlist(path: string): Promise { + const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + try { + const { stdout } = await execFile("/usr/bin/plutil", ["-convert", "json", "-o", "-", path], { + encoding: "utf8", + timeout: 10_000, + maxBuffer: 2 * 1_024 * 1_024, + }); + return JSON.parse(stdout); + } catch { + throw new Error(`Cannot read plist at ${path}.`); + } +} + +async function uploadArchive( + archive: string, + exportOptions: string, + config: TestFlightConfig, + privateKey: NodeCrypto.KeyObject, + verifyUpload: (selection: BuildSelection) => Promise, +) { + const archivePath = NodePath.resolve(expandHome(archive)); + const optionsPath = NodePath.resolve(expandHome(exportOptions)); + const [info, options] = await Promise.all([ + readPlist(NodePath.join(archivePath, "Products/Applications/T3Code.app/Info.plist")), + readPlist(optionsPath), + ]); + const selection = validateUploadMetadata(info, options); + await verifyUpload(selection); + await withTemporaryPrivateKey( + privateKey, + (keyPath) => + new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + "xcodebuild", + [ + "-exportArchive", + "-archivePath", + archivePath, + "-exportOptionsPlist", + optionsPath, + "-authenticationKeyPath", + keyPath, + "-authenticationKeyID", + config.keyId, + "-authenticationKeyIssuerID", + config.issuerId, + ], + { stdio: "inherit" }, + ); + child.once("error", () => reject(new Error("Could not start xcodebuild."))); + child.once("exit", (code) => + code === 0 + ? resolve() + : reject(new Error("Xcode upload failed. Check status before trying another upload.")), + ); + }), + ); + process.stdout.write( + `Xcode uploaded SwiftUI ${selection.version} (${selection.build}). Check status after Apple processes it.\n`, + ); +} + +// Xcode needs a file. REST requests use only the in-memory key. +export async function withTemporaryPrivateKey( + privateKey: NodeCrypto.KeyObject, + operation: (keyPath: string) => Promise, +) { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-swift-testflight-")); + try { + await NodeFSP.chmod(directory, 0o700); + const keyPath = NodePath.join(directory, "AuthKey.p8"); + await NodeFSP.writeFile(keyPath, privateKey.export({ format: "pem", type: "pkcs8" }), { + mode: 0o600, + flag: "wx", + }); + await operation(keyPath); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +} + +export function parseTestFlightArgs(args: string[]) { + const { values, positionals } = NodeUtil.parseArgs({ + args, + allowPositionals: true, + options: { + "env-file": { type: "string" }, + build: { type: "string" }, + version: { type: "string" }, + "notes-file": { type: "string" }, + archive: { type: "string" }, + "export-options": { type: "string" }, + help: { type: "boolean" }, + }, + }); + if (values.help) return { command: "help" as const }; + const command = positionals[0]; + if ( + positionals.length !== 1 || + (command !== "status" && command !== "publish" && command !== "upload") + ) { + throw new Error("Choose status, publish, or upload. Use --help for usage."); + } + const envFile = + values["env-file"] ?? + process.env.T3_SWIFT_TESTFLIGHT_ENV_FILE ?? + NodePath.join(NodeOS.homedir(), ".config/t3code/.env.testflight"); + if (command === "upload") { + if (values.build || values.version || values["notes-file"]) { + throw new Error( + "Upload reads the build and version from the archive; do not supply --build, --version, or --notes-file.", + ); + } + return { + command: "upload" as const, + envFile, + archive: text(values.archive, "--archive"), + exportOptions: text(values["export-options"], "--export-options"), + }; + } + if (values.archive || values["export-options"]) + throw new Error("Archive options are only valid with upload."); + const selection = validateBuildSelection({ + build: text(values.build, "--build"), + version: text(values.version, "--version"), + }); + if (command === "status" && values["notes-file"]) { + throw new Error("--notes-file is only valid with publish."); + } + return command === "publish" + ? { + command: "publish" as const, + envFile, + ...selection, + notesFile: text(values["notes-file"], "--notes-file"), + } + : { command: "status" as const, envFile, ...selection }; +} + +function expandHome(path: string) { + return path.startsWith("~/") ? NodePath.join(NodeOS.homedir(), path.slice(2)) : path; +} + +async function main() { + const args = parseTestFlightArgs(process.argv.slice(2)); + if (args.command === "help") { + process.stdout.write(`Usage: + node scripts/swift-testflight.ts status --build 46 --version 0.1.0 + node scripts/swift-testflight.ts publish --build 46 --version 0.1.0 --notes-file /path/to/notes.txt + node scripts/swift-testflight.ts upload --archive /path/to/T3Code.xcarchive --export-options /path/to/ExportOptions.plist + +Optional: --env-file /path/to/.env or T3_SWIFT_TESTFLIGHT_ENV_FILE. +Default env file: ~/.config/t3code/.env.testflight (mode 600, never committed). +Required variables: T3_SWIFT_ASC_KEY_ID, T3_SWIFT_ASC_ISSUER_ID, +T3_SWIFT_ASC_PRIVATE_KEY_BASE64, T3_SWIFT_ASC_APP_ID, +T3_SWIFT_ASC_PUBLIC_GROUP_ID, T3_SWIFT_ASC_INTERNAL_GROUP_ID. +Only the Release SwiftUI app and the two configured existing groups are supported. +Publish does not upload, create testers, change signing, or expire other builds. +`); + return; + } + const { config, privateKey } = await readTestFlightEnv(args.envFile); + const client = createTestFlightClient(config, privateKey); + if (args.command === "upload") { + await uploadArchive(args.archive, args.exportOptions, config, privateKey, client.verifyUpload); + return; + } + const selection = { build: args.build, version: args.version }; + const result = + args.command === "publish" + ? await client.publish(selection, await NodeFSP.readFile(expandHome(args.notesFile), "utf8")) + : await client.status(selection); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (args.command === "publish") { + process.stdout.write( + result.publicTesting + ? "Public beta testing is active.\n" + : `Public testing is not active yet. Apple reports ${result.externalState ?? result.processingState}. Run status to check again.\n`, + ); + } +} + +if (import.meta.main) { + main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : "TestFlight command failed."}\n`, + ); + process.exitCode = 1; + }); +}