diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md index 8d204ff201bc..5f8d3ed22d9e 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. @@ -31,7 +38,7 @@ Avoid generic Mac window automation for switching among Simulator windows. Expli ## Choose build or launch -For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. +For React Native mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. This helper does not apply to SwiftUI mobile. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. - Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. - Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. 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 afebcb7aa7d5..2af6ec2904ae 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 or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker. @@ -23,7 +32,9 @@ Do not treat unavailable iOS tooling as a blocker when Android is a valid repres Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission. -Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: +For SwiftUI, build or reuse the native `T3Code` scheme with `ios-debugger-agent`. The Expo fingerprint helper and Metro do not apply. + +For React Native, run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: ```bash node scripts/mobile-native-client.ts ensure ios @@ -36,7 +47,7 @@ For a read-only decision, use `check` in place of `ensure`. Exit 0 means compati A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output. -The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” +The React Native development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” ## Start one disposable T3 environment @@ -74,7 +85,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. @@ -92,7 +103,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: @@ -110,7 +121,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 Use the emulator serial already checked by `ensure`: @@ -144,6 +155,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 38df1e94fa8c..a435b67e5f63 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. For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. @@ -149,10 +154,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..75ab62644fd5 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift @@ -0,0 +1,447 @@ +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 { + guard environment.kind == .managedDPoP, + Self.managedEndpoint(for: environment) != nil else { + throw HTTPError.incompatibleCredential + } + 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/NativeConversationRewind.swift b/apps/swift-ios/App/NativeConversationRewind.swift new file mode 100644 index 000000000000..351e74557a03 --- /dev/null +++ b/apps/swift-ios/App/NativeConversationRewind.swift @@ -0,0 +1,93 @@ +import Foundation + +enum NativeConversationRewind { + /// Use checkpoint counts, not visible message indexes. Pages and steering messages + /// do not each represent one completed provider turn. + static func turnCount(before messageID: String, in thread: OrchestrationThread) -> Int? { + guard let index = thread.messages.firstIndex(where: { $0.id == messageID }), + thread.messages[index].role == "user" else { return nil } + let checkpoints = Dictionary( + thread.checkpoints.compactMap { checkpoint in + checkpoint.assistantMessageId.map { ($0, checkpoint.checkpointTurnCount) } + }, + uniquingKeysWith: max + ) + for message in thread.messages.dropFirst(index + 1) { + if message.role == "user" { return nil } + if let count = checkpoints[message.id] { return max(0, count - 1) } + } + return nil + } + + static func isComplete(_ thread: OrchestrationThread, messageID: String, turnCount: Int) -> Bool { + !thread.messages.contains(where: { $0.id == messageID }) + && thread.checkpoints.allSatisfy { $0.checkpointTurnCount <= turnCount } + && (turnCount == 0 + ? thread.latestTurn == nil + : thread.checkpoints.contains { $0.turnId == thread.latestTurn?.turnId }) + } + + /// Command acceptance precedes provider rollback. Wait for its completion event + /// or an authoritative replacement snapshot, including while another thread is open. + static func waitForCompletion( + batches: AsyncThrowingStream<[ThreadStreamItem], Error>, + threadID: String, + messageID: String, + turnCount: Int, + afterSequence: Int, + previousFailureIDs: Set, + timeout: Duration = .seconds(120) + ) async throws -> Int { + try await withThrowingTaskGroup(of: Int.self) { group in + group.addTask { + for try await batch in batches { + for item in batch { + switch item { + case .synchronized: + continue + case let .snapshot(snapshot): + guard snapshot.thread.id == threadID, + snapshot.snapshotSequence > afterSequence else { continue } + if isComplete(snapshot.thread, messageID: messageID, turnCount: turnCount) { + return snapshot.snapshotSequence + } + if let failure = snapshot.thread.activities.last(where: { + $0.kind == "checkpoint.revert.failed" && !previousFailureIDs.contains($0.id) + && $0.payload["turnCount"] == .number(Double(turnCount)) + }) { + throw FeatureConversationRewindError( + message: failure.payload["detail"]?.stringValue ?? failure.summary, + didNotRevert: true + ) + } + case let .event(event): + guard event["payload"]?["threadId"]?.stringValue == threadID, + case let .number(sequence)? = event["sequence"], + sequence > Double(afterSequence) else { continue } + if event["type"]?.stringValue == "thread.activity-appended", + let activity = event["payload"]?["activity"], + activity["kind"]?.stringValue == "checkpoint.revert.failed", + activity["payload"]?["turnCount"] == .number(Double(turnCount)) { + throw FeatureConversationRewindError( + message: activity["payload"]?["detail"]?.stringValue + ?? activity["summary"]?.stringValue ?? "Conversation rewind failed.", + didNotRevert: true + ) + } + if event["type"]?.stringValue == "thread.reverted", + event["payload"]?["turnCount"] == .number(Double(turnCount)) { return Int(sequence) } + } + } + } + throw FeatureConversationRewindError(message: "The connection closed before rewind finished. Reload the thread before trying again.") + } + group.addTask { + try await Task.sleep(for: timeout) + throw FeatureConversationRewindError(message: "Timed out waiting for rewind. Reload the thread before trying again.") + } + defer { group.cancelAll() } + guard let sequence = try await group.next() else { throw CancellationError() } + return sequence + } + } +} diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift new file mode 100644 index 000000000000..008893653031 --- /dev/null +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -0,0 +1,8575 @@ +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, FeatureContextAttachmentResolving, 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 static let gitHubRoutingKey = "swift-ios.github-routing.v1" + private var routedPullRequests: [FeaturePullRequestTarget: Set] = [:] + private var cachedSettings: FeatureSettings? + 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? + /// One reorder at a time: a second move planned from the same keys would + /// land a conflicting write (React Native holds a pending order for this). + private var threadMoveInFlight = false + 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 projectSettingsWriteTask: Task? + private var projectSettingsWriteGeneration: UInt64 = 0 + private struct AcceptedCommandRefresh: Sendable { + let id: UUID + let client: T3Client + let generation: Int + let task: Task + var pending = false + var needsDetail: Bool + } + private var acceptedCommandRefreshes: [String: AcceptedCommandRefresh] = [:] + 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() } + acceptedCommandRefreshes.values.forEach { $0.task.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 activeLoad = loads.first { $0.environment.id == environment.id } + let activeIsReachable = activeLoad?.shell != nil + if activeIsReachable { + scheduleArchivedRefresh(client: activeClient, environment: environment) + } + if activeLoad?.credentialRejected == true { + markActiveEnvironmentNeedsPairing(detail: activeLoad?.failureDetail) + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: environmentConnectionStates[environment.id] ?? .disconnected, + connectionDetail: environmentConnectionDetails[environment.id] + ) + 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 snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: environmentConnectionStates[environment.id] ?? .disconnected, + connectionDetail: environmentConnectionDetails[environment.id] + ) + 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 { + cancelAcceptedCommandRefreshes(environmentID: id) + 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) + } + cancelAcceptedCommandRefreshes(environmentID: id) + try await runtime.remove(id: id) + saveGitHubRoutingGrants(gitHubRoutingGrants.filter { $0.environmentID != id }) + if removesActiveEnvironment { + await clearActiveEnvironment(disconnectClient: false) + } + } + + func disconnect() async { + await clearActiveEnvironment() + } + + 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, + input: ProviderConsumeResetCreditInput + ) 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(input) + } + + 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 } + } + } + + private var gitHubRoutingGrants: [GitHubRoutingGrant] { + guard let data = settingsStore.data(forKey: Self.gitHubRoutingKey) else { return [] } + return (try? JSONDecoder().decode([GitHubRoutingGrant].self, from: data)) ?? [] + } + + private func saveGitHubRoutingGrants(_ grants: [GitHubRoutingGrant]) { + settingsStore.set(try? JSONEncoder().encode(grants), forKey: Self.gitHubRoutingKey) + } + + func gitHubRoutingPermission(environmentID: String) async throws -> GitHubRoutingPermission { + guard let environment = try await runtime.environments().first(where: { $0.id == environmentID }) else { + throw NativeFeatureClientError.environmentNotFound + } + return GitHubRoutingGrant.permission(for: environment, grants: gitHubRoutingGrants) + } + + func setGitHubRoutingPermission(environmentID: String, permission: GitHubRoutingPermission) async throws { + guard let environment = try await runtime.environments().first(where: { $0.id == environmentID }), + let key = GitHubRoutingGrant.connectionKey(environment) else { + throw NativeFeatureClientError.environmentNotFound + } + var grants = gitHubRoutingGrants.filter { $0.environmentID != environmentID } + if permission != .off { + grants.append(GitHubRoutingGrant(environmentID: environmentID, connectionKey: key, permission: permission)) + } + saveGitHubRoutingGrants(grants) + } + + private func routingAllowed(origin: Environment, destination: Environment, write: Bool) async -> Bool { + guard let current = try? await runtime.environments(), + let source = current.first(where: { $0.id == origin.id }), + let target = current.first(where: { $0.id == destination.id }), + GitHubRoutingGrant.connectionKey(source) == GitHubRoutingGrant.connectionKey(origin), + GitHubRoutingGrant.connectionKey(target) == GitHubRoutingGrant.connectionKey(destination) else { return false } + return GitHubRoutingGrant.allowed(origin: source, destination: target, grants: gitHubRoutingGrants, write: write) + } + + /// Only verified GitHub accounts can route. A dispatched write is never retried elsewhere. + private func withPullRequestRoute( + _ target: FeaturePullRequestTarget, write: Bool = false, allowStaleFallback: Bool = false, + operation: (T3Client, PullRequestRef, PullRequestRoutingIdentity?) async throws -> Result + ) async throws -> Result { + let client = try await projectCreationClient(environmentID: target.environmentID) + let environments = try await runtime.environments() + let origin = client.environment + let alternatives = environments.filter { + GitHubRoutingGrant.allowed(origin: origin, destination: $0, grants: gitHubRoutingGrants, write: write) + && environmentConnectionStates[$0.id] == .connected + }.sorted { left, right in + let localHosts = ["localhost", "127.0.0.1", "::1"] + let leftLocal = localHosts.contains(left.httpBaseURL.host ?? "") + let rightLocal = localHosts.contains(right.httpBaseURL.host ?? "") + return leftLocal == rightLocal ? left.id < right.id : leftLocal + } + guard !alternatives.isEmpty, + let identity = try? await client.pullRequestRouting(target.reference), + identity.provider == .github else { + try Task.checkCancellation() + return try await operation(client, target.reference, nil) + } + let reference = PullRequestRef(projectId: target.reference.projectId, + repository: target.reference.repository, number: target.reference.number, + host: identity.host, expectedAccountId: identity.accountId, + allowStale: write ? target.reference.allowStale : false) + for environment in alternatives { + try Task.checkCancellation() + guard await routingAllowed(origin: origin, destination: environment, write: write) else { continue } + guard let alternate = try? await projectCreationClient(environmentID: environment.id), + GitHubRoutingGrant.connectionKey(alternate.environment) == GitHubRoutingGrant.connectionKey(environment), + let account = try? await alternate.pullRequestRoutingIdentity(host: identity.host), + account.provider == .github, account.accountId == identity.accountId, + account.host.caseInsensitiveCompare(identity.host) == .orderedSame, + await routingAllowed(origin: origin, destination: environment, write: write) else { continue } + do { + let result = try await operation(alternate, reference, identity) + if routedPullRequests.count >= 256 { routedPullRequests.removeAll(keepingCapacity: true) } + routedPullRequests[target, default: []].insert(environment.id) + if write { try? await invalidatePullRequests(target) } + return result + } catch { + if write || Task.isCancelled { throw error } + } + } + try Task.checkCancellation() + do { + let result = try await operation(client, reference, identity) + if write { try? await invalidatePullRequests(target) } + return result + } catch { + try Task.checkCancellation() + guard allowStaleFallback, !write, target.reference.allowStale != false else { throw error } + return try await operation(client, target.reference, nil) + } + } + + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail { + try await withPullRequestRoute(target, allowStaleFallback: true) { client, reference, identity in + var detail = try await client.pullRequestDetail(reference) + detail.projectId = target.reference.projectId + if let title = identity?.projectTitle { detail.projectTitle = title } + if let root = identity?.workspaceRoot { detail.workspaceRoot = root } + return detail + } + } + + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + { + try await withPullRequestRoute(target) { client, reference, _ in + try await client.pullRequestActivity(reference) + } + } + + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + { + try await withPullRequestRoute(target) { client, reference, _ in + try await client.pullRequestDiff(PullRequestDiffInput( + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + cursor: cursor, + commit: nil, + host: reference.host, + expectedAccountId: reference.expectedAccountId, + allowStale: reference.allowStale + )) + } + } + + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.runPullRequestAction(reference, action: action, mergeMethod: mergeMethod, updateMethod: updateMethod) + } + } + + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.updatePullRequest(reference, title: title, body: body) + } + } + + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.commentOnPullRequest(reference, body: body) + } + } + + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.submitPullRequestReview( + reference, + verdict: verdict, + body: body, + comments: comments + ) + } + } + + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.replyToPullRequestThread(reference, threadID: threadID, body: body) + } + } + + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.setPullRequestThreadResolved( + reference, + threadID: threadID, + resolved: resolved + ) + } + } + + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.setPullRequestReaction( + reference, + subjectID: subjectID, + content: content, + reacted: reacted + ) + } + } + + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + { + try await withPullRequestRoute(target) { client, reference, _ in + try await client.pullRequestReviewerCandidates(reference) + } + } + + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.requestPullRequestReviewers( + reference, + reviewers: reviewers, + requested: requested + ) + } + } + + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws { + if let target { + try await projectCreationClient(environmentID: target.environmentID) + .invalidatePullRequests(target.reference) + let environments = try await runtime.environments() + if let origin = environments.first(where: { $0.id == target.environmentID }) { + for id in routedPullRequests[target] ?? [] { + guard let destination = environments.first(where: { $0.id == id }), + await routingAllowed(origin: origin, destination: destination, write: false) else { continue } + try? await projectCreationClient(environmentID: id).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 + cancelAcceptedCommandRefreshes() + 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) + // An existing repository does not prove this clone succeeded. A lost + // reply must remain an error until the server confirms the destination. + return try await client.cloneRepository(remoteURL: remoteURL, destinationPath: destinationPath) + } + + 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 selectWorkspaceBranch( + projectID: String, branch: FeatureWorkspaceBranch, mode: FeatureWorkspaceMode + ) async throws -> FeatureWorkspaceBranch { + let route = try projectRoute(for: projectID) + let project = try project(for: route) + return try await NewTaskWorkspaceDefaults.selectBranch(branch, mode: mode) { name in + try await route.client.switchVCSRef(cwd: project.workspaceRoot, name: name).refName + } + } + + 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, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity, + context: OrchestrationMessageContext? = nil + ) 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, + context: context + ) + } + + 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?, + context: OrchestrationMessageContext? = nil + ) 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 await 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, + context: context + ) + 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, + context: context, + 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. Confirm the original message + // before recovery. An empty worktree thread can still be in setup. + let recovered = try await recoverBootstrap( + client: client, + pending: pending, + projectID: route.wireID, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + attachments: uploads, + context: context + ) + guard recovered else { + if pending.worktreeBranchName == nil { + await resetFailedLocalBootstrapIfConfirmed( + client: client, + pending: pending + ) + } + 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], + context: OrchestrationMessageContext? = nil + ) 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 + } + // Setup and its cancellation cleanup own the worktree until the first + // turn is committed. Sending a bare turn can race either operation. + guard pending.worktreeBranchName == nil else { return false } + 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, + context: context, + 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 + } + + /// Reset a local retry only when a fresh shell confirms its thread is gone. + /// Worktree setup and cleanup stay server-owned and keep their stable IDs. + private func resetFailedLocalBootstrapIfConfirmed( + client: T3Client, + pending: PendingBootstrapSubmission + ) async { + guard let shell = try? await client.shellSnapshot(), + !shell.threads.contains(where: { $0.id == pending.threadID }) else { + return + } + + 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) + // Same placement as web and React Native: a fresh pin takes the top of + // the arranged run. Servers that predate reordering get the bare pin + // (keyless, sorted by creation order below keyed threads). + var orderKey: String? = nil + if pinned, cachedThread(id: route.uiID)?.supportsPinReorder == true { + let firstKey = latestSnapshot?.threads + .filter { $0.pinnedAt != nil } + .compactMap(\.pinOrderKey) + .min() + orderKey = ThreadOrderPlanner.orderKeyBetween(before: nil, after: firstKey) + } + _ = try await route.client.pin(threadID: route.wireID, pinned: pinned, orderKey: orderKey) + try? await refresh(client: route.client) + } + + /// Saves a full-section order. Cross-section moves first clear the source + /// lifecycle state. Every key write goes to the thread's owning server. + @discardableResult + func reorderThread( + id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) async throws -> [FeatureThreadOrderAssignment] { + guard !threadMoveInFlight else { return [] } + guard let snapshot = latestSnapshot else { + throw NativeFeatureClientError.threadNotFound + } + // Only currently-connected environments are writable; a disconnected + // server's rows keep their stale keys as anchors but never receive + // writes, so a spread rewrite cannot half-land on a dead client. + let connectedEnvironmentIDs = Set( + snapshot.environments + .filter { $0.isEnabled && environmentConnectionStates[$0.id] == .connected } + .map(\.id) + ) + let threadsByID = Dictionary( + snapshot.threads.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let now = Date.now + let canonical = DailyUXSidebarIndex.orderedSection(snapshot.threads, section: section, now: now) + guard let moved = threadsByID[id], + ThreadArrangementPlanner.canEnter(moved, section: section, now: now), + Set(orderedIDs).count == orderedIDs.count, + Set(orderedIDs) == Set(canonical.map(\.id)).union([id]), + let assignments = ThreadOrderPlanner.planDrop( + ordered: orderedIDs.compactMap { threadsByID[$0] }, + all: snapshot.threads, + section: section, + connectedEnvironmentIDs: connectedEnvironmentIDs, + movedID: id + ) else { + return [] + } + + threadMoveInFlight = true + defer { threadMoveInFlight = false } + + var confirmed: [FeatureThreadOrderAssignment] = [] + var touchedEnvironmentIDs = Set() + var firstError: Error? + let crossesSection = !canonical.contains(where: { $0.id == id }) + if crossesSection { + do { + let route = try threadRoute(for: id) + touchedEnvironmentIDs.insert(route.environmentID) + for action in ThreadArrangementPlanner.lifecycle(moved, section: section, now: now) { + switch action { + case .pin: + let key = assignments.first(where: { $0.threadID == id })?.orderKey + _ = try await route.client.pin(threadID: route.wireID, pinned: true, orderKey: key) + case .unpin: + _ = try await route.client.pin(threadID: route.wireID, pinned: false) + case .unsettle: + _ = try await route.client.settle(threadID: route.wireID, settled: false) + case .unsnooze: + _ = try await route.client.snooze(threadID: route.wireID, until: nil) + } + } + } catch { + firstError = error + } + } + for assignment in assignments { + guard firstError == nil else { break } + do { + let route = try threadRoute(for: assignment.threadID) + // Refresh even when the transport lost a receipt after the + // server accepted the write. + touchedEnvironmentIDs.insert(route.environmentID) + switch section { + case .pinned: + _ = try await route.client.reorderPinnedThread( + threadID: route.wireID, + orderKey: assignment.orderKey + ) + case .active: + _ = try await route.client.reorderActiveThread( + threadID: route.wireID, + orderKey: assignment.orderKey + ) + } + confirmed.append(assignment) + } catch { + // Confirmed writes stand: a later environment rejecting its + // write leaves the earlier arrangement in place. + firstError = error + break + } + } + for environmentID in touchedEnvironmentIDs { + if let client = environmentClients[environmentID] { + try? await refresh(client: client) + } + } + if let firstError { + guard confirmed.isEmpty else { + throw FeatureThreadMovePartialError( + confirmed: confirmed, + underlying: firstError + ) + } + throw firstError + } + return confirmed + } + + func setThreadPullRequest(id: String, url: String, linked: Bool) async throws { + let route = try threadRoute(for: id) + guard let thread = cachedThread(id: route.uiID), + thread.supportsMultiplePullRequests == true || thread.supportsPullRequestLinking == true else { + throw NativeFeatureClientError.invalidPullRequestLink + } + let existing = thread.pullRequests?.first { $0.url == url } + let legacyKey = thread.linkedPullRequest.flatMap { link -> ThreadPullRequestKey? in + guard link.url == url, let host = URL(string: link.url)?.host else { return nil } + return ThreadPullRequestKey(host: host, repository: link.repository, number: link.number) + } + guard let key = existing?.id ?? ThreadPullRequests.parseURL(url) ?? legacyKey else { + throw NativeFeatureClientError.invalidPullRequestLink + } + let project = latestSnapshot?.projects.first { + $0.environmentID == route.environmentID + && $0.repositoryIdentity.map { key.matchesRepository($0.canonicalKey) } == true + } + guard let command = ThreadPullRequests.mutation( + threadID: route.wireID, key: key, url: url, linked: linked, + multiple: thread.supportsMultiplePullRequests == true, + legacyProjectID: project.flatMap { projectWireIDs[$0.id] }, + legacyRepository: key.host == "dev.azure.com" ? key.repository.components(separatedBy: "/_git/").last : nil + ) else { throw NativeFeatureClientError.invalidPullRequestLink } + _ = try await route.client.dispatch(command) + try? await refresh(client: route.client) + if activeThreadID == route.uiID { try? await refreshThread(id: route.uiID, 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) + // A pending accepted-command refresh would publish the deleted thread again. + cancelAcceptedCommandRefreshes(threadID: route.uiID) + 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, fresh: Bool) async throws -> FeatureThreadDetail { + let route = try threadRoute(for: id) + if let previous = activeThreadID, previous != route.uiID { + cancelAcceptedCommandRefreshes(threadID: previous) + } + 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) { + cancelAcceptedCommandRefreshes(threadID: id) + 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?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity, + context: OrchestrationMessageContext? = nil + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: runtimeMode, + attachments: attachments, + submissionIdentity: identity, + context: context + ) + } + + private func sendMessageResolved( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode requestedRuntimeMode: FeatureRuntimeMode?, + attachments: [FeatureUploadAttachment], + submissionIdentity: FeatureSubmissionIdentity?, + context: OrchestrationMessageContext? = nil + ) 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 await 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, + context: context + ) + 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, + context: context, + 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 + } + scheduleAcceptedCommandRefresh(threadID: route.uiID, client: client, generation: generation) + } + + /// Complete accepted commands without waiting for optional reads. A send + /// needs detail and shell recovery; Stop retains its shell-only refresh. + private func scheduleAcceptedCommandRefresh( + threadID: String, client: T3Client, generation: Int, refreshDetail: Bool = true + ) { + guard isKnownClient(client, environmentID: client.environment.id, generation: generation) else { + return + } + if let current = acceptedCommandRefreshes[threadID], + current.client === client, current.generation == generation { + acceptedCommandRefreshes[threadID]?.pending = true + acceptedCommandRefreshes[threadID]?.needsDetail = current.needsDetail || refreshDetail + return + } + cancelAcceptedCommandRefreshes(threadID: threadID) + let id = UUID() + let task = Task { [weak self] in + while self?.isCurrentAcceptedCommandRefresh(threadID: threadID, id: id) == true { + let needsDetail = self?.acceptedCommandRefreshes[threadID]?.needsDetail == true + self?.acceptedCommandRefreshes[threadID]?.needsDetail = false + self?.acceptedCommandRefreshes[threadID]?.pending = false + if needsDetail { + try? await self?.refreshThread(id: threadID, client: client) { [weak self] in + guard self?.acceptedCommandRefreshes[threadID]?.pending == true else { return false } + // The discarded detail must be read again, even for a shell-only Stop. + self?.acceptedCommandRefreshes[threadID]?.needsDetail = true + return true + } + } + guard self?.isCurrentAcceptedCommandRefresh(threadID: threadID, id: id) == true else { break } + try? await self?.refresh(client: client) + guard self?.acceptedCommandRefreshes[threadID]?.pending == true else { break } + } + if self?.acceptedCommandRefreshes[threadID]?.id == id { + self?.acceptedCommandRefreshes[threadID] = nil + } + } + acceptedCommandRefreshes[threadID] = AcceptedCommandRefresh( + id: id, client: client, generation: generation, task: task, needsDetail: refreshDetail + ) + } + + private func isCurrentAcceptedCommandRefresh(threadID: String, id: UUID) -> Bool { + guard !Task.isCancelled, let refresh = acceptedCommandRefreshes[threadID], refresh.id == id else { + return false + } + return isKnownClient( + refresh.client, environmentID: refresh.client.environment.id, generation: refresh.generation + ) + } + + private func cancelAcceptedCommandRefreshes(threadID: String? = nil, environmentID: String? = nil) { + for (key, refresh) in acceptedCommandRefreshes { + guard threadID.map({ $0 == key }) ?? true, + environmentID.map({ $0 == refresh.client.environment.id }) ?? true else { continue } + refresh.task.cancel() + acceptedCommandRefreshes[key] = nil + } + } + + 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 generation = environmentGeneration + let turnID = shellsByEnvironmentID[route.environmentID]?.threads + .first(where: { $0.id == route.wireID })? + .latestTurn? + .turnId + _ = try await route.client.interrupt(threadID: route.wireID, turnID: turnID) + scheduleAcceptedCommandRefresh( + threadID: route.uiID, client: route.client, generation: generation, refreshDetail: false + ) + } + + func canRewindConversation(threadID: String, messageID: String) -> Bool { + guard let route = try? threadRoute(for: threadID), + let thread = activeThreadID == route.uiID + ? activeRawThread : threadResumeStates[route.uiID]?.thread, + let provider = serverConfigsByEnvironmentID[route.environmentID]?.providers.first(where: { + $0.instanceId == (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId) + }), + provider.supportsConversationRollback != false, + provider.driver != "cursor", provider.driver != "grok" else { return false } + return NativeConversationRewind.turnCount(before: messageID, in: thread) != nil + } + + func rewindConversation( + threadID: String, messageID: String, + prepareRecovery: @MainActor (FeatureRevertedMessage) async throws -> Void + ) async throws { + let route = try threadRoute(for: threadID) + let generation = environmentGeneration + // The visible page can omit checkpoints. Validate against the whole thread. + let snapshot = try await route.client.threadSnapshot(id: route.wireID) + guard let provider = serverConfigsByEnvironmentID[route.environmentID]?.providers.first(where: { + $0.instanceId == (snapshot.thread.session?.providerInstanceId ?? snapshot.thread.modelSelection.instanceId) + }), provider.supportsConversationRollback != false, + provider.driver != "cursor", provider.driver != "grok" else { + throw FeatureCapabilityUnavailable("Conversation rewind for this provider") + } + guard snapshot.thread.session?.status != "running", + snapshot.thread.session?.status != "starting", + let turnCount = NativeConversationRewind.turnCount(before: messageID, in: snapshot.thread), + let original = snapshot.thread.messages.first(where: { $0.id == messageID }) else { + throw FeatureConversationRewindError(message: "Wait for this turn to finish before rewinding.") + } + let message = mapMessage(original, environmentID: route.environmentID) + let fileStore = ManagedAttachmentFileStore() + var attachments: [FeatureDraftAttachment] = [] + var recoveryIsStored = false + defer { + if !recoveryIsStored { + for attachment in attachments { + if let file = attachment.ownedFile { + try? fileStore.removeOwnedFile(fileName: file.fileName) + } + } + } + } + // Rewind removes the old server assets, even when workspace files are kept. + for attachment in message.attachments { + let url = try await attachmentAssetURL(threadID: route.uiID, attachment: attachment) + let (temporaryURL, response) = try await URLSession.shared.download(from: url) + defer { try? FileManager.default.removeItem(at: temporaryURL) } + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode) else { + throw FeatureConversationRewindError(message: "Could not save \(attachment.name) before rewind.") + } + let id = UUID() + let file = try await Task.detached { + try fileStore.copyOwnedFile( + from: temporaryURL, attachmentID: id, originalFileName: attachment.name + ) + }.value + attachments.append(FeatureDraftAttachment( + id: id, ownedFile: file, filename: attachment.name, mimeType: attachment.mimeType, + source: attachment.source + )) + } + try await prepareRecovery(FeatureRevertedMessage(message: message, attachments: attachments)) + recoveryIsStored = true + let subscription: (events: AsyncThrowingStream<[ThreadStreamItem], Error>, connectionID: UUID) + do { + try Task.checkCancellation() + guard isKnownClient(route.client, environmentID: route.environmentID, generation: generation) else { + throw CancellationError() + } + subscription = try await route.client.threadEventBatches( + threadID: route.wireID, after: snapshot.snapshotSequence + ) + } catch { + throw FeatureConversationRewindError(message: error.localizedDescription, didNotRevert: true) + } + // Keep events that arrive before dispatch replies. The pump owns socket + // cancellation even when dispatch rejects before completion tracking starts. + let buffered = AsyncThrowingStream<[ThreadStreamItem], Error>.makeStream(bufferingPolicy: .bufferingOldest(64)) + let pump = Task { + do { + for try await batch in subscription.events { + if case .dropped = buffered.continuation.yield(batch) { + throw FeatureConversationRewindError(message: "Rewind updates fell behind. Reload the thread to check its history.") + } + } + buffered.continuation.finish() + } catch { + buffered.continuation.finish(throwing: error) + } + } + defer { + pump.cancel() + buffered.continuation.finish() + } + do { + let accepted: DispatchResult + do { + accepted = try await route.client.dispatch(OrchestrationCommands.revertConversation( + threadID: route.wireID, turnCount: turnCount + )) + } catch let error as RPCError { + if case .remote = error { + throw FeatureConversationRewindError(message: error.localizedDescription, didNotRevert: true) + } + throw error + } catch let error as HTTPError { + if case let .status(code, _, _) = error, (400..<500).contains(code) { + throw FeatureConversationRewindError(message: error.localizedDescription, didNotRevert: true) + } + throw error + } + let receiptSequence = try await NativeConversationRewind.waitForCompletion( + batches: buffered.stream, + threadID: route.wireID, + messageID: messageID, + turnCount: turnCount, + afterSequence: accepted.sequence, + previousFailureIDs: Set(snapshot.thread.activities.filter { + $0.kind == "checkpoint.revert.failed" + }.map(\.id)) + ) + let current = try await route.client.threadSnapshot(id: route.wireID) + guard current.snapshotSequence >= receiptSequence, + NativeConversationRewind.isComplete(current.thread, messageID: messageID, turnCount: turnCount) else { + throw FeatureConversationRewindError(message: "Could not confirm the final rewind state. The prompt remains saved for recovery.") + } + } catch { + if (error as? FeatureConversationRewindError)?.didNotRevert == true { throw error } + // A lost socket does not mean rollback failed. An HTTP read can + // confirm completion without submitting the destructive command again. + guard let current = try? await route.client.threadSnapshot(id: route.wireID), + current.snapshotSequence > snapshot.snapshotSequence, + NativeConversationRewind.isComplete(current.thread, messageID: messageID, turnCount: turnCount) else { + throw error + } + } + // Do not turn a failed refresh into a failed rewind. The receipt confirms + // that history changed, so the recovered prompt must still reach the draft. + threadResumeStates[route.uiID] = nil + do { + try await refreshThread(id: route.uiID, client: route.client) + } catch { + continuation.yield(.threadSync(id: route.uiID, state: .failed(error.localizedDescription))) + } + } + + 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) + await refreshThreadUnlessLive(id: route.uiID, client: route.client) + } + + func resolveUserInput( + id: String, answers: [String: FeatureInputAnswer], + attachmentsByQuestionID: [String: [FeatureUploadAttachment]] + ) async throws { + guard let request = inputRoutes[id] else { + throw NativeFeatureClientError.inputRequestNotFound + } + let route = try threadRoute(for: request.threadID) + var uploads: [String: [UploadChatAttachment]] = [:] + for (questionID, attachments) in attachmentsByQuestionID { + uploads[questionID] = try await makeUploadAttachments(attachments) + } + _ = try await route.client.respondToUserInput( + threadID: route.wireID, + requestID: request.wireID, + answers: answers.mapValues(\.jsonValue), + attachmentsByQuestionID: uploads + ) + inputRoutes[id] = nil + removeCachedInput(id: id, threadID: route.uiID) + await refreshThreadUnlessLive(id: route.uiID, client: route.client) + } + + func dismissUserInput(id: String) async throws { + guard let request = inputRoutes[id], + detailRenderCaches[request.threadID]?.userInputs.first(where: { $0.id == id })?.canDismiss == true else { + throw NativeFeatureClientError.inputRequestNotFound + } + let route = try threadRoute(for: request.threadID) + _ = try await route.client.dismissUserInput(threadID: route.wireID, requestID: request.wireID) + inputRoutes[id] = nil + removeCachedInput(id: id, threadID: route.uiID) + await refreshThreadUnlessLive(id: route.uiID, client: route.client) + } + + func saveSettings(_ settings: FeatureSettings) async throws { + let data = try JSONEncoder().encode(settings) + settingsStore.set(data, forKey: Self.settingsKey) + cachedSettings = settings + 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 } + } + + func projectPreferences(projectID: String) async throws -> FeatureProjectPreferences { + let route = try projectRoute(for: projectID) + let settings = try await serverPreferences(environmentID: route.environmentID) + let project = try project(for: route) + let providers = serverConfigsByEnvironmentID[route.environmentID]?.providers ?? [] + return FeatureProjectPreferences( + environment: settings, + effective: settings.resolvingProject( + id: route.wireID, legacyModelSelection: project.defaultModelSelection, + legacyWorkspaceMode: project.defaultThreadEnvMode, + disabledProviderIDs: Set(providers.filter { !providerCanRun($0) }.map(\.instanceId)) + ) + ) + } + + func updateProjectPreferences(projectID: String, change: ServerProjectSettingChange) async throws { + // Entries replace the entire project's overrides. Serialize local edits + // and read the latest entry only after the previous write completes. + let predecessor = projectSettingsWriteTask + let write = Task { @MainActor [self] in + if let predecessor { _ = try? await predecessor.value } + let route = try projectRoute(for: projectID) + let generation = environmentGeneration + let config = try await route.client.serverConfig() + guard isKnownClient(route.client, environmentID: route.environmentID, generation: generation) else { + throw CancellationError() + } + guard config.environment?.capabilities.projectSettingsOverrides == true else { + throw FeatureCapabilityUnavailable("Project preferences") + } + let settings = try await route.client.serverSettings() + if change.key == .responseStreamingMode, settings.responseStreamingMode == nil { + throw FeatureCapabilityUnavailable("Response streaming preferences") + } + if change.key == .continueThreadsAfterServerUpdate, + config.environment?.capabilities.threadRestartContinuation != true { + throw FeatureCapabilityUnavailable("Restart continuation") + } + _ = try await saveServerPreferences( + client: route.client, environmentID: route.environmentID, + change: change.patch(projectID: route.wireID, settings: settings) + ) + } + projectSettingsWriteGeneration &+= 1 + let generation = projectSettingsWriteGeneration + projectSettingsWriteTask = write + defer { + if projectSettingsWriteGeneration == generation { projectSettingsWriteTask = nil } + } + try await write.value + } + + 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 .responseStreamingMode: + guard config.settings?.responseStreamingMode != nil else { + throw FeatureCapabilityUnavailable("Response streaming preferences") + } + case .projectSettingsOverrides: + guard config.environment?.capabilities.projectSettingsOverrides == true else { + throw FeatureCapabilityUnavailable("Project preferences") + } + 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) + switch supportedChange { + case .environmentIcon, .projectSettingsOverrides, .responseStreamingMode: return + default: break + } + 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 generation = environmentGeneration + let directory = NativeWorkspaceMapper.directoryPath(path, workspaceRoot: context.cwd) + let result = try await route.client.listProjectEntries( + cwd: context.cwd, + directoryPath: directory + ) + guard isKnownClient(route.client, environmentID: route.environmentID, generation: generation), + try workspaceContext(route: route).cwd == context.cwd else { + throw CancellationError() + } + return NativeWorkspaceMapper.files(result.entries, directory: directory, workspaceRoot: context.cwd) + } + + 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 uploads = try await makeUploadAttachments([attachment]) + let prepared = try await client.prepareAttachment(uploads[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, + pullRequests: thread.pullRequests, + 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, + pinOrderKey: thread.pinOrderKey, + 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.shellEventBatches(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 batch in events { + guard !Task.isCancelled, + let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) + else { + break + } + self.lastShellEventAt = .now + self.emitConnection(.connected) + var deltas: [ShellStreamItem] = [] + for item in batch { + switch item { + case let .snapshot(shell): + await self.consume(deltas: deltas, client: activeClient, generation: generation) + deltas.removeAll(keepingCapacity: true) + await self.consume( + shell: shell, + client: activeClient, + generation: generation, + refreshActiveThread: true + ) + case .projectUpserted, .projectRemoved, .threadUpserted, .threadRemoved: + deltas.append(item) + case .refreshRequired: + await self.consume(deltas: deltas, client: activeClient, generation: generation) + deltas.removeAll(keepingCapacity: true) + if let shell = try? await activeClient.shellSnapshot() { + await self.consume( + shell: shell, + client: activeClient, + generation: generation, + refreshActiveThread: true + ) + } + case .synchronized: + break + } + } + await self.consume(deltas: deltas, client: activeClient, generation: generation) + } + } catch is CancellationError { + return + } catch { + if error.isRejectedAuthorization, + let self, + self.isCurrentSession(client: activeClient, generation: generation) { + self.markActiveEnvironmentNeedsPairing(detail: error.localizedDescription) + return + } + // 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 + } + if error.isRejectedAuthorization { + self.markActiveEnvironmentNeedsPairing(detail: error.localizedDescription) + 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.credentialRejected { + // Only re-pairing changes this. Re-pairing replaces + // the runtime client and restarts this loop. + failureBackoffs[load.environment.id] = .seconds(24 * 60 * 60) + } else 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(deltas: [ShellStreamItem], client: T3Client, generation: Int) async { + guard !deltas.isEmpty, !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 + } + + var projects = current.projects + var threads = current.threads + var sequence = current.snapshotSequence + var changedThreadIDs: Set = [] + var shouldRefreshArchived = false + + for delta in deltas { + let nextSequence: Int + switch delta { + case let .projectUpserted(value, _), let .projectRemoved(value, _), + let .threadUpserted(value, _), let .threadRemoved(value, _): + nextSequence = value + case .snapshot, .synchronized, .refreshRequired: + continue + } + + // Replayed deltas are expected after reconnect. They must be entirely + // side-effect free, including for cached detail and selection state. + guard nextSequence > sequence else { continue } + sequence = nextSequence + + 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): + changedThreadIDs.insert(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 + ) + changedThreadIDs.insert(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: + continue + } + } + guard sequence > current.snapshotSequence else { 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 activeThreadID, changedThreadIDs.contains(activeThreadID) { + scheduleDetailRefresh(threadID: activeThreadID, 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.threadEventBatches( + 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 items in subscription.events { + if items.contains(where: { if case .synchronized = $0 { true } else { false } }) { + 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.consumeDetailStreamBatch( + items, 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 consumeDetailStreamBatch( + _ items: [ThreadStreamItem], + route: NativeThreadRoute, + subscriptionEpoch: Int + ) { + // Keep snapshot replacement and page-watermark merges at their event + // positions. Ordinary replay batches share one legacy sync publication. + let previousSequence = activeThreadSequence + let needsIndividualUpdates = items.count == 1 || activeRawThread == nil + || pendingOlderThreadPage != nil || items.contains { item in + switch item { + case .snapshot: true + case let .event(event): + event["type"]?.stringValue == "thread.reverted" + || event["type"]?.stringValue == "thread.deleted" + case .synchronized: false + } + } + for item in items { + consumeDetailStreamItem( + item, route: route, subscriptionEpoch: subscriptionEpoch, + synchronizeLegacy: needsIndividualUpdates + ) + } + if !needsIndividualUpdates, activeThreadSequence != previousSequence, + serverConfigsByEnvironmentID[route.environmentID]?.threadResumeCompletionMarker != true { + markDetailSynchronized(route) + } + } + + private func consumeDetailStreamItem( + _ item: ThreadStreamItem, + route: NativeThreadRoute, + subscriptionEpoch: Int, + synchronizeLegacy: Bool + ) { + 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 synchronizeLegacy, + 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: OrchestrationShellSnapshot? + do { + shell = try await pair.client.shellSnapshot( + timeoutInterval: shellTimeoutInterval + ) + } catch { + return EnvironmentShellLoad( + environment: pair.environment, + client: pair.client, + shell: nil, + config: nil, + credentialRejected: error.isRejectedAuthorization, + failureDetail: error.localizedDescription + ) + } + + 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 if load.credentialRejected && load.environment.kind != .managedDPoP { + environmentConnectionStates[load.environment.id] = .needsPairing + environmentConnectionDetails[load.environment.id] = Self.needsPairingDetail + } else { + environmentConnectionStates[load.environment.id] = .disconnected + environmentConnectionDetails[load.environment.id] = + load.failureDetail ?? "That server is currently unreachable." + } + } + rebuildEntityIndexes(savedEnvironments) + } + + static let needsPairingDetail = "This computer no longer accepts the saved pairing. Pair again." + + /// A rejected credential cannot recover on its own. Stop the live + /// subscription and the HTTP fallback so the app is not minting tickets + /// and polling a 401 every few seconds until the user pairs again. + private func markActiveEnvironmentNeedsPairing(detail: String?) { + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + pollingTask = nil + fallbackPollingTask = nil + configurationTask = nil + lastShellEventAt = nil + if activeEnvironment?.kind == .managedDPoP { + emitConnection(.disconnected, detail: detail) + } else { + emitConnection(.needsPairing, detail: Self.needsPairingDetail) + } + if let client { + Task { await client.disconnect() } + } + } + + 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() + // A cancelled refresh (for example, of a thread deleted meanwhile) must not + // write a shell that was read before the change. + guard !Task.isCancelled, + 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) + } + } + } + + /// After a request is answered, the detail stream delivers the matching + /// `approval.resolved` or `user-input.resolved` event. Re-reading the + /// snapshot here would replace `activeRawThread` with the first page and + /// drop every "Load earlier" page the user opened. Only fall back to a + /// read when the stream is not synchronized for this thread. + private func refreshThreadUnlessLive(id: String, client: T3Client) async { + if activeThreadID == id, detailWasSynchronized { return } + try? await refreshThread(id: id, client: client) + } + + private func refreshThread( + id: String, client: T3Client, expectedStreamGeneration: Int? = nil, + isSuperseded: (() -> Bool)? = 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() + } + // A newer accepted command makes this snapshot stale; its owner reads again. + if isSuperseded?() == true { return } + 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 capabilities = threadCapabilities(for: environment) + detail.thread.supportsSettlement = capabilities?.threadSettlement + detail.thread.supportsSnooze = capabilities?.threadSnooze + detail.thread.supportsPinning = capabilities?.threadPinning + detail.thread.supportsTitleRegeneration = capabilities?.threadTitleRegeneration + detail.thread.supportsPullRequestLinking = capabilities?.threadPullRequestLinking + detail.thread.supportsMultiplePullRequests = capabilities?.threadPullRequests + 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 activity.kind == "user-input.answer-submitted" { + changedIDs.formUnion(NativeQuestionAnswerHistory.messages( + activity, createdAt: parseDate(activity.createdAt) + ).map(\.id)) + } + 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 latestSnapshot != nil { + latestSnapshot?.connection = connection + if let index = latestSnapshot?.environments.firstIndex(where: { $0.id == environment.id }) { + latestSnapshot?.environments[index].connectionState = state + latestSnapshot?.environments[index].connectionDetail = detail + } + } + // A reconnect blip used to republish the whole snapshot twice; the + // model patches the connection and environment row from this event. + continuation.yield(.connection(connection, environmentID: environment.id)) + } + + 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 projectConfig = serverConfigsByEnvironmentID[environment.id] + let projectSettings = projectConfig?.settings ?? ServerSettingsSnapshot() + let disabledProviderIDs = Set((projectConfig?.providers ?? []).filter { !providerCanRun($0) }.map(\.instanceId)) + let supportsProjectSettings = projectConfig?.environment?.capabilities.projectSettingsOverrides == true + let mappedProjects = projection.mapProjects( + shellsByEnvironmentID[environment.id]?.projects ?? [], + settings: projectSettings, + disabledProviderIDs: disabledProviderIDs, + supportsProjectSettings: supportsProjectSettings + ) { project in + let effective = projectSettings.resolvingProject( + id: project.id, legacyModelSelection: project.defaultModelSelection, + legacyWorkspaceMode: project.defaultThreadEnvMode, + disabledProviderIDs: disabledProviderIDs + ) + 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: effective.defaultModelSelection.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 + mapped.defaultWorkspaceMode = effective.defaultThreadEnvMode == .worktree ? .worktree : .local + mapped.newWorktreesStartFromOrigin = effective.newWorktreesStartFromOrigin + mapped.supportsProjectSettingsOverrides = supportsProjectSettings + 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 + } + + /// The live server config is the source of truth for capabilities, the + /// same way the web sidebar reads them. The pair-time descriptor only + /// covers the window before the first config subscription lands, so a + /// server upgrade takes effect without re-pairing. + private func threadCapabilities(for environment: Environment) -> EnvironmentDescriptor.Capabilities? { + serverConfigsByEnvironmentID[environment.id]?.environment?.capabilities + ?? environment.descriptor?.capabilities + } + + private func mapThread( + _ thread: OrchestrationThreadShell, + environment: Environment + ) -> FeatureThread { + let backgroundLiveness = thread.backgroundLiveness + let backgroundWorkIsActive = backgroundLiveness == .working + let capabilities = threadCapabilities(for: environment) + 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, + pullRequests: thread.pullRequests, + 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, + sessionProviderID: thread.session?.providerInstanceId, + 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.flatMap(parseValidDate), + 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), + pinOrderKey: thread.pinOrderKey, + supportsSettlement: capabilities?.threadSettlement, + supportsSnooze: capabilities?.threadSnooze, + supportsPinning: capabilities?.threadPinning, + supportsPinReorder: capabilities?.threadPinReorder, + supportsActiveReorder: capabilities?.threadActiveReorder, + supportsTitleRegeneration: capabilities?.threadTitleRegeneration, + supportsPullRequestLinking: capabilities?.threadPullRequestLinking, + supportsMultiplePullRequests: capabilities?.threadPullRequests, + isRegeneratingTitle: thread.titleRegeneration != nil, + 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 + let capabilities = threadCapabilities(for: environment) + 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, + pullRequests: thread.pullRequests, + 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, + sessionProviderID: thread.session?.providerInstanceId, + 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.flatMap(parseValidDate), + 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), + pinOrderKey: thread.pinOrderKey, + supportsSettlement: capabilities?.threadSettlement, + supportsSnooze: capabilities?.threadSnooze, + supportsPinning: capabilities?.threadPinning, + supportsPinReorder: capabilities?.threadPinReorder, + supportsActiveReorder: capabilities?.threadActiveReorder, + supportsTitleRegeneration: capabilities?.threadTitleRegeneration, + supportsPullRequestLinking: capabilities?.threadPullRequestLinking, + supportsMultiplePullRequests: capabilities?.threadPullRequests, + isRegeneratingTitle: thread.titleRegeneration != nil, + 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, + pullRequests: loaded.pullRequests, + 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, + pinOrderKey: loaded.pinOrderKey, + titleRegeneration: loaded.titleRegeneration, + 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) + } + for answer in NativeQuestionAnswerHistory.messages(activity, createdAt: parseDate(activity.createdAt)) { + upsertMergedMessage(answer, 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 + } + var request = FeatureUserInput( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + questions: questions + ) + request.dismissible = activity.payload["responseMode"]?.stringValue == "message" + request.supportsAttachments = (serverConfigsByEnvironmentID[environment.id]?.environment + ?? environment.descriptor)?.capabilities.questionAttachments == true + 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), + source: $0.source.flatMap { try? $0.decode(PastedTextAttachmentSource.self) } + ) + }, + context: message.context + ) + } + + /// 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 + } + guard !options.isEmpty || question["allowCustomAnswer"] != .bool(false) else { return nil } + var mapped = FeatureInputQuestion( + id: id, + header: header, + question: text, + options: options, + allowsMultiple: allowsMultiple + ) + if case let .bool(value)? = question["allowCustomAnswer"] { mapped.allowCustomAnswer = value } + return mapped + } + } + + nonisolated static func resolveThreadState( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession?, + hasApprovals: Bool, + hasUserInput: Bool, + backgroundLiveness: OrchestrationBackgroundLiveness? + ) -> FeatureThreadState { + // Same rules as the web sidebar (resolveSidebarThreadStatus): the + // session owns working and failed. `latestTurn.state` is not consulted + // because a session that died mid-turn leaves the turn "running" + // forever, and a recovered session leaves an old turn "error". + if hasApprovals { return .waitingForApproval } + if hasUserInput { return .waitingForInput } + if session?.status == "starting" { return .queued } + if session?.status == "running" { return .working } + if session?.status == "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 + ) { + // The shell is the freshest source for the title. A cached detail can + // still carry the pre-regeneration title after the server renamed it. + thread.title = shell.title + thread.isRegeneratingTitle = shell.titleRegeneration != nil + 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.pinnedAt = shell.pinnedAt.flatMap(parseValidDate) + thread.pinOrderKey = shell.pinOrderKey + thread.linkedPullRequest = shell.linkedPullRequest + thread.pullRequests = shell.pullRequests + 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]?.environment?.capabilities + != config.environment?.capabilities { + shellProjectionCache[environmentID] = nil + } + 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: ProviderInstanceDisplay.name( + instanceID: provider.instanceId, driver: provider.driver, + displayName: provider.displayName + ), + 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.accentColor = ProviderInstanceDisplay.accentColor(provider.accentColor) + 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) + } + let project = shell?.projects.first(where: { $0.id == projectID }) + let config = serverConfigsByEnvironmentID[environmentID] + let settings = config?.settings ?? ServerSettingsSnapshot() + let effective = settings.resolvingProject( + id: projectID, legacyModelSelection: project?.defaultModelSelection, + legacyWorkspaceMode: project?.defaultThreadEnvMode, + disabledProviderIDs: Set((config?.providers ?? []).filter { !providerCanRun($0) }.map(\.instanceId)) + ) + if let projectDefault = effective.defaultModelSelection { + return projectDefault + } + 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 + } + + func contextAttachmentAssetURL( + environmentID: String, attachment: ComposerContextRecord.Attachment + ) async throws -> URL { + try Task.checkCancellation() + guard try await runtime.environments().contains(where: { $0.id == environmentID && $0.isEnabled }) else { + throw ComposerContextClipboardError.sourceUnavailable + } + let client = try await projectCreationClient(environmentID: environmentID) + let generation = environmentGeneration + let resolved = try await client.resolvedAsset(resource: .attachment( + id: attachment.attachmentId, fileName: attachment.name, mimeType: attachment.mimeType + )) + try Task.checkCancellation() + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + 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" + 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] + ) async throws -> [UploadChatAttachment] { + guard attachments.count <= 8 else { + throw NativeFeatureClientError.tooManyAttachments + } + let uploads = try await Task.detached(priority: .userInitiated) { + try attachments.map { + let reference = $0.uploadedReference.map { + UploadedAttachmentReference( + environmentID: $0.environmentID, + attachmentID: $0.attachmentID + ) + } + if let ownedFile = $0.ownedFile { + if $0.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("image/") { + return try UploadChatAttachment( + id: $0.id, data: Data(contentsOf: ownedFile.url), + name: $0.name, mimeType: $0.mimeType, uploadedReference: reference + ) + } + return try UploadChatAttachment( + id: $0.id, + fileURL: ownedFile.url, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: ownedFile.byteCount, + uploadedReference: reference, + contextSource: $0.source + ) + } + return try UploadChatAttachment( + id: $0.id, + data: $0.data, + name: $0.name, + mimeType: $0.mimeType, + uploadedReference: reference, + contextSource: $0.source + ) + } + }.value + try Task.checkCancellation() + return uploads + } + + 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 + } + + /// Decoded once per process and on every `saveSettings`. `makeSnapshot` + /// runs on every publish, so it must not hit UserDefaults and JSONDecoder. + private func loadSettings() -> FeatureSettings { + if let cachedSettings { return cachedSettings } + let settings: FeatureSettings + if let data = settingsStore.data(forKey: Self.settingsKey), + let decoded = try? JSONDecoder().decode(FeatureSettings.self, from: data) { + settings = decoded + } else { + settings = FeatureSettings() + } + cachedSettings = settings + 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 NativeQuestionAnswerHistory { + static func messages(_ activity: OrchestrationActivity, createdAt: Date) -> [FeatureMessage] { + guard activity.kind == "user-input.answer-submitted", + case let .object(attachments)? = activity.payload["attachmentsByQuestionId"], + case let .object(answers)? = activity.payload["answers"] else { return [] } + let questionText = activity.payload["questionTextById"] + return Set(answers.keys).union(attachments.keys).sorted().map { questionID in + let answer: String + switch answers[questionID] { + case let .string(text): answer = text + case let .array(values): answer = values.compactMap(\.stringValue).joined(separator: ", ") + default: answer = "" + } + let text = [questionText?[questionID]?.stringValue, answer] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: "\n\n") + let files: [JSONValue] + if case let .array(values)? = attachments[questionID] { files = values } else { files = [] } + return FeatureMessage( + id: "question-answer:\(activity.id):\(questionID)", role: .user, text: text, + createdAt: createdAt, + attachments: files.compactMap { file in + guard let file = try? file.decode(ChatAttachment.self) else { return nil } + return FeatureMessageAttachment( + id: file.id, name: file.name, mimeType: file.mimeType, + sizeBytes: file.sizeBytes + ) + } + ) + } + } +} + +enum NativeActivityNotice { + static func accepts(_ activity: OrchestrationActivity) -> Bool { + guard activity.tone == "error" || activity.kind == "runtime.warning" + || activity.kind == "context-compaction" else { return false } + if NativeActivityFilters.isNoContentRuntimeWarning(activity) { return false } + if NativeActivityFilters.isAgentInternal(activity) { return false } + return true + } + + 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 + ) + } +} + +/// Row filters shared with the web transcript (apps/web/src/session-logic.ts). +/// Rows owned by a subagent, bypassed lifecycle rows, and adapter warnings with +/// no displayable text belong to the fleet view, not the parent timeline. +enum NativeActivityFilters { + static func isNoContentRuntimeWarning(_ activity: OrchestrationActivity) -> Bool { + activity.kind == "runtime.warning" + && activity.summary.hasSuffix("(no displayable text content)") + } + + static func isPlanBoundaryTool(_ activity: OrchestrationActivity) -> Bool { + guard activity.kind == "tool.updated" || activity.kind == "tool.completed" else { + return false + } + return activity.payload["detail"]?.stringValue?.hasPrefix("ExitPlanMode:") == true + } + + static func isAgentInternal(_ activity: OrchestrationActivity) -> Bool { + let ownedByAgent = activity.payload["agentId"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + let bypassed = activity.payload["timelineBypass"]?.boolValue == true + let isTaskRow = activity.kind.hasPrefix("task.") + if isTaskRow { + guard ownedByAgent || bypassed else { return false } + // A nested agent's own task row stays visible as a spawn anchor; + // its background shell rows and updates do not. + let isAgentTaskRow = activity.kind != "task.updated" + && activity.payload["taskId"]?.stringValue != nil + && activity.payload["agentKind"]?.stringValue == "agent" + return !isAgentTaskRow + } + return bypassed || ownedByAgent + } +} + +/// 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", + ]) + 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 { + guard activeKinds.contains(activity.kind) + || (activity.tone != "error" && terminalKinds.contains(activity.kind)) else { + return false + } + if NativeActivityFilters.isPlanBoundaryTool(activity) { return false } + if NativeActivityFilters.isAgentInternal(activity) { return false } + return true + } + + 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.pin-reordered": + result = reducePinReordered( + payload: payload, + occurredAt: occurredAt, + thread: thread + ) + case "thread.pull-request-linked", "thread.pull-request-unlinked", "thread.pull-request-synced": + result = reducePullRequest(type: type, payload: payload, 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 reducePullRequest( + type: String, payload: JSONValue, thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let updatedAt = payload["updatedAt"]?.stringValue else { return .refresh } + var updated = replacing(thread, updatedAt: updatedAt) + var links = thread.pullRequests ?? [] + if type == "thread.pull-request-linked" { + guard let link = try? payload["link"]?.decode(ThreadPullRequestLink.self) else { return .refresh } + if let index = links.firstIndex(where: { $0.id == link.id }) { links[index] = link } + else { links.append(link) } + } else { + guard let host = payload["host"]?.stringValue, + let repository = payload["repository"]?.stringValue, + let number = intValue(payload["number"]) else { return .refresh } + let key = ThreadPullRequestKey(host: host, repository: repository, number: number) + guard let index = links.firstIndex(where: { $0.id == key }) else { return .unchanged } + if type == "thread.pull-request-unlinked" { + links.remove(at: index) + } else { + guard let snapshot = try? payload["snapshot"]?.decode(ThreadPullRequestSnapshot.self), + let stackValue = payload["stack"] else { return .refresh } + let stack: ThreadPullRequestStack? + if stackValue == .null { stack = nil } + else { + guard let decoded = try? stackValue.decode(ThreadPullRequestStack.self) else { return .refresh } + stack = decoded + } + links[index].snapshot = snapshot + links[index].stack = stack + } + } + updated.pullRequests = links + if let legacy = updated.linkedPullRequest, + !links.contains(where: { $0.isVisible && $0.number == legacy.number && $0.url == legacy.url }) { + updated.linkedPullRequest = nil + } + return .updated(updated) + } + + 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 reducePinReordered( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let orderKey = payload["orderKey"]?.stringValue, + !orderKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .refresh + } + var updated = replacing( + thread, + updatedAt: payload["updatedAt"]?.stringValue ?? occurredAt + ) + updated.pinOrderKey = orderKey + 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 context: OrchestrationMessageContext? + if let rawContext = payload["context"], rawContext != .null { + guard let decoded = try? rawContext.decode(OrchestrationMessageContext.self) else { return .refresh } + context = decoded + } else { + context = nil + } + 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, + context: context ?? existing.context + ) + renderMutation = .message(messages[index]) + } else { + let message = OrchestrationMessage( + id: id, + role: role, + text: text, + attachments: attachments, + turnId: turnID, + streaming: streaming, + createdAt: createdAt, + updatedAt: updatedAt, + context: context + ) + 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, + pullRequests: thread.pullRequests, + 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, + pinOrderKey: thread.pinOrderKey, + titleRegeneration: thread.titleRegeneration, + 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 projectSettings: ServerSettingsSnapshot? + private var disabledProjectProviderIDs: Set = [] + private var supportsProjectSettings = false + private var projects = NativeShellRowProjection() + + mutating func mapProjects( + _ source: [OrchestrationProject], + settings: ServerSettingsSnapshot, + disabledProviderIDs: Set = [], + supportsProjectSettings: Bool = false, + transform: (OrchestrationProject) -> FeatureProject + ) -> [FeatureProject] { + if projectSettings != settings + || disabledProjectProviderIDs != disabledProviderIDs || self.supportsProjectSettings != supportsProjectSettings { + projects = NativeShellRowProjection() + projectSettings = settings + disabledProjectProviderIDs = disabledProviderIDs + self.supportsProjectSettings = supportsProjectSettings + } + 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? + /// The server answered and refused the saved credential. + var credentialRejected = false + var failureDetail: String? +} + +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] + var context: OrchestrationMessageContext? = nil +} + +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] + var context: OrchestrationMessageContext? = nil +} + +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 + case invalidPullRequestLink + + var errorDescription: String? { + switch self { + case .invalidPullRequestLink: + "Use a supported pull request URL. Older servers need a project for its repository." + 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..b7109da99340 --- /dev/null +++ b/apps/swift-ios/App/NativeWorkspaceMapper.swift @@ -0,0 +1,455 @@ +import Foundation + +enum NativeWorkspaceMapper { + static func files( + _ entries: [ProjectEntry], + directory: String?, + workspaceRoot: String + ) -> [FeatureFileEntry] { + let directory = directoryPath(directory, workspaceRoot: workspaceRoot) + let prefix = directory.isEmpty ? "" : "\(directory)/" + var children: [String: FeatureFileEntry] = [:] + + for entry in entries { + let fullPath = directoryPath(entry.path, workspaceRoot: workspaceRoot) + 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 + // Older servers ignore directoryPath and return the recursive index. + // Infer its immediate folders, but prefer each folder's own metadata. + if isNested, children[childPath] != nil { + continue + } + children[childPath] = FeatureFileEntry( + path: childPath, + name: component, + kind: kind, + isHidden: component.hasPrefix("."), + isIgnored: !isNested && entry.ignored == true + ) + } + + 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 + } + } + + static func directoryPath(_ path: String?, workspaceRoot: String) -> String { + let isWindows = workspaceRoot.hasPrefix("\\\\") || workspaceRoot.hasPrefix("//") + || workspaceRoot.range(of: #"^[A-Za-z]:[/\\]"#, options: .regularExpression) != nil + let path = path ?? "" + return (isWindows ? path.replacingOccurrences(of: "\\", with: "/") : path) + .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/NativeDiagnostics.swift b/apps/swift-ios/App/Platform/NativeDiagnostics.swift new file mode 100644 index 000000000000..dba6a514d005 --- /dev/null +++ b/apps/swift-ios/App/Platform/NativeDiagnostics.swift @@ -0,0 +1,146 @@ +import CryptoKit +import Foundation +import MetricKit +import Observation + +struct NativeDiagnosticReport: Codable, Identifiable, Sendable { + static let maximumReportBytes = 256 * 1024 + + let id: String + let periodStart: Date + let periodEnd: Date + let crashCount: Int + let launchCount: Int + let json: String? + + init(data: Data, periodStart: Date, periodEnd: Date, crashCount: Int, launchCount: Int) { + id = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + self.periodStart = periodStart + self.periodEnd = periodEnd + self.crashCount = crashCount + self.launchCount = launchCount + json = data.count <= Self.maximumReportBytes ? String(data: data, encoding: .utf8) : nil + } + + var title: String { + if crashCount > 0, launchCount > 0 { return "Crash and launch report" } + return crashCount > 0 ? "Crash report" : "Launch report" + } +} + +/// MetricKit delivers these reports during normal execution, sometimes on a later launch. +/// Do not infer crashes from app lifecycle events or install crash handlers here. +@MainActor +@Observable +final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { + private struct Archive: Codable { + let reports: [NativeDiagnosticReport] + let clearedThrough: Date? + } + + static let shared = NativeDiagnostics( + fileURL: URL.applicationSupportDirectory.appending(path: "Diagnostics/reports.json") + ) + static let maximumReports = 5 + static let maximumStorageBytes = 2 * 1024 * 1024 + + private(set) var reports: [NativeDiagnosticReport] = [] + private(set) var storageError: String? + private let fileURL: URL + private var started = false + private var clearedThrough: Date? + + init(fileURL: URL) { + self.fileURL = fileURL + super.init() + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + do { + let file = try FileHandle(forReadingFrom: fileURL) + defer { try? file.close() } + let data = try file.read(upToCount: Self.maximumStorageBytes + 1) ?? Data() + guard data.count <= Self.maximumStorageBytes else { + storageError = "Saved reports exceed the size limit." + return + } + let archive = try JSONDecoder().decode(Archive.self, from: data) + reports = Array(archive.reports.prefix(Self.maximumReports)) + clearedThrough = archive.clearedThrough + } catch { + storageError = "Could not read saved reports." + } + } + + func start() { + guard !started else { return } + started = true + let manager = MXMetricManager.shared + manager.add(self) + didReceive(manager.pastDiagnosticPayloads) + } + + nonisolated func didReceive(_ payloads: [MXDiagnosticPayload]) { + // Convert on MetricKit's callback queue. Only Sendable values cross to the UI. + let reports = payloads.compactMap { payload -> NativeDiagnosticReport? in + let crashes = payload.crashDiagnostics?.count ?? 0 + let launches = payload.appLaunchDiagnostics?.count ?? 0 + guard crashes > 0 || launches > 0 else { return nil } + return NativeDiagnosticReport( + data: payload.jsonRepresentation(), + periodStart: payload.timeStampBegin, + periodEnd: payload.timeStampEnd, + crashCount: crashes, + launchCount: launches + ) + } + Task { @MainActor [weak self] in + self?.receive(reports) + } + } + + func receive(_ incoming: [NativeDiagnosticReport]) { + guard !incoming.isEmpty else { return } + var byID = Dictionary(reports.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + for report in incoming where clearedThrough.map({ report.periodEnd > $0 }) ?? true { + byID[report.id] = report + } + var updatedReports = Array(byID.values.sorted { + if $0.periodEnd != $1.periodEnd { return $0.periodEnd > $1.periodEnd } + return $0.id < $1.id + }.prefix(Self.maximumReports)) + do { + var data = try JSONEncoder().encode(Archive(reports: updatedReports, clearedThrough: clearedThrough)) + // Encoding JSON as a string escapes it again. Drop older reports to + // fit the stored byte limit, as well as the report count limit. + while data.count > Self.maximumStorageBytes, !updatedReports.isEmpty { + updatedReports.removeLast() + data = try JSONEncoder().encode(Archive(reports: updatedReports, clearedThrough: clearedThrough)) + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + reports = updatedReports + storageError = nil + } catch { + storageError = "Could not save reports." + } + } + + func clear(at date: Date = .now) { + do { + // MetricKit can redeliver old payloads. Keep one timestamp after deletion + // so reports for cleared periods do not return on the next launch. + let cutoff = max(date, reports.map(\.periodEnd).max() ?? date, clearedThrough ?? date) + let data = try JSONEncoder().encode(Archive(reports: [], clearedThrough: cutoff)) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + reports = [] + clearedThrough = cutoff + storageError = nil + } catch { + storageError = "Could not clear saved reports." + } + } +} 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..ae8a68c527d1 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift @@ -0,0 +1,316 @@ +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?) + case usageLimits + + var id: String { + switch self { + case .usageLimits: + "usage-limits" + 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 .usageLimits: + components.host = "usage" + components.path = "/limits" + 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 "usage" where tail == ["limits"]: + return .usageLimits + 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..f54b3657aebf --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformRootView.swift @@ -0,0 +1,420 @@ +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 previousThreadMembership: [PlatformRecentThreadChangeKey] = [] + @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? + @State private var subscriptionUsage = PlatformSubscriptionUsageCoordinator() + + 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) + } + .task(id: subscriptionUsageKey) { + guard subscriptionUsageKey.isActive else { return } + await subscriptionUsage.observe( + client: model.client, + key: subscriptionUsageKey + ) + } + .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: model.threadRowRevision) { _, _ 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 subscriptionUsageKey: PlatformSubscriptionUsageObservationKey { + .init( + isActive: scenePhase == .active && !model.isLoading, + environments: model.snapshot.environments.filter(\.isEnabled), + accountID: (model.client as? any T3ConnectCapable)?.t3ConnectController.account?.id + ) + } + + 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 .usageLimits: + navigationRequest = FeatureWorkspaceNavigationRequest(destination: .usageLimits) + 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 + ) + let statesChanged = current != previousThreadStates + previousThreadStates = current + // Streaming turns advance the row revision several times a second + // without changing any thread's state. The recent-thread store and + // the Live Activity only care about state and membership. + let membership = model.snapshot.threads.map(PlatformRecentThreadChangeKey.init) + let membershipChanged = membership != previousThreadMembership + previousThreadMembership = membership + // The awareness coordinator compares its own visible fields and limits + // writes. Project names and provider changes must reach that comparison. + synchronizeAgentAwareness() + guard statesChanged || membershipChanged else { return } + recentThreadsPersistenceTask?.cancel() + let threads = model.snapshot.threads + recentThreadsPersistenceTask = Task.detached(priority: .utility) { + guard !Task.isCancelled else { return } + PlatformRecentThreadStore.shared.update(from: threads) + } + 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..6822e5665bdb --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformRouteResolver.swift @@ -0,0 +1,90 @@ +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 .usageLimits: + return 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..9b2b7d8479fa --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformShortcuts.swift @@ -0,0 +1,163 @@ +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 +} + +struct PlatformRecentThreadChangeKey: Equatable { + let id: String + let environmentID: String? + let wireID: String + let title: String + let environmentName: String? + let isArchived: Bool + let activityMinute: Double + + init(_ thread: FeatureThread) { + id = thread.id + environmentID = thread.environmentID + wireID = thread.wireID ?? thread.id + title = thread.title + environmentName = thread.environmentName + isArchived = thread.isArchived + // Refresh recency during long turns without persisting every token. + activityMinute = floor(thread.updatedAt.timeIntervalSince1970 / 60) + } +} + +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/Platform/PlatformSubscriptionUsage.swift b/apps/swift-ios/App/Platform/PlatformSubscriptionUsage.swift new file mode 100644 index 000000000000..f68e3728094d --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformSubscriptionUsage.swift @@ -0,0 +1,197 @@ +import CryptoKit +import Foundation +import WidgetKit + +struct PlatformSubscriptionUsageObservationKey: Equatable { + struct Environment: Equatable { + let id: String + let name: String + let endpoint: String + let connectionState: FeatureConnection.State? + } + + let isActive: Bool + let environments: [Environment] + let accountID: String? + + init(isActive: Bool, environments: [FeatureEnvironment], accountID: String? = nil) { + self.isActive = isActive + self.accountID = accountID + self.environments = environments.map { + Environment(id: $0.id, name: $0.name, endpoint: $0.endpoint, connectionState: $0.connectionState) + } + } + + var scopeID: String { + let identifiers = [accountID ?? ""] + environments.sorted { $0.id < $1.id }.flatMap { [$0.id, $0.endpoint] } + // Length prefixes make the digest unambiguous even when an identifier + // contains a separator. Neither labels nor transient connection state + // change which account data the saved snapshot belongs to. + let input = identifiers.map { "\($0.utf8.count):\($0)" }.joined() + return SHA256.hash(data: Data(input.utf8)).map { String(format: "%02x", $0) }.joined() + } +} + +enum PlatformSubscriptionUsageSnapshot { + static func retainingPendingSnapshot( + _ incoming: T3SubscriptionUsageSnapshot, + previous: T3SubscriptionUsageSnapshot?, + isPending: Bool + ) -> T3SubscriptionUsageSnapshot { + guard isPending, let scopeID = incoming.scopeID, + let previous, previous.scopeID == scopeID, + previous.providers.contains(where: { !$0.windows.isEmpty }) else { return incoming } + // Pooled display values have no per-environment contributions to merge. + // Keep the whole saved reading until every environment answers. + return T3SubscriptionUsageSnapshot(providers: previous.providers.map { + var provider = $0 + provider.hasPartialData = true + return provider + }, scopeID: scopeID) + } + + static func make(_ environments: [FeatureEnvironmentUsageLimits]) -> T3SubscriptionUsageSnapshot { + let pools = UsageLimitPooling.pools(UsageLimitPooling.accounts(environments), now: .distantPast) + return T3SubscriptionUsageSnapshot(providers: ["codex", "claudeAgent"].map { driver in + let pool = pools.first { $0.driver == driver } + let checked = pool?.accounts.map { UsageFormat.isoDate($0.limits.checkedAt) } ?? [] + let checkedAt = checked.contains(where: { $0 == nil }) ? nil : checked.compactMap { $0 }.min() + let expiresAt = checkedAt.map { checkedAt in + min(checkedAt.addingTimeInterval(15 * 60), pool?.windows.flatMap(\.resets).map(\.at).min() ?? .distantFuture) + } + let ordered = pool?.windows.sorted { $0.remainingPercent < $1.remainingPercent } ?? [] + let primary = [ordered.first { $0.kind == .session }, ordered.first { $0.kind == .weekly }].compactMap { $0 } + let selected = Array((primary + ordered.filter { row in !primary.contains(where: { $0.id == row.id }) }).prefix(6)) + .sorted { $0.remainingPercent < $1.remainingPercent } + let partial = environments.contains { environment in + environment.isPending || !environment.isConnected || environment.errorMessage != nil + || environment.sources.contains { source in + source.error != nil || source.accounts.contains { + $0.driver == driver && $0.usageLimits.unavailable?.reason != .unsupported + && UsageLimitsPresentation.limitsNotice($0.usageLimits) != nil + } + } + || UsageLimitsPresentation.providersWithLimits(environment.providers).contains { + $0.driver == driver && $0.usageLimits?.unavailable?.reason != .unsupported + && $0.usageLimits.map { UsageLimitsPresentation.limitsNotice($0) != nil } == true + } + } + return .init( + id: driver, name: UsageLimitsPresentation.providerLabel(driver: driver), + accountCount: pool?.accounts.count ?? 0, + checkedAt: checkedAt, expiresAt: expiresAt, + windows: selected.map { window in + .init(id: window.id, kind: window.kind.rawValue, label: String(window.label.prefix(120)), remaining: window.remainingPercent, resetAt: window.resets.first?.at) + }, + totalWindows: ordered.count, hasPartialData: partial + ) + }) + } +} + +/// The foreground host observes config updates and makes one refresh attempt on +/// activation. No timer, background socket, or widget-owned transport is needed. +@MainActor +final class PlatformSubscriptionUsageCoordinator { + // The writer outlives views. Its ordering must outlive their coordinators too. + private static var nextGeneration = 0 + private var environments: [FeatureEnvironmentUsageLimits] = [] + private var generation = 0 + private var lastRefresh: Date? + private var lastRefreshEnvironmentIDs: Set = [] + private var scopeID: String? + private var previousSnapshot: T3SubscriptionUsageSnapshot? + + func observe(client: any FeatureClient, key: PlatformSubscriptionUsageObservationKey) async { + Self.nextGeneration += 1 + generation = Self.nextGeneration + let currentGeneration = generation + let environmentIDs = key.environments.map(\.id) + if scopeID != key.scopeID { + environments = [] + lastRefresh = nil + scopeID = key.scopeID + } + let savedSnapshot = await PlatformSubscriptionUsageWriter.shared.load() + guard !Task.isCancelled, generation == currentGeneration else { return } + previousSnapshot = savedSnapshot + environments = UsageLimitsPresentation.retainingPendingRows(key.environments.map { + .init(environmentID: $0.id, label: $0.name, isPending: true) + }, previous: environments) + await publish() + guard !environmentIDs.isEmpty else { return } + let hasWidget = await withCheckedContinuation { continuation in + WidgetCenter.shared.getCurrentConfigurations { result in + continuation.resume(returning: (try? result.get())?.contains { + $0.kind == T3SubscriptionUsageSnapshotStore.kind + } ?? false) + } + } + guard hasWidget, !Task.isCancelled, generation == currentGeneration else { return } + + // Bound probes across rapid scene and connection changes. + let currentIDs = Set(environmentIDs) + let shouldRefresh = !currentIDs.isSubset(of: lastRefreshEnvironmentIDs) + || (lastRefresh.map { Date().timeIntervalSince($0) >= 5 * 60 } ?? true) + if shouldRefresh { + lastRefresh = Date() + lastRefreshEnvironmentIDs = currentIDs + } + let refreshTask: Task? = shouldRefresh ? Task { @MainActor in + // Refresh emits config events. Do not overwrite newer stream + // data with the operation result if the two complete out of order. + _ = try? await client.refreshUsageLimits() + } : nil + await withTaskCancellationHandler { + do { + for try await rows in client.usageLimitsUpdates() { + guard !Task.isCancelled, generation == currentGeneration else { break } + environments = UsageLimitsPresentation.retainingPendingRows(rows, previous: environments) + await publish() + } + } catch { + guard !Task.isCancelled, generation == currentGeneration else { return } + environments = environments.map { + .init(environmentID: $0.id, label: $0.label, providers: $0.providers, sources: $0.sources, + isConnected: false, errorMessage: "Could not read limits.") + } + await publish() + } + } onCancel: { + refreshTask?.cancel() + } + refreshTask?.cancel() + await refreshTask?.value + } + + private func publish() async { + var incoming = PlatformSubscriptionUsageSnapshot.make(environments) + incoming.scopeID = scopeID + let snapshot = PlatformSubscriptionUsageSnapshot.retainingPendingSnapshot( + incoming, previous: previousSnapshot, isPending: environments.contains(where: \.isPending) + ) + let saved = await PlatformSubscriptionUsageWriter.shared.save(snapshot, generation: generation) + if saved { WidgetCenter.shared.reloadTimelines(ofKind: T3SubscriptionUsageSnapshotStore.kind) } + } +} + +private actor PlatformSubscriptionUsageWriter { + static let shared = PlatformSubscriptionUsageWriter() + private var generation = 0 + private var previous: T3SubscriptionUsageSnapshot? + + func load() -> T3SubscriptionUsageSnapshot { + previous ?? T3SubscriptionUsageSnapshotStore.load() + } + + func save(_ snapshot: T3SubscriptionUsageSnapshot, generation: Int) -> Bool { + guard generation >= self.generation else { return false } + self.generation = generation + guard snapshot != previous else { return false } + do { + try T3SubscriptionUsageSnapshotStore.save(snapshot) + previous = snapshot + return true + } catch { return false } + } +} 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..5d63d6d9414a --- /dev/null +++ b/apps/swift-ios/App/T3CodeApp.swift @@ -0,0 +1,30 @@ +import SwiftUI + +@main +@MainActor +struct T3CodeApp: App { + @UIApplicationDelegateAdaptor(T3PlatformAppDelegate.self) private var appDelegate + @State private var model: FeatureRootModel + + init() { + NativeDiagnostics.shared.start() + 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..ecb94dd5bf9b --- /dev/null +++ b/apps/swift-ios/Core/Attachments.swift @@ -0,0 +1,261 @@ +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 require the upload capability. Clipboard text can stay in memory. +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 fileData(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? + public let contextSource: PastedTextAttachmentSource? + let source: Source + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: UploadedAttachmentReference? = nil, + contextSource: PastedTextAttachmentSource? = nil + ) throws { + let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !normalizedMIME.hasPrefix("image/") { + guard !data.isEmpty else { throw FileAttachmentError.empty } + guard data.count <= Self.maximumFileBytes else { + throw FileAttachmentError.tooLarge(actualBytes: data.count, maximumBytes: Self.maximumFileBytes) + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 255 else { throw FileAttachmentError.invalidName } + 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 + sizeBytes = data.count + self.uploadedReference = uploadedReference + self.contextSource = contextSource + source = .fileData(data) + return + } + 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 + } + 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 + self.contextSource = nil + source = .imageData(data) + } + + public init( + id: UUID = UUID(), + fileURL: URL, + name: String, + mimeType: String, + sizeBytes: Int, + uploadedReference: UploadedAttachmentReference? = nil, + contextSource: PastedTextAttachmentSource? = 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 + self.contextSource = contextSource + 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["id"] = .string(id.uuidString) + value["dataUrl"] = .string( + "data:\(mimeType);base64,\(data.base64EncodedString())" + ) + } + return .object(value) + } + + func uploadedJSONValue(id: String) -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string(type), + "id": .string(id), + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ] + if let contextSource { + value["source"] = .object(["_tag": .string(contextSource.rawValue)]) + } + return .object(value) + } +} + +/// 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/ComposerContext.swift b/apps/swift-ios/Core/ComposerContext.swift new file mode 100644 index 000000000000..53386c77d0cc --- /dev/null +++ b/apps/swift-ios/Core/ComposerContext.swift @@ -0,0 +1,287 @@ +import Foundation + +/// The link owns position. A record owns the captured data behind that link. +public struct ComposerContextRecord: Codable, Equatable, Hashable, Sendable, Identifiable { + public var id: String { contextId } + public let version: Int + public var contextId: String + public let label: String + public var payload: Payload + + public enum Payload: Equatable, Hashable, Sendable { + case image(Attachment) + case file(Attachment) + case terminal(Terminal) + case element(Element) + case previewAnnotation(PreviewAnnotation) + case reviewComment(ReviewComment) + case mention(Mention) + case skill(Skill) + case unknown(kind: String, payload: JSONValue) + } + + public struct Attachment: Codable, Equatable, Hashable, Sendable { + public var attachmentId: String + public let name: String + public let mimeType: String + public let sizeBytes: Int + } + + public struct Terminal: Codable, Equatable, Hashable, Sendable { + public let terminalId: String + public let terminalLabel: String + public let lineStart: Int + public let lineEnd: Int + public let text: String + } + + public struct Element: Codable, Equatable, Hashable, Sendable { + public struct Source: Codable, Equatable, Hashable, Sendable { + public let functionName: String? + public let fileName: String? + public let lineNumber: Int? + public let columnNumber: Int? + + private enum CodingKeys: String, CodingKey { case functionName, fileName, lineNumber, columnNumber } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(functionName, forKey: .functionName) + try values.encode(fileName, forKey: .fileName) + try values.encode(lineNumber, forKey: .lineNumber) + try values.encode(columnNumber, forKey: .columnNumber) + } + } + public let pageUrl: String + public let pageTitle: String? + public let tagName: String + public let selector: String? + public let htmlPreview: String + public let componentName: String? + public let source: Source? + public let styles: String + + private enum CodingKeys: String, CodingKey { case pageUrl, pageTitle, tagName, selector, htmlPreview, componentName, source, styles } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(pageUrl, forKey: .pageUrl) + try values.encode(pageTitle, forKey: .pageTitle) + try values.encode(tagName, forKey: .tagName) + try values.encode(selector, forKey: .selector) + try values.encode(htmlPreview, forKey: .htmlPreview) + try values.encode(componentName, forKey: .componentName) + try values.encode(source, forKey: .source) + try values.encode(styles, forKey: .styles) + } + } + + public struct PreviewAnnotation: Codable, Equatable, Hashable, Sendable { + public struct StyleChange: Codable, Equatable, Hashable, Sendable { + public let targetId: String + public let selector: String? + public let property: String + public let previousValue: String + public let value: String + + private enum CodingKeys: String, CodingKey { case targetId, selector, property, previousValue, value } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(targetId, forKey: .targetId) + try values.encode(selector, forKey: .selector) + try values.encode(property, forKey: .property) + try values.encode(previousValue, forKey: .previousValue) + try values.encode(value, forKey: .value) + } + } + public let annotationId: String + public let pageUrl: String + public let pageTitle: String? + public let comment: String + public let targetSummary: String + public let styleChanges: [String] + public let elements: [Element]? + public let elementIds: [String]? + public let regionCount: Int? + public let strokeCount: Int? + public let styleChangeDetails: [StyleChange]? + public var screenshotContextId: String? + + private enum CodingKeys: String, CodingKey { + case annotationId, pageUrl, pageTitle, comment, targetSummary, styleChanges + case elements, elementIds, regionCount, strokeCount, styleChangeDetails, screenshotContextId + } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(annotationId, forKey: .annotationId) + try values.encode(pageUrl, forKey: .pageUrl) + try values.encode(pageTitle, forKey: .pageTitle) + try values.encode(comment, forKey: .comment) + try values.encode(targetSummary, forKey: .targetSummary) + try values.encode(styleChanges, forKey: .styleChanges) + try values.encodeIfPresent(elements, forKey: .elements) + try values.encodeIfPresent(elementIds, forKey: .elementIds) + try values.encodeIfPresent(regionCount, forKey: .regionCount) + try values.encodeIfPresent(strokeCount, forKey: .strokeCount) + try values.encodeIfPresent(styleChangeDetails, forKey: .styleChangeDetails) + try values.encodeIfPresent(screenshotContextId, forKey: .screenshotContextId) + } + } + + public struct ReviewComment: Codable, Equatable, Hashable, Sendable { + public struct PullRequest: Codable, Equatable, Hashable, Sendable { + public let number: Int + public let title: String + public let url: String + public let headBranch: String + public let baseBranch: String + public let state: String + public let isDraft: Bool + } + public let sectionId: String + public let sectionTitle: String + public let filePath: String + public let startIndex: Int + public let endIndex: Int + public let rangeLabel: String + public let text: String + public let diff: String + public let fenceLanguage: String? + public let pullRequest: PullRequest? + } + + public struct Mention: Codable, Equatable, Hashable, Sendable { + public let path: String + } + + public struct Skill: Codable, Equatable, Hashable, Sendable { + public let name: String + } + + public var kind: String { + switch payload { + case .image: "image" + case .file: "file" + case .terminal: "terminal" + case .element: "element" + case .previewAnnotation: "preview-annotation" + case .reviewComment: "review-comment" + case .mention: "mention" + case .skill: "skill" + case let .unknown(kind, _): kind + } + } + + public init(contextId: String = UUID().uuidString, label: String, payload: Payload) { + version = 1 + self.contextId = contextId + self.label = ComposerContextReferences.sanitizeLabel(label, kind: "context") + self.payload = payload + } + + private enum CodingKeys: String, CodingKey { + case version, contextId, label, kind, payload + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + version = try values.decode(Int.self, forKey: .version) + contextId = try values.decode(String.self, forKey: .contextId) + label = try values.decode(String.self, forKey: .label) + let kind = try values.decode(String.self, forKey: .kind) + guard version == 1, label.utf16.count <= 200, + ComposerContextReferences.parseHref("t3-context://v1/\(kind)/\(contextId)") != nil else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: values, debugDescription: "Invalid context identity") + } + switch kind { + case "image": payload = .image(try Attachment(from: decoder)) + case "file": payload = .file(try Attachment(from: decoder)) + case "terminal": + let value = try Terminal(from: decoder) + guard value.lineStart >= 0, value.lineEnd >= value.lineStart, value.text.utf16.count <= 64_000 else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: values, debugDescription: "Invalid terminal context") + } + payload = .terminal(value) + case "element": payload = .element(try Element(from: decoder)) + case "preview-annotation": payload = .previewAnnotation(try PreviewAnnotation(from: decoder)) + case "review-comment": + let value = try ReviewComment(from: decoder) + guard value.startIndex >= 0, value.endIndex >= value.startIndex, + value.text.utf16.count <= 16_000, value.diff.utf16.count <= 32_000 else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: values, debugDescription: "Invalid review context") + } + payload = .reviewComment(value) + case "mention": payload = .mention(try Mention(from: decoder)) + case "skill": payload = .skill(try Skill(from: decoder)) + default: payload = .unknown(kind: kind, payload: try values.decode(JSONValue.self, forKey: .payload)) + } + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(version, forKey: .version) + try values.encode(contextId, forKey: .contextId) + try values.encode(label, forKey: .label) + try values.encode(kind, forKey: .kind) + switch payload { + case let .image(value), let .file(value): try value.encode(to: encoder) + case let .terminal(value): try value.encode(to: encoder) + case let .element(value): try value.encode(to: encoder) + case let .previewAnnotation(value): try value.encode(to: encoder) + case let .reviewComment(value): try value.encode(to: encoder) + case let .mention(value): try value.encode(to: encoder) + case let .skill(value): try value.encode(to: encoder) + case let .unknown(_, value): try values.encode(value, forKey: .payload) + } + } + + public var attachment: Attachment? { + switch payload { + case let .file(value), let .image(value): value + default: nil + } + } +} + +public struct OrchestrationMessageContext: Codable, Equatable, Hashable, Sendable { + public let version: Int + public var records: [ComposerContextRecord] + + public init(records: [ComposerContextRecord]) { + version = 1 + self.records = records + } + + private enum CodingKeys: String, CodingKey { case version, records } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + version = try values.decode(Int.self, forKey: .version) + let raw = try values.decode([JSONValue].self, forKey: .records) + guard version == 1, raw.count <= 200 else { + throw DecodingError.dataCorruptedError(forKey: .records, in: values, debugDescription: "Invalid message context") + } + records = raw.compactMap { try? $0.decode(ComposerContextRecord.self) } + guard Set(records.map(\.contextId)).count == records.count else { + throw DecodingError.dataCorruptedError(forKey: .records, in: values, debugDescription: "Duplicate context identity") + } + } +} + +public enum PastedTextAttachmentSource: String, Codable, Equatable, Hashable, Sendable { + case pastedText = "pasted-text" + + private enum CodingKeys: String, CodingKey { case tag = "_tag" } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let tag = try values.decode(String.self, forKey: .tag) + guard let source = Self(rawValue: tag) else { + throw DecodingError.dataCorruptedError(forKey: .tag, in: values, debugDescription: "Unknown file source") + } + self = source + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(rawValue, forKey: .tag) + } +} diff --git a/apps/swift-ios/Core/ComposerContextClipboard.swift b/apps/swift-ios/Core/ComposerContextClipboard.swift new file mode 100644 index 000000000000..b5c3a3efd49a --- /dev/null +++ b/apps/swift-ios/Core/ComposerContextClipboard.swift @@ -0,0 +1,154 @@ +import Foundation + +public struct ComposerContextClipboardFragment: Codable, Equatable, Sendable { + public struct Source: Codable, Equatable, Sendable { + public let environmentId: String + public var threadId: String? + public var messageId: String? + + public init(environmentId: String, threadId: String? = nil, messageId: String? = nil) { + self.environmentId = environmentId + self.threadId = threadId + self.messageId = messageId + } + } + + public let version: Int + public let source: Source + public let records: [ComposerContextRecord] + + public init(source: Source, records: [ComposerContextRecord]) { + version = 1 + self.source = source + self.records = records + } +} + +public enum ComposerContextClipboardError: LocalizedError, Equatable { + case invalidFragment + case tooLarge + case missingRecord + case contextLimit + case sourceUnavailable + case draftChanged + + public var errorDescription: String? { + switch self { + case .invalidFragment: "The clipboard context is invalid. Copy it again from the source." + case .tooLarge: "This context is too large to copy. Select fewer items." + case .missingRecord: "A copied context item is missing its saved data. Copy it again from the source." + case .contextLimit: "A message can have up to 200 context items. Remove some items and paste again." + case .sourceUnavailable: "Reconnect to the source environment to paste its attachments." + case .draftChanged: "The draft changed while the paste was loading. Paste again." + } + } +} + +/// Matches the web/native clipboard contract. Binary data and resolved asset URLs stay out of this payload. +public enum ComposerContextClipboard { + public static let mimeType = "web application/x-t3-context-fragment+json" + public static let maximumCharacters = 16_000_000 + public static let maximumRecords = 200 + + public struct Content: Equatable, Sendable { + public let text: String + public let fragment: ComposerContextClipboardFragment + } + + public static func encode(_ fragment: ComposerContextClipboardFragment) throws -> String { + try validate(fragment) + let data = try JSONEncoder.t3.encode(fragment) + let text = String(decoding: data, as: UTF8.self) + guard text.utf16.count <= maximumCharacters else { throw ComposerContextClipboardError.tooLarge } + return text + } + + public static func decode(_ text: String) throws -> ComposerContextClipboardFragment { + guard text.utf16.count <= maximumCharacters else { throw ComposerContextClipboardError.tooLarge } + let fragment: ComposerContextClipboardFragment + do { + // Decode every record. An invalid record must not turn into a broken pasted link. + fragment = try JSONDecoder.t3.decode(ComposerContextClipboardFragment.self, from: Data(text.utf8)) + } catch { + throw ComposerContextClipboardError.invalidFragment + } + try validate(fragment) + return fragment + } + + private static func validate(_ fragment: ComposerContextClipboardFragment) throws { + guard fragment.version == 1, !fragment.source.environmentId.isEmpty, + Set(fragment.records.map(\.contextId)).count == fragment.records.count else { + throw ComposerContextClipboardError.invalidFragment + } + guard fragment.records.count <= maximumRecords else { throw ComposerContextClipboardError.contextLimit } + guard fragment.records.allSatisfy({ record in + record.label.utf16.count <= 200 + && ComposerContextReferences.parseHref("t3-context://v1/\(record.kind)/\(record.contextId)") != nil + }) else { throw ComposerContextClipboardError.invalidFragment } + } + + public static func html(text: String, fragment: String) -> String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~") + let encoded = fragment.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" + let escaped = text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">") + return "
\(escaped)
" + } + + public static func decodeHTML(_ html: String) throws -> ComposerContextClipboardFragment? { + guard html.utf16.count <= maximumCharacters * 9 + 4_096 else { + throw ComposerContextClipboardError.tooLarge + } + let expression = try NSRegularExpression(pattern: #"data-t3-context-fragment=["']([^"']+)["']"#) + guard let match = expression.firstMatch(in: html, range: NSRange(location: 0, length: html.utf16.count)) else { + return nil + } + guard let decoded = (html as NSString).substring(with: match.range(at: 1)).removingPercentEncoding else { + throw ComposerContextClipboardError.invalidFragment + } + return try decode(decoded) + } + + /// Include only selected records and the screenshots required by selected annotations. + public static func selected(text: String, fragment: ComposerContextClipboardFragment) throws -> Content { + try validate(fragment) + let references = ComposerContextReferences.collect(text) + let byID = Dictionary(uniqueKeysWithValues: fragment.records.map { ($0.contextId, $0) }) + var ids = Set() + for reference in references { + guard let record = byID[reference.contextId], record.kind == reference.kind else { + throw ComposerContextClipboardError.missingRecord + } + ids.insert(record.contextId) + if case let .previewAnnotation(annotation) = record.payload, let screenshot = annotation.screenshotContextId { + guard byID[screenshot]?.kind == "image" else { throw ComposerContextClipboardError.missingRecord } + ids.insert(screenshot) + } + } + return Content(text: text, fragment: .init(source: fragment.source, records: fragment.records.filter { ids.contains($0.contextId) })) + } + + public static func reidentified(_ content: Content, createID: () -> String = { UUID().uuidString }) throws -> Content { + let selected = try selected(text: content.text, fragment: content.fragment) + let ids = Dictionary(uniqueKeysWithValues: selected.fragment.records.map { ($0.contextId, createID()) }) + guard Set(ids.values).count == ids.count else { throw ComposerContextClipboardError.invalidFragment } + let text = ComposerContextReferences.replace(selected.text) { reference in + let original = (selected.text as NSString).substring(with: reference.range) + return original.replacingOccurrences( + of: "t3-context://v1/\(reference.kind)/\(reference.contextId)", + with: "t3-context://v1/\(reference.kind)/\(ids[reference.contextId]!)" + ) + } + let records = selected.fragment.records.map { record in + var result = record + result.contextId = ids[record.contextId]! + if case var .previewAnnotation(annotation) = record.payload, let screenshot = annotation.screenshotContextId { + annotation.screenshotContextId = ids[screenshot]! + result.payload = .previewAnnotation(annotation) + } + return result + } + return Content(text: text, fragment: .init(source: selected.fragment.source, records: records)) + } +} diff --git a/apps/swift-ios/Core/ComposerContextReferences.swift b/apps/swift-ios/Core/ComposerContextReferences.swift new file mode 100644 index 000000000000..b2b5b141d8cd --- /dev/null +++ b/apps/swift-ios/Core/ComposerContextReferences.swift @@ -0,0 +1,193 @@ +import Foundation + +/// Matches packages/shared/src/composerContextReferences.ts. Labels are never identities. +public enum ComposerContextReferences { + public struct Reference: Equatable, Sendable { + public let kind: String + public let contextId: String + public let label: String + public let image: Bool + public let range: NSRange + } + + private static let expression = try! NSRegularExpression( + pattern: #"(!?)\[([^\]\n]{0,512})\]\((t3-context://v1/[^\s)]{1,200})\)"# + ) + + public static func parseHref(_ href: String) -> (kind: String, contextId: String)? { + guard href.hasPrefix("t3-context://v1/") else { return nil } + let parts = href.dropFirst("t3-context://v1/".count).split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, + parts[0].range(of: #"^[a-z][a-z0-9-]{0,39}$"#, options: .regularExpression) != nil, + parts[1].range(of: #"^[a-zA-Z0-9_-]{1,128}$"#, options: .regularExpression) != nil else { return nil } + return (String(parts[0]), String(parts[1])) + } + + public static func sanitizeLabel(_ label: String, kind: String) -> String { + let clean = label.replacingOccurrences(of: #"[\[\]\\\r\n]"#, with: " ", options: .regularExpression) + .split(whereSeparator: \.isWhitespace).joined(separator: " ") + let bounded = boundedPrefix(clean, maximumUTF16: 200) + return bounded.isEmpty ? kind : bounded + } + + public static func boundedPrefix(_ text: String, maximumUTF16: Int) -> String { + let units = text.utf16.prefix(max(0, maximumUTF16)) + return String(decoding: units.last.map { (0xD800...0xDBFF).contains($0) } == true ? units.dropLast() : units, as: UTF16.self) + } + + public static func format(_ record: ComposerContextRecord) -> String { + "\(record.kind == "image" ? "!" : "")[\(sanitizeLabel(record.label, kind: record.kind))](t3-context://v1/\(record.kind)/\(record.contextId))" + } + + public static func collect(_ text: String) -> [Reference] { + guard text.contains("](t3-context:") else { return [] } + let source = text as NSString + return expression.matches(in: text, range: NSRange(location: 0, length: source.length)).compactMap { match in + guard let parsed = parseHref(source.substring(with: match.range(at: 3))) else { return nil } + return Reference( + kind: parsed.kind, contextId: parsed.contextId, + label: sanitizeLabel(source.substring(with: match.range(at: 2)), kind: parsed.kind), + image: match.range(at: 1).length > 0, range: match.range + ) + } + } + + public static func replace(_ text: String, with replacement: (Reference) -> String) -> String { + let result = NSMutableString(string: text) + for reference in collect(text).reversed() { + result.replaceCharacters(in: reference.range, with: replacement(reference)) + } + return result as String + } + + public static func displayText(_ text: String) -> String { + replace(text) { $0.label } + } + + public static func ensureReferences(_ text: String, records: [ComposerContextRecord]) -> String { + var referenced = Set(collect(text).map(\.contextId)) + var result = text + for record in records where referenced.insert(record.contextId).inserted { + if let last = result.last, !last.isWhitespace { result += " " } + result += format(record) + " " + } + return result + } + + /// Keep linked records and annotation screenshots, including unknown kinds. + public static func referenced(_ context: OrchestrationMessageContext?, text: String) -> OrchestrationMessageContext? { + guard let context else { return nil } + var ids = Set(collect(text).map(\.contextId)) + for record in context.records where ids.contains(record.contextId) { + if case let .previewAnnotation(value) = record.payload, let screenshot = value.screenshotContextId { + ids.insert(screenshot) + } + } + let records = context.records.filter { ids.contains($0.contextId) } + return records.isEmpty ? nil : OrchestrationMessageContext(records: records) + } + + /// Attachment uploads replace client ids. Rebind payloads without changing link identity. + public static func rebind( + _ context: OrchestrationMessageContext?, attachmentIDs: [String: String] + ) -> OrchestrationMessageContext? { + guard var context else { return nil } + context.records = context.records.map { record in + var result = record + guard var attachment = record.attachment, + let id = attachmentIDs[attachment.attachmentId] else { return result } + attachment.attachmentId = id + result.payload = record.kind == "image" ? .image(attachment) : .file(attachment) + return result + } + return context + } + + /// Older servers need readable text because they discard the context field. + public static func providerProjection(_ text: String, context: OrchestrationMessageContext?) -> String { + let references = collect(text) + guard !references.isEmpty else { return text } + let grouped = Dictionary(grouping: context?.records ?? [], by: \.contextId) + let byID = grouped.compactMapValues { $0.count == 1 ? $0.first : nil } + let body = replace(text) { reference in + let kind = byID[reference.contextId]?.kind ?? reference.kind + let spaced = kind.replacingOccurrences(of: "-", with: " ") + let name = spaced.prefix(1).uppercased() + spaced.dropFirst() + let label = reference.label.replacingOccurrences(of: #"[\r\n;\]]"#, with: " ", options: .regularExpression) + .split(whereSeparator: \.isWhitespace).joined(separator: " ") + return "[\(name): \(escapePayload(label)); ref=\(reference.contextId)]" + } + var seen = Set() + let entries = references.filter { seen.insert($0.contextId).inserted }.map { reference in + let record = byID[reference.contextId] + let open = "" } + return open + ">\n" + escapePayload(providerPayload(record)) + "\n" + } + return body + "\n\n\n" + entries.joined(separator: "\n") + "\n" + } + + private static func escapePayload(_ text: String) -> String { + text.replacingOccurrences(of: #"<(?=/?(?:t3_context|context)\b)"#, with: "<", options: [.regularExpression, .caseInsensitive]) + } + + private static func indent(_ text: String) -> String { + text.components(separatedBy: "\n").map { " " + $0 }.joined(separator: "\n") + } + + private static func elementLines(_ element: ComposerContextRecord.Element) -> [String] { + var lines = ["url: \(element.pageUrl)", "tag: \(element.tagName)"] + if let title = element.pageTitle, !title.isEmpty { lines.append("title: \(title)") } + if let selector = element.selector, !selector.isEmpty { lines.append("selector: \(selector)") } + if let component = element.componentName, !component.isEmpty { lines.append("component: \(component)") } + if let source = element.source, let file = source.fileName, !file.isEmpty { + var location = file + if let line = source.lineNumber { + location += ":\(line)" + if let column = source.columnNumber { location += ":\(column)" } + } + lines.append("source: \(location)") + } + let html = element.htmlPreview.trimmingCharacters(in: .whitespacesAndNewlines) + let styles = element.styles.trimmingCharacters(in: .whitespacesAndNewlines) + if !html.isEmpty { lines += ["html:", indent(html)] } + if !styles.isEmpty { lines += ["styles:", indent(styles)] } + return lines + } + + public static func providerPayload(_ record: ComposerContextRecord) -> String { + switch record.payload { + case let .image(value), let .file(value): + return "name: \(value.name)\nmimeType: \(value.mimeType)\nsizeBytes: \(value.sizeBytes)\nattachmentId: \(value.attachmentId)" + case let .terminal(value): + let lines = value.text.components(separatedBy: "\n").prefix(min(value.lineEnd - value.lineStart, 64_000) + 1) + .enumerated().map { "\(value.lineStart + $0.offset) | \($0.element)" } + return (["terminal: \(value.terminalLabel)"] + lines).joined(separator: "\n") + case let .mention(value): return "path: \(value.path)" + case let .skill(value): return "name: \(value.name)" + case let .reviewComment(value): + var lines = ["file: \(value.filePath)", "range: \(value.rangeLabel) (\(value.startIndex)-\(value.endIndex))", "section: \(value.sectionTitle)"] + let text = value.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { lines += ["comment:", indent(text)] } + if !value.diff.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines += ["\(value.fenceLanguage ?? "diff"):", indent(value.diff.replacingOccurrences(of: #"\s+$"#, with: "", options: .regularExpression))] + } + return lines.joined(separator: "\n") + case let .element(value): return elementLines(value).joined(separator: "\n") + case let .previewAnnotation(value): + let title = value.pageTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var lines = ["page: \(title.isEmpty ? value.pageUrl : title)", "url: \(value.pageUrl)"] + let comment = value.comment.trimmingCharacters(in: .whitespacesAndNewlines) + if !comment.isEmpty { lines.append("comment: \(comment)") } + if !value.targetSummary.isEmpty { lines.append("targets: \(value.targetSummary)") } + if !value.styleChanges.isEmpty { lines += ["requested visual changes:"] + value.styleChanges.map { "- " + $0 } } + if let id = value.screenshotContextId { lines.append("screenshot: ref=\(id)") } + for (index, element) in (value.elements ?? []).enumerated() { + lines += ["element \(index + 1):", indent(elementLines(element).joined(separator: "\n"))] + } + return lines.joined(separator: "\n") + case let .unknown(_, value): + return (try? JSONEncoder.t3.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? "null" + } + } +} diff --git a/apps/swift-ios/Core/GitHubRouting.swift b/apps/swift-ios/Core/GitHubRouting.swift new file mode 100644 index 000000000000..a56765ccb42c --- /dev/null +++ b/apps/swift-ios/Core/GitHubRouting.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum GitHubRoutingPermission: String, Codable, CaseIterable, Sendable { + case off + case read + case readWrite = "read-write" + + public var label: String { + switch self { + case .off: "Off" + case .read: "Read" + case .readWrite: "Read and write" + } + } +} + +/// Permission belongs to the saved endpoint, not an identity advertised by a server. +public struct GitHubRoutingGrant: Codable, Equatable, Sendable { + public let environmentID: String + public let connectionKey: String + public let permission: GitHubRoutingPermission + + public static func connectionKey(_ environment: Environment) -> String? { + let http = environment.httpBaseURL + let ws = environment.webSocketBaseURL + guard ["http", "https"].contains(http.scheme ?? ""), ["ws", "wss"].contains(ws.scheme ?? ""), + http.host != nil, ws.host != nil, http.user == nil, http.password == nil, + ws.user == nil, ws.password == nil else { return nil } + return [environment.kind.rawValue, environment.id, + http.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")), + ws.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))] + .joined(separator: "\u{0}") + } + + public static func permission(for environment: Environment, grants: [Self]) -> GitHubRoutingPermission { + guard let key = connectionKey(environment) else { return .off } + return grants.first { $0.environmentID == environment.id && $0.connectionKey == key }?.permission ?? .off + } + + public static func allowed(origin: Environment, destination: Environment, grants: [Self], write: Bool) -> Bool { + guard origin.isEnabled, destination.isEnabled, origin.id != destination.id else { return false } + let source = permission(for: origin, grants: grants) + let target = permission(for: destination, grants: grants) + return write ? source == .readWrite && target == .readWrite : source != .off && target != .off + } +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift new file mode 100644 index 000000000000..bed78a29430a --- /dev/null +++ b/apps/swift-ios/Core/HTTP.swift @@ -0,0 +1,717 @@ +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 { + let credential = try await credentials.credential(for: environment.id) + + switch environment.kind { + case .bearer, .local: + guard let credential else { throw HTTPError.missingCredential } + 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 let managedAuthorization else { + throw HTTPError.managedAuthorizationUnavailable + } + + // Device-only Keychain entries do not transfer to a new iPhone. + // The restored catalog can still use the signed-in relay account. + var current: EnvironmentCredential + if let credential, + credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id { + current = credential + } else { + current = try await refreshManagedCredential( + credential, environment: environment, using: managedAuthorization + ) + } + 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 + } +} + +public extension HTTPError { + /// The server refused the saved credential. Callers stop retrying and ask + /// the user to pair again instead of reporting the server as unreachable. + var isRejectedAuthorization: Bool { + switch self { + case .unauthenticatedSession, .missingCredential, .incompatibleCredential: return true + case let .status(status, _, _): return status == 401 + default: return false + } + } +} + +public extension Error { + var isRejectedAuthorization: Bool { + (self as? HTTPError)?.isRejectedAuthorization == true + } +} + +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..e2bddea8e72e --- /dev/null +++ b/apps/swift-ios/Core/JSONValue.swift @@ -0,0 +1,116 @@ +import Foundation + +/// A lossless, Sendable JSON representation used at protocol boundaries that +/// intentionally carry provider-defined payloads. +public enum JSONValue: Codable, Equatable, Hashable, 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 var boolValue: Bool? { + guard case let .bool(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..7c49b9f619db --- /dev/null +++ b/apps/swift-ios/Core/Models.swift @@ -0,0 +1,761 @@ +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 threadPinReorder: Bool? + public let threadActiveReorder: Bool? + public let threadTitleRegeneration: Bool? + public let threadPullRequestLinking: Bool? + public var threadPullRequests: Bool? = nil + public var pullRequestStackActions: Bool? = nil + public let serverSelfUpdate: String? + public let serverSelfUpdateProgress: Bool? + public var environmentIcon: Bool? = nil + public var usageLimitSources: Bool? = nil + public var questionAttachments: Bool? = nil + public var projectSettingsOverrides: Bool? = nil + public var inlineMessageContext: 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 threadPinReorder + case threadActiveReorder + case threadTitleRegeneration + case threadPullRequestLinking + case threadPullRequests + case pullRequestStackActions + case serverSelfUpdate + case serverSelfUpdateProgress + case environmentIcon + case usageLimitSources + case questionAttachments + case projectSettingsOverrides + case inlineMessageContext + } + + 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) + questionAttachments = try container.decodeIfPresent(Bool.self, forKey: .questionAttachments) + projectSettingsOverrides = try container.decodeIfPresent(Bool.self, forKey: .projectSettingsOverrides) + inlineMessageContext = try container.decodeIfPresent(Bool.self, forKey: .inlineMessageContext) + 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) + threadPinReorder = try container.decodeIfPresent(Bool.self, forKey: .threadPinReorder) + threadActiveReorder = try container.decodeIfPresent( + Bool.self, + forKey: .threadActiveReorder + ) + threadTitleRegeneration = try container.decodeIfPresent( + Bool.self, + forKey: .threadTitleRegeneration + ) + threadPullRequestLinking = try container.decodeIfPresent( + Bool.self, + forKey: .threadPullRequestLinking + ) + threadPullRequests = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequests) + pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) + 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 var defaultThreadEnvMode: ServerThreadEnvironmentMode? = 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 + } +} + +/// Present on a thread while the server is generating a new title for it. +/// Cleared by the server when the regeneration completes or the thread is renamed. +public struct ThreadTitleRegeneration: Codable, Equatable, Sendable { + public let requestId: String + public let startedAt: String +} + +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 pullRequests: [ThreadPullRequestLink]? = 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 var pinOrderKey: String? = nil + public var titleRegeneration: ThreadTitleRegeneration? = nil + 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 var context: OrchestrationMessageContext? = nil +} + +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 var source: JSONValue? = nil +} + +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 pullRequests: [ThreadPullRequestLink]? = 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 var pinOrderKey: String? = nil + public var titleRegeneration: ThreadTitleRegeneration? = nil + 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: "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/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..c1d6ef902d02 --- /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/ProjectSettingsModels.swift b/apps/swift-ios/Core/ProjectSettingsModels.swift new file mode 100644 index 000000000000..4f39ab377099 --- /dev/null +++ b/apps/swift-ios/Core/ProjectSettingsModels.swift @@ -0,0 +1,86 @@ +import Foundation + +public enum ResponseStreamingMode: String, Codable, CaseIterable, Sendable { + case turn, paragraph, token + + public var label: String { + switch self { + case .turn: "After each turn" + case .paragraph: "Paragraphs" + case .token: "Tokens" + } + } +} + +/// Keys the native settings controls can change. Other override keys stay intact. +public enum ServerProjectSettingKey: String, Sendable { + case defaultModelSelection, defaultThreadEnvMode, newWorktreesStartFromOrigin + case defaultAutoPull, sidebarAutoSettleOnMerge, sidebarAutoSettleAfterDays + case continueThreadsAfterServerUpdate, responseStreamingMode +} + +public struct ServerProjectSettingChange: Sendable { + public let key: ServerProjectSettingKey + /// Nil inherits. JSON null is an explicit override for nullable settings. + public let value: JSONValue? + + public init(key: ServerProjectSettingKey, value: JSONValue?) { + self.key = key + self.value = value + } + + public func patch(projectID: String, settings: ServerSettingsSnapshot) -> ServerSettingsChange { + var entry = settings.projectSettingsOverrides[projectID] ?? [:] + entry[key.rawValue] = value + return .projectSettingsOverrides(projectID: projectID, entry: entry.isEmpty ? nil : entry) + } +} + +public extension ServerSettingsSnapshot { + /// The aggregate fields apply only until the server folds them into settings. + /// After that, removing an override must not restore a stale aggregate value. + func resolvingProject( + id: String, + legacyModelSelection: ModelSelection? = nil, + legacyWorkspaceMode: ServerThreadEnvironmentMode? = nil, + disabledProviderIDs: Set = [] + ) -> ServerSettingsSnapshot { + var resolved = self + var entry = projectSettingsOverrides[id] ?? [:] + if !projectSettingsFolded { + if entry["defaultModelSelection"] == nil, let legacyModelSelection { + entry["defaultModelSelection"] = try? JSONValue.encode(legacyModelSelection) + } + if entry["defaultThreadEnvMode"] == nil, let legacyWorkspaceMode { + entry["defaultThreadEnvMode"] = .string(legacyWorkspaceMode.rawValue) + } + } + if let value = entry["defaultModelSelection"] { + if value == .null { + resolved.defaultModelSelection = nil + } else if let selection = try? value.decode(ModelSelection.self), + !disabledProviderIDs.contains(selection.instanceId) { + resolved.defaultModelSelection = selection + } + } + if let value = entry["defaultThreadEnvMode"]?.stringValue.flatMap(ServerThreadEnvironmentMode.init(rawValue:)) { + resolved.defaultThreadEnvMode = value + } + if let value = entry["newWorktreesStartFromOrigin"]?.boolValue { + resolved.newWorktreesStartFromOrigin = value + } + if let value = entry["defaultAutoPull"]?.boolValue { resolved.defaultAutoPull = value } + if let value = entry["sidebarAutoSettleOnMerge"]?.boolValue { resolved.sidebarAutoSettleOnMerge = value } + if let value = entry["sidebarAutoSettleAfterDays"] { + if value == .null { resolved.sidebarAutoSettleAfterDays = nil } + else if let days = try? value.decode(Double.self) { resolved.sidebarAutoSettleAfterDays = days } + } + if let value = entry["continueThreadsAfterServerUpdate"]?.boolValue { + resolved.continueThreadsAfterServerUpdate = value + } + if let value = entry["responseStreamingMode"]?.stringValue.flatMap(ResponseStreamingMode.init(rawValue:)) { + resolved.responseStreamingMode = value + } + return resolved + } +} 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..cf9e88bfdbc5 --- /dev/null +++ b/apps/swift-ios/Core/PullRequestWireModels.swift @@ -0,0 +1,544 @@ +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, Hashable, 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 host: String? + public let expectedAccountId: String? + public let allowStale: Bool? + public let repository: String + public let number: Int + + public init(projectId: String, repository: String, number: Int, host: String? = nil, + expectedAccountId: String? = nil, allowStale: Bool? = nil) { + self.projectId = projectId + self.host = host + self.expectedAccountId = expectedAccountId + self.allowStale = allowStale + 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 PullRequestRoutingIdentity: Codable, Equatable, Sendable { + public let accountId: String + public let host: String + public let provider: SourceControlProviderKind + public let viewer: String + public let projectTitle: String? + public let workspaceRoot: String? +} + +public struct PullRequestDetail: Codable, Equatable, Sendable { + public let provider: SourceControlProviderKind + public let capabilities: PullRequestCapabilities + public let viewerPermissions: PullRequestViewerPermissions + public var projectId: String + public var projectTitle: String + public var 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 var host: String? = nil + public var expectedAccountId: String? = nil + public var allowStale: Bool? = nil +} + +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..bd0186c1a46f --- /dev/null +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -0,0 +1,426 @@ +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 var supportsConversationRollback: Bool? = 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 var defaultThreadEnvMode: ServerThreadEnvironmentMode + public var newWorktreesStartFromOrigin: Bool + public let sidebarProjectGroupingMode: ServerProjectGroupingMode? + public let sidebarProjectGroupingOverrides: [String: ServerProjectGroupingMode]? + public var sidebarAutoSettleOnMerge: Bool + public var sidebarAutoSettleAfterDays: Double? + public var continueThreadsAfterServerUpdate: Bool + public var defaultAutoPull = false + /// Missing on servers that do not support the current streaming setting. + public var responseStreamingMode: ResponseStreamingMode? = nil + public var projectSettingsOverrides: [String: [String: JSONValue]] = [:] + public var projectSettingsFolded = false + 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 + case defaultAutoPull, responseStreamingMode, projectSettingsOverrides, projectSettingsFolded + } + + 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) + defaultAutoPull = try container.decodeIfPresent(Bool.self, forKey: .defaultAutoPull) ?? false + responseStreamingMode = try container.decodeIfPresent(ResponseStreamingMode.self, forKey: .responseStreamingMode) + projectSettingsOverrides = try container.decodeIfPresent([String: [String: JSONValue]].self, forKey: .projectSettingsOverrides) ?? [:] + projectSettingsFolded = try container.decodeIfPresent(Bool.self, forKey: .projectSettingsFolded) ?? false + 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) + case responseStreamingMode(ResponseStreamingMode) + case projectSettingsOverrides(projectID: String, entry: [String: 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 .responseStreamingMode(value): .object(["responseStreamingMode": .string(value.rawValue)]) + case let .projectSettingsOverrides(projectID, entry): + .object(["projectSettingsOverrides": .object([projectID: entry.map(JSONValue.object) ?? .null])]) + 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..0c8659ac4e58 --- /dev/null +++ b/apps/swift-ios/Core/T3Client.swift @@ -0,0 +1,2493 @@ +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()! + } + } + + /// Read before replacing an override entry, including changes from other clients. + public func serverSettings() async throws -> ServerSettingsSnapshot { + try await rpc.request("server.getSettings", as: ServerSettingsSnapshot.self) + } + + 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 consumeResetCredit(.provider(instanceID: instanceID)) + } + + public func consumeResetCredit(_ input: ProviderConsumeResetCreditInput) async throws -> ProviderConsumeResetCreditResult { + try await rpc.request( + RPCMethod.providerConsumeResetCredit.rawValue, + payload: try JSONValue.encode(input), + 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 pullRequestRouting(_ reference: PullRequestRef) async throws -> PullRequestRoutingIdentity { + try await rpc.request(RPCMethod.pullRequestsRouting.rawValue, payload: try JSONValue.encode(reference), as: PullRequestRoutingIdentity.self) + } + + public func pullRequestRoutingIdentity(host: String) async throws -> PullRequestRoutingIdentity { + try await rpc.request(RPCMethod.pullRequestsRoutingIdentity.rawValue, payload: .object(["host": .string(host)]), as: PullRequestRoutingIdentity.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 + } + + public func shellEventBatches( + after sequence: Int? = nil, + reconnect: Bool = true + ) async -> AsyncThrowingStream<[ShellStreamItem], Error> { + var payload: [String: JSONValue] = ["requestCompletionMarker": .bool(true)] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + return await rpc.subscribeBatches( + RPCMethod.subscribeShell.rawValue, + payload: .object(payload), + reconnect: reconnect, + as: ShellStreamItem.self + ) + } + + public func threadEventBatches( + threadID: String, + after sequence: Int? = nil, + turnLimit: Int? = nil + ) async throws -> (events: AsyncThrowingStream<[ThreadStreamItem], Error>, 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.subscribeBatchesOnCurrentConnection( + 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] = [], + context: OrchestrationMessageContext? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) async throws -> DispatchResult { + let uploadedAttachments = try await prepareTurnAttachments(attachments) + let preparedMessage = Self.prepareMessageContext( + text: text, context: context, attachments: attachments, uploadedAttachments: uploadedAttachments, + supportsContext: (latestServerEnvironment ?? environment.descriptor)?.capabilities.inlineMessageContext == true + ) + return try await dispatch( + try OrchestrationCommands.sendTurn( + threadID: threadID, + text: preparedMessage.text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: attachments, + uploadedAttachments: uploadedAttachments, + context: preparedMessage.context, + 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] = [], + context: OrchestrationMessageContext? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) async throws -> DispatchResult { + let uploadedAttachments = try await prepareTurnAttachments(attachments) + let preparedMessage = Self.prepareMessageContext( + text: text, context: context, attachments: attachments, uploadedAttachments: uploadedAttachments, + supportsContext: (latestServerEnvironment ?? environment.descriptor)?.capabilities.inlineMessageContext == true + ) + return try await dispatchOverWebSocket( + try OrchestrationCommands.createThreadAndSend( + threadID: threadID, + projectID: projectID, + title: title, + text: preparedMessage.text, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + branch: branch, + worktreePath: worktreePath, + worktreePreparation: worktreePreparation, + attachments: attachments, + uploadedAttachments: uploadedAttachments, + context: preparedMessage.context, + commandID: commandID, + messageID: messageID, + createdAt: createdAt + ), + responseDeadline: worktreePreparation == nil ? .standard : .none + ) + } + + private func dispatchOverWebSocket( + _ command: JSONValue, + responseDeadline: WebSocketRPCClient.ResponseDeadline = .standard + ) async throws -> DispatchResult { + try await rpc.request( + RPCMethod.dispatchCommand.rawValue, + payload: command, + responseDeadline: responseDeadline, + 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], + attachmentsByQuestionID: [String: [UploadChatAttachment]] = [:] + ) async throws -> DispatchResult { + let count = attachmentsByQuestionID.values.reduce(0) { $0 + $1.count } + guard count <= 8 else { throw FileAttachmentError.tooMany(maximum: 8) } + var prepared: [String: [JSONValue]] = [:] + if count > 0 { + let config = try await serverConfig() + guard (config.environment ?? environment.descriptor)?.capabilities.questionAttachments == true else { + throw RPCError.protocolViolation("This environment does not support attachments in question answers.") + } + for questionID in attachmentsByQuestionID.keys.sorted() { + for attachment in attachmentsByQuestionID[questionID] ?? [] { + guard let reference = try await prepareAttachment(attachment) else { + throw FileAttachmentError.unsupported + } + prepared[questionID, default: []].append(attachment.uploadedJSONValue(id: reference.attachmentID)) + } + } + } + return try await dispatch( + OrchestrationCommands.respondToUserInput( + threadID: threadID, + requestID: requestID, + answers: answers, + attachmentsByQuestionID: prepared + ) + ) + } + + @discardableResult + public func dismissUserInput(threadID: String, requestID: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.dismissUserInput(threadID: threadID, requestID: requestID)) + } + + @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, + orderKey: String? = nil + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.pin(threadID: threadID, pinned: pinned, orderKey: orderKey) + ) + } + + @discardableResult + public func reorderPinnedThread( + threadID: String, + orderKey: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.reorderPinned(threadID: threadID, orderKey: orderKey) + ) + } + + @discardableResult + public func reorderActiveThread( + threadID: String, + orderKey: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.reorderActive(threadID: threadID, orderKey: orderKey) + ) + } + + @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, + directoryPath: String? = nil + ) async throws -> ProjectEntriesResult { + var payload: [String: JSONValue] = ["cwd": .string(cwd)] + if let directoryPath { + payload["directoryPath"] = .string(directoryPath) + } + return try await rpc.request( + RPCMethod.projectsListEntries.rawValue, + payload: .object(payload), + 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 + ) + } + + static func prepareMessageContext( + text: String, + context: OrchestrationMessageContext?, + attachments: [UploadChatAttachment], + uploadedAttachments: [JSONValue]?, + supportsContext: Bool + ) -> (text: String, context: OrchestrationMessageContext?) { + var records = ComposerContextReferences.referenced(context, text: text)?.records ?? [] + var prompt = text + if supportsContext { + for attachment in attachments where records.count < 200 { + guard !records.contains(where: { $0.attachment?.attachmentId == attachment.id.uuidString }) else { continue } + let binding = ComposerContextRecord.Attachment( + attachmentId: attachment.id.uuidString, name: attachment.name, + mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes + ) + let record = ComposerContextRecord( + contextId: "\(attachment.type)_\(attachment.id.uuidString)", label: attachment.name, + payload: attachment.type == "image" ? .image(binding) : .file(binding) + ) + records.append(record) + prompt = ComposerContextReferences.ensureReferences(prompt, records: [record]) + } + } + let ids = Dictionary(zip(attachments, uploadedAttachments ?? []).compactMap { attachment, uploaded in + uploaded["id"]?.stringValue.map { (attachment.id.uuidString, $0) } + }, uniquingKeysWith: { first, _ in first }) + let rebound = ComposerContextReferences.rebind(records.isEmpty ? nil : OrchestrationMessageContext(records: records), attachmentIDs: ids) + return supportsContext + ? (prompt, rebound) + : (ComposerContextReferences.providerProjection(prompt, context: rebound), nil) + } + + 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), let .fileData(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 pullRequestsRouting = "pullRequests.routing" + case pullRequestsRoutingIdentity = "pullRequests.routingIdentity" + 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 { + /// A distinct command makes older servers reject this action without restoring files. + public static func revertConversation( + threadID: String, + turnCount: Int, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.conversation.revert"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "turnCount": .number(Double(turnCount)), + "createdAt": .string(createdAt), + ]) + } + + 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, + context: OrchestrationMessageContext? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var message: [String: JSONValue] = [ + "messageId": .string(messageID), "role": .string("user"), "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ] + if let context { message["context"] = try .encode(context) } + var command: [String: JSONValue] = [ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object(message), + "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, + context: OrchestrationMessageContext? = 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) + } + var message: [String: JSONValue] = [ + "messageId": .string(messageID), "role": .string("user"), "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ] + if let context { message["context"] = try .encode(context) } + return .object([ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object(message), + "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], + attachmentsByQuestionID: [String: [JSONValue]] = [:], + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + let attachments = attachmentsByQuestionID.filter { !$0.value.isEmpty } + var completeAnswers = answers + // Message-mode questions require a string even when the answer is only a file. + for questionID in attachments.keys where completeAnswers[questionID] == nil { + completeAnswers[questionID] = .string("") + } + var payload: [String: JSONValue] = [ + "type": .string("thread.user-input.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "answers": .object(completeAnswers), + "createdAt": .string(createdAt), + ] + if !attachments.isEmpty { + payload["attachmentsByQuestionId"] = .object(attachments.mapValues(JSONValue.array)) + } + return .object(payload) + } + + public static func dismissUserInput( + threadID: String, + requestID: String, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.user-input.dismiss"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "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, + orderKey: String? = nil, + commandID: String = UUID().uuidString + ) -> JSONValue { + guard pinned, let orderKey else { + return basic( + type: pinned ? "thread.pin" : "thread.unpin", + threadID: threadID, + commandID: commandID + ) + } + return .object([ + "type": .string("thread.pin"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "orderKey": .string(orderKey), + ]) + } + + /// Fractional-index reorder: pinned threads sort by `pinOrderKey`, so a + /// move writes one key on one thread (see `thread.pin.reorder` in + /// packages/contracts). `thread.active.reorder` does the same for the + /// active section's `activeOrderKey`. + public static func reorderPinned( + threadID: String, + orderKey: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + reorder(type: "thread.pin.reorder", threadID: threadID, orderKey: orderKey, commandID: commandID) + } + + public static func reorderActive( + threadID: String, + orderKey: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + reorder(type: "thread.active.reorder", threadID: threadID, orderKey: orderKey, commandID: commandID) + } + + private static func reorder( + type: String, + threadID: String, + orderKey: String, + commandID: String + ) -> JSONValue { + .object([ + "type": .string(type), + "commandId": .string(commandID), + "threadId": .string(threadID), + "orderKey": .string(orderKey), + ]) + } + + 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/ThreadPullRequests.swift b/apps/swift-ios/Core/ThreadPullRequests.swift new file mode 100644 index 000000000000..a6d6a393c70e --- /dev/null +++ b/apps/swift-ios/Core/ThreadPullRequests.swift @@ -0,0 +1,213 @@ +import Foundation + +public struct ThreadPullRequestKey: Codable, Hashable, Sendable { + public let host: String + public let repository: String + public let number: Int + + public init(host: String, repository: String, number: Int) { + let host = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let repository = repository.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let suffix = ".visualstudio.com" + let parts = repository.split(separator: "/") + if ["ssh.dev.azure.com", "vs-ssh.visualstudio.com"].contains(host), parts.count == 4, parts[0] == "v3" { + self.host = "dev.azure.com" + self.repository = "\(parts[1])/\(parts[2])/_git/\(parts[3])" + } else if host.hasSuffix(suffix), repository.contains("/_git/") { + let organization = String(host.dropLast(suffix.count)) + self.host = "dev.azure.com" + self.repository = "\(organization)/\(repository.hasPrefix("defaultcollection/") ? String(repository.dropFirst("defaultcollection/".count)) : repository)" + } else { + self.host = host + self.repository = repository + } + self.number = number + } + + public func matchesRepository(_ canonicalKey: String) -> Bool { + guard let slash = canonicalKey.firstIndex(of: "/") else { return false } + return self == Self(host: String(canonicalKey[.. String? { + guard let url = URL(string: text), let host = url.host else { return nil } + return url.port.map { "\(host):\($0)" } ?? host + } + + public static func parseURL(_ text: String) -> ThreadPullRequestKey? { + guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), + ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host?.lowercased(), url.user == nil, url.password == nil else { return nil } + var routes: [(pattern: String, host: String)] = [] + if host == "github.com" || host.hasSuffix(".github.com") || host.split(separator: ".").contains("github") { + routes.append((#"^/([^/]+/[^/]+)/pull/([0-9]+)(?:/|$)"#, host)) + } + routes.append((#"^/([^/]+(?:/[^/]+)+)/-/merge_requests/([0-9]+)(?:/|$)"#, host)) + routes.append((#"^/([^/]+(?:/[^/]+)+)/pulls/([0-9]+)(?:/|$)"#, authority(of: url.absoluteString) ?? host)) + if host == "bitbucket.org" || host.hasSuffix(".bitbucket.org") || host.split(separator: ".").contains("bitbucket") { + routes.append((#"^/([^/]+/[^/]+)/pull-requests/([0-9]+)(?:/|$)"#, host)) + } + if host == "dev.azure.com" || host.hasSuffix(".dev.azure.com") || host.hasSuffix(".visualstudio.com") { + routes.append((#"^/((?:[^/]+/)*_git/[^/]+)/pullrequest/([0-9]+)(?:/|$)"#, host)) + } + let path = url.path + for route in routes { + guard let regex = try? NSRegularExpression(pattern: route.pattern), + let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), + let repositoryRange = Range(match.range(at: 1), in: path), + let numberRange = Range(match.range(at: 2), in: path), + let number = Int(path[numberRange]), number > 0 else { continue } + return ThreadPullRequestKey(host: route.host, repository: String(path[repositoryRange]), number: number) + } + return nil + } + + public static func mutation(threadID: String, key: ThreadPullRequestKey, url: String, + linked: Bool, multiple: Bool, legacyProjectID: String?, legacyRepository: String? = nil, + commandID: String = UUID().uuidString) -> JSONValue? { + var payload: [String: JSONValue] = ["commandId": .string(commandID), "threadId": .string(threadID)] + if multiple { + payload["type"] = .string(linked ? "thread.pull-request.link" : "thread.pull-request.unlink") + payload["host"] = .string(key.host) + payload["repository"] = .string(key.repository) + payload["number"] = .number(Double(key.number)) + if linked { + payload["url"] = .string(url) + payload["source"] = .string("manual") + } + } else { + guard !linked || legacyProjectID != nil else { return nil } + payload["type"] = .string("thread.meta.update") + payload["linkedPullRequest"] = linked ? .object([ + "projectId": .string(legacyProjectID ?? ""), "repository": .string(legacyRepository ?? key.repository), + "number": .number(Double(key.number)), "url": .string(url), + ]) : .null + } + return .object(payload) + } + + public static func visible(_ links: [ThreadPullRequestLink]) -> [ThreadPullRequestLink] { + links.filter(\.isVisible) + } + + public static func chains(_ links: [ThreadPullRequestLink]) -> [[ThreadPullRequestLink]] { + let links = visible(links) + var placed: Set = [] + var result: [[ThreadPullRequestLink]] = [] + for link in links where link.stack != nil && !placed.contains(link.id) { + guard let stack = link.stack else { continue } + let members = links.filter { + $0.id.host == link.id.host && $0.id.repository == link.id.repository && $0.stack?.id == stack.id + } + let ordered = members.sorted { left, right in + (stack.layers.firstIndex { $0.number == left.number } ?? 0) + < (stack.layers.firstIndex { $0.number == right.number } ?? 0) + } + placed.formUnion(ordered.map(\.id)) + result.append(ordered) + } + let remaining = links.filter { !placed.contains($0.id) } + func parent(of link: ThreadPullRequestLink) -> ThreadPullRequestLink? { + guard let base = link.snapshot?.baseBranch else { return nil } + let matches = remaining.filter { + $0.id.host == link.id.host && $0.id.repository == link.id.repository + && $0.snapshot?.headBranch == base + } + return matches.count == 1 && matches[0].id != link.id ? matches[0] : nil + } + let parents = Set(remaining.compactMap { parent(of: $0)?.id }) + for top in remaining where !parents.contains(top.id) { + var chain: [ThreadPullRequestLink] = [] + var cursor: ThreadPullRequestLink? = top + while let link = cursor, placed.insert(link.id).inserted { + chain.insert(link, at: 0) + cursor = parent(of: link) + } + if !chain.isEmpty { result.append(chain) } + } + for link in remaining where !placed.contains(link.id) { result.append([link]) } + return result + } + + public static func current(_ links: [ThreadPullRequestLink]) -> ThreadPullRequestLink? { + let visible = visible(links) + let open = visible.filter(\.isOpen) + if open.count == 1 { return open.first } + let chains = chains(visible) + if !open.isEmpty { + return chains.map { $0.reversed().filter(\.isOpen) }.filter { !$0.isEmpty } + .sorted { ($0.map(\.linkedAt).max() ?? "") > ($1.map(\.linkedAt).max() ?? "") } + .first?.first + } + if chains.count == 1 { return chains.first?.last } + return visible.max { ($0.snapshot?.updatedAt ?? $0.linkedAt) < ($1.snapshot?.updatedAt ?? $1.linkedAt) } + } + + public static func searchTerms(_ links: [ThreadPullRequestLink]?, legacy: ThreadLinkedPullRequest?) -> [String] { + if let links, !links.isEmpty { + return visible(links).flatMap { ["#\($0.number)", "\($0.repository)#\($0.number)", $0.url, $0.snapshot?.title ?? ""] } + } + return legacy.map { ["#\($0.number)", "\($0.repository)#\($0.number)", $0.url] } ?? [] + } +} 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..2701018abe02 --- /dev/null +++ b/apps/swift-ios/Core/UsageLimitsModels.swift @@ -0,0 +1,192 @@ +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 let nextCreditId: String? + + public init(availableCount: Int, nextExpiresAt: String? = nil, nextCreditId: String? = nil) { + self.availableCount = availableCount + self.nextExpiresAt = nextExpiresAt + self.nextCreditId = nextCreditId + } +} + +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 enum ProviderConsumeResetCreditInput: Codable, Hashable, Sendable { + case provider(instanceID: String) + case source(sourceID: String, accountID: String, creditID: String) + + private enum CodingKeys: String, CodingKey { case instanceId, sourceId, accountId, creditId } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + if let instanceID = try values.decodeIfPresent(String.self, forKey: .instanceId) { + self = .provider(instanceID: instanceID) + } else { + self = .source( + sourceID: try values.decode(String.self, forKey: .sourceId), + accountID: try values.decode(String.self, forKey: .accountId), + creditID: try values.decode(String.self, forKey: .creditId) + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .provider(instanceID): + try values.encode(instanceID, forKey: .instanceId) + case let .source(sourceID, accountID, creditID): + try values.encode(sourceID, forKey: .sourceId) + try values.encode(accountID, forKey: .accountId) + try values.encode(creditID, forKey: .creditId) + } + } +} + +public struct ProviderConsumeResetCreditResult: Codable, Equatable, Sendable { + public let outcome: ProviderConsumeResetCreditOutcome + public let warning: String? + + public init(outcome: ProviderConsumeResetCreditOutcome, warning: String? = nil) { + self.outcome = outcome + self.warning = warning + } +} 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..4c13309c3351 --- /dev/null +++ b/apps/swift-ios/Core/WebSocketRPC.swift @@ -0,0 +1,1071 @@ +import Foundation +import Network +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 + + public enum ResponseDeadline: Sendable, Equatable { + case standard + /// Setup can run until it completes, disconnects, or its caller cancels. + case none + } + + private static let logger = Logger( + subsystem: "com.t3tools.t3code", + category: "WebSocketRPC" + ) + + private struct UnaryRequest { + let envelope: RPCRequestEnvelope + let responseDeadline: ResponseDeadline + 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 batchSize: Int + 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 beginReconnectDelay(failureCount: Int, loopID: UUID) async -> Task? { + guard let value else { return nil } + return await value.beginReconnectDelay(failureCount: failureCount, loopID: loopID) + } + } + + private let connector: any WebSocketConnecting + private let endpointProvider: EndpointProvider + private let connectionWaitTimeout: Duration + private let responseTimeout: Duration + private let responseDeadlineSleep: @Sendable (Duration) async throws -> Void + 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? + /// The reconnect backoff currently sleeping, so a network path recovery + /// can cut it short. Cancelled only while the client is still desired. + private var backoffSleepTask: Task? + private var keepaliveTask: Task? + private var pathMonitor: NWPathMonitor? + private var pathIsSatisfied: Bool? + 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), + responseDeadlineSleep: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + }, + keepaliveInterval: Duration = .seconds(5), + subscriptionBufferLimit: Int = 1_024, + reconnectBackoff: @escaping @Sendable (Int) -> Duration = { failureCount in + // Fast first retries, then a 30s cap. Every attempt mints an HTTP + // ticket, so a long outage must not hammer the ticket endpoint. + // Jitter desynchronizes reconnects across environments so a + // server restart doesn't trigger simultaneous ticket mints. + let backoff = min(30.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.responseDeadlineSleep = responseDeadlineSleep + self.keepaliveInterval = keepaliveInterval > .zero ? keepaliveInterval : .seconds(5) + self.subscriptionBufferLimit = max(1, subscriptionBufferLimit) + self.reconnectBackoff = reconnectBackoff + self.endpointProvider = endpointProvider + } + + deinit { + loopTask?.cancel() + backoffSleepTask?.cancel() + keepaliveTask?.cancel() + pathMonitor?.cancel() + } + + public func start() { + desired = true + startPathMonitor() + 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 + backoffSleepTask?.cancel() + backoffSleepTask = nil + pathMonitor?.cancel() + pathMonitor = nil + pathIsSatisfied = 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([:]), + responseDeadline: ResponseDeadline = .standard, + as type: Result.Type + ) async throws -> Result { + let raw = try await requestRaw(tag, payload: payload, responseDeadline: responseDeadline) + return try raw.decode(type) + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]) + ) async throws { + _ = try await requestRaw(tag, payload: payload, responseDeadline: .standard) + } + + public func subscribe( + _ tag: String, + payload: JSONValue = .object([:]), + reconnect: Bool = true, + as type: Value.Type + ) -> AsyncThrowingStream { + subscribe(tag, payload: payload, reconnect: reconnect, batchSize: 1) { + try $0[0].decode(type) + } + } + + /// Preserve server batches for consumers that can apply several events at once. + /// The queue fits normal catch-up chunks. An overflow requests a resync + /// without acknowledging lost values. Enqueueing is not consumer progress, + /// so server acknowledgements alone cannot bound this queue. + public func subscribeBatches( + _ tag: String, + payload: JSONValue = .object([:]), + reconnect: Bool = true, + as type: Value.Type + ) -> AsyncThrowingStream<[Value], Error> { + subscribe(tag, payload: payload, reconnect: reconnect, + batchSize: min(64, subscriptionBufferLimit)) { values in + try values.map { try $0.decode(type) } + } + } + + private func subscribe( + _ tag: String, + payload: JSONValue, + reconnect: Bool, + batchSize: Int, + decode: @escaping @Sendable ([JSONValue]) throws -> Value + ) -> AsyncThrowingStream { + let subscriptionID = UUID() + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(max(1, subscriptionBufferLimit / batchSize))) { + continuation in + subscriptions[subscriptionID] = Subscription( + tag: tag, + payload: payload, + reconnect: reconnect, + requestID: nil, + batchSize: batchSize, + yield: { value in + do { + switch continuation.yield(try decode(value)) { + 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 [weak self] _ 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 await subscribeOnCurrentConnection { + subscribe(tag, payload: payload, reconnect: false, as: type) + } + } + + public func subscribeBatchesOnCurrentConnection( + _ tag: String, + payload: JSONValue = .object([:]), + as type: Value.Type + ) async throws -> (events: AsyncThrowingStream<[Value], Error>, connectionID: UUID) { + try await subscribeOnCurrentConnection { + subscribeBatches(tag, payload: payload, reconnect: false, as: type) + } + } + + private func subscribeOnCurrentConnection( + makeStream: () -> AsyncThrowingStream + ) async throws -> (events: AsyncThrowingStream, connectionID: UUID) { + try Task.checkCancellation() + start() + while true { + try Task.checkCancellation() + if let id = connectionID { + return (makeStream(), id) + } + _ = try await waitForConnection(after: nil) + } + } + + private func requestRaw( + _ tag: String, payload: JSONValue, responseDeadline: ResponseDeadline + ) 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, + responseDeadline: responseDeadline, + 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 sleep = await owner.beginReconnectDelay( + failureCount: retry, + loopID: loopID + ) else { break } + // The sleep is a detached task so this wait does not retain + // the client. Cancelling this loop must still end it at once. + let interrupted = await withTaskCancellationHandler { + await sleep.value + } onCancel: { + sleep.cancel() + } + // A network path recovery cancels the sleep while this loop is + // still current. Retry immediately with fresh backoff. + if interrupted, await owner.isCurrentConnectionLoop(loopID) { + retry = 0 + } + } + } + await owner.finishConnectionLoop(loopID) + } + + private func connectionAttempt(loopID: UUID) -> ConnectionAttempt? { + guard isCurrentConnectionLoop(loopID) else { return nil } + return ConnectionAttempt(connector: connector, endpointProvider: endpointProvider) + } + + /// Returns a sleeping task that resolves to `true` when it was cancelled + /// before the delay elapsed. + private func beginReconnectDelay(failureCount: Int, loopID: UUID) -> Task? { + guard isCurrentConnectionLoop(loopID) else { return nil } + let delay = reconnectBackoff(failureCount) + let sleep = Task.detached { + do { + try await Task.sleep(for: delay) + return false + } catch { + return true + } + } + backoffSleepTask = sleep + return sleep + } + + private func startPathMonitor() { + guard pathMonitor == nil else { return } + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { @Sendable [weak self] path in + let isSatisfied = path.status == .satisfied + Task { await self?.pathStatusChanged(isSatisfied: isSatisfied) } + } + monitor.start(queue: .global(qos: .utility)) + pathMonitor = monitor + } + + /// Wakes a sleeping backoff when the network comes back. The first report + /// and later drops change nothing: only an unsatisfied-to-satisfied edge + /// while no socket is live is worth an immediate attempt. + private func pathStatusChanged(isSatisfied: Bool) { + let wasSatisfied = pathIsSatisfied + pathIsSatisfied = isSatisfied + guard isSatisfied, wasSatisfied == false, desired, connection == nil else { return } + backoffSleepTask?.cancel() + backoffSleepTask = nil + } + + 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 } + let values = response.values ?? [] + for start in stride(from: 0, to: values.count, by: subscription.batchSize) { + let end = min(start + subscription.batchSize, values.count) + switch subscription.yield(Array(values[start.. 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 + guard request.responseDeadline == .standard else { + unary[id] = request + return + } + let responseTimeout = responseTimeout + let sleep = responseDeadlineSleep + request.responseDeadlineTask = Task { [weak self] in + do { + try await sleep(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.sendDeadlineTask != 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..e65600b93463 --- /dev/null +++ b/apps/swift-ios/Core/WorkspaceModels.swift @@ -0,0 +1,583 @@ +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 var ignored: Bool? = nil +} + +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 forgejo + case gitlab + case azureDevOps = "azure-devops" + case bitbucket + case unknown + + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = Self(rawValue: raw) ?? (raw == "gitea" ? .forgejo : .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..79f7960411cb --- /dev/null +++ b/apps/swift-ios/DesignSystem/ProviderIcon.swift @@ -0,0 +1,147 @@ +import SwiftUI + +enum ProviderInstanceDisplay { + static func name(instanceID: String, driver: String, displayName: String?) -> String { + let brand = UsageLimitsPresentation.providerLabel(driver: driver) + let trimmed = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty, trimmed != brand { return trimmed } + if instanceID != driver { + let label = instanceID + .replacingOccurrences(of: "([a-z])([A-Z])", with: "$1 $2", options: .regularExpression) + .replacingOccurrences(of: "[_-]+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isWhitespace) + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + if !label.isEmpty { return label } + } + if let trimmed, !trimmed.isEmpty { return trimmed } + return brand + } + + static func initials(_ name: String) -> String { + let words = name.replacingOccurrences(of: "[_-]+", with: " ", options: .regularExpression) + .split(whereSeparator: \.isWhitespace) + if words.count == 1 { return String(words[0].prefix(2)).uppercased() } + return words.prefix(2).compactMap(\.first).map(String.init).joined().uppercased() + } + + static func accentColor(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + value.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil else { return nil } + return value + } +} + +struct ProviderAccountBadge: Equatable { + let initials: String + let accentColor: String? +} + +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 accountBadge: ProviderAccountBadge? = nil + + 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) + .overlay(alignment: .bottomTrailing) { + if let accountBadge { + Text(accountBadge.initials) + .font(.system(size: max(8, size * 0.45), weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 2) + .frame(minWidth: 12, minHeight: 12) + .background(badgeColor(accountBadge.accentColor), in: RoundedRectangle(cornerRadius: 3)) + .overlay { + RoundedRectangle(cornerRadius: 3).stroke(T3Colors.background, lineWidth: 1) + } + .offset(x: 5, y: 5) + } + } + .accessibilityHidden(true) + } + + private var fallbackInitial: String { + fallbackName.trimmingCharacters(in: .whitespacesAndNewlines) + .first + .map { String($0).uppercased() } ?? "?" + } + + private func badgeColor(_ value: String?) -> Color { + guard let value = ProviderInstanceDisplay.accentColor(value), + let rgb = UInt32(value.dropFirst(), radix: 16) else { return Color(white: 0.3) } + return Color(red: Double(rgb >> 16) / 255, green: Double((rgb >> 8) & 255) / 255, blue: Double(rgb & 255) / 255) + } +} 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..a0a3e2990a92 --- /dev/null +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -0,0 +1,106 @@ +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 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/SubscriptionUsageSnapshot.swift b/apps/swift-ios/Extensions/Shared/SubscriptionUsageSnapshot.swift new file mode 100644 index 000000000000..016512314501 --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/SubscriptionUsageSnapshot.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Display data only. Account emails, server addresses, and credentials never +/// leave the app through this snapshot. +struct T3SubscriptionUsageSnapshot: Codable, Equatable, Sendable { + struct Window: Codable, Equatable, Identifiable, Sendable { + let id: String + let kind: String + let label: String + let remaining: Int + let resetAt: Date? + } + + struct Provider: Codable, Equatable, Identifiable, Sendable { + let id: String + let name: String + let accountCount: Int + let checkedAt: Date? + let expiresAt: Date? + let windows: [Window] + let totalWindows: Int + var hasPartialData: Bool + + func isFresh(at now: Date) -> Bool { expiresAt.map { $0 > now } ?? false } + + func detail(at now: Date) -> String { + if !windows.isEmpty && !isFresh(at: now) { return "Open T3 to refresh" } + if hasPartialData { return "Some limits unavailable" } + if windows.isEmpty { return "No limits available" } + return accountCount > 1 ? "\(accountCount) accounts · pooled" : "Subscription remaining" + } + + func visibleWindows(period: String, limit: Int, at now: Date, tightestOnly: Bool = false) -> [Window] { + guard isFresh(at: now) else { return [] } + let matching = windows.filter { period == "both" || $0.kind == period } + if tightestOnly { return matching.min { $0.remaining < $1.remaining }.map { [$0] } ?? [] } + let primary = [matching.first { $0.kind == "session" }, matching.first { $0.kind == "weekly" }].compactMap { $0 } + return Array((primary + matching.filter { window in !primary.contains(where: { $0.id == window.id }) }).prefix(limit)) + } + } + + let providers: [Provider] + /// An opaque digest identifies the enabled environments and signed-in user. + /// The widget does not receive their identifiers or connection addresses. + var scopeID: String? = nil + static let empty = T3SubscriptionUsageSnapshot(providers: [ + .init(id: "codex", name: "Codex", accountCount: 0, checkedAt: nil, expiresAt: nil, windows: [], totalWindows: 0, hasPartialData: false), + .init(id: "claudeAgent", name: "Claude", accountCount: 0, checkedAt: nil, expiresAt: nil, windows: [], totalWindows: 0, hasPartialData: false), + ]) + + var checkedAt: Date? { providers.compactMap(\.checkedAt).min() } + + /// Precompute each expiration. WidgetKit need not wake the app or poll a server. + func timelineDates(from now: Date) -> [Date] { + [now] + Set(providers.compactMap(\.expiresAt).filter { $0 > now }).sorted() + } +} + +enum T3SubscriptionUsageSnapshotStore { + static let kind = "T3SubscriptionUsageWidget" + + static func load() -> T3SubscriptionUsageSnapshot { + guard let url = fileURL(), + let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size <= 64 * 1_024, + let data = try? Data(contentsOf: url), + let snapshot = try? JSONDecoder().decode(T3SubscriptionUsageSnapshot.self, from: data) else { return .empty } + return snapshot + } + + static func save(_ snapshot: T3SubscriptionUsageSnapshot) throws { + guard let url = fileURL() else { throw CocoaError(.fileNoSuchFile) } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(snapshot).write(to: url, options: .atomic) + } + + private static func fileURL() -> URL? { + T3SharedContainer.rootURL? + .appending(path: "Library/Application Support/T3Code", directoryHint: .isDirectory) + .appending(path: "subscription-usage-widget.json", directoryHint: .notDirectory) + } +} 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":"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/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":"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/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: "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/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: "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/preview/two" + ), + ] + ) +} diff --git a/apps/swift-ios/Extensions/Widgets/SubscriptionUsageWidget.swift b/apps/swift-ios/Extensions/Widgets/SubscriptionUsageWidget.swift new file mode 100644 index 000000000000..a1b4a73bb666 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/SubscriptionUsageWidget.swift @@ -0,0 +1,183 @@ +import AppIntents +import SwiftUI +import WidgetKit + +enum T3SubscriptionPeriod: String, AppEnum { + case both + case session + case weekly + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Period" + static let caseDisplayRepresentations: [T3SubscriptionPeriod: DisplayRepresentation] = [ + .both: "Both", .session: "Session", .weekly: "Weekly", + ] +} + +struct T3SubscriptionUsageConfiguration: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Subscription usage" + static let description = IntentDescription("Choose the limits shown for each provider.") + + @Parameter(title: "Codex", default: .both) + var codexPeriod: T3SubscriptionPeriod + + @Parameter(title: "Claude", default: .both) + var claudePeriod: T3SubscriptionPeriod +} + +private struct T3SubscriptionUsageEntry: TimelineEntry { + let date: Date + let snapshot: T3SubscriptionUsageSnapshot + let configuration: T3SubscriptionUsageConfiguration +} + +private struct T3SubscriptionUsageProvider: AppIntentTimelineProvider { + func placeholder(in _: Context) -> T3SubscriptionUsageEntry { + .init(date: Date(), snapshot: .empty, configuration: .init()) + } + + func snapshot(for configuration: T3SubscriptionUsageConfiguration, in _: Context) async -> T3SubscriptionUsageEntry { + .init(date: Date(), snapshot: T3SubscriptionUsageSnapshotStore.load(), configuration: configuration) + } + + func timeline(for configuration: T3SubscriptionUsageConfiguration, in _: Context) async -> Timeline { + let snapshot = T3SubscriptionUsageSnapshotStore.load() + let entries = snapshot.timelineDates(from: Date()).map { + T3SubscriptionUsageEntry(date: $0, snapshot: snapshot, configuration: configuration) + } + return Timeline(entries: entries, policy: .never) + } +} + +struct T3SubscriptionUsageWidget: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: T3SubscriptionUsageSnapshotStore.kind, + intent: T3SubscriptionUsageConfiguration.self, + provider: T3SubscriptionUsageProvider() + ) { entry in + T3SubscriptionUsageView(entry: entry) + .containerBackground(.black, for: .widget) + } + .configurationDisplayName("Subscription usage") + .description("Pooled Codex and Claude limits. Open T3 to refresh.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryRectangular]) + } +} + +private struct T3SubscriptionUsageView: View { + @Environment(\.widgetFamily) private var family + let entry: T3SubscriptionUsageEntry + + private var accessory: Bool { family == .accessoryRectangular } + private var compact: Bool { family == .systemSmall || accessory } + private var dense: Bool { family == .systemSmall || family == .systemMedium } + private var limit: Int { family == .systemExtraLarge ? 6 : family == .systemLarge ? 4 : 2 } + // Widget galleries can request an old entry after its expiry. + private var now: Date { max(entry.date, Date()) } + + var body: some View { + VStack(alignment: .leading, spacing: dense || accessory ? 2 : 6) { + if compact { + VStack(alignment: .leading, spacing: 4) { + columns + } + } else { + HStack(alignment: .top, spacing: 16) { + columns + } + } + if !accessory { + Spacer(minLength: 0) + Text(entry.snapshot.checkedAt.map { + "As of \($0.formatted(date: .abbreviated, time: .shortened))" + } ?? "Tap to connect in T3") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + } + } + .foregroundStyle(.white) + .widgetURL(URL(string: "\(T3SharedContainer.urlScheme)://usage/limits")) + } + + @ViewBuilder + private var columns: some View { + ForEach(entry.snapshot.providers) { provider in + providerColumn(provider) + } + } + + private func providerColumn(_ provider: T3SubscriptionUsageSnapshot.Provider) -> some View { + let period = provider.id == "codex" ? entry.configuration.codexPeriod : entry.configuration.claudePeriod + let shown = provider.visibleWindows(period: period.rawValue, limit: limit, at: now, tightestOnly: accessory) + let missingPeriod = period != .both && shown.isEmpty && provider.isFresh(at: now) && !provider.windows.isEmpty + let detail = missingPeriod ? "No \(period.rawValue) limit reported" : provider.detail(at: now) + return VStack(alignment: .leading, spacing: dense ? 1 : 4) { + HStack(spacing: 4) { + Text(provider.name) + .font(.system(size: compact ? 12 : 15, weight: .bold)) + .lineLimit(1) + if accessory { + Spacer(minLength: 4) + Text(shown.first.map { "\($0.remaining)% left" } ?? (missingPeriod ? "N/A" : "Open T3")) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + } + } + if !accessory && (!compact || shown.isEmpty || provider.hasPartialData) { + // Both columns reserve the same detail line. Quota count must + // not change text sizes, bar heights, or first-row alignment. + Text(detail == "Subscription remaining" ? " " : detail) + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + .frame(height: 12, alignment: .leading) + } + ForEach(shown) { window in + VStack(alignment: .leading, spacing: dense ? 1 : 2) { + if !accessory { + HStack(spacing: 4) { + Text(window.label) + .lineLimit(1) + Spacer(minLength: 2) + Text("\(window.remaining)% left") + .fontWeight(.semibold) + .monospacedDigit() + .lineLimit(1) + .layoutPriority(1) + } + .font(.system(size: compact || dense ? 11 : 12)) + } + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle().fill(.white.opacity(0.18)) + Rectangle().fill(.white) + .frame(width: geometry.size.width * Double(min(100, max(0, window.remaining))) / 100) + } + } + .frame(height: 4) + if !compact { + Text(window.resetAt.map { + "Next reset \($0.formatted(date: .abbreviated, time: .shortened))" + } ?? "Reset time unavailable") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + } + } + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(provider.name), \(window.label), \(window.remaining) percent remaining. \(detail).") + } + let total = period == .both ? provider.totalWindows : provider.windows.filter { $0.kind == period.rawValue }.count + if !compact, provider.isFresh(at: now), total > shown.count { + Text("\(total - shown.count) more in T3") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } +} 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..eda2001060b7 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift @@ -0,0 +1,11 @@ +import SwiftUI +import WidgetKit + +@main +struct T3CodeWidgetBundle: WidgetBundle { + var body: some Widget { + T3TaskLiveActivity() + T3RecentTasksWidget() + T3SubscriptionUsageWidget() + } +} 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/FeatureComposerContext.swift b/apps/swift-ios/Features/Chat/FeatureComposerContext.swift new file mode 100644 index 000000000000..88a527786629 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerContext.swift @@ -0,0 +1,29 @@ +import Foundation + +enum FeatureComposerContext { + enum MergeError: LocalizedError { + case tooManyRecords + var errorDescription: String? { "A message can include up to 200 context items. Remove an item before adding another." } + } + + static func terminalRecord(text: String, terminalID: String, label: String) -> ComposerContextRecord { + // Keep the newest output and its original line numbers within the wire limit. + let suffix = String(decoding: text.utf16.suffix(64_000).drop(while: { (0xDC00...0xDFFF).contains($0) }), as: UTF16.self) + let end = text.components(separatedBy: "\n").count - 1 + let start = max(0, end - suffix.components(separatedBy: "\n").count + 1) + return ComposerContextRecord(label: "\(label) lines \(start)-\(end)", payload: .terminal(.init( + terminalId: terminalID, terminalLabel: label, lineStart: start, lineEnd: end, text: suffix + ))) + } + + static func merge( + _ first: OrchestrationMessageContext?, _ second: OrchestrationMessageContext? + ) throws -> OrchestrationMessageContext? { + var seen = Set() + let records = ((first?.records ?? []) + (second?.records ?? [])).filter { + seen.insert($0.contextId).inserted + } + guard records.count <= 200 else { throw MergeError.tooManyRecords } + return records.isEmpty ? nil : OrchestrationMessageContext(records: records) + } +} 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..c882e2973cea --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift @@ -0,0 +1,480 @@ +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("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/.codex/plugins/") + || normalizedPath.contains("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/.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[.. URL? { + // Keep a Windows drive followed by a slash so the workspace router + // recognizes it as a file path instead of an external URL scheme. + URL(string: encodeDestination(path.replacingOccurrences(of: "\\", with: "/"))) + } + + static func markdownLink(for path: String) -> 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..78b31085581f --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -0,0 +1,668 @@ +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) + + // A patch or command can be a whole diff. Scroll it so + // the decision buttons below never leave the screen. + ScrollView { + 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) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 220) + } + .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) + .frame(minHeight: T3Metrics.minimumTapTarget) + } + + Button("Cancel turn", action: onCancelTurn) + .foregroundStyle(T3Colors.textTertiary) + .frame(minHeight: T3Metrics.minimumTapTarget) + } + .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], [String: [FeatureUploadAttachment]]) async -> Void + var onDismiss: (() async -> Void)? = nil + var environmentID: String? = nil + var attachmentPreferences = FeatureEnvironmentPreferences() + + @State private var answers: [String: FeatureInputAnswer] = [:] + @State private var questionIndex = 0 + @State private var attachmentsByQuestionID: [String: [FeatureDraftAttachment]] = [:] + @State private var preparation = FeatureAttachmentPreparationState() + @State private var attachmentFlowActive = false + @State private var restoredInputID: String? + @State private var draftError: String? + @State private var draftWrite: Task? + @State private var isSubmittingAnswer = false + + 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) + + if !question.options.isEmpty { + ScrollView { + VStack(spacing: 6) { + ForEach( + Array(question.options.enumerated()), + id: \.element.label + ) { index, option in + optionButton(option, number: index + 1, question: question) + .disabled(preparation.isPreparing || attachmentFlowActive) + } + } + .padding(.horizontal, 10) + .padding(.top, 10) + } + .frame(maxHeight: 320) + .scrollIndicators(.hidden) + } + + if question.canWriteCustomAnswer { + HStack(spacing: 8) { + if input.supportsAttachments == true { + FeatureImageAttachmentPicker( + attachments: attachmentBinding(questionID: question.id), + preparationState: $preparation, + isFlowActive: $attachmentFlowActive, + draftOwnerID: "\(input.id):\(question.id)", + environmentID: environmentID, + imagesAllowed: attachmentPreferences.supportsImageUploads, + maximumFileBytes: attachmentPreferences.maxFileAttachmentBytes, + maximumCount: max(0, FeatureImageAttachmentLimits.maximumCount - otherAttachmentCount(questionID: question.id)) + ) + .disabled(restoredInputID != input.id) + } + 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) + } + + if !(attachmentsByQuestionID[question.id] ?? []).isEmpty { + FeatureAttachmentStrip(attachments: attachmentBinding(questionID: question.id)) + .padding(.horizontal, 10) + } + if preparation.isPreparing || isResponding || isSubmittingAnswer { + Text(preparation.isPreparing ? preparation.statusLabel : responseStatus) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .padding(.top, 6) + } + if let draftError { + Text(draftError) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .padding(.horizontal, 10) + } + if let attachmentBlocker { + Text(attachmentBlocker) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .padding(.horizontal, 10) + } + + HStack(spacing: 8) { + if input.canDismiss, let onDismiss { + Button("Dismiss") { + let pendingWrite = draftWrite + isSubmittingAnswer = true + Task { @MainActor in + defer { isSubmittingAnswer = false } + await pendingWrite?.value + await onDismiss() + } + } + .font(T3Typography.control) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Dismiss question without replying") + .disabled(preparation.isPreparing || attachmentFlowActive) + } + if questionIndex > 0 { + Button("Back") { + questionIndex -= 1 + } + .disabled(preparation.isPreparing || attachmentFlowActive) + .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 || isSubmittingAnswer) + .opacity(isResponding || isSubmittingAnswer ? 0.56 : 1) + } + } + .onChange(of: input.id) { + answers = [:] + questionIndex = 0 + restoredInputID = nil + attachmentsByQuestionID = [:] + draftError = nil + } + .task(id: input.id) { + let requestID = input.id + do { + let stored = try await FeatureComposerDraftStore.shared.draft( + for: FeatureQuestionAttachmentDraft.key(inputID: requestID) + ) + try Task.checkCancellation() + guard requestID == input.id else { return } + attachmentsByQuestionID = try FeatureQuestionAttachmentDraft.decode(stored) + .filter { questionIDs.contains($0.key) } + restoredInputID = requestID + } catch is CancellationError { + return + } catch { + restoredInputID = requestID + draftError = "Could not restore question attachments." + } + } + .onChange(of: attachmentsByQuestionID) { _, current in + guard restoredInputID == input.id else { return } + let key = FeatureQuestionAttachmentDraft.key(inputID: input.id) + let previousWrite = draftWrite + draftWrite = Task { + await previousWrite?.value + do { + try await FeatureComposerDraftStore.shared.setDraft( + FeatureQuestionAttachmentDraft.encode(current), for: key + ) + draftError = nil + } catch { + draftError = "Could not save question attachments. Keep this thread open to send them." + } + } + } + .onChange(of: questionIDs) { previousIDs, currentIDs in + questionIndex = FeatureComposerQuestionReconciliation.index( + current: questionIndex, + previousQuestionIDs: previousIDs, + currentQuestionIDs: currentIDs + ) + answers = FeatureComposerQuestionReconciliation.answers( + answers, + currentQuestionIDs: currentIDs + ) + attachmentsByQuestionID = attachmentsByQuestionID.filter { currentIDs.contains($0.key) } + } + } + + 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, !isSubmittingAnswer, !preparation.isPreparing, !attachmentFlowActive, + restoredInputID == input.id, attachmentBlocker == nil else { return false } + return normalizedAnswer(for: activeQuestion.id) != nil || hasAttachments(for: activeQuestion.id) + } + + private var normalizedAnswers: [String: FeatureInputAnswer]? { + var result: [String: FeatureInputAnswer] = [:] + for question in input.questions { + if let answer = normalizedAnswer(for: question.id) { + result[question.id] = answer + } else if !hasAttachments(for: question.id) { + return nil + } + } + 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 { + let submitted = attachmentsByQuestionID.filter { hasAttachments(for: $0.key) } + .mapValues { $0.map(FeatureUploadAttachment.init) } + let pendingWrite = draftWrite + isSubmittingAnswer = true + Task { @MainActor in + defer { isSubmittingAnswer = false } + await pendingWrite?.value + await onSubmit(normalizedAnswers, submitted) + } + } else if let unanswered = input.questions.firstIndex(where: { + normalizedAnswer(for: $0.id) == nil && !hasAttachments(for: $0.id) + }) { + questionIndex = unanswered + } + } + + private func hasAttachments(for questionID: String) -> Bool { + input.supportsAttachments == true + && input.questions.contains { $0.id == questionID && $0.canWriteCustomAnswer } + && !(attachmentsByQuestionID[questionID] ?? []).isEmpty + } + + private var responseStatus: String { + attachmentsByQuestionID.values.contains { !$0.isEmpty } + ? "Uploading attachments and sending answer..." + : "Sending answer..." + } + + private var attachmentBlocker: String? { + if attachmentsByQuestionID.contains(where: { !$0.value.isEmpty && !hasAttachments(for: $0.key) }) { + return "Attachments are no longer supported for this question. Remove them to send the answer." + } + return nil + } + + private func otherAttachmentCount(questionID: String) -> Int { + attachmentsByQuestionID.reduce(0) { count, entry in + count + (entry.key == questionID ? 0 : entry.value.count) + } + } + + private func attachmentBinding(questionID: String) -> Binding<[FeatureDraftAttachment]> { + let requestID = input.id + return Binding( + get: { attachmentsByQuestionID[questionID] ?? [] }, + set: { next in + guard requestID == input.id else { return } + attachmentsByQuestionID[questionID] = next + } + ) + } + + 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..7b0ce5c45926 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift @@ -0,0 +1,835 @@ +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)? + var maximumPastedTextBytes: Int? = nil + var onPasteTextAttachment: ((String, @escaping @MainActor () -> Bool) -> Void)? = nil + var onPasteTextError: ((String) -> Void)? = nil + var draftOwnerID: String = "" + var onCopyContext: ((String) throws -> Bool)? = nil + var onPasteContext: ((ComposerContextClipboard.Content, String, NSRange) -> Void)? = nil + + 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 + textView.maximumPastedTextBytes = maximumPastedTextBytes + textView.onPasteTextAttachment = onPasteTextAttachment + textView.draftOwnerID = draftOwnerID + textView.onPasteTextError = onPasteTextError + textView.onCopySelection = { selected in + try onCopyContext?(FeatureInlineSkillProjection.plainText(from: selected)) ?? false + } + textView.onCopyError = onPasteTextError + textView.onPasteContext = onPasteContext + 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.maximumPastedTextBytes = maximumPastedTextBytes + textView.onPasteTextAttachment = onPasteTextAttachment + textView.draftOwnerID = draftOwnerID + textView.onPasteTextError = onPasteTextError + textView.onCopySelection = { selected in + try onCopyContext?(FeatureInlineSkillProjection.plainText(from: selected)) ?? false + } + textView.onCopyError = onPasteTextError + textView.onPasteContext = onPasteContext + 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 textView( + _ textView: UITextView, + editMenuForTextIn range: NSRange, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + guard !parent.isReadOnly, UIPasteboard.general.hasStrings, + let composerTextView = textView as? FeatureComposerUITextView else { + return UIMenu(children: suggestedActions) + } + let pasteAsText = UIAction(title: "Paste as Text") { [weak composerTextView] _ in + composerTextView?.pasteAsText(nil) + } + return UIMenu(children: suggestedActions + [pasteAsText]) + } + + 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() + } + } +} + +/// Routes images and large text pastes to attachments. Ordinary text keeps +/// UIKit's native paste behavior. +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)? + var maximumPastedTextBytes: Int? + var onPasteTextAttachment: ((String, @escaping @MainActor () -> Bool) -> Void)? + var draftOwnerID = "" + private var pastedTextRequestID = UUID() + var onPasteTextError: ((String) -> Void)? + var onPasteContext: ((ComposerContextClipboard.Content, String, NSRange) -> Void)? + private var wantsFirstResponderOnAttach = false + + override var keyCommands: [UIKeyCommand]? { + let pasteAsTextCommand = UIKeyCommand( + input: "v", + modifierFlags: [.command, .shift], + action: #selector(pasteAsText(_:)) + ) + pasteAsTextCommand.discoverabilityTitle = "Paste as Text" + pasteAsTextCommand.wantsPriorityOverSystemBehavior = true + return (super.keyCommands ?? []) + [pasteAsTextCommand] + } + + /// 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 action == #selector(pasteAsText(_:)) { + return !isReadOnly && UIPasteboard.general.hasStrings + } + 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 } + do { + if let content = try FeatureContextClipboard.read() { + guard let onPasteContext, markedTextRange == nil else { + throw ComposerContextClipboardError.draftChanged + } + onPasteContext( + content, + FeatureInlineSkillProjection.plainText(from: attributedText), + FeatureInlineSkillProjection.plainRange(for: selectedRange, in: attributedText) + ) + return + } + } catch { + onPasteTextError?(error.localizedDescription) + return + } + if acceptsImages { + let imageProviders = UIPasteboard.general.itemProviders.filter { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + if !imageProviders.isEmpty { + onPasteImages?(imageProviders) + return + } + } + if let pastedText = UIPasteboard.general.string, foldPastedText(pastedText) { + return + } + super.paste(sender) + } + + var readClipboardText: () -> String? = { UIPasteboard.general.string } + + @objc func pasteAsText(_ sender: Any?) { + guard !isReadOnly, let pastedText = readClipboardText() else { return } + if !foldPastedText(pastedText, bypassAutoAttachment: true) { + insertText(pastedText) + } + } + + /// Returning false leaves the paste to UIKit. A rejected fold must not + /// replace selected draft text with an empty string. + func foldPastedText(_ pastedText: String, bypassAutoAttachment: Bool = false) -> Bool { + guard !isReadOnly else { return true } + let source = FeatureInlineSkillProjection.plainText(from: attributedText) + let selection = FeatureInlineSkillProjection.plainRange( + for: selectedRange, + in: attributedText + ) + switch FeaturePastedText.disposition( + text: pastedText, + currentTextLength: source.utf16.count, + selection: selection, + maximumAttachmentBytes: maximumPastedTextBytes, + bypassAutoAttachment: bypassAutoAttachment + ) { + case .inline: + return false + case .rejected: + onPasteTextError?( + "Pasted text is too large for this message. Remove some text or an attachment, then paste again." + ) + case .attachment: + guard markedTextRange == nil, let onPasteTextAttachment else { + onPasteTextError?("Could not attach pasted text. Your draft has not changed.") + return true + } + let requestID = UUID() + pastedTextRequestID = requestID + let ownerID = draftOwnerID + onPasteTextAttachment(pastedText) { [weak self] in + guard let self, self.pastedTextRequestID == requestID, + self.draftOwnerID == ownerID, !self.isReadOnly, self.markedTextRange == nil, + FeatureInlineSkillProjection.plainText(from: self.attributedText) == source, + FeatureInlineSkillProjection.plainRange(for: self.selectedRange, in: self.attributedText) == selection else { + return false + } + if self.selectedRange.length > 0 { self.insertText("") } + self.scrollSelectionIntoView() + self.pastedTextRequestID = UUID() + return true + } + } + return true + } +} + +/// 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..ba17e50d2652 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -0,0 +1,1450 @@ +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 + @SwiftUI.Environment(\.isEnabled) private var isEnabled + @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 pastedTextErrorMessage: String? + @State private var pastedTextTask: Task? + @State private var pastedTextGeneration = UUID() + @State private var contextImportTask: Task? + @State private var contextImportID: UUID? + @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] + @Binding private var context: OrchestrationMessageContext? + + private let providers: [FeatureProvider] + private let draftOwnerID: String + private let environmentID: String? + private let draftStorageKey: String? + private let environmentIsConnected: Bool + private let contextAttachmentResolver: (any FeatureContextAttachmentResolving)? + private let attachmentUploads: FeatureAttachmentUploadCoordinator + private let attachmentPreferences: FeatureEnvironmentPreferences + private let onRefreshModels: (() async throws -> Void)? + private let draftSaveError: String? + private let onRetryDraftSave: (() -> Void)? + private let onInputPreparationChange: ((Bool) -> 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 resolvingRequestIDs: Set + 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], [String: [FeatureUploadAttachment]]) async -> Void)? + private let onUserInputDismiss: ((String) async -> 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] = [], + resolvingRequestIDs: Set = [], + powerFeatures: FeatureComposerPowerFeatures = .disabled, + showsKeyboardDismissControl: Bool = false, + onDismissKeyboard: (() -> Void)? = nil, + onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? = nil, + onUserInputSubmit: ((String, [String: FeatureInputAnswer], [String: [FeatureUploadAttachment]]) async -> Void)? = nil, + onUserInputDismiss: ((String) async -> Void)? = nil, + onRefreshModels: (() async throws -> Void)? = nil, + draftSaveError: String? = nil, + onRetryDraftSave: (() -> Void)? = nil, + context: Binding = .constant(nil), + onInputPreparationChange: ((Bool) -> Void)? = nil, + contextAttachmentResolver: (any FeatureContextAttachmentResolving)? = nil + ) { + _text = text + _selection = selection + _attachments = attachments + _context = context + self.contextAttachmentResolver = contextAttachmentResolver + self.draftOwnerID = draftOwnerID + self.environmentID = environmentID + self.draftStorageKey = draftStorageKey + self.environmentIsConnected = environmentIsConnected + self.attachmentUploads = attachmentUploads + self.attachmentPreferences = attachmentPreferences + self.onInputPreparationChange = onInputPreparationChange + 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.resolvingRequestIDs = resolvingRequestIDs + self.powerFeatures = powerFeatures + self.showsKeyboardDismissControl = showsKeyboardDismissControl + self.onDismissKeyboard = onDismissKeyboard + self.onApprovalDecision = onApprovalDecision + self.onUserInputSubmit = onUserInputSubmit + self.onUserInputDismiss = onUserInputDismiss + } + + 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() + pastedTextTask?.cancel() + pastedTextGeneration = UUID() + contextImportTask?.cancel() + } + .onChange(of: text) { previous, _ in + textRevision &+= 1 + synchronizeVoiceDraft(ownerChanged: false) + removeUnlinkedContextAttachments(previousText: previous) + } + .onChange(of: attachments) { previous, _ in + removeDeletedAttachmentReferences(previousAttachments: previous) + } + .onChange(of: draftOwnerID) { + synchronizeVoiceDraft(ownerChanged: true) + pastedTextTask?.cancel() + pastedTextGeneration = UUID() + contextImportTask?.cancel() + } + .onChange(of: environmentID) { + contextImportTask?.cancel() + pastedTextTask?.cancel() + pastedTextGeneration = UUID() + } + .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 ?? "") + } + .alert( + "Clipboard error", + isPresented: Binding( + get: { pastedTextErrorMessage != nil }, + set: { if !$0 { pastedTextErrorMessage = nil } } + ) + ) { + Button("OK") { pastedTextErrorMessage = nil } + } message: { + Text(pastedTextErrorMessage ?? "") + } + } + + private var composerSurface: some View { + composerSurfaceContent + .frame(maxWidth: T3Metrics.readingWidth) + .frame(maxWidth: .infinity) + } + + private var composerSurfaceContent: some View { + VStack(spacing: 0) { + if let approval = pendingApprovals.first, let onApprovalDecision { + FeatureComposerApprovalPanel( + approval: approval, + position: 1, + total: pendingApprovals.count, + isResponding: resolvingRequestIDs.contains(approval.id), + onDecision: { decision in + onApprovalDecision(approval.id, decision) + }, + onCancelTurn: onStop + ) + } else if let input = pendingUserInputs.first, let onUserInputSubmit { + FeatureComposerUserInputPanel( + input: input, + isResponding: resolvingRequestIDs.contains(input.id), + onSubmit: { answers, attachments in + await onUserInputSubmit(input.id, answers, attachments) + }, + onDismiss: onUserInputDismiss.map { dismiss in { await dismiss(input.id) } }, + environmentID: environmentID, + attachmentPreferences: attachmentPreferences + ) + } else if isExpanded { + expandedComposer + } else { + collapsedComposer + } + } + .background(T3Colors.input.opacity(0.98), in: composerShape) + .overlay { + composerShape + .stroke(T3Colors.inputBorder, lineWidth: 1) + } + .clipShape(composerShape) + .onChange(of: attachmentPreparation.isPreparing || isAttachmentFlowActive || voiceInputController.isBusy || contextImportID != nil, initial: true) { _, busy in + onInputPreparationChange?(busy) + } + .modifier( + FeatureComposerImageDrop( + isEnabled: imagesAllowed && !voiceInputController.isBusy && contextImportID == nil, + 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 || !isEnabled || contextImportID != nil, + skills: powerFeatures.enabledSkills, + selectionRequest: textSelectionRequest, + onSelectionChange: handleTextSelectionChange, + onPasteImages: attachImageProviders, + onDismissKeyboard: onDismissKeyboard, + maximumPastedTextBytes: maximumPastedTextBytes, + onPasteTextAttachment: attachPastedText, + onPasteTextError: { pastedTextErrorMessage = $0 }, + draftOwnerID: draftOwnerID, + onCopyContext: copyContext, + onPasteContext: pasteContext + ) + .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 contextImportID != nil { + HStack { + Text("Importing context") + Spacer() + Button("Cancel paste") { contextImportTask?.cancel() } + } + .font(T3Typography.supporting) + .padding(.horizontal, 15) + .padding(.bottom, 4) + } + + 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 + .disabled(contextImportID != nil) + .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 + } + + 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 contextReference(label: String, payload: ComposerContextRecord.Payload) throws -> String { + let record = ComposerContextRecord(label: label, payload: payload) + context = try FeatureComposerContext.merge(ComposerContextReferences.referenced(context, text: text), .init(records: [record])) + return ComposerContextReferences.format(record) + " " + } + + private func selectCommandItem(_ item: FeatureComposerMenuItem) { + guard let trigger = composerTrigger else { return } + let replacement: String + do { + switch item { + case .modelCommand: + replacement = "/model " + case let .model(nextSelection, _, _): + selection = nextSelection + replacement = "" + case let .providerCommand(command): + replacement = "/\(command.name) " + case let .skill(skill): + if skill.userInvocationOnly == true { + replacement = skill.invocation + } else { + replacement = try contextReference(label: skill.invocationDisplayName, payload: .skill(.init(name: skill.name))) + } + case let .path(entry): + replacement = try contextReference(label: entry.name, payload: .mention(.init(path: entry.path))) + } + } catch { + pathSearchError = error.localizedDescription + return + } + 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, contextImportID == nil 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 + } + } + } + } + + private var maximumPastedTextBytes: Int? { + FeaturePastedText.maximumAttachmentBytes( + advertisedMaximum: attachmentPreferences.maxFileAttachmentBytes, + attachmentCount: attachments.count, + pendingCount: attachmentPreparation.pendingItemCount + ) + } + + private func copyContext(_ selectedText: String) throws -> Bool { + try FeatureContextClipboard.write( + text: selectedText, + source: environmentID.map { .init(environmentId: $0) }, + context: context, attachments: attachments + ) + } + + private func pasteContext(_ content: ComposerContextClipboard.Content, _ originalText: String, _ range: NSRange) { + guard contextImportID == nil, !voiceInputController.isBusy else { return } + pastedTextTask?.cancel() + pastedTextGeneration = UUID() + let originalContext = context + let originalAttachments = attachments + let owner = draftOwnerID + let environment = environmentID + let importID = UUID() + contextImportID = importID + let importer = FeatureContextClipboardImporter(resolver: contextAttachmentResolver) + contextImportTask = Task { @MainActor in + defer { + if contextImportID == importID { + contextImportID = nil + contextImportTask = nil + } + } + do { + guard range.location != NSNotFound, NSMaxRange(range) <= originalText.utf16.count else { + throw ComposerContextClipboardError.draftChanged + } + let remainingText = (originalText as NSString).replacingCharacters(in: range, with: "") + let remainingContext = ComposerContextReferences.referenced(originalContext, text: remainingText) + let removedIDs = FeatureContextClipboardEdit.unlinkedAttachmentIDs( + context: originalContext, previousText: originalText, text: remainingText + ) + let remainingAttachmentCount = originalAttachments.filter { + !removedIDs.contains($0.id.uuidString.lowercased()) + }.count + let result = try await importer.importContent( + content, attachmentCount: remainingAttachmentCount + attachmentPreparation.pendingItemCount, + contextCount: remainingContext?.records.count ?? 0, + imagesAllowed: imagesAllowed, + maximumFileBytes: attachmentPreferences.maxFileAttachmentBytes + ) + var committed = false + defer { if !committed { result.discardFiles(using: importer.fileStore) } } + try Task.checkCancellation() + guard owner == draftOwnerID, environment == environmentID, text == originalText, + context == originalContext, attachments == originalAttachments, + textObservation.selection == range else { throw ComposerContextClipboardError.draftChanged } + for attachment in result.attachments { + if attachment.mimeType.hasPrefix("image/") { + guard imagesAllowed else { throw FeatureAttachmentIntakeError.imagesUnsupported } + } else { + guard let maximum = attachmentPreferences.maxFileAttachmentBytes else { throw FileAttachmentError.unsupported } + guard attachment.byteCount <= maximum else { + throw FileAttachmentError.tooLarge(actualBytes: attachment.byteCount, maximumBytes: maximum) + } + } + } + let edit = try FeatureContextClipboardEdit.apply( + text: originalText, selection: range, context: originalContext, + attachments: originalAttachments, imported: result + ) + context = edit.context + text = edit.text + textSelectionRequest = FeatureComposerTextSelectionRequest(location: edit.cursor) + attachments = edit.attachments + committed = true + } catch { + if owner == draftOwnerID, environment == environmentID { + pastedTextErrorMessage = error is CancellationError + ? "Paste cancelled. Your draft has not changed." + : error.localizedDescription + } + } + } + } + + private func removeUnlinkedContextAttachments(previousText: String) { + let removedIDs = FeatureContextClipboardEdit.unlinkedAttachmentIDs( + context: context, previousText: previousText, text: text + ) + guard !removedIDs.isEmpty else { return } + attachments.removeAll { removedIDs.contains($0.id.uuidString.lowercased()) } + } + + private func removeDeletedAttachmentReferences(previousAttachments: [FeatureDraftAttachment]) { + let removedIDs = Set(previousAttachments.map { $0.id.uuidString.lowercased() }) + .subtracting(attachments.map { $0.id.uuidString.lowercased() }) + guard !removedIDs.isEmpty else { return } + var removedContextIDs = Set((context?.records ?? []).filter { + $0.attachment.map { removedIDs.contains($0.attachmentId.lowercased()) } ?? false + }.map(\.contextId)) + for record in context?.records ?? [] { + if case let .previewAnnotation(annotation) = record.payload, + let screenshot = annotation.screenshotContextId, removedContextIDs.contains(screenshot) { + removedContextIDs.insert(record.contextId) + } + } + guard !removedContextIDs.isEmpty else { return } + text = ComposerContextReferences.replace(text) { reference in + removedContextIDs.contains(reference.contextId) ? "" : (text as NSString).substring(with: reference.range) + } + } + + private func attachPastedText(_ pastedText: String, commitSelection: @escaping @MainActor () -> Bool) { + guard !voiceInputController.isBusy, contextImportID == nil, !pastedText.isEmpty, + let maximumPastedTextBytes, + pastedText.utf8.count <= maximumPastedTextBytes else { + pastedTextErrorMessage = "Could not attach pasted text. Your draft has not changed." + return + } + pastedTextTask?.cancel() + let generation = UUID() + pastedTextGeneration = generation + let fileName = FeaturePastedText.nextFileName(existingNames: attachments.map(\.filename)) + let fileStore = ManagedAttachmentFileStore() + let operation = attachmentPreparation.begin(itemCount: 1) + pastedTextTask = Task { @MainActor in + defer { + attachmentPreparation.finish(operation) + if pastedTextGeneration == generation { pastedTextTask = nil } + } + do { + try Task.checkCancellation() + let attachment = try await Task.detached(priority: .userInitiated) { + try FeaturePastedText.attachment(text: pastedText, fileName: fileName, maximumBytes: maximumPastedTextBytes, fileStore: fileStore) + }.value + var adopted = false + defer { + if !adopted, let file = attachment.ownedFile { + try? fileStore.removeOwnedFile(fileName: file.fileName) + } + } + guard !Task.isCancelled, pastedTextGeneration == generation else { return } + guard attachments.count + attachmentPreparation.pendingItemCount <= FeatureImageAttachmentLimits.maximumCount, + commitSelection() else { + pastedTextErrorMessage = "The draft changed while the file was prepared. Paste again to add it." + return + } + attachments.append(attachment) + adopted = true + } catch { + guard !Task.isCancelled, pastedTextGeneration == generation else { return } + pastedTextErrorMessage = 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, contextImportID == nil 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/FeatureContextClipboardEdit.swift b/apps/swift-ios/Features/Chat/FeatureContextClipboardEdit.swift new file mode 100644 index 000000000000..3e29df5c772d --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureContextClipboardEdit.swift @@ -0,0 +1,51 @@ +import Foundation + +@MainActor +enum FeatureContextClipboardEdit { + struct Result { + let text: String + let context: OrchestrationMessageContext? + let attachments: [FeatureDraftAttachment] + let cursor: Int + } + + static func unlinkedAttachmentIDs( + context: OrchestrationMessageContext?, previousText: String, text: String + ) -> Set { + let previous = ComposerContextReferences.referenced(context, text: previousText)?.records ?? [] + let remaining = ComposerContextReferences.referenced(context, text: text)?.records ?? [] + let liveIDs = Set(remaining.compactMap { $0.attachment?.attachmentId.lowercased() }) + return Set(previous.compactMap { $0.attachment?.attachmentId.lowercased() }).subtracting(liveIDs) + } + + static func apply( + text: String, selection: NSRange, context: OrchestrationMessageContext?, + attachments: [FeatureDraftAttachment], imported: FeatureContextClipboardImporter.Result + ) throws -> Result { + guard selection.location != NSNotFound, selection.location >= 0, + selection.length >= 0, selection.location <= text.utf16.count, + selection.length <= text.utf16.count - selection.location else { + throw ComposerContextClipboardError.draftChanged + } + let remainingText = (text as NSString).replacingCharacters(in: selection, with: "") + let updatedText = (text as NSString).replacingCharacters(in: selection, with: imported.text) + guard updatedText.utf16.count <= FeaturePastedText.maximumInputCharacters else { + throw ComposerContextClipboardError.tooLarge + } + let remainingContext = ComposerContextReferences.referenced(context, text: remainingText) + let merged = try FeatureComposerContext.merge(remainingContext, imported.context) + if let merged { + let encoded = try JSONEncoder.t3.encode(merged) + guard String(decoding: encoded, as: UTF8.self).utf16.count <= ComposerContextClipboard.maximumCharacters else { + throw ComposerContextClipboardError.tooLarge + } + } + let removedIDs = unlinkedAttachmentIDs(context: context, previousText: text, text: remainingText) + let updatedAttachments = attachments.filter { !removedIDs.contains($0.id.uuidString.lowercased()) } + imported.attachments + guard updatedAttachments.count <= FeatureImageAttachmentLimits.maximumCount else { + throw FileAttachmentError.tooMany(maximum: FeatureImageAttachmentLimits.maximumCount) + } + return Result(text: updatedText, context: merged, attachments: updatedAttachments, + cursor: selection.location + imported.text.utf16.count) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift new file mode 100644 index 000000000000..66a3e1d6c25a --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift @@ -0,0 +1,440 @@ +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 else { return [] } + let references = ComposerContextReferences.collect(text) + let contextDescriptors = references.map { reference in + FeatureInlineSkillDescriptor( + rawText: (text as NSString).substring(with: reference.range), + displayName: reference.label, + range: reference.range + ) + } + + let skillsByName = Dictionary(skills.map { ($0.name, $0) }) { first, _ in first } + let source = text as NSString + let skillDescriptors = tokenExpression.matches( + in: text, + range: NSRange(location: 0, length: source.length) + ).compactMap { match -> FeatureInlineSkillDescriptor? in + let range = match.range(at: 0) + guard !references.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) else { return nil } + 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 + ) + } + return (skillDescriptors + contextDescriptors).sorted { $0.range.location < $1.range.location } + } +} + +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 { + var onCopySelection: ((NSAttributedString) throws -> Bool)? + var onCopyError: ((String) -> Void)? + + override func copy(_ sender: Any?) { + if copyContextSelection() != nil { return } + guard let selectedPlainText else { + super.copy(sender) + return + } + UIPasteboard.general.string = selectedPlainText + } + + override func cut(_ sender: Any?) { + if let copied = copyContextSelection() { + if copied { insertText("") } + return + } + guard let selectedPlainText else { + super.cut(sender) + return + } + super.cut(sender) + UIPasteboard.general.string = selectedPlainText + } + + /// A failed rich copy claims the action but must not delete the selected text during Cut. + private func copyContextSelection() -> Bool? { + guard let onCopySelection, selectedRange.location != NSNotFound, + selectedRange.length > 0, NSMaxRange(selectedRange) <= attributedText.length else { return nil } + do { + return try onCopySelection(attributedText.attributedSubstring(from: selectedRange)) ? true : nil + } catch { + onCopyError?(error.localizedDescription) + return false + } + } + + 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/FeaturePastedText.swift b/apps/swift-ios/Features/Chat/FeaturePastedText.swift new file mode 100644 index 000000000000..28df9162ddb1 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeaturePastedText.swift @@ -0,0 +1,71 @@ +import Foundation + +enum FeaturePastedText { + // Keep these limits in sync with client-runtime/textPaste.ts and contracts/orchestration.ts. + static let attachmentThresholdBytes = 32 * 1024 + static let maximumInputCharacters = 120_000 + + enum Disposition: Equatable { + case inline + case attachment + case rejected + } + + /// Call off the main actor. Drafts and outbox entries retain the owned file, + /// so a large clipboard value is never copied into their JSON documents. + static func attachment( + text: String, fileName: String, maximumBytes: Int, + fileStore: ManagedAttachmentFileStore = .init() + ) throws -> FeatureDraftAttachment { + let id = UUID() + let file = try fileStore.writeOwnedFile( + data: Data(text.utf8), attachmentID: id, originalFileName: fileName, maximumBytes: maximumBytes + ) + return FeatureDraftAttachment(id: id, ownedFile: file, filename: fileName, mimeType: "text/plain", source: .pastedText) + } + + static func maximumAttachmentBytes( + advertisedMaximum: Int?, + attachmentCount: Int, + pendingCount: Int + ) -> Int? { + guard let advertisedMaximum, advertisedMaximum > 0, + attachmentCount + pendingCount < FeatureImageAttachmentLimits.maximumCount else { + return nil + } + return min(advertisedMaximum, ManagedAttachmentFileStore.maximumBytes) + } + + /// Input limits use UTF-16 offsets, as on web. Attachment limits use UTF-8 bytes. + static func disposition( + text: String, + currentTextLength: Int, + selection: NSRange, + maximumAttachmentBytes: Int?, + bypassAutoAttachment: Bool = false + ) -> Disposition { + guard !bypassAutoAttachment, !text.isEmpty else { return .inline } + let start = min(max(0, selection.location), currentTextLength) + let selectedLength = min(max(0, selection.length), currentTextLength - start) + let wouldExceedInputLimit = currentTextLength - selectedLength + text.utf16.count + > maximumInputCharacters + let byteCount = text.utf8.count + guard wouldExceedInputLimit || byteCount >= attachmentThresholdBytes else { + return .inline + } + if let maximumAttachmentBytes, byteCount <= maximumAttachmentBytes { + return .attachment + } + return wouldExceedInputLimit ? .rejected : .inline + } + + static func nextFileName(existingNames: [String]) -> String { + let names = Set(existingNames.map { $0.lowercased() }) + guard names.contains("pasted-text.txt") else { return "pasted-text.txt" } + var sequence = 2 + while names.contains("pasted-text-\(sequence).txt") { + sequence += 1 + } + return "pasted-text-\(sequence).txt" + } +} 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..e3dacf34f78c --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift @@ -0,0 +1,420 @@ +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: + // The asset download can take minutes on cellular. Cancel it so + // the operation gate releases and the mic button works again. + operationTask?.cancel() + 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..97b896a69d0e --- /dev/null +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -0,0 +1,808 @@ +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 thumbnail(fileURL: URL) -> Data? { + guard let source = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 160, + ] as CFDictionary) else { return nil } + return UIImage(cgImage: image).jpegData(compressionQuality: 0.72) + } + + 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..eb22dd58f003 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -0,0 +1,1236 @@ +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] + private let clipboardSource: ComposerContextClipboardFragment.Source? + private let messageContext: OrchestrationMessageContext? + private let copyText: String? + @State private var copyError: String? + @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] = [], + clipboardSource: ComposerContextClipboardFragment.Source? = nil, + messageContext: OrchestrationMessageContext? = nil, + copyText: String? = nil + ) { + self.source = source + self.isStreaming = isStreaming + self.copyActionTitle = copyActionTitle + self.imageContext = imageContext + self.skills = skills + self.clipboardSource = clipboardSource + self.messageContext = messageContext + self.copyText = copyText + _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) { + selectionSource.copyMessage() + } + .alert("Could not copy context", isPresented: Binding( + get: { copyError != nil }, set: { if !$0 { copyError = nil } } + )) { + Button("OK") { copyError = nil } + } message: { Text(copyError ?? "") } + .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 + selectionSource.originalText = copyText ?? source + selectionSource.clipboardSource = clipboardSource + selectionSource.messageContext = messageContext + selectionSource.onCopyError = { copyError = $0 } + 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 + var originalText: String + var clipboardSource: ComposerContextClipboardFragment.Source? + var messageContext: OrchestrationMessageContext? + var onCopyError: ((String) -> Void)? + + init(_ text: String) { + self.text = text + originalText = text + } + + @MainActor func copyMessage() { + do { + if try !FeatureContextClipboard.write(text: originalText, source: clipboardSource, context: messageContext) { + UIPasteboard.general.string = originalText + } + } catch { onCopyError?(error.localizedDescription) } + } +} + +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 + if let textView = textView as? FeatureInlineSkillTextView { + let source = selectionContext.source + textView.onCopySelection = { selected in + try FeatureContextClipboard.write( + text: FeatureContextClipboard.selectionText(selected, originalSource: source.originalText), + source: source.clipboardSource, context: source.messageContext + ) + } + textView.onCopyError = source.onCopyError + } + 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() { + selectionContext.source.copyMessage() + } + } +} + +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..6483b15c0ef4 --- /dev/null +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -0,0 +1,3395 @@ +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 composerContext: OrchestrationMessageContext? + @State private var selection: FeatureSelection? + @State private var attachments: [FeatureDraftAttachment] = [] + @State private var isSending = false + @State private var pendingRewindMessageID: String? + @State private var isPreparingRewind = false + @State private var isPreparingInput = 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 draftRestoreBaseline: FeatureComposerDraft? + @State private var missingFileRecoverySnapshot: FeatureComposerDraft? + @State private var draftSaveTask: Task? + @State private var draftSaveError: String? + @State private var toolSurface: FeatureThreadToolSurface? + @State private var branchPullRequest: FeaturePullRequest? + @State private var showsLinkPullRequest = false + @State private var pullRequestURL = "" + @State private var pullRequestError: String? + @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 + } + + private var threadContent: 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) + .alert("Link pull request", isPresented: $showsLinkPullRequest) { + TextField("Pull request URL", text: $pullRequestURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Link") { changePullRequest(url: pullRequestURL, linked: true) } + Button("Cancel", role: .cancel) {} + } + .alert("Could not update pull request", isPresented: Binding( + get: { pullRequestError != nil }, set: { if !$0 { pullRequestError = nil } } + )) { + Button("OK") { pullRequestError = nil } + } message: { Text(pullRequestError ?? "") } + .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. + await model.checkRewindRecovery(for: currentThread) + 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: model.recoveredRewindDrafts[thread.id]) { _, recovered in + if recovered != nil { restoreRewindDraft() } + } + .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, + sendMessage: submitMessage + ) + case .sourceControl: + FeatureSourceControlView(client: model.client, threadID: thread.id) + case .terminal: + FeatureTerminalView(client: model.client, threadID: thread.id) { record in + composerContext = try FeatureComposerContext.merge( + ComposerContextReferences.referenced(composerContext, text: draft), .init(records: [record]) + ) + draft = ComposerContextReferences.ensureReferences(draft, records: [record]) + persistDraftImmediately() + composerFocused = true + } + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + toolSurface = nil + } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .t3CodeSizing(steps: codeSizeSteps) + } + } + + public var body: some View { + threadContent + .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.") + } + .confirmationDialog("Edit from here?", isPresented: Binding( + get: { pendingRewindMessageID != nil }, + set: { if !$0 { pendingRewindMessageID = nil } } + ), titleVisibility: .visible) { + Button("Revert and keep changes") { + guard let messageID = pendingRewindMessageID else { return } + pendingRewindMessageID = nil + rewindConversation(before: messageID) + } + Button("Cancel", role: .cancel) { pendingRewindMessageID = nil } + } message: { + Text("Rewind chat to before this message. Your prompt and attachments return to the composer. File changes stay as they are.") + } + .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 { + // iOS 26 owns the interactive content back-swipe. A second pan + // recognizer can block it or clear the selection during a pop. + if #unavailable(iOS 26.0) { + ThreadBackSwipeGestureView( + isEnabled: horizontalSizeClass == .compact, + onNavigateBack: onNavigateBack + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .environment(\.openURL, transcriptOpenURL) + .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 isRewinding: Bool { + isPreparingRewind || model.rewindingThreadIDs.contains(thread.id) + } + + private func canRewind(_ messageID: String) -> Bool { + !isSending && !isRewinding && !isPreparingInput && didRestoreDraft + && model.canRewindConversation(threadID: thread.id, messageID: messageID) + } + + private func rewindConversation(before messageID: String) { + guard canRewind(messageID) else { return } + isPreparingRewind = true + dismissKeyboard() + let pendingSave = draftSaveTask + pendingSave?.cancel() + draftSaveTask = nil + let saved = composerDraft + Task { + await pendingSave?.value + await model.rewindConversation(threadID: thread.id, messageID: messageID, draft: saved) + isPreparingRewind = false + } + } + + private func restoreRewindDraft() { + guard let recovered = model.consumeRewindDraft(threadID: thread.id) else { return } + draft = recovered.text + attachments = recovered.attachments + selection = recovered.selection + composerContext = recovered.context + didRestoreDraft = true + } + + private func recoverSavedRewind() { + guard !isRewinding && !isSending && !isPreparingInput else { return } + isPreparingRewind = true + let pendingSave = draftSaveTask + pendingSave?.cancel() + draftSaveTask = nil + let saved = composerDraft + Task { + await pendingSave?.value + await model.recoverSavedRewind(threadID: thread.id, draft: saved) + isPreparingRewind = false + } + } + + 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") + } + } + let links = ThreadPullRequests.visible(currentThread.pullRequests ?? []) + if links.count > 1 { + Menu("Linked pull requests") { + ForEach(links) { link in + if let url = URL(string: link.url) { + Button("\(link.repository)#\(link.number)") { parentOpenURL(url) } + } + } + } + } + if currentThread.supportsMultiplePullRequests == true || currentThread.supportsPullRequestLinking == true { + Button("Link pull request…") { showsLinkPullRequest = true } + if !links.isEmpty { + Menu("Unlink pull request") { + ForEach(links) { link in + Button("\(link.repository)#\(link.number)") { changePullRequest(url: link.url, linked: false) } + } + } + } else if let linked = currentThread.linkedPullRequest { + Button("Unlink pull request #\(linked.number)") { changePullRequest(url: linked.url, linked: false) } + } + } + if currentThread.supportsTitleRegeneration == true { + Button { + Task { await model.regenerateThreadTitle(thread.id) } + } label: { + Label( + currentThread.isRegeneratingTitle ? "Regenerating title…" : "Regenerate title", + systemImage: "sparkles" + ) + } + .disabled(currentThread.isRegeneratingTitle) + } + 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(isSending) + 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") + } + if let message = detail?.messages.last(where: { $0.role == .user }), + canRewind(message.id) { + Button { pendingRewindMessageID = message.id } label: { + Label("Edit last prompt", systemImage: "arrow.uturn.backward") + } + } + } + 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 func changePullRequest(url: String, linked: Bool) { + Task { + do { try await model.client.setThreadPullRequest(id: thread.id, url: url.trimmingCharacters(in: .whitespacesAndNewlines), linked: linked) } + catch { pullRequestError = error.localizedDescription } + } + } + + private var pullRequestObservationID: String? { + currentThread.pullRequestObservationIdentity + } + + @MainActor + private func observeThreadPullRequest() async { + branchPullRequest = nil + guard let observationIdentity = pullRequestObservationID else { + branchPullRequest = nil + return + } + + if let links = currentThread.pullRequests, !links.isEmpty { + model.updatePullRequest( + HomeThreadPullRequestPresentation.resolve(links: links), + threadID: currentThread.id, observationIdentity: observationIdentity + ) + 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, + host: ThreadPullRequests.authority(of: linked.url) + ) + ) + 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), + openURL: transcriptOpenURL, + imageContext: markdownImageContext, + attachmentContext: (model.client as? any FeatureAttachmentAssetResolving).map { + FeatureAttachmentContext( + threadID: thread.id, resolver: $0, + environmentID: currentThread.environmentID, + wireThreadID: currentThread.wireID ?? currentThread.id + ) + }, + 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, + canEditMessage: canRewind, + onEditMessage: { pendingRewindMessageID = $0 } + ) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + refreshStatus + if isRewinding { + Text("Rewinding conversation") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textPrimary) + .padding(.vertical, 8) + .accessibilityIdentifier("conversation-rewind-status") + } + if let error = model.rewindErrors[thread.id] { + Text(error) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .padding(.horizontal, 18) + .padding(.vertical, 8) + } + if model.pendingRewindRecoveryIDs.contains(thread.id), !isRewinding { + VStack(spacing: 4) { + Text("A prompt is saved from an unconfirmed rewind. Reload the thread to check its history.") + .font(T3Typography.supporting) + Button("Recover saved prompt", action: recoverSavedRewind) + .disabled(isSending || isPreparingInput || !didRestoreDraft) + } + .padding(.horizontal, 18) + .padding(.vertical, 8) + } + 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 || isRewinding || !didRestoreDraft, + 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, + resolvingRequestIDs: model.resolvingRequestIDs, + powerFeatures: composerPowerFeatures, + showsKeyboardDismissControl: true, + onDismissKeyboard: dismissKeyboard, + onApprovalDecision: { id, decision in + Task { await model.resolveApproval(id, decision: decision) } + }, + onUserInputSubmit: { id, answers, attachments in + await model.resolveUserInput(id, answers: answers, attachmentsByQuestionID: attachments) + }, + onUserInputDismiss: { id in + await model.dismissUserInput(id) + }, + onRefreshModels: refreshThreadEnvironmentModels, + draftSaveError: draftSaveError, + onRetryDraftSave: missingFileRecoverySnapshot == nil ? { + if didRestoreDraft { + persistDraftImmediately() + } else { + Task { await restoreDraft(from: draftRestoreBaseline ?? composerDraft, key: draftKey) } + } + } : nil, + context: contextBinding, + onInputPreparationChange: { isPreparingInput = $0 }, + contextAttachmentResolver: model.client as? any FeatureContextAttachmentResolving + ) + .disabled(isRewinding) + } + .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() { + guard !isRewinding, didRestoreDraft else { return } + let message = draft + let pendingContext = composerContext + 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 = [] + composerContext = nil + composerFocused = false + Task { + await pendingDraftSave?.value + let sent = await submitMessage( + FeatureMessageSubmission( + threadID: thread.id, + text: message, + selection: selection, + attachments: pendingAttachments, + context: pendingContext + ) + ) + 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) + } + // Release the sent attachments' bytes and upload jobs. + if let environmentID = currentThread.environmentID { + model.attachmentUploads.syncOwner( + draftKey: draftKey, + environmentID: environmentID, + attachments: followUpDraft.attachments + ) + } + } else { + let currentDraft = draft + let restoredMessage: String + do { + composerContext = try FeatureComposerContext.merge(pendingContext, composerContext) + restoredMessage = message + } catch { + // The failed turn and the new draft can each contain 200 items. + // Retain the failed turn as readable text if their records cannot fit together. + restoredMessage = ComposerContextReferences.providerProjection(message, context: pendingContext) + } + if currentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft = restoredMessage + } else if !restoredMessage.isEmpty { + draft = "\(restoredMessage)\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 contextBinding: Binding { + Binding(get: { composerContext }, set: { value in + composerContext = value + scheduleDraftSave() + }) + } + + 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 { + if model.recoveredRewindDrafts[thread.id] != nil { + restoreRewindDraft() + return + } + draftRestoreBaseline = baseline + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled else { return } + if model.recoveredRewindDrafts[thread.id] != nil { + restoreRewindDraft() + return + } + + let liveDraft = composerDraft + var restored: FeatureComposerDraft + var recoveredMissingFiles = false + do { + restored = try FeatureComposerDraftRestoration.merge(saved: saved, baseline: baseline, current: liveDraft, + onMissingAttachments: { recoveredMissingFiles = true }) + } catch { + draftSaveError = error.localizedDescription + return + } + restored.selection = ThreadComposerModelSelectionPolicy.explicitSelection( + restored.selection, + inherited: currentSelection, + providers: threadProviders + ) + draft = restored.text + composerContext = restored.context + attachments = restored.attachments + selection = restored.selection + didRestoreDraft = true + missingFileRecoverySnapshot = recoveredMissingFiles ? composerDraft : nil + draftSaveError = recoveredMissingFiles ? FeatureComposerDraftRestoration.missingFilesWarning : nil + + // 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, !isRewinding else { return } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil + 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, !isRewinding else { return } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil + 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 else { return } + persistDraftImmediately() + } + + /// Routes transcript links in-app: workspace files open the Files sheet, + /// media opens the native preview, artifact templates fill the composer. + /// Installed on the SwiftUI tree and injected into every hosted cell, + /// because `UIHostingConfiguration` does not inherit the parent + /// environment across the representable boundary. + private var transcriptOpenURL: OpenURLAction { + 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 + } + } + + private var composerDraft: FeatureComposerDraft { + FeatureComposerDraft( + text: draft, + attachments: attachments, + selection: selection, + context: composerContext + ) + } + +} + +enum ThreadRefreshPresentation: Equatable { + case loading + case catchingUp + case reconnecting + case offline + case failed + case needsPairing + + 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" + case .needsPairing: "Pair with this computer again in Settings" + } + } + + var systemImage: String { + switch self { + case .loading, .catchingUp: "hourglass" + case .reconnecting: "wifi" + case .offline, .failed: "wifi.exclamationmark" + case .needsPairing: "key.slash" + } + } + + var canRetry: Bool { self == .offline || self == .failed } + + static func resolve( + loadState: FeatureThreadLoadState?, + connectionState: FeatureConnection.State?, + isOpening: Bool, + syncState: FeatureThreadSyncState? = nil + ) -> Self? { + if connectionState == .needsPairing { return .needsPairing } + 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 .needsPairing: return .needsPairing + 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) + } +} + +/// Keep live edits, but restore file context only with the attachments it needs. +enum FeatureComposerDraftRestoration { + static let missingFilesWarning = "Some saved files are missing. Their metadata is shown in the draft. The saved draft stays unchanged until you edit or send." + + static func keepsSavedRecovery(_ snapshot: FeatureComposerDraft?, current: FeatureComposerDraft) -> Bool { + guard let snapshot else { return false } + return snapshot.text == current.text && snapshot.context == current.context + && snapshot.attachments.count == current.attachments.count + && zip(snapshot.attachments, current.attachments).allSatisfy { saved, live in + var saved = saved + var live = live + saved.uploadedReference = nil + live.uploadedReference = nil + return saved == live + } + } + + enum RestorationError: LocalizedError { + case attachmentLimit + + var errorDescription: String? { + switch self { + case .attachmentLimit: + "Remove an attachment, then retry restoring the draft. The saved draft has not changed." + } + } + } + + static func merge( + saved: FeatureComposerDraft?, + baseline: FeatureComposerDraft, + current: FeatureComposerDraft, + fallbackSelection: FeatureSelection? = nil, + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil, + onMissingAttachments: () -> Void = {} + ) throws -> FeatureComposerDraft { + var restored = 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 + ), + context: current.context == baseline.context ? saved?.context : current.context + ) + restored.context = ComposerContextReferences.referenced(restored.context, text: restored.text) + var missing: [ComposerContextRecord] = [] + if let context = restored.context { + func matches(_ attachment: FeatureDraftAttachment, id: String) -> Bool { + attachment.id.uuidString.caseInsensitiveCompare(id) == .orderedSame + || attachment.uploadedReference?.attachmentID == id + } + for record in context.records { + guard let binding = record.attachment, + !restored.attachments.contains(where: { matches($0, id: binding.attachmentId) }) else { continue } + guard let attachment = saved?.attachments.first(where: { matches($0, id: binding.attachmentId) }) else { + missing.append(record) + continue + } + guard restored.attachments.count < FeatureImageAttachmentLimits.maximumCount else { + throw RestorationError.attachmentLimit + } + restored.attachments.append(attachment) + } + } + let missingIDs = Set(missing.map(\.contextId)) + let directIDs = Set(ComposerContextReferences.collect(restored.text).map(\.contextId)) + func missingText(_ record: ComposerContextRecord) -> String { + "[Missing attachment: \(record.label)]\n" + ComposerContextReferences.providerPayload(record) + } + let originalText = restored.text + restored.text = ComposerContextReferences.replace(originalText) { reference in + missing.first(where: { $0.contextId == reference.contextId }).map(missingText) + ?? (originalText as NSString).substring(with: reference.range) + } + for record in missing where !directIDs.contains(record.contextId) { + restored.text += "\n\n" + missingText(record) + } + var repairedScreenshot = false + let records = (restored.context?.records ?? []).filter { !missingIDs.contains($0.contextId) }.map { record in + var record = record + if case var .previewAnnotation(annotation) = record.payload, + let screenshot = annotation.screenshotContextId, + missingIDs.contains(screenshot) || !(restored.context?.records.contains { $0.contextId == screenshot && $0.kind == "image" } ?? false) { + if !missingIDs.contains(screenshot) { + restored.text += "\n\n[Missing screenshot: \(record.label)]\ncontextId: \(screenshot)" + } + annotation.screenshotContextId = nil + record.payload = .previewAnnotation(annotation) + repairedScreenshot = true + } + return record + } + restored.context = records.isEmpty ? nil : .init(records: records) + if !missing.isEmpty || repairedScreenshot { onMissingAttachments() } + return restored + } + + 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 openURL: OpenURLAction + 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 + let canEditMessage: (String) -> Bool + let onEditMessage: (String) -> 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 = .interactive + 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.currentOpenURL = openURL + context.coordinator.canEditMessage = canEditMessage + context.coordinator.onEditMessage = onEditMessage + 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? + var currentOpenURL: OpenURLAction? + var canEditMessage: ((String) -> Bool)? + var onEditMessage: ((String) -> Void)? + 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 collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + guard let messageID = dataSource?.itemIdentifier(for: indexPath), + messagesByID[messageID]?.role == .user, + canEditMessage?(messageID) == true else { return nil } + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + UIMenu(children: [UIAction( + title: "Edit from here", image: UIImage(systemName: "arrow.uturn.backward") + ) { [weak self] _ in + guard self?.canEditMessage?(messageID) == true else { return } + self?.onEditMessage?(messageID) + }]) + } + } + + 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) + .environment( + \.openURL, + self?.currentOpenURL ?? OpenURLAction { _ in .systemAction } + ) + } + .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 + // Self-sizing cells can grow before the next layout restores the + // bottom offset. Keep following until an actual drag releases it. + let shouldFollowBottom = isInitialLoad || wasNearBottom + || (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor == true + 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 = + shouldFollowBottom + + 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 { + guard self.currentThreadID == threadID else { return } + // A streaming delta lands every ~80 ms. Never fight a + // finger that is on the list. + let userIsScrolling = collectionView.isTracking + || collectionView.isDragging + || collectionView.isDecelerating + let stillFollowing = (collectionView as? BottomAnchoredTranscriptCollectionView)? + .maintainsBottomAnchor == true + if stillFollowing, !userIsScrolling { + self.scrollToBottom( + collectionView, + animated: !isInitialLoad && lastIDChanged + ) + } else if !userIsScrolling, !stillFollowing, 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 + } + + 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 showsScrollToBottom(at offset: CGFloat) -> Bool { + viewportHeight > 0 && bottomOffset - offset >= 120 + } + + 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 + } +} + +/// Full-content back-swipe fallback for iOS 17 and 18. Newer iOS versions use +/// system navigation. SwiftUI's `DragGesture` 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. +final class BottomAnchoredTranscriptCollectionView: UICollectionView { + var maintainsBottomAnchor = false + + private var lastLaidOutGeometry: TranscriptViewportGeometry? + private var isRestoringBottomAnchor = false + private var needsInitialBottomPosition = true + private let bottomButton = UIButton(type: .system) + + override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { + super.init(frame: frame, collectionViewLayout: layout) + var configuration = UIButton.Configuration.filled() + configuration.image = UIImage(systemName: "arrow.down") + configuration.preferredSymbolConfigurationForImage = .init(pointSize: 17, weight: .semibold) + configuration.baseForegroundColor = T3Colors.uiTextPrimary + configuration.baseBackgroundColor = T3Colors.uiSurfaceRaised + configuration.cornerStyle = .capsule + bottomButton.configuration = configuration + bottomButton.accessibilityLabel = "Scroll to bottom" + bottomButton.accessibilityIdentifier = "thread-scroll-to-bottom" + bottomButton.isHidden = true + bottomButton.addTarget(self, action: #selector(jumpToBottom), for: .touchUpInside) + addSubview(bottomButton) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + needsInitialBottomPosition = true + maintainsBottomAnchor = true + setNeedsLayout() + } + } + + @objc private func jumpToBottom() { + layoutIfNeeded() + maintainsBottomAnchor = true + let geometry = viewportGeometry + // Jump directly in long threads instead of rendering every intervening + // message. Keep keyboard focus and follow subsequent streamed output. + setContentOffset(CGPoint(x: contentOffset.x, y: geometry.bottomOffset), animated: false) + updateBottomButton(geometry) + } + + private var viewportGeometry: TranscriptViewportGeometry { + TranscriptViewportGeometry( + contentHeight: contentSize.height, + viewportHeight: bounds.height, + topInset: adjustedContentInset.top, + bottomInset: adjustedContentInset.bottom + ) + } + + private func updateBottomButton(_ geometry: TranscriptViewportGeometry) { + bottomButton.isHidden = needsInitialBottomPosition || bounds.height < 64 + || !geometry.showsScrollToBottom(at: contentOffset.y) + bottomButton.frame = CGRect( + x: bounds.midX - 22, y: bounds.maxY - adjustedContentInset.bottom - 54, + width: 44, height: 44 + ) + bringSubviewToFront(bottomButton) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let geometry = viewportGeometry + defer { + lastLaidOutGeometry = geometry + updateBottomButton(geometry) + } + + let isInteracting = isTracking || isDragging || isDecelerating || isRestoringBottomAnchor + if needsInitialBottomPosition, isInteracting { + needsInitialBottomPosition = false + } + if needsInitialBottomPosition, window != nil, bounds.height > 0, contentSize.height > 0 { + needsInitialBottomPosition = false + maintainsBottomAnchor = true + isRestoringBottomAnchor = true + contentOffset = CGPoint(x: contentOffset.x, y: geometry.bottomOffset) + isRestoringBottomAnchor = false + return + } + + guard let bottomY = geometry.restoredBottomOffset( + after: lastLaidOutGeometry, + maintainsBottomAnchor: maintainsBottomAnchor, + isInteracting: isInteracting + ) 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] = [] + @SwiftUI.Environment(\.openURL) private var openURL + @State private var previewedContext: ComposerContextRecord? + @State private var contextUnavailable = false + + var body: some View { + messageBody + .environment(\.openURL, OpenURLAction { url in + guard let reference = ComposerContextReferences.parseHref(url.absoluteString) else { + openURL(url) + return .handled + } + guard let record = message.context?.records.first(where: { $0.contextId == reference.contextId }) else { + contextUnavailable = true + return .handled + } + if case let .mention(value) = record.payload, let path = FeatureComposerFileLinkSerializer.url(for: value.path) { + openURL(path) + } else { + previewedContext = record + } + return .handled + }) + .sheet(item: $previewedContext) { record in + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let binding = record.attachment, + let attachment = message.attachments.first(where: { $0.id == binding.attachmentId }) { + FeatureMessageAttachmentsView(attachments: [attachment], context: attachmentContext) + } else { + Text(ComposerContextReferences.providerPayload(record)) + .font(T3Typography.tool) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if case let .reviewComment(value) = record.payload, + let request = value.pullRequest, + let url = URL(string: request.url), ["https", "http"].contains(url.scheme?.lowercased() ?? "") { + Link("Open pull request #\(request.number)", destination: url) + } + } + .padding() + } + .background(T3Colors.background) + .navigationTitle(record.label) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { previewedContext = nil } + } + } + .t3NavigationChrome() + } + .preferredColorScheme(.dark) + } + .alert("Context unavailable", isPresented: $contextUnavailable) { + Button("OK", role: .cancel) {} + } message: { + Text("This message has a context link without its saved record.") + } + } + + private var renderedText: String { + // Images use the existing attachment preview. Do not ask Markdown to fetch t3-context URLs. + ComposerContextReferences.replace(message.text) { + "[\($0.label)](t3-context://v1/\($0.kind)/\($0.contextId))" + } + } + + private var clipboardSource: ComposerContextClipboardFragment.Source? { + attachmentContext?.environmentID.map { + .init(environmentId: $0, threadId: attachmentContext?.wireThreadID, messageId: message.id) + } + } + + @ViewBuilder private var messageBody: 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( + renderedText, + isStreaming: message.state == .streaming, + imageContext: imageContext, + skills: skills, + clipboardSource: clipboardSource, + messageContext: message.context, + copyText: message.text + ) + } + if message.state == .queued { + Label("Queued. Sends when connected.", systemImage: "clock") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } else if message.state == .failed { + Label("Not sent", systemImage: "exclamationmark.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + } + } + .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) { + FeatureMessageAttachmentsView(attachments: message.attachments, context: attachmentContext) + if !message.text.isEmpty { + MarkdownMessageView( + renderedText, + isStreaming: message.state == .streaming, + imageContext: imageContext, + skills: skills, + clipboardSource: clipboardSource, + messageContext: message.context, + copyText: message.text + ) + .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..fc1c5a23bc64 --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -0,0 +1,731 @@ +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 + /// Pre-fills the endpoint and opens the details form. Used to pair again + /// with a computer whose saved credential was rejected. + private let initialEndpoint: String? + + @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, + initialEndpoint: String? = nil, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + readinessChecker = LocalNetworkAccessChecker() + self.showsT3ConnectOption = showsT3ConnectOption + self.initialEndpoint = initialEndpoint + self.onConnected = onConnected + self.onCancel = onCancel + } + + init( + model: FeatureRootModel, + readinessChecker: any ConnectionReadinessChecking, + showsT3ConnectOption: Bool = true, + initialEndpoint: String? = nil, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + self.readinessChecker = readinessChecker + self.showsT3ConnectOption = showsT3ConnectOption + self.initialEndpoint = initialEndpoint + 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") + } + .onAppear { + guard let initialEndpoint, stage == .welcome else { return } + endpoint = initialEndpoint + entryHeading = "Pair again" + stage = .details + focusedField = .pairingCode + } + .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..002a06bea64f --- /dev/null +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -0,0 +1,283 @@ +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 { + Text("Loading devices") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } 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 { + DeviceSessionRow(session: currentSession) + } header: { + sectionHeader("This device") + } + } + + if !otherSessions.isEmpty { + Section { + 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") + } + } + } + } header: { + sectionHeader("Other devices") + } + } + + if let errorMessage { + Section { + VStack(alignment: .leading, spacing: 10) { + Label(errorMessage, systemImage: "exclamationmark.circle") + .font(T3Typography.control) + .foregroundStyle(T3Colors.warning) + 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 { + Text("Updating device access") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .padding(.top, 12) + } + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .textCase(nil) + .accessibilityAddTraits(.isHeader) + } + + 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 ? T3Colors.success : T3Colors.textSecondary) + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(session.displayName) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + if session.isCurrent { + Text("Current") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.success) + } else if session.isConnected { + Text("Online") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.success) + } + } + + 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/FeatureFileBrowserState.swift b/apps/swift-ios/Features/Files/FeatureFileBrowserState.swift new file mode 100644 index 000000000000..7deabd3b5f9e --- /dev/null +++ b/apps/swift-ios/Features/Files/FeatureFileBrowserState.swift @@ -0,0 +1,71 @@ +import Foundation +import Observation + +/// A browser session caches only the folders the user opens, never file contents. +@MainActor +@Observable +final class FeatureFileBrowserState { + struct Directory: Hashable { + // Native thread IDs include their environment ID. + let threadID: String + let workspaceRoot: String? + let path: String? + } + + struct Listing { + var entries: [FeatureFileEntry]? + var errorMessage: String? + var isLoading = false + fileprivate var requestID: UUID? + } + + private var listings: [Directory: Listing] = [:] + + func listing(for directory: Directory) -> Listing { + listings[directory] ?? Listing() + } + + func load( + _ directory: Directory, + refresh: Bool = false, + fetch: () async throws -> [FeatureFileEntry] + ) async { + let previous = listing(for: directory) + guard refresh || (previous.entries == nil && !previous.isLoading) else { return } + let requestID = UUID() + listings[directory, default: Listing()].requestID = requestID + listings[directory, default: Listing()].isLoading = true + defer { + if listings[directory]?.requestID == requestID { + listings[directory]?.isLoading = false + } + } + do { + let entries = try await fetch() + try Task.checkCancellation() + guard listings[directory]?.requestID == requestID else { return } + listings[directory]?.entries = entries + listings[directory]?.errorMessage = nil + discardRemovedDirectories(in: directory, entries: entries) + } catch { + guard !Task.isCancelled, !(error is CancellationError), + listings[directory]?.requestID == requestID else { return } + listings[directory]?.errorMessage = error.localizedDescription + } + } + + private func discardRemovedDirectories(in directory: Directory, entries: [FeatureFileEntry]) { + let prefix = directory.path.map { "\($0)/" } ?? "" + let childDirectories = Set(entries.filter { $0.kind == .directory }.map(\.path)) + for cached in Array(listings.keys) { + guard cached.threadID == directory.threadID, + cached.workspaceRoot == directory.workspaceRoot, + let path = cached.path, path.hasPrefix(prefix) else { continue } + let relative = path.dropFirst(prefix.count) + guard let child = relative.split(separator: "/").first else { continue } + if !childDirectories.contains(prefix + String(child)) { + listings.removeValue(forKey: cached) + } + } + } +} diff --git a/apps/swift-ios/Features/Files/FeatureFilesView.swift b/apps/swift-ios/Features/Files/FeatureFilesView.swift new file mode 100644 index 000000000000..f9ce340a3548 --- /dev/null +++ b/apps/swift-ios/Features/Files/FeatureFilesView.swift @@ -0,0 +1,528 @@ +import ImageIO +import SwiftUI +import UIKit + +public struct FeatureFilesView: View { + let client: any FeatureClient + let threadID: String + let initialPath: String? + let workspaceRoot: String? + + @State private var browser = FeatureFileBrowserState() + + 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, + browser: browser, + threadID: threadID, + path: nil, + title: "Files", + workspaceRoot: workspaceRoot + ) + } + } + .background(T3Colors.background) + } +} + +private struct FeatureFileDirectoryView: View { + let client: any FeatureClient + let browser: FeatureFileBrowserState + let threadID: String + let path: String? + let title: String + let workspaceRoot: String? + + @State private var searchText = "" + @State private var includesHidden = false + + private var directory: FeatureFileBrowserState.Directory { + .init(threadID: threadID, workspaceRoot: workspaceRoot, path: path) + } + + private var listing: FeatureFileBrowserState.Listing { + browser.listing(for: directory) + } + + var body: some View { + let filteredEntries = self.filteredEntries + List { + if let errorMessage = listing.errorMessage { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(T3Colors.warning) + Text(errorMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Retry") { Task { await load(refresh: true) } } + .buttonStyle(.borderless) + .disabled(listing.isLoading) + } + } + if listing.isLoading { + Label("Loading files…", systemImage: "folder") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .listRowBackground(Color.clear) + } else if listing.entries == nil, listing.errorMessage != nil { + ContentUnavailableView( + "Files unavailable", + systemImage: "folder.badge.questionmark" + ) + .listRowBackground(Color.clear) + } + if listing.entries != nil, filteredEntries.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Empty folder" : "No matches", + systemImage: "folder", + description: Text(searchText.isEmpty ? "This folder has no visible files." : "Try another search.") + ) + .listRowBackground(Color.clear) + } + ForEach(filteredEntries) { entry in + NavigationLink { + destination(for: entry) + } label: { + FeatureFileRow(entry: entry) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await load(refresh: true) } + .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(refresh: true) } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("File browser options") + } + } + .task(id: directory) { await load() } + } + + @ViewBuilder + private func destination(for entry: FeatureFileEntry) -> some View { + if entry.kind == .directory { + FeatureFileDirectoryView( + client: client, + browser: browser, + threadID: threadID, + path: entry.path, + title: entry.name, + workspaceRoot: workspaceRoot + ) + } else { + FeatureFilePreviewView( + client: client, + threadID: threadID, + entry: entry, + workspaceRoot: workspaceRoot + ) + } + } + + private var filteredEntries: [FeatureFileEntry] { + (listing.entries ?? []).featureFiltered(by: searchText, includesHidden: includesHidden) + } + + private func load(refresh: Bool = false) async { + await browser.load(directory, refresh: refresh) { + try await client.listFiles(threadID: threadID, path: path) + } + } +} + +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) + .opacity(entry.isIgnored ? 0.55 : 1) + .accessibilityElement(children: .combine) + .accessibilityValue(entry.isIgnored ? "Ignored by Git" : "") + } + + 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) + .frame(minHeight: proxy.size.height, alignment: .topLeading) + .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..afc2a5dfcde3 --- /dev/null +++ b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift @@ -0,0 +1,1740 @@ +import Observation +import SwiftUI +import UIKit + +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, + host: entry.host + ) + ) + } +} + +@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? + + /// Reuse filter results while this view is open. A new view must read its + /// current environments instead of restoring rows from a removed account. + private struct CachedList { + let input: PullRequestListInput + let environments: [FeaturePullRequestEnvironmentList] + } + + private var cache: [CachedList] = [] + private static let cacheLimit = 8 + + init(client: any FeatureClient) { + self.client = client + } + + private var currentInput: PullRequestListInput { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let filters = PullRequestListFilters( + draft: draftFilter, + review: reviewFilter, + checks: checksFilter + ) + return PullRequestListInput( + state: state, + involvement: involvement, + filters: filters == PullRequestListFilters() ? nil : filters, + query: trimmedQuery.isEmpty ? nil : trimmedQuery + ) + } + + func load(invalidate: Bool = false) async { + loadGeneration &+= 1 + let generation = loadGeneration + isLoading = true + isLoadingMore = false + errorMessage = nil + defer { + if loadGeneration == generation { + isLoading = false + } + } + let input = currentInput + if !invalidate, let cached = cachedEnvironments(for: input) { + loadedInput = input + environments = cached + updateRows() + } + do { + if invalidate { try await client.invalidatePullRequests(nil) } + let result = try await client.pullRequestLists(input) + guard !Task.isCancelled, loadGeneration == generation else { return } + loadedInput = input + environments = result + remember() + updateRows() + } catch { + guard loadGeneration == generation, !(error is CancellationError) else { return } + errorMessage = error.localizedDescription + } + } + + private func cachedEnvironments(for input: PullRequestListInput) -> [FeaturePullRequestEnvironmentList]? { + cache.first { $0.input == input }?.environments + } + + private func remember() { + guard let loadedInput else { return } + cache.removeAll { $0.input == loadedInput } + cache.insert(CachedList(input: loadedInput, environments: environments), at: 0) + cache = Array(cache.prefix(Self.cacheLimit)) + } + + 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 + ) + remember() + 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 + ) + } + } + } + + /// The server already matched `loadedInput.query` against body, labels, and + /// branches, so the text filter only runs locally while a newer query is + /// still in flight. + func applyLocalFilters() { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let needle = trimmed == (loadedInput?.query ?? "") ? "" : trimmed.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(400)) + 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 { + PullRequestLoadingText() + .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: { + Text(model.isLoadingMore ? "Loading more" : "Load more") + .frame(maxWidth: .infinity) + } + .disabled(model.isLoading || model.isLoadingMore) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .refreshable { await model.load(invalidate: true) } + } + } + + private func reload() { + Task { await model.load() } + } +} + +/// Static loading copy. Spinners repaint continuously, which pegs the GPU. +private struct PullRequestLoadingText: View { + var body: some View { + Text("Loading") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } +} + +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 + } + + /// Runs under the Files tab's `.task(id:)`, so leaving the tab cancels it + /// between pages. Parsing runs off the main actor. + func loadDiff() async { + guard detail?.capabilities.diff == true, diffFiles.isEmpty, !isLoadingDiff else { return } + isLoadingDiff = true + defer { isLoadingDiff = false } + do { + var cursor: String? + var pagination = PullRequestDiffPagination() + repeat { + try Task.checkCancellation() + let page = try await client.pullRequestDiff(target, cursor: cursor) + cursor = pagination.append(page) + } while cursor != nil + try Task.checkCancellation() + let patch = pagination.patch + let files = await Task.detached(priority: .userInitiated) { + PullRequestDiffParser.parse(patch) + }.value + try Task.checkCancellation() + diffFiles = files + isDiffIncomplete = pagination.isIncomplete + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + } + } + + 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 enum PendingAction: Identifiable { + case merge(PullRequestMergeMethod) + case close + + var id: String { + switch self { + case .merge(let method): "merge:\(method.rawValue)" + case .close: "close" + } + } + } + + @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 { + PullRequestLoadingText() + } 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() } + .task(id: tab) { + guard tab == .files else { return } + 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( + pendingAction.map(confirmationTitle) ?? "", + isPresented: Binding( + get: { pendingAction != nil }, + set: { if !$0 { pendingAction = nil } } + ), + presenting: pendingAction + ) { pending in + switch pending { + case .merge(let method): + Button("Merge") { + pendingAction = nil + Task { await model.run(.merge, mergeMethod: method) } + } + case .close: + Button("Close", role: .destructive) { + pendingAction = nil + Task { await model.run(.close) } + } + } + Button("Cancel", role: .cancel) { pendingAction = nil } + } message: { pending in + Text(confirmationMessage(pending)) + } + } + + private func confirmationTitle(_ pending: PendingAction) -> String { + switch pending { + case .merge: "Merge pull request?" + case .close: "Close pull request?" + } + } + + private func confirmationMessage(_ pending: PendingAction) -> String { + let number = target.reference.number + switch pending { + case .merge(let method): + let base = model.detail?.baseBranch ?? "the base branch" + return "\(method.label) #\(number) into \(base). You cannot undo a merge." + case .close: + return "#\(number) will be closed without merging. You can reopen it later." + } + } + + 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 + Task { await model.run(.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 { + pendingAction = .close + } 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 = .merge(method) + } + } + } + } + } label: { + 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 { + PullRequestLoadingText() + } + } + .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? + @State private var selectedLine: 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 { + PullRequestLoadingText() + .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()) + .foregroundStyle(T3Colors.textPrimary) + .padding(.vertical, 10) + 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()) + .onTapGesture { + guard line.kind != .header else { return } + selectedLine = PullRequestDiffSelection(file: file, line: line) + } + } + } + } + } + // One menu per file. A menu per line costs one interaction per row. + .contextMenu { + Button("Copy file path", systemImage: "doc.on.doc") { + UIPasteboard.general.string = file.path + } + Button("Copy diff", systemImage: "doc.on.doc") { + UIPasteboard.general.string = file.lines.map(\.text).joined(separator: "\n") + } + } + } + } + } + .padding(16) + } + .confirmationDialog( + selectedLine.map { "\($0.file.path):\($0.line.displayLineNumber)" } ?? "", + isPresented: Binding( + get: { selectedLine != nil }, + set: { if !$0 { selectedLine = nil } } + ), + titleVisibility: .visible, + presenting: selectedLine + ) { selection in + if canComment, selection.line.position != nil { + Button("Add review comment") { commentingLine = selection } + } + Button("Send line to agent") { sendToAgent(selection.line, selection.file) } + Button("Copy line") { UIPasteboard.general.string = selection.line.text } + } + .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 { + PullRequestLoadingText() + } + 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..0cf291b32555 --- /dev/null +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -0,0 +1,550 @@ +import SwiftUI +import UIKit + +public struct FeatureReviewView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + let client: any FeatureClient + let threadID: String + /// Comments are ordinary turns. They go through the same queue as the + /// composer so an offline comment is kept and retried, not dropped. + let sendMessage: (FeatureMessageSubmission) async -> Bool + + @State private var review: FeatureReview? + @State private var isLoading = true + @State private var errorMessage: String? + + public init( + client: any FeatureClient, + threadID: String, + sendMessage: @escaping (FeatureMessageSubmission) async -> Bool + ) { + self.client = client + self.threadID = threadID + self.sendMessage = sendMessage + } + + 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, + sendMessage: sendMessage + ) + } 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 + let sendMessage: (FeatureMessageSubmission) async -> Bool + + @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, + sendMessage: @escaping (FeatureMessageSubmission) async -> Bool + ) { + self.client = client + self.threadID = threadID + self.file = file + self.sendMessage = sendMessage + _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 record = reviewDraft.contextRecord(lines: renderedLines) + let prompt = reviewDraft.submissionText(contextRecord: record) + isSending = true + commentError = nil + Task { + let sent = await sendMessage( + FeatureMessageSubmission(threadID: threadID, text: prompt, selection: nil, context: .init(records: [record])) + ) + if sent { + comment = "" + selectedLine = nil + isCommenting = false + isCommentFocused = false + } else { + commentError = "Could not send the comment." + } + 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..1ff9a34c2b96 --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -0,0 +1,2140 @@ +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() + /// Why the last `startTask` returned nil, for the sheet that made the request. + public private(set) var lastTaskStartError: String? + 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 when a Home shelf or order input changes (see `HomeOrderKey`) + /// or when projects, environments, providers, or preferences change. + /// Streaming updates to a row's own content do not advance it. + public private(set) var homePresentationRevision: UInt64 = 0 + /// Advances on any thread insert, removal, or field change. Rows read this + /// to refresh their own content without re-sorting the list. + public private(set) var threadRowRevision: 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 + private(set) var isArrangingThreads = false + public private(set) var rewindingThreadIDs: Set = [] + public private(set) var recoveredRewindDrafts: [String: FeatureComposerDraft] = [:] + public private(set) var rewindErrors: [String: String] = [:] + public private(set) var pendingRewindRecoveryIDs: Set = [] + /// Approval and question IDs with a response in flight. Views disable + /// only that request, not every request in every thread. + public private(set) var resolvingRequestIDs: Set = [] + 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] = [:] + /// Accepted messages not yet present in a server transcript, by message ID. + private var deliveredAwaitingDetail: [String: (threadID: String, message: FeatureMessage)] = [:] + /// Message IDs in each thread's latest server transcript. + private var serverMessageIDs: [String: Set] = [:] + private var activeSubmissionCounts: [String: Int] = [:] + 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 } + + lastTaskStartError = nil + guard let project = snapshot.projects.first(where: { $0.id == request.projectID }) else { + lastTaskStartError = "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 + ), + context: request.context + ) + 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, + context: request.context + ) + 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) { + lastTaskStartError = 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 }), + !thread.canArchive { + // Rows hide Archive on live work; a stale row can still race in. + 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 arrangementPlan( + id: String, + destination: ThreadArrangementDestination + ) -> ThreadArrangementPlanner.Plan? { + guard !isArrangingThreads else { return nil } + return ThreadArrangementPlanner.plan( + id: id, + destination: destination, + threads: snapshot.threads, + connectedEnvironmentIDs: Set(snapshot.environments.filter { + $0.isEnabled && $0.connectionState == .connected + }.map(\.id)), + now: .now + ) + } + + @discardableResult + func arrangeThread(_ id: String, destination: ThreadArrangementDestination) async -> Bool { + guard let plan = arrangementPlan(id: id, destination: destination), + let previous = snapshot.threads.first(where: { $0.id == id }) else { return false } + isArrangingThreads = true + defer { isArrangingThreads = false } + guard let section = plan.section else { return await setSettled(id, settled: true) } + let environment = currentEnvironmentIdentity + let succeeded = await reorderThread(id, section: section, orderedIDs: plan.orderedIDs) + guard succeeded, currentEnvironmentIdentity == environment else { return false } + if ThreadArrangementPlanner.section(of: previous, now: .now).orderSection != section { + mutateThread(id: id) { + $0.pinnedAt = section == .pinned ? .now : nil + $0.snoozedUntil = nil + $0.snoozedAt = nil + if section == .active { $0.pinOrderKey = nil } + if previous.isEffectivelySettled() { + $0.isSettled = false + $0.keepsActive = true + $0.settledAt = nil + $0.unsettledAt = .now + $0.settlementFacts?.settlementOverride = .active + } + } + } + return true + } + + /// Commits a dropped order: `orderedIDs` is the section's displayed order + /// with the dragged row already in its new slot. Returns false when the + /// write was rejected or failed so the collection view can snap the row + /// back. + @discardableResult + public func reorderThread( + _ id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) async -> Bool { + let environment = currentEnvironmentIdentity + do { + let assignments = try await client.reorderThread( + id: id, + section: section, + orderedIDs: orderedIDs + ) + applyMoveAssignments(assignments, section: section, environment: environment) + return !assignments.isEmpty + } catch let partial as FeatureThreadMovePartialError { + applyMoveAssignments(partial.confirmed, section: section, environment: environment) + await perform { throw partial.underlying } + // Confirmed keys are in the model; snapping back renders the true + // arrangement instead of holding a full order that never landed. + return false + } catch { + await perform { throw error } + return false + } + } + + private func applyMoveAssignments( + _ assignments: [FeatureThreadOrderAssignment], + section: FeatureThreadOrderSection, + environment: String + ) { + guard currentEnvironmentIdentity == environment else { return } + for assignment in assignments { + mutateThread(id: assignment.threadID) { + switch section { + case .pinned: $0.pinOrderKey = assignment.orderKey + case .active: $0.activeOrderKey = assignment.orderKey + } + } + } + } + + 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(_ submission: FeatureMessageSubmission) async -> Bool { + guard !rewindingThreadIDs.contains(submission.threadID) else { return false } + activeSubmissionCounts[submission.threadID, default: 0] += 1 + defer { + let remaining = (activeSubmissionCounts[submission.threadID] ?? 1) - 1 + activeSubmissionCounts[submission.threadID] = remaining > 0 ? remaining : nil + } + 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, + context: submission.context + ) + 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, + source: $0.source + ) + }, + context: submission.context + ) + 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, + context: submission.context + ) + 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 canRewindConversation(threadID: String, messageID: String) -> Bool { + guard !rewindingThreadIDs.contains(threadID), + !pendingRewindRecoveryIDs.contains(threadID), + activeSubmissionCounts[threadID] == nil, + let detail = details[threadID], + FeatureConversationRewind.canStart(in: detail), + let environmentID = detail.thread.environmentID, + isEnvironmentConnected(environmentID), + !pendingSubmissionsByID.values.contains(where: { $0.threadID == threadID }), + detail.messages.contains(where: { + $0.id == messageID && $0.role == .user && $0.state == .complete + }) else { return false } + return client.canRewindConversation(threadID: threadID, messageID: messageID) + } + + /// The lock and saved recovery live outside the view so navigation cannot + /// enable sends or lose a restored prompt while provider rollback is pending. + public func rewindConversation( + threadID: String, messageID: String, draft: FeatureComposerDraft + ) async { + guard canRewindConversation(threadID: threadID, messageID: messageID), + let detail = details[threadID], + let message = detail.messages.first(where: { $0.id == messageID }) else { return } + guard draft.attachments.count + message.attachments.count <= 8 else { + rewindErrors[threadID] = "Make room for this message's attachments before rewinding." + return + } + rewindingThreadIDs.insert(threadID) + rewindErrors[threadID] = nil + recoveredRewindDrafts[threadID] = nil + defer { rewindingThreadIDs.remove(threadID) } + let key = FeatureComposerDraftStore.threadKey(detail.thread) + let recoveryKey = FeatureComposerDraftStore.rewindRecoveryKey(for: key) + do { + guard try await !draftStore.hasRewindRecovery(for: key) else { + pendingRewindRecoveryIDs.insert(threadID) + rewindErrors[threadID] = "Recover the saved prompt before starting another rewind." + return + } + try await draftStore.setDraft(draft, for: key) + try await client.rewindConversation(threadID: threadID, messageID: messageID) { reverted in + guard self.snapshot.environments.contains(where: { $0.id == detail.thread.environmentID }) else { + throw FeatureConversationRewindError(message: "The computer was removed before rewind started.", didNotRevert: true) + } + let recovery = try FeatureConversationRewind.recover(reverted, draft: FeatureComposerDraft()) + // Check the combined record limit before the server changes history. + _ = try FeatureComposerContext.merge(draft.context, recovery.context) + try await self.draftStore.setDraft(recovery, for: recoveryKey) + self.pendingRewindRecoveryIDs.insert(threadID) + } + try await finishRewindRecovery(thread: detail.thread) + } catch { + rewindErrors[threadID] = error.localizedDescription + if (error as? FeatureConversationRewindError)?.didNotRevert == true { + do { + try await draftStore.discardRewindRecovery(for: key) + pendingRewindRecoveryIDs.remove(threadID) + } catch { + rewindErrors[threadID] = "The saved prompt could not be cleared. \(error.localizedDescription)" + } + } + } + } + + public func checkRewindRecovery(for thread: FeatureThread) async { + guard !rewindingThreadIDs.contains(thread.id) else { return } + do { + let key = FeatureComposerDraftStore.threadKey(thread) + let saved = try await draftStore.hasRewindRecovery(for: key) + guard !rewindingThreadIDs.contains(thread.id) else { return } + if saved { pendingRewindRecoveryIDs.insert(thread.id) } + else { pendingRewindRecoveryIDs.remove(thread.id) } + } catch { + rewindErrors[thread.id] = "Could not read the saved rewind prompt. \(error.localizedDescription)" + } + } + + /// Recovery changes only the local composer. It never sends another rewind. + public func recoverSavedRewind(threadID: String, draft: FeatureComposerDraft) async { + guard let thread = details[threadID]?.thread ?? snapshot.threads.first(where: { $0.id == threadID }), + rewindingThreadIDs.insert(threadID).inserted else { return } + defer { rewindingThreadIDs.remove(threadID) } + do { + try await draftStore.setDraft(draft, for: FeatureComposerDraftStore.threadKey(thread)) + try await finishRewindRecovery(thread: thread) + } catch { + rewindErrors[threadID] = error.localizedDescription + } + } + + private func finishRewindRecovery(thread: FeatureThread) async throws { + guard snapshot.environments.contains(where: { $0.id == thread.environmentID }) else { return } + let key = FeatureComposerDraftStore.threadKey(thread) + if let recovered = try await draftStore.consumeRewindRecovery(for: key) { + guard snapshot.environments.contains(where: { $0.id == thread.environmentID }) else { + try await draftStore.removeDraft(for: key) + return + } + recoveredRewindDrafts[thread.id] = recovered + if let environmentID = thread.environmentID { + attachmentUploads.syncOwner(draftKey: key, environmentID: environmentID, attachments: recovered.attachments) + } + } + pendingRewindRecoveryIDs.remove(thread.id) + rewindErrors[thread.id] = nil + } + + public func consumeRewindDraft(threadID: String) -> FeatureComposerDraft? { + recoveredRewindDrafts.removeValue(forKey: threadID) + } + + 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 { + guard resolvingRequestIDs.insert(id).inserted else { return } + defer { resolvingRequestIDs.remove(id) } + 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], + attachmentsByQuestionID: [String: [FeatureUploadAttachment]] = [:] + ) async { + guard resolvingRequestIDs.insert(id).inserted else { return } + defer { resolvingRequestIDs.remove(id) } + let environment = currentEnvironmentIdentity + await perform { + try await client.resolveUserInput(id: id, answers: answers, attachmentsByQuestionID: attachmentsByQuestionID) + try? await FeatureComposerDraftStore.shared.removeDraft(for: FeatureQuestionAttachmentDraft.key(inputID: id)) + 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 } + } + } + } + } + + public func dismissUserInput(_ id: String) async { + guard resolvingRequestIDs.insert(id).inserted else { return } + defer { resolvingRequestIDs.remove(id) } + let environment = currentEnvironmentIdentity + await perform { + try await client.dismissUserInput(id: id) + try? await FeatureComposerDraftStore.shared.removeDraft(for: FeatureQuestionAttachmentDraft.key(inputID: id)) + 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 } + } + } + } + } + + @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 } + if let urlError = error as? URLError, urlError.code == .cancelled { return true } + return false + } + + 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, environmentID): + var changed = false + if snapshot.connection != value { + snapshot.connection = value + changed = true + } + if let environmentID, + let index = snapshot.environments.firstIndex(where: { $0.id == environmentID }), + snapshot.environments[index].connectionState != value.state + || snapshot.environments[index].connectionDetail != value.detail { + snapshot.environments[index].connectionState = value.state + snapshot.environments[index].connectionDetail = value.detail + changed = true + } + guard changed else { return } + 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 + var orderChanged = 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 + orderChanged = HomeOrderKey(previous) != HomeOrderKey(thread) + 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 + orderChanged = true + } + if metadataChanged { + threadRowRevision &+= 1 + } + if orderChanged { + 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) { + forgetDeliveredMessages(threadID: id) + 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) + threadRowRevision &+= 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 + let environmentIDs = Set(value.environments.map(\.id)) + for thread in snapshot.threads where thread.environmentID.map({ !environmentIDs.contains($0) }) == true { + recoveredRewindDrafts[thread.id] = nil + pendingRewindRecoveryIDs.remove(thread.id) + rewindErrors[thread.id] = nil + } + 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) + } + + let threadsChanged = snapshot.threads != value.threads + let orderChanged = threadsChanged && ( + snapshot.threads.count != value.threads.count + || zip(snapshot.threads, value.threads).contains { previous, next in + previous.id != next.id || HomeOrderKey(previous) != HomeOrderKey(next) + } + ) + 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 + || orderChanged { + homePresentationRevision &+= 1 + } + if threadsChanged { + threadRowRevision &+= 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 + threadRowRevision &+= 1 + homePresentationRevision &+= 1 + } + } + let detailChanged = mutateDetail( + id: id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + mutation(&$0.thread) + } + if metadataChanged || detailChanged { + bumpDetailMetadataRevision(id: id) + } + } + + /// `isServerTranscript` is false for details built locally, such as a + /// restored outbox or a pending creation; they cannot confirm delivery. + private func store( + _ incoming: FeatureThreadDetail, + invalidatesInFlightLoad: Bool = true, + isServerTranscript: 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, isServerTranscript: isServerTranscript) + 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, isServerTranscript: true) + 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 forgetDeliveredMessages(threadID: String) { + deliveredAwaitingDetail = deliveredAwaitingDetail.filter { $0.value.threadID != threadID } + serverMessageIDs[threadID] = nil + } + + private func removeDetail(id: String, forgettingDeliveredMessages: Bool = true) { + if forgettingDeliveredMessages { + forgetDeliveredMessages(threadID: id) + } else { + // The next server transcript records these again; keep the cache bounded. + serverMessageIDs[id] = nil + } + 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() { + deliveredAwaitingDetail.removeAll() + serverMessageIDs.removeAll() + 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 } + // Eviction only drops the cache; accepted messages must survive a reopen. + removeDetail(id: candidate, forgettingDeliveredMessages: false) + } + } + + 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(detail, isServerTranscript: false) + } + 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(detail, isServerTranscript: false) + } + } + } + + 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)] + ), isServerTranscript: false) + } + + 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.map { attachment in + FeatureMessageAttachment( + id: attachment.id.uuidString, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.byteCount ?? attachment.data?.count ?? 0, + source: attachment.source + ) + }, + context: submission.context + ) + } + + private func addingPendingMessages( + to incoming: FeatureThreadDetail, + isServerTranscript: Bool + ) -> FeatureThreadDetail { + let existing = Set(incoming.messages.map(\.id)) + // A delivered message stays visible until a server transcript includes it. + // Its refresh no longer blocks the send, so an older read can arrive first. + if isServerTranscript { + serverMessageIDs[incoming.thread.id] = existing + for (id, delivered) in deliveredAwaitingDetail + where delivered.threadID == incoming.thread.id && existing.contains(id) { + deliveredAwaitingDetail.removeValue(forKey: id) + } + } + let delivered = deliveredAwaitingDetail.values + .filter { $0.threadID == incoming.thread.id && !existing.contains($0.message.id) } + .map(\.message) + let queued = pendingSubmissionsByID.values + .filter { $0.threadID == incoming.thread.id && !existing.contains($0.identity.messageID) } + .map(queuedMessage(for:)) + guard !queued.isEmpty || !delivered.isEmpty else { return incoming } + var result = incoming + // A newer send can be delivered while an older one waits to retry; keep send order. + result.messages.append(contentsOf: (delivered + queued).sorted { $0.createdAt < $1.createdAt }) + 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) + // A transcript that already includes it confirmed delivery; do not retain it. + if serverMessageIDs[submission.threadID]?.contains(submission.identity.messageID) != true { + var message = queuedMessage(for: submission) + message.state = .complete + deliveredAwaitingDetail[submission.identity.messageID] = (submission.threadID, message) + } + 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, + context: submission.context + ) + 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, + context: submission.context + ) + 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 + } + + /// Only transport failures keep a submission queued. A server that + /// answered and rejected the command is final, so the message is dropped + /// and the error shown. Matching on error text queued permanent failures + /// (a provider "connection refused", a validation error mentioning + /// "network") and retried them forever. + 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 { + switch rpcError { + case .responseTimedOut, .connectionUnavailable, .disconnected: return true + case .remote, .protocolViolation: break + } + } + if let httpError = error as? HTTPError { + switch httpError { + case .invalidResponse: return true + case let .status(status, _, _): return status >= 500 + default: break + } + } + if let environment = snapshot.environments.first(where: { $0.id == environmentID }) { + return !environment.isEnabled || environment.connectionState != .connected + } + return false + } +} + +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..c3536405b7ed --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -0,0 +1,105 @@ +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 + ) + } 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..cd481abf982c --- /dev/null +++ b/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift @@ -0,0 +1,152 @@ +import Foundation + +enum ConnectionHubStatus: Equatable { + case disabled + case checking + case connecting + case offline + case online + case needsPairing + + var title: String { + switch self { + case .disabled: "Off" + case .checking: "Checking" + case .connecting: "Connecting" + case .offline: "Offline" + case .online: "Online" + case .needsPairing: "Pair again" + } + } +} + +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 .needsPairing: .needsPairing + 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..0720411d79da --- /dev/null +++ b/apps/swift-ios/Features/Settings/ConnectionsView.swift @@ -0,0 +1,715 @@ +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 + @State private var showingPairAgain = 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) + if environment.source == .t3Connect, + let detail = environment.connectionDetail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + } + if environment.connectionState == .needsPairing, + environment.source == .direct { + Text(environment.connectionDetail ?? "The saved pairing was rejected.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + Button("Pair again") { showingPairAgain = true } + } + } + + 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) + } + .sheet(isPresented: $showingPairAgain) { + ConnectionOnboardingView( + model: model, + showsT3ConnectOption: false, + initialEndpoint: environment?.endpoint, + onConnected: { + showingPairAgain = false + Task { await model.reloadAfterConnection() } + }, + onCancel: { showingPairAgain = false } + ) + } + } + + 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, .needsPairing: 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..835bc7a020e3 --- /dev/null +++ b/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift @@ -0,0 +1,147 @@ +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] = [] + @State private var routingPermission = GitHubRoutingPermission.off + + 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 { + Section { + Picker("GitHub sharing", selection: Binding( + get: { routingPermission }, set: { saveRoutingPermission($0) } + )) { + ForEach(GitHubRoutingPermission.allCases, id: \.self) { Text($0.label).tag($0) } + } + } footer: { + Text("Enable both environments to share PR data through the same GitHub account. Write access permits PR changes. Credentials stay on each environment.") + } + if let settings { + if let streamingMode = settings.responseStreamingMode { + Section { + ResponseStreamingPicker(title: "Streaming", selection: Binding( + get: { streamingMode }, + set: { save(.responseStreamingMode($0)) } + )) + .accessibilityIdentifier("environment-response-streaming") + } header: { + Text("Responses") + } footer: { + if streamingMode == .token { + Text("Token streaming updates more often and can use more battery.") + } + } + } + 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 { + busy = true + defer { busy = false } + do { + routingPermission = try await model.client.gitHubRoutingPermission(environmentID: environmentID) + 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." } + } + } + + private func saveRoutingPermission(_ permission: GitHubRoutingPermission) { + guard !busy else { return } + busy = true + Task { + defer { busy = false } + do { + try await model.client.setGitHubRoutingPermission(environmentID: environmentID, permission: permission) + routingPermission = permission + } catch { errorMessage = "Could not save GitHub sharing." } + } + } +} diff --git a/apps/swift-ios/Features/Settings/NativeLicenseCatalog.swift b/apps/swift-ios/Features/Settings/NativeLicenseCatalog.swift new file mode 100644 index 000000000000..2c4ecf7e221b --- /dev/null +++ b/apps/swift-ios/Features/Settings/NativeLicenseCatalog.swift @@ -0,0 +1,28 @@ +import Foundation + +struct NativeLicense: Decodable, Identifiable { + struct Notice: Decodable { + let sourceURL: URL + let text: String + } + + let name: String + let version: String + let notices: [Notice] + var id: String { name } + + var versionLabel: String { + version.count == 40 && version.allSatisfy(\.isHexDigit) + ? String(version.prefix(12)) : version + } +} + +enum NativeLicenseCatalog { + /// These notices ship in the app so they remain available without an environment. + static func load(bundle: Bundle = .main) throws -> [NativeLicense] { + guard let url = bundle.url(forResource: "NativeLicenses", withExtension: "json") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode([NativeLicense].self, from: Data(contentsOf: url)) + } +} diff --git a/apps/swift-ios/Features/Settings/ProjectsSettingsView.swift b/apps/swift-ios/Features/Settings/ProjectsSettingsView.swift new file mode 100644 index 000000000000..c8a9bec237a1 --- /dev/null +++ b/apps/swift-ios/Features/Settings/ProjectsSettingsView.swift @@ -0,0 +1,251 @@ +import SwiftUI + +struct ResponseStreamingPicker: View { + let title: String + @Binding var selection: ResponseStreamingMode + @State private var showingTokenWarning = false + + var body: some View { + Picker(title, selection: Binding( + get: { selection }, + set: { mode in + if mode == .token { showingTokenWarning = true } + else { selection = mode } + } + )) { + ForEach(ResponseStreamingMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .confirmationDialog("Use token streaming?", isPresented: $showingTokenWarning, titleVisibility: .visible) { + Button("Use paragraphs") { selection = .paragraph } + Button("Use tokens") { selection = .token } + Button("Cancel", role: .cancel) { } + } message: { + Text("Token streaming updates more often and can use more battery. Paragraph streaming is recommended.") + } + } +} + +struct ProjectPreferencesSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + let projectID: String + + var body: some View { + NavigationStack { + ProjectPreferencesView(model: model, projectID: projectID) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .onAppear { model.setConnectionManagementPresented(true) } + .onDisappear { model.setConnectionManagementPresented(false) } + .presentationBackground(T3Colors.background) + } +} + +struct ProjectsSettingsView: View { + @Bindable var model: FeatureRootModel + + var body: some View { + List { + ForEach(model.snapshot.environments) { environment in + let projects = model.snapshot.projects.filter { $0.environmentID == environment.id } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + if !projects.isEmpty { + Section(environment.name) { + ForEach(projects) { project in + NavigationLink(project.name) { + ProjectPreferencesView(model: model, projectID: project.id) + } + } + } + .listRowBackground(T3Colors.background) + } + } + if model.snapshot.projects.isEmpty { Text("No projects") } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle("Projects") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + } +} + +struct ProjectPreferencesView: View { + @Bindable var model: FeatureRootModel + let projectID: String + @State private var preferences: FeatureProjectPreferences? + @State private var busy = false + @State private var errorMessage: String? + + private var project: FeatureProject? { model.snapshot.projects.first { $0.id == projectID } } + private var wireID: String { project?.wireID ?? projectID } + private var providers: [FeatureProvider] { + DailyUXCreationContext.providers(for: project, in: model.snapshot) + } + private var settings: ServerSettingsSnapshot? { preferences?.environment } + private var effective: ServerSettingsSnapshot? { preferences?.effective } + private var supportsRestartContinuation: Bool { + guard let project else { return false } + return model.snapshot.preferencesByEnvironment?[project.environmentID]?.continueThreadsAfterServerUpdate != nil + } + + var body: some View { + Form { + if project?.supportsProjectSettingsOverrides != true { + Text("Update this environment to change project settings.") + } else if let effective { + projectSetting(.defaultModelSelection) { + Menu { + Button("No default model") { save(.defaultModelSelection, value: .null) } + ForEach(providers.filter(\.isAvailable)) { provider in + Section(provider.name) { + ForEach(provider.models) { model in + Button(model.name) { + setDefaultModel(providerID: provider.id, modelID: model.id) + } + } + } + } + } label: { + LabeledContent("Default model", value: modelLabel(effective.defaultModelSelection)) + } + .accessibilityIdentifier("project-default-model") + } + projectSetting(.defaultThreadEnvMode) { + Picker("New threads", selection: Binding( + get: { effective.defaultThreadEnvMode }, + set: { save(.defaultThreadEnvMode, value: .string($0.rawValue)) } + )) { + Text("Local workspace").tag(ServerThreadEnvironmentMode.local) + Text("New worktree").tag(ServerThreadEnvironmentMode.worktree) + } + .accessibilityIdentifier("project-default-workspace") + } + projectSetting(.newWorktreesStartFromOrigin) { + Toggle("Start worktrees from origin", isOn: booleanBinding( + .newWorktreesStartFromOrigin, value: effective.newWorktreesStartFromOrigin + )) + } + projectSetting(.defaultAutoPull) { + Toggle("Pull before new threads", isOn: booleanBinding(.defaultAutoPull, value: effective.defaultAutoPull)) + } + if settings?.responseStreamingMode != nil { + projectSetting(.responseStreamingMode) { + ResponseStreamingPicker(title: "Response streaming", selection: Binding( + get: { effective.responseStreamingMode ?? .paragraph }, + set: { save(.responseStreamingMode, value: .string($0.rawValue)) } + )) + .accessibilityIdentifier("project-response-streaming") + } + } + projectSetting(.sidebarAutoSettleOnMerge) { + Toggle("Settle after merge", isOn: booleanBinding( + .sidebarAutoSettleOnMerge, value: effective.sidebarAutoSettleOnMerge + )) + } + projectSetting(.sidebarAutoSettleAfterDays) { + Picker("Settle after inactivity", selection: Binding( + get: { effective.sidebarAutoSettleAfterDays ?? 0 }, + set: { save(.sidebarAutoSettleAfterDays, value: $0 == 0 ? .null : .number($0)) } + )) { + Text("Never").tag(0.0) + ForEach(settlementDays(effective.sidebarAutoSettleAfterDays), id: \.self) { days in + Text(days == 1 ? "1 day" : "\(days.formatted()) days").tag(days) + } + } + } + if supportsRestartContinuation { + projectSetting(.continueThreadsAfterServerUpdate) { + Toggle("Continue threads after restarts", isOn: booleanBinding( + .continueThreadsAfterServerUpdate, value: effective.continueThreadsAfterServerUpdate + )) + } + } + } else if errorMessage == nil { + Text("Loading preferences...") + } + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(T3Colors.danger) + Button("Try again") { Task { await load() } } + } + } + } + .disabled(busy) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle(project?.name ?? "Project") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .task(id: project?.supportsProjectSettingsOverrides) { await load() } + } + + private func projectSetting( + _ key: ServerProjectSettingKey, + @ViewBuilder content: () -> Content + ) -> some View { + let overridden = settings?.projectSettingsOverrides[wireID]?[key.rawValue] != nil + return Section { + content() + if overridden { + Button("Use environment setting") { save(key, value: nil) } + .accessibilityIdentifier("project-reset-\(key.rawValue)") + } + } footer: { + Text(overridden ? "Project setting" : "Uses environment setting") + if key == .responseStreamingMode, effective?.responseStreamingMode == .token { + Text("Token streaming updates more often and can use more battery.") + } + } + .listRowBackground(T3Colors.background) + } + + private func booleanBinding(_ key: ServerProjectSettingKey, value: Bool) -> Binding { + Binding(get: { value }, set: { save(key, value: .bool($0)) }) + } + + private func settlementDays(_ current: Double?) -> [Double] { + Array(Set([1, 3, 7, 14, 30, 90] + (current.map { [$0] } ?? []))).sorted() + } + + private func modelLabel(_ selection: ModelSelection?) -> String { + guard let selection else { return "None" } + return providers.first { $0.id == selection.instanceId }? + .models.first { $0.id == selection.model }?.name ?? selection.model + } + + private func setDefaultModel(providerID: String, modelID: String) { + do { + let selection = ModelSelection(instanceId: providerID, model: modelID) + save(.defaultModelSelection, value: try JSONValue.encode(selection)) + } catch { errorMessage = "Could not save this model." } + } + + private func load() async { + guard let project, project.supportsProjectSettingsOverrides == true else { return } + do { + preferences = try await model.client.projectPreferences(projectID: projectID) + errorMessage = nil + } catch { errorMessage = "Could not load project settings. Check this connection." } + } + + private func save(_ key: ServerProjectSettingKey, value: JSONValue?) { + busy = true + Task { + defer { busy = false } + do { + try await model.client.updateProjectPreferences( + projectID: projectID, change: .init(key: key, value: value) + ) + await load() + } catch { errorMessage = "Could not save project settings. 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/SettingsDiagnosticsView.swift b/apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift new file mode 100644 index 000000000000..fd825d8df0b3 --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct SettingsDiagnosticsView: View { + private let diagnostics = NativeDiagnostics.shared + @State private var confirmingClear = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Crash and slow-launch reports from iOS. Reports can arrive on a later launch. An empty list does not mean the app never crashed.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + if let error = diagnostics.storageError { + Text(error).foregroundStyle(T3Colors.danger) + } + if diagnostics.reports.isEmpty { + Text("No reports received.") + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(diagnostics.reports) { report in + NavigationLink { + DiagnosticReportView(report: report) + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(report.title) + .font(T3Typography.threadBody) + Text(report.periodEnd.formatted(date: .abbreviated, time: .shortened)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Divider().overlay(T3Colors.separator) + } + if !diagnostics.reports.isEmpty || diagnostics.storageError != nil { + Button("Clear saved reports", role: .destructive) { + confirmingClear = true + } + } + } + .padding(20) + } + .foregroundStyle(T3Colors.textPrimary) + .background(T3Colors.background) + .navigationTitle("Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .confirmationDialog("Clear saved reports?", isPresented: $confirmingClear) { + Button("Clear reports", role: .destructive) { diagnostics.clear() } + } message: { + Text("This removes up to five saved reports from this device.") + } + } +} + +private struct DiagnosticReportView: View { + let report: NativeDiagnosticReport + @State private var copied = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Reporting period: \(report.periodStart.formatted()) to \(report.periodEnd.formatted())") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + if let json = report.json { + Text("Review before sharing. Reports can include app exception details.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + HStack(spacing: 24) { + Button { + UIPasteboard.general.string = json + copied = true + } label: { + Label(copied ? "Copied" : "Copy report", systemImage: "doc.on.doc") + } + ShareLink(item: json) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Text(verbatim: json) + .font(T3Typography.code) + .textSelection(.enabled) + } else { + Text("This report exceeded the 256 KB limit. Only its reporting period was saved.") + .font(T3Typography.supporting) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(T3Colors.textPrimary) + .background(T3Colors.background) + .navigationTitle(report.title) + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsLicensesView.swift b/apps/swift-ios/Features/Settings/SettingsLicensesView.swift new file mode 100644 index 000000000000..be25fdbe0ce7 --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsLicensesView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct SettingsLicensesView: View { + @State private var licenses: [NativeLicense] = [] + @State private var search = "" + @State private var loadFailed = false + + private var filteredLicenses: [NativeLicense] { + let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty ? licenses : licenses.filter { + $0.name.localizedStandardContains(query) + } + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if loadFailed { + Text("Could not load bundled license notices.") + .foregroundStyle(T3Colors.danger) + .padding(20) + } else if !search.isEmpty, filteredLicenses.isEmpty { + Text("No matching dependencies.") + .foregroundStyle(T3Colors.textSecondary) + .padding(20) + } + ForEach(filteredLicenses) { license in + NavigationLink { + LicenseNoticeView(license: license) + } label: { + HStack(spacing: 12) { + Text(license.name) + .font(T3Typography.threadBody) + Spacer(minLength: 8) + Text(license.versionLabel) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Divider().overlay(T3Colors.separator).padding(.horizontal, 20) + } + } + .padding(.vertical, 8) + } + .foregroundStyle(T3Colors.textPrimary) + .background(T3Colors.background) + .searchable(text: $search, prompt: "Find a dependency") + .navigationTitle("Open source licenses") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .task { + do { + licenses = try NativeLicenseCatalog.load() + } catch { + loadFailed = true + } + } + } +} + +private struct LicenseNoticeView: View { + let license: NativeLicense + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text(license.version) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + ForEach(license.notices, id: \.sourceURL) { notice in + Link("Source", destination: notice.sourceURL) + Text(verbatim: notice.text) + .font(T3Typography.supporting) + .textSelection(.enabled) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(T3Colors.textPrimary) + .background(T3Colors.background) + .navigationTitle(license.name) + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift new file mode 100644 index 000000000000..3c13f2b30ac2 --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -0,0 +1,585 @@ +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 + supportSection + 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 { + ProjectsSettingsView(model: model) + } label: { + SettingsNavigationRow(title: "Projects", systemImage: "folder") + } + .buttonStyle(.plain) + .accessibilityIdentifier("settings-projects") + 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 supportSection: some View { + SettingsSection(title: "Support") { + NavigationLink { + SettingsDiagnosticsView() + } label: { + SettingsNavigationRow(title: "Diagnostics", systemImage: "stethoscope") + } + .buttonStyle(.plain) + .accessibilityIdentifier("settings-diagnostics") + } + } + + private var aboutSection: some View { + SettingsSection(title: "About", footer: "Version \(appVersionLabel)") { + VStack(spacing: 0) { + Link(destination: URL(string: "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/pingdotgg/t3code")!) { + SettingsNavigationRow( + title: "Source code", + systemImage: "chevron.left.forwardslash.chevron.right", + trailingSystemImage: "arrow.up.right" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Opens GitHub in your browser") + settingsDivider + NavigationLink { + SettingsLicensesView() + } label: { + SettingsNavigationRow(title: "Open source licenses", systemImage: "doc.text") + } + .buttonStyle(.plain) + .accessibilityIdentifier("settings-licenses") + } + } + } + + 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..a38ac4df470b --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift @@ -0,0 +1,23 @@ +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 + var environmentID: String? = nil + var wireThreadID: String? = nil + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.threadID == rhs.threadID && lhs.resolver === rhs.resolver + && lhs.environmentID == rhs.environmentID && lhs.wireThreadID == rhs.wireThreadID + } +} 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..5b791cca92cf --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -0,0 +1,572 @@ +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 + /// Creates the thread and sends its first turn as one idempotent command. + /// `identity` lets a retry after an ambiguous network failure reuse the + /// same command id so the server does not create a second thread. + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity, + context: OrchestrationMessageContext? + ) async throws -> FeatureThread + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] + func selectWorkspaceBranch( + projectID: String, branch: FeatureWorkspaceBranch, mode: FeatureWorkspaceMode + ) 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 + /// Commits a drag reorder: `orderedIDs` is the thread's displayed section + /// order after the drop. Returns the order-key assignments the server + /// confirmed; an empty result means the move was unavailable (capability, + /// connectivity, or an unwritable neighbor). + @discardableResult + func reorderThread( + id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) async throws -> [FeatureThreadOrderAssignment] + func setThreadPullRequest(id: String, url: String, linked: 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 + + /// `fresh` bypasses the client's warm cache and reads from the server. + func loadThread(id: String, fresh: Bool) async throws -> FeatureThreadDetail + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? + func releaseThread(id: String) + /// Sends one turn. `runtimeMode` is the mode that was active when the user + /// sent it, so a retry keeps the original permission level. + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity, + context: OrchestrationMessageContext? + ) async throws + func cancelTurn(threadID: String) async throws + func canRewindConversation(threadID: String, messageID: String) -> Bool + /// Returns only after provider history is rewound. Attachment bytes are copied first. + func rewindConversation( + threadID: String, messageID: String, + prepareRecovery: @MainActor (FeatureRevertedMessage) async throws -> Void + ) async throws + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws + func resolveUserInput( + id: String, answers: [String: FeatureInputAnswer], + attachmentsByQuestionID: [String: [FeatureUploadAttachment]] + ) async throws + func dismissUserInput(id: String) async throws + + func saveSettings(_ settings: FeatureSettings) async throws + func serverPreferences(environmentID: String) async throws -> ServerSettingsSnapshot + func updateServerPreferences(environmentID: String, change: ServerSettingsChange) async throws + func updateProjectPreferences(projectID: String, change: ServerProjectSettingChange) async throws + func projectPreferences(projectID: String) async throws -> FeatureProjectPreferences + func sharedPreferenceMismatches(environmentID: String) -> [String] + func gitHubRoutingPermission(environmentID: String) async throws -> GitHubRoutingPermission + func setGitHubRoutingPermission(environmentID: String, permission: GitHubRoutingPermission) async throws + 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 usageSummaryUpdates( + _ input: UsageSummaryInput, + refreshPricing: Bool + ) -> AsyncThrowingStream<[FeatureEnvironmentUsage], Error> + func usageLimitsUpdates() -> AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error> + func refreshUsageLimits() async throws -> [FeatureEnvironmentUsageLimits] + func consumeResetCredit( + environmentID: String, + input: ProviderConsumeResetCreditInput + ) 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 gitHubRoutingPermission(environmentID: String) async throws -> GitHubRoutingPermission { .off } + func setGitHubRoutingPermission(environmentID: String, permission: GitHubRoutingPermission) async throws { + throw FeatureCapabilityUnavailable("GitHub sharing") + } + func setThreadPullRequest(id: String, url: String, linked: Bool) async throws { + throw FeatureCapabilityUnavailable("Pull request linking") + } + func canRewindConversation(threadID: String, messageID: String) -> Bool { false } + + func rewindConversation( + threadID: String, messageID: String, + prepareRecovery: @MainActor (FeatureRevertedMessage) async throws -> Void + ) async throws { + throw FeatureCapabilityUnavailable("Conversation rewind") + } + + func serverPreferences(environmentID: String) async throws -> ServerSettingsSnapshot { + throw FeatureCapabilityUnavailable("Server preferences") + } + func updateServerPreferences(environmentID: String, change: ServerSettingsChange) async throws { + throw FeatureCapabilityUnavailable("Server preferences") + } + func updateProjectPreferences(projectID: String, change: ServerProjectSettingChange) async throws { + throw FeatureCapabilityUnavailable("Project preferences") + } + func projectPreferences(projectID: String) async throws -> FeatureProjectPreferences { + throw FeatureCapabilityUnavailable("Project preferences") + } + func sharedPreferenceMismatches(environmentID: String) -> [String] { [] } + + /// Warm-cache read. The requirement takes `fresh:`; this is the common call. + func loadThread(id: String) async throws -> FeatureThreadDetail { + try await loadThread(id: id, fresh: false) + } + + func usageSummaryUpdates( + _ input: UsageSummaryInput, + refreshPricing: Bool + ) -> AsyncThrowingStream<[FeatureEnvironmentUsage], Error> { + AsyncThrowingStream { $0.finish() } + } + + func usageLimitsUpdates() -> AsyncThrowingStream<[FeatureEnvironmentUsageLimits], Error> { + AsyncThrowingStream { $0.finish() } + } + + func refreshUsageLimits() async throws -> [FeatureEnvironmentUsageLimits] { [] } + + func consumeResetCredit( + environmentID: String, + instanceID: String + ) async throws -> ProviderConsumeResetCreditResult { + try await consumeResetCredit(environmentID: environmentID, input: .provider(instanceID: instanceID)) + } + + func consumeResetCredit( + environmentID: String, + input: ProviderConsumeResetCreditInput + ) 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 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 dismissUserInput(id: String) async throws { + throw FeatureCapabilityUnavailable("Question dismissal") + } + 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 reorderThread( + id: String, + section: FeatureThreadOrderSection, + orderedIDs: [String] + ) async throws -> [FeatureThreadOrderAssignment] { [] } + 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] { + [] + } + + func selectWorkspaceBranch( + projectID: String, branch: FeatureWorkspaceBranch, mode: FeatureWorkspaceMode + ) async throws -> FeatureWorkspaceBranch { + try await NewTaskWorkspaceDefaults.selectBranch(branch, mode: mode) { _ in + throw FeatureCapabilityUnavailable("Branch checkout") + } + } + + 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..45a23577a04f --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift @@ -0,0 +1,497 @@ +import Foundation + +public struct FeatureComposerDraft: Sendable, Equatable { + public var context: OrchestrationMessageContext? + 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, + context: OrchestrationMessageContext? = nil + ) { + self.text = text + self.attachments = attachments + self.selection = selection + self.workspace = workspace + self.context = context + } + + 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 + } +} + +enum FeatureQuestionAttachmentDraft { + static func key(inputID: String) -> String { "question-attachments:\(inputID)" } + + static func encode(_ attachments: [String: [FeatureDraftAttachment]]) throws -> FeatureComposerDraft { + let nonempty = attachments.filter { !$0.value.isEmpty } + guard !nonempty.isEmpty else { return FeatureComposerDraft() } + let data = try JSONEncoder().encode(nonempty.mapValues { $0.map(\.id) }) + return FeatureComposerDraft( + text: String(decoding: data, as: UTF8.self), + attachments: nonempty.keys.sorted().flatMap { nonempty[$0] ?? [] } + ) + } + + static func decode(_ draft: FeatureComposerDraft?) throws -> [String: [FeatureDraftAttachment]] { + guard let draft, !draft.isEmpty else { return [:] } + let ids = try JSONDecoder().decode([String: [UUID]].self, from: Data(draft.text.utf8)) + let byID = Dictionary(draft.attachments.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return ids.mapValues { $0.compactMap { byID[$0] } } + } +} + +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 context: OrchestrationMessageContext? + var text: String + var attachments: [PersistedAttachment] + var selection: FeatureSelection? + var workspace: PersistedWorkspace? + var importedShareIDs: [String]? + + init(_ draft: FeatureComposerDraft) { + text = draft.text + context = draft.context + 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, + context: context + ) + } + } + + 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 source: PastedTextAttachmentSource? + 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 + source = attachment.source + 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, + source: source + ) + } + guard let data else { return nil } + return FeatureDraftAttachment( + id: id, + data: data, + thumbnailData: thumbnailData, + filename: filename, + mimeType: mimeType, + uploadedReference: uploadedReference, + source: source + ) + } + + func hasSameContent(as attachment: FeatureDraftAttachment) -> Bool { + guard id == attachment.id, + filename == attachment.filename, + mimeType == attachment.mimeType, + source == attachment.source, + (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 clipboardAttachment(environmentID: String, attachmentID: String) throws -> FeatureDraftAttachment? { + for (key, draft) in try loadIfNeeded() { + for attachment in draft.attachments { + // Logical-project drafts move between environments. Their local UUID identifies + // the bytes. Server attachment IDs still require the matching upload environment. + let localMatch = (key.hasPrefix("environment:\(environmentID):") + || key.hasPrefix(Self.rewindRecoveryKey(for: "environment:\(environmentID):")) + || key.hasPrefix("logical-project:")) + && attachment.id.uuidString.caseInsensitiveCompare(attachmentID) == .orderedSame + let uploadedMatch = attachment.uploadedReference?.environmentID == environmentID + && attachment.uploadedReference?.attachmentID == attachmentID + if localMatch || uploadedMatch { return attachment.featureValue(fileStore: attachmentFileStore) } + } + } + return nil + } + + 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 static func rewindRecoveryKey(for threadKey: String) -> String { + "rewind-recovery:" + threadKey + } + + public func hasRewindRecovery(for threadKey: String) throws -> Bool { + try loadIfNeeded()[Self.rewindRecoveryKey(for: threadKey)] != nil + } + + /// Moving recovery into the composer is one disk write. A crash cannot + /// leave the same recovered message available to append a second time. + public func consumeRewindRecovery(for threadKey: String) throws -> FeatureComposerDraft? { + var drafts = try loadIfNeeded() + let recoveryKey = Self.rewindRecoveryKey(for: threadKey) + guard let savedRecovery = drafts[recoveryKey] else { return nil } + let recovery = savedRecovery.featureValue(fileStore: attachmentFileStore) + let current = drafts[threadKey]?.featureValue(fileStore: attachmentFileStore) ?? FeatureComposerDraft() + let files = (recovery.attachments + current.attachments).compactMap(\.ownedFile) + let filesAreReadable = files.allSatisfy { file in + guard FileManager.default.isReadableFile(atPath: file.url.path), + let attributes = try? file.url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) else { + return false + } + return attributes.isRegularFile == true && attributes.fileSize == file.byteCount + } + guard recovery.attachments.count == savedRecovery.attachments.count, + current.attachments.count == (drafts[threadKey]?.attachments.count ?? 0), + filesAreReadable else { + throw FeatureConversationRewindError(message: "Some saved attachments could not be read. The recovery copy is kept.") + } + guard current.attachments.count + recovery.attachments.count <= 8 else { + throw FeatureConversationRewindError(message: "Make room for the saved prompt's attachments before recovering it.") + } + let recovered = try FeatureConversationRewind.merge(recovery: recovery, into: current) + var persisted = PersistedDraft(recovered) + persisted.importedShareIDs = drafts[threadKey]?.importedShareIDs + drafts[threadKey] = persisted + drafts.removeValue(forKey: recoveryKey) + try persist(drafts) + loadedDrafts = drafts + return recovered + } + + /// Call only when the command was rejected or never sent. Recovery copies + /// have fresh IDs and are not shared with the outbox or existing drafts. + public func discardRewindRecovery(for threadKey: String) throws { + let recoveryKey = Self.rewindRecoveryKey(for: threadKey) + let attachments = try loadIfNeeded()[recoveryKey]?.attachments ?? [] + try removeDraft(for: recoveryKey) + for attachment in attachments { + if let fileName = attachment.ownedFileName { + try? attachmentFileStore.removeOwnedFile(fileName: fileName) + } + } + } + + public func removeDrafts( + environmentID: String, + logicalProjectIDs: Set = [] + ) throws { + var drafts = try loadIfNeeded() + let environmentPrefix = "environment:\(environmentID):" + let rewindPrefix = Self.rewindRecoveryKey(for: environmentPrefix) + let questionPrefix = FeatureQuestionAttachmentDraft.key( + inputID: FeatureScopedID.input(environmentID: environmentID, wireID: "") + ) + let logicalKeys = Set(logicalProjectIDs.map(Self.newTaskKey(logicalProjectID:))) + drafts = drafts.filter { + !$0.key.hasPrefix(environmentPrefix) && !$0.key.hasPrefix(rewindPrefix) + && !$0.key.hasPrefix(questionPrefix) + && !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/FeatureContextClipboard.swift b/apps/swift-ios/Features/Shared/FeatureContextClipboard.swift new file mode 100644 index 000000000000..3310c981a28a --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureContextClipboard.swift @@ -0,0 +1,94 @@ +import Foundation +import UIKit +import UniformTypeIdentifiers + +@MainActor +enum FeatureContextClipboard { + private static var retainedEnvironmentID: String? + private static var retainedAttachments: [FeatureDraftAttachment] = [] + + /// Keep local bytes in the app, not on the system clipboard. This also keeps cut attachments available. + static func write( + text: String, + source: ComposerContextClipboardFragment.Source?, + context: OrchestrationMessageContext?, + attachments: [FeatureDraftAttachment] = [], + pasteboard: UIPasteboard = .general + ) throws -> Bool { + guard !ComposerContextReferences.collect(text).isEmpty else { return false } + guard let source else { return false } + let content = try ComposerContextClipboard.selected( + text: text, fragment: .init(source: source, records: context?.records ?? []) + ) + let encoded = try ComposerContextClipboard.encode(content.fragment) + let ids = Set(content.fragment.records.compactMap { $0.attachment?.attachmentId.lowercased() }) + retainedEnvironmentID = source.environmentId + retainedAttachments = attachments.filter { + ids.contains($0.id.uuidString.lowercased()) + || ($0.uploadedReference?.environmentID == source.environmentId + && ids.contains($0.uploadedReference?.attachmentID.lowercased() ?? "")) + } + pasteboard.items = [[ + UTType.utf8PlainText.identifier: text, + ComposerContextClipboard.mimeType: Data(encoded.utf8), + UTType.html.identifier: Data(ComposerContextClipboard.html(text: text, fragment: encoded).utf8), + ]] + return true + } + + static func read(from pasteboard: UIPasteboard = .general) throws -> ComposerContextClipboard.Content? { + func string(for type: String) -> String? { + if let data = pasteboard.data(forPasteboardType: type) { return String(data: data, encoding: .utf8) } + return pasteboard.value(forPasteboardType: type) as? String + } + var fragment: ComposerContextClipboardFragment? + var decodingError: (any Error)? + if let raw = string(for: ComposerContextClipboard.mimeType) { + do { fragment = try ComposerContextClipboard.decode(raw) } + catch { decodingError = error } + } + if fragment == nil, let html = string(for: UTType.html.identifier) { + do { fragment = try ComposerContextClipboard.decodeHTML(html) } + catch { decodingError = error } + } + guard let fragment else { + if let decodingError { throw decodingError } + return nil + } + guard let text = pasteboard.string else { throw ComposerContextClipboardError.invalidFragment } + let content = try ComposerContextClipboard.selected(text: text, fragment: fragment) + return content.fragment.records.isEmpty ? nil : content + } + + static func retainedAttachment(environmentID: String, attachmentID: String) -> FeatureDraftAttachment? { + guard retainedEnvironmentID == environmentID else { return nil } + return retainedAttachments.first { + $0.id.uuidString.caseInsensitiveCompare(attachmentID) == .orderedSame + || ($0.uploadedReference?.environmentID == environmentID + && $0.uploadedReference?.attachmentID == attachmentID) + } + } + + /// UIKit history renders links as labels. Rebuild only the selected link spans without changing the selection. + static func selectionText(_ selected: NSAttributedString, originalSource: String) -> String { + let result = NSMutableString() + let references = ComposerContextReferences.collect(originalSource) + selected.enumerateAttribute(.link, in: NSRange(location: 0, length: selected.length)) { value, range, _ in + let part = selected.attributedSubstring(from: range) + let text = FeatureInlineSkillProjection.plainText(from: part) + let href = (value as? URL)?.absoluteString ?? value as? String + guard let href, let identity = ComposerContextReferences.parseHref(href) else { + result.append(text) + return + } + if let original = references.first(where: { + $0.contextId == identity.contextId && $0.kind == identity.kind && $0.label == text + }) { + result.append((originalSource as NSString).substring(with: original.range)) + } else { + result.append("[\(ComposerContextReferences.sanitizeLabel(text, kind: identity.kind))](\(href))") + } + } + return result as String + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureContextClipboardImporter.swift b/apps/swift-ios/Features/Shared/FeatureContextClipboardImporter.swift new file mode 100644 index 000000000000..34122aea6912 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureContextClipboardImporter.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Resolve in the source environment. Its attachment IDs never enter the destination upload queue. +@MainActor +public protocol FeatureContextAttachmentResolving: AnyObject { + func contextAttachmentAssetURL( + environmentID: String, + attachment: ComposerContextRecord.Attachment + ) async throws -> URL +} + +@MainActor +struct FeatureContextClipboardImporter { + struct Result { + let text: String + let context: OrchestrationMessageContext + let attachments: [FeatureDraftAttachment] + + func discardFiles(using store: ManagedAttachmentFileStore) { + for attachment in attachments { + if let file = attachment.ownedFile { try? store.removeOwnedFile(fileName: file.fileName) } + } + } + } + + var fileStore = ManagedAttachmentFileStore() + var draftStore = FeatureComposerDraftStore.shared + var outboxStore = FeatureOutboxStore.shared + var resolver: (any FeatureContextAttachmentResolving)? + var download: (URL) async throws -> (URL, URLResponse) = { + try await URLSession.shared.download(for: URLRequest(url: $0, timeoutInterval: 30)) + } + + func importContent( + _ content: ComposerContextClipboard.Content, + attachmentCount: Int, + contextCount: Int, + imagesAllowed: Bool, + maximumFileBytes: Int? + ) async throws -> Result { + let content = try ComposerContextClipboard.reidentified(content) + guard contextCount + content.fragment.records.count <= ComposerContextClipboard.maximumRecords else { + throw ComposerContextClipboardError.contextLimit + } + let bindings = content.fragment.records.compactMap(\.attachment) + guard attachmentCount + Set(bindings.map(\.attachmentId)).count <= FeatureImageAttachmentLimits.maximumCount else { + throw FileAttachmentError.tooMany(maximum: FeatureImageAttachmentLimits.maximumCount) + } + var imported: [String: FeatureDraftAttachment] = [:] + var ordered: [FeatureDraftAttachment] = [] + do { + for record in content.fragment.records { + try Task.checkCancellation() + guard let binding = record.attachment else { continue } + let maximum: Int + if record.kind == "image" { + guard imagesAllowed else { throw ImageAttachmentError.invalidMIMEType } + maximum = UploadChatAttachment.maximumBytes + } else { + guard let maximumFileBytes, maximumFileBytes > 0 else { throw FileAttachmentError.unsupported } + maximum = min(maximumFileBytes, ManagedAttachmentFileStore.maximumBytes) + } + if let previous = imported[binding.attachmentId] { + guard previous.filename == binding.name, + previous.mimeType == binding.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + throw ComposerContextClipboardError.invalidFragment + } + continue + } + guard binding.sizeBytes >= 0, binding.sizeBytes <= maximum else { + throw FileAttachmentError.tooLarge(actualBytes: binding.sizeBytes, maximumBytes: maximum) + } + let attachment = try await importAttachment( + binding, environmentID: content.fragment.source.environmentId, maximumBytes: maximum + ) + imported[binding.attachmentId] = attachment + ordered.append(attachment) + } + try Task.checkCancellation() + let rebound = ComposerContextReferences.rebind( + .init(records: content.fragment.records), + attachmentIDs: imported.mapValues { $0.id.uuidString } + )! + return Result(text: content.text, context: rebound, attachments: ordered) + } catch { + Result(text: "", context: .init(records: []), attachments: ordered).discardFiles(using: fileStore) + throw error + } + } + + private func importAttachment( + _ binding: ComposerContextRecord.Attachment, + environmentID: String, + maximumBytes: Int + ) async throws -> FeatureDraftAttachment { + let binding = ComposerContextRecord.Attachment( + attachmentId: binding.attachmentId, name: binding.name, + mimeType: binding.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), sizeBytes: binding.sizeBytes + ) + let retained = FeatureContextClipboard.retainedAttachment(environmentID: environmentID, attachmentID: binding.attachmentId) + var local = if let retained { retained } else { + try await draftStore.clipboardAttachment(environmentID: environmentID, attachmentID: binding.attachmentId) + } + if local == nil { + let queued = try await outboxStore.submissions().filter { $0.environmentID == environmentID } + .flatMap(\.uploads).first { + $0.id.uuidString.caseInsensitiveCompare(binding.attachmentId) == .orderedSame + || ($0.uploadedReference?.environmentID == environmentID + && $0.uploadedReference?.attachmentID == binding.attachmentId) + } + if let queued { + if let file = queued.ownedFile { + local = FeatureDraftAttachment(id: queued.id, ownedFile: file, filename: queued.name, mimeType: queued.mimeType, source: queued.source) + } else { + local = FeatureDraftAttachment(id: queued.id, data: queued.data, filename: queued.name, mimeType: queued.mimeType, source: queued.source) + } + } + } + try Task.checkCancellation() + if let local { + guard local.filename == binding.name, + local.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == binding.mimeType else { + throw ComposerContextClipboardError.invalidFragment + } + if let file = local.ownedFile, FileManager.default.fileExists(atPath: file.url.path) { + return try await copyFile(file.url, binding: binding, maximumBytes: maximumBytes, source: local.source) + } + if local.ownedFile == nil, !local.data.isEmpty { + guard local.byteCount <= maximumBytes else { + throw FileAttachmentError.tooLarge(actualBytes: local.byteCount, maximumBytes: maximumBytes) + } + if !binding.mimeType.hasPrefix("image/") { + let id = UUID() + let store = fileStore + let data = local.data + let file = try await Task.detached(priority: .userInitiated) { + try store.writeOwnedFile(data: data, attachmentID: id, originalFileName: binding.name, maximumBytes: maximumBytes) + }.value + return FeatureDraftAttachment(id: id, ownedFile: file, filename: binding.name, mimeType: binding.mimeType, source: local.source) + } + return FeatureDraftAttachment( + data: local.data, thumbnailData: local.thumbnailData, + filename: binding.name, mimeType: binding.mimeType, source: local.source + ) + } + } + guard let resolver else { throw ComposerContextClipboardError.sourceUnavailable } + let url = try await resolver.contextAttachmentAssetURL(environmentID: environmentID, attachment: binding) + try Task.checkCancellation() + let (temporary, response) = try await download(url) + defer { try? FileManager.default.removeItem(at: temporary) } + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { throw FeatureMediaPreviewError.invalidResponse } + guard (200 ... 299).contains(response.statusCode) else { throw FeatureMediaPreviewError.httpStatus(response.statusCode) } + return try await copyFile(temporary, binding: binding, maximumBytes: maximumBytes, source: local?.source) + } + + private func copyFile( + _ url: URL, binding: ComposerContextRecord.Attachment, + maximumBytes: Int, source: PastedTextAttachmentSource? + ) async throws -> FeatureDraftAttachment { + let id = UUID() + let store = fileStore + let (file, thumbnail) = try await Task.detached(priority: .userInitiated) { + let file = try store.copyOwnedFile(from: url, attachmentID: id, originalFileName: binding.name, maximumBytes: maximumBytes) + let thumbnail = binding.mimeType.hasPrefix("image/") ? FeatureImageProcessor.thumbnail(fileURL: file.url) : nil + return (file, thumbnail) + }.value + // Ownership passes to the caller even after cancellation, so its rollback removes this copy. + return FeatureDraftAttachment(id: id, ownedFile: file, thumbnailData: thumbnail, filename: binding.name, mimeType: binding.mimeType, source: source) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureConversationRewind.swift b/apps/swift-ios/Features/Shared/FeatureConversationRewind.swift new file mode 100644 index 000000000000..1683fbd34e32 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureConversationRewind.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct FeatureRevertedMessage: Sendable { + public let message: FeatureMessage + public let attachments: [FeatureDraftAttachment] + + public init(message: FeatureMessage, attachments: [FeatureDraftAttachment]) { + self.message = message + self.attachments = attachments + } +} + +struct FeatureConversationRewindError: LocalizedError { + let message: String + var didNotRevert = false + var errorDescription: String? { message } +} + +enum FeatureConversationRewind { + static func canStart(in detail: FeatureThreadDetail) -> Bool { + switch detail.thread.state { + case .idle, .completed, .failed: + detail.isCompacting != true && !detail.backgroundWorkIsActive + && detail.approvals.isEmpty && detail.userInputs.isEmpty + default: + false + } + } + + /// Append recovered input without changing the current model or workspace. + static func recover(_ reverted: FeatureRevertedMessage, draft: FeatureComposerDraft) throws -> FeatureComposerDraft { + let original = reverted.message.text + let prompt = !reverted.attachments.isEmpty + && original == "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]" + ? "" : original + // Copies keep the original attachment order but receive fresh local IDs. + // Keep the context links stable and point their records at those copies. + let attachmentIDs = Dictionary(zip(reverted.message.attachments, reverted.attachments).map { original, copy in + (original.id, copy.id.uuidString) + }, uniquingKeysWith: { first, _ in first }) + let context = ComposerContextReferences.rebind(reverted.message.context, attachmentIDs: attachmentIDs) + return try merge( + recovery: FeatureComposerDraft(text: prompt, attachments: reverted.attachments, context: context), + into: draft + ) + } + + static func merge(recovery: FeatureComposerDraft, into draft: FeatureComposerDraft) throws -> FeatureComposerDraft { + var recovered = draft + if !recovery.text.isEmpty { + recovered.text = draft.text.isEmpty ? recovery.text : draft.text + "\n\n" + recovery.text + } + recovered.attachments.append(contentsOf: recovery.attachments) + recovered.context = try FeatureComposerContext.merge(draft.context, recovery.context) + return recovered + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift new file mode 100644 index 000000000000..930ce76dfe65 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -0,0 +1,1407 @@ +import Foundation + +public struct FeatureConnection: Sendable, Equatable, Codable { + public enum State: String, Sendable, Hashable, Codable { + case disconnected + case connecting + case connected + case reconnecting + /// The server rejected the saved credential (expired, revoked, or + /// restored onto a new device). Retrying cannot fix it; only pairing + /// again can. + case needsPairing + } + + 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 var defaultWorkspaceMode: FeatureWorkspaceMode? = nil + public var newWorktreesStartFromOrigin: Bool? = nil + public var supportsProjectSettingsOverrides: Bool? = 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 pullRequests: [ThreadPullRequestLink]? + public var branchPullRequest: ThreadLinkedPullRequest? + public var createdAt: Date + public var updatedAt: Date + public var state: FeatureThreadState + public var providerID: String? + public var sessionProviderID: 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? + /// User-arranged position in the pinned block. Shared with web and + /// React Native; keyless pins sort after keyed ones by creation date. + public var pinOrderKey: String? + public var supportsSettlement: Bool? + public var supportsSnooze: Bool? + public var supportsPinning: Bool? + public var supportsPinReorder: Bool? + public var supportsActiveReorder: Bool? + public var supportsTitleRegeneration: Bool? + public var supportsPullRequestLinking: Bool? + public var supportsMultiplePullRequests: Bool? + /// True while the server is generating a new title. Derived from the wire + /// snapshot only, the same way the web and React Native clients do it. + public var isRegeneratingTitle: 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, + pullRequests: [ThreadPullRequestLink]? = nil, + branchPullRequest: ThreadLinkedPullRequest? = nil, + createdAt: Date = .now, + updatedAt: Date = .now, + state: FeatureThreadState = .idle, + providerID: String? = nil, + sessionProviderID: 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, + pinOrderKey: String? = nil, + supportsSettlement: Bool? = nil, + supportsSnooze: Bool? = nil, + supportsPinning: Bool? = nil, + supportsPinReorder: Bool? = nil, + supportsActiveReorder: Bool? = nil, + supportsTitleRegeneration: Bool? = nil, + supportsPullRequestLinking: Bool? = nil, + supportsMultiplePullRequests: Bool? = nil, + isRegeneratingTitle: Bool = false, + 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.pullRequests = pullRequests + self.branchPullRequest = branchPullRequest + self.createdAt = createdAt + self.updatedAt = updatedAt + self.state = state + self.providerID = providerID + self.sessionProviderID = sessionProviderID + 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.pinOrderKey = pinOrderKey + self.supportsSettlement = supportsSettlement + self.supportsSnooze = supportsSnooze + self.supportsPinning = supportsPinning + self.supportsPinReorder = supportsPinReorder + self.supportsActiveReorder = supportsActiveReorder + self.supportsTitleRegeneration = supportsTitleRegeneration + self.supportsPullRequestLinking = supportsPullRequestLinking + self.supportsMultiplePullRequests = supportsMultiplePullRequests + self.isRegeneratingTitle = isRegeneratingTitle + self.attentionAt = attentionAt + self.workingStartedAt = workingStartedAt + self.latestTurnCompletedAt = latestTurnCompletedAt + self.settlementFacts = settlementFacts + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + } + + public var effectivePullRequest: ThreadLinkedPullRequest? { + if let pullRequests, !pullRequests.isEmpty { + return ThreadPullRequests.current(pullRequests).map { + ThreadLinkedPullRequest(projectId: projectID, repository: $0.repository, + number: $0.number, url: $0.url) + } + } + return 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 + } + + /// The server refuses to archive a thread with live work. Hide the action + /// on those rows instead of offering it and then reporting the refusal. + public var canArchive: Bool { + switch state { + case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: + false + case .idle, .completed, .failed: + true + } + } + + /// Mirrors the server's `thread.snooze` guard and client-runtime + /// `canSnooze`: a raised hand or a turn that is still being adopted blocks + /// snooze. The session lifecycle state on its own does not. + public func canSnoozeNow(at now: Date) -> Bool { + guard canToggleSnooze else { return false } + if state == .waitingForApproval || state == .waitingForInput { return false } + return !hasQueuedTurnStart(at: now) + } + +} + +/// The thread-list section a drag reorder arranges. Pinned threads write +/// `pinOrderKey`; active threads write `activeOrderKey`. +public enum FeatureThreadOrderSection: String, Sendable, Equatable { + case pinned + case active +} + +/// One `orderKey` write needed to realize a move. A move between keyed +/// neighbors produces a single assignment on the moved thread; a move next to +/// keyless threads rewrites the whole section (see ThreadOrderPlanner). +public struct FeatureThreadOrderAssignment: Sendable, Equatable { + /// Feature-scoped thread id (`FeatureScopedID.thread`), so assignments can + /// span environments. + public let threadID: String + public let orderKey: String + + public init(threadID: String, orderKey: String) { + self.threadID = threadID + self.orderKey = orderKey + } +} + +/// A spread rewrite that wrote some assignments before a later environment +/// rejected its write. Callers apply `confirmed` — those rows are arranged on +/// their servers — and surface `underlying` as the move's failure. +public struct FeatureThreadMovePartialError: Error, Sendable { + public let confirmed: [FeatureThreadOrderAssignment] + public let underlying: Error + + public init(confirmed: [FeatureThreadOrderAssignment], underlying: Error) { + self.confirmed = confirmed + self.underlying = underlying + } +} + +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 var source: PastedTextAttachmentSource? + 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, + source: PastedTextAttachmentSource? = nil + ) { + self.id = id + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.url = url + self.previewData = previewData + self.source = source + } +} + +public struct FeatureUploadAttachment: Sendable, Equatable { + public var source: PastedTextAttachmentSource? + 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, + source: PastedTextAttachmentSource? = nil + ) { + self.id = id + inlineData = data + ownedFile = nil + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + self.source = source + } + + public init( + id: UUID = UUID(), + ownedFile: FeatureOwnedAttachmentFile, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil + ) { + self.id = id + inlineData = nil + self.ownedFile = ownedFile + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + self.source = source + } + + 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 + source = draft.source + } + + 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 var context: OrchestrationMessageContext? + 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, + context: OrchestrationMessageContext? = 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 + self.context = context + } +} + +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 var allowCustomAnswer: Bool? = nil + + public var canWriteCustomAnswer: Bool { allowCustomAnswer != false } + + 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] + /// Only message-based questions can close without a provider callback. + public var dismissible: Bool? = nil + public var supportsAttachments: Bool? = nil + + public var canDismiss: Bool { dismissible == true } + + 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 + public var accentColor: 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 FeatureProjectPreferences: Sendable { + public let environment: ServerSettingsSnapshot + public let effective: ServerSettingsSnapshot +} + +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) + /// Active environment connection change. `environmentID` lets the model + /// patch that environment's row without installing a whole snapshot. + case connection(FeatureConnection, environmentID: String? = nil) + 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..11f125460a90 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift @@ -0,0 +1,323 @@ +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 source: PastedTextAttachmentSource? + 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, + source: PastedTextAttachmentSource? = nil + ) { + self.id = id + self.data = data + ownedFileName = nil + byteCount = data.count + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + self.source = source + resolvedOwnedFile = nil + } + + init(_ attachment: FeatureUploadAttachment) { + id = attachment.id + source = attachment.source + 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 + case source + } + + 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) + source = try container.decodeIfPresent(PastedTextAttachmentSource.self, forKey: .source) + 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(source, forKey: .source) + 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, + source: source + ) + } + guard let data else { return nil } + return FeatureUploadAttachment( + id: id, + data: data, + name: name, + mimeType: mimeType, + uploadedReference: uploadedReference, + source: source + ) + } +} + +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 var context: OrchestrationMessageContext? + 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, + context: OrchestrationMessageContext? = 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 + self.context = context + } + + 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..e7b9d68bac00 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -0,0 +1,1074 @@ +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 var isIgnored: Bool + + public init( + path: String, + name: String, + kind: FeatureFileKind, + sizeBytes: Int? = nil, + isHidden: Bool = false, + isIgnored: Bool = false + ) { + self.path = path + self.name = name + self.kind = kind + self.sizeBytes = sizeBytes + self.isHidden = isHidden + self.isIgnored = isIgnored + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + path = try values.decode(String.self, forKey: .path) + name = try values.decode(String.self, forKey: .name) + kind = try values.decode(FeatureFileKind.self, forKey: .kind) + sizeBytes = try values.decodeIfPresent(Int.self, forKey: .sizeBytes) + isHidden = try values.decode(Bool.self, forKey: .isHidden) + isIgnored = try values.decodeIfPresent(Bool.self, forKey: .isIgnored) ?? false + } +} + +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 func submissionText(contextRecord: ComposerContextRecord) -> String { + let reference = ComposerContextReferences.format(contextRecord) + // The record has a smaller bound than a message. Keep the full comment + // in ordinary text when it does not fit instead of discarding instructions. + return body.utf16.count > 16_000 + ? prompt + "\n\n" + reference + : "Address this review comment: " + reference + } + + public func contextRecord(lines: [FeatureDiffLine]) -> ComposerContextRecord { + let range = line.map { "\($0.side.rawValue) line \($0.line)" } ?? "File" + let selectedIndex = line.flatMap { selected in + lines.firstIndex { + selected.side == .new ? $0.newLine == selected.line : $0.oldLine == selected.line + } + } ?? 0 + func formatted(_ index: Int) -> String { + let line = lines[index] + let prefix = switch line.kind { + case .addition: "+" + case .deletion: "-" + case .context: " " + case .hunk: "" + } + return prefix + line.text + } + // Build outward from the selected row so a large file never clips away + // the code the comment is about. Indices still refer to the full diff. + var lower = min(selectedIndex, max(0, lines.count - 1)) + var upper = min(lines.count, lower + 1) + let selectedText = lines.isEmpty ? "" : ComposerContextReferences.boundedPrefix(formatted(lower), maximumUTF16: 32_000) + var remaining = 32_000 - selectedText.utf16.count + while remaining > 0, lower > 0 || upper < lines.count { + var added = false + if lower > 0, formatted(lower - 1).utf16.count + 1 <= remaining { + lower -= 1 + remaining -= formatted(lower).utf16.count + 1 + added = true + } + if upper < lines.count, formatted(upper).utf16.count + 1 <= remaining { + remaining -= formatted(upper).utf16.count + 1 + upper += 1 + added = true + } + if !added { break } + } + return ComposerContextRecord(label: "\(filePath) \(range)", payload: .reviewComment(.init( + sectionId: "working-tree", sectionTitle: "Working changes", filePath: filePath, + startIndex: selectedIndex, endIndex: selectedIndex, + rangeLabel: range, text: ComposerContextReferences.boundedPrefix(body, maximumUTF16: 16_000), + diff: (lower.. [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..0688cc75dbc9 --- /dev/null +++ b/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift @@ -0,0 +1,192 @@ +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 = URL(fileURLWithPath: rootURL.standardizedFileURL.path, isDirectory: true) + } 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 writeOwnedFile( + data: Data, + attachmentID: UUID, + originalFileName: String, + maximumBytes: Int = Self.maximumBytes + ) throws -> FeatureOwnedAttachmentFile { + guard !data.isEmpty else { throw ManagedAttachmentFileError.empty } + let limit = min(Self.maximumBytes, max(0, maximumBytes)) + guard data.count <= limit else { + throw ManagedAttachmentFileError.tooLarge(actualBytes: data.count, maximumBytes: limit) + } + let fileName = try Self.ownedFileName(attachmentID: attachmentID, originalFileName: originalFileName) + let destination = try resolvedFile(fileName: fileName, byteCount: data.count).url + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw ManagedAttachmentFileError.alreadyExists + } + try data.write(to: destination, options: .atomic) + return FeatureOwnedAttachmentFile(fileName: fileName, url: destination, byteCount: data.count) + } + + 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..d3070a6f34d0 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -0,0 +1,681 @@ +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 + let onAttachContext: ((ComposerContextRecord) throws -> Void)? + + @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 isLoading = true + @State private var isOpening = false + @State private var errorMessage: String? + @State private var inputSession = TerminalInputSession() + + public init(client: any FeatureClient, threadID: String, onAttachContext: ((ComposerContextRecord) throws -> Void)? = nil) { + self.client = client + self.threadID = threadID + self.onAttachContext = onAttachContext + } + + 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) + }, + onAttachOutput: onAttachContext.map { attach in + { output in + guard !output.isEmpty else { return } + do { + try attach(FeatureComposerContext.terminalRecord( + text: output, terminalID: activeTerminalID, + label: terminal.map(TerminalSessionList.displayTitle) ?? "Terminal" + )) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + } + ) + .id(terminalTaskID) + .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) + guard updateTerminal(update) else { continue } + 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 = "" + } + 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..c7d63dcaa35b --- /dev/null +++ b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift @@ -0,0 +1,1385 @@ +import GhosttyKit +import QuartzCore +import SwiftUI +import UIKit + +/// Ghostty's worker threads wake the host to drain its bounded app mailbox. +/// Coalesce wakeups without polling or touching the native app off the main thread. +private final class GhosttyAppEventLoop: @unchecked Sendable { + private let lock = NSLock() + private var scheduled = false + @MainActor var app: ghostty_app_t? + + @MainActor init() {} + + func wakeup() { + lock.lock() + guard !scheduled else { lock.unlock(); return } + scheduled = true + lock.unlock() + DispatchQueue.main.async { [self] in + lock.lock() + scheduled = false + lock.unlock() + if let app { ghostty_app_tick(app) } + } + } +} + +private final class GhosttyRetirementFence: @unchecked Sendable { + private let lock = NSLock() + private var delivered = false + private let completion: @MainActor () -> Void + + init(completion: @escaping @MainActor () -> Void) { self.completion = completion } + + func complete() { + lock.lock() + guard !delivered else { lock.unlock(); return } + delivered = true + lock.unlock() + DispatchQueue.main.async { [self] in + completion() + // The native callback owns a retain until the I/O thread stops. + Unmanaged.passUnretained(self).release() + } + } +} + +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 + var onAttachOutput: ((String) -> Void)? = nil + + 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.onAttachOutput = onAttachOutput + 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) + } + } +} + +/// Ghostty accepts appended bytes. Compare UTF-8 directly so unchanged buffers +/// do not require a walk over Swift characters. A trimmed or replaced snapshot +/// resets terminal content without rebuilding its native surface. +enum TerminalBufferDelta: Equatable { + case append(Data) + case replace(Data) + + struct Applied: Equatable { + private(set) var bytes = Data() + + init() {} + + init(buffer: String) { + bytes = Data(buffer.utf8) + } + + mutating func apply(_ delta: TerminalBufferDelta) { + switch delta { + case .append(let data): bytes.append(data) + case .replace(let data): bytes = data + } + } + } + + static func compute(previous: Applied, next: String) -> TerminalBufferDelta { + var next = next + return next.withUTF8 { bytes in + guard !previous.bytes.isEmpty else { return .append(Data(buffer: bytes)) } + return previous.bytes.withUnsafeBytes { oldBytes in + guard bytes.count >= oldBytes.count, + let oldBase = oldBytes.baseAddress, + let nextBase = bytes.baseAddress, + memcmp(oldBase, nextBase, oldBytes.count) == 0 else { + return .replace(Data(buffer: bytes)) + } + return .append(Data(buffer: UnsafeBufferPointer(rebasing: bytes[oldBytes.count...]))) + } + } + } +} + +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 = #000000 + foreground = #adadb1 + cursor-color = #009fff + cursor-text = #000000 + 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 + """ + /// Full reset, drop scrollback, clear, home. Puts an existing surface in + /// the same state as a new one without rebuilding it. + private static let resetSequence = Data("\u{1B}c\u{1B}[3J\u{1B}[2J\u{1B}[H".utf8) + + var onInput: ((String) -> Void)? + var onPaste: ((String) -> Void)? + var onResize: ((Int, Int) -> Void)? + var onClear: (() -> Void)? + var onFontSizeStep: ((Int) -> Void)? + var onAttachOutput: ((String) -> Void)? + + var isDarkMode = true { + didSet { + guard oldValue != isDarkMode else { return } + applyChromeAppearance() + refreshSurfaceIfCreated() + } + } + + 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 applyRemoteBuffer(buffer), UIAccessibility.isVoiceOverRunning else { return } + 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) + refreshSurfaceIfCreated() + } + } + + 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 appliedBuffer = TerminalBufferDelta.Applied() + private var isReplayingBuffer = false + private var pendingVerticalScrollPoints: CGFloat = 0 + private var hasAutoFocused = false + private var app: ghostty_app_t? + private var eventLoop: GhosttyAppEventLoop? + private var surface: ghostty_surface_t? + private var isCreatingSurface = false + private var surfaceCreationFailed = false + private var isTornDown = false + private var teardownTask: Task? + + 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() + if let surface { ghostty_surface_set_occlusion(surface, window != nil) } + 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?() + } + var actions = [copy, paste, clear] + if self.onAttachOutput != nil { + actions.insert(UIAction(title: "Add output to message", image: UIImage(systemName: "text.bubble")) { [weak self] _ in + guard let self else { return } + self.onAttachOutput?(TerminalText.plainText(from: self.buffer)) + }, at: 1) + } + return UIMenu(children: actions) + } + } + + private func createSurfaceIfPossible() { + guard !isTornDown, 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 } + + let eventLoop = GhosttyAppEventLoop() + var runtimeConfig = ghostty_runtime_config_s( + userdata: Unmanaged.passUnretained(eventLoop).toOpaque(), + supports_selection_clipboard: false, + wakeup_cb: { userdata in + guard let userdata else { return } + Unmanaged.fromOpaque(userdata).takeUnretainedValue().wakeup() + }, + action_cb: { _, target, action in + // App ticks deliver render requests after queued output is parsed. + guard action.tag == GHOSTTY_ACTION_RENDER, + target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface else { return false } + ghostty_surface_draw(surface) + return true + }, + 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 + eventLoop.app = createdApp + self.eventLoop = eventLoop + 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) + if inputField.isFirstResponder { ghostty_surface_set_focus(createdSurface, true) } + setupWriteCallback() + resizeSurface() + feedBuffer(buffer) + } + + private func resetSurface() { + destroySurface() + appliedBuffer = TerminalBufferDelta.Applied() + lastViewportSize = .zero + lastContentScale = 0 + lastReportedGrid = nil + surfaceCreationFailed = false + setNeedsLayout() + } + + private func applyChromeAppearance() { + let background = T3Colors.uiBackground.resolvedColor( + with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) + ) + 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 + } + + /// Ghostty reads font size and theme when a surface is created, so those + /// changes rebuild the surface. Before the first layout there is nothing to + /// rebuild, and creation picks up the current values. + private func refreshSurfaceIfCreated() { + guard surface != nil else { return } + resetSurface() + createSurfaceIfPossible() + } + + @discardableResult + func tearDown() -> Task? { + guard !isTornDown else { return teardownTask } + isTornDown = true + onInput = nil + onPaste = nil + onResize = nil + onClear = nil + onFontSizeStep = nil + onAttachOutput = nil + inputField.resignFirstResponder() + teardownTask = destroySurface() + return teardownTask + } + + @discardableResult + private func destroySurface() -> Task? { + let surface = self.surface + let app = self.app + let eventLoop = self.eventLoop + guard surface != nil || app != nil else { return nil } + self.surface = nil + self.app = nil + self.eventLoop = nil + // Remove the renderer's display callbacks from the view before a + // dismissal layout or keyboard callback can try to use it again. + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + if let surface { + ghostty_surface_set_focus(surface, false) + ghostty_surface_set_occlusion(surface, false) + } + return Task { @MainActor [self] in + if let surface { + await withCheckedContinuation { continuation in + let fence = GhosttyRetirementFence { + eventLoop?.app = nil + ghostty_surface_free(surface) + if let app { ghostty_app_free(app) } + continuation.resume() + } + // Callback changes and writes use the same FIFO as feed_data. + // A write to this replacement callback proves all earlier output + // was parsed. Swallow it here: it must never reach the remote PTY. + // Keep ticking the app while waiting, or its bounded mailbox can + // block the I/O thread that surface_free needs to join. + ghostty_surface_set_write_callback(surface, { userdata, _, _ in + guard let userdata else { return } + Unmanaged.fromOpaque(userdata).takeUnretainedValue().complete() + }, Unmanaged.passRetained(fence).toOpaque()) + " ".withCString { ghostty_surface_text(surface, $0, 1) } + } + } else { + eventLoop?.app = nil + if let app { ghostty_app_free(app) } + } + withExtendedLifetime((self, eventLoop)) {} + } + } + + /// Feeds the surface whatever `newBuffer` adds on top of what it already + /// shows. Returns false when nothing changed. + @discardableResult + private func applyRemoteBuffer(_ newBuffer: String) -> Bool { + guard surface != nil else { + createSurfaceIfPossible() + return true + } + let delta = TerminalBufferDelta.compute(previous: appliedBuffer, next: newBuffer) + appliedBuffer.apply(delta) + switch delta { + case .append(let data): + guard !data.isEmpty else { return false } + feedData(data) + case .replace(let data): + replay { + feedData(Self.resetSequence) + feedData(data) + } + } + return true + } + + /// Feeds the whole buffer into a fresh surface. + private func feedBuffer(_ value: String) { + appliedBuffer = TerminalBufferDelta.Applied(buffer: value) + guard !value.isEmpty else { return } + var value = value + replay { value.withUTF8 { feedBytes($0) } } + } + + /// Replayed history must not echo terminal query replies back to the host. + private func replay(_ body: () -> Void) { + isReplayingBuffer = true + defer { isReplayingBuffer = false } + body() + } + + private func feedData(_ data: Data) { + data.withUnsafeBytes { feedBytes($0.bindMemory(to: UInt8.self)) } + } + + private func feedBytes(_ bytes: UnsafeBufferPointer) { + guard let surface, let pointer = bytes.baseAddress, !bytes.isEmpty 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 { 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() + } + + /// Reports the grid only once the surface has measured it, so the host + /// never sees a guessed size followed by the real one. + private func emitGhosttyResize() { + guard let surface else { return } + let size = ghostty_surface_size(surface) + emitResize(columns: max(1, Int(size.columns)), rows: max(1, Int(size.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 !isTornDown, 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/UsageLimitPools.swift b/apps/swift-ios/Features/Usage/UsageLimitPools.swift new file mode 100644 index 000000000000..0e4e924b6040 --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageLimitPools.swift @@ -0,0 +1,261 @@ +import Foundation + +struct UsageLimitAccount: Identifiable, Equatable, Sendable { + struct Environment: Equatable, Sendable { + let id: String + let label: String + } + + struct Redemption: Equatable, Sendable { + let environmentID: String + let input: ProviderConsumeResetCreditInput + let isConnected: Bool + + var target: UsageResetCreditTarget { + let account: UsageResetCreditTarget.Account = switch input { + case let .provider(instanceID): .provider(instanceID) + case let .source(sourceID, accountID, _): .source(sourceID: sourceID, accountID: accountID) + } + return UsageResetCreditTarget(environmentID: environmentID, account: account) + } + } + + let id: String + let driver: String + var displayName: String? + let email: String? + var plan: String? + var environments: [Environment] + var sourceLabel: String? + var redeem: Redemption? + var limits: ServerProviderUsageLimits + + var label: String { displayName ?? email ?? UsageLimitsPresentation.providerLabel(driver: driver) } + var locationLabel: String { environments.isEmpty ? sourceLabel ?? "" : environments.map(\.label).joined(separator: ", ") } +} + +struct UsageLimitPool: Identifiable, Equatable, Sendable { + let driver: String + let accounts: [UsageLimitAccount] + let windows: [UsageLimitPoolWindow] + var id: String { driver } +} + +struct UsageLimitPoolWindow: Identifiable, Equatable, Sendable { + struct Member: Equatable, Sendable { + let account: UsageLimitAccount + let window: ServerProviderUsageWindow + } + + struct Reset: Equatable, Sendable { + let accountID: String + let at: Date + let restoresPercent: Int + } + + let windowID: String + let kind: ServerProviderUsageWindow.Kind + let label: String + let members: [Member] + /// Keep account positions fixed when a subscription lacks this window. + let columns: [ServerProviderUsageWindow?] + let remainingPercent: Int + let pace: UsageLimitPace? + let resets: [Reset] + var id: String { "\(kind.rawValue):\(windowID)" } +} + +/// Mirrors packages/shared/usageLimits. Email identifies a subscription across +/// computers and hubs. Anonymous native instances keep their environment identity. +enum UsageLimitPooling { + static func accounts(_ environments: [FeatureEnvironmentUsageLimits]) -> [UsageLimitAccount] { + var order: [String] = [] + var collected: [String: UsageLimitAccount] = [:] + var credits: [String: UsageLimitAccount] = [:] + var hubRedeems: [String: UsageLimitAccount] = [:] + + func merge(_ next: UsageLimitAccount) { + let key = next.id + if let redeem = next.redeem, case .source = redeem.input, + hubRedeems[key].map({ checkedAt(next) > checkedAt($0) }) ?? true { + hubRedeems[key] = next + } + if next.limits.resetCredits != nil, + credits[key].map({ checkedAt(next) > checkedAt($0) }) ?? true { + credits[key] = next + } + guard var previous = collected[key] else { + order.append(key) + collected[key] = next + return + } + let winner = checkedAt(next) > checkedAt(previous) ? next : previous + previous.displayName = previous.displayName ?? next.displayName + previous.plan = previous.plan ?? next.plan + for environment in next.environments where !previous.environments.contains(where: { $0.id == environment.id }) { + previous.environments.append(environment) + } + previous.sourceLabel = previous.environments.isEmpty ? previous.sourceLabel ?? next.sourceLabel : nil + // Display the newest successful credit balance, but redeem through a + // hub when possible so its account-routing cooldown is cleared too. + previous.redeem = hubRedeems[key]?.redeem + ?? (credits[key] != nil + ? credits[key]?.redeem + : winner.redeem ?? previous.redeem ?? next.redeem) + previous.limits = ServerProviderUsageLimits( + checkedAt: winner.limits.checkedAt, + windows: winner.limits.windows, + resetCredits: credits[key]?.limits.resetCredits, + unavailable: winner.limits.unavailable + ) + collected[key] = previous + } + + for environment in environments { + for provider in UsageLimitsPresentation.providersWithLimits(environment.providers) { + guard let limits = provider.usageLimits, + UsageLimitsPresentation.limitsNotice(limits) == nil else { continue } + merge(UsageLimitAccount( + id: accountKey(driver: provider.driver, email: provider.auth.email) + ?? "native:\(environment.id):\(provider.instanceId)", + driver: provider.driver, + displayName: provider.displayName.flatMap(nonEmpty), + email: provider.auth.email, + plan: provider.auth.label, + environments: [.init(id: environment.id, label: environment.label)], + sourceLabel: nil, + redeem: .init( + environmentID: environment.id, + input: .provider(instanceID: provider.instanceId), + isConnected: environment.isConnected && !environment.isPending + ), + limits: limits + )) + } + } + for environment in environments { + for source in environment.sources { + for account in source.accounts { + guard UsageLimitsPresentation.limitsNotice(account.usageLimits) == nil else { continue } + merge(UsageLimitAccount( + id: accountKey(driver: account.driver, email: account.email) + ?? "hub:\(source.id):\(account.id)", + driver: account.driver, + displayName: account.email == nil + ? account.id.replacingOccurrences(of: "(?i)\\.json$", with: "", options: .regularExpression) : nil, + email: account.email, + plan: account.plan, + environments: [], + sourceLabel: environments.count > 1 ? "\(environment.label) · \(source.label)" : source.label, + redeem: account.usageLimits.resetCredits?.nextCreditId.map { + .init( + environmentID: environment.id, + input: .source(sourceID: source.id, accountID: account.id, creditID: $0), + isConnected: environment.isConnected && !environment.isPending + ) + }, + limits: account.usageLimits + )) + } + } + } + return order.compactMap { collected[$0] } + } + + static func pools(_ accounts: [UsageLimitAccount], now: Date) -> [UsageLimitPool] { + var drivers: [String] = [] + for account in accounts where !drivers.contains(account.driver) { drivers.append(account.driver) } + return drivers.map { driver in + let members = accounts.filter { $0.driver == driver } + let orderWindow = members.flatMap(\.limits.windows).min { rank($0.kind) < rank($1.kind) } + func reset(_ account: UsageLimitAccount) -> Date { + account.limits.windows.first { + $0.id == orderWindow?.id && $0.kind == orderWindow?.kind + }?.resetsAt.flatMap(UsageFormat.isoDate) ?? .distantFuture + } + let sorted = members.sorted { left, right in + if reset(left) != reset(right) { return reset(left) < reset(right) } + let leftName = (left.displayName ?? left.email ?? left.id).lowercased() + let rightName = (right.displayName ?? right.email ?? right.id).lowercased() + return leftName == rightName ? left.id < right.id : leftName.localizedStandardCompare(rightName) == .orderedAscending + } + return UsageLimitPool(driver: driver, accounts: sorted, windows: windows(sorted, now: now)) + } + } + + static func notices(_ environments: [FeatureEnvironmentUsageLimits]) -> [String] { + var result: [String] = [] + for environment in environments { + if environment.isPending { result.append("\(environment.label): Waiting for limits.") } + else if let error = environment.errorMessage { result.append("\(environment.label): \(error)") } + else if !environment.isConnected { result.append("\(environment.label): Disconnected. Showing last known limits.") } + func label(_ value: String) -> String { environments.count > 1 ? "\(environment.label) · \(value)" : value } + for provider in UsageLimitsPresentation.providersWithLimits(environment.providers) { + guard let limits = provider.usageLimits, limits.unavailable?.reason != .unsupported, + let notice = UsageLimitsPresentation.limitsNotice(limits) else { continue } + result.append("\(label(provider.displayName.flatMap(nonEmpty) ?? UsageLimitsPresentation.providerLabel(driver: provider.driver))): \(notice)") + } + for source in environment.sources { + if let error = source.error { result.append("\(label(source.label)): \(error)") } + else if source.accounts.isEmpty { result.append("\(label(source.label)): No accounts reported.") } + for (index, account) in source.accounts.enumerated() { + guard account.usageLimits.unavailable?.reason != .unsupported, + let notice = UsageLimitsPresentation.limitsNotice(account.usageLimits) else { continue } + result.append("\(label(source.label)) · \(UsageLimitsPresentation.providerLabel(driver: account.driver)) account \(index + 1): \(notice)") + } + } + } + return result + } + + private static func windows(_ accounts: [UsageLimitAccount], now: Date) -> [UsageLimitPoolWindow] { + var order: [String] = [] + var members: [String: [UsageLimitPoolWindow.Member]] = [:] + for account in accounts { + for window in account.limits.windows { + let key = "\(window.kind.rawValue):\(window.id)" + if members[key] == nil { order.append(key) } + members[key, default: []].append(.init(account: account, window: window)) + } + } + return order.compactMap { key -> UsageLimitPoolWindow? in + guard let rows = members[key], let first = rows.first?.window else { return nil } + let count = Double(rows.count) + let used = rows.reduce(0) { $0 + UsageLimitsMath.usedPercent($1.window) } / count + let timed = rows.compactMap { member -> (used: Double, elapsed: Double)? in + UsageLimitsMath.elapsedShare(member.window, now: now).map { + (UsageLimitsMath.usedPercent(member.window), $0) + } + } + let gap = timed.isEmpty ? nil : timed.reduce(0) { $0 + $1.used - $1.elapsed * 100 } / Double(timed.count) + let resets = rows.compactMap { member -> UsageLimitPoolWindow.Reset? in + guard let at = member.window.resetsAt.flatMap(UsageFormat.isoDate) else { return nil } + return .init(accountID: member.account.id, at: at, restoresPercent: Int((UsageLimitsMath.usedPercent(member.window) / count).rounded())) + }.sorted { $0.at < $1.at } + return UsageLimitPoolWindow( + windowID: first.id, kind: first.kind, label: first.label, members: rows, + columns: accounts.map { account in rows.first { $0.account.id == account.id }?.window }, + remainingPercent: Int((100 - used).rounded()), + pace: gap.map { $0 > 5 ? .ahead : $0 < -5 ? .under : .on }, + resets: resets + ) + }.sorted { rank($0.kind) < rank($1.kind) } + } + + private static func rank(_ kind: ServerProviderUsageWindow.Kind) -> Int { + switch kind { case .session: 0; case .weekly: 1; case .monthly: 2; case .other: 3 } + } + + private static func checkedAt(_ account: UsageLimitAccount) -> Date { + UsageFormat.isoDate(account.limits.checkedAt) ?? .distantPast + } + + private static func accountKey(driver: String, email: String?) -> String? { + email.flatMap(nonEmpty).map { "\(driver):\($0.lowercased())" } + } + + private static func nonEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/swift-ios/Features/Usage/UsageLimitsPresentation.swift b/apps/swift-ios/Features/Usage/UsageLimitsPresentation.swift new file mode 100644 index 000000000000..d4e8a4eaef8f --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageLimitsPresentation.swift @@ -0,0 +1,262 @@ +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, Sendable { + 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? { + UsageFormat.isoDate(value) + } +} + +struct UsageResetCreditTarget: Hashable { + let environmentID: String + let account: Account + + enum Account: Hashable { + case provider(String) + case source(sourceID: String, accountID: 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, warning: String? = nil) { + 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." + } + if let warning, !warning.isEmpty { statusMessage = "\(statusMessage ?? "") \(warning)" } + } + + 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..58c78f31630e --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageLimitsView.swift @@ -0,0 +1,541 @@ +import SwiftUI + +struct UsageLimitsView: View { + let client: any FeatureClient + @Binding var resetCreditStates: [UsageResetCreditTarget: UsageResetCreditState] + /// The parent keeps this view mounted behind the usage tab. Only the visible + /// tab may add toolbar items. + var isActive = true + + @State private var environments: [FeatureEnvironmentUsageLimits] = [] + @State private var groups: [UsageLimitsGroup] = [] + @State private var pools: [UsageLimitPool] = [] + @State private var showEnvironments = false + @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() + /// Subscriptions start on the first visit and then outlive tab switches. + @State private var hasActivated = false + + 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) { + Picker("Limits grouping", selection: $showEnvironments) { + Text("Pooled").tag(false) + Text("By environment").tag(true) + } + .pickerStyle(.segmented) + 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.textTertiary) + .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 if showEnvironments { + ForEach(groups) { group in + environmentSection(group) + } + } else { + ForEach(environments) { environment in + if let error = refreshErrors[environment.id] { + notice("\(environment.label): Could not refresh limits. \(error)") + } + } + ForEach(Array(UsageLimitPooling.notices(environments).enumerated()), id: \.offset) { _, message in + notice(message) + } + ForEach(pools) { pool in + pooledSection(pool) + } + if pools.isEmpty { + notice("No provider reports subscription limits.") + } + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .refreshable { await refresh() } + .toolbar { + if isActive { + ToolbarItem(placement: .topBarTrailing) { + Button("Refresh limits", systemImage: "arrow.clockwise") { + Task { await refresh() } + } + .disabled(isRefreshing) + } + } + } + .onChange(of: isActive, initial: true) { _, active in + if active { hasActivated = true } + } + .task(id: hasActivated ? subscriptionID : nil) { + guard hasActivated else { return } + 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 pooledSection(_ pool: UsageLimitPool) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text(UsageLimitsPresentation.providerLabel(driver: pool.driver)) + .font(T3Typography.threadHeading3) + Spacer() + Text("\(pool.accounts.count) \(pool.accounts.count == 1 ? "account" : "accounts")") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(pool.windows) { window in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(window.label) + Spacer() + Text("\(window.remainingPercent)% left").monospacedDigit() + } + .font(T3Typography.supporting) + HStack(spacing: 3) { + ForEach(Array(window.columns.enumerated()), id: \.offset) { index, accountWindow in + VStack(alignment: .leading, spacing: 3) { + GeometryReader { geometry in + if let accountWindow { + let remaining = UsageLimitsMath.remainingPercent(accountWindow) + ZStack(alignment: .leading) { + Rectangle().fill(T3Colors.subtleStrong) + Rectangle().fill(remaining <= 10 ? T3Colors.danger : T3Colors.textPrimary) + .frame(width: geometry.size.width * remaining / 100) + } + } + } + .frame(height: 6) + Text(accountWindow.map { "\(index + 1): \(Int(UsageLimitsMath.remainingPercent($0)))%" } ?? "\(index + 1): N/A") + .font(.caption2) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + } + } + HStack { + if let pace = window.pace { Text(pace.label) } + Spacer() + if let reset = window.resets.first(where: { $0.restoresPercent > 0 }) { + Text("+\(reset.restoresPercent)% on \(reset.at.formatted(date: .abbreviated, time: .shortened))") + } + } + .font(.caption) + .foregroundStyle(T3Colors.textSecondary) + } + .accessibilityElement(children: .combine) + } + ForEach(Array(pool.accounts.enumerated()), id: \.element.id) { index, account in + DisclosureGroup { + UsageLimitsAccountView( + driver: account.driver, instanceID: account.id, + label: account.label, detail: account.plan, + limits: account.limits, now: now + ) + if let credits = account.limits.resetCredits { + if let redeem = account.redeem { + UsageResetCreditsView( + client: client, environmentID: redeem.environmentID, + input: redeem.input, isConnected: redeem.isConnected, + credits: credits, now: now, + state: resetState(environmentID: redeem.target.environmentID, account: redeem.target.account) + ) + } else { + notice(UsageLimitsMath.creditSummary(credits, now: now)) + } + } + } label: { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("\(index + 1)") + UsageAccountLabel(value: account.label) + } + .font(T3Typography.control) + Text(account.locationLabel) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .tint(T3Colors.textPrimary) + } + } + .foregroundStyle(T3Colors.textPrimary) + } + + 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, + input: .provider(instanceID: provider.instanceId), + isConnected: group.environment.isConnected && !group.environment.isPending, + credits: credits, + now: now, + state: resetState(environmentID: group.id, account: .provider(provider.instanceId)) + ) + } + } + } + } + + ForEach(group.sources) { source in + sourceSection(source, environment: group.environment) + } + } + } + + private func sourceSection(_ row: UsageLimitSourceRows, environment: FeatureEnvironmentUsageLimits) -> 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 + ) + if let credits = account.usageLimits.resetCredits { + UsageResetCreditsView( + client: client, + environmentID: environment.id, + input: credits.nextCreditId.map { + .source(sourceID: row.id, accountID: account.id, creditID: $0) + }, + isConnected: environment.isConnected && !environment.isPending, + credits: credits, + now: now, + state: resetState( + environmentID: environment.id, + account: .source(sourceID: row.id, accountID: account.id) + ) + ) + } + } + } + } + .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) + groups = UsageLimitsPresentation.groups(environments) + hasSnapshot = true + now = Date() + pools = UsageLimitPooling.pools(UsageLimitPooling.accounts(environments), now: now) + 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 + // Each environment can end its own stream without throwing from + // the combined stream. Refresh must reconnect those failed streams. + 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, account: UsageResetCreditTarget.Account) -> Binding { + let target = UsageResetCreditTarget(environmentID: environmentID, account: account) + 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) + UsageAccountLabel(value: label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + if let detail { + UsageAccountLabel(value: 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 UsageAccountLabel: View { + let value: String + @State private var isRevealed = false + + var body: some View { + if value.contains("@") { + Button { + isRevealed.toggle() + } label: { + Text(isRevealed ? value : "••••••@••••••") + } + .buttonStyle(.plain) + .accessibilityLabel(isRevealed ? "Hide account label" : "Reveal account label") + .onChange(of: value) { isRevealed = false } + } else { + Text(value) + } + } +} + +private struct UsageLimitWindowView: View { + let window: ServerProviderUsageWindow + let driver: String + let now: Date + + var body: some View { + if window.resetsAt != nil { + TimelineView(.periodic(from: .now, by: 60)) { context in + content(now: context.date) + } + } else { + content(now: now) + } + } + + private func content(now: Date) -> 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) + return 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" ? UsageProviderKind.claudeColor : T3Colors.textPrimary + } +} + +private struct UsageResetCreditsView: View { + let client: any FeatureClient + let environmentID: String + let input: ProviderConsumeResetCreditInput? + let isConnected: Bool + let credits: ServerProviderResetCredits + let now: Date + @Binding var state: UsageResetCreditState + + @State private var confirmationPresented = false + @State private var confirmedInput: ProviderConsumeResetCreditInput? + + 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 && input != nil) || state.isPending { + Button(state.isPending ? "Using credit..." : "Use a reset credit") { + confirmedInput = input + 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 { + // Live updates can replace the next credit while the confirmation is open. + // Send the credit the user saw, never a later one. + guard let confirmedInput, + state.begin(availableCount: credits.availableCount, isConnected: isConnected) else { return } + do { + let result = try await client.consumeResetCredit(environmentID: environmentID, input: confirmedInput) + state.finish(result.outcome, warning: result.warning) + } 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..a72bc1bdf3d0 --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageModels.swift @@ -0,0 +1,743 @@ +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 unpricedRecords = 0 + + var id: String { "\(provider.rawValue):\(model)" } + var isCostUnknown: Bool { records > 0 && unpricedRecords >= records } + var costLabel: String { isCostUnknown ? "Unpriced" : UsageFormat.usd(costUsd) } + var costShareLabel: String { + isCostUnknown ? "No known rates" : "\(UsageFormat.percent(costShare)) of cost" + } +} + +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 presentation = UsagePresentation() + 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() + presentation = UsagePresentation() + 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) + presentation = UsagePresentation.make(merged, input: request.input) + 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 + var unpricedRecords = 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 + model.unpricedRecords += bucket.unpricedRecords + 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, + unpricedRecords: totals.unpricedRecords + ) + } + .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 { + UsageFormat.isoString(date) + } + + private static func isoDate(_ value: String) -> Date? { + UsageFormat.isoDate(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 + } +} + +/// Chart bars stack one segment per provider for each day or hour in the window. +struct UsageChartSegment: Identifiable, Equatable { + let period: String + let provider: UsageProviderKind + let start: Double + let end: Double + + var id: String { "\(period):\(provider.rawValue)" } +} + +struct UsagePeriodRow: Identifiable, Equatable { + let id: String + let label: String + let costUsd: Double + let totalTokens: Int +} + +/// Sorted, labelled, and charted values the usage screen renders. Computed once +/// per received result so the view body only reads stored values. +struct UsagePresentation: Equatable { + var providersByCost: [UsageProviderTotals] = [] + var providersByTokens: [UsageProviderTotals] = [] + var costSegments: [UsageChartSegment] = [] + var tokenSegments: [UsageChartSegment] = [] + var hasCostActivity = false + var hasTokenActivity = false + /// Newest period first. + var periods: [UsagePeriodRow] = [] + var activePeriodCount = 0 + var periodAverageTokens = 0 + var cachedInputShare = 0.0 + var sinceLabel = "" + var untilLabel = "" + + static func make(_ merged: MergedUsage, input: UsageSummaryInput) -> UsagePresentation { + var result = UsagePresentation() + result.providersByCost = merged.providers.sorted { $0.costUsd > $1.costUsd } + result.providersByTokens = merged.providers.sorted { $0.totalTokens > $1.totalTokens } + result.hasCostActivity = merged.daily.contains { $0.costUsd > 0 } + result.hasTokenActivity = merged.daily.contains { $0.totalTokens > 0 } + result.sinceLabel = UsageFormat.dayShort(input.sinceDay) + result.untilLabel = UsageFormat.dayShort(input.untilDay) + + let periodKeys: [String] + let byPeriod: [String: [UsageProviderKind: UsageProviderValue]] + if input.resolution == .hour { + let hourStyle = UsageFormat.hourStyle(timeZone: input.timeZone) + periodKeys = UsageWindow.hours(in: input) + byPeriod = Dictionary(uniqueKeysWithValues: merged.hourly.map { ($0.hourStart, $0.byProvider) }) + result.periods = merged.hourly.reversed().map { + UsagePeriodRow( + id: $0.hourStart, + label: UsageFormat.hourShort($0.hourStart, style: hourStyle), + costUsd: $0.costUsd, + totalTokens: $0.totalTokens + ) + } + result.activePeriodCount = merged.hourly.filter { $0.totalTokens > 0 }.count + } else { + periodKeys = UsageWindow.days(in: input) + byPeriod = Dictionary(uniqueKeysWithValues: merged.daily.map { ($0.day, $0.byProvider) }) + result.periods = merged.daily.reversed().map { + UsagePeriodRow( + id: $0.day, + label: UsageFormat.dayShort($0.day), + costUsd: $0.costUsd, + totalTokens: $0.totalTokens + ) + } + result.activePeriodCount = merged.daily.filter { $0.totalTokens > 0 }.count + } + result.costSegments = segments(periodKeys, byPeriod: byPeriod) { $0.costUsd } + result.tokenSegments = segments(periodKeys, byPeriod: byPeriod) { Double($0.totalTokens) } + result.periodAverageTokens = result.activePeriodCount == 0 + ? 0 + : merged.totalTokens / result.activePeriodCount + let observedInput = merged.uncachedInputTokens + merged.cachedInputTokens + result.cachedInputShare = observedInput == 0 + ? 0 + : Double(merged.cachedInputTokens) / Double(observedInput) + return result + } + + private static func segments( + _ periods: [String], + byPeriod: [String: [UsageProviderKind: UsageProviderValue]], + value: (UsageProviderValue) -> Double + ) -> [UsageChartSegment] { + periods.flatMap { period in + let totals = byPeriod[period] ?? [:] + var start = 0.0 + return UsageProviderKind.allCases.map { provider in + let amount = totals[provider].map(value) ?? 0 + defer { start += amount } + return UsageChartSegment(period: period, provider: provider, start: start, end: start + amount) + } + } + } +} + +/// Format styles are Sendable values, so each one is built once here instead of +/// once per call. All of them follow the current locale. +enum UsageFormat { + private static let isoFractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + private static let isoWholeSeconds = Date.ISO8601FormatStyle() + private static let dayParser = Date.ISO8601FormatStyle().year().month().day() + private static let dayLabel = Date.FormatStyle(timeZone: .gmt).month(.abbreviated).day() + private static let usdStyle = FloatingPointFormatStyle.Currency(code: "USD") + .precision(.fractionLength(2)) + private static let countStyle = IntegerFormatStyle() + private static let percentStyle = FloatingPointFormatStyle.Percent() + .precision(.fractionLength(1)) + + static func usd(_ value: Double) -> String { + usdStyle.format(value) + } + + static func count(_ value: Int) -> String { + countStyle.format(value) + } + + 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 { + percentStyle.format(value) + } + + /// "2026-08-18" becomes "Aug 18" in the current locale. + static func dayShort(_ day: String) -> String { + (try? dayParser.parse(day)).map(dayLabel.format) ?? day + } + + /// One style per window, since the label follows the window's time zone. + static func hourStyle(timeZone: String) -> Date.FormatStyle { + Date.FormatStyle(timeZone: TimeZone(identifier: timeZone) ?? .current) + .weekday(.abbreviated) + .hour(.defaultDigits(amPM: .abbreviated)) + } + + static func hourShort(_ value: String, style: Date.FormatStyle) -> String { + isoDate(value).map(style.format) ?? value + } + + static func isoString(_ date: Date) -> String { + isoFractional.format(date) + } + + static func isoDate(_ value: String) -> Date? { + (try? isoFractional.parse(value)) ?? (try? isoWholeSeconds.parse(value)) + } + + 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/Usage/UsageView.swift b/apps/swift-ios/Features/Usage/UsageView.swift new file mode 100644 index 000000000000..752cf5e2c04f --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageView.swift @@ -0,0 +1,589 @@ +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 { + case model + case time +} + +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 hasActivatedUsage = false + @State private var resetCreditStates: [UsageResetCreditTarget: UsageResetCreditState] = [:] + + public init(client: any FeatureClient, initiallyShowsLimits: Bool = false) { + self.client = client + _tab = State(initialValue: initiallyShowsLimits ? .limits : .usage) + } + + private var windowInput: UsageSummaryInput { loadState.windowInput } + private var environments: [FeatureEnvironmentUsage] { loadState.environments } + private var merged: MergedUsage { loadState.merged } + private var presentation: UsagePresentation { loadState.presentation } + 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) + + // Both tabs stay mounted. Switching only toggles visibility, so the + // usage load and every limit subscription survive a tab change. + ZStack { + usageContent + .opacity(tab == .usage ? 1 : 0) + .allowsHitTesting(tab == .usage) + .accessibilityHidden(tab != .usage) + UsageLimitsView( + client: client, + resetCreditStates: $resetCreditStates, + isActive: tab == .limits + ) + .opacity(tab == .limits ? 1 : 0) + .allowsHitTesting(tab == .limits) + .accessibilityHidden(tab != .limits) + } + } + .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() + .onChange(of: tab, initial: true) { _, selected in + if selected == .usage { hasActivatedUsage = true } + } + .task(id: hasActivatedUsage ? loadState.windowDays : nil) { + guard hasActivatedUsage else { return } + await load() + } + } + + private var usageContent: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + 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.textTertiary) + .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 { + chartSection + usageDivider + providersSection + usageDivider + totalsSection + usageDivider + breakdownSection + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .refreshable { 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) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var chartSection: 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" + + (merged.costQuality.unpricedShare > 0 + ? ". Excludes \(UsageFormat.percent(merged.costQuality.unpricedShare)) unpriced records." + : "") + : "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 metric == .cost ? presentation.hasCostActivity : presentation.hasTokenActivity { + UsagePeriodChart( + segments: metric == .cost ? presentation.costSegments : presentation.tokenSegments + ) + .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(presentation.sinceLabel) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 14) { + ForEach(presentation.providersByCost) { provider in + HStack(spacing: 5) { + Circle() + .fill(provider.provider.color) + .frame(width: 8, height: 8) + Text(provider.provider.displayName) + } + } + } + .fixedSize() + + Text(presentation.untilLabel) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + } + } + + @ViewBuilder + private var providersSection: some View { + let ordered = metric == .cost ? presentation.providersByCost : presentation.providersByTokens + if !ordered.isEmpty { + UsageSection(title: "Providers") { + 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: 8) { + 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(.vertical, 10) + } + } + } + } + } + + private var totalsSection: some View { + UsageSection(title: "Totals") { + let isHourly = windowInput.resolution == .hour + 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(presentation.periodAverageTokens)) 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(presentation.cachedInputShare)) 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" + ) + } + } + } + + private var breakdownSection: some View { + UsageSection(title: "Breakdown") { + VStack(spacing: 4) { + 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 { + noActivity + } 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( + "\(model.costShareLabel) · " + + "\(UsageFormat.tokens(model.totalTokens)) tokens" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer(minLength: 8) + Text(model.costLabel) + .font(T3Typography.threadBody) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + } + .padding(.vertical, 10) + } + } + } + } + + @ViewBuilder + private var timeBreakdown: some View { + if presentation.periods.isEmpty { + noActivity + } else { + LazyVStack(spacing: 0) { + ForEach(Array(presentation.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(.vertical, 10) + } + } + } + } + + private var noActivity: some View { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + 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 segments: [UsageChartSegment] + + 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 UsageSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .accessibilityAddTraits(.isHeader) + 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(.vertical, 8) + .padding(.trailing, 12) + .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) + } +} + +extension UsageProviderKind { + /// Claude's brand orange. Usage charts and limit bars share this one value. + static let claudeColor = Color(red: 0.851, green: 0.467, blue: 0.341) + + var color: Color { + switch self { + case .codex: T3Colors.textPrimary + case .claude: Self.claudeColor + case .grok: T3Colors.textSecondary + } + } +} diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift new file mode 100644 index 000000000000..b874b7a4fca8 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -0,0 +1,1545 @@ +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 var source: PastedTextAttachmentSource? + + public init( + id: UUID = UUID(), + data: Data, + thumbnailData: Data? = nil, + filename: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil + ) { + self.id = id + inlineData = data + ownedFile = nil + self.thumbnailData = thumbnailData + self.filename = filename + self.mimeType = mimeType + self.uploadedReference = uploadedReference + self.source = source + } + + public init( + id: UUID = UUID(), + ownedFile: FeatureOwnedAttachmentFile, + thumbnailData: Data? = nil, + filename: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil + ) { + self.id = id + inlineData = nil + self.ownedFile = ownedFile + self.thumbnailData = thumbnailData + self.filename = filename + self.mimeType = mimeType + self.uploadedReference = uploadedReference + self.source = source + } + + /// 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 context: OrchestrationMessageContext? + 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] = [], + context: OrchestrationMessageContext? = nil + ) { + 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 + self.context = context + } + + 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 context: OrchestrationMessageContext? + 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] = [], + context: OrchestrationMessageContext? = nil + ) { + self.threadID = threadID + self.text = text + self.selection = selection + self.attachments = attachments + self.context = context + } +} + +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() + } + var preferences = snapshot.preferencesByEnvironment?[environmentID] + ?? FeatureEnvironmentPreferences() + if let mode = project?.defaultWorkspaceMode { preferences.defaultWorkspaceMode = mode } + if let startFromOrigin = project?.newWorktreesStartFromOrigin { + preferences.newWorktreesStartFromOrigin = startFromOrigin + } + return preferences + } +} + +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] + + 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 = Self.orderedSection(visible, section: .pinned, now: now) + + active = Self.orderedSection(visible, section: .active, now: now) + + 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 + ) + } + + /// The pinned or active list in display order, independent of project + /// filtering and search — the same canonical section React Native plans + /// `thread.pin.reorder` / `thread.active.reorder` against, so a reorder + /// means the same thing no matter which rows are on screen. + static func orderedSection( + _ threads: [FeatureThread], + section: FeatureThreadOrderSection, + now: Date + ) -> [FeatureThread] { + threads + .filter { thread in + !thread.isArchived + && !thread.isEffectivelySnoozed(at: now) + && !(thread.supportsSettlement == true && thread.isEffectivelySettled()) + && (thread.pinnedAt != nil) == (section == .pinned) + } + .sorted(by: section == .pinned ? pinnedOrder : activeOrder) + } + + /// Keyed rows hold their user-arranged order first; threads pinned by + /// clients that predate reordering keep static creation order below them + /// (`sortPinnedThreadsByOrderKey` in client-runtime). + private static func pinnedOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + switch (lhs.pinOrderKey, rhs.pinOrderKey) { + case let (.some(left), .some(right)): + return left == right ? identityOrder(lhs, rhs) : left < right + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + return lhs.createdAt == rhs.createdAt + ? identityOrder(lhs, rhs) + : lhs.createdAt > rhs.createdAt + } + } + + /// New and reopened threads lead the active list. Arranged threads follow + /// their saved keys; activity leaves both groups in place + /// (`sortActiveThreadsByOrderKey` in client-runtime). + private static func activeOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + switch (lhs.activeOrderKey, rhs.activeOrderKey) { + case (.none, .some): return true + case (.some, .none): return false + case let (.some(left), .some(right)): + return left == right ? identityOrder(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 + ? identityOrder(lhs, rhs) + : leftAnchor > rightAnchor + } + + /// Wire id first, then environment: thread ids are only unique within an + /// environment, and merged sections need both parts or two clients could + /// render equal-key threads in stream-arrival order. + private static func identityOrder(_ 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 ?? "", + ] + ThreadPullRequests.searchTerms(thread.pullRequests, legacy: thread.linkedPullRequest)) + .contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + } +} + +/// Every thread field the sidebar index reads to pick a shelf or an order. +/// `FeatureRootModel` bumps the Home presentation revision only when one of +/// these changes, so a streaming turn (which touches `updatedAt`, `preview`, +/// and `settlementFacts.latestTurn` several times a second) reconfigures its +/// own row without re-sorting the whole list. +struct HomeOrderKey: Equatable { + let projectID: String + let environmentID: String? + let wireID: String? + let isArchived: Bool + let state: FeatureThreadState + let createdAt: Date + let unsettledAt: Date? + let activeOrderKey: String? + let pinnedAt: Date? + let pinOrderKey: String? + let snoozedUntil: Date? + let snoozedAt: Date? + let attentionAt: Date? + let latestTurnCompletedAt: Date? + let supportsSettlement: Bool? + let settlementOverride: FeatureThreadSettlementOverride? + let latestUserMessageAt: Date? + let sessionStatus: String? + let latestTurn: FeatureThreadSettlementFacts.LatestTurn? + let keepsActive: Bool + let isSettled: Bool + let title: String + let pullRequestSearchTerms: [String] + /// Only the archived shelf orders by `updatedAt`; live shelves ignore it. + let archivedSortDate: Date? + /// Only a settled thread's position depends on its settled sort date. + let settledSortDate: Date? + + init(_ thread: FeatureThread) { + projectID = thread.projectID + environmentID = thread.environmentID + wireID = thread.wireID + isArchived = thread.isArchived + state = thread.state + createdAt = thread.createdAt + unsettledAt = thread.unsettledAt + activeOrderKey = thread.activeOrderKey + pinnedAt = thread.pinnedAt + pinOrderKey = thread.pinOrderKey + snoozedUntil = thread.snoozedUntil + snoozedAt = thread.snoozedAt + attentionAt = thread.attentionAt + latestTurnCompletedAt = thread.latestTurnCompletedAt + supportsSettlement = thread.supportsSettlement + settlementOverride = thread.settlementFacts?.settlementOverride + latestUserMessageAt = thread.settlementFacts?.latestUserMessageAt + sessionStatus = thread.settlementFacts?.sessionStatus + latestTurn = thread.settlementFacts?.latestTurn + keepsActive = thread.keepsActive + isSettled = thread.isSettled + title = thread.title + pullRequestSearchTerms = ThreadPullRequests.searchTerms(thread.pullRequests, legacy: thread.linkedPullRequest) + archivedSortDate = thread.isArchived ? thread.updatedAt : nil + settledSortDate = thread.isEffectivelySettled() ? thread.settledSortDate : nil + } +} + +/// 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 + ) -> 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..73a8624c1b87 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -0,0 +1,1188 @@ +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 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 onArrange: () -> 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, !thread.isRegeneratingTitle { + actions.append(accessibilityAction("Regenerate title", systemImage: "sparkles") { coordinator in + coordinator.parent.onRegenerateTitle(thread) + }) + } + + if !isArchived { + actions.append(accessibilityAction("Arrange threads", systemImage: "line.3.horizontal") { coordinator in + coordinator.parent.onArrange() + }) + 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.canSnoozeNow(at: .now) { + actions.append(contentsOf: DailyUXSnoozePresets.resolve(now: .now).map { preset in + accessibilityAction("Snooze: \(preset.label)", systemImage: "clock") { coordinator in + coordinator.parent.onSnooze(thread, preset.until) + } + }) + } + } + } + + if isArchived || thread.canArchive { + 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 { + let regenerate = UIAction( + title: thread.isRegeneratingTitle ? "Regenerating title…" : "Regenerate title", + image: UIImage(systemName: "sparkles") + ) { [weak self] _ in + self?.parent.onRegenerateTitle(thread) + } + regenerate.attributes = thread.isRegeneratingTitle ? .disabled : [] + titleActions.append(regenerate) + } + 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 { + statusActions.append(UIAction( + title: "Arrange threads", + image: UIImage(systemName: "line.3.horizontal") + ) { [weak self] _ in + self?.parent.onArrange() + }) + 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) + } + } + if !thread.canSnoozeNow(at: .now) { + 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) + } + if !isArchived, !thread.canArchive { + archive.attributes = .disabled + } + 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 if thread.canArchive { + 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..a234bdc09c2e --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift @@ -0,0 +1,103 @@ +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 { + /// Existing worktrees already identify a checkout. A new worktree only needs its base. + @MainActor + static func selectBranch( + _ branch: FeatureWorkspaceBranch, + mode: FeatureWorkspaceMode, + checkout: (String) async throws -> String? + ) async throws -> FeatureWorkspaceBranch { + guard mode == .local, !branch.isCurrent, + branch.worktreePath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false else { + return branch + } + let checkedOutName = try await checkout(branch.name) + var checkedOut = branch + checkedOut.name = checkedOutName ?? branch.name + checkedOut.isRemote = false + checkedOut.isCurrent = true + return checkedOut + } + + 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..d94fd16338c5 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -0,0 +1,1796 @@ +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 composerContext: OrchestrationMessageContext? + @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 isSwitchingBranch = false + @State private var branchSelectionError: String? + @State private var activePicker: NewTaskPicker? + @State private var isSubmitting = 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 missingFileRecoverySnapshot: FeatureComposerDraft? + @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: missingFileRecoverySnapshot == nil ? { + if restoredDraftProjectID == projectID { + persistCurrentDraftImmediately() + } else { + Task { await restoreDraftAndLoadBranches() } + } + } : nil, + context: contextBinding, + contextAttachmentResolver: model.client as? any FeatureContextAttachmentResolving + ) + } + .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: initialSelection) { _, value in + if !selectionIsExplicit { selection = value } + } + .onChange(of: environmentPreferences) { previous, preferences in + guard !workspaceSelectionIsExplicit, + previous.defaultWorkspaceMode != preferences.defaultWorkspaceMode + || previous.newWorktreesStartFromOrigin != preferences.newWorktreesStartFromOrigin else { return } + workspaceMode = preferences.defaultWorkspaceMode + startFromOrigin = preferences.newWorktreesStartFromOrigin + selectedBranch = workspaceMode == .local + ? NewTaskWorkspaceDefaults.localBranch(in: branches) + : NewTaskWorkspaceDefaults.worktreeBase(in: branches) + } + .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 .projectSettings: + ProjectPreferencesSheet(model: model, projectID: projectID) + 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( + title: workspaceMode == .local ? "Branch" : "Base branch", + branches: branches, + selection: selectedBranch, + isLoading: branchesLoading, + loadFailed: branchLoadFailed, + isSwitching: isSwitchingBranch, + selectionError: branchSelectionError, + onSelect: { branch in + Task { await selectBranch(branch) } + }, + onRefresh: { Task { await loadBranches(refresh: true) } } + ) + } + } + .interactiveDismissDisabled(isSubmitting || isSwitchingBranch) + .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() + if selectedProject != nil { + Button { + presentPicker(.projectSettings) + } label: { + Image(systemName: "gearshape") + .frame(width: 44, height: 44) + } + .foregroundStyle(T3Colors.textSecondary) + .disabled(isSubmitting) + .accessibilityLabel("Project settings") + .accessibilityIdentifier("new-task-project-settings") + } + } + .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) + + Button { + presentPicker(.branch) + } label: { + workspaceControlLabel( + selectedBranch?.name + ?? (branchesLoading ? "Loading branches" : "Choose branch"), + systemImage: "arrow.triangle.branch", + showsChevron: true + ) + } + .buttonStyle(.plain) + .disabled(isSubmitting || isSwitchingBranch) + .accessibilityLabel(workspaceMode == .local ? "Branch" : "Base branch") + .accessibilityValue(selectedBranch?.name ?? "Not selected") + + if workspaceMode == .worktree { + Button { + workspaceSelectionIsExplicit = true + missingFileRecoverySnapshot = nil + 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") + } + } + .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 .needsPairing: return "Pair again" + 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 } + missingFileRecoverySnapshot = nil + 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 && !isSwitchingBranch && 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) { + branchSelectionError = nil + 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 + ), + context: composerContext + ) + + 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() + submissionValidationError = model.lastTaskStartError + ?? "Could not start the task. Check the connection and try again." + promptFocused = 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 + missingFileRecoverySnapshot = nil + draftSaveTask?.cancel() + draftSaveTask = nil + prompt = carryingContent?.text ?? "" + attachments = carryingContent?.attachments ?? [] + composerContext = carryingContent?.context + 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) { + missingFileRecoverySnapshot = nil + workspaceSelectionIsExplicit = true + workspaceMode = mode + selectedBranch = switch mode { + case .local: NewTaskWorkspaceDefaults.localBranch(in: branches) + case .worktree: NewTaskWorkspaceDefaults.worktreeBase(in: branches) + } + } + + @MainActor + private func selectBranch(_ branch: FeatureWorkspaceBranch) async { + guard !isSwitchingBranch else { return } + let requestedProjectID = projectID + let requestedMode = workspaceMode + isSwitchingBranch = true + branchSelectionError = nil + defer { isSwitchingBranch = false } + do { + let selected = try await model.client.selectWorkspaceBranch( + projectID: requestedProjectID, branch: branch, mode: requestedMode + ) + guard projectID == requestedProjectID, workspaceMode == requestedMode else { return } + missingFileRecoverySnapshot = nil + workspaceSelectionIsExplicit = true + selectedBranch = selected + // A checkout can change a remote ref into a local one. + if selected.isCurrent { + branches = branches.map { existing in + if existing.id == branch.id { return selected } + var other = existing + if other.isCurrent { other.worktreePath = nil } + other.isCurrent = false + return other + } + } + activePicker = nil + } catch { + guard projectID == requestedProjectID else { return } + branchSelectionError = error.localizedDescription + } + } + + @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: FeatureComposerDraft + var recoveredMissingFiles = false + do { + restored = try context.merging( + saved: saved, + current: liveDraft, + fallbackSelection: initialSelection, + fallbackWorkspace: FeatureComposerWorkspaceDraft( + mode: environmentPreferences.defaultWorkspaceMode, + branch: nil, + worktreePath: nil, + startFromOrigin: environmentPreferences.newWorktreesStartFromOrigin + ), + onMissingAttachments: { recoveredMissingFiles = true } + ) + } catch { + draftSaveError = error.localizedDescription + return + } + prompt = restored.text + attachments = restored.attachments + composerContext = restored.context + 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 + missingFileRecoverySnapshot = context.recoverySnapshot( + restored: composerDraft, saved: saved, hasMissingFiles: recoveredMissingFiles + ) + draftSaveError = recoveredMissingFiles ? FeatureComposerDraftRestoration.missingFilesWarning : nil + 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 contextBinding: Binding { + Binding(get: { composerContext }, set: { value in + composerContext = value + scheduleDraftSave() + }) + } + + 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, + context: composerContext + ) + } + + private func scheduleDraftSave() { + guard restoredDraftProjectID == projectID, + !isSubmitting, + !submittedSuccessfully, + let key = currentDraftKey else { + return + } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil + 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 + } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil + 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 } + do { + var hasMissingAttachments = false + let merged = try restoreContext.merging(saved: saved, current: snapshot, + onMissingAttachments: { hasMissingAttachments = true }) + guard !hasMissingAttachments else { + if currentDraftKey == key { draftSaveError = FeatureComposerDraftRestoration.missingFilesWarning } + return + } + 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 + }, + context: draft.context + ) + } + + 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 + ) + } + + /// A carry writes to the new target, not the source draft that still owns the recovery copy. + func recoverySnapshot( + restored: FeatureComposerDraft, saved: FeatureComposerDraft?, hasMissingFiles: Bool + ) -> FeatureComposerDraft? { + hasMissingFiles && !shouldCarryContent(into: saved) ? restored : nil + } + + func merging( + saved: FeatureComposerDraft?, + current: FeatureComposerDraft, + fallbackSelection: FeatureSelection? = nil, + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil, + onMissingAttachments: () -> Void = {} + ) throws -> FeatureComposerDraft { + var target = saved + if shouldCarryContent(into: saved) { + target = saved ?? FeatureComposerDraft() + target?.text = baseline.text + target?.attachments = baseline.attachments + target?.context = baseline.context + } + var restored = try FeatureComposerDraftRestoration.merge( + saved: target, + baseline: baseline, + current: current, + fallbackSelection: fallbackSelection, + fallbackWorkspace: fallbackWorkspace, + onMissingAttachments: onMissingAttachments + ) + 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 projectSettings + 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 title: String + let branches: [FeatureWorkspaceBranch] + let selection: FeatureWorkspaceBranch? + let isLoading: Bool + let loadFailed: Bool + let isSwitching: Bool + let selectionError: String? + 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) + .disabled(isSwitching) + .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) + .safeAreaInset(edge: .bottom) { + if isSwitching || selectionError != nil { + Text(isSwitching ? "Switching branch..." : selectionError ?? "") + .font(T3Typography.supporting) + .foregroundStyle(selectionError == nil ? T3Colors.textSecondary : T3Colors.danger) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.background) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Search branches") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isSwitching) + } + ToolbarItem(placement: .primaryAction) { + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading || isSwitching) + .accessibilityLabel("Refresh branches") + } + } + } + .presentationDetents([.medium, .large]) + .interactiveDismissDisabled(isSwitching) + .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: "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/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 "/\(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/ThreadArrangementPlanner.swift b/apps/swift-ios/Features/Workspace/ThreadArrangementPlanner.swift new file mode 100644 index 000000000000..1a2e3e2a1641 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadArrangementPlanner.swift @@ -0,0 +1,137 @@ +import Foundation + +enum ThreadArrangementSection: String, CaseIterable, Hashable { + case pinned, active, snoozed, settled + + var title: String { rawValue.capitalized } + + var orderSection: FeatureThreadOrderSection? { + switch self { + case .pinned: .pinned + case .active: .active + case .snoozed, .settled: nil + } + } +} + +struct ThreadArrangementDestination: Equatable { + let section: ThreadArrangementSection + var targetID: String? = nil + var after = false +} + +struct ThreadArrangementRow { + let section: ThreadArrangementSection + var thread: FeatureThread? + var id: String { thread.map { "thread:\($0.id)" } ?? "section:\(section.rawValue)" } +} + +/// Resolves drops against all environments, without the Home project filter. +/// A destination identifies a row rather than an index so a stale drag cannot +/// silently move next to a different thread. +enum ThreadArrangementPlanner { + enum LifecycleAction: Equatable { + case pin, unpin, unsettle, unsnooze + } + + struct Plan { + let section: FeatureThreadOrderSection? + let orderedIDs: [String] + let assignments: [FeatureThreadOrderAssignment] + } + + static func destination( + rows: [ThreadArrangementRow], + insertionIndex: Int, + isBeforeHeader: Bool + ) -> ThreadArrangementDestination? { + if insertionIndex == rows.count, let last = rows.last { + return .init(section: last.section, targetID: last.thread?.id, after: true) + } + guard rows.indices.contains(insertionIndex) else { return nil } + let row = rows[insertionIndex] + // UIKit uses the next header's index for the preceding section's end. + // A pointer on the header itself still targets that section, including + // empty sections and the collapsed Settled shelf. + if row.thread == nil, isBeforeHeader, insertionIndex > 0 { + let previous = rows[insertionIndex - 1] + return .init(section: previous.section, targetID: previous.thread?.id, after: true) + } + return .init(section: row.section, targetID: row.thread?.id) + } + + static func lifecycle( + _ thread: FeatureThread, + section: FeatureThreadOrderSection, + now: Date + ) -> [LifecycleAction] { + if section == .pinned { return [.pin] } + var actions: [LifecycleAction] = [] + if thread.pinnedAt != nil { actions.append(.unpin) } + if thread.isEffectivelySettled() { actions.append(.unsettle) } + if thread.isEffectivelySnoozed(at: now) { actions.append(.unsnooze) } + return actions + } + + static func section(of thread: FeatureThread, now: Date) -> ThreadArrangementSection { + if thread.isEffectivelySnoozed(at: now) { return .snoozed } + if thread.supportsSettlement == true, thread.isEffectivelySettled() { return .settled } + return thread.pinnedAt == nil ? .active : .pinned + } + + static func canEnter( + _ thread: FeatureThread, + section: FeatureThreadOrderSection, + now: Date + ) -> Bool { + guard !thread.isArchived, ThreadOrderPlanner.isWritable(thread, section: section) else { + return false + } + if thread.isEffectivelySettled(), thread.supportsSettlement != true { return false } + let source = self.section(of: thread, now: now) + if source.orderSection == section { return true } + if (section == .pinned || thread.pinnedAt != nil), thread.supportsPinning != true { + return false + } + if thread.isEffectivelySnoozed(at: now), thread.supportsSnooze != true { return false } + return true + } + + static func plan( + id: String, + destination: ThreadArrangementDestination, + threads: [FeatureThread], + connectedEnvironmentIDs: Set, + now: Date + ) -> Plan? { + guard let moved = threads.first(where: { $0.id == id }), + !moved.isArchived, + connectedEnvironmentIDs.contains(moved.environmentID ?? "") else { return nil } + if destination.section == .settled { + guard moved.supportsSettlement == true, !moved.isEffectivelySettled(), + moved.canSettleNow(at: now) else { return nil } + return Plan(section: nil, orderedIDs: [], assignments: []) + } + guard let section = destination.section.orderSection, + canEnter(moved, section: section, now: now) else { return nil } + let current = DailyUXSidebarIndex.orderedSection(threads, section: section, now: now) + var ordered = current.filter { $0.id != id } + let index: Int + if let target = destination.targetID { + guard let targetIndex = ordered.firstIndex(where: { $0.id == target }) else { return nil } + index = targetIndex + (destination.after ? 1 : 0) + } else { + index = destination.after ? ordered.count : 0 + } + ordered.insert(moved, at: index) + guard ordered.map(\.id) != current.map(\.id), + let assignments = ThreadOrderPlanner.planDrop( + ordered: ordered, + all: threads, + section: section, + connectedEnvironmentIDs: connectedEnvironmentIDs, + movedID: id + ) else { return nil } + return Plan(section: section, orderedIDs: ordered.map(\.id), assignments: assignments) + } +} diff --git a/apps/swift-ios/Features/Workspace/ThreadArrangementView.swift b/apps/swift-ios/Features/Workspace/ThreadArrangementView.swift new file mode 100644 index 000000000000..fbe14cb1b14d --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadArrangementView.swift @@ -0,0 +1,353 @@ +import SwiftUI +import UIKit + +/// Lives on the workspace, not a row, so moving the source to another shelf +/// does not dismiss the sheet. +struct ThreadArrangementView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + @State private var action: String? + @State private var now = Date.now + + var body: some View { + NavigationStack { + ThreadArrangementCollection( + model: model, + revision: model.homePresentationRevision, + busy: model.isArrangingThreads, + now: now, + onAction: { action = $0 } + ) + .background(.black) + .navigationTitle(action ?? "Arrange threads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .disabled(model.isArrangingThreads) + } + } + } + .preferredColorScheme(.dark) + .interactiveDismissDisabled(model.isArrangingThreads) + .alert("Could not arrange threads", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("OK") { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "The move failed.") + } + .task(id: nextWake) { + guard let wake = nextWake else { return } + do { + try await Task.sleep(for: .seconds(max(0, wake.timeIntervalSinceNow))) + now = max(.now, wake) + } catch {} + } + } + + private var nextWake: Date? { + model.snapshot.threads.compactMap(\.snoozedUntil).filter { $0 > now }.min() + } +} + +private struct ThreadArrangementCollection: UIViewRepresentable { + let model: FeatureRootModel + let revision: UInt64 + let busy: Bool + let now: Date + let onAction: (String?) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeUIView(context: Context) -> UICollectionView { + var layout = UICollectionLayoutListConfiguration(appearance: .plain) + layout.backgroundColor = .black + layout.showsSeparators = false + let view = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout.list(using: layout) + ) + view.backgroundColor = .black + view.alwaysBounceVertical = true + view.dragInteractionEnabled = true + view.delegate = context.coordinator + view.dragDelegate = context.coordinator + view.dropDelegate = context.coordinator + context.coordinator.configure(view) + return view + } + + func updateUIView(_ view: UICollectionView, context: Context) { + context.coordinator.parent = self + context.coordinator.update() + } + + static func dismantleUIView(_ view: UICollectionView, coordinator: Coordinator) { + view.delegate = nil + view.dragDelegate = nil + view.dropDelegate = nil + } + + @MainActor + final class Coordinator: NSObject, UICollectionViewDelegate, + UICollectionViewDragDelegate, UICollectionViewDropDelegate { + typealias Row = ThreadArrangementRow + + var parent: ThreadArrangementCollection + private weak var view: UICollectionView? + private var dataSource: UICollectionViewDiffableDataSource? + private var rows: [Row] = [] + private var expanded = Set() + private var draggedID: String? + private var pendingRows: [Row]? + + init(parent: ThreadArrangementCollection) { self.parent = parent } + + func configure(_ view: UICollectionView) { + self.view = view + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, id in + self?.configure(cell, id: id) + } + dataSource = UICollectionViewDiffableDataSource(collectionView: view) { + collection, indexPath, id in + collection.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id) + } + update() + } + + func update(completion: (() -> Void)? = nil) { + guard draggedID == nil || pendingRows != nil else { return } + if let pendingRows { + rows = pendingRows + } else { + let index = DailyUXSidebarIndex(snapshot: parent.model.snapshot, query: "", now: parent.now) + rows = ThreadArrangementSection.allCases.flatMap { section -> [Row] in + let threads: [FeatureThread] + switch section { + case .pinned: threads = index.pinned + case .active: threads = index.active + case .snoozed: threads = index.snoozed + case .settled: threads = index.settled + } + if section == .snoozed, threads.isEmpty { return [] } + let visible = section.orderSection != nil || expanded.contains(section) + return [Row(section: section)] + (visible ? threads.map { Row(section: section, thread: $0) } : []) + } + } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(rows.map(\.id)) + if let existing = dataSource?.snapshot().itemIdentifiers { + let previousIDs = Set(existing) + snapshot.reconfigureItems(rows.map(\.id).filter { previousIDs.contains($0) }) + } + dataSource?.apply(snapshot, animatingDifferences: !UIAccessibility.isReduceMotionEnabled, completion: completion) + } + + private func configure(_ cell: UICollectionViewListCell, id: String) { + guard let row = rows.first(where: { $0.id == id }) else { return } + cell.backgroundConfiguration = .clear() + cell.accessories = [] + if let thread = row.thread { + let enabled = !parent.model.isArrangingThreads && canLift(thread) + cell.contentConfiguration = UIHostingConfiguration { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(thread.title).font(.system(size: 15)).foregroundStyle(.white).lineLimit(1) + if let name = thread.environmentName { + Text(name).font(.system(size: 11)).foregroundStyle(.gray).lineLimit(1) + } + } + Spacer(minLength: 0) + Image(systemName: "line.3.horizontal") + .foregroundStyle(enabled ? Color.white : Color.gray) + .frame(width: 48, height: 48) + } + .frame(height: 56) + .padding(.horizontal, 16) + .background(.black) + }.margins(.all, 0) + cell.isAccessibilityElement = true + cell.accessibilityLabel = thread.title + cell.accessibilityValue = row.section.title + cell.accessibilityHint = "Use actions to change order or section." + cell.accessibilityTraits = .button + cell.accessibilityCustomActions = accessibilityActions(thread, section: row.section) + } else { + let expandable = row.section.orderSection == nil + let isExpanded = expanded.contains(row.section) + cell.contentConfiguration = UIHostingConfiguration { + HStack { + Text(row.section.title).font(.system(size: 14, weight: .semibold)) + Spacer() + if expandable { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + } + } + .foregroundStyle(.white) + .padding(.horizontal, 16) + .frame(height: 48) + .background(.black) + }.margins(.all, 0) + cell.isAccessibilityElement = true + cell.accessibilityLabel = row.section.title + cell.accessibilityValue = expandable ? (expanded.contains(row.section) ? "Expanded" : "Collapsed") : nil + cell.accessibilityHint = nil + cell.accessibilityTraits = expandable ? [.header, .button] : .header + cell.accessibilityCustomActions = [] + } + } + + private func canLift(_ thread: FeatureThread) -> Bool { + guard !thread.isArchived, + parent.model.snapshot.environments.contains(where: { + $0.id == thread.environmentID && $0.isEnabled && $0.connectionState == .connected + }) else { return false } + return ThreadArrangementPlanner.canEnter(thread, section: .pinned, now: .now) + || ThreadArrangementPlanner.canEnter(thread, section: .active, now: .now) + || (thread.supportsSettlement == true && !thread.isEffectivelySettled() && thread.canSettleNow()) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard rows.indices.contains(indexPath.item), rows[indexPath.item].thread == nil, + !parent.model.isArrangingThreads else { return } + let section = rows[indexPath.item].section + guard section.orderSection == nil else { return } + if expanded.contains(section) { expanded.remove(section) } else { expanded.insert(section) } + update() + } + + func collectionView( + _ collectionView: UICollectionView, + itemsForBeginning session: UIDragSession, + at indexPath: IndexPath + ) -> [UIDragItem] { + guard !parent.model.isArrangingThreads, pendingRows == nil, rows.indices.contains(indexPath.item), + let thread = rows[indexPath.item].thread, canLift(thread), + let cell = collectionView.cellForItem(at: indexPath), + session.location(in: cell).x >= cell.bounds.maxX - 64 else { return [] } + draggedID = thread.id + let item = UIDragItem(itemProvider: NSItemProvider(object: thread.id as NSString)) + item.localObject = thread.id + return [item] + } + + func collectionView( + _ collectionView: UICollectionView, + dragSessionIsRestrictedToDraggingApplication session: UIDragSession + ) -> Bool { true } + + func collectionView( + _ collectionView: UICollectionView, + dragSessionAllowsMoveOperation session: UIDragSession + ) -> Bool { true } + + func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) { + draggedID = nil + parent.onAction(nil) + update() + } + + private func destination(at indexPath: IndexPath?, location: CGPoint) -> ThreadArrangementDestination? { + guard let indexPath, indexPath.section == 0 else { return nil } + let frame = view?.layoutAttributesForItem(at: indexPath)?.frame + return ThreadArrangementPlanner.destination( + rows: rows, + insertionIndex: indexPath.item, + isBeforeHeader: frame.map { location.y < $0.minY } ?? false + ) + } + + func collectionView( + _ collectionView: UICollectionView, + dropSessionDidUpdate session: UIDropSession, + withDestinationIndexPath indexPath: IndexPath? + ) -> UICollectionViewDropProposal { + guard session.localDragSession != nil, let id = draggedID, + let destination = destination(at: indexPath, location: session.location(in: collectionView)), + parent.model.arrangementPlan(id: id, destination: destination) != nil else { + parent.onAction(nil) + return UICollectionViewDropProposal(operation: .forbidden) + } + if let source = rows.first(where: { $0.thread?.id == id }) { + parent.onAction(action(source: source.section, destination: destination.section)) + } + return UICollectionViewDropProposal( + operation: .move, + intent: destination.targetID == nil ? .insertIntoDestinationIndexPath : .insertAtDestinationIndexPath + ) + } + + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { + guard let id = draggedID, let item = coordinator.items.first, + let destination = destination( + at: coordinator.destinationIndexPath, + location: coordinator.session.location(in: collectionView) + ), + parent.model.arrangementPlan(id: id, destination: destination) != nil, + let moved = rows.first(where: { $0.thread?.id == id })?.thread else { return } + var next = rows.filter { $0.thread?.id != id } + let target = destination.targetID.flatMap { id in next.firstIndex { $0.thread?.id == id } } + ?? next.firstIndex { $0.thread == nil && $0.section == destination.section } + guard let target else { return } + let insertAt = target + (destination.targetID == nil || destination.after ? 1 : 0) + if destination.section.orderSection != nil || expanded.contains(destination.section) { + next.insert(Row(section: destination.section, thread: moved), at: insertAt) + } + pendingRows = next + let dropPath = IndexPath(item: min(insertAt, next.count - 1), section: 0) + update { + coordinator.drop(item.dragItem, toItemAt: dropPath) + } + commit(id, destination: destination) + } + + private func commit(_ id: String, destination: ThreadArrangementDestination) { + Task { [weak self] in + guard let self else { return } + _ = await parent.model.arrangeThread(id, destination: destination) + pendingRows = nil + draggedID = nil + parent.onAction(nil) + update() + } + } + + private func action(source: ThreadArrangementSection, destination: ThreadArrangementSection) -> String { + if destination == .settled { return "Settle" } + if source == destination { return "Reorder" } + if destination == .pinned { return "Pin" } + if source == .pinned { return "Unpin" } + return source == .settled ? "Unsettle" : "Unsnooze" + } + + private func accessibilityActions(_ thread: FeatureThread, section: ThreadArrangementSection) -> [UIAccessibilityCustomAction] { + var choices: [(String, ThreadArrangementDestination)] = [] + if let orderSection = section.orderSection { + let ordered = DailyUXSidebarIndex.orderedSection(parent.model.snapshot.threads, section: orderSection, now: .now) + if let index = ordered.firstIndex(where: { $0.id == thread.id }) { + if index > 0 { + choices.append(("Move up", .init(section: section, targetID: ordered[index - 1].id))) + } + if index + 1 < ordered.count { + choices.append(("Move down", .init(section: section, targetID: ordered[index + 1].id, after: true))) + } + } + } + for destination in [ThreadArrangementSection.pinned, .active, .settled] where destination != section { + choices.append((action(source: section, destination: destination), .init(section: destination))) + } + return choices.compactMap { name, destination in + guard parent.model.arrangementPlan(id: thread.id, destination: destination) != nil else { return nil } + return UIAccessibilityCustomAction(name: name) { [weak self] _ in + guard let self, self.parent.model.arrangementPlan(id: thread.id, destination: destination) != nil else { return false } + self.commit(thread.id, destination: destination) + return true + } + } + } + } +} 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/ThreadOrderPlanner.swift b/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift new file mode 100644 index 000000000000..164918873d60 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadOrderPlanner.swift @@ -0,0 +1,196 @@ +import Foundation + +/// Swift port of the order-key math shared by web and React Native +/// (`packages/client-runtime/src/state/threadSort.ts`). Order keys are +/// base-26 fractional indices: a move writes one key that sorts between the +/// moved thread's new neighbors, so threads hosted on other servers are never +/// touched and every client converges on the same order. +enum ThreadOrderPlanner { + private static let digits = Array("abcdefghijklmnopqrstuvwxyz") + + /// Whether the thread's environment accepts `thread.pin.reorder` / + /// `thread.active.reorder` writes for this section. + static func isWritable(_ thread: FeatureThread, section: FeatureThreadOrderSection) -> Bool { + switch section { + case .pinned: thread.supportsPinReorder == true + case .active: thread.supportsActiveReorder == true + } + } + + /// Key that sorts strictly between two neighbors; nil bounds mean "top of + /// the arranged run" / "bottom of the keyed run". Returns nil instead of + /// trapping when existing keys are corrupt or out of order — callers fall + /// back to rewriting the section. + static func orderKeyBetween(before: String?, after: String?) -> String? { + let a = before ?? "" + let b = after ?? "" + if !a.isEmpty, !isValidKey(a) { return nil } + if !b.isEmpty, !isValidKey(b) { return nil } + if !b.isEmpty, a >= b { return nil } + return midpoint(a, b) + } + + /// Evenly spaced keys for materializing an order. Wider keys keep a + /// large section from exhausting the space between two-digit keys + /// (`generateSpreadPinOrderKeys` in client-runtime). + static func spreadKeys(count: Int) -> [String] { + guard count > 0 else { return [] } + var width = 2 + var space = digits.count * digits.count + while space <= (count + 1) * 2 { + width += 1 + space *= digits.count + } + let step = Double(space) / Double(count + 1) + var keys: [String] = [] + for index in 0..