From 411ba96443dcb67ba242d439af94020829915e29 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 07:56:24 -0700 Subject: [PATCH 001/154] feat(ios): add native T3 Code app --- apps/swift-ios/.gitignore | 4 + apps/swift-ios/App/NativeFeatureClient.swift | 1088 +++++++++++++++ .../swift-ios/App/NativeWorkspaceMapper.swift | 321 +++++ apps/swift-ios/App/RootView.swift | 17 + apps/swift-ios/App/T3CodeApp.swift | 14 + apps/swift-ios/Core/HTTP.swift | 188 +++ apps/swift-ios/Core/JSONValue.swift | 86 ++ apps/swift-ios/Core/Models.swift | 407 ++++++ apps/swift-ios/Core/PairingService.swift | 148 +++ apps/swift-ios/Core/PairingURL.swift | 108 ++ apps/swift-ios/Core/Persistence.swift | 204 +++ apps/swift-ios/Core/T3Client.swift | 1169 +++++++++++++++++ apps/swift-ios/Core/WebSocketRPC.swift | 425 ++++++ apps/swift-ios/Core/WorkspaceModels.swift | 343 +++++ apps/swift-ios/DesignSystem/T3Theme.swift | 34 + .../Features/Chat/FeatureComposerView.swift | 114 ++ .../Features/Chat/ThreadDetailView.swift | 484 +++++++ .../Connection/ConnectionOnboardingView.swift | 160 +++ .../Features/Files/FeatureFilesView.swift | 238 ++++ .../Features/Review/FeatureReviewView.swift | 269 ++++ .../Features/Root/FeatureRootModel.swift | 260 ++++ .../Features/Root/FeatureRootView.swift | 55 + .../Features/Settings/SettingsView.swift | 150 +++ .../Features/Shared/FeatureClient.swift | 114 ++ .../Features/Shared/FeatureModels.swift | 361 +++++ .../Features/Shared/FeatureToolModels.swift | 294 +++++ .../FeatureSourceControlView.swift | 227 ++++ .../Terminal/FeatureTerminalView.swift | 209 +++ .../Features/Workspace/NewThreadView.swift | 129 ++ .../Workspace/ProjectAndArchiveViews.swift | 135 ++ .../Features/Workspace/WorkspaceView.swift | 335 +++++ apps/swift-ios/README.md | 67 + .../AccentColor.colorset/Contents.json | 20 + .../Resources/Assets.xcassets/Contents.json | 6 + .../T3Code.xcodeproj/project.pbxproj | 496 +++++++ .../xcshareddata/xcschemes/T3Code.xcscheme | 99 ++ .../Tests/CoreTests/CoreContractTests.swift | 97 ++ .../Tests/CoreTests/PairingServiceTests.swift | 85 ++ .../CoreTests/WorkspaceContractTests.swift | 135 ++ .../FeatureTests/FeatureRootModelTests.swift | 135 ++ .../FeatureTests/FeatureToolStateTests.swift | 58 + 41 files changed, 9288 insertions(+) create mode 100644 apps/swift-ios/.gitignore create mode 100644 apps/swift-ios/App/NativeFeatureClient.swift create mode 100644 apps/swift-ios/App/NativeWorkspaceMapper.swift create mode 100644 apps/swift-ios/App/RootView.swift create mode 100644 apps/swift-ios/App/T3CodeApp.swift create mode 100644 apps/swift-ios/Core/HTTP.swift create mode 100644 apps/swift-ios/Core/JSONValue.swift create mode 100644 apps/swift-ios/Core/Models.swift create mode 100644 apps/swift-ios/Core/PairingService.swift create mode 100644 apps/swift-ios/Core/PairingURL.swift create mode 100644 apps/swift-ios/Core/Persistence.swift create mode 100644 apps/swift-ios/Core/T3Client.swift create mode 100644 apps/swift-ios/Core/WebSocketRPC.swift create mode 100644 apps/swift-ios/Core/WorkspaceModels.swift create mode 100644 apps/swift-ios/DesignSystem/T3Theme.swift create mode 100644 apps/swift-ios/Features/Chat/FeatureComposerView.swift create mode 100644 apps/swift-ios/Features/Chat/ThreadDetailView.swift create mode 100644 apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift create mode 100644 apps/swift-ios/Features/Files/FeatureFilesView.swift create mode 100644 apps/swift-ios/Features/Review/FeatureReviewView.swift create mode 100644 apps/swift-ios/Features/Root/FeatureRootModel.swift create mode 100644 apps/swift-ios/Features/Root/FeatureRootView.swift create mode 100644 apps/swift-ios/Features/Settings/SettingsView.swift create mode 100644 apps/swift-ios/Features/Shared/FeatureClient.swift create mode 100644 apps/swift-ios/Features/Shared/FeatureModels.swift create mode 100644 apps/swift-ios/Features/Shared/FeatureToolModels.swift create mode 100644 apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift create mode 100644 apps/swift-ios/Features/Terminal/FeatureTerminalView.swift create mode 100644 apps/swift-ios/Features/Workspace/NewThreadView.swift create mode 100644 apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift create mode 100644 apps/swift-ios/Features/Workspace/WorkspaceView.swift create mode 100644 apps/swift-ios/README.md create mode 100644 apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/swift-ios/Resources/Assets.xcassets/Contents.json create mode 100644 apps/swift-ios/T3Code.xcodeproj/project.pbxproj create mode 100644 apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme create mode 100644 apps/swift-ios/Tests/CoreTests/CoreContractTests.swift create mode 100644 apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift create mode 100644 apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift diff --git a/apps/swift-ios/.gitignore b/apps/swift-ios/.gitignore new file mode 100644 index 00000000000..9a2d42b10dc --- /dev/null +++ b/apps/swift-ios/.gitignore @@ -0,0 +1,4 @@ +.derivedData/ +DerivedData/ +*.xcuserstate +xcuserdata/ diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift new file mode 100644 index 00000000000..11493ef9d41 --- /dev/null +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -0,0 +1,1088 @@ +import Foundation + +/// Composes the transport-focused Core layer with the UI-focused Features layer. +@MainActor +final class NativeFeatureClient: FeatureClient { + private let runtime: EnvironmentRuntime + private let settingsStore: UserDefaults + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var activeEnvironment: Environment? + private var client: T3Client? + private var latestShell: OrchestrationShellSnapshot? + private var archivedThreads: [FeatureThread] = [] + private var latestSnapshot: FeatureSnapshot? + private var loadedThreadIDs = Set() + private var latestDetails: [String: FeatureThreadDetail] = [:] + private var approvalThreadIDs: [String: String] = [:] + private var inputThreadIDs: [String: String] = [:] + private var terminalIDs: [String: String] = [:] + private var terminalSnapshots: [String: FeatureTerminalSnapshot] = [:] + private var terminalContinuations: [ + String: [UUID: AsyncStream.Continuation] + ] = [:] + private var pollingTask: Task? + + init( + runtime: EnvironmentRuntime = EnvironmentRuntime(), + settingsStore: UserDefaults = .standard + ) { + self.runtime = runtime + self.settingsStore = settingsStore + let pair = AsyncStream.makeStream() + stream = pair.stream + continuation = pair.continuation + } + + deinit { + pollingTask?.cancel() + continuation.finish() + } + + func initialSnapshot() async throws -> FeatureSnapshot { + let environments = try await runtime.environments() + guard let environment = try await runtime.activeEnvironment(), + let activeClient = try await runtime.activeClient() else { + let snapshot = disconnectedSnapshot(environments: environments) + latestSnapshot = snapshot + return snapshot + } + + activeEnvironment = environment + client = activeClient + async let shellRequest = activeClient.shellSnapshot() + async let readModelRequest = activeClient.readModel() + let (shell, readModel) = try await (shellRequest, readModelRequest) + latestShell = shell + archivedThreads = readModel.threads + .filter { $0.archivedAt != nil } + .map(mapThread) + startPolling(activeClient) + let snapshot = makeSnapshot( + shell: shell, + environments: environments, + activeEnvironment: environment + ) + 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") + } + activeEnvironment = pairedClient.environment + client = pairedClient + loadedThreadIDs.removeAll() + latestDetails.removeAll() + archivedThreads.removeAll() + startPolling(pairedClient) + } + + func activateEnvironment(id: String) async throws { + pollingTask?.cancel() + let activated = try await runtime.activate(id: id) + activeEnvironment = activated.environment + client = activated + loadedThreadIDs.removeAll() + latestDetails.removeAll() + latestShell = nil + archivedThreads.removeAll() + finishTerminalStreams() + terminalIDs.removeAll() + terminalSnapshots.removeAll() + startPolling(activated) + } + + func removeEnvironment(id: String) async throws { + if activeEnvironment?.id == id { + pollingTask?.cancel() + pollingTask = nil + client = nil + activeEnvironment = nil + latestShell = nil + archivedThreads.removeAll() + loadedThreadIDs.removeAll() + latestDetails.removeAll() + finishTerminalStreams() + terminalIDs.removeAll() + terminalSnapshots.removeAll() + } + try await runtime.remove(id: id) + } + + func disconnect() async { + pollingTask?.cancel() + pollingTask = nil + if let client { + await client.disconnect() + } + client = nil + activeEnvironment = nil + latestShell = nil + archivedThreads.removeAll() + loadedThreadIDs.removeAll() + latestDetails.removeAll() + finishTerminalStreams() + terminalIDs.removeAll() + terminalSnapshots.removeAll() + } + + func addProject(path: String) async throws { + let client = try requireClient() + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NativeFeatureClientError.invalidProjectPath + } + let title = URL(fileURLWithPath: trimmed).lastPathComponent + _ = try await client.createProject( + title: title.isEmpty ? "Project" : title, + workspaceRoot: trimmed, + defaultModel: defaultModelSelection() + ) + try await refresh(client: client) + } + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + let client = try requireClient() + let threadID = UUID().uuidString + let model = modelSelection(selection, projectID: projectID) + let resolvedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) + let threadTitle = resolvedTitle?.isEmpty == false ? resolvedTitle! : "New thread" + _ = try await client.createThread( + threadID: threadID, + projectID: projectID, + title: threadTitle, + model: model, + runtimeMode: .fullAccess + ) + let shell = try await client.shellSnapshot() + latestShell = shell + if let created = shell.threads.first(where: { $0.id == threadID }) { + let mapped = mapThread(created) + await emitSnapshot(shell) + return mapped + } + await emitSnapshot(shell) + return FeatureThread( + id: threadID, + projectID: projectID, + title: threadTitle, + providerID: model.instanceId, + modelID: model.model + ) + } + + func renameThread(id: String, title: String) async throws { + let client = try requireClient() + _ = try await client.rename(threadID: id, title: title) + try await refresh(client: client) + } + + func setThreadArchived(id: String, archived: Bool) async throws { + let client = try requireClient() + _ = try await client.archive(threadID: id, archived: archived) + try await refresh(client: client, includeArchived: true) + } + + func setThreadSettled(id: String, settled: Bool) async throws { + let client = try requireClient() + _ = try await client.settle(threadID: id, settled: settled) + try await refresh(client: client) + } + + func setThreadSnoozed(id: String, until: Date?) async throws { + let client = try requireClient() + _ = try await client.snooze(threadID: id, until: until) + try await refresh(client: client) + } + + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { + let client = try requireClient() + _ = try await client.setRuntimeMode(threadID: id, mode: coreRuntimeMode(mode)) + try await refresh(client: client) + if loadedThreadIDs.contains(id) { + try await refreshThread(id: id, client: client) + } + } + + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws { + let client = try requireClient() + _ = try await client.setInteractionMode(threadID: id, mode: coreInteractionMode(mode)) + try await refresh(client: client) + if loadedThreadIDs.contains(id) { + try await refreshThread(id: id, client: client) + } + } + + func deleteThread(id: String) async throws { + let client = try requireClient() + _ = try await client.delete(threadID: id) + loadedThreadIDs.remove(id) + latestDetails[id] = nil + try await refresh(client: client, includeArchived: true) + } + + func loadThread(id: String) async throws -> FeatureThreadDetail { + let client = try requireClient() + loadedThreadIDs.insert(id) + let snapshot = try await client.threadSnapshot(id: id) + let detail = mapDetail(snapshot.thread) + latestDetails[id] = detail + return detail + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection? + ) async throws { + let client = try requireClient() + guard let shellThread = latestShell?.threads.first(where: { $0.id == threadID }) else { + throw NativeFeatureClientError.threadNotFound + } + + if let selection, + selection.providerID != shellThread.modelSelection.instanceId + || selection.modelID != shellThread.modelSelection.model { + _ = try await client.dispatch( + .object([ + "type": .string("thread.meta.update"), + "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), + "modelSelection": try .encode( + ModelSelection(instanceId: selection.providerID, model: selection.modelID) + ), + ]) + ) + } + + _ = try await client.sendTurn( + threadID: threadID, + text: text, + runtimeMode: shellThread.runtimeMode, + interactionMode: shellThread.interactionMode + ) + try await refreshThread(id: threadID, client: client) + try await refresh(client: client) + } + + func cancelTurn(threadID: String) async throws { + let client = try requireClient() + let turnID = latestShell?.threads + .first(where: { $0.id == threadID })? + .latestTurn? + .turnId + _ = try await client.interrupt(threadID: threadID, turnID: turnID) + try await refresh(client: client) + } + + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws { + let client = try requireClient() + guard let threadID = approvalThreadIDs[id] else { + throw NativeFeatureClientError.approvalNotFound + } + let wireDecision = switch decision { + case .allowOnce: "accept" + case .allowForSession: "acceptForSession" + case .deny: "decline" + } + _ = try await client.respondToApproval( + threadID: threadID, + requestID: id, + decision: wireDecision + ) + try await refreshThread(id: threadID, client: client) + } + + func resolveUserInput(id: String, answers: [String: String]) async throws { + let client = try requireClient() + guard let threadID = inputThreadIDs[id] else { + throw NativeFeatureClientError.inputRequestNotFound + } + _ = try await client.respondToUserInput( + threadID: threadID, + requestID: id, + answers: answers.mapValues(JSONValue.string) + ) + try await refreshThread(id: threadID, client: client) + } + + func saveSettings(_ settings: FeatureSettings) async throws { + let data = try JSONEncoder().encode(settings) + settingsStore.set(data, forKey: Self.settingsKey) + } + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { + let client = try requireClient() + let context = try workspaceContext(threadID: threadID) + let result = try await client.listProjectEntries(cwd: context.cwd) + return NativeWorkspaceMapper.files(result.entries, directory: path) + } + + func readFile(threadID: String, path: String) async throws -> FeatureFileContent { + let client = try requireClient() + let context = try workspaceContext(threadID: threadID) + let result = try await 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 client = try requireClient() + let context = try workspaceContext(threadID: threadID) + let preview = try await client.reviewDiffPreview(cwd: context.cwd) + return NativeWorkspaceMapper.review(preview) + } + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus { + let client = try requireClient() + let context = try workspaceContext(threadID: threadID) + return NativeWorkspaceMapper.sourceControl( + try await client.refreshVCSStatus(cwd: context.cwd) + ) + } + + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws -> FeatureSourceControlStatus { + let client = try requireClient() + let context = try workspaceContext(threadID: threadID) + + 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.") + } + } + } + + return NativeWorkspaceMapper.sourceControl( + try await client.refreshVCSStatus(cwd: context.cwd) + ) + } + + func terminalSnapshot(threadID: String) async throws -> FeatureTerminalSnapshot { + if let snapshot = terminalSnapshots[threadID] { + return snapshot + } + let context = try workspaceContext(threadID: threadID) + return FeatureTerminalSnapshot( + threadID: threadID, + workingDirectory: context.cwd + ) + } + + func terminalEvents(threadID: String) -> AsyncStream { + guard let client else { + return AsyncStream { continuation in continuation.finish() } + } + return AsyncStream { continuation in + let subscriptionID = UUID() + terminalContinuations[threadID, default: [:]][subscriptionID] = continuation + if let snapshot = terminalSnapshots[threadID] { + continuation.yield(snapshot) + } + let task = Task { [weak self] in + do { + for try await event in await client.terminalEvents() { + guard !Task.isCancelled else { break } + guard let self else { break } + guard event.threadId == threadID else { continue } + if let terminalID = self.terminalIDs[threadID], + event.terminalId != nil, + event.terminalId != terminalID { + continue + } + let snapshot = self.consumeTerminalEvent(event, threadID: threadID) + self.publishTerminal(snapshot, threadID: threadID) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + guard let self else { + continuation.finish() + return + } + var snapshot = self.terminalSnapshots[threadID] + ?? FeatureTerminalSnapshot(threadID: threadID) + snapshot.state = .failed + snapshot.error = error.localizedDescription + self.terminalSnapshots[threadID] = snapshot + self.publishTerminal(snapshot, threadID: threadID) + continuation.finish() + } + } + continuation.onTermination = { @Sendable [weak self] _ in + task.cancel() + Task { @MainActor in + self?.terminalContinuations[threadID]?[subscriptionID] = nil + if self?.terminalContinuations[threadID]?.isEmpty == true { + self?.terminalContinuations[threadID] = nil + } + } + } + } + } + + func openTerminal(threadID: String, columns: Int, rows: Int) async throws { + let client = try requireClient() + let context = try workspaceContext(threadID: threadID) + let terminalID = terminalIDs[threadID] ?? UUID().uuidString + terminalIDs[threadID] = terminalID + let snapshot = try await client.openTerminal( + threadID: threadID, + terminalID: terminalID, + cwd: context.cwd, + worktreePath: context.worktreePath, + columns: columns, + rows: rows + ) + let mapped = NativeWorkspaceMapper.terminal(snapshot) + terminalSnapshots[threadID] = mapped + publishTerminal(mapped, threadID: threadID) + } + + func writeTerminal(threadID: String, data: String) async throws { + let client = try requireClient() + let terminalID = try requireTerminalID(threadID: threadID) + try await client.writeTerminal( + threadID: threadID, + terminalID: terminalID, + data: data + ) + } + + func resizeTerminal(threadID: String, columns: Int, rows: Int) async throws { + let client = try requireClient() + let terminalID = try requireTerminalID(threadID: threadID) + try await client.resizeTerminal( + threadID: threadID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + } + + func closeTerminal(threadID: String) async throws { + let client = try requireClient() + let terminalID = try requireTerminalID(threadID: threadID) + try await client.closeTerminal(threadID: threadID, terminalID: terminalID) + terminalIDs[threadID] = nil + let snapshot = FeatureTerminalSnapshot(threadID: threadID) + terminalSnapshots[threadID] = snapshot + publishTerminal(snapshot, threadID: threadID) + } + + private func requireClient() throws -> T3Client { + guard let client else { throw NativeFeatureClientError.notConnected } + return client + } + + private func requireTerminalID(threadID: String) throws -> String { + guard let terminalID = terminalIDs[threadID] else { + throw NativeFeatureClientError.terminalNotOpen + } + return terminalID + } + + private func workspaceContext(threadID: String) throws -> ( + cwd: String, + worktreePath: String? + ) { + guard let thread = latestShell?.threads.first(where: { $0.id == threadID }), + let project = latestShell?.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 + ) -> FeatureTerminalSnapshot { + if let coreSnapshot = event.snapshot { + let snapshot = NativeWorkspaceMapper.terminal(coreSnapshot) + terminalIDs[threadID] = coreSnapshot.terminalId + terminalSnapshots[threadID] = snapshot + return snapshot + } + + var snapshot = terminalSnapshots[threadID] + ?? FeatureTerminalSnapshot(threadID: threadID) + switch event.type { + case "output": + snapshot.buffer.append(NativeWorkspaceMapper.terminalText(event.data ?? "")) + case "exited": + snapshot.state = .exited + snapshot.exitCode = event.exitCode + case "closed": + snapshot.state = .stopped + case "error": + snapshot.state = .failed + snapshot.error = event.message + case "cleared": + snapshot.buffer = "" + case "activity": + snapshot.title = event.label ?? snapshot.title + default: + break + } + terminalSnapshots[threadID] = snapshot + return snapshot + } + + private func publishTerminal( + _ snapshot: FeatureTerminalSnapshot, + threadID: String + ) { + for continuation in terminalContinuations[threadID]?.values ?? [:].values { + continuation.yield(snapshot) + } + } + + private func finishTerminalStreams() { + for continuations in terminalContinuations.values { + for continuation in continuations.values { + continuation.finish() + } + } + terminalContinuations.removeAll() + } + + private func startPolling(_ activeClient: T3Client) { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + do { + await activeClient.connect() + let sequence = self?.latestShell?.snapshotSequence + for try await item in await activeClient.shellEvents(after: sequence) { + guard !Task.isCancelled, let self else { break } + switch item { + case let .snapshot(shell): + await self.consume(shell: shell, client: activeClient) + case .projectUpserted, .projectRemoved, .threadUpserted, .threadRemoved: + if let shell = try? await activeClient.shellSnapshot() { + await self.consume(shell: shell, client: activeClient) + } + case .synchronized: + break + } + } + } catch is CancellationError { + return + } catch { + guard let self else { return } + self.continuation.yield(.failure("Live sync unavailable. Using HTTP updates.")) + do { + for try await shell in await activeClient.pollShell(every: .seconds(2)) { + guard !Task.isCancelled else { break } + await self.consume(shell: shell, client: activeClient) + } + } catch { + self.continuation.yield(.failure(error.localizedDescription)) + } + } + } + } + + private func consume(shell: OrchestrationShellSnapshot, client: T3Client) async { + latestShell = shell + if let readModel = try? await client.readModel() { + archivedThreads = readModel.threads + .filter { $0.archivedAt != nil } + .map(mapThread) + } + await emitSnapshot(shell) + for threadID in loadedThreadIDs { + try? await refreshThread(id: threadID, client: client) + } + } + + private func refresh(client: T3Client, includeArchived: Bool = false) async throws { + let shell = try await client.shellSnapshot() + latestShell = shell + if includeArchived { + let readModel = try await client.readModel() + archivedThreads = readModel.threads + .filter { $0.archivedAt != nil } + .map(mapThread) + } + await emitSnapshot(shell) + } + + private func refreshThread(id: String, client: T3Client) async throws { + let snapshot = try await client.threadSnapshot(id: id) + let detail = mapDetail(snapshot.thread) + guard latestDetails[id] != detail else { return } + latestDetails[id] = detail + continuation.yield(.detail(detail)) + } + + private func emitSnapshot(_ shell: OrchestrationShellSnapshot) async { + guard let environment = activeEnvironment else { return } + let environments = (try? await runtime.environments()) ?? [environment] + let snapshot = makeSnapshot( + shell: shell, + environments: environments, + activeEnvironment: environment + ) + guard latestSnapshot != snapshot else { return } + latestSnapshot = snapshot + continuation.yield(.snapshot(snapshot)) + } + + private func disconnectedSnapshot(environments: [Environment]) -> FeatureSnapshot { + FeatureSnapshot( + connection: .init(state: .disconnected), + environments: environments.map { mapEnvironment($0, activeID: nil) }, + settings: loadSettings() + ) + } + + private func makeSnapshot( + shell: OrchestrationShellSnapshot, + environments: [Environment], + activeEnvironment: Environment + ) -> FeatureSnapshot { + let threads = shell.threads.map(mapThread) + archivedThreads + return FeatureSnapshot( + connection: FeatureConnection( + state: .connected, + environmentName: activeEnvironment.label, + endpoint: activeEnvironment.httpBaseURL.absoluteString + ), + environments: environments.map { + mapEnvironment($0, activeID: activeEnvironment.id) + }, + projects: shell.projects.map { project in + FeatureProject( + id: project.id, + environmentID: activeEnvironment.id, + name: project.title, + path: project.workspaceRoot, + threadCount: threads.lazy.filter { $0.projectID == project.id }.count + ) + }, + threads: threads, + providers: mapProviders(shell), + settings: loadSettings() + ) + } + + private func mapEnvironment(_ environment: Environment, activeID: String?) -> FeatureEnvironment { + FeatureEnvironment( + id: environment.id, + name: environment.label, + endpoint: environment.httpBaseURL.absoluteString, + isActive: environment.id == activeID + ) + } + + private func mapThread(_ thread: OrchestrationThreadShell) -> FeatureThread { + FeatureThread( + id: thread.id, + projectID: thread.projectId, + title: thread.title, + updatedAt: parseDate(thread.updatedAt), + state: mapThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: thread.hasPendingApprovals, + hasUserInput: thread.hasPendingUserInput + ), + providerID: thread.modelSelection.instanceId, + modelID: thread.modelSelection.model, + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + snoozedUntil: thread.snoozedUntil.map(parseDate), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + } + + private func mapThread(_ thread: OrchestrationThread) -> FeatureThread { + FeatureThread( + id: thread.id, + projectID: thread.projectId, + title: thread.title, + preview: thread.messages.last?.text, + updatedAt: parseDate(thread.updatedAt), + state: mapThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: false, + hasUserInput: false + ), + providerID: thread.modelSelection.instanceId, + modelID: thread.modelSelection.model, + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + snoozedUntil: thread.snoozedUntil.map(parseDate), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + } + + private func mapDetail(_ thread: OrchestrationThread) -> FeatureThreadDetail { + let approvals = pendingApprovals(thread) + let userInputs = pendingUserInputs(thread) + let messages = thread.messages.map { message in + FeatureMessage( + id: message.id, + role: mapRole(message.role), + text: message.text, + createdAt: parseDate(message.createdAt), + state: message.streaming ? .streaming : .complete + ) + } + let work = thread.activities.compactMap { activity -> FeatureMessage? in + guard ![ + "approval.requested", + "approval.resolved", + "user-input.requested", + "user-input.resolved", + "context-window.updated", + "checkpoint.captured", + "task.started", + "tool.started", + ].contains(activity.kind), + activity.summary != "Checkpoint captured" else { + return nil + } + let detail = activity.payload["detail"]?.stringValue + let text = detail.map { "\(activity.summary)\n\($0)" } ?? activity.summary + return FeatureMessage( + id: "activity-\(activity.id)", + role: activity.tone == "error" ? .system : .tool, + text: text, + createdAt: parseDate(activity.createdAt), + state: .complete, + toolName: activity.kind + ) + } + let mergedMessages = (messages + work).sorted { $0.createdAt < $1.createdAt } + let mappedThread = FeatureThread( + id: thread.id, + projectID: thread.projectId, + title: thread.title, + preview: thread.messages.last?.text, + updatedAt: parseDate(thread.updatedAt), + state: mapThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: !approvals.isEmpty, + hasUserInput: !userInputs.isEmpty + ), + providerID: thread.modelSelection.instanceId, + modelID: thread.modelSelection.model, + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + snoozedUntil: thread.snoozedUntil.map(parseDate), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + return FeatureThreadDetail( + thread: mappedThread, + messages: mergedMessages, + approvals: approvals, + userInputs: userInputs + ) + } + + private func pendingApprovals(_ thread: OrchestrationThread) -> [FeatureApproval] { + var open: [String: FeatureApproval] = [:] + for activity in thread.activities.sorted(by: { + parseDate($0.createdAt) < parseDate($1.createdAt) + }) { + let requestID = activity.payload["requestId"]?.stringValue + if activity.kind == "approval.requested", let requestID { + let requestKind = activity.payload["requestKind"]?.stringValue + let kind: FeatureApprovalKind = switch requestKind { + case "command": .command + case "file-read": .fileRead + case "file-change": .fileChange + default: .other + } + let detail = activity.payload["detail"]?.stringValue ?? activity.summary + open[requestID] = FeatureApproval( + id: requestID, + threadID: thread.id, + kind: kind, + title: activity.summary, + detail: detail + ) + approvalThreadIDs[requestID] = thread.id + } else if activity.kind == "approval.resolved", let requestID { + open[requestID] = nil + approvalThreadIDs[requestID] = nil + } else if activity.kind == "provider.approval.respond.failed", let requestID { + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + if detail.contains("stale") || detail.contains("unknown") { + open[requestID] = nil + approvalThreadIDs[requestID] = nil + } + } + } + return open.values.sorted { $0.id < $1.id } + } + + private func pendingUserInputs(_ thread: OrchestrationThread) -> [FeatureUserInput] { + var open: [String: FeatureUserInput] = [:] + for activity in thread.activities.sorted(by: { + parseDate($0.createdAt) < parseDate($1.createdAt) + }) { + let requestID = activity.payload["requestId"]?.stringValue + if activity.kind == "user-input.requested", + let requestID, + let questions = parseInputQuestions(activity.payload), + !questions.isEmpty { + open[requestID] = FeatureUserInput( + id: requestID, + threadID: thread.id, + questions: questions + ) + inputThreadIDs[requestID] = thread.id + } else if activity.kind == "user-input.resolved", let requestID { + open[requestID] = nil + inputThreadIDs[requestID] = nil + } else if activity.kind == "provider.user-input.respond.failed", let requestID { + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + if detail.contains("stale") || detail.contains("unknown") { + open[requestID] = nil + inputThreadIDs[requestID] = nil + } + } + } + return open.values.sorted { $0.id < $1.id } + } + + private func parseInputQuestions(_ payload: JSONValue) -> [FeatureInputQuestion]? { + guard case let .array(rawQuestions)? = payload["questions"] else { return nil } + return rawQuestions.compactMap { rawQuestion in + guard case let .object(question) = rawQuestion, + let id = question["id"]?.stringValue, + let header = question["header"]?.stringValue, + let text = question["question"]?.stringValue else { + return nil + } + let options: [FeatureInputOption] + if case let .array(rawOptions)? = question["options"] { + options = rawOptions.compactMap { rawOption in + guard case let .object(option) = rawOption, + let label = option["label"]?.stringValue else { + return nil + } + return FeatureInputOption( + label: label, + detail: option["description"]?.stringValue ?? "" + ) + } + } else { + options = [] + } + let allowsMultiple: Bool + if case let .bool(value)? = question["multiSelect"] { + allowsMultiple = value + } else { + allowsMultiple = false + } + return FeatureInputQuestion( + id: id, + header: header, + question: text, + options: options, + allowsMultiple: allowsMultiple + ) + } + } + + private func mapThreadState( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession?, + hasApprovals: Bool, + hasUserInput: Bool + ) -> FeatureThreadState { + if hasApprovals { return .waitingForApproval } + if hasUserInput { return .waitingForInput } + if session?.status == "starting" { return .queued } + if session?.status == "running" || latestTurn?.state == "running" { return .working } + if session?.status == "error" || latestTurn?.state == "error" { return .failed } + if 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 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(_ mode: InteractionMode) -> FeatureInteractionMode { + mode == .plan ? .plan : .standard + } + + private func coreInteractionMode(_ mode: FeatureInteractionMode) -> InteractionMode { + mode == .plan ? .plan : .default + } + + private func mapProviders(_ 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), + models: (modelsByProvider[providerID] ?? []).sorted().map { + FeatureModel(id: $0, name: $0) + } + ) + } + } + + private func modelSelection( + _ selection: FeatureSelection?, + projectID: String + ) -> ModelSelection { + if let selection { + return ModelSelection(instanceId: selection.providerID, model: selection.modelID) + } + if let projectDefault = latestShell?.projects + .first(where: { $0.id == projectID })? + .defaultModelSelection { + return projectDefault + } + return defaultModelSelection() + } + + private func defaultModelSelection() -> ModelSelection { + if let selection = loadSettings().defaultSelection { + return ModelSelection(instanceId: selection.providerID, model: selection.modelID) + } + return ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") + } + + private func providerDisplayName(_ id: String) -> String { + switch id { + case "codex": "Codex" + case "claudeAgent", "claude": "Claude" + case "cursor": "Cursor" + case "grok": "Grok" + case "opencode": "OpenCode" + default: id + } + } + + private func loadSettings() -> FeatureSettings { + guard let data = settingsStore.data(forKey: Self.settingsKey), + let settings = try? JSONDecoder().decode(FeatureSettings.self, from: data) else { + return FeatureSettings() + } + return settings + } + + private func parseDate(_ value: String) -> Date { + Self.fractionalDateFormatter.date(from: value) + ?? Self.dateFormatter.date(from: value) + ?? .distantPast + } + + private static let settingsKey = "swift-ios.feature-settings.v1" + private static let fractionalDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let dateFormatter = ISO8601DateFormatter() +} + +private enum NativeFeatureClientError: LocalizedError { + case notConnected + case threadNotFound + case workspaceNotFound + case approvalNotFound + case inputRequestNotFound + case invalidProjectPath + case terminalNotOpen + + var errorDescription: String? { + switch self { + case .notConnected: "Connect to a T3 environment first." + case .threadNotFound: "The selected thread is no longer available." + 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 .terminalNotOpen: "Open the terminal before sending input." + } + } +} diff --git a/apps/swift-ios/App/NativeWorkspaceMapper.swift b/apps/swift-ios/App/NativeWorkspaceMapper.swift new file mode 100644 index 00000000000..900982a4faa --- /dev/null +++ b/apps/swift-ios/App/NativeWorkspaceMapper.swift @@ -0,0 +1,321 @@ +import Foundation + +enum NativeWorkspaceMapper { + static func files( + _ entries: [ProjectEntry], + directory: String? + ) -> [FeatureFileEntry] { + let directory = normalize(directory ?? "") + let prefix = directory.isEmpty ? "" : "\(directory)/" + var children: [String: FeatureFileEntry] = [:] + + for entry in entries { + let fullPath = normalize(entry.path) + guard fullPath.hasPrefix(prefix) else { continue } + let remainder = String(fullPath.dropFirst(prefix.count)) + guard !remainder.isEmpty else { continue } + + let component = remainder.split(separator: "/", maxSplits: 1).first.map(String.init)! + let childPath = prefix + component + let isNested = remainder.contains("/") + let kind: FeatureFileKind = isNested || entry.kind == .directory + ? .directory + : .file + if children[childPath]?.kind == .directory { + continue + } + children[childPath] = FeatureFileEntry( + path: childPath, + name: component, + kind: kind, + isHidden: component.hasPrefix(".") + ) + } + + return Array(children.values).featureFiltered(by: "", includesHidden: true) + } + + static func language(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "swift": "swift" + case "ts", "tsx": "typescript" + case "js", "jsx", "mjs", "cjs": "javascript" + case "json": "json" + case "md", "mdx": "markdown" + case "css", "scss": "css" + case "html": "html" + case "sh", "zsh", "bash": "shell" + case "py": "python" + case "rs": "rust" + case "go": "go" + case "yml", "yaml": "yaml" + default: nil + } + } + + static func review(_ preview: ReviewDiffPreview) -> FeatureReview { + FeatureReview( + title: "Working tree", + files: preview.sources.flatMap(parseDiff), + isTruncated: preview.sources.contains(where: \.truncated) + ) + } + + static func sourceControl(_ status: VCSStatus) -> FeatureSourceControlStatus { + FeatureSourceControlStatus( + isRepository: status.isRepo, + branch: status.refName, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + files: status.workingTree.files.map { + FeatureSourceControlFile( + path: $0.path, + state: .modified, + isStaged: false + ) + }, + pullRequest: status.pr.map { + FeaturePullRequest( + number: $0.number, + title: $0.title, + state: $0.state, + url: URL(string: $0.url) + ) + } + ) + } + + 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, + state: terminalState(snapshot.status), + title: snapshot.label, + workingDirectory: snapshot.cwd, + buffer: terminalText(snapshot.history), + exitCode: snapshot.exitCode + ) + } + + private static func terminalState(_ status: TerminalSessionStatus) -> FeatureTerminalState { + switch status { + case .starting: .starting + case .running: .running + case .exited: .exited + case .error: .failed + } + } + + /// This surface is a plain-text terminal viewer, not a VT100 emulator. + static func terminalText(_ value: String) -> String { + let withoutEscapes = value + .replacingOccurrences( + of: "\u{1B}\\][^\u{7}\u{1B}]*(?:\u{7}|\u{1B}\\\\)", + with: "", + options: .regularExpression + ) + .replacingOccurrences( + of: "\u{1B}\\[[0-?]*[ -/]*[@-~]", + with: "", + options: .regularExpression + ) + .replacingOccurrences( + of: "\u{1B}[@-_]", + with: "", + options: .regularExpression + ) + return withoutEscapes.reduce(into: "") { output, character in + if character == "\u{8}" || character == "\u{7F}" { + if !output.isEmpty { output.removeLast() } + return + } + if character == "\r" { return } + if character.unicodeScalars.count == 1, + let scalar = character.unicodeScalars.first, + scalar.value < 32, + character != "\n", + character != "\t" { + return + } + output.append(character) + } + } + + private static func normalize(_ path: String) -> String { + path.replacingOccurrences(of: "\\", with: "/") + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + } + + private static func parseDiff(_ source: ReviewDiffSource) -> [FeatureReviewFile] { + let rawLines = source.diff.split( + separator: "\n", + omittingEmptySubsequences: false + ).map(String.init) + var files: [FeatureReviewFile] = [] + var currentPath: String? + var previousPath: String? + var change = FeatureReviewChangeKind.modified + var lines: [FeatureDiffLine] = [] + var oldLine: Int? + var newLine: Int? + var additions = 0 + var deletions = 0 + + func finishFile() { + guard let currentPath else { return } + files.append( + FeatureReviewFile( + path: currentPath, + previousPath: previousPath, + change: change, + additions: additions, + deletions: deletions, + lines: lines + ) + ) + } + + 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: lines + ), + ] + } + return files + } + + 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 + ?? "" + ) + } +} diff --git a/apps/swift-ios/App/RootView.swift b/apps/swift-ios/App/RootView.swift new file mode 100644 index 00000000000..95d18ab6bdf --- /dev/null +++ b/apps/swift-ios/App/RootView.swift @@ -0,0 +1,17 @@ +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) + .preferredColorScheme(.dark) + } +} diff --git a/apps/swift-ios/App/T3CodeApp.swift b/apps/swift-ios/App/T3CodeApp.swift new file mode 100644 index 00000000000..e36b6690c87 --- /dev/null +++ b/apps/swift-ios/App/T3CodeApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct T3CodeApp: App { + @State private var client = NativeFeatureClient() + + var body: some Scene { + WindowGroup { + RootView { + FeatureRootView(client: client) + } + } + } +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift new file mode 100644 index 00000000000..3ba43180f6d --- /dev/null +++ b/apps/swift-ios/Core/HTTP.swift @@ -0,0 +1,188 @@ +import Foundation + +public protocol HTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +public struct URLSessionHTTPTransport: HTTPTransport { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw HTTPError.invalidResponse + } + return (data, httpResponse) + } +} + +public enum HTTPError: LocalizedError, Sendable { + case invalidResponse + case status(Int, message: String, traceID: String?) + case missingCredential + + 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." + } + } +} + +private struct ErrorBody: Decodable { + let message: String? + let reason: String? + let traceId: String? +} + +public actor EnvironmentAPI { + private let transport: any HTTPTransport + private let credentials: any CredentialStore + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + credentials: any CredentialStore + ) { + self.transport = transport + self.credentials = credentials + } + + 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) async throws + -> OrchestrationShellSnapshot + { + try await authorized( + environment: environment, + path: "/api/orchestration/shell", + method: "GET", + 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 + ) async throws -> OrchestrationThreadDetailSnapshot { + let encodedID = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return try await authorized( + environment: environment, + path: "/api/orchestration/threads/\(encodedID)", + method: "GET", + 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 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", + as: AuthSessionState.self + ) + } + + private func authorized( + environment: Environment, + path: String, + method: String, + body: Data? = nil, + as type: Result.Type + ) async throws -> Result { + guard let credential = try await credentials.credential(for: environment.id) else { + throw HTTPError.missingCredential + } + var request = URLRequest(url: endpoint(environment.httpBaseURL, path: path)) + request.httpMethod = method + request.httpBody = body + request.setValue("Bearer \(credential.accessToken)", forHTTPHeaderField: "Authorization") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return try await send(request, as: type) + } + + private func send( + _ request: URLRequest, + as type: Result.Type + ) async throws -> Result { + let (data, response) = try await transport.data(for: request) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(ErrorBody.self, from: data) + throw HTTPError.status( + response.statusCode, + message: body?.message ?? body?.reason ?? "Environment request failed.", + traceID: body?.traceId + ) + } + return try JSONDecoder.t3.decode(type, from: data) + } +} + +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? +} + +func endpoint(_ baseURL: URL, path: String) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.path = path + components.query = nil + 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 00000000000..16ba8ad98b9 --- /dev/null +++ b/apps/swift-ios/Core/JSONValue.swift @@ -0,0 +1,86 @@ +import Foundation + +/// A lossless, Sendable JSON representation used at protocol boundaries that +/// intentionally carry provider-defined payloads. +public enum JSONValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case 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(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 .number(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } + + public subscript(key: String) -> JSONValue? { + guard case let .object(object) = self else { return nil } + return object[key] + } + + public var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + public static func encode( + _ value: T, + encoder: JSONEncoder = .t3 + ) throws -> JSONValue { + let data = try encoder.encode(value) + return try JSONDecoder.t3.decode(JSONValue.self, from: data) + } + + public func decode( + _ type: T.Type, + decoder: JSONDecoder = .t3 + ) throws -> T { + try decoder.decode(type, from: JSONEncoder.t3.encode(self)) + } +} + +extension JSONEncoder { + public static var t3: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } +} + +extension JSONDecoder { + public static var t3: JSONDecoder { + JSONDecoder() + } +} diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift new file mode 100644 index 00000000000..463df1fd22c --- /dev/null +++ b/apps/swift-ios/Core/Models.swift @@ -0,0 +1,407 @@ +import Foundation + +public enum EnvironmentKind: String, Codable, Sendable { + case bearer + case local +} + +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 init( + id: String, + label: String, + httpBaseURL: URL, + webSocketBaseURL: URL, + kind: EnvironmentKind = .bearer, + descriptor: EnvironmentDescriptor? = nil + ) { + self.id = id + self.label = label + self.httpBaseURL = httpBaseURL + self.webSocketBaseURL = webSocketBaseURL + self.kind = kind + self.descriptor = descriptor + } +} + +public struct EnvironmentDescriptor: Codable, Equatable, Sendable { + public struct Platform: Codable, Equatable, Sendable { + public let os: String + public let arch: String + } + + public struct Capabilities: Codable, Equatable, Sendable { + public let repositoryIdentity: Bool + public let connectionProbe: Bool? + public let threadSettlement: Bool? + public let threadSnooze: Bool? + public let threadTitleRegeneration: Bool? + public let serverSelfUpdate: String? + public let serverSelfUpdateProgress: Bool? + + private enum CodingKeys: String, CodingKey { + case repositoryIdentity + case connectionProbe + case threadSettlement + case threadSnooze + case threadTitleRegeneration + case serverSelfUpdate + case serverSelfUpdateProgress + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + repositoryIdentity = + try container.decodeIfPresent(Bool.self, forKey: .repositoryIdentity) ?? false + connectionProbe = try container.decodeIfPresent(Bool.self, forKey: .connectionProbe) + threadSettlement = try container.decodeIfPresent(Bool.self, forKey: .threadSettlement) + threadSnooze = try container.decodeIfPresent(Bool.self, forKey: .threadSnooze) + threadTitleRegeneration = try container.decodeIfPresent( + Bool.self, + forKey: .threadTitleRegeneration + ) + 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 EnvironmentCredential: Codable, Equatable, Sendable { + public let accessToken: String + public let expiresAt: Date? + public let scopes: [String] + + public init(accessToken: String, expiresAt: Date? = nil, scopes: [String] = []) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + } +} + +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 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 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 let latestTurn: OrchestrationLatestTurn? + public let createdAt: String + public let updatedAt: String + public let archivedAt: String? + public let settledOverride: String? + public let settledAt: String? + public let snoozedUntil: String? + public let snoozedAt: String? + public let session: OrchestrationSession? + public let latestUserMessageAt: String? + public let hasPendingApprovals: Bool + public let hasPendingUserInput: Bool + public let hasActionableProposedPlan: Bool +} + +public struct OrchestrationMessage: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let role: String + public let text: String + public let attachments: [ChatAttachment]? + public let turnId: String? + public let streaming: Bool + public let createdAt: String + public let updatedAt: String +} + +public struct ChatAttachment: Codable, Identifiable, Equatable, Sendable { + public let type: String + public let id: String + public let name: String + public let mimeType: String + public let sizeBytes: Int +} + +public struct OrchestrationActivity: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let tone: String + public let kind: String + public let summary: String + public let payload: JSONValue + public let turnId: String? + public let sequence: Int? + public let createdAt: String +} + +public struct CheckpointFile: Codable, Equatable, Sendable { + public let path: String + public let kind: String + public let additions: Int + public let deletions: Int +} + +public struct CheckpointSummary: Codable, Equatable, Sendable { + public let turnId: String + public let checkpointTurnCount: Int + public let checkpointRef: String + public let status: String + public let files: [CheckpointFile] + public let assistantMessageId: String? + public let completedAt: String +} + +public struct OrchestrationThread: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let projectId: String + public let title: String + public let modelSelection: ModelSelection + public let runtimeMode: RuntimeMode + public let interactionMode: InteractionMode + public let branch: String? + public let worktreePath: String? + public let latestTurn: OrchestrationLatestTurn? + public let createdAt: String + public let updatedAt: String + public let archivedAt: String? + public let settledOverride: String? + public let settledAt: String? + public let snoozedUntil: String? + public let snoozedAt: String? + public let deletedAt: String? + public let messages: [OrchestrationMessage] + public let activities: [OrchestrationActivity] + public let checkpoints: [CheckpointSummary] + public let session: OrchestrationSession? +} + +public struct OrchestrationShellSnapshot: Codable, Equatable, Sendable { + public let snapshotSequence: Int + public let projects: [OrchestrationProject] + public let threads: [OrchestrationThreadShell] + public let updatedAt: String +} + +public struct OrchestrationReadModel: Codable, Equatable, Sendable { + public let snapshotSequence: Int + public let projects: [OrchestrationProject] + public let threads: [OrchestrationThread] + public let updatedAt: String +} + +public struct OrchestrationThreadDetailSnapshot: Codable, Equatable, Sendable { + public let snapshotSequence: Int + public let thread: OrchestrationThread +} + +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) + + 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": + self = .snapshot(try container.decode(OrchestrationShellSnapshot.self, forKey: .snapshot)) + case "project-upserted": + self = .projectUpserted( + sequence: try container.decode(Int.self, forKey: .sequence), + project: try container.decode(OrchestrationProject.self, forKey: .project) + ) + case "project-removed": + self = .projectRemoved( + sequence: try container.decode(Int.self, forKey: .sequence), + projectID: try container.decode(String.self, forKey: .projectId) + ) + case "thread-upserted": + self = .threadUpserted( + sequence: try container.decode(Int.self, forKey: .sequence), + thread: try container.decode(OrchestrationThreadShell.self, forKey: .thread) + ) + case "thread-removed": + self = .threadRemoved( + sequence: try container.decode(Int.self, forKey: .sequence), + threadID: try container.decode(String.self, forKey: .threadId) + ) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown shell stream item \(kind)" + ) + } + } +} + +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": + self = .snapshot( + try container.decode(OrchestrationThreadDetailSnapshot.self, forKey: .snapshot) + ) + case "event": + self = .event(try container.decode(JSONValue.self, forKey: .event)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown thread stream item \(kind)" + ) + } + } +} diff --git a/apps/swift-ios/Core/PairingService.swift b/apps/swift-ios/Core/PairingService.swift new file mode 100644 index 00000000000..4643fc9ed5e --- /dev/null +++ b/apps/swift-ios/Core/PairingService.swift @@ -0,0 +1,148 @@ +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 { + public static let standardScopes = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + ] + + 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. + try await credentialStore.setCredential(credential, for: environment.id) + do { + try await environmentStore.upsert(environment) + if try await environmentStore.activeEnvironmentID() == nil { + try await environmentStore.setActiveEnvironment(id: environment.id) + } + } catch { + try? await credentialStore.removeCredential(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: "scope", value: Self.standardScopes.joined(separator: " ")), + URLQueryItem(name: "client_device_type", value: "mobile"), + URLQueryItem(name: "client_os", value: "iOS"), + ] + if let clientLabel, !clientLabel.isEmpty { + fields.append(URLQueryItem(name: "client_label", value: clientLabel)) + } + var form = URLComponents() + form.queryItems = fields + var request = URLRequest(url: endpoint(target.httpBaseURL, path: "/oauth/token")) + request.httpMethod = "POST" + request.httpBody = form.percentEncodedQuery?.data(using: .utf8) + request.setValue( + "application/x-www-form-urlencoded", + forHTTPHeaderField: "Content-Type" + ) + let (data, response) = try await transport.data(for: 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 00000000000..2df3dccb264 --- /dev/null +++ b/apps/swift-ios/Core/PairingURL.swift @@ -0,0 +1,108 @@ +import Foundation + +public struct PairingTarget: Equatable, Sendable { + public let credential: String + public let httpBaseURL: URL + public let webSocketBaseURL: URL +} + +public enum PairingURLError: LocalizedError, Equatable { + case invalidURL + case unsupportedScheme + case missingToken + case missingHost + + public var errorDescription: String? { + switch self { + 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." + } + } +} + +public enum PairingURL { + public static func resolve(_ rawValue: String) throws -> PairingTarget { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmed), components.url != nil else { + throw PairingURLError.invalidURL + } + guard ["http", "https", "ws", "wss"].contains(components.scheme?.lowercased() ?? "") else { + throw PairingURLError.unsupportedScheme + } + + let query = components.queryItems ?? [] + let fragment = URLComponents(string: "?\(components.fragment ?? "")")?.queryItems ?? [] + let token = (fragment + query) + .first(where: { $0.name == "token" })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let hosted = query.first(where: { $0.name == "host" })?.value, + !hosted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return try directTarget(host: hosted, credential: requireToken(token)) + } + + components.path = "/" + components.query = nil + components.fragment = nil + guard let base = components.url, base.host != nil else { throw PairingURLError.missingHost } + return try target(baseURL: base, credential: requireToken(token)) + } + + public static func resolve(host: String, pairingCode: String) throws -> PairingTarget { + let normalized = host.contains("://") ? host : "https://\(host)" + guard let url = URL(string: normalized), url.host != nil else { + throw PairingURLError.invalidURL + } + return try directTarget(host: normalized, credential: requireToken(pairingCode)) + } + + private static func directTarget(host: String, credential: String) throws -> PairingTarget { + let normalized = host.contains("://") ? host : "https://\(host)" + guard var components = URLComponents(string: normalized), let url = components.url else { + throw PairingURLError.invalidURL + } + guard ["http", "https", "ws", "wss"].contains(components.scheme?.lowercased() ?? "") else { + throw PairingURLError.unsupportedScheme + } + components.path = "/" + components.query = nil + components.fragment = nil + guard let base = components.url, url.host != nil else { throw PairingURLError.missingHost } + return try target(baseURL: base, credential: credential) + } + + 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 requireToken(_ token: String?) throws -> String { + let trimmed = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { throw PairingURLError.missingToken } + return trimmed + } +} diff --git a/apps/swift-ios/Core/Persistence.swift b/apps/swift-ios/Core/Persistence.swift new file mode 100644 index 00000000000..bfc1c8fdcbd --- /dev/null +++ b/apps/swift-ios/Core/Persistence.swift @@ -0,0 +1,204 @@ +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 removeCredential(for environmentID: String) async 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 let service: String + private let accessibility: CFString + + public init( + service: String = "codes.t3.swift-ios.environment-credentials", + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) { + self.service = service + self.accessibility = accessibility + } + + public func credential(for environmentID: String) throws -> EnvironmentCredential? { + 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 + } + } + + public func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws { + 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) + } + } + + public func removeCredential(for environmentID: String) throws { + 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 actor EnvironmentStore { + private struct Document: Codable { + let version: Int + var environments: [Environment] + var activeEnvironmentID: String? + } + + public let fileURL: URL + + 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) + } + + 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?.id + } + try save(document) + return document.environments + } + + private func loadDocument() throws -> Document { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return Document(version: 1, environments: [], activeEnvironmentID: nil) + } + let data = try Data(contentsOf: fileURL) + return try JSONDecoder.t3.decode(Document.self, from: data) + } + + 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) + } +} diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift new file mode 100644 index 00000000000..6fe2a279945 --- /dev/null +++ b/apps/swift-ios/Core/T3Client.swift @@ -0,0 +1,1169 @@ +import Foundation + +public actor T3Client { + public let environment: Environment + private let api: EnvironmentAPI + private let rpc: WebSocketRPCClient + + public init( + environment: Environment, + credentialStore: any CredentialStore, + httpTransport: any HTTPTransport = URLSessionHTTPTransport(), + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector() + ) { + self.environment = environment + let api = EnvironmentAPI(transport: httpTransport, credentials: credentialStore) + self.api = api + self.rpc = WebSocketRPCClient(connector: webSocketConnector) { + 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" } + query.append(URLQueryItem(name: "wsTicket", value: ticket.ticket)) + 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 { + await rpc.stop() + } + + public func shellSnapshot() async throws -> OrchestrationShellSnapshot { + try await api.shellSnapshot(for: environment) + } + + public func readModel() async throws -> OrchestrationReadModel { + try await api.readModel(for: environment) + } + + public func threadSnapshot(id: String) async throws -> OrchestrationThreadDetailSnapshot { + try await api.threadSnapshot(id: id, environment: environment) + } + + /// HTTP live-sync fallback. Each iteration is an independent request, so a + /// transient network loss naturally reconnects without replaying commands. + public func pollShell( + every interval: Duration = .seconds(2) + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + var lastSequence: Int? + while !Task.isCancelled { + do { + let snapshot = try await self.shellSnapshot() + if lastSequence != snapshot.snapshotSequence { + lastSequence = snapshot.snapshotSequence + continuation.yield(snapshot) + } + } catch is CancellationError { + break + } catch { + // Keep retrying transient HTTP failures. Authentication + // failures surface from direct loads and pairing UI. + } + try? await Task.sleep(for: interval) + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + public func shellEvents( + after sequence: Int? = nil + ) async -> AsyncThrowingStream { + var payload: [String: JSONValue] = ["requestCompletionMarker": .bool(true)] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + return await rpc.subscribe( + RPCMethod.subscribeShell.rawValue, + payload: .object(payload), + as: ShellStreamItem.self + ) + } + + public func threadEvents( + threadID: String, + after sequence: Int? = nil + ) async -> AsyncThrowingStream { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "requestCompletionMarker": .bool(true), + ] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + return await rpc.subscribe( + RPCMethod.subscribeThread.rawValue, + payload: .object(payload), + as: ThreadStreamItem.self + ) + } + + @discardableResult + public func dispatch(_ command: JSONValue) async throws -> DispatchResult { + try await api.dispatch(command, environment: environment) + } + + @discardableResult + public func sendTurn( + threadID: String, + text: String, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.sendTurn( + threadID: threadID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode + ) + ) + } + + @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 + ) + ) + } + + @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 interrupt(threadID: String, turnID: String? = nil) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.interrupt(threadID: threadID, turnID: turnID) + ) + } + + @discardableResult + public func respondToApproval( + threadID: String, + requestID: String, + decision: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.respondToApproval( + threadID: threadID, + requestID: requestID, + decision: decision + ) + ) + } + + @discardableResult + public func respondToUserInput( + threadID: String, + requestID: String, + answers: [String: JSONValue] + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.respondToUserInput( + threadID: threadID, + requestID: requestID, + answers: answers + ) + ) + } + + @discardableResult + public func settle(threadID: String, settled: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.settle(threadID: threadID, settled: settled)) + } + + @discardableResult + public func snooze(threadID: String, until: Date?) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.snooze(threadID: threadID, until: until) + ) + } + + @discardableResult + public func setRuntimeMode( + threadID: String, + mode: RuntimeMode + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.setRuntimeMode(threadID: threadID, mode: mode) + ) + } + + @discardableResult + public func setInteractionMode( + threadID: String, + mode: InteractionMode + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.setInteractionMode(threadID: threadID, mode: mode) + ) + } + + // MARK: Workspace files + + public func listProjectEntries(cwd: String) async throws -> ProjectEntriesResult { + try await rpc.request( + RPCMethod.projectsListEntries.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: ProjectEntriesResult.self + ) + } + + public func searchProjectEntries( + cwd: String, + query: String, + limit: Int = 100 + ) async throws -> ProjectEntriesResult { + try await rpc.request( + RPCMethod.projectsSearchEntries.rawValue, + payload: .object([ + "cwd": .string(cwd), + "query": .string(query), + "limit": .number(Double(limit)), + ]), + as: ProjectEntriesResult.self + ) + } + + public func readProjectFile( + cwd: String, + relativePath: String + ) async throws -> ProjectReadFileResult { + try await rpc.request( + RPCMethod.projectsReadFile.rawValue, + payload: .object([ + "cwd": .string(cwd), + "relativePath": .string(relativePath), + ]), + as: ProjectReadFileResult.self + ) + } + + public func writeProjectFile( + cwd: String, + relativePath: String, + contents: String + ) async throws -> ProjectWriteFileResult { + try await rpc.request( + RPCMethod.projectsWriteFile.rawValue, + payload: .object([ + "cwd": .string(cwd), + "relativePath": .string(relativePath), + "contents": .string(contents), + ]), + as: ProjectWriteFileResult.self + ) + } + + public func browseFilesystem( + partialPath: String, + cwd: String? = nil + ) async throws -> FilesystemBrowseResult { + var payload: [String: JSONValue] = ["partialPath": .string(partialPath)] + if let cwd { payload["cwd"] = .string(cwd) } + return try await rpc.request( + RPCMethod.filesystemBrowse.rawValue, + payload: .object(payload), + as: FilesystemBrowseResult.self + ) + } + + // 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 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 + ) + } + + // 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 actor EnvironmentRuntime { + public let environmentStore: EnvironmentStore + public let credentialStore: any CredentialStore + private let httpTransport: any HTTPTransport + private let webSocketConnector: any WebSocketConnecting + private var clients: [String: T3Client] = [:] + + public init( + environmentStore: EnvironmentStore = EnvironmentStore(), + credentialStore: any CredentialStore = KeychainCredentialStore(), + httpTransport: any HTTPTransport = URLSessionHTTPTransport(), + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector() + ) { + self.environmentStore = environmentStore + self.credentialStore = credentialStore + self.httpTransport = httpTransport + self.webSocketConnector = webSocketConnector + } + + public func environments() async throws -> [Environment] { + try await environmentStore.load() + } + + public func activeEnvironment() async throws -> Environment? { + let environments = try await environmentStore.load() + guard !environments.isEmpty else { return nil } + let activeID = try await environmentStore.activeEnvironmentID() + return environments.first(where: { $0.id == activeID }) ?? environments[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.") + } + try await environmentStore.setActiveEnvironment(id: id) + return client(for: environment) + } + + public func activeClient() async throws -> T3Client? { + guard let environment = try await activeEnvironment() else { return nil } + return 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 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 client(for: environment) + } + + public func remove(id: String) async throws { + if let client = clients.removeValue(forKey: id) { + await client.disconnect() + } + try await credentialStore.removeCredential(for: id) + try await environmentStore.remove(id: id) + } + + private func client(for environment: Environment) -> T3Client { + if let existing = clients[environment.id] { return existing } + let client = T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector + ) + clients[environment.id] = client + return client + } +} + +public enum RPCMethod: String, Sendable { + case serverProbe = "server.probe" + case serverGetConfig = "server.getConfig" + case dispatchCommand = "orchestration.dispatchCommand" + 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 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 terminalOpen = "terminal.open" + case terminalAttach = "terminal.attach" + case terminalWrite = "terminal.write" + case terminalResize = "terminal.resize" + case terminalClear = "terminal.clear" + case terminalRestart = "terminal.restart" + case terminalClose = "terminal.close" + case subscribeTerminalEvents + case subscribeTerminalMetadata +} + +public enum OrchestrationCommands { + public static func createThread( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + .object([ + "type": .string("thread.create"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "projectId": .string(projectID), + "title": .string(title), + "modelSelection": try .encode(model), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "branch": branch.map(JSONValue.string) ?? .null, + "worktreePath": worktreePath.map(JSONValue.string) ?? .null, + "createdAt": .string(createdAt), + ]) + } + + public static func createProject( + projectID: String = UUID().uuidString, + title: String, + workspaceRoot: String, + defaultModel: ModelSelection? = nil, + createWorkspaceRootIfMissing: Bool = false, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("project.create"), + "commandId": .string(commandID), + "projectId": .string(projectID), + "title": .string(title), + "workspaceRoot": .string(workspaceRoot), + "createWorkspaceRootIfMissing": .bool(createWorkspaceRootIfMissing), + "createdAt": .string(createdAt), + ] + if let defaultModel { + value["defaultModelSelection"] = try JSONValue.encode(defaultModel) + } else { + value["defaultModelSelection"] = .null + } + return .object(value) + } + + public static func sendTurn( + threadID: String, + text: String, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object([ + "messageId": .string(messageID), + "role": .string("user"), + "text": .string(text), + "attachments": .array([]), + ]), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "createdAt": .string(createdAt), + ]) + } + + 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, + 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) + return .object([ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object([ + "messageId": .string(messageID), + "role": .string("user"), + "text": .string(text), + "attachments": .array([]), + ]), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "bootstrap": .object(["createThread": .object(create)]), + "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 interrupt( + threadID: String, + turnID: String?, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("thread.turn.interrupt"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "createdAt": .string(createdAt), + ] + if let turnID { value["turnId"] = .string(turnID) } + return .object(value) + } + + public static func respondToApproval( + threadID: String, + requestID: String, + decision: String, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.approval.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "decision": .string(decision), + "createdAt": .string(createdAt), + ]) + } + + public static func respondToUserInput( + threadID: String, + requestID: String, + answers: [String: JSONValue], + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.user-input.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "answers": .object(answers), + "createdAt": .string(createdAt), + ]) + } + + public static func settle( + threadID: String, + settled: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + var value = basic( + type: settled ? "thread.settle" : "thread.unsettle", + threadID: threadID, + commandID: commandID + ) + if !settled, case var .object(object) = value { + object["reason"] = .string("user") + value = .object(object) + } + return value + } + + public static func snooze( + threadID: String, + until: Date?, + commandID: String = UUID().uuidString + ) -> JSONValue { + if let until { + return .object([ + "type": .string("thread.snooze"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "snoozedUntil": .string(ISO8601DateFormatter().string(from: until)), + ]) + } + return .object([ + "type": .string("thread.unsnooze"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "reason": .string("user"), + ]) + } + + 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 { + ISO8601DateFormatter().string(from: Date()) + } +} diff --git a/apps/swift-ios/Core/WebSocketRPC.swift b/apps/swift-ios/Core/WebSocketRPC.swift new file mode 100644 index 00000000000..8b38e6dcb8c --- /dev/null +++ b/apps/swift-ios/Core/WebSocketRPC.swift @@ -0,0 +1,425 @@ +import Foundation + +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 struct URLSessionWebSocketConnector: WebSocketConnecting { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func connect(to url: URL) async throws -> any WebSocketConnection { + let connection = URLSessionWebSocketConnection(session: session, url: url) + await connection.open() + return connection + } +} + +private actor URLSessionWebSocketConnection: WebSocketConnection { + private let task: URLSessionWebSocketTask + + init(session: URLSession, url: URL) { + task = session.webSocketTask(with: url) + } + + 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 disconnected + case remote(String) + case protocolViolation(String) + + public var errorDescription: String? { + switch self { + case .disconnected: "The environment disconnected." + case let .remote(message): message + case let .protocolViolation(message): message + } + } +} + +private struct RPCRequestEnvelope: Encodable, Sendable { + let _tag = "Request" + let id: Int + let tag: String + let payload: JSONValue + let headers: [[String]] +} + +private struct RPCControlEnvelope: Encodable, Sendable { + let _tag: String + let requestId: Int? + + init(_ tag: String, requestID: Int? = nil) { + _tag = tag + requestId = requestID + } +} + +private struct RPCResponseEnvelope: Decodable, Sendable { + struct Exit: Decodable, Sendable { + struct Cause: Decodable, Sendable { + let _tag: String + let error: JSONValue? + let defect: JSONValue? + } + + let _tag: String + let value: JSONValue? + let cause: [Cause]? + } + + let _tag: String + let requestId: Int? + let values: [JSONValue]? + let exit: Exit? + let defect: JSONValue? +} + +/// Implements Effect RPC's JSON socket framing. Subscriptions survive +/// reconnects; unary calls that crossed a broken connection fail rather than +/// being replayed, because replaying a command could duplicate side effects. +public actor WebSocketRPCClient { + public typealias EndpointProvider = @Sendable () async throws -> URL + + private struct UnaryRequest { + let envelope: RPCRequestEnvelope + var sent: Bool + let resume: @Sendable (Result) -> Void + } + + private struct Subscription { + let tag: String + let payload: JSONValue + let reconnect: Bool + var requestID: Int? + let yield: @Sendable (JSONValue) -> Void + let finish: @Sendable (Error?) -> Void + } + + private let connector: any WebSocketConnecting + private let endpointProvider: EndpointProvider + private var connection: (any WebSocketConnection)? + private var loopTask: Task? + private var keepaliveTask: Task? + private var desired = false + private var nextRequestID = 1 + private var unary: [Int: UnaryRequest] = [:] + private var subscriptions: [UUID: Subscription] = [:] + private var subscriptionByRequestID: [Int: UUID] = [:] + + public init( + connector: any WebSocketConnecting = URLSessionWebSocketConnector(), + endpointProvider: @escaping EndpointProvider + ) { + self.connector = connector + self.endpointProvider = endpointProvider + } + + deinit { + loopTask?.cancel() + keepaliveTask?.cancel() + } + + public func start() { + desired = true + guard loopTask == nil else { return } + loopTask = Task { await self.connectionLoop() } + } + + public func stop() async { + desired = false + loopTask?.cancel() + loopTask = nil + keepaliveTask?.cancel() + keepaliveTask = nil + if let connection { + await connection.close() + } + connection = nil + failUnary(RPCError.disconnected, includingUnsent: true) + let active = subscriptions.values + subscriptions.removeAll() + subscriptionByRequestID.removeAll() + active.forEach { $0.finish(RPCError.disconnected) } + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]), + as type: Result.Type + ) async throws -> Result { + let raw = try await requestRaw(tag, payload: payload) + return try raw.decode(type) + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]) + ) async throws { + _ = try await requestRaw(tag, payload: payload) + } + + public func subscribe( + _ tag: String, + payload: JSONValue = .object([:]), + reconnect: Bool = true, + as type: Value.Type + ) -> AsyncThrowingStream { + let subscriptionID = UUID() + return AsyncThrowingStream { continuation in + subscriptions[subscriptionID] = Subscription( + tag: tag, + payload: payload, + reconnect: reconnect, + requestID: nil, + yield: { value in + do { + continuation.yield(try value.decode(type)) + } catch { + continuation.finish(throwing: error) + } + }, + finish: { error in + if let error { + continuation.finish(throwing: error) + } else { + continuation.finish() + } + } + ) + continuation.onTermination = { @Sendable _ in + Task { await self.removeSubscription(subscriptionID) } + } + if connection != nil { + Task { await self.sendSubscription(subscriptionID) } + } + start() + } + } + + private func requestRaw(_ tag: String, payload: JSONValue) async throws -> JSONValue { + start() + let id = allocateRequestID() + let envelope = RPCRequestEnvelope( + id: id, + tag: tag, + payload: payload, + headers: [] + ) + return try await withCheckedThrowingContinuation { continuation in + unary[id] = UnaryRequest( + envelope: envelope, + sent: false, + resume: { continuation.resume(with: $0) } + ) + if connection != nil { + Task { await self.sendUnary(id) } + } + } + } + + private func connectionLoop() async { + var retry = 0 + while desired, !Task.isCancelled { + do { + let url = try await endpointProvider() + let opened = try await connector.connect(to: url) + connection = opened + retry = 0 + await connected() + try await receiveLoop(opened) + } catch is CancellationError { + break + } catch { + await disconnected() + guard desired, !Task.isCancelled else { break } + retry += 1 + let delay = min(5.0, 0.35 * pow(1.7, Double(retry - 1))) + try? await Task.sleep(for: .seconds(delay)) + } + } + loopTask = nil + } + + private func connected() async { + keepaliveTask?.cancel() + keepaliveTask = Task { await self.keepaliveLoop() } + for id in unary.keys { + await sendUnary(id) + } + subscriptionByRequestID.removeAll() + for id in subscriptions.keys { + await sendSubscription(id) + } + } + + private func disconnected() async { + keepaliveTask?.cancel() + keepaliveTask = nil + connection = nil + 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(RPCError.disconnected) + } + for id in subscriptions.keys { + subscriptions[id]?.requestID = nil + } + } + + private func receiveLoop(_ opened: any WebSocketConnection) async throws { + while desired, !Task.isCancelled { + let data = try await opened.receive() + let response = try JSONDecoder.t3.decode(RPCResponseEnvelope.self, from: data) + try await handle(response) + } + } + + 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 } + response.values?.forEach(subscription.yield) + try await sendControl("Ack", requestID: requestID) + case "Exit": + guard let requestID = response.requestId, let exit = response.exit else { return } + if var request = unary.removeValue(forKey: requestID) { + request.sent = false + if exit._tag == "Success" { + request.resume(.success(exit.value ?? .null)) + } else { + request.resume(.failure(remoteError(exit))) + } + return + } + guard let subscriptionID = subscriptionByRequestID.removeValue(forKey: requestID), + let subscription = subscriptions.removeValue(forKey: subscriptionID) + else { return } + subscription.finish(exit._tag == "Success" ? nil : remoteError(exit)) + case "Defect", "ClientProtocolError": + throw RPCError.remote( + response.defect?.stringValue ?? "The server reported an RPC protocol error." + ) + default: + throw RPCError.protocolViolation("Unknown RPC response \(response._tag).") + } + } + + private func sendUnary(_ id: Int) async { + guard let connection, var request = unary[id], !request.sent else { return } + do { + try await connection.send(JSONEncoder.t3.encode(request.envelope)) + request.sent = true + unary[id] = request + } catch { + request.resume(.failure(error)) + unary.removeValue(forKey: id) + } + } + + private func sendSubscription(_ subscriptionID: UUID) async { + guard let connection, var subscription = subscriptions[subscriptionID] else { return } + let requestID = allocateRequestID() + let envelope = RPCRequestEnvelope( + id: requestID, + tag: subscription.tag, + payload: subscription.payload, + headers: [] + ) + do { + try await connection.send(JSONEncoder.t3.encode(envelope)) + subscription.requestID = requestID + subscriptions[subscriptionID] = subscription + subscriptionByRequestID[requestID] = subscriptionID + } catch { + // The connection loop owns retries; retaining this subscription is + // enough for it to be sent on the next socket. + } + } + + private func removeSubscription(_ id: UUID) async { + guard let subscription = subscriptions.removeValue(forKey: id) else { return } + if let requestID = subscription.requestID { + subscriptionByRequestID.removeValue(forKey: requestID) + try? await sendControl("Interrupt", requestID: requestID) + } + } + + private func keepaliveLoop() async { + while desired, connection != nil, !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + guard desired, connection != nil, !Task.isCancelled else { return } + try? await sendControl("Ping", requestID: nil) + } + } + + private func sendControl(_ tag: String, requestID: Int?) async throws { + guard let connection else { throw RPCError.disconnected } + try await connection.send( + JSONEncoder.t3.encode(RPCControlEnvelope(tag, requestID: requestID)) + ) + } + + private func failUnary(_ error: Error, includingUnsent: Bool) { + let failed = unary.filter { includingUnsent || $0.value.sent } + for (id, request) in failed { + unary.removeValue(forKey: id) + request.resume(.failure(error)) + } + } + + 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 00000000000..11f86ad69c7 --- /dev/null +++ b/apps/swift-ios/Core/WorkspaceModels.swift @@ -0,0 +1,343 @@ +import Foundation + +// MARK: - Project files + +public enum ProjectEntryKind: String, Codable, Sendable { + case file + case directory +} + +public struct ProjectEntry: Codable, Equatable, Sendable { + public let path: String + public let kind: ProjectEntryKind +} + +public struct ProjectEntriesResult: Codable, Equatable, Sendable { + public let entries: [ProjectEntry] + public let truncated: Bool +} + +public struct ProjectReadFileResult: Codable, Equatable, Sendable { + public let relativePath: String + public let contents: String + public let byteLength: Int + public let truncated: Bool +} + +public struct ProjectWriteFileResult: Codable, Equatable, Sendable { + public let relativePath: String +} + +public struct FilesystemBrowseEntry: Codable, Equatable, Sendable { + public let name: String + public let fullPath: String +} + +public struct FilesystemBrowseResult: Codable, Equatable, Sendable { + public let parentPath: String + public let entries: [FilesystemBrowseEntry] +} + +// MARK: - Source control and VCS + +public enum SourceControlProviderKind: String, Codable, CaseIterable, Sendable { + case github + case gitlab + case azureDevOps = "azure-devops" + case bitbucket + case unknown +} + +public struct SourceControlProviderInfo: Codable, Equatable, Sendable { + public let kind: SourceControlProviderKind + public let name: String + public let baseUrl: String +} + +public struct SourceControlRepositoryInfo: Codable, Equatable, Sendable { + public let provider: SourceControlProviderKind + public let nameWithOwner: String + public let url: String + public let sshUrl: String +} + +public struct SourceControlCloneResult: Codable, Equatable, Sendable { + public let cwd: String + public let remoteUrl: String + public let repository: SourceControlRepositoryInfo? +} + +public struct SourceControlPublishResult: Codable, Equatable, Sendable { + public let repository: SourceControlRepositoryInfo + public let remoteName: String + public let remoteUrl: String + public let branch: String + public let upstreamBranch: String? + public let status: String +} + +public 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 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] +} + +// 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/T3Theme.swift b/apps/swift-ios/DesignSystem/T3Theme.swift new file mode 100644 index 00000000000..a723b7db474 --- /dev/null +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -0,0 +1,34 @@ +import SwiftUI + +enum T3Colors { + static let background = Color.black + static let surface = Color(white: 0.055) + static let surfaceRaised = Color(white: 0.09) + static let input = Color(white: 0.075) + static let border = Color.white.opacity(0.09) + static let separator = Color.white.opacity(0.07) + + static let textPrimary = Color.white + static let textSecondary = Color.white.opacity(0.62) + static let textTertiary = Color.white.opacity(0.4) + + static let accent = Color(red: 0.04, green: 0.52, blue: 1) + static let success = Color(red: 0.19, green: 0.82, blue: 0.35) + static let warning = Color(red: 1, green: 0.62, blue: 0.04) + static let danger = Color(red: 1, green: 0.27, blue: 0.23) +} + +enum T3Metrics { + static let minimumTapTarget: CGFloat = 44 + 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.background.opacity(0.97), for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift new file mode 100644 index 00000000000..8c3fc1a20dc --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct FeatureComposerView: View { + @Binding var text: String + @Binding var selection: FeatureSelection? + let providers: [FeatureProvider] + let isSending: Bool + let isWorking: Bool + let focused: FocusState.Binding + let onSend: () -> Void + let onStop: () -> Void + + var body: some View { + VStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 9) { + TextField("Message agent", text: $text, axis: .vertical) + .lineLimit(1...7) + .focused(focused) + .submitLabel(.send) + .onSubmit { + if canSend { onSend() } + } + .padding(.leading, 12) + .padding(.vertical, 10) + + Button(action: isWorking && textIsEmpty ? onStop : onSend) { + Image(systemName: isWorking && textIsEmpty ? "stop.fill" : "arrow.up") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.black) + .frame(width: 32, height: 32) + .background(.white, in: Circle()) + } + .disabled((!isWorking && !canSend) || isSending) + .padding(.trailing, 7) + .padding(.bottom, 7) + .accessibilityLabel(isWorking && textIsEmpty ? "Stop agent" : "Send") + } + .background(Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.14), lineWidth: 1) + } + + HStack { + CompactModelMenu(providers: providers, selection: $selection) + Spacer() + Text("Return to send") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 3) + } + .padding(.horizontal, 12) + .padding(.top, 9) + .padding(.bottom, 7) + .background(.black.opacity(0.97)) + .overlay(alignment: .top) { + Divider().opacity(0.4) + } + } + + private var textIsEmpty: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var canSend: Bool { + !textIsEmpty && !isSending + } +} + +private struct CompactModelMenu: View { + let providers: [FeatureProvider] + @Binding var selection: FeatureSelection? + + var body: some View { + Menu { + ForEach(providers.filter(\.isAvailable)) { provider in + Section(provider.name) { + ForEach(provider.models) { model in + Button { + selection = .init(providerID: provider.id, modelID: model.id) + } label: { + if selection?.providerID == provider.id, selection?.modelID == model.id { + Label(model.name, systemImage: "checkmark") + } else { + Text(model.name) + } + } + } + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "cpu") + Text(label) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .accessibilityLabel("Choose model") + } + + private var label: String { + guard let selection, + let provider = providers.first(where: { $0.id == selection.providerID }), + let model = provider.models.first(where: { $0.id == selection.modelID }) else { + return "Automatic" + } + return model.name + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift new file mode 100644 index 00000000000..58c66a62d61 --- /dev/null +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -0,0 +1,484 @@ +import SwiftUI + +public struct ThreadDetailView: View { + @Bindable var model: FeatureRootModel + let thread: FeatureThread + + @State private var draft = "" + @State private var selection: FeatureSelection? + @State private var isSending = false + @State private var isLoading = true + @State private var toolSurface: FeatureThreadToolSurface? + @FocusState private var composerFocused: Bool + + public init(model: FeatureRootModel, thread: FeatureThread) { + self.model = model + self.thread = thread + } + + public var body: some View { + Group { + if isLoading, detail == nil { + ProgressView("Loading thread…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let detail { + timeline(detail) + } else { + ContentUnavailableView( + "Thread unavailable", + systemImage: "exclamationmark.bubble", + description: Text("The thread could not be loaded.") + ) + } + } + .background(Color.black) + .navigationTitle(detail?.thread.title ?? thread.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + 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("Interaction") { + Button { + Task { + await model.setInteractionMode( + thread.id, + mode: currentThread.interactionMode == .plan ? .standard : .plan + ) + } + } label: { + Label( + currentThread.interactionMode == .plan + ? "Use standard mode" + : "Use plan mode", + systemImage: "list.bullet.clipboard" + ) + } + } + Section("Access") { + ForEach(FeatureRuntimeMode.allCases, id: \.self) { mode in + Button { + Task { await model.setRuntimeMode(thread.id, mode: mode) } + } label: { + if currentThread.runtimeMode == mode { + Label(runtimeModeLabel(mode), systemImage: "checkmark") + } else { + Text(runtimeModeLabel(mode)) + } + } + } + } + Section { + Button { + Task { _ = await model.detail(for: thread.id, force: true) } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + Button { + Task { + await model.setArchived(thread.id, archived: !thread.isArchived) + } + } label: { + Label( + thread.isArchived ? "Restore" : "Archive", + systemImage: thread.isArchived + ? "arrow.uturn.backward" + : "archivebox" + ) + } + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("Thread actions") + } + } + .task(id: thread.id) { + isLoading = true + _ = await model.detail(for: thread.id) + selection = currentSelection + isLoading = false + } + .sheet(item: $toolSurface) { surface in + NavigationStack { + switch surface { + case .files: + FeatureFilesView(client: model.client, threadID: thread.id) + case .review: + FeatureReviewView(client: model.client, threadID: thread.id) + case .sourceControl: + FeatureSourceControlView(client: model.client, threadID: thread.id) + case .terminal: + FeatureTerminalView(client: model.client, threadID: thread.id) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + toolSurface = nil + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } + + private var detail: FeatureThreadDetail? { + model.details[thread.id] + } + + private var currentThread: FeatureThread { + detail?.thread ?? thread + } + + private var currentSelection: FeatureSelection? { + guard let providerID = detail?.thread.providerID ?? thread.providerID, + let modelID = detail?.thread.modelID ?? thread.modelID else { + return model.snapshot.settings.defaultSelection + } + return FeatureSelection(providerID: providerID, modelID: modelID) + } + + private func timeline(_ detail: FeatureThreadDetail) -> some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if detail.messages.isEmpty { + ContentUnavailableView( + "Ready for a task", + systemImage: "sparkles", + description: Text("Tell the agent what you want to build.") + ) + .frame(maxWidth: .infinity) + .padding(.top, 80) + } + + ForEach(detail.messages) { message in + FeatureMessageView(message: message) + .id(message.id) + } + + ForEach(detail.approvals) { approval in + ApprovalRequestView(model: model, approval: approval) + .id("approval-\(approval.id)") + } + + ForEach(detail.userInputs) { input in + UserInputRequestView(model: model, input: input) + .id("user-input-\(input.id)") + } + + if detail.thread.state == .working || detail.thread.state == .queued { + StreamingStatusView( + state: detail.thread.state, + onStop: { + Task { await model.cancelTurn(threadID: thread.id) } + } + ) + .id("streaming-status") + } + + Color.clear.frame(height: 1).id("timeline-bottom") + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 10) + } + .defaultScrollAnchor(.bottom) + .scrollDismissesKeyboard(.interactively) + .onChange(of: detail.messages.count) { + withAnimation(.easeOut(duration: 0.18)) { + proxy.scrollTo("timeline-bottom", anchor: .bottom) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + FeatureComposerView( + text: $draft, + selection: $selection, + providers: model.snapshot.providers, + isSending: isSending, + isWorking: detail.thread.state == .working || detail.thread.state == .queued, + focused: $composerFocused, + onSend: send, + onStop: { + Task { await model.cancelTurn(threadID: thread.id) } + } + ) + } + } + } + + private func send() { + let message = draft + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + draft = "" + isSending = true + Task { + let sent = await model.sendMessage( + threadID: thread.id, + text: message, + selection: selection + ) + if !sent { + draft = message + } + isSending = false + composerFocused = true + } + } + + private func runtimeModeLabel(_ mode: FeatureRuntimeMode) -> String { + switch mode { + case .approvalRequired: "Ask before changes" + case .autoAcceptEdits: "Auto-accept edits" + case .automatic: "Automatic" + case .fullAccess: "Full access" + } + } +} + +private enum FeatureThreadToolSurface: String, Identifiable { + case files + case review + case sourceControl + case terminal + + var id: String { rawValue } +} + +private struct UserInputRequestView: View { + @Bindable var model: FeatureRootModel + let input: FeatureUserInput + @State private var answers: [String: String] = [:] + @State private var isSubmitting = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label("Input requested", systemImage: "questionmark.bubble") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.indigo) + + ForEach(input.questions) { question in + VStack(alignment: .leading, spacing: 8) { + Text(question.header) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(question.question) + .font(.callout) + + if !question.options.isEmpty { + ForEach(question.options, id: \.label) { option in + Button { + answers[question.id] = option.label + } label: { + HStack(alignment: .top, spacing: 9) { + Image( + systemName: answers[question.id] == option.label + ? "checkmark.circle.fill" + : "circle" + ) + VStack(alignment: .leading, spacing: 2) { + Text(option.label) + if !option.detail.isEmpty { + Text(option.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + TextField( + question.options.isEmpty ? "Your answer" : "Or type another answer", + text: Binding( + get: { answers[question.id] ?? "" }, + set: { answers[question.id] = $0 } + ), + axis: .vertical + ) + .lineLimit(1...4) + .textFieldStyle(.roundedBorder) + } + } + + Button(isSubmitting ? "Submitting…" : "Submit") { + isSubmitting = true + Task { + await model.resolveUserInput(input.id, answers: answers) + isSubmitting = false + } + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(.black) + .disabled( + isSubmitting + || input.questions.contains { + answers[$0.id]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + != false + } + ) + } + .padding(13) + .background(Color.indigo.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.indigo.opacity(0.35), lineWidth: 1) + } + } +} + +struct FeatureMessageView: View { + let message: FeatureMessage + + var body: some View { + switch message.role { + case .user: + HStack { + Spacer(minLength: 34) + Text(.init(message.text)) + .textSelection(.enabled) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(Color.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 15)) + } + .accessibilityLabel("You") + .accessibilityValue(message.text) + case .assistant: + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + Text(message.state == .streaming ? "Agent · Working" : "Agent") + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(.init(message.text)) + .textSelection(.enabled) + .lineSpacing(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + case .tool: + DisclosureGroup { + Text(message.text) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(.top, 6) + } label: { + Label(message.toolName ?? "Tool output", systemImage: "terminal") + .font(.subheadline.weight(.medium)) + } + .padding(11) + .background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 10)) + case .system: + Text(message.text) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + } +} + +struct StreamingStatusView: View { + let state: FeatureThreadState + let onStop: () -> Void + + var body: some View { + HStack(spacing: 9) { + ProgressView() + .controlSize(.small) + Text(state == .queued ? "Queued" : "Agent is working") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + Button("Stop", role: .destructive, action: onStop) + .font(.footnote.weight(.semibold)) + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } +} + +struct ApprovalRequestView: View { + @Bindable var model: FeatureRootModel + let approval: FeatureApproval + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label(approval.title, systemImage: icon) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + + Text(approval.detail) + .font(approval.kind == .command ? .caption.monospaced() : .callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + HStack(spacing: 8) { + Button("Deny", role: .destructive) { + decide(.deny) + } + .buttonStyle(.bordered) + Spacer() + Button("Always Allow") { + decide(.allowForSession) + } + .buttonStyle(.bordered) + Button("Allow") { + decide(.allowOnce) + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(.black) + } + .font(.footnote.weight(.semibold)) + } + .padding(13) + .background(Color.orange.opacity(0.09), in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.orange.opacity(0.3), lineWidth: 1) + } + .accessibilityElement(children: .contain) + } + + private var icon: String { + switch approval.kind { + case .command: "terminal" + case .fileRead: "doc.text.magnifyingglass" + case .fileChange, .patch: "doc.badge.ellipsis" + case .other: "exclamationmark.shield" + } + } + + private func decide(_ decision: FeatureApprovalDecision) { + Task { await model.resolveApproval(approval.id, decision: decision) } + } +} diff --git a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift new file mode 100644 index 00000000000..025512e413a --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +public struct ConnectionOnboardingView: View { + @Bindable var model: FeatureRootModel + @State private var endpoint = "" + @State private var pairingToken = "" + @State private var isPairing = false + + public init(model: FeatureRootModel) { + self.model = model + } + + public var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 34, weight: .bold)) + Text("T3 Code") + .font(.largeTitle.bold()) + Text("Connect this iPhone to a T3 environment.") + .font(.body) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 12) { + Text("SERVER") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + TextField("https://your-server.example", text: $endpoint) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + .featureInput() + .accessibilityLabel("Server address") + + SecureField("Pairing token (optional)", text: $pairingToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .featureInput() + + Button { + pair() + } label: { + HStack { + if isPairing { + ProgressView().controlSize(.small) + } + Text(isPairing ? "Connecting…" : "Connect") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(FeaturePrimaryButtonStyle()) + .disabled(normalizedEndpoint == nil || isPairing) + } + + if !model.snapshot.environments.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("KNOWN ENVIRONMENTS") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + ForEach(model.snapshot.environments) { environment in + Button { + endpoint = environment.endpoint + } label: { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .foregroundStyle(.primary) + Text(environment.endpoint) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + Label("How to pair", systemImage: "info.circle") + .font(.subheadline.weight(.semibold)) + Text("Open T3 Code on the host, copy its mobile pairing link, then paste the server and one-time token above.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(24) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .background(Color.black) + .navigationTitle("Connect") + .navigationBarTitleDisplayMode(.inline) + } + } + + private var normalizedEndpoint: String? { + let value = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + guard let components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + components.host != nil else { + return nil + } + return value + } + + private func pair() { + guard let endpoint = normalizedEndpoint else { return } + let trimmedToken = pairingToken.trimmingCharacters(in: .whitespacesAndNewlines) + isPairing = true + Task { + _ = await model.pair( + endpoint: endpoint, + token: trimmedToken.isEmpty ? nil : trimmedToken + ) + isPairing = false + } + } +} + +private extension View { + func featureInput() -> some View { + self + .padding(.horizontal, 12) + .frame(minHeight: 46) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + } + } +} + +struct FeaturePrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.body.weight(.semibold)) + .foregroundStyle(.black) + .padding(.horizontal, 16) + .frame(minHeight: 46) + .background(configuration.isPressed ? Color.white.opacity(0.75) : .white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/apps/swift-ios/Features/Files/FeatureFilesView.swift b/apps/swift-ios/Features/Files/FeatureFilesView.swift new file mode 100644 index 00000000000..f4d600f627c --- /dev/null +++ b/apps/swift-ios/Features/Files/FeatureFilesView.swift @@ -0,0 +1,238 @@ +import SwiftUI + +public struct FeatureFilesView: View { + let client: any FeatureClient + let threadID: String + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + FeatureFileDirectoryView(client: client, threadID: threadID, path: nil, title: "Files") + .background(Color.black) + } +} + +private struct FeatureFileDirectoryView: View { + let client: any FeatureClient + let threadID: String + let path: String? + let title: String + + @State private var entries: [FeatureFileEntry] = [] + @State private var searchText = "" + @State private var includesHidden = false + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + Group { + if isLoading, entries.isEmpty { + ProgressView("Loading files…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage, entries.isEmpty { + ContentUnavailableView( + "Files unavailable", + systemImage: "folder.badge.questionmark", + description: Text(errorMessage) + ) + } else if filteredEntries.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Empty folder" : "No matches", + systemImage: "folder", + description: Text(searchText.isEmpty ? "This folder has no visible files." : "Try another search.") + ) + } else { + List(filteredEntries) { entry in + NavigationLink { + destination(for: entry) + } label: { + FeatureFileRow(entry: entry) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await load() } + } + } + .background(Color.black) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Filter files") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle("Show hidden files", isOn: $includesHidden) + Button { + Task { await load() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("File browser options") + } + } + .task(id: path) { await load() } + } + + @ViewBuilder + private func destination(for entry: FeatureFileEntry) -> some View { + if entry.kind == .directory { + FeatureFileDirectoryView( + client: client, + threadID: threadID, + path: entry.path, + title: entry.name + ) + } else { + FeatureFilePreviewView(client: client, threadID: threadID, entry: entry) + } + } + + private var filteredEntries: [FeatureFileEntry] { + entries.featureFiltered(by: searchText, includesHidden: includesHidden) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + entries = try await client.listFiles(threadID: threadID, path: path) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureFileRow: View { + let entry: FeatureFileEntry + + var body: some View { + HStack(spacing: 11) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundStyle(entry.kind == .directory ? .blue : .secondary) + .frame(width: 20) + Text(entry.name) + .font(.subheadline) + .lineLimit(1) + Spacer() + if let size = entry.sizeBytes, entry.kind != .directory { + Text(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + } + + private var icon: String { + switch entry.kind { + case .directory: "folder.fill" + case .symbolicLink: "link" + case .file: + entry.name.hasSuffix(".swift") ? "swift" : "doc.text" + } + } +} + +private struct FeatureFilePreviewView: View { + let client: any FeatureClient + let threadID: String + let entry: FeatureFileEntry + + @State private var content: FeatureFileContent? + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + Group { + if isLoading, content == nil { + ProgressView("Loading file…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let content { + VStack(spacing: 0) { + if content.isTruncated { + Label("Partial preview", systemImage: "exclamationmark.triangle") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.09)) + } + FeatureSourceTextView(text: content.text) + } + } else { + ContentUnavailableView( + "File unavailable", + systemImage: "doc.badge.ellipsis", + description: Text(errorMessage ?? "The file could not be read.") + ) + } + } + .background(Color.black) + .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 func load() async { + isLoading = true + defer { isLoading = false } + do { + content = try await client.readFile(threadID: threadID, path: entry.path) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureSourceTextView: View { + let text: String + + private var lines: [Substring] { + text.split(separator: "\n", omittingEmptySubsequences: false) + } + + var body: some View { + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 10) { + Text("\(index + 1)") + .foregroundStyle(.tertiary) + .frame(width: 38, alignment: .trailing) + .accessibilityHidden(true) + Text(String(line).isEmpty ? " " : String(line)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + .font(.system(size: 12, design: .monospaced)) + .frame(minHeight: 19) + } + } + .padding(.vertical, 10) + .padding(.trailing, 14) + } + .background(Color.black) + .accessibilityLabel("Source file") + } +} diff --git a/apps/swift-ios/Features/Review/FeatureReviewView.swift b/apps/swift-ios/Features/Review/FeatureReviewView.swift new file mode 100644 index 00000000000..6330f1e86ec --- /dev/null +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -0,0 +1,269 @@ +import SwiftUI + +public struct FeatureReviewView: View { + let client: any FeatureClient + let threadID: String + + @State private var review: FeatureReview? + @State private var isLoading = true + @State private var errorMessage: String? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + Group { + if isLoading, review == nil { + ProgressView("Loading changes…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let review { + reviewList(review) + } else { + ContentUnavailableView( + "Review unavailable", + systemImage: "doc.text.magnifyingglass", + description: Text(errorMessage ?? "Changes could not be loaded.") + ) + } + } + .background(Color.black) + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("Reload changes") + } + } + .task { await load() } + } + + private func reviewList(_ review: FeatureReview) -> some View { + List { + Section { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(review.title) + .font(.headline) + if let base = review.baseReference { + Text(base) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + 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(.footnote) + .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(file: file) + } label: { + FeatureReviewFileRow(file: file) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .refreshable { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + review = try await client.loadReview(threadID: threadID) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureReviewFileRow: View { + let file: FeatureReviewFile + + var body: some View { + HStack(spacing: 10) { + Text(changeLabel) + .font(.caption2.monospaced().weight(.bold)) + .foregroundStyle(changeColor) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(fileName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if !directory.isEmpty { + Text(directory) + .font(.caption2) + .foregroundStyle(.secondary) + .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(.caption2.monospacedDigit().weight(.medium)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(additions) additions, \(deletions) deletions") + } +} + +private struct FeatureDiffView: View { + let file: FeatureReviewFile + + var body: some View { + Group { + if file.lines.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 { + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(file.lines) { line in + FeatureDiffLineRow(line: line) + } + } + .padding(.vertical, 8) + } + } + } + .background(Color.black) + .navigationTitle(file.path.split(separator: "/").last.map(String.init) ?? file.path) + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct FeatureDiffLineRow: View { + let line: FeatureDiffLine + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if line.kind == .hunk { + Text(line.text) + .foregroundStyle(.blue) + .padding(.horizontal, 10) + } else { + lineNumber(line.oldLine) + lineNumber(line.newLine) + Text(prefix) + .foregroundStyle(prefixColor) + .frame(width: 18) + Text(line.text.isEmpty ? " " : line.text) + .foregroundStyle(.primary) + .textSelection(.enabled) + .padding(.trailing, 12) + } + } + .font(.system(size: 12, design: .monospaced)) + .frame(minHeight: line.kind == .hunk ? 28 : 19, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background) + } + + private func lineNumber(_ value: Int?) -> some View { + Text(value.map(String.init) ?? "") + .foregroundStyle(.tertiary) + .frame(width: 42, 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 + } + } + + 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 00000000000..c85c4dce402 --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -0,0 +1,260 @@ +import Foundation +import Observation + +@MainActor +@Observable +public final class FeatureRootModel { + public private(set) var snapshot = FeatureSnapshot() + public private(set) var details: [String: FeatureThreadDetail] = [:] + public private(set) var isLoading = true + public private(set) var isPerformingAction = false + public var errorMessage: String? + + let client: any FeatureClient + + public init(client: any FeatureClient) { + self.client = client + } + + public func start() async { + do { + snapshot = try await client.initialSnapshot() + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + + for await event in client.events() { + apply(event) + } + } + + public func reload() async { + do { + snapshot = try await client.initialSnapshot() + } catch { + errorMessage = error.localizedDescription + } + } + + public func pair(endpoint: String, token: String?) async -> Bool { + await perform { + try await client.pair(endpoint: endpoint, token: token) + snapshot = try await client.initialSnapshot() + } + } + + public func activateEnvironment(_ id: String) async { + await perform { + try await client.activateEnvironment(id: id) + snapshot = try await client.initialSnapshot() + details.removeAll() + } + } + + public func removeEnvironment(_ id: String) async { + await perform { + try await client.removeEnvironment(id: id) + snapshot = try await client.initialSnapshot() + details.removeAll() + } + } + + public func disconnect() async { + await client.disconnect() + snapshot.connection = .init() + details.removeAll() + } + + public func addProject(path: String) async -> Bool { + await perform { + try await client.addProject(path: path) + snapshot = try await client.initialSnapshot() + } + } + + public func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async -> FeatureThread? { + var created: FeatureThread? + let succeeded = await perform { + let thread = try await client.createThread( + projectID: projectID, + title: title, + selection: selection + ) + upsert(thread) + created = thread + } + return succeeded ? created : nil + } + + public func renameThread(_ id: String, title: String) async { + await perform { + try await client.renameThread(id: id, title: title) + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + snapshot.threads[index].title = title + details[id]?.thread.title = title + } + } + + public func setArchived(_ id: String, archived: Bool) async { + await perform { + try await client.setThreadArchived(id: id, archived: archived) + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + snapshot.threads[index].isArchived = archived + details[id]?.thread.isArchived = archived + } + } + + public func setSettled(_ id: String, settled: Bool) async { + await perform { + try await client.setThreadSettled(id: id, settled: settled) + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + snapshot.threads[index].isSettled = settled + details[id]?.thread.isSettled = settled + } + } + + public func setSnoozed(_ id: String, until: Date?) async { + await perform { + try await client.setThreadSnoozed(id: id, until: until) + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + snapshot.threads[index].snoozedUntil = until + details[id]?.thread.snoozedUntil = until + } + } + + public func setRuntimeMode(_ id: String, mode: FeatureRuntimeMode) async { + await perform { + try await client.setRuntimeMode(id: id, mode: mode) + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + snapshot.threads[index].runtimeMode = mode + details[id]?.thread.runtimeMode = mode + } + } + + public func setInteractionMode(_ id: String, mode: FeatureInteractionMode) async { + await perform { + try await client.setInteractionMode(id: id, mode: mode) + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + snapshot.threads[index].interactionMode = mode + details[id]?.thread.interactionMode = mode + } + } + + public func deleteThread(_ id: String) async { + await perform { + try await client.deleteThread(id: id) + snapshot.threads.removeAll { $0.id == id } + details[id] = nil + } + } + + public func detail(for id: String, force: Bool = false) async -> FeatureThreadDetail? { + if !force, let cached = details[id] { + return cached + } + do { + let detail = try await client.loadThread(id: id) + details[id] = detail + upsert(detail.thread) + return detail + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + public func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + return await perform { + let optimistic = FeatureMessage( + id: "local-\(UUID().uuidString)", + role: .user, + text: trimmed, + state: .queued + ) + details[threadID]?.messages.append(optimistic) + try await client.sendMessage(threadID: threadID, text: trimmed, selection: selection) + } + } + + public func cancelTurn(threadID: String) async { + await perform { + try await client.cancelTurn(threadID: threadID) + } + } + + public func resolveApproval(_ id: String, decision: FeatureApprovalDecision) async { + await perform { + try await client.resolveApproval(id: id, decision: decision) + for key in details.keys { + details[key]?.approvals.removeAll { $0.id == id } + } + } + } + + public func resolveUserInput(_ id: String, answers: [String: String]) async { + await perform { + try await client.resolveUserInput(id: id, answers: answers) + for key in details.keys { + details[key]?.userInputs.removeAll { $0.id == id } + } + } + } + + public func saveSettings(_ settings: FeatureSettings) async { + await perform { + try await client.saveSettings(settings) + snapshot.settings = settings + } + } + + @discardableResult + private func perform(_ operation: () async throws -> Void) async -> Bool { + isPerformingAction = true + defer { isPerformingAction = false } + do { + try await operation() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private func apply(_ event: FeatureEvent) { + switch event { + case let .snapshot(value): + snapshot = value + case let .connection(value): + snapshot.connection = value + case let .thread(value): + upsert(value) + if details[value.id] != nil { + details[value.id]?.thread = value + } + case let .threadRemoved(id): + snapshot.threads.removeAll { $0.id == id } + details[id] = nil + case let .detail(value): + details[value.thread.id] = value + upsert(value.thread) + case let .failure(message): + errorMessage = message + } + } + + private func upsert(_ thread: FeatureThread) { + if let index = snapshot.threads.firstIndex(where: { $0.id == thread.id }) { + snapshot.threads[index] = thread + } else { + snapshot.threads.append(thread) + } + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift new file mode 100644 index 00000000000..ff2e51059e6 --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +public struct FeatureRootView: View { + @State private var model: FeatureRootModel + + public init(client: any FeatureClient) { + _model = State(initialValue: FeatureRootModel(client: client)) + } + + public var body: some View { + Group { + if model.isLoading { + FeatureLoadingView() + } else if model.snapshot.connection.state == .connected { + WorkspaceView(model: model) + } else { + ConnectionOnboardingView(model: model) + } + } + .preferredColorScheme(model.snapshot.settings.appearance == .dark ? .dark : nil) + .tint(.white) + .background(Color.black.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") + } + ) + } +} + +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(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .accessibilityElement(children: .combine) + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift new file mode 100644 index 00000000000..370f49b098e --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +public struct SettingsView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + @State private var settings: FeatureSettings + @State private var isSaving = false + @State private var showingDisconnect = false + + public init(model: FeatureRootModel) { + self.model = model + _settings = State(initialValue: model.snapshot.settings) + } + + public var body: some View { + NavigationStack { + Form { + Section("Appearance") { + Picker("Theme", selection: $settings.appearance) { + Text("System").tag(FeatureAppearance.system) + Text("Dark").tag(FeatureAppearance.dark) + } + Toggle("Haptics", isOn: $settings.hapticsEnabled) + Toggle("Notifications", isOn: $settings.notificationsEnabled) + } + + Section("Default agent") { + ProviderModelPicker( + providers: model.snapshot.providers, + selection: $settings.defaultSelection + ) + if let provider = selectedProvider, let selectedModel { + LabeledContent("Provider", value: provider.name) + if let detail = selectedModel.detail { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + Section("Environment") { + LabeledContent( + "Status", + value: model.snapshot.connection.state == .connected ? "Connected" : "Offline" + ) + if let endpoint = model.snapshot.connection.endpoint { + LabeledContent("Server") { + Text(endpoint) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + ForEach(model.snapshot.environments) { environment in + Button { + Task { await model.activateEnvironment(environment.id) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .foregroundStyle(.primary) + Text(environment.endpoint) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if environment.isActive { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + } + .swipeActions { + Button(role: .destructive) { + Task { await model.removeEnvironment(environment.id) } + } label: { + Label("Remove", systemImage: "trash") + } + } + .contextMenu { + Button(role: .destructive) { + Task { await model.removeEnvironment(environment.id) } + } label: { + Label("Remove Environment", systemImage: "trash") + } + } + } + Button("Disconnect", role: .destructive) { + showingDisconnect = true + } + } + + Section("About") { + LabeledContent("App", value: "T3 Code for iOS") + LabeledContent("Platform", value: "Native SwiftUI") + Link("Open source", destination: URL(string: "https://github.com/pingdotgg/t3code")!) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving…" : "Save") { + save() + } + .disabled(isSaving || settings == model.snapshot.settings) + } + } + .confirmationDialog( + "Disconnect from this environment?", + isPresented: $showingDisconnect, + titleVisibility: .visible + ) { + Button("Disconnect", role: .destructive) { + Task { + await model.disconnect() + dismiss() + } + } + Button("Cancel", role: .cancel) {} + } + } + } + + private var selectedProvider: FeatureProvider? { + guard let selection = settings.defaultSelection else { return nil } + return model.snapshot.providers.first { $0.id == selection.providerID } + } + + private var selectedModel: FeatureModel? { + guard let selection = settings.defaultSelection else { return nil } + return selectedProvider?.models.first { $0.id == selection.modelID } + } + + private func save() { + isSaving = true + Task { + await model.saveSettings(settings) + isSaving = false + if model.snapshot.settings == settings { + dismiss() + } + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift new file mode 100644 index 00000000000..e06d9ddcb29 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -0,0 +1,114 @@ +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 + func events() -> AsyncStream + + func pair(endpoint: String, token: String?) async throws + func activateEnvironment(id: String) async throws + func removeEnvironment(id: String) async throws + func disconnect() async + + func addProject(path: String) async throws + func createThread(projectID: String, title: String?, selection: FeatureSelection?) async throws -> FeatureThread + func renameThread(id: String, title: 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 setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws + func deleteThread(id: String) async throws + + func loadThread(id: String) async throws -> FeatureThreadDetail + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws + func cancelTurn(threadID: String) async throws + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws + func resolveUserInput(id: String, answers: [String: String]) async throws + + func saveSettings(_ settings: FeatureSettings) async throws + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] + func readFile(threadID: String, path: String) async throws -> FeatureFileContent + func loadReview(threadID: String) async throws -> FeatureReview + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws -> FeatureSourceControlStatus + + func terminalSnapshot(threadID: String) async throws -> FeatureTerminalSnapshot + func terminalEvents(threadID: String) -> AsyncStream + func openTerminal(threadID: String, columns: Int, rows: Int) async throws + func writeTerminal(threadID: String, data: String) async throws + func resizeTerminal(threadID: String, columns: Int, rows: Int) async throws + func closeTerminal(threadID: String) async throws +} + +public extension FeatureClient { + func events() -> AsyncStream { + AsyncStream { continuation in continuation.finish() } + } + + func activateEnvironment(id: String) async throws {} + func removeEnvironment(id: String) async throws {} + func disconnect() async {} + func addProject(path: String) async throws {} + func resolveUserInput(id: String, answers: [String: String]) async throws {} + func setThreadSettled(id: String, settled: Bool) async throws {} + func setThreadSnoozed(id: String, until: Date?) async throws {} + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws {} + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("Files") + } + + 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 performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws -> FeatureSourceControlStatus { + throw FeatureCapabilityUnavailable("Source control actions") + } + + func terminalSnapshot(threadID: String) async throws -> FeatureTerminalSnapshot { + throw FeatureCapabilityUnavailable("Terminal") + } + + func terminalEvents(threadID: String) -> AsyncStream { + AsyncStream { $0.finish() } + } + + func openTerminal(threadID: String, columns: Int, rows: Int) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func writeTerminal(threadID: String, data: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func resizeTerminal(threadID: String, columns: Int, rows: Int) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func closeTerminal(threadID: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift new file mode 100644 index 00000000000..05edbc9fe2f --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -0,0 +1,361 @@ +import Foundation + +public struct FeatureConnection: Sendable, Equatable, Codable { + public enum State: String, Sendable, Codable { + case disconnected + case connecting + case connected + case reconnecting + } + + public var state: State + public var environmentName: String? + public var endpoint: String? + public var detail: String? + + public init( + state: State = .disconnected, + environmentName: String? = nil, + endpoint: String? = nil, + detail: String? = nil + ) { + self.state = state + self.environmentName = environmentName + self.endpoint = endpoint + self.detail = detail + } +} + +public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var endpoint: String + public var isActive: Bool + + public init(id: String, name: String, endpoint: String, isActive: Bool = false) { + self.id = id + self.name = name + self.endpoint = endpoint + self.isActive = isActive + } +} + +public struct FeatureProject: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var environmentID: String + public var name: String + public var path: String + public var threadCount: Int + + public init(id: String, environmentID: String, name: String, path: String, threadCount: Int = 0) { + self.id = id + self.environmentID = environmentID + self.name = name + self.path = path + self.threadCount = threadCount + } +} + +public enum FeatureThreadState: String, Sendable, Codable { + case idle + case queued + case working + case waitingForApproval + case waitingForInput + case failed + case completed +} + +public enum FeatureRuntimeMode: String, CaseIterable, Sendable, Codable { + case approvalRequired + case autoAcceptEdits + case automatic + case fullAccess +} + +public enum FeatureInteractionMode: String, CaseIterable, Sendable, Codable { + case standard + case plan +} + +public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var projectID: String + public var title: String + public var preview: String? + public var updatedAt: Date + public var state: FeatureThreadState + public var providerID: String? + public var modelID: String? + public var isArchived: Bool + public var isSettled: Bool + public var snoozedUntil: Date? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + + public init( + id: String, + projectID: String, + title: String, + preview: String? = nil, + updatedAt: Date = .now, + state: FeatureThreadState = .idle, + providerID: String? = nil, + modelID: String? = nil, + isArchived: Bool = false, + isSettled: Bool = false, + snoozedUntil: Date? = nil, + runtimeMode: FeatureRuntimeMode = .fullAccess, + interactionMode: FeatureInteractionMode = .standard + ) { + self.id = id + self.projectID = projectID + self.title = title + self.preview = preview + self.updatedAt = updatedAt + self.state = state + self.providerID = providerID + self.modelID = modelID + self.isArchived = isArchived + self.isSettled = isSettled + self.snoozedUntil = snoozedUntil + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + } +} + +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 FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var role: FeatureMessageRole + public var text: String + public var createdAt: Date + public var state: FeatureMessageState + public var toolName: String? + + public init( + id: String, + role: FeatureMessageRole, + text: String, + createdAt: Date = .now, + state: FeatureMessageState = .complete, + toolName: String? = nil + ) { + self.id = id + self.role = role + self.text = text + self.createdAt = createdAt + self.state = state + self.toolName = toolName + } +} + +public enum FeatureApprovalKind: String, Sendable, Codable { + case command + case fileRead + case fileChange + case patch + case other +} + +public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var threadID: String + public var kind: FeatureApprovalKind + public var title: String + public var detail: String + + public init(id: String, threadID: String, kind: FeatureApprovalKind, title: String, detail: String) { + self.id = id + self.threadID = threadID + self.kind = kind + self.title = title + self.detail = detail + } +} + +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 + } +} + +public struct FeatureInputQuestion: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var header: String + public var question: String + public var options: [FeatureInputOption] + public var allowsMultiple: Bool + + public init( + id: String, + header: String, + question: String, + options: [FeatureInputOption] = [], + allowsMultiple: Bool = false + ) { + self.id = id + self.header = header + self.question = question + self.options = options + self.allowsMultiple = allowsMultiple + } +} + +public struct FeatureUserInput: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var threadID: String + public var questions: [FeatureInputQuestion] + + public init(id: String, threadID: String, questions: [FeatureInputQuestion]) { + self.id = id + self.threadID = threadID + self.questions = questions + } +} + +public struct FeatureThreadDetail: Sendable, Equatable, Codable { + public var thread: FeatureThread + public var messages: [FeatureMessage] + public var approvals: [FeatureApproval] + public var userInputs: [FeatureUserInput] + + public init( + thread: FeatureThread, + messages: [FeatureMessage] = [], + approvals: [FeatureApproval] = [], + userInputs: [FeatureUserInput] = [] + ) { + self.thread = thread + self.messages = messages + self.approvals = approvals + self.userInputs = userInputs + } +} + +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 supportsReasoning: Bool + + public init( + id: String, + name: String, + detail: String? = nil, + supportsImages: Bool = false, + supportsReasoning: Bool = false + ) { + self.id = id + self.name = name + self.detail = detail + self.supportsImages = supportsImages + self.supportsReasoning = supportsReasoning + } +} + +public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var isAvailable: Bool + public var models: [FeatureModel] + + public init(id: String, name: String, isAvailable: Bool = true, models: [FeatureModel] = []) { + self.id = id + self.name = name + self.isAvailable = isAvailable + self.models = models + } +} + +public struct FeatureSelection: Sendable, Equatable, Hashable, Codable { + public var providerID: String + public var modelID: String + + public init(providerID: String, modelID: String) { + self.providerID = providerID + self.modelID = modelID + } +} + +public enum FeatureAppearance: String, CaseIterable, Sendable, Codable { + case system + case dark +} + +public struct FeatureSettings: Sendable, Equatable, Codable { + public var appearance: FeatureAppearance + public var hapticsEnabled: Bool + public var notificationsEnabled: Bool + public var defaultSelection: FeatureSelection? + + public init( + appearance: FeatureAppearance = .dark, + hapticsEnabled: Bool = true, + notificationsEnabled: Bool = true, + defaultSelection: FeatureSelection? = nil + ) { + self.appearance = appearance + self.hapticsEnabled = hapticsEnabled + self.notificationsEnabled = notificationsEnabled + self.defaultSelection = defaultSelection + } +} + +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] + public var settings: FeatureSettings + + public init( + connection: FeatureConnection = .init(), + environments: [FeatureEnvironment] = [], + projects: [FeatureProject] = [], + threads: [FeatureThread] = [], + providers: [FeatureProvider] = [], + settings: FeatureSettings = .init() + ) { + self.connection = connection + self.environments = environments + self.projects = projects + self.threads = threads + self.providers = providers + self.settings = settings + } +} + +public enum FeatureApprovalDecision: String, Sendable, Codable { + case allowOnce + case allowForSession + case deny +} + +public enum FeatureEvent: Sendable { + case snapshot(FeatureSnapshot) + case connection(FeatureConnection) + case thread(FeatureThread) + case threadRemoved(id: String) + case detail(FeatureThreadDetail) + case failure(String) +} diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift new file mode 100644 index 00000000000..d014f4627cf --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -0,0 +1,294 @@ +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." + } +} + +public enum FeatureFileKind: String, Sendable, Codable { + case file + case directory + case symbolicLink +} + +public struct FeatureFileEntry: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public let path: String + public var name: String + public var kind: FeatureFileKind + public var sizeBytes: Int? + public var isHidden: Bool + + public init( + path: String, + name: String, + kind: FeatureFileKind, + sizeBytes: Int? = nil, + isHidden: Bool = false + ) { + self.path = path + self.name = name + self.kind = kind + self.sizeBytes = sizeBytes + self.isHidden = isHidden + } +} + +public extension Array where Element == FeatureFileEntry { + func featureFiltered(by query: String, includesHidden: Bool) -> [FeatureFileEntry] { + let visible = includesHidden ? self : filter { !$0.isHidden } + let filtered: [FeatureFileEntry] + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + filtered = visible + } else { + filtered = visible.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + return filtered.sorted { + if $0.kind == .directory, $1.kind != .directory { return true } + if $0.kind != .directory, $1.kind == .directory { return false } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } +} + +public struct FeatureFileContent: Sendable, Equatable, Codable { + public var path: String + public var text: String + public var language: String? + public var isTruncated: Bool + public var totalBytes: Int? + + public init( + path: String, + text: String, + language: String? = nil, + isTruncated: Bool = false, + totalBytes: Int? = nil + ) { + self.path = path + self.text = text + self.language = language + self.isTruncated = isTruncated + self.totalBytes = totalBytes + } +} + +public enum 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 init( + id: String, + kind: FeatureDiffLineKind, + oldLine: Int? = nil, + newLine: Int? = nil, + text: String + ) { + self.id = id + self.kind = kind + self.oldLine = oldLine + self.newLine = newLine + self.text = text + } +} + +public enum FeatureReviewChangeKind: String, Sendable, Codable { + case added + case modified + case deleted + case renamed + case binary +} + +public struct FeatureReviewFile: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public var path: String + public var previousPath: String? + public var change: FeatureReviewChangeKind + public var additions: Int + public var deletions: Int + public var lines: [FeatureDiffLine] + + public init( + path: String, + previousPath: String? = nil, + change: FeatureReviewChangeKind, + additions: Int, + deletions: Int, + lines: [FeatureDiffLine] = [] + ) { + self.path = path + self.previousPath = previousPath + self.change = change + self.additions = additions + self.deletions = deletions + self.lines = 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 init(number: Int, title: String, state: String, url: URL? = nil) { + self.number = number + self.title = title + self.state = state + self.url = url + } +} + +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 + 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, + files: [FeatureSourceControlFile] = [], + pullRequest: FeaturePullRequest? = nil, + isBusy: Bool = false + ) { + self.isRepository = isRepository + self.branch = branch + self.upstream = upstream + self.aheadCount = aheadCount + self.behindCount = behindCount + 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 pullRequest == nil { + actions.append(.commitPushAndCreatePullRequest) + } + } + if aheadCount > 0 { actions.append(.push) } + if behindCount > 0 { actions.append(.pull) } + if 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 state: FeatureTerminalState + public var title: String + public var workingDirectory: String? + public var buffer: String + public var exitCode: Int? + public var error: String? + + public init( + threadID: String, + state: FeatureTerminalState = .stopped, + title: String = "Terminal", + workingDirectory: String? = nil, + buffer: String = "", + exitCode: Int? = nil, + error: String? = nil + ) { + self.threadID = threadID + self.state = state + self.title = title + self.workingDirectory = workingDirectory + self.buffer = buffer + self.exitCode = exitCode + self.error = error + } +} diff --git a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift new file mode 100644 index 00000000000..a51115c8adb --- /dev/null +++ b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +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 errorMessage: String? + @State private var commitMessage = "" + @State private var pendingCommitAction: FeatureSourceControlAction? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + Group { + if isLoading, status == nil { + ProgressView("Loading repository…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let status { + statusList(status) + } else { + ContentUnavailableView( + "Source control unavailable", + systemImage: "arrow.triangle.branch", + description: Text(errorMessage ?? "Repository status could not be loaded.") + ) + } + } + .background(Color.black) + .navigationTitle("Source Control") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } + .disabled(isLoading || isRunningAction) + .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) + } + .task { await load() } + } + + private func statusList(_ status: FeatureSourceControlStatus) -> some View { + List { + Section("Repository") { + LabeledContent("Branch", value: status.branch ?? "Detached HEAD") + if let upstream = status.upstream { + LabeledContent("Upstream", value: upstream) + } + HStack { + Label("\(status.aheadCount) ahead", systemImage: "arrow.up") + Spacer() + Label("\(status.behindCount) behind", systemImage: "arrow.down") + } + .font(.footnote) + .foregroundStyle(.secondary) + 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(.secondary) + } + ForEach(status.availableActions, id: \.self) { action in + Button { + begin(action) + } label: { + Label(action.title, systemImage: action.icon) + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(isRunningAction) + } + } + + Section("\(status.files.count) changed \(status.files.count == 1 ? "file" : "files")") { + if status.files.isEmpty { + Label("Working tree clean", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } + 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(.subheadline) + .lineLimit(1) + Spacer() + if file.isStaged { + Text("STAGED") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.green) + } + } + .accessibilityElement(children: .combine) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .refreshable { await load() } + .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) } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + status = try await client.sourceControlStatus(threadID: threadID) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func perform(_ action: FeatureSourceControlAction, message: String?) async { + isRunningAction = true + defer { isRunningAction = false } + do { + status = try await client.performSourceControlAction( + threadID: threadID, + action: action, + message: message?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private extension FeatureSourceControlAction { + var requiresMessage: Bool { + switch self { + case .commit, .commitAndPush, .commitPushAndCreatePullRequest: true + case .push, .pull, .createPullRequest: false + } + } + + 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" + } + } + + 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 00000000000..944d9cf31a4 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -0,0 +1,209 @@ +import SwiftUI + +public struct FeatureTerminalView: View { + let client: any FeatureClient + let threadID: String + + @State private var terminal: FeatureTerminalSnapshot? + @State private var input = "" + @State private var isLoading = true + @State private var errorMessage: String? + @FocusState private var inputFocused: Bool + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + VStack(spacing: 0) { + terminalHeader + Divider() + terminalBuffer + terminalControls + } + .background(Color.black) + .navigationTitle("Terminal") + .navigationBarTitleDisplayMode(.inline) + .task { await loadAndOpen() } + .task { + for await update in client.terminalEvents(threadID: threadID) { + terminal = update + } + } + } + + private var terminalHeader: some View { + HStack(spacing: 9) { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text(terminal?.title ?? "Terminal") + .font(.subheadline.weight(.semibold)) + Text(terminal?.workingDirectory ?? statusLabel) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if terminal?.state == .running { + Button("Stop", role: .destructive) { + Task { await stop() } + } + .font(.footnote.weight(.semibold)) + } else { + Button("Start") { + Task { await open() } + } + .font(.footnote.weight(.semibold)) + .disabled(isLoading) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + } + + @ViewBuilder + private var terminalBuffer: some View { + if isLoading, terminal == nil { + ProgressView("Opening terminal…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage, terminal == nil { + ContentUnavailableView( + "Terminal unavailable", + systemImage: "terminal", + description: Text(errorMessage) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView([.horizontal, .vertical]) { + Text(terminal?.buffer.isEmpty == false ? terminal?.buffer ?? "" : " ") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Color(white: 0.86)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, minHeight: 300, alignment: .topLeading) + .padding(12) + .id("terminal-end") + } + .defaultScrollAnchor(.bottom) + .onChange(of: terminal?.buffer) { + proxy.scrollTo("terminal-end", anchor: .bottom) + } + } + } + } + + private var terminalControls: some View { + VStack(spacing: 7) { + HStack(spacing: 7) { + terminalKey("ctrl-c", label: "^C", data: "\u{3}") + terminalKey("escape", label: "esc", data: "\u{1B}") + terminalKey("tab", label: "tab", data: "\t") + Spacer() + } + HStack(spacing: 8) { + TextField("Command", text: $input) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($inputFocused) + .submitLabel(.send) + .onSubmit(sendCommand) + .padding(.horizontal, 11) + .frame(minHeight: 40) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 9)) + Button(action: sendCommand) { + Image(systemName: "return") + .frame(width: 40, height: 40) + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(.black) + .disabled(!isRunning || input.isEmpty) + .accessibilityLabel("Send command") + } + } + .padding(10) + .background(Color(white: 0.025)) + .overlay(alignment: .top) { Divider() } + } + + private func terminalKey(_ id: String, label: String, data: String) -> some View { + Button(label) { + Task { await write(data) } + } + .font(.caption.monospaced().weight(.medium)) + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!isRunning) + .accessibilityIdentifier("terminal-\(id)") + } + + private var isRunning: Bool { terminal?.state == .running } + + private var statusColor: Color { + switch terminal?.state { + case .running: .green + case .starting: .orange + case .failed: .red + case .exited, .stopped, nil: .gray + } + } + + private var statusLabel: String { + terminal?.state.rawValue.capitalized ?? "Not connected" + } + + private func loadAndOpen() async { + isLoading = true + defer { isLoading = false } + do { + terminal = try await client.terminalSnapshot(threadID: threadID) + if terminal?.state == .stopped || terminal?.state == .exited { + try await client.openTerminal(threadID: threadID, columns: 80, rows: 24) + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func open() async { + do { + try await client.openTerminal(threadID: threadID, columns: 80, rows: 24) + terminal = try await client.terminalSnapshot(threadID: threadID) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func stop() async { + do { + try await client.closeTerminal(threadID: threadID) + terminal = try? await client.terminalSnapshot(threadID: threadID) + } catch { + errorMessage = error.localizedDescription + } + } + + private func sendCommand() { + guard isRunning, !input.isEmpty else { return } + let command = input + input = "" + Task { + await write(command + "\n") + inputFocused = true + } + } + + private func write(_ data: String) async { + do { + try await client.writeTerminal(threadID: threadID, data: data) + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift new file mode 100644 index 00000000000..dca6e73f4d2 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +public struct NewThreadView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + let onCreated: (FeatureThread) -> Void + + @State private var projectID = "" + @State private var title = "" + @State private var selection: FeatureSelection? + @State private var isCreating = false + + public init(model: FeatureRootModel, onCreated: @escaping (FeatureThread) -> Void) { + self.model = model + self.onCreated = onCreated + } + + public var body: some View { + NavigationStack { + Form { + Section("Project") { + Picker("Project", selection: $projectID) { + ForEach(model.snapshot.projects) { project in + Text(project.name).tag(project.id) + } + } + } + + Section("Task") { + TextField("Optional title", text: $title, axis: .vertical) + .lineLimit(1...3) + ProviderModelPicker( + providers: model.snapshot.providers, + selection: $selection + ) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("New Thread") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isCreating ? "Creating…" : "Create") { + create() + } + .disabled(projectID.isEmpty || isCreating) + } + } + .onAppear { + if projectID.isEmpty { + projectID = model.snapshot.projects.first?.id ?? "" + } + if selection == nil { + selection = model.snapshot.settings.defaultSelection + ?? model.snapshot.providers.first?.models.first.map { + FeatureSelection( + providerID: model.snapshot.providers.first?.id ?? "", + modelID: $0.id + ) + } + } + } + } + } + + private func create() { + isCreating = true + Task { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if let thread = await model.createThread( + projectID: projectID, + title: trimmed.isEmpty ? nil : trimmed, + selection: selection + ) { + onCreated(thread) + } + isCreating = false + } + } +} + +public struct ProviderModelPicker: View { + let providers: [FeatureProvider] + @Binding var selection: FeatureSelection? + + public init(providers: [FeatureProvider], selection: Binding) { + self.providers = providers + _selection = selection + } + + public var body: some View { + Menu { + ForEach(providers.filter(\.isAvailable)) { provider in + Section(provider.name) { + ForEach(provider.models) { model in + Button { + selection = FeatureSelection(providerID: provider.id, modelID: model.id) + } label: { + if selection?.providerID == provider.id, selection?.modelID == model.id { + Label(model.name, systemImage: "checkmark") + } else { + Text(model.name) + } + } + } + } + } + } label: { + LabeledContent("Model") { + Text(selectionLabel) + .foregroundStyle(.secondary) + } + } + .accessibilityLabel("Provider and model") + } + + private var selectionLabel: String { + guard let selection, + let provider = providers.first(where: { $0.id == selection.providerID }), + let model = provider.models.first(where: { $0.id == selection.modelID }) else { + return "Automatic" + } + return "\(provider.name) · \(model.name)" + } +} diff --git a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift new file mode 100644 index 00000000000..72f4c850b08 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift @@ -0,0 +1,135 @@ +import SwiftUI + +public struct AddProjectView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + @State private var path = "" + @State private var isAdding = false + + public init(model: FeatureRootModel) { + self.model = model + } + + public var body: some View { + NavigationStack { + Form { + Section { + TextField("/path/to/project", text: $path) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Workspace path") + } footer: { + Text("The path is resolved by the connected T3 environment.") + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Add Project") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isAdding ? "Adding…" : "Add") { add() } + .disabled(trimmedPath.isEmpty || isAdding) + } + } + } + } + + private var trimmedPath: String { + path.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func add() { + isAdding = true + Task { + if await model.addProject(path: trimmedPath) { + dismiss() + } + isAdding = false + } + } +} + +public struct ArchivedThreadsView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + let onSelect: (FeatureThread) -> Void + + public init(model: FeatureRootModel, onSelect: @escaping (FeatureThread) -> Void) { + self.model = model + self.onSelect = onSelect + } + + public var body: some View { + NavigationStack { + List { + if archived.isEmpty { + ContentUnavailableView( + "No archived threads", + systemImage: "archivebox", + description: Text("Archived work will appear here.") + ) + .listRowBackground(Color.clear) + } + ForEach(archived) { thread in + HStack(spacing: 12) { + Button { + onSelect(thread) + } label: { + FeatureThreadRow( + thread: thread, + projectName: projectName(for: thread.projectID) + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + Button { + Task { await model.setArchived(thread.id, archived: false) } + } label: { + Image(systemName: "arrow.uturn.backward") + } + .buttonStyle(.borderless) + .accessibilityLabel("Restore \(thread.title)") + } + .swipeActions { + Button(role: .destructive) { + Task { await model.deleteThread(thread.id) } + } label: { + Label("Delete", systemImage: "trash") + } + Button { + Task { await model.setArchived(thread.id, archived: false) } + } label: { + Label("Restore", systemImage: "arrow.uturn.backward") + } + .tint(.blue) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Archived") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private var archived: [FeatureThread] { + model.snapshot.threads + .filter(\.isArchived) + .sorted { $0.updatedAt > $1.updatedAt } + } + + private func projectName(for id: String) -> String { + model.snapshot.projects.first(where: { $0.id == id })?.name ?? "Unknown project" + } +} diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift new file mode 100644 index 00000000000..8c6785cdf61 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -0,0 +1,335 @@ +import SwiftUI + +public struct WorkspaceView: View { + @Bindable var model: FeatureRootModel + @State private var selectedThreadID: String? + @State private var searchText = "" + @State private var showingNewThread = false + @State private var showingAddProject = false + @State private var showingSettings = false + @State private var showingArchived = false + @State private var renamingThread: FeatureThread? + @State private var renameTitle = "" + + public init(model: FeatureRootModel) { + self.model = model + } + + public var body: some View { + NavigationSplitView { + List(selection: $selectedThreadID) { + connectionSection + projectSection + threadSection + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .background(Color.black) + .searchable(text: $searchText, prompt: "Search threads") + .navigationTitle("T3 Code") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showingSettings = true + } label: { + Image(systemName: "gearshape") + } + .accessibilityLabel("Settings") + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showingAddProject = true + } label: { + Image(systemName: "folder.badge.plus") + } + .accessibilityLabel("Add project") + Button { + showingNewThread = true + } label: { + Image(systemName: "square.and.pencil") + } + .accessibilityLabel("New thread") + .disabled(model.snapshot.projects.isEmpty) + } + } + } detail: { + if let id = selectedThreadID, + let thread = model.snapshot.threads.first(where: { $0.id == id }) { + ThreadDetailView(model: model, thread: thread) + .id(id) + } else { + ContentUnavailableView( + "Choose a thread", + systemImage: "bubble.left.and.bubble.right", + description: Text("Select a thread or start a new task.") + ) + .background(Color.black) + } + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $showingNewThread) { + NewThreadView(model: model) { thread in + selectedThreadID = thread.id + showingNewThread = false + } + } + .sheet(isPresented: $showingAddProject) { + AddProjectView(model: model) + } + .sheet(isPresented: $showingSettings) { + SettingsView(model: model) + } + .sheet(isPresented: $showingArchived) { + ArchivedThreadsView(model: model) { thread in + selectedThreadID = thread.id + showingArchived = false + } + } + .alert( + "Rename thread", + isPresented: Binding( + get: { renamingThread != nil }, + set: { if !$0 { renamingThread = nil } } + ) + ) { + TextField("Thread title", text: $renameTitle) + Button("Cancel", role: .cancel) { + renamingThread = nil + } + Button("Save") { + guard let thread = renamingThread else { return } + let title = renameTitle + renamingThread = nil + Task { await model.renameThread(thread.id, title: title) } + } + .disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .onChange(of: model.snapshot.threads) { + if let selectedThreadID, + !model.snapshot.threads.contains(where: { $0.id == selectedThreadID }) { + self.selectedThreadID = nil + } + } + } + + @ViewBuilder + private var connectionSection: some View { + Section { + Menu { + ForEach(model.snapshot.environments) { environment in + Button { + Task { await model.activateEnvironment(environment.id) } + } label: { + if environment.isActive { + Label(environment.name, systemImage: "checkmark") + } else { + Text(environment.name) + } + } + } + } label: { + HStack(spacing: 9) { + Circle() + .fill(Color.green) + .frame(width: 7, height: 7) + Text(model.snapshot.connection.environmentName ?? "Connected") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + if model.snapshot.environments.count > 1 { + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private var projectSection: some View { + Section("Projects") { + if model.snapshot.projects.isEmpty { + Text("No projects") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(model.snapshot.projects) { project in + HStack(spacing: 10) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(project.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Text(project.path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Text("\(activeThreads.filter { $0.projectID == project.id }.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + .accessibilityElement(children: .combine) + } + } + } + } + + @ViewBuilder + private var threadSection: some View { + Section { + if filteredThreads.isEmpty { + Text(searchText.isEmpty ? "No active threads" : "No results") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(filteredThreads) { thread in + NavigationLink(value: thread.id) { + FeatureThreadRow( + thread: thread, + projectName: projectName(for: thread.projectID) + ) + } + .tag(thread.id) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + Task { await model.deleteThread(thread.id) } + } label: { + Label("Delete", systemImage: "trash") + } + Button { + Task { await model.setArchived(thread.id, archived: true) } + } label: { + Label("Archive", systemImage: "archivebox") + } + .tint(.orange) + } + .contextMenu { + Button { + renameTitle = thread.title + renamingThread = thread + } label: { + Label("Rename", systemImage: "pencil") + } + Button { + Task { await model.setArchived(thread.id, archived: true) } + } label: { + Label("Archive", systemImage: "archivebox") + } + Button { + Task { await model.setSettled(thread.id, settled: !thread.isSettled) } + } label: { + Label( + thread.isSettled ? "Reopen" : "Mark done", + systemImage: thread.isSettled ? "arrow.counterclockwise" : "checkmark" + ) + } + Button { + Task { + await model.setSnoozed( + thread.id, + until: thread.snoozedUntil == nil + ? Date().addingTimeInterval(60 * 60) + : nil + ) + } + } label: { + Label( + thread.snoozedUntil == nil ? "Snooze 1 hour" : "Unsnooze", + systemImage: thread.snoozedUntil == nil ? "clock" : "bell" + ) + } + Button(role: .destructive) { + Task { await model.deleteThread(thread.id) } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + Button { + showingArchived = true + } label: { + Label("Archived", systemImage: "archivebox") + .font(.subheadline) + } + } header: { + Text("Threads") + } + } + + private var activeThreads: [FeatureThread] { + model.snapshot.threads + .filter { !$0.isArchived } + .sorted { $0.updatedAt > $1.updatedAt } + } + + private var filteredThreads: [FeatureThread] { + guard !searchText.isEmpty else { return activeThreads } + return activeThreads.filter { + $0.title.localizedCaseInsensitiveContains(searchText) + || ($0.preview?.localizedCaseInsensitiveContains(searchText) ?? false) + || projectName(for: $0.projectID).localizedCaseInsensitiveContains(searchText) + } + } + + private func projectName(for id: String) -> String { + model.snapshot.projects.first(where: { $0.id == id })?.name ?? "Unknown project" + } +} + +struct FeatureThreadRow: View { + let thread: FeatureThread + let projectName: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .padding(.top, 6) + VStack(alignment: .leading, spacing: 3) { + Text(thread.title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + HStack(spacing: 5) { + Text(projectName) + if let preview = thread.preview, !preview.isEmpty { + Text("·") + Text(preview) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityValue(statusText) + } + + private var statusColor: Color { + switch thread.state { + case .idle, .completed: .gray + case .queued: .blue + case .working: .cyan + case .waitingForApproval: .orange + case .waitingForInput: .indigo + case .failed: .red + } + } + + private var statusText: String { + switch thread.state { + case .waitingForApproval: "Waiting for approval" + case .waitingForInput: "Waiting for input" + default: thread.state.rawValue + } + } +} diff --git a/apps/swift-ios/README.md b/apps/swift-ios/README.md new file mode 100644 index 00000000000..99ea13b3802 --- /dev/null +++ b/apps/swift-ios/README.md @@ -0,0 +1,67 @@ +# T3 Code for iOS + +A dependency-free, native SwiftUI client for T3 Code. The project targets iOS 17 +and later on iPhone and iPad. + +## Open + +Open `T3Code.xcodeproj`, choose the `T3Code` scheme, and run an installed iOS +simulator. Xcode automatically includes Swift files added below `App`, `Core`, +`Features`, and `DesignSystem`. + +Pair with the same URL produced by a T3 server. The one-time pairing credential is +exchanged for an access token and stored in the Keychain. Environment metadata and +the active selection are stored separately in Application Support. + +## Structure + +- `App` owns the app lifecycle and the thin root composition seam. +- `Core` owns persistence, credentials, transport, and the T3 protocol. +- `Features` owns onboarding, environments, threads, messages, and settings. +- `DesignSystem` contains the small set of shared visual tokens. +- `Resources` contains the asset catalog. +- `Tests` covers pairing, wire contracts, persistence, and feature state changes. + +`RootView` deliberately accepts any SwiftUI content. Production composition injects +`FeatureRootView(client:)` there, keeping protocol adapters out of the UI shell. + +## Included + +- Direct pairing URLs, token exchange, Keychain credentials, saved environment + switching and removal, reconnect, and HTTP polling. +- Server-side project creation from a known workspace path, plus thread search, + creation, rename, archive, restore, delete, settle, snooze, runtime mode, and + interaction mode. +- Provider/model selection, synchronized conversation history, turn cancellation, + approval decisions, and structured user-input requests. +- Workspace files and previews, working-tree review, Git status and common actions, + plus an interactive terminal session scoped to each thread. +- Native settings with persisted appearance and behavior preferences. + +The app speaks the existing HTTP and Effect RPC WebSocket contracts directly. No +JavaScript runtime or third-party package is embedded. + +## Current gaps + +- T3 Connect account sign-in, managed relay environment discovery, and push/live + activity registration are not implemented. Pairing is direct to an environment. +- Adding a project requires entering a path that exists on the server. Remote + filesystem browsing, repository cloning, and source-control account discovery are + not implemented yet. +- Shell updates use the reconnecting WebSocket stream with HTTP polling as a + fallback. Open thread details refresh from the typed thread snapshot when their + shell state changes. +- Attachments and notification deep links are not exposed by the current feature + UI. Source-control status currently reflects changed paths but the server status + response does not distinguish staged files or individual change kinds. + +## Verify + +Run the `T3Code` scheme's tests in Xcode, or use: + +```sh +xcodebuild test \ + -project T3Code.xcodeproj \ + -scheme T3Code \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' +``` diff --git a/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000000..b1e4d30e3c2 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "1.000", + "green": "0.520", + "red": "0.040" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000000..74d6a722cf3 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..3d6d61311a8 --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj @@ -0,0 +1,496 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000001 /* App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = App; + sourceTree = ""; + }; + A10000000000000000000002 /* Core */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Core; + sourceTree = ""; + }; + A10000000000000000000003 /* Features */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Features; + sourceTree = ""; + }; + A10000000000000000000004 /* DesignSystem */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DesignSystem; + sourceTree = ""; + }; + A10000000000000000000005 /* Resources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Resources; + sourceTree = ""; + }; + A10000000000000000000006 /* Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Tests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A20000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A20000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + AC0000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = T3Code; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXGroup section */ + A30000000000000000000001 = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* App */, + A10000000000000000000002 /* Core */, + A10000000000000000000003 /* Features */, + A10000000000000000000004 /* DesignSystem */, + A10000000000000000000005 /* Resources */, + A10000000000000000000006 /* Tests */, + A30000000000000000000002 /* Products */, + ); + sourceTree = ""; + }; + A30000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + A40000000000000000000001 /* T3 Code.app */, + A40000000000000000000002 /* T3CodeTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A50000000000000000000001 /* T3Code */ = { + isa = PBXNativeTarget; + buildConfigurationList = A60000000000000000000002 /* Build configuration list for PBXNativeTarget "T3Code" */; + buildPhases = ( + A70000000000000000000001 /* Sources */, + A20000000000000000000001 /* Frameworks */, + A80000000000000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000001 /* App */, + A10000000000000000000002 /* Core */, + A10000000000000000000003 /* Features */, + A10000000000000000000004 /* DesignSystem */, + A10000000000000000000005 /* Resources */, + ); + name = T3Code; + packageProductDependencies = ( + ); + productName = T3Code; + productReference = A40000000000000000000001 /* T3 Code.app */; + productType = "com.apple.product-type.application"; + }; + A50000000000000000000002 /* T3CodeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A60000000000000000000003 /* Build configuration list for PBXNativeTarget "T3CodeTests" */; + buildPhases = ( + A70000000000000000000002 /* Sources */, + A20000000000000000000002 /* Frameworks */, + A80000000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AD0000000000000000000001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000006 /* Tests */, + ); + name = T3CodeTests; + packageProductDependencies = ( + ); + productName = T3CodeTests; + productReference = A40000000000000000000002 /* T3CodeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A90000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A50000000000000000000001 = { + CreatedOnToolsVersion = 16.0; + }; + A50000000000000000000002 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = A50000000000000000000001; + }; + }; + }; + buildConfigurationList = A60000000000000000000001 /* Build configuration list for PBXProject "T3Code" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A30000000000000000000001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A30000000000000000000002 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A50000000000000000000001 /* T3Code */, + A50000000000000000000002 /* T3CodeTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A80000000000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A80000000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A70000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A70000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AD0000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* T3Code */; + targetProxy = AC0000000000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + AB0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AB0000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + AB0000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "T3 Code Swift"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swift; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AB0000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "T3 Code"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swift; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AB0000000000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swift.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/T3Code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/T3Code"; + }; + name = Debug; + }; + AB0000000000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swift.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/T3Code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/T3Code"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A60000000000000000000001 /* Build configuration list for PBXProject "T3Code" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000001 /* Debug */, + AB0000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A60000000000000000000002 /* Build configuration list for PBXNativeTarget "T3Code" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000003 /* Debug */, + AB0000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A60000000000000000000003 /* Build configuration list for PBXNativeTarget "T3CodeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000005 /* Debug */, + AB0000000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin PBXFileReference section */ + A40000000000000000000001 /* T3 Code.app */ = { + isa = PBXFileReference; + explicitFileType = wrapper.application; + includeInIndex = 0; + path = "T3 Code.app"; + sourceTree = BUILT_PRODUCTS_DIR; + }; + A40000000000000000000002 /* T3CodeTests.xctest */ = { + isa = PBXFileReference; + explicitFileType = wrapper.cfbundle; + includeInIndex = 0; + path = T3CodeTests.xctest; + sourceTree = BUILT_PRODUCTS_DIR; + }; +/* End PBXFileReference section */ + }; + rootObject = A90000000000000000000001 /* Project object */; +} diff --git a/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme b/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme new file mode 100644 index 00000000000..45680ddc0e7 --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift b/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift new file mode 100644 index 00000000000..ca4a64990f1 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import T3Code + +@MainActor +final class CoreContractTests: XCTestCase { + func testDirectAndHostedPairingURLsResolveLikeExistingClients() throws { + let direct = try PairingURL.resolve("https://studio.example/pair#token=secret") + XCTAssertEqual(direct.credential, "secret") + XCTAssertEqual(direct.httpBaseURL.absoluteString, "https://studio.example/") + XCTAssertEqual(direct.webSocketBaseURL.absoluteString, "wss://studio.example/") + + let hosted = try PairingURL.resolve( + "https://app.t3.codes/pair?host=https%3A%2F%2Fremote.example#token=hosted-secret" + ) + XCTAssertEqual(hosted.credential, "hosted-secret") + XCTAssertEqual(hosted.httpBaseURL.absoluteString, "https://remote.example/") + XCTAssertEqual(hosted.webSocketBaseURL.absoluteString, "wss://remote.example/") + } + + func testShellSnapshotDecodesCurrentWireShape() throws { + let data = Data( + """ + { + "snapshotSequence": 7, + "projects": [{ + "id": "project-1", + "title": "T3 Code", + "workspaceRoot": "/work/t3", + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol" + }, + "scripts": [], + "createdAt": "2026-07-30T12:00:00.000Z", + "updatedAt": "2026-07-30T12:00:00.000Z" + }], + "threads": [], + "updatedAt": "2026-07-30T12:00:00.000Z" + } + """.utf8 + ) + + let snapshot = try JSONDecoder.t3.decode(OrchestrationShellSnapshot.self, from: data) + XCTAssertEqual(snapshot.snapshotSequence, 7) + XCTAssertEqual(snapshot.projects.first?.defaultModelSelection?.instanceId, "codex") + XCTAssertNil(snapshot.projects.first?.deletedAt) + } + + func testCommandBuildersMatchOrchestrationContract() throws { + let model = ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") + let command = try OrchestrationCommands.createThread( + threadID: "thread-1", + projectID: "project-1", + title: "Native rebuild", + model: model, + runtimeMode: .fullAccess, + commandID: "command-1", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + XCTAssertEqual(command["type"]?.stringValue, "thread.create") + XCTAssertEqual(command["threadId"]?.stringValue, "thread-1") + XCTAssertEqual(command["modelSelection"]?["instanceId"]?.stringValue, "codex") + XCTAssertEqual(command["runtimeMode"]?.stringValue, "full-access") + } + + func testEnvironmentStorePersistsSelectionAndClearsRemovedActiveEnvironment() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-core-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let store = EnvironmentStore(fileURL: file) + let first = Environment( + id: "one", + label: "One", + httpBaseURL: URL(string: "https://one.example")!, + webSocketBaseURL: URL(string: "wss://one.example")! + ) + let second = Environment( + id: "two", + label: "Two", + httpBaseURL: URL(string: "https://two.example")!, + webSocketBaseURL: URL(string: "wss://two.example")! + ) + + try await store.save([first, second]) + try await store.setActiveEnvironment(id: second.id) + let selected = try await store.activeEnvironmentID() + XCTAssertEqual(selected, second.id) + + let remaining = try await store.remove(id: second.id) + let fallback = try await store.activeEnvironmentID() + XCTAssertEqual(remaining.map(\.id), [first.id]) + XCTAssertEqual(fallback, first.id) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift b/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift new file mode 100644 index 00000000000..3e537e9a0d5 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import T3Code + +@MainActor +final class PairingServiceTests: XCTestCase { + func testPairingExchangesTokenAndPersistsSecretSeparately() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transport = PairingHTTPTransport() + let credentials = InMemoryCredentialStore() + let environments = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + let service = PairingService( + transport: transport, + environmentStore: environments, + credentialStore: credentials + ) + + let environment = try await service.pair( + url: "https://studio.example/#token=pair-once", + label: "Theo's iPhone" + ) + + XCTAssertEqual(environment.id, "environment-1") + let storedEnvironments = try await environments.load() + let activeID = try await environments.activeEnvironmentID() + let credential = await credentials.credential(for: "environment-1") + XCTAssertEqual(storedEnvironments.map(\.id), ["environment-1"]) + XCTAssertEqual(activeID, "environment-1") + XCTAssertEqual(credential?.accessToken, "access-token") + + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url?.path }, [ + "/.well-known/t3/environment", + "/oauth/token", + ]) + let form = String(data: requests[1].httpBody!, encoding: .utf8)! + XCTAssertTrue(form.contains("subject_token=pair-once")) + XCTAssertTrue(form.contains("client_device_type=mobile")) + } +} + +private actor PairingHTTPTransport: HTTPTransport { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url?.path { + case "/.well-known/t3/environment": + body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """ + case "/oauth/token": + body = """ + { + "access_token": "access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "orchestration:read orchestration:operate" + } + """ + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "")") + body = "{}" + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (Data(body.utf8), response) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift b/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift new file mode 100644 index 00000000000..c034ba6fdc1 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import T3Code + +@MainActor +final class WorkspaceContractTests: XCTestCase { + func testVCSStatusSnapshotDecodesTaggedEffectRPCShape() throws { + let data = Data( + """ + { + "_tag": "snapshot", + "local": { + "isRepo": true, + "sourceControlProvider": { + "kind": "github", + "name": "GitHub", + "baseUrl": "https://github.com" + }, + "hasPrimaryRemote": true, + "isDefaultRef": false, + "refName": "feat/swift", + "hasWorkingTreeChanges": true, + "workingTree": { + "files": [{"path":"Core/T3Client.swift","insertions":12,"deletions":2}], + "insertions": 12, + "deletions": 2 + } + }, + "remote": { + "hasUpstream": true, + "aheadCount": 1, + "behindCount": 0, + "aheadOfDefaultCount": 3, + "pr": null + } + } + """.utf8 + ) + + let event = try JSONDecoder.t3.decode(VCSStatusEvent.self, from: data) + guard case let .snapshot(local, remote) = event else { + return XCTFail("Expected snapshot") + } + XCTAssertEqual(local.refName, "feat/swift") + XCTAssertEqual(local.workingTree.files.first?.insertions, 12) + XCTAssertEqual(remote?.aheadCount, 1) + } + + func testTerminalAttachEventsDecodeSnapshotAndOutputShapes() throws { + let snapshotData = Data( + """ + { + "type": "snapshot", + "snapshot": { + "threadId": "thread-1", + "terminalId": "term-1", + "cwd": "/workspace", + "worktreePath": null, + "status": "running", + "pid": 42, + "history": "$ ", + "exitCode": null, + "exitSignal": null, + "label": "Shell", + "updatedAt": "2026-07-30T12:00:00.000Z", + "sequence": 4 + } + } + """.utf8 + ) + let outputData = Data( + """ + { + "type": "output", + "threadId": "thread-1", + "terminalId": "term-1", + "sequence": 5, + "data": "hello\\r\\n" + } + """.utf8 + ) + + let snapshot = try JSONDecoder.t3.decode(TerminalEvent.self, from: snapshotData) + let output = try JSONDecoder.t3.decode(TerminalEvent.self, from: outputData) + XCTAssertEqual(snapshot.snapshot?.pid, 42) + XCTAssertEqual(snapshot.snapshot?.sequence, 4) + XCTAssertEqual(output.data, "hello\r\n") + XCTAssertEqual(output.sequence, 5) + } + + func testReviewAndProjectFileResultsDecodeExactServerFields() throws { + let reviewData = Data( + """ + { + "cwd": "/workspace", + "generatedAt": "2026-07-30T12:00:00.000Z", + "sources": [{ + "id": "working-tree", + "kind": "working-tree", + "title": "Working tree", + "baseRef": null, + "headRef": null, + "diff": "diff --git a/file b/file", + "diffHash": "abc123", + "truncated": false + }] + } + """.utf8 + ) + let fileData = Data( + """ + { + "relativePath": "README.md", + "contents": "# T3", + "byteLength": 4, + "truncated": false + } + """.utf8 + ) + + let review = try JSONDecoder.t3.decode(ReviewDiffPreview.self, from: reviewData) + let file = try JSONDecoder.t3.decode(ProjectReadFileResult.self, from: fileData) + XCTAssertEqual(review.sources.first?.kind, "working-tree") + XCTAssertEqual(review.sources.first?.diffHash, "abc123") + XCTAssertEqual(file.relativePath, "README.md") + XCTAssertFalse(file.truncated) + } + + func testWorkspaceRPCMethodNamesMatchContractConstants() { + XCTAssertEqual(RPCMethod.projectsListEntries.rawValue, "projects.listEntries") + XCTAssertEqual(RPCMethod.vcsRefreshStatus.rawValue, "vcs.refreshStatus") + XCTAssertEqual(RPCMethod.reviewDiffPreview.rawValue, "review.getDiffPreview") + XCTAssertEqual(RPCMethod.terminalAttach.rawValue, "terminal.attach") + XCTAssertEqual(RPCMethod.subscribeTerminalEvents.rawValue, "subscribeTerminalEvents") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift new file mode 100644 index 00000000000..32062f6e3a3 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -0,0 +1,135 @@ +import Testing +@testable import T3Code + +@MainActor +@Suite("Feature root model") +struct FeatureRootModelTests { + @Test + func testPairReloadsConnectedSnapshot() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot(connection: .init(state: .disconnected)) + client.snapshotAfterPair = FeatureSnapshot( + connection: .init( + state: .connected, + environmentName: "Studio", + endpoint: "https://studio.example" + ) + ) + let model = FeatureRootModel(client: client) + + let result = await model.pair(endpoint: "https://studio.example", token: "pair-token") + + #expect(result) + #expect(client.pairEndpoint == "https://studio.example") + #expect(client.pairToken == "pair-token") + #expect(model.snapshot.connection.state == .connected) + } + + @Test + func testCreateThreadOptimisticallyUpsertsIt() async { + let client = FeatureClientStub() + let created = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Build native app", + providerID: "codex", + modelID: "gpt-5" + ) + client.createdThread = created + let model = FeatureRootModel(client: client) + + let result = await model.createThread( + projectID: "project-1", + title: created.title, + selection: .init(providerID: "codex", modelID: "gpt-5") + ) + + #expect(result == created) + #expect(model.snapshot.threads == [created]) + } + + @Test + func testSendAddsQueuedMessageBeforeServerEvent() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.threadDetail = FeatureThreadDetail(thread: thread) + let model = FeatureRootModel(client: client) + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: " ship it ", + selection: nil + ) + + #expect(sent) + #expect(client.sentText == "ship it") + #expect(model.details[thread.id]?.messages.last?.text == "ship it") + #expect(model.details[thread.id]?.messages.last?.state == .queued) + } + + @Test + func testArchiveAndDeleteKeepLocalListsConsistent() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.createdThread = thread + let model = FeatureRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setArchived(thread.id, archived: true) + #expect(model.snapshot.threads[0].isArchived) + + await model.deleteThread(thread.id) + #expect(model.snapshot.threads.isEmpty) + } +} + +@MainActor +private final class FeatureClientStub: FeatureClient { + var snapshot = FeatureSnapshot() + var snapshotAfterPair: FeatureSnapshot? + var createdThread = FeatureThread(id: "created", projectID: "project", title: "Created") + var threadDetail: FeatureThreadDetail? + var pairEndpoint: String? + var pairToken: String? + var sentText: String? + + func initialSnapshot() async throws -> FeatureSnapshot { + if pairEndpoint != nil, let snapshotAfterPair { + return snapshotAfterPair + } + return snapshot + } + + func pair(endpoint: String, token: String?) async throws { + pairEndpoint = endpoint + pairToken = token + } + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + createdThread + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func deleteThread(id: String) async throws {} + + func loadThread(id: String) async throws -> FeatureThreadDetail { + if let threadDetail { + return threadDetail + } + return FeatureThreadDetail(thread: createdThread) + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws { + sentText = text + } + + func cancelTurn(threadID: String) async throws {} + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func saveSettings(_ settings: FeatureSettings) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift new file mode 100644 index 00000000000..7df5a90ae50 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift @@ -0,0 +1,58 @@ +import Testing +@testable import T3Code + +@Suite("Thread tool state") +struct FeatureToolStateTests { + @Test + func fileFilteringKeepsDirectoriesFirstAndHonorsHiddenFiles() { + let entries = [ + FeatureFileEntry(path: "z.swift", name: "z.swift", kind: .file), + FeatureFileEntry(path: ".env", name: ".env", kind: .file, isHidden: true), + FeatureFileEntry(path: "Sources", name: "Sources", kind: .directory), + FeatureFileEntry(path: "a.swift", name: "a.swift", kind: .file), + ] + + #expect(entries.featureFiltered(by: "", includesHidden: false).map(\.name) == [ + "Sources", "a.swift", "z.swift", + ]) + #expect(entries.featureFiltered(by: "env", includesHidden: true).map(\.name) == [".env"]) + } + + @Test + func reviewTotalsAggregateAcrossFiles() { + let review = FeatureReview(files: [ + FeatureReviewFile(path: "a.swift", change: .modified, additions: 4, deletions: 1), + FeatureReviewFile(path: "b.swift", change: .added, additions: 8, deletions: 0), + ]) + + #expect(review.additions == 12) + #expect(review.deletions == 1) + } + + @Test + func sourceControlActionsReflectRepositoryState() { + let clean = FeatureSourceControlStatus(branch: "main") + #expect(clean.availableActions == [.createPullRequest]) + + let changed = FeatureSourceControlStatus( + branch: "feature/native", + aheadCount: 2, + behindCount: 1, + files: [.init(path: "App.swift", state: .modified, isStaged: false)] + ) + #expect(changed.availableActions.contains(.commit)) + #expect(changed.availableActions.contains(.push)) + #expect(changed.availableActions.contains(.pull)) + #expect(changed.availableActions.contains(.commitPushAndCreatePullRequest)) + + var busy = changed + busy.isBusy = true + #expect(busy.availableActions.isEmpty) + } + + @Test + func terminalPlainTextDropsControlSequences() { + let prompt = "\u{1B}]0;workspace\u{7}\u{1B}[38;5;221mx\u{8}repo\u{1B}[39m ❯ " + #expect(NativeWorkspaceMapper.terminalText(prompt) == "repo ❯ ") + } +} From c9679c3ec46ad71db49592ed4b8466544f1bd88d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 08:17:17 -0700 Subject: [PATCH 002/154] feat(ios): harden native connectivity --- apps/swift-ios/Core/Attachments.swift | 102 +++++++ apps/swift-ios/Core/HTTP.swift | 106 +++++++- apps/swift-ios/Core/LocalNetworkProbe.swift | 181 +++++++++++++ apps/swift-ios/Core/PairingService.swift | 8 +- apps/swift-ios/Core/PairingURL.swift | 226 ++++++++++++++-- apps/swift-ios/Core/ServerConfigModels.swift | 136 ++++++++++ apps/swift-ios/Core/T3Client.swift | 115 +++++++- apps/swift-ios/Core/WebSocketRPC.swift | 39 ++- .../NativeContractExpansionTests.swift | 250 ++++++++++++++++++ .../CoreTests/TransportReliabilityTests.swift | 193 ++++++++++++++ 10 files changed, 1313 insertions(+), 43 deletions(-) create mode 100644 apps/swift-ios/Core/Attachments.swift create mode 100644 apps/swift-ios/Core/LocalNetworkProbe.swift create mode 100644 apps/swift-ios/Core/ServerConfigModels.swift create mode 100644 apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift create mode 100644 apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift new file mode 100644 index 00000000000..6ca34516886 --- /dev/null +++ b/apps/swift-ios/Core/Attachments.swift @@ -0,0 +1,102 @@ +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." + } + } +} + +/// Inline upload shape accepted by `thread.turn.start`. +/// +/// There is intentionally no standalone attachment-upload endpoint in the T3 +/// contract. New image bytes travel as a base64 data URL on the turn command; +/// the server normalizes them into a persisted `ChatAttachment`. +public struct UploadChatImageAttachment: Codable, Equatable, Sendable { + public static let maximumBytes = 10 * 1024 * 1024 + + public let type: String + public let name: String + public let mimeType: String + public let sizeBytes: Int + public let dataUrl: String + + public init(data: Data, name: String, mimeType: String) throws { + guard !data.isEmpty else { throw ImageAttachmentError.empty } + guard data.count <= Self.maximumBytes else { + throw ImageAttachmentError.tooLarge( + actualBytes: data.count, + maximumBytes: Self.maximumBytes + ) + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 255 else { + throw ImageAttachmentError.invalidName + } + let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedMIME.hasPrefix("image/"), normalizedMIME.count <= 100 else { + throw ImageAttachmentError.invalidMIMEType + } + type = "image" + self.name = normalizedName + self.mimeType = normalizedMIME + sizeBytes = data.count + dataUrl = "data:\(normalizedMIME);base64,\(data.base64EncodedString())" + } + + var jsonValue: JSONValue { + .object([ + "type": .string(type), + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + "dataUrl": .string(dataUrl), + ]) + } +} + +public enum AssetResource: Equatable, Sendable { + case workspaceFile(threadID: String, path: String) + case attachment(id: String) + case projectFavicon(cwd: String) + + var jsonValue: JSONValue { + switch self { + case let .workspaceFile(threadID, path): + .object([ + "_tag": .string("workspace-file"), + "threadId": .string(threadID), + "path": .string(path), + ]) + case let .attachment(id): + .object([ + "_tag": .string("attachment"), + "attachmentId": .string(id), + ]) + case let .projectFavicon(cwd): + .object([ + "_tag": .string("project-favicon"), + "cwd": .string(cwd), + ]) + } + } +} + +public struct AssetCreateURLResult: Codable, Equatable, Sendable { + public let relativeUrl: String + /// Unix epoch milliseconds from the server contract. + public let expiresAt: Double +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index 3ba43180f6d..cbcf7295860 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -7,12 +7,23 @@ public protocol HTTPTransport: Sendable { public struct URLSessionHTTPTransport: HTTPTransport { private let session: URLSession - public init(session: URLSession = .shared) { - self.session = session + 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) { - let (data, response) = try await session.data(for: request) + // 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 } @@ -20,6 +31,26 @@ public struct URLSessionHTTPTransport: HTTPTransport { } } +/// 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?) @@ -126,6 +157,41 @@ public actor EnvironmentAPI { ) } + 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, @@ -150,7 +216,7 @@ public actor EnvironmentAPI { _ request: URLRequest, as type: Result.Type ) async throws -> Result { - let (data, response) = try await transport.data(for: request) + let (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) guard (200..<300).contains(response.statusCode) else { let body = try? JSONDecoder.t3.decode(ErrorBody.self, from: data) throw HTTPError.status( @@ -179,6 +245,38 @@ public struct AuthSessionState: Codable, Equatable, Sendable { 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) -> URL { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! components.path = path diff --git a/apps/swift-ios/Core/LocalNetworkProbe.swift b/apps/swift-ios/Core/LocalNetworkProbe.swift new file mode 100644 index 00000000000..371f8449a95 --- /dev/null +++ b/apps/swift-ios/Core/LocalNetworkProbe.swift @@ -0,0 +1,181 @@ +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 = URL(string: fields.host), + let host = baseURL.host, + ["http", "https"].contains(baseURL.scheme?.lowercased() ?? "") + 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/PairingService.swift b/apps/swift-ios/Core/PairingService.swift index 4643fc9ed5e..906954663f2 100644 --- a/apps/swift-ios/Core/PairingService.swift +++ b/apps/swift-ios/Core/PairingService.swift @@ -22,6 +22,12 @@ public actor PairingService { "orchestration:operate", "terminal:operate", "review:write", + // Startup pairing credentials carry administrative access. Requesting + // these scopes preserves that access for native multi-device + // management; ordinary pairing links remain bounded by the scopes the + // server granted them. + "access:read", + "access:write", "relay:read", ] @@ -134,7 +140,7 @@ public actor PairingService { "application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type" ) - let (data, response) = try await transport.data(for: request) + 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( diff --git a/apps/swift-ios/Core/PairingURL.swift b/apps/swift-ios/Core/PairingURL.swift index 2df3dccb264..cc88418dd78 100644 --- a/apps/swift-ios/Core/PairingURL.swift +++ b/apps/swift-ios/Core/PairingURL.swift @@ -6,72 +6,232 @@ public struct PairingTarget: Equatable, Sendable { 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 trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard var components = URLComponents(string: trimmed), components.url != nil else { - throw PairingURLError.invalidURL + 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 ["http", "https", "ws", "wss"].contains(components.scheme?.lowercased() ?? "") else { - throw PairingURLError.unsupportedScheme + + 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 = URLComponents(string: "?\(components.fragment ?? "")")?.queryItems ?? [] + let fragment = queryItems(fromFragment: components.fragment) let token = (fragment + query) - .first(where: { $0.name == "token" })? + .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 == "host" })?.value, - !hosted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return try directTarget(host: hosted, credential: requireToken(token)) + 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 + ) } - components.path = "/" - components.query = nil - components.fragment = nil - guard let base = components.url, base.host != nil else { throw PairingURLError.missingHost } - return try target(baseURL: base, credential: requireToken(token)) + 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 + ) } - public static func resolve(host: String, pairingCode: String) throws -> PairingTarget { - let normalized = host.contains("://") ? host : "https://\(host)" - guard let url = URL(string: normalized), url.host != nil else { + /// 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 try directTarget(host: normalized, credential: requireToken(pairingCode)) + return value + } + + 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 { - let normalized = host.contains("://") ? host : "https://\(host)" - guard var components = URLComponents(string: normalized), let url = components.url else { - throw PairingURLError.invalidURL + 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 ["http", "https", "ws", "wss"].contains(components.scheme?.lowercased() ?? "") else { - throw PairingURLError.unsupportedScheme + 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, url.host != nil else { throw PairingURLError.missingHost } - return try target(baseURL: base, credential: credential) + 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 { @@ -100,9 +260,23 @@ public enum PairingURL { ) } + 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/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift new file mode 100644 index 00000000000..d62606842c6 --- /dev/null +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -0,0 +1,136 @@ +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 capabilities: ServerModelCapabilities? +} + +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] +} + +/// Narrow decode view of the much larger `ServerConfig` RPC result. +public struct ServerConfigSnapshot: Codable, Equatable, Sendable { + public let providers: [ServerProviderSnapshot] +} + +public enum ServerConfigStreamEvent: Decodable, Sendable { + case snapshot(ServerConfigSnapshot) + case providerStatuses([ServerProviderSnapshot]) + case unrelated(type: String) + + private enum CodingKeys: String, CodingKey { case type, config, payload } + private struct ProviderPayload: Decodable { let providers: [ServerProviderSnapshot] } + + 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 + ) + default: + self = .unrelated(type: type) + } + } +} diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index 6fe2a279945..03dd1df7f55 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -52,6 +52,40 @@ public actor T3Client { try await api.threadSnapshot(id: id, environment: environment) } + public func serverConfig() async throws -> ServerConfigSnapshot { + try await rpc.request( + RPCMethod.serverGetConfig.rawValue, + as: ServerConfigSnapshot.self + ) + } + + public func serverConfigEvents() async + -> AsyncThrowingStream + { + await rpc.subscribe( + RPCMethod.subscribeServerConfig.rawValue, + as: ServerConfigStreamEvent.self + ) + } + + public func clientSessions() async throws -> [AuthClientSession] { + try await api.clientSessions(for: environment) + } + + public func authSession() async throws -> AuthSessionState { + try await api.session(for: environment) + } + + @discardableResult + public func revokeClientSession(id: String) async throws -> Bool { + try await api.revokeClientSession(id: id, environment: environment).revoked + } + + @discardableResult + public func revokeOtherClientSessions() async throws -> Int { + try await api.revokeOtherClientSessions(for: environment).revokedCount + } + /// HTTP live-sync fallback. Each iteration is an independent request, so a /// transient network loss naturally reconnects without replaying commands. public func pollShell( @@ -119,14 +153,18 @@ public actor T3Client { threadID: String, text: String, runtimeMode: RuntimeMode, - interactionMode: InteractionMode = .default + interactionMode: InteractionMode = .default, + model: ModelSelection? = nil, + attachments: [UploadChatImageAttachment] = [] ) async throws -> DispatchResult { try await dispatch( - OrchestrationCommands.sendTurn( + try OrchestrationCommands.sendTurn( threadID: threadID, text: text, runtimeMode: runtimeMode, - interactionMode: interactionMode + interactionMode: interactionMode, + model: model, + attachments: attachments ) ) } @@ -156,6 +194,37 @@ public actor T3Client { ) } + /// Atomically creates a thread and starts its first turn. This is the + /// server-supported path for message-first thread creation. + @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, + attachments: [UploadChatImageAttachment] = [] + ) async throws -> DispatchResult { + try await dispatch( + try OrchestrationCommands.createThreadAndSend( + threadID: threadID, + projectID: projectID, + title: title, + text: text, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + branch: branch, + worktreePath: worktreePath, + attachments: attachments + ) + ) + } + @discardableResult public func createProject( projectID: String = UUID().uuidString, @@ -328,6 +397,27 @@ public actor T3Client { ) } + /// 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 resolvedAssetURL(resource: AssetResource) async throws -> URL { + 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 url + } + // MARK: VCS and source control public func refreshVCSStatus(cwd: String) async throws -> VCSStatus { @@ -859,6 +949,8 @@ public enum RPCMethod: String, Sendable { case projectsReadFile = "projects.readFile" case projectsWriteFile = "projects.writeFile" case filesystemBrowse = "filesystem.browse" + case assetsCreateURL = "assets.createUrl" + case subscribeServerConfig case subscribeVCSStatus = "subscribeVcsStatus" case vcsPull = "vcs.pull" case vcsRefreshStatus = "vcs.refreshStatus" @@ -943,11 +1035,13 @@ public enum OrchestrationCommands { text: String, runtimeMode: RuntimeMode, interactionMode: InteractionMode = .default, + model: ModelSelection? = nil, + attachments: [UploadChatImageAttachment] = [], commandID: String = UUID().uuidString, messageID: String = UUID().uuidString, createdAt: String = now() - ) -> JSONValue { - .object([ + ) throws -> JSONValue { + var command: [String: JSONValue] = [ "type": .string("thread.turn.start"), "commandId": .string(commandID), "threadId": .string(threadID), @@ -955,12 +1049,16 @@ public enum OrchestrationCommands { "messageId": .string(messageID), "role": .string("user"), "text": .string(text), - "attachments": .array([]), + "attachments": .array(attachments.map(\.jsonValue)), ]), "runtimeMode": .string(runtimeMode.rawValue), "interactionMode": .string(interactionMode.rawValue), "createdAt": .string(createdAt), - ]) + ] + if let model { + command["modelSelection"] = try .encode(model) + } + return .object(command) } public static func createThreadAndSend( @@ -973,6 +1071,7 @@ public enum OrchestrationCommands { interactionMode: InteractionMode = .default, branch: String? = nil, worktreePath: String? = nil, + attachments: [UploadChatImageAttachment] = [], commandID: String = UUID().uuidString, messageID: String = UUID().uuidString, createdAt: String = now() @@ -996,7 +1095,7 @@ public enum OrchestrationCommands { "messageId": .string(messageID), "role": .string("user"), "text": .string(text), - "attachments": .array([]), + "attachments": .array(attachments.map(\.jsonValue)), ]), "runtimeMode": .string(runtimeMode.rawValue), "interactionMode": .string(interactionMode.rawValue), diff --git a/apps/swift-ios/Core/WebSocketRPC.swift b/apps/swift-ios/Core/WebSocketRPC.swift index 8b38e6dcb8c..075223471e1 100644 --- a/apps/swift-ios/Core/WebSocketRPC.swift +++ b/apps/swift-ios/Core/WebSocketRPC.swift @@ -10,15 +10,46 @@ 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) { + 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 connection = URLSessionWebSocketConnection(session: session, url: url) + let request = WebSocketHandshakeRequest.make( + url: url, + offersPerMessageDeflate: offersPerMessageDeflate + ) + let connection = URLSessionWebSocketConnection(session: session, request: request) await connection.open() return connection } @@ -27,8 +58,8 @@ public struct URLSessionWebSocketConnector: WebSocketConnecting { private actor URLSessionWebSocketConnection: WebSocketConnection { private let task: URLSessionWebSocketTask - init(session: URLSession, url: URL) { - task = session.webSocketTask(with: url) + init(session: URLSession, request: URLRequest) { + task = session.webSocketTask(with: request) } func open() { diff --git a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift new file mode 100644 index 00000000000..31c55215d97 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift @@ -0,0 +1,250 @@ +import XCTest +@testable import T3Code + +@MainActor +final class NativeContractExpansionTests: XCTestCase { + func testAdministrativeClientSessionContractsAndRequests() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential( + accessToken: "bearer", + scopes: ["access:read", "access:write"] + ), + ]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + let sessions = try await api.clientSessions(for: environment) + let revoked = try await api.revokeClientSession( + id: "session-2", + environment: environment + ) + let others = try await api.revokeOtherClientSessions(for: environment) + + XCTAssertEqual(sessions.first?.client.label, "Big O") + XCTAssertEqual(sessions.first?.client.deviceType, "mobile") + XCTAssertFalse(sessions.first?.current ?? true) + XCTAssertTrue(revoked.revoked) + XCTAssertEqual(others.revokedCount, 2) + + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url?.path }, [ + "/api/auth/clients", + "/api/auth/clients/revoke", + "/api/auth/clients/revoke-others", + ]) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "Bearer bearer" + }) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Accept-Encoding") == "gzip" + }) + let revokeBody = try JSONDecoder.t3.decode( + [String: String].self, + from: try XCTUnwrap(requests[1].httpBody) + ) + XCTAssertEqual(revokeBody, ["sessionId": "session-2"]) + } + + func testImageAttachmentBuildsExactTurnUploadShape() throws { + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + let command = try OrchestrationCommands.sendTurn( + threadID: "thread-1", + text: "What is in this image?", + runtimeMode: .fullAccess, + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + attachments: [image], + commandID: "command-1", + messageID: "message-1", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + guard case let .array(attachments)? = command["message"]?["attachments"] else { + return XCTFail("Expected an attachment array") + } + let attachment = try XCTUnwrap(attachments.first) + XCTAssertEqual(attachment["type"]?.stringValue, "image") + XCTAssertEqual(attachment["name"]?.stringValue, "screenshot.png") + XCTAssertEqual(attachment["mimeType"]?.stringValue, "image/png") + guard case let .number(sizeBytes)? = attachment["sizeBytes"] else { + return XCTFail("Expected numeric attachment size") + } + XCTAssertEqual(sizeBytes, 4) + XCTAssertEqual( + attachment["dataUrl"]?.stringValue, + "data:image/png;base64,iVBORw==" + ) + XCTAssertEqual(command["modelSelection"]?["instanceId"]?.stringValue, "codex") + } + + func testImageAttachmentRejectsOversizedInput() { + XCTAssertThrowsError( + try UploadChatImageAttachment( + data: Data(count: UploadChatImageAttachment.maximumBytes + 1), + name: "huge.png", + mimeType: "image/png" + ) + ) { error in + guard case ImageAttachmentError.tooLarge = error else { + return XCTFail("Expected size validation, got \(error)") + } + } + } + + func testAssetContractUsesExactTagsAndResultFields() throws { + XCTAssertEqual(RPCMethod.assetsCreateURL.rawValue, "assets.createUrl") + XCTAssertEqual( + AssetResource.attachment(id: "attachment-1").jsonValue["_tag"]?.stringValue, + "attachment" + ) + XCTAssertEqual( + AssetResource.workspaceFile( + threadID: "thread-1", + path: "screenshots/app.png" + ).jsonValue["threadId"]?.stringValue, + "thread-1" + ) + let result = try JSONDecoder.t3.decode( + AssetCreateURLResult.self, + from: Data( + """ + { + "relativeUrl": "/api/assets/signed/image.png", + "expiresAt": 1785466800000 + } + """.utf8 + ) + ) + XCTAssertEqual(result.relativeUrl, "/api/assets/signed/image.png") + XCTAssertEqual(result.expiresAt, 1_785_466_800_000) + } + + func testServerConfigDecodesFullModelPickerCatalogue() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + """ + { + "providers": [{ + "instanceId": "codex-work", + "driver": "codex", + "displayName": "Codex", + "accentColor": "#10a37f", + "badgeLabel": "OpenAI", + "showInteractionModeToggle": true, + "requiresNewThreadForModelChange": false, + "enabled": true, + "installed": true, + "version": "1.2.3", + "status": "ready", + "auth": { + "status": "authenticated", + "type": "chatgpt", + "label": "ChatGPT", + "email": "theo@example.com" + }, + "checkedAt": "2026-07-30T12:00:00.000Z", + "availability": "available", + "models": [{ + "slug": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "shortName": "Sol", + "isCustom": false, + "isDefault": true, + "capabilities": { + "optionDescriptors": [{ + "id": "effort", + "type": "select", + "label": "Reasoning", + "description": "How hard the model thinks.", + "options": [{ + "id": "high", + "label": "High", + "isDefault": true + }], + "currentValue": "high" + }, { + "id": "fastMode", + "type": "boolean", + "label": "Fast mode", + "currentValue": true + }] + } + }] + }] + } + """.utf8 + ) + ) + + let provider = try XCTUnwrap(config.providers.first) + XCTAssertEqual(provider.instanceId, "codex-work") + XCTAssertEqual(provider.auth.status, "authenticated") + let model = try XCTUnwrap(provider.models.first) + XCTAssertEqual(model.slug, "gpt-5.6-sol") + let descriptors = try XCTUnwrap(model.capabilities?.optionDescriptors) + guard case let .select(effort) = descriptors[0], + case let .boolean(fastMode) = descriptors[1] + else { + return XCTFail("Expected typed select and boolean descriptors") + } + XCTAssertEqual(effort.options.first?.label, "High") + XCTAssertEqual(effort.currentValue, "high") + XCTAssertEqual(fastMode.currentValue, true) + } +} + +private actor AccessHTTPTransport: HTTPTransport { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url?.path { + case "/api/auth/clients": + body = """ + [{ + "sessionId": "session-2", + "subject": "paired-client", + "scopes": ["orchestration:read"], + "method": "bearer-access-token", + "client": { + "label": "Big O", + "ipAddress": "192.168.1.10", + "deviceType": "mobile", + "os": "iOS" + }, + "issuedAt": "2026-07-30T12:00:00.000Z", + "expiresAt": "2026-08-30T12:00:00.000Z", + "lastConnectedAt": "2026-07-30T12:05:00.000Z", + "connected": true, + "current": false + }] + """ + case "/api/auth/clients/revoke": + body = #"{"revoked":true}"# + case "/api/auth/clients/revoke-others": + body = #"{"revokedCount":2}"# + default: + body = "{}" + } + return ( + Data(body.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift b/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift new file mode 100644 index 00000000000..8a8db050a8d --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift @@ -0,0 +1,193 @@ +import XCTest +@testable import T3Code + +@MainActor +final class TransportReliabilityTests: XCTestCase { + func testHTTPPolicyOffersGzipWithoutOverwritingCallerPreference() { + var request = URLRequest(url: URL(string: "https://studio.example/api")!) + let prepared = HTTPRequestPolicy.prepare(request) + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Accept"), "application/json") + + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + XCTAssertEqual( + HTTPRequestPolicy.prepare(request).value(forHTTPHeaderField: "Accept-Encoding"), + "identity" + ) + } + + func testEnvironmentAPIDecodesURLSessionDecompressedGzipResponse() async throws { + let transport = RecordingHTTPTransport { request in + let body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """ + return ( + Data(body.utf8), + transportResponse( + request, + headers: [ + "Content-Type": "application/json", + // URLSession retains this response header while + // returning the already decompressed body. + "Content-Encoding": "gzip", + ] + ) + ) + } + let api = EnvironmentAPI( + transport: transport, + credentials: InMemoryCredentialStore() + ) + + let descriptor = try await api.descriptor( + at: URL(string: "https://studio.example")! + ) + XCTAssertEqual(descriptor.environmentId, "environment-1") + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") + } + + func testWebSocketHandshakeOffersPerMessageDeflate() { + let url = URL(string: "wss://studio.example/ws?wsTicket=secret")! + let compressed = WebSocketHandshakeRequest.make(url: url) + XCTAssertEqual( + compressed.value(forHTTPHeaderField: "Sec-WebSocket-Extensions"), + "permessage-deflate; client_max_window_bits" + ) + XCTAssertNil( + WebSocketHandshakeRequest.make( + url: url, + offersPerMessageDeflate: false + ).value(forHTTPHeaderField: "Sec-WebSocket-Extensions") + ) + } + + /// Set `T3_SWIFT_WS_DEFLATE_ECHO_URL` to a WebSocket endpoint that rejects + /// non-deflate handshakes and echoes binary frames. This is intentionally + /// opt-in because XCTest does not own a Node process. A successful round + /// trip proves URLSession accepted the server's compressed frame. + func testLivePerMessageDeflateRoundTripWhenConfigured() async throws { + guard let value = ProcessInfo.processInfo.environment[ + "T3_SWIFT_WS_DEFLATE_ECHO_URL" + ], let url = URL(string: value) else { + throw XCTSkip("Set T3_SWIFT_WS_DEFLATE_ECHO_URL for live compression proof.") + } + let connection = try await URLSessionWebSocketConnector().connect(to: url) + defer { Task { await connection.close() } } + let payload = Data(repeating: 0x54, count: 64 * 1024) + try await connection.send(payload) + let echoed = try await connection.receive() + XCTAssertEqual(echoed, payload) + } + + func testPairingInputParsesClipboardQRHostedAndLooseFormats() throws { + let direct = try PairingURL.parseFields( + " https://studio.example:3773/pair#token=N735%4BQXJ " + ) + XCTAssertEqual(direct.host, "https://studio.example:3773") + XCTAssertEqual(direct.pairingCode, "N735KQXJ") + + let hosted = try PairingURL.parseFields( + "https://app.t3.codes/pair?host=http%3A%2F%2F192.168.1.7%3A18773" + + "&label=Big%20O#token=PAIRING" + ) + XCTAssertEqual(hosted.host, "http://192.168.1.7:18773") + XCTAssertEqual(hosted.pairingCode, "PAIRING") + XCTAssertEqual(hosted.label, "Big O") + + let loose = try PairingURL.parseFields("192.168.1.7:18773 N735KQXJ5SJW") + XCTAssertEqual(loose.host, "https://192.168.1.7:18773") + XCTAssertEqual(loose.pairingCode, "N735KQXJ5SJW") + + let wrapped = try PairingURL.pairingURL( + fromQRCode: "t3code://pair?pairingUrl=https%3A%2F%2Fstudio.example" + + "%2Fpair%23token%3DQR-CODE" + ) + XCTAssertEqual(wrapped, "https://studio.example/pair#token=QR-CODE") + XCTAssertEqual(try PairingURL.parseFields(wrapped).pairingCode, "QR-CODE") + } + + func testSplitPairingFieldsAcceptCompleteURLInHostField() throws { + let target = try PairingURL.resolve( + host: "http://192.168.1.7:18773/pair#token=FROM-URL", + pairingCode: "" + ) + XCTAssertEqual(target.credential, "FROM-URL") + XCTAssertEqual(target.httpBaseURL.absoluteString, "http://192.168.1.7:18773/") + XCTAssertEqual(target.webSocketBaseURL.absoluteString, "ws://192.168.1.7:18773/") + } + + func testLocalNetworkProbeClassificationDistinguishesFailureModes() { + XCTAssertTrue(LocalNetworkProbe.isLocalHost("192.168.20.4")) + XCTAssertTrue(LocalNetworkProbe.isLocalHost("studio.local")) + XCTAssertFalse(LocalNetworkProbe.isLocalHost("app.t3.codes")) + + let denied = NSError( + domain: NSURLErrorDomain, + code: URLError.notConnectedToInternet.rawValue, + userInfo: [ + NSUnderlyingErrorKey: NSError( + domain: NSPOSIXErrorDomain, + code: 13 + ), + ] + ) + XCTAssertEqual( + LocalNetworkProbe.classify(denied, host: "192.168.20.4", isLocal: true), + .likelyLocalNetworkDenied("192.168.20.4") + ) + XCTAssertEqual( + LocalNetworkProbe.classify( + URLError(.timedOut), + host: "studio.local", + isLocal: true + ), + .timeout("studio.local") + ) + XCTAssertEqual( + LocalNetworkProbe.classify( + URLError(.cannotConnectToHost), + host: "studio.local", + isLocal: true + ), + .unavailableHost("studio.local") + ) + } + +} + +private func transportResponse( + _ request: URLRequest, + status: Int = 200, + headers: [String: String] = ["Content-Type": "application/json"] +) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: headers + )! +} + +private actor RecordingHTTPTransport: HTTPTransport { + typealias Handler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + private(set) var requests: [URLRequest] = [] + private let handler: Handler + + init(handler: @escaping Handler) { + self.handler = handler + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + return try handler(request) + } +} From 80b89ac44a135ec41eab5e5dcf6c82ae26491417 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 08:17:27 -0700 Subject: [PATCH 003/154] feat(ios): rebuild onboarding and device access --- .../Connection/ConnectionDetails.swift | 248 +++++++ .../Connection/ConnectionOnboardingView.swift | 680 +++++++++++++++--- .../LocalNetworkAccessChecker.swift | 112 +++ .../Connection/QRCodeScannerView.swift | 296 ++++++++ .../Features/Devices/DevicesView.swift | 253 +++++++ .../Devices/FeatureDeviceManagement.swift | 141 ++++ .../Features/Settings/SettingsView.swift | 262 ++++--- .../T3Code.xcodeproj/project.pbxproj | 2 + .../FeatureTests/ConnectionDetailsTests.swift | 99 +++ .../FeatureTests/DeviceManagementTests.swift | 54 ++ 10 files changed, 1951 insertions(+), 196 deletions(-) create mode 100644 apps/swift-ios/Features/Connection/ConnectionDetails.swift create mode 100644 apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift create mode 100644 apps/swift-ios/Features/Connection/QRCodeScannerView.swift create mode 100644 apps/swift-ios/Features/Devices/DevicesView.swift create mode 100644 apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift diff --git a/apps/swift-ios/Features/Connection/ConnectionDetails.swift b/apps/swift-ios/Features/Connection/ConnectionDetails.swift new file mode 100644 index 00000000000..f723bb55d7b --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionDetails.swift @@ -0,0 +1,248 @@ +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", "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 candidate = trimmed.lowercased().hasPrefix("t3code:") + ? trimmed + : (firstURL(in: trimmed) ?? trimmed) + 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"].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:(?://)?)[^\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 String(input[swiftRange]) + } + + 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 index 025512e413a..20780e3fa34 100644 --- a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -1,160 +1,612 @@ import SwiftUI +import UIKit public struct ConnectionOnboardingView: View { - @Bindable var model: FeatureRootModel + @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)? + + @State private var stage = ConnectionStage.welcome @State private var endpoint = "" - @State private var pairingToken = "" - @State private var isPairing = false + @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" + @FocusState private var focusedField: ConnectionField? + + public init( + model: FeatureRootModel, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + readinessChecker = LocalNetworkAccessChecker() + self.onConnected = onConnected + self.onCancel = onCancel + } - public init(model: FeatureRootModel) { + init( + model: FeatureRootModel, + readinessChecker: any ConnectionReadinessChecking, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { self.model = model + self.readinessChecker = readinessChecker + self.onConnected = onConnected + self.onCancel = onCancel } public var body: some View { NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 28) { - VStack(alignment: .leading, spacing: 8) { - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.system(size: 34, weight: .bold)) - Text("T3 Code") - .font(.largeTitle.bold()) - Text("Connect this iPhone to a T3 environment.") - .font(.body) - .foregroundStyle(.secondary) + Group { + switch stage { + case .welcome: + welcomeView + case .details: + detailsView + case .checking, .connecting: + progressView + case .success: + successView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .animation(.snappy(duration: 0.24), value: stage) + } + .preferredColorScheme(.dark) + .toolbar { + if stage == .welcome, let onCancel { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: onCancel) + } + } + } + .fullScreenCover(isPresented: $showingScanner) { + QRCodeScannerView( + onScan: { value in + showingScanner = false + applyConnectionString( + value, + heading: "Confirm connection", + connectAutomatically: true + ) + }, + onCancel: { + showingScanner = false + }, + onPaste: { + showingScanner = false + pasteConnectionLink() + } + ) + } + .onOpenURL { url in + applyConnectionString(url.absoluteString, heading: "Confirm connection") + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active, showsPermissionAction { + showsPermissionAction = false + errorMessage = nil + } + } + } + + private var welcomeView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 48) + + 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("Your agents,\nwherever you are.") + .font(.system(size: 38, weight: .bold, design: .default)) + .tracking(-1.1) + .padding(.top, 24) + + Text("Connect securely to T3 Code running on your computer.") + .font(.body) + .foregroundStyle(.secondary) + .padding(.top, 12) + + Button { + showingScanner = true + } label: { + Label("Scan QR code", systemImage: "qrcode.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(ConnectionPrimaryButtonStyle()) + .padding(.top, 36) + .accessibilityHint("Opens the camera") + + VStack(spacing: 0) { + connectionAction( + title: "Paste connection link", + subtitle: "Copy it from T3 Code on your computer", + systemImage: "doc.on.clipboard" + ) { + pasteConnectionLink() } - VStack(alignment: .leading, spacing: 12) { - Text("SERVER") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - - TextField("https://your-server.example", text: $endpoint) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - .autocorrectionDisabled() - .featureInput() - .accessibilityLabel("Server address") - - SecureField("Pairing token (optional)", text: $pairingToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .featureInput() - - Button { - pair() - } label: { - HStack { - if isPairing { - ProgressView().controlSize(.small) - } - Text(isPairing ? "Connecting…" : "Connect") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(FeaturePrimaryButtonStyle()) - .disabled(normalizedEndpoint == nil || isPairing) + Divider().overlay(Color.white.opacity(0.09)) + + connectionAction( + title: "Enter details manually", + subtitle: "Use the server address and pairing code", + systemImage: "keyboard" + ) { + entryHeading = "Connect manually" + errorMessage = nil + stage = .details } + } + .padding(.top, 12) - if !model.snapshot.environments.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("KNOWN ENVIRONMENTS") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) + knownEnvironments + } + .padding(.horizontal, 24) + .padding(.bottom, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + } - ForEach(model.snapshot.environments) { environment in - Button { - endpoint = environment.endpoint - } label: { - HStack(spacing: 12) { - Image(systemName: "server.rack") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(environment.name) - .foregroundStyle(.primary) - Text(environment.endpoint) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - .padding(.vertical, 8) - } - .buttonStyle(.plain) + @ViewBuilder + private var knownEnvironments: some View { + if !model.snapshot.environments.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text("KNOWN SERVERS") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.top, 34) + .padding(.bottom, 8) + + ForEach(model.snapshot.environments) { 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) + .foregroundStyle(.primary) + Text(environment.endpoint) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } + Spacer() + Image(systemName: "arrow.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) } + .contentShape(Rectangle()) + .padding(.vertical, 12) } + .buttonStyle(.plain) + .accessibilityHint("Reconnects to this server") - VStack(alignment: .leading, spacing: 6) { - Label("How to pair", systemImage: "info.circle") - .font(.subheadline.weight(.semibold)) - Text("Open T3 Code on the host, copy its mobile pairing link, then paste the server and one-time token above.") - .font(.footnote) - .foregroundStyle(.secondary) + if environment.id != model.snapshot.environments.last?.id { + Divider().overlay(Color.white.opacity(0.09)) } } - .padding(24) - .frame(maxWidth: 560) - .frame(maxWidth: .infinity) } - .scrollDismissesKeyboard(.interactively) - .background(Color.black) - .navigationTitle("Connect") - .navigationBarTitleDisplayMode(.inline) } } - private var normalizedEndpoint: String? { - let value = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) - guard let components = URLComponents(string: value), - let scheme = components.scheme?.lowercased(), - ["http", "https"].contains(scheme), - components.host != nil else { - return nil + private var detailsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(entryHeading) + .font(.largeTitle.bold()) + .tracking(-0.6) + Text("Both values are shown in T3 Code when you create a mobile connection.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("SERVER ADDRESS") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + TextField("http://192.168.1.5:3773", text: $endpoint) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + .focused($focusedField, equals: .endpoint) + .connectionInput() + .accessibilityLabel("Server address") + .onChange(of: endpoint) { _, value in + autofillIfPairingLink(value) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("PAIRING CODE") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + TextField("12-character code", text: $pairingCode) + .textInputAutocapitalization(.never) + .textContentType(.oneTimeCode) + .autocorrectionDisabled() + .focused($focusedField, equals: .pairingCode) + .connectionInput() + .accessibilityLabel("Pairing code") + } + + Button { + pasteConnectionLink() + } label: { + Label("Paste a connection link instead", systemImage: "doc.on.clipboard") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.plain) + + if let errorMessage { + connectionError(message: errorMessage) + } + + Button { + submitDetails() + } label: { + Text("Connect") + .frame(maxWidth: .infinity) + } + .buttonStyle(ConnectionPrimaryButtonStyle()) + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.45) + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Connect") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + errorMessage = nil + showsPermissionAction = false + focusedField = nil + stage = .welcome + } label: { + Label("Back", systemImage: "chevron.left") + } + } } - return value } - private func pair() { - guard let endpoint = normalizedEndpoint else { return } - let trimmedToken = pairingToken.trimmingCharacters(in: .whitespacesAndNewlines) - isPairing = true - Task { - _ = await model.pair( - endpoint: endpoint, - token: trimmedToken.isEmpty ? nil : trimmedToken + private var progressView: some View { + VStack(alignment: .leading, spacing: 34) { + Spacer() + + Text(stage == .checking ? "Finding your T3" : "Connecting securely") + .font(.largeTitle.bold()) + .tracking(-0.7) + + VStack(alignment: .leading, spacing: 20) { + progressRow( + title: "Server details", + state: .complete + ) + progressRow( + title: EndpointNetworkScope.isLocal(endpoint) + ? "Local network access" + : "Network access", + state: stage == .checking ? .active : .complete + ) + progressRow( + title: "Secure pairing", + state: stage == .connecting ? .active : .waiting + ) + } + + Spacer() + + Text("Keep T3 Code open on your computer.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .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("You're connected") + .font(.title.bold()) + Text("Loading your projects and threads.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .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(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .contentShape(Rectangle()) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + } + + private func connectionError(message: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(message, systemImage: showsPermissionAction ? "network.slash" : "exclamationmark.circle") + .font(.subheadline) + .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(.subheadline.weight(.semibold)) + } + } + .accessibilityElement(children: .combine) + } + + 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) + ) ) - isPairing = false + } 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 + ) { + do { + let details = try ConnectionDetailsParser.parse(value) + endpoint = details.endpoint + if let code = details.pairingCode { + pairingCode = code + } + 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) { + let lowered = value.lowercased() + guard lowered.contains("token=") + || lowered.hasPrefix("t3:") + || lowered.hasPrefix("t3code:") + || lowered.contains("pairingurl=") + || lowered.contains("pairing_url="), + 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) { + endpoint = action.endpoint + errorMessage = nil + showsPermissionAction = false + stage = .checking + + Task { + let readiness = await readinessChecker.check(endpoint: action.endpoint) + switch readiness { + case .ready: + stage = .connecting + case .localNetworkDenied: + errorMessage = "Allow Local Network access so this iPhone can find T3 Code on your computer." + showsPermissionAction = true + stage = .details + return + case .unreachable: + errorMessage = "This iPhone cannot reach that server. Confirm the address and that both devices are on the same network." + stage = .details + 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, _): + await model.activateEnvironment(id) + didConnect = model.snapshot.connection.state == .connected + } + + if didConnect { + stage = .success + onConnected() + } else { + let rawError = model.errorMessage + model.errorMessage = nil + errorMessage = ConnectionErrorCopy.message(for: rawError) + stage = .details + } + } + } +} + +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 featureInput() -> some View { + func connectionInput() -> some View { self - .padding(.horizontal, 12) - .frame(minHeight: 46) - .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(Color.white.opacity(0.12), lineWidth: 1) + .font(.body.monospaced()) + .padding(.horizontal, 14) + .frame(minHeight: 50) + .background(Color.white.opacity(0.07)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.white.opacity(0.18)) + .frame(height: 1) } } } -struct FeaturePrimaryButtonStyle: ButtonStyle { +private struct ConnectionPrimaryButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.body.weight(.semibold)) .foregroundStyle(.black) .padding(.horizontal, 16) - .frame(minHeight: 46) - .background(configuration.isPressed ? Color.white.opacity(0.75) : .white) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(minHeight: 52) + .background(configuration.isPressed ? Color.white.opacity(0.76) : .white) + .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 00000000000..2adcf72b2f2 --- /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 00000000000..4de09faad73 --- /dev/null +++ b/apps/swift-ios/Features/Connection/QRCodeScannerView.swift @@ -0,0 +1,296 @@ +@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(.subheadline) + .foregroundStyle(.white.opacity(0.72)) + .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(.subheadline) + .foregroundStyle(.white.opacity(0.65)) + .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(.subheadline) + .foregroundStyle(.white.opacity(0.65)) + } + } + } +} + +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? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + func prepare( + delegate: AVCaptureMetadataOutputObjectsDelegate, + availabilityChanged: @escaping (QRScannerAvailability) -> Void + ) { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configure(delegate: delegate, availabilityChanged: availabilityChanged) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + Task { @MainActor in + guard let self else { return } + if granted { + self.configure( + delegate: delegate, + availabilityChanged: availabilityChanged + ) + } else { + availabilityChanged(.denied) + } + } + } + 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 (QRScannerAvailability) -> Void + ) { + 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/Devices/DevicesView.swift b/apps/swift-ios/Features/Devices/DevicesView.swift new file mode 100644 index 00000000000..9439296b234 --- /dev/null +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -0,0 +1,253 @@ +import SwiftUI + +public struct DevicesView: View { + private let manager: any FeatureDeviceManaging + + @State private var sessions: [FeatureDeviceSession] = [] + @State private var isLoading = true + @State private var isRevoking = false + @State private var errorMessage: String? + @State private var revokeTarget: FeatureDeviceSession? + @State private var showingRevokeOthers = false + + public init(manager: any FeatureDeviceManaging) { + self.manager = manager + } + + public var body: some View { + Group { + if isLoading, sessions.isEmpty { + VStack(spacing: 12) { + ProgressView() + Text("Loading devices") + .font(.footnote) + .foregroundStyle(.secondary) + } + } 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(Color.black) + .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("Remove access", role: .destructive) { + Task { await revoke(device) } + } + Button("Cancel", role: .cancel) {} + } message: { device in + Text("\(device.displayName) will need a new pairing code to reconnect.") + } + .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("Every other phone, tablet, browser, and desktop will be signed out.") + } + } + + private var deviceList: some View { + List { + if let currentSession { + Section("THIS DEVICE") { + DeviceSessionRow(session: currentSession) + } + } + + if !otherSessions.isEmpty { + Section("OTHER DEVICES") { + ForEach(otherSessions) { session in + DeviceSessionRow(session: session) + .contentShape(Rectangle()) + .swipeActions { + Button("Remove", role: .destructive) { + revokeTarget = session + } + } + .contextMenu { + Button(role: .destructive) { + revokeTarget = session + } label: { + Label("Remove access", systemImage: "trash") + } + } + } + } + } + + if let errorMessage { + Section { + VStack(alignment: .leading, spacing: 10) { + Label(errorMessage, systemImage: "exclamationmark.circle") + .font(.subheadline) + .foregroundStyle(.orange) + Button("Try again") { + Task { await reload() } + } + .font(.subheadline.weight(.semibold)) + } + .padding(.vertical, 4) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.black) + .refreshable { + await reload() + } + .overlay(alignment: .top) { + if isRevoking { + ProgressView() + .padding(.top, 12) + .accessibilityLabel("Updating device access") + } + } + } + + private var currentSession: FeatureDeviceSession? { + sessions.first(where: \.isCurrent) + } + + private var otherSessions: [FeatureDeviceSession] { + sessions.filter { !$0.isCurrent } + } + + @MainActor + private func reload() async { + isLoading = true + defer { isLoading = false } + do { + sessions = FeatureDeviceSession.sortedForDisplay( + try await manager.loadDeviceSessions() + ) + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } + + @MainActor + private func revoke(_ session: FeatureDeviceSession) async { + isRevoking = true + defer { + isRevoking = false + revokeTarget = nil + } + do { + try await manager.revokeDeviceSession(id: session.id) + sessions.removeAll { $0.id == session.id } + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } + + @MainActor + private func revokeOthers() async { + isRevoking = true + defer { isRevoking = false } + do { + try await manager.revokeOtherDeviceSessions() + sessions.removeAll { !$0.isCurrent } + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } +} + +private struct DeviceSessionRow: View { + let session: FeatureDeviceSession + + var body: some View { + HStack(alignment: .top, spacing: 13) { + Image(systemName: session.deviceType.systemImage) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(session.isCurrent ? .green : .secondary) + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(session.displayName) + .font(.body.weight(.semibold)) + if session.isCurrent { + Text("Current") + .font(.caption) + .foregroundStyle(.green) + } else if session.isConnected { + Text("Online") + .font(.caption) + .foregroundStyle(.green) + } + } + + if !session.platformDescription.isEmpty { + Text(session.platformDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(lastSeenDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + if let ipAddress = session.ipAddress, !ipAddress.isEmpty { + Text(ipAddress) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + } + } + 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 00000000000..edfc9f4784a --- /dev/null +++ b/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift @@ -0,0 +1,141 @@ +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 { + func loadDeviceSessions() async throws -> [FeatureDeviceSession] + func revokeDeviceSession(id: String) async throws + func revokeOtherDeviceSessions() async throws +} + +@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") { + 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/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index 370f49b098e..ba13dc045e3 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -2,10 +2,12 @@ import SwiftUI public struct SettingsView: View { @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable var model: FeatureRootModel + @Bindable private var model: FeatureRootModel @State private var settings: FeatureSettings @State private var isSaving = false @State private var showingDisconnect = false + @State private var showingAddEnvironment = false + @State private var removalTarget: FeatureEnvironment? public init(model: FeatureRootModel) { self.model = model @@ -15,86 +17,10 @@ public struct SettingsView: View { public var body: some View { NavigationStack { Form { - Section("Appearance") { - Picker("Theme", selection: $settings.appearance) { - Text("System").tag(FeatureAppearance.system) - Text("Dark").tag(FeatureAppearance.dark) - } - Toggle("Haptics", isOn: $settings.hapticsEnabled) - Toggle("Notifications", isOn: $settings.notificationsEnabled) - } - - Section("Default agent") { - ProviderModelPicker( - providers: model.snapshot.providers, - selection: $settings.defaultSelection - ) - if let provider = selectedProvider, let selectedModel { - LabeledContent("Provider", value: provider.name) - if let detail = selectedModel.detail { - Text(detail) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - - Section("Environment") { - LabeledContent( - "Status", - value: model.snapshot.connection.state == .connected ? "Connected" : "Offline" - ) - if let endpoint = model.snapshot.connection.endpoint { - LabeledContent("Server") { - Text(endpoint) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - ForEach(model.snapshot.environments) { environment in - Button { - Task { await model.activateEnvironment(environment.id) } - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(environment.name) - .foregroundStyle(.primary) - Text(environment.endpoint) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if environment.isActive { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } - } - .swipeActions { - Button(role: .destructive) { - Task { await model.removeEnvironment(environment.id) } - } label: { - Label("Remove", systemImage: "trash") - } - } - .contextMenu { - Button(role: .destructive) { - Task { await model.removeEnvironment(environment.id) } - } label: { - Label("Remove Environment", systemImage: "trash") - } - } - } - Button("Disconnect", role: .destructive) { - showingDisconnect = true - } - } - - Section("About") { - LabeledContent("App", value: "T3 Code for iOS") - LabeledContent("Platform", value: "Native SwiftUI") - Link("Open source", destination: URL(string: "https://github.com/pingdotgg/t3code")!) - } + connectionSection + agentSection + preferencesSection + aboutSection } .scrollContentBackground(.hidden) .background(Color.black) @@ -112,7 +38,7 @@ public struct SettingsView: View { } } .confirmationDialog( - "Disconnect from this environment?", + "Disconnect from this server?", isPresented: $showingDisconnect, titleVisibility: .visible ) { @@ -123,10 +49,160 @@ public struct SettingsView: View { } } Button("Cancel", role: .cancel) {} + } message: { + Text("Your saved server and credentials will stay on this iPhone.") + } + .alert( + "Remove saved server?", + 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("\(environment.name) will need a new pairing code to be added again.") + } + .sheet(isPresented: $showingAddEnvironment) { + ConnectionOnboardingView( + model: model, + onConnected: { + showingAddEnvironment = false + }, + onCancel: { + showingAddEnvironment = false + } + ) } } } + private var connectionSection: some View { + Section("Connections") { + HStack(spacing: 12) { + Image(systemName: connectionSymbol) + .foregroundStyle(connectionColor) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(model.snapshot.connection.environmentName ?? "T3 server") + .font(.body.weight(.semibold)) + Text(connectionDescription) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Text(connectionStatus) + .font(.caption.weight(.semibold)) + .foregroundStyle(connectionColor) + } + .accessibilityElement(children: .combine) + + NavigationLink { + DevicesView(manager: deviceManager) + } label: { + Label("Devices and sessions", systemImage: "laptopcomputer.and.iphone") + } + + ForEach(model.snapshot.environments) { environment in + Button { + guard !environment.isActive else { return } + Task { await model.activateEnvironment(environment.id) } + } label: { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .foregroundStyle(.secondary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .foregroundStyle(.primary) + Text(environment.endpoint) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if environment.isActive { + Image(systemName: "checkmark") + .font(.subheadline.weight(.bold)) + .foregroundStyle(.green) + .accessibilityLabel("Active") + } + } + } + .swipeActions { + Button("Remove", role: .destructive) { + removalTarget = environment + } + } + .contextMenu { + Button(role: .destructive) { + removalTarget = environment + } label: { + Label("Remove saved server", systemImage: "trash") + } + } + } + + Button { + showingAddEnvironment = true + } label: { + Label("Add another server", systemImage: "plus") + } + + Button("Disconnect", role: .destructive) { + showingDisconnect = true + } + } + } + + private var agentSection: some View { + Section("Default agent") { + ProviderModelPicker( + providers: model.snapshot.providers, + selection: $settings.defaultSelection + ) + if let provider = selectedProvider, let selectedModel { + LabeledContent("Provider", value: provider.name) + if let detail = selectedModel.detail { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + + private var preferencesSection: some View { + Section("Preferences") { + Picker("Theme", selection: $settings.appearance) { + Text("System").tag(FeatureAppearance.system) + Text("Dark").tag(FeatureAppearance.dark) + } + Toggle("Haptics", isOn: $settings.hapticsEnabled) + Toggle("Notifications", isOn: $settings.notificationsEnabled) + } + } + + private var aboutSection: some View { + Section("About") { + LabeledContent("App", value: "T3 Code for iOS") + LabeledContent("Platform", value: "Native SwiftUI") + Link("Open source", destination: URL(string: "https://github.com/pingdotgg/t3code")!) + } + } + + private var deviceManager: any FeatureDeviceManaging { + (model.client as? any FeatureDeviceManaging) ?? EmptyFeatureDeviceManager.shared + } + private var selectedProvider: FeatureProvider? { guard let selection = settings.defaultSelection else { return nil } return model.snapshot.providers.first { $0.id == selection.providerID } @@ -137,6 +213,28 @@ public struct SettingsView: View { return selectedProvider?.models.first { $0.id == selection.modelID } } + private var connectionStatus: String { + switch model.snapshot.connection.state { + case .connected: "Online" + case .connecting: "Connecting" + case .reconnecting: "Reconnecting" + case .disconnected: "Offline" + } + } + + private var connectionSymbol: String { + model.snapshot.connection.state == .connected ? "checkmark.circle.fill" : "network.slash" + } + + private var connectionColor: Color { + model.snapshot.connection.state == .connected ? .green : .secondary + } + + private var connectionDescription: String { + model.snapshot.connection.endpoint ?? "No active server" + } + + @MainActor private func save() { isSaving = true Task { diff --git a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj index 3d6d61311a8..2fb2a419507 100644 --- a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj +++ b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj @@ -348,6 +348,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan a T3 Code pairing QR code to connect this device."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -383,6 +384,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan a T3 Code pairing QR code to connect this device."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift b/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift new file mode 100644 index 00000000000..09022d93400 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift @@ -0,0 +1,99 @@ +import Testing +@testable import T3Code + +@Suite("Connection details") +struct ConnectionDetailsTests { + @Test + func parsesRawPairingURL() throws { + let details = try ConnectionDetailsParser.parse( + "http://192.168.1.42:3773/pair#token=PAIRCODE" + ) + + #expect(details.endpoint == "http://192.168.1.42:3773") + #expect(details.pairingCode == "PAIRCODE") + } + + @Test + func parsesHostedPairingURL() throws { + let details = try ConnectionDetailsParser.parse( + "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=PAIRCODE" + ) + + #expect(details.endpoint == "https://desktop.tailnet.ts.net") + #expect(details.pairingCode == "PAIRCODE") + } + + @Test(arguments: [ + "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code:?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + ]) + func unwrapsMobileQRCode(_ payload: String) throws { + let details = try ConnectionDetailsParser.parse(payload) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "pairing-token") + } + + @Test + func extractsPairingURLFromSurroundingText() throws { + let details = try ConnectionDetailsParser.parse( + "Pairing URL: http://10.0.0.8:18773/pair#token=ABC123\nOpen this on your phone." + ) + + #expect(details.endpoint == "http://10.0.0.8:18773") + #expect(details.pairingCode == "ABC123") + } + + @Test + func splitsManualAddressAndCode() throws { + let details = try ConnectionDetailsParser.parse("192.168.20.2:3773 ABC123") + + #expect(details.endpoint == "http://192.168.20.2:3773") + #expect(details.pairingCode == "ABC123") + } + + @Test + func mapsCancellationToUsefulCopy() { + let message = ConnectionErrorCopy.message(for: "cancelled") + + #expect(!message.lowercased().contains("cancelled")) + #expect(message.contains("Make sure T3 Code is running")) + } +} + +@Suite("Local endpoint detection") +struct LocalEndpointDetectionTests { + @Test(arguments: [ + "localhost", + "studio.local", + "127.0.0.1", + "10.20.30.40", + "172.20.10.2", + "192.168.213.171", + "[::1]", + "::1", + "[fe80::aede:48ff:fe00:1122]:3773", + "fd12:3456:789a::1", + "fc00::1", + ]) + func recognizesLocalHosts(_ host: String) { + #expect(EndpointNetworkScope.isLocalHost(host)) + } + + @Test(arguments: [ + "8.8.8.8", + "172.32.0.1", + "example.com", + "2001:4860:4860::8888", + ]) + func rejectsPublicHosts(_ host: String) { + #expect(!EndpointNetworkScope.isLocalHost(host)) + } + + @Test + func bracketsBareIPv6DuringNormalization() throws { + let endpoint = try ConnectionDetailsParser.normalizedEndpoint("::1") + + #expect(endpoint == "http://[::1]") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift b/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift new file mode 100644 index 00000000000..6fbb33b25e4 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Device management") +struct DeviceManagementTests { + @Test + func sortsCurrentThenOnlineThenRecent() { + let now = Date() + let current = session( + id: "current", + at: now.addingTimeInterval(-300), + isCurrent: true + ) + let online = session( + id: "online", + at: now.addingTimeInterval(-600), + isConnected: true + ) + let recent = session(id: "recent", at: now.addingTimeInterval(-60)) + let older = session(id: "older", at: now.addingTimeInterval(-3_600)) + + let sorted = FeatureDeviceSession.sortedForDisplay([older, recent, online, current]) + + #expect(sorted.map(\.id) == ["current", "online", "recent", "older"]) + } + + @Test + func usesSafeFallbackLabels() { + let current = session(id: "current", at: .now, isCurrent: true) + let desktop = session(id: "desktop", at: .now, deviceType: .desktop) + + #expect(current.displayName == "This device") + #expect(desktop.displayName == "Desktop") + } + + private func session( + id: String, + at date: Date, + deviceType: FeatureDeviceType = .mobile, + isConnected: Bool = false, + isCurrent: Bool = false + ) -> FeatureDeviceSession { + FeatureDeviceSession( + sessionID: id, + deviceType: deviceType, + issuedAt: date.addingTimeInterval(-100), + expiresAt: date.addingTimeInterval(86_400), + lastConnectedAt: date, + isConnected: isConnected, + isCurrent: isCurrent + ) + } +} From fb46f74ebae2189287b6624ba285a06c7beb03b6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 08:18:03 -0700 Subject: [PATCH 004/154] feat(ios): ship message-first daily UX --- apps/swift-ios/App/NativeFeatureClient.swift | 317 ++++++++- .../Features/Chat/FeatureComposerView.swift | 93 ++- .../Features/Chat/ImageAttachmentViews.swift | 197 ++++++ .../Features/Chat/ThreadDetailView.swift | 114 ++- .../Features/Root/FeatureRootModel.swift | 85 ++- .../Features/Root/FeatureRootView.swift | 10 +- .../Features/Shared/FeatureClient.swift | 48 ++ .../Features/Shared/FeatureModels.swift | 170 ++++- .../Features/Workspace/DailyUXModels.swift | 312 +++++++++ .../Features/Workspace/NewThreadView.swift | 318 +++++++-- .../Workspace/ProviderModelPicker.swift | 544 +++++++++++++++ .../Features/Workspace/WorkspaceView.swift | 649 ++++++++++++------ .../DailyUXModelPickerTests.swift | 176 +++++ .../FeatureTests/DailyUXNewTaskTests.swift | 27 + .../FeatureTests/DailyUXSidebarTests.swift | 134 ++++ .../FeatureTests/FeatureRootModelTests.swift | 59 +- 16 files changed, 2877 insertions(+), 376 deletions(-) create mode 100644 apps/swift-ios/Features/Chat/ImageAttachmentViews.swift create mode 100644 apps/swift-ios/Features/Workspace/DailyUXModels.swift create mode 100644 apps/swift-ios/Features/Workspace/ProviderModelPicker.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 11493ef9d41..2fe5d480b92 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -2,7 +2,7 @@ import Foundation /// Composes the transport-focused Core layer with the UI-focused Features layer. @MainActor -final class NativeFeatureClient: FeatureClient { +final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { private let runtime: EnvironmentRuntime private let settingsStore: UserDefaults private let stream: AsyncStream @@ -11,6 +11,7 @@ final class NativeFeatureClient: FeatureClient { private var activeEnvironment: Environment? private var client: T3Client? private var latestShell: OrchestrationShellSnapshot? + private var latestServerConfig: ServerConfigSnapshot? private var archivedThreads: [FeatureThread] = [] private var latestSnapshot: FeatureSnapshot? private var loadedThreadIDs = Set() @@ -23,6 +24,7 @@ final class NativeFeatureClient: FeatureClient { String: [UUID: AsyncStream.Continuation] ] = [:] private var pollingTask: Task? + private var configurationTask: Task? init( runtime: EnvironmentRuntime = EnvironmentRuntime(), @@ -37,6 +39,7 @@ final class NativeFeatureClient: FeatureClient { deinit { pollingTask?.cancel() + configurationTask?.cancel() continuation.finish() } @@ -53,7 +56,9 @@ final class NativeFeatureClient: FeatureClient { client = activeClient async let shellRequest = activeClient.shellSnapshot() async let readModelRequest = activeClient.readModel() + async let serverConfigRequest: ServerConfigSnapshot? = try? activeClient.serverConfig() let (shell, readModel) = try await (shellRequest, readModelRequest) + latestServerConfig = await serverConfigRequest latestShell = shell archivedThreads = readModel.threads .filter { $0.archivedAt != nil } @@ -88,17 +93,20 @@ final class NativeFeatureClient: FeatureClient { loadedThreadIDs.removeAll() latestDetails.removeAll() archivedThreads.removeAll() + latestServerConfig = nil startPolling(pairedClient) } func activateEnvironment(id: String) async throws { pollingTask?.cancel() + configurationTask?.cancel() let activated = try await runtime.activate(id: id) activeEnvironment = activated.environment client = activated loadedThreadIDs.removeAll() latestDetails.removeAll() latestShell = nil + latestServerConfig = nil archivedThreads.removeAll() finishTerminalStreams() terminalIDs.removeAll() @@ -109,10 +117,13 @@ final class NativeFeatureClient: FeatureClient { func removeEnvironment(id: String) async throws { if activeEnvironment?.id == id { pollingTask?.cancel() + configurationTask?.cancel() pollingTask = nil + configurationTask = nil client = nil activeEnvironment = nil latestShell = nil + latestServerConfig = nil archivedThreads.removeAll() loadedThreadIDs.removeAll() latestDetails.removeAll() @@ -125,13 +136,16 @@ final class NativeFeatureClient: FeatureClient { func disconnect() async { pollingTask?.cancel() + configurationTask?.cancel() pollingTask = nil + configurationTask = nil if let client { await client.disconnect() } client = nil activeEnvironment = nil latestShell = nil + latestServerConfig = nil archivedThreads.removeAll() loadedThreadIDs.removeAll() latestDetails.removeAll() @@ -189,6 +203,49 @@ final class NativeFeatureClient: FeatureClient { ) } + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + let client = try requireClient() + let threadID = UUID().uuidString + let model = modelSelection(selection, projectID: projectID) + let title = Self.title(from: prompt, hasAttachments: !attachments.isEmpty) + let uploads = try makeUploadAttachments(attachments) + + _ = try await client.createThreadAndSend( + threadID: threadID, + projectID: projectID, + title: title, + text: prompt, + model: model, + runtimeMode: coreRuntimeMode(runtimeMode), + interactionMode: coreInteractionMode(interactionMode), + attachments: uploads + ) + + let shell = try await client.shellSnapshot() + latestShell = shell + await emitSnapshot(shell) + if let created = shell.threads.first(where: { $0.id == threadID }) { + return mapThread(created) + } + return FeatureThread( + id: threadID, + projectID: projectID, + title: title, + providerID: model.instanceId, + modelID: model.model, + modelOptions: mapOptionSelections(model.options), + runtimeMode: runtimeMode, + interactionMode: interactionMode + ) + } + func renameThread(id: String, title: String) async throws { let client = try requireClient() _ = try await client.rename(threadID: id, title: title) @@ -252,32 +309,33 @@ final class NativeFeatureClient: FeatureClient { threadID: String, text: String, selection: FeatureSelection? + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: [] + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] ) async throws { let client = try requireClient() guard let shellThread = latestShell?.threads.first(where: { $0.id == threadID }) else { throw NativeFeatureClientError.threadNotFound } - if let selection, - selection.providerID != shellThread.modelSelection.instanceId - || selection.modelID != shellThread.modelSelection.model { - _ = try await client.dispatch( - .object([ - "type": .string("thread.meta.update"), - "commandId": .string(UUID().uuidString), - "threadId": .string(threadID), - "modelSelection": try .encode( - ModelSelection(instanceId: selection.providerID, model: selection.modelID) - ), - ]) - ) - } - _ = try await client.sendTurn( threadID: threadID, text: text, runtimeMode: shellThread.runtimeMode, - interactionMode: shellThread.interactionMode + interactionMode: shellThread.interactionMode, + model: selection.map(coreModelSelection), + attachments: try makeUploadAttachments(attachments) ) try await refreshThread(id: threadID, client: client) try await refresh(client: client) @@ -329,6 +387,40 @@ final class NativeFeatureClient: FeatureClient { settingsStore.set(data, forKey: Self.settingsKey) } + func loadDeviceSessions() async throws -> [FeatureDeviceSession] { + 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 { + 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 { + 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 client = try requireClient() let context = try workspaceContext(threadID: threadID) @@ -589,6 +681,7 @@ final class NativeFeatureClient: FeatureClient { private func startPolling(_ activeClient: T3Client) { pollingTask?.cancel() + configurationTask?.cancel() pollingTask = Task { [weak self] in do { await activeClient.connect() @@ -621,6 +714,29 @@ final class NativeFeatureClient: FeatureClient { } } } + configurationTask = Task { [weak self] in + do { + for try await event in await activeClient.serverConfigEvents() { + guard !Task.isCancelled, let self else { break } + switch event { + case let .snapshot(config): + self.latestServerConfig = config + case let .providerStatuses(providers): + self.latestServerConfig = ServerConfigSnapshot(providers: providers) + case .unrelated: + continue + } + if let shell = self.latestShell { + await self.emitSnapshot(shell) + } + } + } catch is CancellationError { + return + } catch { + // The shell and thread streams remain useful on older servers + // that do not expose the provider catalogue subscription. + } + } } private func consume(shell: OrchestrationShellSnapshot, client: T3Client) async { @@ -721,6 +837,7 @@ final class NativeFeatureClient: FeatureClient { id: thread.id, projectID: thread.projectId, title: thread.title, + createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), state: mapThreadState( latestTurn: thread.latestTurn, @@ -730,6 +847,7 @@ final class NativeFeatureClient: FeatureClient { ), providerID: thread.modelSelection.instanceId, modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), isArchived: thread.archivedAt != nil, isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), snoozedUntil: thread.snoozedUntil.map(parseDate), @@ -744,6 +862,7 @@ final class NativeFeatureClient: FeatureClient { projectID: thread.projectId, title: thread.title, preview: thread.messages.last?.text, + createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), state: mapThreadState( latestTurn: thread.latestTurn, @@ -753,6 +872,7 @@ final class NativeFeatureClient: FeatureClient { ), providerID: thread.modelSelection.instanceId, modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), isArchived: thread.archivedAt != nil, isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), snoozedUntil: thread.snoozedUntil.map(parseDate), @@ -770,7 +890,15 @@ final class NativeFeatureClient: FeatureClient { role: mapRole(message.role), text: message.text, createdAt: parseDate(message.createdAt), - state: message.streaming ? .streaming : .complete + state: message.streaming ? .streaming : .complete, + attachments: (message.attachments ?? []).map { + FeatureMessageAttachment( + id: $0.id, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: $0.sizeBytes + ) + } ) } let work = thread.activities.compactMap { activity -> FeatureMessage? in @@ -804,6 +932,7 @@ final class NativeFeatureClient: FeatureClient { projectID: thread.projectId, title: thread.title, preview: thread.messages.last?.text, + createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), state: mapThreadState( latestTurn: thread.latestTurn, @@ -813,6 +942,7 @@ final class NativeFeatureClient: FeatureClient { ), providerID: thread.modelSelection.instanceId, modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), isArchived: thread.archivedAt != nil, isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), snoozedUntil: thread.snoozedUntil.map(parseDate), @@ -990,6 +1120,40 @@ final class NativeFeatureClient: FeatureClient { } private func mapProviders(_ shell: OrchestrationShellSnapshot) -> [FeatureProvider] { + if let providers = latestServerConfig?.providers, !providers.isEmpty { + return providers.map { provider in + FeatureProvider( + id: provider.instanceId, + name: provider.displayName ?? providerDisplayName(provider.driver), + isAvailable: provider.enabled + && provider.installed + && provider.status != "disabled" + && 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, + options: options + ) + } + ) + } + } + var modelsByProvider: [String: Set] = [:] for selection in shell.projects.compactMap(\.defaultModelSelection) + shell.threads.map(\.modelSelection) { @@ -1002,6 +1166,7 @@ final class NativeFeatureClient: FeatureClient { FeatureProvider( id: providerID, name: providerDisplayName(providerID), + driver: providerID, models: (modelsByProvider[providerID] ?? []).sorted().map { FeatureModel(id: $0, name: $0) } @@ -1014,7 +1179,7 @@ final class NativeFeatureClient: FeatureClient { projectID: String ) -> ModelSelection { if let selection { - return ModelSelection(instanceId: selection.providerID, model: selection.modelID) + return coreModelSelection(selection) } if let projectDefault = latestShell?.projects .first(where: { $0.id == projectID })? @@ -1026,11 +1191,84 @@ final class NativeFeatureClient: FeatureClient { private func defaultModelSelection() -> ModelSelection { if let selection = loadSettings().defaultSelection { - return ModelSelection(instanceId: selection.providerID, model: selection.modelID) + return coreModelSelection(selection) } return ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") } + 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 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) + ) + 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" @@ -1042,6 +1280,39 @@ final class NativeFeatureClient: FeatureClient { } } + private func makeUploadAttachments( + _ attachments: [FeatureUploadAttachment] + ) throws -> [UploadChatImageAttachment] { + guard attachments.count <= 8 else { + throw NativeFeatureClientError.tooManyAttachments + } + return try attachments.map { + try UploadChatImageAttachment( + data: $0.data, + name: $0.name, + mimeType: $0.mimeType + ) + } + } + + 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 loadSettings() -> FeatureSettings { guard let data = settingsStore.data(forKey: Self.settingsKey), let settings = try? JSONDecoder().decode(FeatureSettings.self, from: data) else { @@ -1073,6 +1344,9 @@ private enum NativeFeatureClientError: LocalizedError { case inputRequestNotFound case invalidProjectPath case terminalNotOpen + case deviceSessionNotFound + case missingScope(String) + case tooManyAttachments var errorDescription: String? { switch self { @@ -1083,6 +1357,9 @@ private enum NativeFeatureClientError: LocalizedError { case .inputRequestNotFound: "The input request is no longer active." case .invalidProjectPath: "Enter a workspace path on the connected environment." case .terminalNotOpen: "Open the terminal before sending input." + case .deviceSessionNotFound: "That device session is no longer active." + case .missingScope: "This connection does not have permission to manage devices." + case .tooManyAttachments: "You can attach up to 8 images per message." } } } diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 8c3fc1a20dc..6cac31d62a6 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -3,7 +3,9 @@ import SwiftUI struct FeatureComposerView: View { @Binding var text: String @Binding var selection: FeatureSelection? + @Binding var attachments: [FeatureDraftAttachment] let providers: [FeatureProvider] + let threadSelection: FeatureSelection? let isSending: Bool let isWorking: Bool let focused: FocusState.Binding @@ -11,8 +13,20 @@ struct FeatureComposerView: View { let onStop: () -> Void var body: some View { - VStack(spacing: 8) { + VStack(spacing: 7) { + FeatureAttachmentStrip(attachments: $attachments) + HStack(alignment: .bottom, spacing: 9) { + FeatureImageAttachmentPicker( + attachments: $attachments, + isEnabled: DailyUXModelOptions.supportsImages( + selection: selection, + providers: providers + ) + ) + .padding(.leading, 2) + .padding(.bottom, 1) + TextField("Message agent", text: $text, axis: .vertical) .lineLimit(1...7) .focused(focused) @@ -20,8 +34,7 @@ struct FeatureComposerView: View { .onSubmit { if canSend { onSend() } } - .padding(.leading, 12) - .padding(.vertical, 10) + .padding(.vertical, 11) Button(action: isWorking && textIsEmpty ? onStop : onSend) { Image(systemName: isWorking && textIsEmpty ? "stop.fill" : "arrow.up") @@ -35,18 +48,29 @@ struct FeatureComposerView: View { .padding(.bottom, 7) .accessibilityLabel(isWorking && textIsEmpty ? "Stop agent" : "Send") } - .background(Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 16)) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 18)) .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(Color.white.opacity(0.14), lineWidth: 1) + RoundedRectangle(cornerRadius: 18) + .stroke(T3Colors.border, lineWidth: 1) } HStack { - CompactModelMenu(providers: providers, selection: $selection) + ProviderModelPicker( + providers: providers, + selection: $selection, + style: .compact, + threadSelection: threadSelection + ) Spacer() - Text("Return to send") - .font(.caption2) - .foregroundStyle(.tertiary) + if !attachments.isEmpty, !imagesAllowed { + Text("Choose an image model") + .font(.caption2) + .foregroundStyle(T3Colors.warning) + } else if !attachments.isEmpty { + Text("\(attachments.count) image\(attachments.count == 1 ? "" : "s")") + .font(.caption2.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } } .padding(.horizontal, 3) } @@ -64,51 +88,12 @@ struct FeatureComposerView: View { } private var canSend: Bool { - !textIsEmpty && !isSending - } -} - -private struct CompactModelMenu: View { - let providers: [FeatureProvider] - @Binding var selection: FeatureSelection? - - var body: some View { - Menu { - ForEach(providers.filter(\.isAvailable)) { provider in - Section(provider.name) { - ForEach(provider.models) { model in - Button { - selection = .init(providerID: provider.id, modelID: model.id) - } label: { - if selection?.providerID == provider.id, selection?.modelID == model.id { - Label(model.name, systemImage: "checkmark") - } else { - Text(model.name) - } - } - } - } - } - } label: { - HStack(spacing: 5) { - Image(systemName: "cpu") - Text(label) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - } - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - .accessibilityLabel("Choose model") + (!textIsEmpty || !attachments.isEmpty) + && (attachments.isEmpty || imagesAllowed) + && !isSending } - private var label: String { - guard let selection, - let provider = providers.first(where: { $0.id == selection.providerID }), - let model = provider.models.first(where: { $0.id == selection.modelID }) else { - return "Automatic" - } - return model.name + private var imagesAllowed: Bool { + DailyUXModelOptions.supportsImages(selection: selection, providers: providers) } } diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift new file mode 100644 index 00000000000..f60ab53c87a --- /dev/null +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -0,0 +1,197 @@ +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +struct FeatureImageAttachmentPicker: View { + @Binding var attachments: [FeatureDraftAttachment] + let maximumCount: Int + let isEnabled: Bool + + @State private var selection: [PhotosPickerItem] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + init( + attachments: Binding<[FeatureDraftAttachment]>, + maximumCount: Int = 8, + isEnabled: Bool = true + ) { + _attachments = attachments + self.maximumCount = maximumCount + self.isEnabled = isEnabled + } + + var body: some View { + PhotosPicker( + selection: $selection, + maxSelectionCount: max(1, maximumCount - attachments.count), + matching: .images + ) { + Image(systemName: isLoading ? "ellipsis" : "photo") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .disabled(!isEnabled || isLoading || attachments.count >= maximumCount) + .opacity(isEnabled ? 1 : 0.3) + .accessibilityLabel(isLoading ? "Adding images" : "Add images") + .accessibilityHint(isEnabled ? "" : "The selected model does not accept images") + .onChange(of: selection) { + loadSelection() + } + .alert( + "Couldn’t add image", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + + private func loadSelection() { + let items = selection + guard !items.isEmpty else { return } + selection = [] + isLoading = true + + Task { + defer { isLoading = false } + for item in items.prefix(maximumCount - attachments.count) { + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + continue + } + let type = item.supportedContentTypes.first ?? .jpeg + let attachment = try FeatureImageProcessor.attachment( + from: data, + sourceType: type, + ordinal: attachments.count + 1 + ) + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + break + } + } + } + } +} + +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) image attachments") + } + } +} + +private struct FeatureAttachmentThumbnail: View { + let attachment: FeatureDraftAttachment + let onRemove: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Group { + if let image = UIImage(data: attachment.data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "photo") + .foregroundStyle(T3Colors.textSecondary) + } + } + .frame(width: 66, height: 66) + .background(T3Colors.surface) + .clipShape(RoundedRectangle(cornerRadius: 9)) + + 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()) + } + .offset(x: 5, y: -5) + .accessibilityLabel("Remove \(attachment.filename)") + } + .padding(.top, 5) + .padding(.trailing, 5) + } +} + +enum FeatureImageProcessor { + private static let maximumDimension: CGFloat = 2_048 + private static let maximumEncodedBytes = 10 * 1_024 * 1_024 + + static func attachment( + from sourceData: Data, + sourceType _: UTType, + ordinal: Int + ) throws -> FeatureDraftAttachment { + guard let sourceImage = UIImage(data: sourceData) else { + throw FeatureImageAttachmentError.invalidImage + } + + let image = resized(sourceImage) + guard let data = image.jpegData(compressionQuality: 0.82) else { + throw FeatureImageAttachmentError.encodingFailed + } + guard data.count <= maximumEncodedBytes else { + throw FeatureImageAttachmentError.tooLarge + } + + return FeatureDraftAttachment( + data: data, + filename: "Image \(ordinal).jpg", + mimeType: "image/jpeg" + ) + } + + private static func resized(_ image: UIImage) -> UIImage { + let longestSide = max(image.size.width, image.size.height) + guard longestSide > maximumDimension else { return image } + let scale = maximumDimension / longestSide + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } +} + +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." + } + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 58c66a62d61..3026ba373d9 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -3,17 +3,25 @@ import SwiftUI public struct ThreadDetailView: View { @Bindable var model: FeatureRootModel let thread: FeatureThread + let submitMessage: (FeatureMessageSubmission) async -> Bool @State private var draft = "" @State private var selection: FeatureSelection? + @State private var attachments: [FeatureDraftAttachment] = [] @State private var isSending = false @State private var isLoading = true + @State private var sendFailed = false @State private var toolSurface: FeatureThreadToolSurface? @FocusState private var composerFocused: Bool - public init(model: FeatureRootModel, thread: FeatureThread) { + public init( + model: FeatureRootModel, + thread: FeatureThread, + submitMessage: @escaping (FeatureMessageSubmission) async -> Bool + ) { self.model = model self.thread = thread + self.submitMessage = submitMessage } public var body: some View { @@ -143,6 +151,11 @@ public struct ThreadDetailView: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .alert("Message not sent", isPresented: $sendFailed) { + Button("OK") {} + } message: { + Text("Your draft is still here. Check your connection and try again.") + } } private var detail: FeatureThreadDetail? { @@ -158,7 +171,21 @@ public struct ThreadDetailView: View { let modelID = detail?.thread.modelID ?? thread.modelID else { return model.snapshot.settings.defaultSelection } - return FeatureSelection(providerID: providerID, modelID: modelID) + if let defaultSelection = model.snapshot.settings.defaultSelection, + defaultSelection.providerID == providerID, + defaultSelection.modelID == modelID { + return defaultSelection + } + let provider = model.snapshot.providers.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 func timeline(_ detail: FeatureThreadDetail) -> some View { @@ -217,7 +244,9 @@ public struct ThreadDetailView: View { FeatureComposerView( text: $draft, selection: $selection, + attachments: $attachments, providers: model.snapshot.providers, + threadSelection: currentSelection, isSending: isSending, isWorking: detail.thread.state == .working || detail.thread.state == .queued, focused: $composerFocused, @@ -232,17 +261,27 @@ public struct ThreadDetailView: View { private func send() { let message = draft - guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let pendingAttachments = attachments + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !pendingAttachments.isEmpty else { + return + } draft = "" + attachments = [] isSending = true Task { - let sent = await model.sendMessage( + let sent = await submitMessage( + FeatureMessageSubmission( threadID: thread.id, text: message, - selection: selection + selection: selection, + attachments: pendingAttachments + ) ) if !sent { draft = message + attachments = pendingAttachments + sendFailed = true } isSending = false composerFocused = true @@ -363,11 +402,16 @@ struct FeatureMessageView: View { case .user: HStack { Spacer(minLength: 34) - Text(.init(message.text)) - .textSelection(.enabled) - .padding(.horizontal, 13) - .padding(.vertical, 10) - .background(Color.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 15)) + VStack(alignment: .leading, spacing: 8) { + FeatureMessageAttachmentsView(attachments: message.attachments) + if !message.text.isEmpty { + Text(.init(message.text)) + .textSelection(.enabled) + } + } + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(Color.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 15)) } .accessibilityLabel("You") .accessibilityValue(message.text) @@ -379,9 +423,12 @@ struct FeatureMessageView: View { } .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) - Text(.init(message.text)) - .textSelection(.enabled) - .lineSpacing(3) + FeatureMessageAttachmentsView(attachments: message.attachments) + if !message.text.isEmpty { + Text(.init(message.text)) + .textSelection(.enabled) + .lineSpacing(3) + } } .frame(maxWidth: .infinity, alignment: .leading) case .tool: @@ -406,6 +453,47 @@ struct FeatureMessageView: View { } } +private struct FeatureMessageAttachmentsView: View { + let attachments: [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 + HStack(spacing: 9) { + Image(systemName: attachment.mimeType.hasPrefix("image/") ? "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(.caption.weight(.medium)) + .lineLimit(1) + Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(T3Colors.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(7) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(T3Colors.border, lineWidth: 1) + } + .accessibilityElement(children: .combine) + } + } + } + } +} + struct StreamingStatusView: View { let state: FeatureThreadState let onStop: () -> Void diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index c85c4dce402..a474240ad92 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -91,6 +91,26 @@ public final class FeatureRootModel { return succeeded ? created : nil } + public func startTask(_ request: NewTaskRequest) async -> FeatureThread? { + let prompt = request.trimmedPrompt + guard !prompt.isEmpty || !request.attachments.isEmpty else { return nil } + + var created: FeatureThread? + let succeeded = await perform { + let thread = try await client.createThreadAndSend( + projectID: request.projectID, + prompt: prompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode, + attachments: request.attachments.map(\.upload) + ) + upsert(thread) + created = thread + } + return succeeded ? created : nil + } + public func renameThread(_ id: String, title: String) async { await perform { try await client.renameThread(id: id, title: title) @@ -169,19 +189,48 @@ public final class FeatureRootModel { } public func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async -> Bool { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } + await sendMessage( + FeatureMessageSubmission( + threadID: threadID, + text: text, + selection: selection + ) + ) + } + + public func sendMessage(_ submission: FeatureMessageSubmission) async -> Bool { + let trimmed = submission.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !submission.attachments.isEmpty else { return false } + + let optimisticID = "local-\(UUID().uuidString)" + let optimistic = FeatureMessage( + id: optimisticID, + role: .user, + text: trimmed, + state: .queued, + attachments: submission.attachments.map { + FeatureMessageAttachment( + id: $0.id.uuidString, + name: $0.filename, + mimeType: $0.mimeType, + sizeBytes: $0.byteCount + ) + } + ) + details[submission.threadID]?.messages.append(optimistic) - return await perform { - let optimistic = FeatureMessage( - id: "local-\(UUID().uuidString)", - role: .user, + let sent = await perform { + try await client.sendMessage( + threadID: submission.threadID, text: trimmed, - state: .queued + selection: submission.selection, + attachments: submission.attachments.map(\.upload) ) - details[threadID]?.messages.append(optimistic) - try await client.sendMessage(threadID: threadID, text: trimmed, selection: selection) } + if !sent { + details[submission.threadID]?.messages.removeAll { $0.id == optimisticID } + } + return sent } public func cancelTurn(threadID: String) async { @@ -223,11 +272,21 @@ public final class FeatureRootModel { try await operation() return true } catch { - errorMessage = error.localizedDescription + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } return false } } + private static func isBenignCancellation(_ error: any Error) -> Bool { + if error is CancellationError { return true } + let message = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return message == "cancelled" || message == "canceled" + } + private func apply(_ event: FeatureEvent) { switch event { case let .snapshot(value): @@ -258,3 +317,9 @@ public final class FeatureRootModel { } } } + +private extension FeatureDraftAttachment { + var upload: FeatureUploadAttachment { + FeatureUploadAttachment(data: data, name: filename, mimeType: mimeType) + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift index ff2e51059e6..48adc2bbfe9 100644 --- a/apps/swift-ios/Features/Root/FeatureRootView.swift +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -12,7 +12,15 @@ public struct FeatureRootView: View { if model.isLoading { FeatureLoadingView() } else if model.snapshot.connection.state == .connected { - WorkspaceView(model: model) + WorkspaceView( + model: model, + submitNewTask: { request in + await model.startTask(request) + }, + submitMessage: { submission in + await model.sendMessage(submission) + } + ) } else { ConnectionOnboardingView(model: model) } diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index e06d9ddcb29..fdc595ad358 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -14,6 +14,14 @@ public protocol FeatureClient: AnyObject { func addProject(path: String) async throws func createThread(projectID: String, title: String?, selection: FeatureSelection?) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread func renameThread(id: String, title: String) async throws func setThreadArchived(id: String, archived: Bool) async throws func setThreadSettled(id: String, settled: Bool) async throws @@ -24,6 +32,12 @@ public protocol FeatureClient: AnyObject { func loadThread(id: String) async throws -> FeatureThreadDetail func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws func cancelTurn(threadID: String) async throws func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws func resolveUserInput(id: String, answers: [String: String]) async throws @@ -64,6 +78,40 @@ public extension FeatureClient { func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws {} + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + let thread = try await createThread( + projectID: projectID, + title: prompt, + selection: selection + ) + try await sendMessage( + threadID: thread.id, + text: prompt, + selection: selection, + attachments: attachments + ) + return thread + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws { + guard attachments.isEmpty else { + throw FeatureCapabilityUnavailable("Image attachments") + } + try await sendMessage(threadID: threadID, text: text, selection: selection) + } + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { throw FeatureCapabilityUnavailable("Files") } diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 05edbc9fe2f..8c5db5c2ccf 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -83,10 +83,12 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var projectID: String public var title: String public var preview: String? + public var createdAt: Date public var updatedAt: Date public var state: FeatureThreadState public var providerID: String? public var modelID: String? + public var modelOptions: [FeatureModelOptionSelection] public var isArchived: Bool public var isSettled: Bool public var snoozedUntil: Date? @@ -98,10 +100,12 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl projectID: String, title: String, preview: String? = nil, + createdAt: Date = .now, updatedAt: Date = .now, state: FeatureThreadState = .idle, providerID: String? = nil, modelID: String? = nil, + modelOptions: [FeatureModelOptionSelection] = [], isArchived: Bool = false, isSettled: Bool = false, snoozedUntil: Date? = nil, @@ -112,10 +116,12 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.projectID = projectID self.title = title self.preview = preview + self.createdAt = createdAt self.updatedAt = updatedAt self.state = state self.providerID = providerID self.modelID = modelID + self.modelOptions = modelOptions self.isArchived = isArchived self.isSettled = isSettled self.snoozedUntil = snoozedUntil @@ -138,6 +144,40 @@ public enum FeatureMessageState: String, Sendable, Codable { case failed } +public struct FeatureMessageAttachment: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var mimeType: String + public var sizeBytes: Int + public var url: URL? + + public init( + id: String, + name: String, + mimeType: String, + sizeBytes: Int, + url: URL? = nil + ) { + self.id = id + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.url = url + } +} + +public struct FeatureUploadAttachment: Sendable, Equatable { + public var data: Data + public var name: String + public var mimeType: String + + public init(data: Data, name: String, mimeType: String) { + self.data = data + self.name = name + self.mimeType = mimeType + } +} + public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codable { public let id: String public var role: FeatureMessageRole @@ -145,6 +185,7 @@ public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codab public var createdAt: Date public var state: FeatureMessageState public var toolName: String? + public var attachments: [FeatureMessageAttachment] public init( id: String, @@ -152,7 +193,8 @@ public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codab text: String, createdAt: Date = .now, state: FeatureMessageState = .complete, - toolName: String? = nil + toolName: String? = nil, + attachments: [FeatureMessageAttachment] = [] ) { self.id = id self.role = role @@ -160,6 +202,7 @@ public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codab self.createdAt = createdAt self.state = state self.toolName = toolName + self.attachments = attachments } } @@ -256,19 +299,121 @@ public struct FeatureModel: Identifiable, Sendable, Equatable, Hashable, Codable public var detail: String? public var supportsImages: Bool public var supportsReasoning: Bool + public var isDefault: Bool + public var options: [FeatureModelOptionDescriptor] public init( id: String, name: String, detail: String? = nil, supportsImages: Bool = false, - supportsReasoning: Bool = false + supportsReasoning: Bool = false, + isDefault: Bool = false, + options: [FeatureModelOptionDescriptor] = [] ) { self.id = id self.name = name self.detail = detail self.supportsImages = supportsImages self.supportsReasoning = supportsReasoning + self.isDefault = isDefault + 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 init( + id: String, + label: String, + detail: String? = nil, + kind: FeatureModelOptionKind, + choices: [FeatureModelOptionChoice] = [], + defaultValue: FeatureModelOptionValue? = nil + ) { + self.id = id + self.label = label + self.detail = detail + self.kind = kind + self.choices = choices + self.defaultValue = defaultValue + } +} + +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 } } @@ -276,12 +421,23 @@ public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Coda 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 init(id: String, name: String, isAvailable: Bool = true, models: [FeatureModel] = []) { + public init( + id: String, + name: String, + isAvailable: Bool = true, + driver: String = "", + requiresNewThreadForModelChange: Bool = false, + models: [FeatureModel] = [] + ) { self.id = id self.name = name self.isAvailable = isAvailable + self.driver = driver + self.requiresNewThreadForModelChange = requiresNewThreadForModelChange self.models = models } } @@ -289,10 +445,16 @@ public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Coda 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) { + public init( + providerID: String, + modelID: String, + options: [FeatureModelOptionSelection] = [] + ) { self.providerID = providerID self.modelID = modelID + self.options = options } } diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift new file mode 100644 index 00000000000..279c55dc384 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -0,0 +1,312 @@ +import Foundation + +public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { + public let id: UUID + public var data: Data + public var filename: String + public var mimeType: String + + public init( + id: UUID = UUID(), + data: Data, + filename: String, + mimeType: String + ) { + self.id = id + self.data = data + self.filename = filename + self.mimeType = mimeType + } + + public var byteCount: Int { + data.count + } +} + +public struct NewTaskRequest: Sendable, Equatable { + public var projectID: String + public var prompt: String + public var selection: FeatureSelection? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + public var attachments: [FeatureDraftAttachment] + + public init( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureDraftAttachment] = [] + ) { + self.projectID = projectID + self.prompt = prompt + self.selection = selection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + self.attachments = attachments + } + + public var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +public struct FeatureMessageSubmission: Sendable, Equatable { + public var threadID: String + public var text: String + public var selection: FeatureSelection? + public var attachments: [FeatureDraftAttachment] + + public init( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureDraftAttachment] = [] + ) { + self.threadID = threadID + self.text = text + self.selection = selection + self.attachments = attachments + } +} + +struct DailyUXSidebarIndex { + let active: [FeatureThread] + let settled: [FeatureThread] + let searchResults: [FeatureThread] + + init( + snapshot: FeatureSnapshot, + query: String, + projectID: String? = nil, + now: Date = .now + ) { + let projectByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) }) + let visible = snapshot.threads.filter { thread in + guard !thread.isArchived else { return false } + guard thread.snoozedUntil.map({ $0 > now }) != true else { return false } + return projectID == nil || thread.projectID == projectID + } + + active = visible + .filter { !$0.isEffectivelySettled(at: now) } + .sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id < rhs.id + } + + settled = visible + .filter { $0.isEffectivelySettled(at: now) } + .sorted { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.id < rhs.id + } + + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedQuery.isEmpty else { + searchResults = [] + return + } + + searchResults = (active + settled).filter { thread in + let project = projectByID[thread.projectID] + return [ + thread.title, + thread.preview ?? "", + project?.name ?? "", + project?.path ?? "", + ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + } +} + +extension FeatureThread { + var needsAttention: Bool { + state == .waitingForApproval || state == .waitingForInput || state == .failed + } + + func isEffectivelySettled(at now: Date) -> Bool { + switch state { + case .queued, .working, .waitingForApproval, .waitingForInput, .failed: + return false + case .idle, .completed: + break + } + if isSettled { + return true + } + return now.timeIntervalSince(updatedAt) >= 3 * 24 * 60 * 60 + } +} + +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 unfiltered = available.flatMap { provider in + provider.models.map { DailyUXModelOption(provider: provider, model: $0) } + } + 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" : "", + option.model.supportsReasoning ? "reasoning thinking" : "", + ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + + all = matches + favorites = matches.filter { favoriteIDs.contains($0.id) } + + let byID = Dictionary(uniqueKeysWithValues: matches.map { ($0.id, $0) }) + recents = recentIDs.compactMap { byID[$0] }.filter { !favoriteIDs.contains($0.id) } + + providerGroups = available.compactMap { provider in + let options = matches.filter { $0.provider.id == provider.id } + return options.isEmpty ? nil : (provider, options) + } + } +} + +enum DailyUXModelOptions { + 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 + if let defaultValue = descriptor.defaultValue { + return FeatureModelOptionSelection(id: descriptor.id, value: defaultValue) + } + switch descriptor.kind { + case .select: + guard let choice = descriptor.choices.first(where: \.isDefault) + ?? descriptor.choices.first else { + return nil + } + return FeatureModelOptionSelection(id: descriptor.id, value: .string(choice.id)) + case .boolean: + return FeatureModelOptionSelection(id: descriptor.id, value: .boolean(false)) + } + } + } + + static func value( + for descriptor: FeatureModelOptionDescriptor, + in selections: [FeatureModelOptionSelection] + ) -> FeatureModelOptionValue? { + if let selected = selections.first(where: { $0.id == descriptor.id })?.value { + return selected + } + if let defaultValue = descriptor.defaultValue { + return defaultValue + } + switch descriptor.kind { + case .select: + let choice = descriptor.choices.first(where: \.isDefault) + ?? descriptor.choices.first + return choice.map { .string($0.id) } + case .boolean: + return .boolean(false) + } + } + + static func updating( + _ selections: [FeatureModelOptionSelection], + id: String, + value: FeatureModelOptionValue + ) -> [FeatureModelOptionSelection] { + var next = selections.filter { $0.id != id } + 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 + case let .boolean(isEnabled): + return isEnabled ? descriptor.label : nil + } + } + return labels.isEmpty ? nil : labels.joined(separator: " · ") + } + + 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.supportsImages + } +} diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index dca6e73f4d2..1c87894b549 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -3,127 +3,295 @@ import SwiftUI public struct NewThreadView: View { @SwiftUI.Environment(\.dismiss) private var dismiss @Bindable var model: FeatureRootModel + let submit: (NewTaskRequest) async -> FeatureThread? let onCreated: (FeatureThread) -> Void @State private var projectID = "" - @State private var title = "" + @State private var prompt = "" @State private var selection: FeatureSelection? - @State private var isCreating = false + @State private var runtimeMode: FeatureRuntimeMode = .fullAccess + @State private var interactionMode: FeatureInteractionMode = .standard + @State private var attachments: [FeatureDraftAttachment] = [] + @State private var isSubmitting = false + @State private var submissionFailed = false + @FocusState private var promptFocused: Bool - public init(model: FeatureRootModel, onCreated: @escaping (FeatureThread) -> Void) { + public init( + model: FeatureRootModel, + submit: @escaping (NewTaskRequest) async -> FeatureThread?, + onCreated: @escaping (FeatureThread) -> Void + ) { self.model = model + self.submit = submit self.onCreated = onCreated } public var body: some View { NavigationStack { - Form { - Section("Project") { - Picker("Project", selection: $projectID) { - ForEach(model.snapshot.projects) { project in - Text(project.name).tag(project.id) - } + VStack(spacing: 0) { + contextBar + Divider() + .overlay(T3Colors.separator) + + ZStack(alignment: .topLeading) { + if prompt.isEmpty { + Text("What do you want to build?") + .font(.title3) + .foregroundStyle(T3Colors.textTertiary) + .padding(.horizontal, 17) + .padding(.top, 17) + .allowsHitTesting(false) } + + TextEditor(text: $prompt) + .font(.title3) + .lineSpacing(3) + .scrollContentBackground(.hidden) + .focused($promptFocused) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .accessibilityLabel("Task") } - Section("Task") { - TextField("Optional title", text: $title, axis: .vertical) - .lineLimit(1...3) - ProviderModelPicker( - providers: model.snapshot.providers, - selection: $selection - ) + VStack(spacing: 9) { + FeatureAttachmentStrip(attachments: $attachments) + + HStack(spacing: 8) { + FeatureImageAttachmentPicker( + attachments: $attachments, + isEnabled: DailyUXModelOptions.supportsImages( + selection: selection, + providers: model.snapshot.providers + ) + ) + + if !attachments.isEmpty, !imagesAllowed { + Text("Choose an image model") + .font(.caption) + .foregroundStyle(T3Colors.warning) + } else if !attachments.isEmpty { + Text("\(attachments.count) selected") + .font(.caption) + .foregroundStyle(T3Colors.textSecondary) + } + + Spacer() + + Button(action: startTask) { + HStack(spacing: 7) { + if isSubmitting { + ProgressView() + .controlSize(.small) + .tint(.black) + } else { + Text("Start task") + Image(systemName: "arrow.up") + .font(.system(size: 13, weight: .bold)) + } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.black) + .padding(.horizontal, 16) + .frame(height: 40) + .background(.white, in: RoundedRectangle(cornerRadius: 10)) + } + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.35) + .accessibilityLabel(isSubmitting ? "Starting task" : "Start task") + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 10) + .background(T3Colors.background) + .overlay(alignment: .top) { + Divider().overlay(T3Colors.separator) } } - .scrollContentBackground(.hidden) - .background(Color.black) - .navigationTitle("New Thread") + .background(T3Colors.background) + .navigationTitle("New task") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(isCreating ? "Creating…" : "Create") { - create() - } - .disabled(projectID.isEmpty || isCreating) + .disabled(isSubmitting) } } + .t3NavigationChrome() .onAppear { if projectID.isEmpty { projectID = model.snapshot.projects.first?.id ?? "" } if selection == nil { - selection = model.snapshot.settings.defaultSelection - ?? model.snapshot.providers.first?.models.first.map { - FeatureSelection( - providerID: model.snapshot.providers.first?.id ?? "", - modelID: $0.id - ) - } + selection = DailyUXModelOptions.validated( + model.snapshot.settings.defaultSelection, + in: model.snapshot.providers + ) + ?? DailyUXModelOptions.preferredSelection(in: model.snapshot.providers) } + promptFocused = true } - } - } - - private func create() { - isCreating = true - Task { - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - if let thread = await model.createThread( - projectID: projectID, - title: trimmed.isEmpty ? nil : trimmed, - selection: selection - ) { - onCreated(thread) + .alert("Couldn’t start task", isPresented: $submissionFailed) { + Button("OK") {} + } message: { + Text("Check your connection and try again.") } - isCreating = false } + .interactiveDismissDisabled(isSubmitting) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .preferredColorScheme(.dark) } -} -public struct ProviderModelPicker: View { - let providers: [FeatureProvider] - @Binding var selection: FeatureSelection? + private var contextBar: some View { + ScrollView(.horizontal) { + HStack(spacing: 18) { + Menu { + ForEach(model.snapshot.projects) { project in + Button { + projectID = project.id + } label: { + if project.id == projectID { + Label(project.name, systemImage: "checkmark") + } else { + Text(project.name) + } + } + } + } label: { + contextLabel( + icon: "folder", + title: selectedProject?.name ?? "Choose project" + ) + } - public init(providers: [FeatureProvider], selection: Binding) { - self.providers = providers - _selection = selection - } + ProviderModelPicker( + providers: model.snapshot.providers, + selection: $selection, + style: .compact, + isLoading: model.isLoading + ) - public var body: some View { - Menu { - ForEach(providers.filter(\.isAvailable)) { provider in - Section(provider.name) { - ForEach(provider.models) { model in + Menu { + Section("Interaction") { Button { - selection = FeatureSelection(providerID: provider.id, modelID: model.id) + interactionMode = .standard } label: { - if selection?.providerID == provider.id, selection?.modelID == model.id { - Label(model.name, systemImage: "checkmark") - } else { - Text(model.name) + modeLabel("Build", selected: interactionMode == .standard) + } + Button { + interactionMode = .plan + } label: { + modeLabel("Plan", selected: interactionMode == .plan) + } + } + + Section("Access") { + ForEach(FeatureRuntimeMode.allCases, id: \.self) { mode in + Button { + runtimeMode = mode + } label: { + modeLabel(runtimeLabel(mode), selected: runtimeMode == mode) } } } + } label: { + contextLabel( + icon: interactionMode == .plan ? "list.bullet.clipboard" : "bolt", + title: interactionMode == .plan ? "Plan" : runtimeShortLabel + ) } } - } label: { - LabeledContent("Model") { - Text(selectionLabel) - .foregroundStyle(.secondary) + .padding(.horizontal, 16) + .frame(height: 48) + } + .scrollIndicators(.hidden) + } + + private var selectedProject: FeatureProject? { + model.snapshot.projects.first { $0.id == projectID } + } + + private var canSubmit: Bool { + !isSubmitting + && !projectID.isEmpty + && (!trimmedPrompt.isEmpty || !attachments.isEmpty) + && (attachments.isEmpty || imagesAllowed) + } + + private var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var imagesAllowed: Bool { + DailyUXModelOptions.supportsImages( + selection: selection, + providers: model.snapshot.providers + ) + } + + private var runtimeShortLabel: String { + switch runtimeMode { + case .approvalRequired: "Ask" + case .autoAcceptEdits: "Edits" + case .automatic: "Automatic" + case .fullAccess: "Full access" + } + } + + private func contextLabel(icon: String, title: String) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + Text(title) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(.caption.weight(.medium)) + .foregroundStyle(T3Colors.textSecondary) + .contentShape(Rectangle()) + } + + private func modeLabel(_ title: String, selected: Bool) -> some View { + Group { + if selected { + Label(title, systemImage: "checkmark") + } else { + Text(title) } } - .accessibilityLabel("Provider and model") } - private var selectionLabel: String { - guard let selection, - let provider = providers.first(where: { $0.id == selection.providerID }), - let model = provider.models.first(where: { $0.id == selection.modelID }) else { - return "Automatic" + private func runtimeLabel(_ mode: FeatureRuntimeMode) -> String { + switch mode { + case .approvalRequired: "Ask before changes" + case .autoAcceptEdits: "Auto-accept edits" + case .automatic: "Automatic" + case .fullAccess: "Full access" + } + } + + private func startTask() { + guard canSubmit else { return } + promptFocused = false + isSubmitting = true + let request = NewTaskRequest( + projectID: projectID, + prompt: trimmedPrompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + attachments: attachments + ) + + Task { + if let thread = await submit(request) { + onCreated(thread) + } else { + isSubmitting = false + submissionFailed = true + promptFocused = true + } } - return "\(provider.name) · \(model.name)" } } diff --git a/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift new file mode 100644 index 00000000000..6eae8cb9e42 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift @@ -0,0 +1,544 @@ +import SwiftUI + +public struct ProviderModelPicker: View { + public enum Style { + case row + case compact + } + + let providers: [FeatureProvider] + @Binding var selection: FeatureSelection? + let style: Style + let isLoading: Bool + let threadSelection: FeatureSelection? + + @State private var isPresented = false + + public init( + providers: [FeatureProvider], + selection: Binding, + style: Style = .row, + isLoading: Bool = false, + threadSelection: FeatureSelection? = nil + ) { + self.providers = providers + _selection = selection + self.style = style + self.isLoading = isLoading + self.threadSelection = threadSelection + } + + public var body: some View { + Button { + isPresented = true + } label: { + switch style { + case .row: + HStack(spacing: 12) { + Image(systemName: "cpu") + .foregroundStyle(T3Colors.textSecondary) + VStack(alignment: .leading, spacing: 2) { + Text("Model") + .font(.caption) + .foregroundStyle(T3Colors.textSecondary) + Text(selectionLabel) + .font(.subheadline.weight(.medium)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .contentShape(Rectangle()) + case .compact: + HStack(spacing: 6) { + Image(systemName: "cpu") + Text(compactSelectionLabel) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(.caption.weight(.medium)) + .foregroundStyle(T3Colors.textSecondary) + .contentShape(Rectangle()) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Choose model") + .accessibilityValue(selectionLabel) + .sheet(isPresented: $isPresented) { + ModelPickerSheet( + providers: providers, + selection: $selection, + isLoading: isLoading, + threadSelection: threadSelection + ) + } + } + + private var selectedOption: DailyUXModelOption? { + 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 + } + return DailyUXModelOption(provider: provider, model: model) + } + + private var selectionLabel: String { + guard let selectedOption else { + return selection == nil ? "Automatic" : "Model unavailable" + } + let base = "\(selectedOption.provider.name) · \(selectedOption.model.name)" + guard let selection, + let summary = DailyUXModelOptions.summary( + for: selectedOption.model, + selections: selection.options + ) else { + return base + } + return "\(base) · \(summary)" + } + + private var compactSelectionLabel: String { + guard let selectedOption else { + return selection == nil ? "Automatic" : "Choose model" + } + guard let selection, + let summary = DailyUXModelOptions.summary( + for: selectedOption.model, + selections: selection.options + ) else { + return selectedOption.model.name + } + return "\(selectedOption.model.name) · \(summary)" + } +} + +private struct ModelPickerSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let providers: [FeatureProvider] + @Binding var selection: FeatureSelection? + let isLoading: Bool + let threadSelection: FeatureSelection? + + @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? + + var body: some View { + NavigationStack { + Group { + if isLoading, availableModelCount == 0 { + VStack(spacing: 12) { + ProgressView() + Text("Loading models") + .font(.subheadline) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if availableModelCount == 0 { + ContentUnavailableView( + "No models available", + systemImage: "cpu", + description: Text("Check the providers enabled on this environment.") + ) + } else { + modelList + } + } + .background(T3Colors.background) + .navigationTitle("Choose model") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search models") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .navigationDestination(item: $configuring) { option in + ModelConfigurationView( + option: option, + currentSelection: selection + ) { configuredSelection in + selection = configuredSelection + recordRecent(option.id) + dismiss() + } + } + .t3NavigationChrome() + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .preferredColorScheme(.dark) + } + + private var modelList: some View { + List { + if query.isEmpty { + Section { + Button { + selection = nil + dismiss() + } label: { + ModelAutomaticRow(isSelected: selection == nil) + } + .buttonStyle(.plain) + .disabled(modelChangesAreLocked) + .opacity(modelChangesAreLocked ? 0.36 : 1) + } + } + + if modelChangesAreLocked { + Section { + Label( + "This provider fixes the model when a task starts.", + systemImage: "lock" + ) + .font(.caption) + .foregroundStyle(T3Colors.textSecondary) + } + } + + if !catalog.favorites.isEmpty { + Section("Favorites") { + ForEach(catalog.favorites) { option in + modelRow(option) + } + } + } + + if !catalog.recents.isEmpty { + Section("Recent") { + ForEach(catalog.recents) { option in + modelRow(option) + } + } + } + + ForEach(catalog.providerGroups, id: \.provider.id) { group in + Section(group.provider.name) { + ForEach(group.models) { option in + modelRow(option) + } + } + } + + if catalog.all.isEmpty { + ContentUnavailableView.search(text: query) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + } + + private var availableModelCount: Int { + providers + .filter(\.isAvailable) + .reduce(into: 0) { count, provider in + count += provider.models.count + } + } + + private func modelRow(_ option: DailyUXModelOption) -> some View { + HStack(spacing: 10) { + Button { + select(option) + } label: { + ModelOptionLabel( + option: option, + isSelected: selection?.providerID == option.provider.id + && selection?.modelID == option.model.id + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button { + toggleFavorite(option.id) + } label: { + Image(systemName: favoriteIDs.contains(option.id) ? "star.fill" : "star") + .font(.system(size: 15)) + .foregroundStyle( + favoriteIDs.contains(option.id) + ? T3Colors.warning + : T3Colors.textTertiary + ) + .frame(width: 34, height: 34) + } + .buttonStyle(.borderless) + .accessibilityLabel( + favoriteIDs.contains(option.id) ? "Remove favorite" : "Add favorite" + ) + } + .disabled(isLocked(option)) + .opacity(isLocked(option) ? 0.36 : 1) + .listRowBackground(T3Colors.background) + } + + 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 catalog: DailyUXModelCatalog { + DailyUXModelCatalog( + providers: providers, + query: query, + favoriteIDs: favoriteIDs, + recentIDs: recentIDs + ) + } + + private func select(_ option: DailyUXModelOption) { + guard !isLocked(option) else { return } + if option.model.options.isEmpty { + selection = FeatureSelection(providerID: option.provider.id, modelID: option.model.id) + recordRecent(option.id) + dismiss() + } else { + configuring = option + } + } + + private func recordRecent(_ id: String) { + recentStorage = ([id] + recentIDs.filter { $0 != id }) + .prefix(8) + .joined(separator: "\n") + } + + 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 modelChangesAreLocked, let threadSelection else { return false } + return option.provider.id != threadSelection.providerID + || 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 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 + if currentSelection?.providerID == option.provider.id, + currentSelection?.modelID == option.model.id { + _optionSelections = State(initialValue: currentSelection?.options ?? []) + } else { + _optionSelections = State(initialValue: DailyUXModelOptions.defaults(for: option.model)) + } + } + + var body: some View { + Form { + Section { + ModelOptionLabel(option: option, isSelected: false) + } + + ForEach(option.model.options) { descriptor in + Section { + switch descriptor.kind { + case .select: + Picker( + descriptor.label, + selection: stringBinding(for: descriptor) + ) { + ForEach(descriptor.choices) { choice in + VStack(alignment: .leading) { + Text(choice.label) + if let detail = choice.detail { + Text(detail) + } + } + .tag(choice.id) + } + } + .pickerStyle(.navigationLink) + case .boolean: + Toggle( + descriptor.label, + isOn: booleanBinding(for: descriptor) + ) + } + } footer: { + if let detail = descriptor.detail { + Text(detail) + } + } + } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle("Model options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Use model") { + onConfirm( + FeatureSelection( + providerID: option.provider.id, + modelID: option.model.id, + options: optionSelections + ) + ) + } + .fontWeight(.semibold) + } + } + } + + private func stringBinding( + for descriptor: FeatureModelOptionDescriptor + ) -> Binding { + Binding( + get: { + guard case let .string(value) = DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) else { + return "" + } + return value + }, + set: { value in + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: .string(value) + ) + } + ) + } + + private func booleanBinding( + for descriptor: FeatureModelOptionDescriptor + ) -> Binding { + Binding( + get: { + guard case let .boolean(value) = DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) else { + return false + } + return value + }, + set: { value in + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: .boolean(value) + ) + } + ) + } +} + +private struct ModelAutomaticRow: View { + let isSelected: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "wand.and.stars") + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text("Automatic") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(T3Colors.textPrimary) + Text("Use the environment default") + .font(.caption) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.subheadline.weight(.bold)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } +} + +private struct ModelOptionLabel: View { + let option: DailyUXModelOption + let isSelected: Bool + + var body: some View { + HStack(spacing: 12) { + providerMark + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Text(option.model.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + if option.model.supportsReasoning { + capability("Reasoning", icon: "brain") + } + if option.model.supportsImages { + capability("Images", icon: "photo") + } + } + Text(option.model.detail ?? option.model.id) + .font(.caption) + .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 providerMark: some View { + Text(String(option.provider.name.prefix(1)).uppercased()) + .font(.caption.weight(.bold)) + .foregroundStyle(T3Colors.textPrimary) + .frame(width: 26, height: 26) + .background(T3Colors.surfaceRaised, in: RoundedRectangle(cornerRadius: 6)) + } + + private func capability(_ title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(.caption2) + .foregroundStyle(T3Colors.textSecondary) + } +} diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 8c6785cdf61..bdeb1473322 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -2,75 +2,84 @@ import SwiftUI public struct WorkspaceView: View { @Bindable var model: FeatureRootModel + private let submitNewTask: (NewTaskRequest) async -> FeatureThread? + private let submitMessage: (FeatureMessageSubmission) async -> Bool + @State private var selectedThreadID: String? @State private var searchText = "" - @State private var showingNewThread = false + @State private var selectedProjectID: String? + @State private var settledLimit = 10 + @State private var showingNewTask = false @State private var showingAddProject = false @State private var showingSettings = false @State private var showingArchived = false @State private var renamingThread: FeatureThread? @State private var renameTitle = "" - public init(model: FeatureRootModel) { + public init( + model: FeatureRootModel, + submitNewTask: ((NewTaskRequest) async -> FeatureThread?)? = nil, + submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil + ) { self.model = model + self.submitNewTask = submitNewTask ?? { request in + do { + let thread = try await model.client.createThreadAndSend( + projectID: request.projectID, + prompt: request.trimmedPrompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode, + attachments: request.attachments.map(\.uploadValue) + ) + await model.reload() + return thread + } catch { + return nil + } + } + self.submitMessage = submitMessage ?? { submission in + if submission.attachments.isEmpty { + return await model.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection + ) + } + do { + try await model.client.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection, + attachments: submission.attachments.map(\.uploadValue) + ) + _ = await model.detail(for: submission.threadID, force: true) + return true + } catch { + return false + } + } } public var body: some View { NavigationSplitView { - List(selection: $selectedThreadID) { - connectionSection - projectSection - threadSection - } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .background(Color.black) - .searchable(text: $searchText, prompt: "Search threads") - .navigationTitle("T3 Code") - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - showingSettings = true - } label: { - Image(systemName: "gearshape") - } - .accessibilityLabel("Settings") - } - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showingAddProject = true - } label: { - Image(systemName: "folder.badge.plus") - } - .accessibilityLabel("Add project") - Button { - showingNewThread = true - } label: { - Image(systemName: "square.and.pencil") - } - .accessibilityLabel("New thread") - .disabled(model.snapshot.projects.isEmpty) - } - } - } detail: { - if let id = selectedThreadID, - let thread = model.snapshot.threads.first(where: { $0.id == id }) { - ThreadDetailView(model: model, thread: thread) - .id(id) - } else { - ContentUnavailableView( - "Choose a thread", - systemImage: "bubble.left.and.bubble.right", - description: Text("Select a thread or start a new task.") + sidebar + .navigationSplitViewColumnWidth( + min: T3Metrics.minimumSidebarWidth, + ideal: T3Metrics.sidebarWidth, + max: T3Metrics.maximumSidebarWidth ) - .background(Color.black) - } + } detail: { + detail } .navigationSplitViewStyle(.balanced) - .sheet(isPresented: $showingNewThread) { - NewThreadView(model: model) { thread in + .sheet(isPresented: $showingNewTask) { + NewThreadView( + model: model, + submit: submitNewTask + ) { thread in selectedThreadID = thread.id - showingNewThread = false + showingNewTask = false } } .sheet(isPresented: $showingAddProject) { @@ -112,169 +121,346 @@ public struct WorkspaceView: View { } } + private var sidebar: some View { + List(selection: $selectedThreadID) { + connectionHeader + newTaskAction + + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + projectFilter + activeSection + settledSection + archiveAction + } else { + searchResults + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .searchable(text: $searchText, prompt: "Search tasks and projects") + .navigationTitle("T3") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showingSettings = true + } label: { + Image(systemName: "slider.horizontal.3") + } + .accessibilityLabel("Settings") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAddProject = true + } label: { + Image(systemName: "folder.badge.plus") + } + .accessibilityLabel("Add project") + } + } + .t3NavigationChrome() + .onChange(of: selectedProjectID) { + settledLimit = 10 + } + } + @ViewBuilder - private var connectionSection: some View { - Section { - Menu { - ForEach(model.snapshot.environments) { environment in - Button { - Task { await model.activateEnvironment(environment.id) } - } label: { - if environment.isActive { - Label(environment.name, systemImage: "checkmark") - } else { - Text(environment.name) - } + private var detail: some View { + if let id = selectedThreadID, + let thread = model.snapshot.threads.first(where: { $0.id == id }) { + ThreadDetailView( + model: model, + thread: thread, + submitMessage: submitMessage + ) + .id(id) + } else { + VStack(spacing: 18) { + Image(systemName: "cursorarrow.rays") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(T3Colors.textTertiary) + VStack(spacing: 5) { + Text("Ready when you are") + .font(.title3.weight(.semibold)) + Text("Pick up a task or start something new.") + .font(.subheadline) + .foregroundStyle(T3Colors.textSecondary) + } + Button("New task") { + showingNewTask = true + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(.black) + .disabled(model.snapshot.projects.isEmpty) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + } + } + + private var connectionHeader: some View { + Menu { + ForEach(model.snapshot.environments) { environment in + Button { + Task { await model.activateEnvironment(environment.id) } + } label: { + if environment.isActive { + Label(environment.name, systemImage: "checkmark") + } else { + Text(environment.name) } } + } + Divider() + Button { + showingSettings = true } label: { - HStack(spacing: 9) { - Circle() - .fill(Color.green) - .frame(width: 7, height: 7) - Text(model.snapshot.connection.environmentName ?? "Connected") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.primary) + Label("Manage devices", systemImage: "externaldrive.connected.to.line.below") + } + } label: { + HStack(spacing: 9) { + Circle() + .fill(connectionColor) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text(model.snapshot.connection.environmentName ?? "T3 environment") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(T3Colors.textPrimary) .lineLimit(1) - Spacer() - if model.snapshot.environments.count > 1 { - Image(systemName: "chevron.up.chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) + Text(connectionLabel) + .font(.caption2) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowSeparator(.hidden) + } + + private var newTaskAction: some View { + Button { + showingNewTask = true + } label: { + HStack(spacing: 10) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + Text("New task") + .font(.subheadline.weight(.semibold)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption.weight(.bold)) + .foregroundStyle(.black.opacity(0.48)) + } + .foregroundStyle(.black) + .padding(.horizontal, 12) + .frame(height: 42) + .background(.white, in: RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + .disabled(model.snapshot.projects.isEmpty) + .opacity(model.snapshot.projects.isEmpty ? 0.35 : 1) + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 9, trailing: 12)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .accessibilityHint("Compose a message and start a thread") + } + + private var projectFilter: some View { + Menu { + Button { + selectedProjectID = nil + } label: { + if selectedProjectID == nil { + Label("All projects", systemImage: "checkmark") + } else { + Text("All projects") + } + } + ForEach(model.snapshot.projects) { project in + Button { + selectedProjectID = project.id + } label: { + if selectedProjectID == project.id { + Label(project.name, systemImage: "checkmark") + } else { + Text(project.name) } } } - .buttonStyle(.plain) + } label: { + ProjectFilterRow( + project: model.snapshot.projects.first { $0.id == selectedProjectID }, + totalCount: sidebarIndex.active.count + sidebarIndex.settled.count + ) } + .buttonStyle(.plain) + .listRowSeparator(.hidden) } @ViewBuilder - private var projectSection: some View { - Section("Projects") { - if model.snapshot.projects.isEmpty { - Text("No projects") + private var activeSection: some View { + Section("Active") { + if sidebarIndex.active.isEmpty { + Text(selectedProjectID == nil ? "No active tasks" : "No active tasks in this project") .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(T3Colors.textSecondary) } else { - ForEach(model.snapshot.projects) { project in - HStack(spacing: 10) { - Image(systemName: "folder") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(project.name) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - Text(project.path) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Text("\(activeThreads.filter { $0.projectID == project.id }.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - } - .accessibilityElement(children: .combine) + ForEach(sidebarIndex.active) { thread in + threadLink(thread, showsProject: selectedProjectID == nil) } } } } @ViewBuilder - private var threadSection: some View { - Section { - if filteredThreads.isEmpty { - Text(searchText.isEmpty ? "No active threads" : "No results") - .font(.footnote) - .foregroundStyle(.secondary) - } - ForEach(filteredThreads) { thread in - NavigationLink(value: thread.id) { - FeatureThreadRow( - thread: thread, - projectName: projectName(for: thread.projectID) - ) + private var settledSection: some View { + if !sidebarIndex.settled.isEmpty { + Section("Settled") { + ForEach(sidebarIndex.settled.prefix(settledLimit)) { thread in + threadLink(thread, showsProject: selectedProjectID == nil) + .opacity(0.76) } - .tag(thread.id) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - Task { await model.deleteThread(thread.id) } - } label: { - Label("Delete", systemImage: "trash") - } + if sidebarIndex.settled.count > settledLimit { Button { - Task { await model.setArchived(thread.id, archived: true) } + settledLimit += 25 } label: { - Label("Archive", systemImage: "archivebox") - } - .tint(.orange) - } - .contextMenu { - Button { - renameTitle = thread.title - renamingThread = thread - } label: { - Label("Rename", systemImage: "pencil") - } - Button { - Task { await model.setArchived(thread.id, archived: true) } - } label: { - Label("Archive", systemImage: "archivebox") - } - Button { - Task { await model.setSettled(thread.id, settled: !thread.isSettled) } - } label: { - Label( - thread.isSettled ? "Reopen" : "Mark done", - systemImage: thread.isSettled ? "arrow.counterclockwise" : "checkmark" - ) - } - Button { - Task { - await model.setSnoozed( - thread.id, - until: thread.snoozedUntil == nil - ? Date().addingTimeInterval(60 * 60) - : nil - ) + HStack { + Text("Show more") + Spacer() + Text("\(sidebarIndex.settled.count - settledLimit)") + .font(.caption.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) } - } label: { - Label( - thread.snoozedUntil == nil ? "Snooze 1 hour" : "Unsnooze", - systemImage: thread.snoozedUntil == nil ? "clock" : "bell" - ) - } - Button(role: .destructive) { - Task { await model.deleteThread(thread.id) } - } label: { - Label("Delete", systemImage: "trash") + .font(.subheadline) } + .buttonStyle(.plain) } } + } + } + private var archiveAction: some View { + Button { + showingArchived = true + } label: { + Label("Archived", systemImage: "archivebox") + .font(.subheadline) + .foregroundStyle(T3Colors.textSecondary) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var searchResults: some View { + Section("Results") { + if sidebarIndex.searchResults.isEmpty { + ContentUnavailableView.search(text: searchText) + .listRowBackground(Color.clear) + } else { + ForEach(sidebarIndex.searchResults) { thread in + threadLink(thread, showsProject: true) + } + } + } + } + + private func threadLink(_ thread: FeatureThread, showsProject: Bool) -> some View { + NavigationLink(value: thread.id) { + FeatureThreadRow( + thread: thread, + projectName: showsProject ? projectName(for: thread.projectID) : nil + ) + } + .tag(thread.id) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + Task { await model.deleteThread(thread.id) } + } label: { + Label("Delete", systemImage: "trash") + } + Button { + Task { await model.setArchived(thread.id, archived: true) } + } label: { + Label("Archive", systemImage: "archivebox") + } + .tint(.orange) + } + .contextMenu { + Button { + renameTitle = thread.title + renamingThread = thread + } label: { + Label("Rename", systemImage: "pencil") + } + Button { + Task { await model.setArchived(thread.id, archived: true) } + } label: { + Label("Archive", systemImage: "archivebox") + } + if thread.isSettled { + Button { + Task { await model.setSettled(thread.id, settled: false) } + } label: { + Label("Reopen", systemImage: "arrow.counterclockwise") + } + } else if !thread.isEffectivelySettled(at: .now) { + Button { + Task { await model.setSettled(thread.id, settled: true) } + } label: { + Label("Mark done", systemImage: "checkmark") + } + } Button { - showingArchived = true + Task { + await model.setSnoozed( + thread.id, + until: thread.snoozedUntil == nil + ? Date().addingTimeInterval(60 * 60) + : nil + ) + } } label: { - Label("Archived", systemImage: "archivebox") - .font(.subheadline) + Label( + thread.snoozedUntil == nil ? "Snooze 1 hour" : "Unsnooze", + systemImage: thread.snoozedUntil == nil ? "clock" : "bell" + ) + } + Button(role: .destructive) { + Task { await model.deleteThread(thread.id) } + } label: { + Label("Delete", systemImage: "trash") } - } header: { - Text("Threads") } } - private var activeThreads: [FeatureThread] { - model.snapshot.threads - .filter { !$0.isArchived } - .sorted { $0.updatedAt > $1.updatedAt } + private var sidebarIndex: DailyUXSidebarIndex { + DailyUXSidebarIndex( + snapshot: model.snapshot, + query: searchText, + projectID: selectedProjectID + ) } - private var filteredThreads: [FeatureThread] { - guard !searchText.isEmpty else { return activeThreads } - return activeThreads.filter { - $0.title.localizedCaseInsensitiveContains(searchText) - || ($0.preview?.localizedCaseInsensitiveContains(searchText) ?? false) - || projectName(for: $0.projectID).localizedCaseInsensitiveContains(searchText) + private var connectionColor: Color { + switch model.snapshot.connection.state { + case .connected: T3Colors.success + case .connecting, .reconnecting: T3Colors.warning + case .disconnected: T3Colors.danger + } + } + + private var connectionLabel: String { + switch model.snapshot.connection.state { + case .connected: "Connected" + case .connecting: "Connecting" + case .reconnecting: "Reconnecting" + case .disconnected: "Offline" } } @@ -283,29 +469,83 @@ public struct WorkspaceView: View { } } +private extension FeatureDraftAttachment { + var uploadValue: FeatureUploadAttachment { + FeatureUploadAttachment(data: data, name: filename, mimeType: mimeType) + } +} + +private struct ProjectFilterRow: View { + let project: FeatureProject? + let totalCount: Int + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "folder") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text(project?.name ?? "All projects") + .font(.subheadline.weight(.medium)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Text(project?.path ?? "Filter tasks by repository") + .font(.caption2) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } + Spacer() + if totalCount > 0 { + Text("\(totalCount)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } +} + struct FeatureThreadRow: View { let thread: FeatureThread - let projectName: String + let projectName: String? var body: some View { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(statusColor) - .frame(width: 7, height: 7) - .padding(.top, 6) + HStack(alignment: .top, spacing: 9) { + Image(systemName: statusIcon) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(statusColor) + .frame(width: 10, height: 18) VStack(alignment: .leading, spacing: 3) { - Text(thread.title) - .font(.subheadline.weight(.medium)) - .lineLimit(1) + HStack(spacing: 6) { + Text(thread.title) + .font(.subheadline.weight(thread.needsAttention ? .semibold : .regular)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Spacer(minLength: 4) + Text(thread.updatedAt, format: .relative(presentation: .numeric)) + .font(.system(size: 10)) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } HStack(spacing: 5) { - Text(projectName) + if let projectName { + Text(projectName) + } if let preview = thread.preview, !preview.isEmpty { - Text("·") + if projectName != nil { Text("·") } Text(preview) + } else if thread.needsAttention { + if projectName != nil { Text("·") } + Text(statusText) } } .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(thread.needsAttention ? statusColor : T3Colors.textSecondary) .lineLimit(1) } } @@ -314,22 +554,35 @@ struct FeatureThreadRow: View { .accessibilityValue(statusText) } + private var statusIcon: String { + switch thread.state { + case .idle, .completed: "circle.fill" + case .queued, .working: "bolt.fill" + case .waitingForApproval: "exclamationmark.circle.fill" + case .waitingForInput: "questionmark.circle.fill" + case .failed: "xmark.circle.fill" + } + } + private var statusColor: Color { switch thread.state { - case .idle, .completed: .gray - case .queued: .blue + case .idle, .completed: T3Colors.textTertiary + case .queued: T3Colors.accent case .working: .cyan - case .waitingForApproval: .orange + case .waitingForApproval: T3Colors.warning case .waitingForInput: .indigo - case .failed: .red + case .failed: T3Colors.danger } } private var statusText: String { switch thread.state { - case .waitingForApproval: "Waiting for approval" - case .waitingForInput: "Waiting for input" - default: thread.state.rawValue + case .waitingForApproval: "Approval needed" + case .waitingForInput: "Answer needed" + case .working: "Working" + case .queued: "Queued" + case .failed: "Failed" + case .idle, .completed: thread.state.rawValue.capitalized } } } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift new file mode 100644 index 00000000000..7f756c1bfde --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Model picker") +struct DailyUXModelPickerTests { + @Test + func catalogPreservesFavoritesRecentsAndProviderGroups() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5", name: "GPT-5", supportsImages: true), + FeatureModel(id: "gpt-5-mini", name: "GPT-5 Mini"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "sonnet", name: "Sonnet", supportsReasoning: true)] + ), + ] + let favorite = DailyUXModelOption.key(providerID: "claude", modelID: "sonnet") + let recent = DailyUXModelOption.key(providerID: "codex", modelID: "gpt-5") + + let catalog = DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [favorite], + recentIDs: [recent] + ) + + #expect(catalog.favorites.map(\.id) == [favorite]) + #expect(catalog.recents.map(\.id) == [recent]) + #expect(catalog.providerGroups.map(\.provider.id) == ["codex", "claude"]) + } + + @Test + func searchIncludesCapabilitiesAndProviderNames() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "vision", name: "Visual", supportsImages: true), + FeatureModel(id: "plain", name: "Plain"), + ] + ), + ] + + let catalog = DailyUXModelCatalog( + providers: providers, + query: "images", + favoriteIDs: [], + recentIDs: [] + ) + + #expect(catalog.all.map(\.model.id) == ["vision"]) + } + + @Test + func optionDefaultsUseTypedDescriptorDefaults() { + let model = FeatureModel( + id: "gpt-5", + name: "GPT-5", + options: [ + FeatureModelOptionDescriptor( + id: "effort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High", isDefault: true), + ] + ), + FeatureModelOptionDescriptor( + id: "fast", + label: "Fast mode", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ) + + let defaults = DailyUXModelOptions.defaults(for: model) + + #expect(defaults == [ + FeatureModelOptionSelection(id: "effort", value: .string("high")), + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + ]) + } + + @Test + func updatingAnOptionReplacesOnlyItsPreviousValue() { + let initial = [ + FeatureModelOptionSelection(id: "effort", value: .string("low")), + FeatureModelOptionSelection(id: "fast", value: .boolean(false)), + ] + + let updated = DailyUXModelOptions.updating( + initial, + id: "effort", + value: .string("high") + ) + + #expect(updated.first { $0.id == "effort" }?.value == .string("high")) + #expect(updated.first { $0.id == "fast" }?.value == .boolean(false)) + #expect(updated.count == 2) + } + + @Test + func optionSummaryUsesChoiceLabelsAndEnabledBooleans() { + let model = FeatureModel( + id: "gpt-5", + name: "GPT-5", + options: [ + .init( + id: "effort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "high", label: "High")] + ), + .init(id: "fast", label: "Fast", kind: .boolean), + ] + ) + + let summary = DailyUXModelOptions.summary( + for: model, + selections: [ + .init(id: "effort", value: .string("high")), + .init(id: "fast", value: .boolean(true)), + ] + ) + + #expect(summary == "High · Fast") + } + + @Test + func preferredSelectionFindsDefaultAcrossProvidersAndIncludesDefaults() { + let providers = [ + FeatureProvider( + id: "first", + name: "First", + models: [.init(id: "basic", name: "Basic")] + ), + FeatureProvider( + id: "second", + name: "Second", + models: [ + .init( + id: "preferred", + name: "Preferred", + isDefault: true, + options: [ + .init( + id: "fast", + label: "Fast", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ), + ] + ), + ] + + let selection = DailyUXModelOptions.preferredSelection(in: providers) + + #expect(selection?.providerID == "second") + #expect(selection?.modelID == "preferred") + #expect(selection?.options == [ + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + ]) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift new file mode 100644 index 00000000000..30c89b3fbe0 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Message-first task creation") +struct DailyUXNewTaskTests { + @Test + func requestKeepsPromptModesAndImageBytesTogether() { + let image = FeatureDraftAttachment( + data: Data([1, 2, 3]), + filename: "Image 1.jpg", + mimeType: "image/jpeg" + ) + let request = NewTaskRequest( + projectID: "project", + prompt: " Build it \n", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5"), + runtimeMode: .fullAccess, + interactionMode: .plan, + attachments: [image] + ) + + #expect(request.trimmedPrompt == "Build it") + #expect(request.interactionMode == .plan) + #expect(request.attachments.first?.byteCount == 3) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift new file mode 100644 index 00000000000..0be3cef8292 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Sidebar v2") +struct DailyUXSidebarTests { + private let now = Date(timeIntervalSince1970: 2_000_000) + + @Test + func activeOrderUsesCreationTimeAndDoesNotJumpWithActivity() { + let olderCreationRecentActivity = thread( + id: "old", + created: -500, + updated: -5, + state: .working + ) + let newerCreationOlderActivity = thread( + id: "new", + created: -100, + updated: -80, + state: .working + ) + + let index = makeIndex([olderCreationRecentActivity, newerCreationOlderActivity]) + + #expect(index.active.map(\.id) == ["new", "old"]) + } + + @Test + func settledUsesExplicitStateOrThreeDayRestingAge() { + let explicitlySettled = thread( + id: "explicit", + created: -10, + updated: -10, + state: .idle, + isSettled: true + ) + let resting = thread( + id: "resting", + created: -400_000, + updated: -300_000, + state: .idle + ) + let oldButWorking = thread( + id: "working", + created: -400_000, + updated: -300_000, + state: .working + ) + let settledButWorking = thread( + id: "settled-working", + created: -400_000, + updated: -300_000, + state: .working, + isSettled: true + ) + let oldButWaiting = thread( + id: "waiting", + created: -400_000, + updated: -300_000, + state: .waitingForApproval + ) + + let index = makeIndex([ + explicitlySettled, + resting, + oldButWorking, + settledButWorking, + oldButWaiting, + ]) + + #expect(Set(index.settled.map(\.id)) == ["explicit", "resting"]) + #expect(Set(index.active.map(\.id)) == ["working", "settled-working", "waiting"]) + } + + @Test + func snoozedAndArchivedThreadsStayOutOfBothBlocks() { + var snoozed = thread(id: "snoozed", created: -20, updated: -10) + snoozed.snoozedUntil = now.addingTimeInterval(3_600) + var archived = thread(id: "archived", created: -30, updated: -20) + archived.isArchived = true + let visible = thread(id: "visible", created: -10, updated: -5) + + let index = makeIndex([snoozed, archived, visible]) + + #expect(index.active.map(\.id) == ["visible"]) + #expect(index.settled.isEmpty) + } + + @Test + func projectFilterAndSearchUseRepositoryContext() { + let projects = [ + FeatureProject(id: "p1", environmentID: "e", name: "Mobile", path: "/work/mobile"), + FeatureProject(id: "p2", environmentID: "e", name: "Server", path: "/work/server"), + ] + let mobile = thread(id: "mobile", projectID: "p1", title: "Polish picker", created: -10, updated: -5) + let server = thread(id: "server", projectID: "p2", title: "Compression", created: -20, updated: -5) + let snapshot = FeatureSnapshot(projects: projects, threads: [mobile, server]) + + let filtered = DailyUXSidebarIndex(snapshot: snapshot, query: "", projectID: "p1", now: now) + let searched = DailyUXSidebarIndex(snapshot: snapshot, query: "server", now: now) + + #expect(filtered.active.map(\.id) == ["mobile"]) + #expect(searched.searchResults.map(\.id) == ["server"]) + } + + private func makeIndex(_ threads: [FeatureThread]) -> DailyUXSidebarIndex { + DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: threads), + query: "", + now: now + ) + } + + private func thread( + id: String, + projectID: String = "project", + title: String = "Task", + created: TimeInterval, + updated: TimeInterval, + state: FeatureThreadState = .idle, + isSettled: Bool = false + ) -> FeatureThread { + FeatureThread( + id: id, + projectID: projectID, + title: title, + createdAt: now.addingTimeInterval(created), + updatedAt: now.addingTimeInterval(updated), + state: state, + isSettled: isSettled + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift index 32062f6e3a3..e3734c8eb86 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import T3Code @@ -68,6 +69,43 @@ struct FeatureRootModelTests { #expect(model.details[thread.id]?.messages.last?.state == .queued) } + @Test + func testNewTaskStartsThreadAndFirstTurnAtomically() async { + let client = FeatureClientStub() + let created = FeatureThread( + id: "thread-atomic", + projectID: "project-1", + title: "Ship the native app", + providerID: "codex", + modelID: "gpt-5.6-sol" + ) + client.createdThread = created + let model = FeatureRootModel(client: client) + let attachment = FeatureDraftAttachment( + data: Data([0xFF, 0xD8, 0xFF]), + filename: "reference.jpg", + mimeType: "image/jpeg" + ) + + let result = await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: " Ship the native app ", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [attachment] + ) + ) + + #expect(result == created) + #expect(client.startedPrompt == "Ship the native app") + #expect(client.startedAttachments.map(\.name) == ["reference.jpg"]) + #expect(client.createThreadCallCount == 0) + #expect(client.sendMessageCallCount == 0) + #expect(model.snapshot.threads == [created]) + } + @Test func testArchiveAndDeleteKeepLocalListsConsistent() async { let client = FeatureClientStub() @@ -93,6 +131,10 @@ private final class FeatureClientStub: FeatureClient { var pairEndpoint: String? var pairToken: String? var sentText: String? + var startedPrompt: String? + var startedAttachments: [FeatureUploadAttachment] = [] + var createThreadCallCount = 0 + var sendMessageCallCount = 0 func initialSnapshot() async throws -> FeatureSnapshot { if pairEndpoint != nil, let snapshotAfterPair { @@ -111,7 +153,21 @@ private final class FeatureClientStub: FeatureClient { title: String?, selection: FeatureSelection? ) async throws -> FeatureThread { - createdThread + createThreadCallCount += 1 + return createdThread + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + startedPrompt = prompt + startedAttachments = attachments + return createdThread } func renameThread(id: String, title: String) async throws {} @@ -126,6 +182,7 @@ private final class FeatureClientStub: FeatureClient { } func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws { + sendMessageCallCount += 1 sentText = text } From 27a28907d0c1a7f23fd6086c4701270ab85bfde7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 09:57:17 -0700 Subject: [PATCH 005/154] fix(ios): polish native daily flows --- apps/swift-ios/App/NativeFeatureClient.swift | 1044 +++++++++++++++-- apps/swift-ios/Core/Attachments.swift | 5 + apps/swift-ios/Core/PairingService.swift | 15 - apps/swift-ios/Core/T3Client.swift | 94 +- apps/swift-ios/Core/WebSocketRPC.swift | 41 +- .../Features/Chat/FeatureComposerView.swift | 3 + .../Features/Chat/ImageAttachmentViews.swift | 70 +- .../Features/Chat/ThreadDetailView.swift | 190 ++- .../Connection/ConnectionOnboardingView.swift | 29 +- .../Features/Devices/DevicesView.swift | 11 + .../Features/Root/FeatureRootModel.swift | 76 +- .../Features/Root/FeatureRootView.swift | 2 +- .../Features/Shared/FeatureModels.swift | 29 +- .../Features/Workspace/DailyUXModels.swift | 72 +- .../Features/Workspace/NewThreadView.swift | 18 +- .../Features/Workspace/WorkspaceView.swift | 92 +- .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 345819 bytes .../AppIcon.appiconset/Contents.json | 14 + .../T3Code.xcodeproj/project.pbxproj | 4 +- .../Tests/CoreTests/CoreContractTests.swift | 65 + .../Tests/CoreTests/PairingServiceTests.swift | 4 + .../CoreTests/TransportReliabilityTests.swift | 189 +++ .../CoreTests/WebSocketRPCRaceTests.swift | 185 +++ .../CoreTests/WorkspaceContractTests.swift | 4 + .../DailyUXModelPickerTests.swift | 23 + .../FeatureTests/DailyUXNewTaskTests.swift | 24 + .../FeatureTests/DailyUXSidebarTests.swift | 68 +- .../FeatureTests/FeatureRootModelTests.swift | 58 + .../NativeRetryIdentityTests.swift | 419 +++++++ 29 files changed, 2612 insertions(+), 236 deletions(-) create mode 100644 apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 2fe5d480b92..dda366492d6 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -14,8 +14,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { private var latestServerConfig: ServerConfigSnapshot? private var archivedThreads: [FeatureThread] = [] private var latestSnapshot: FeatureSnapshot? - private var loadedThreadIDs = Set() + private var activeThreadID: String? private var latestDetails: [String: FeatureThreadDetail] = [:] + private var attachmentURLs: [AttachmentCacheKey: CachedAttachmentURL] = [:] + private var pendingBootstrapSubmission: PendingBootstrapSubmission? + private var pendingTurnSubmissions: [String: PendingTurnSubmission] = [:] + private var attachmentHydrationTasks: [ + String: (id: UUID, task: Task) + ] = [:] private var approvalThreadIDs: [String: String] = [:] private var inputThreadIDs: [String: String] = [:] private var terminalIDs: [String: String] = [:] @@ -24,7 +30,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { String: [UUID: AsyncStream.Continuation] ] = [:] private var pollingTask: Task? + private var fallbackPollingTask: Task? private var configurationTask: Task? + private var archivedRefreshTask: Task? + private var detailRefreshTask: Task? + private var detailRefreshPending = false + private var detailRefreshGeneration = 0 + private var environmentGeneration = 0 + private var lastShellEventAt: Date? init( runtime: EnvironmentRuntime = EnvironmentRuntime(), @@ -39,7 +52,11 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { deinit { pollingTask?.cancel() + fallbackPollingTask?.cancel() configurationTask?.cancel() + archivedRefreshTask?.cancel() + detailRefreshTask?.cancel() + attachmentHydrationTasks.values.forEach { $0.task.cancel() } continuation.finish() } @@ -47,23 +64,36 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { let environments = try await runtime.environments() guard let environment = try await runtime.activeEnvironment(), let activeClient = try await runtime.activeClient() else { + await clearActiveEnvironment() let snapshot = disconnectedSnapshot(environments: environments) latestSnapshot = snapshot return snapshot } - activeEnvironment = environment - client = activeClient - async let shellRequest = activeClient.shellSnapshot() - async let readModelRequest = activeClient.readModel() - async let serverConfigRequest: ServerConfigSnapshot? = try? activeClient.serverConfig() - let (shell, readModel) = try await (shellRequest, readModelRequest) - latestServerConfig = await serverConfigRequest + await adoptEnvironment(environment, client: activeClient) + let generation = environmentGeneration + let shell: OrchestrationShellSnapshot + do { + shell = try await activeClient.shellSnapshot() + } catch { + guard isCurrentSession(client: activeClient, generation: generation) else { + throw CancellationError() + } + await clearActiveEnvironment() + let snapshot = disconnectedSnapshot( + environments: environments, + detail: "That server is currently unreachable." + ) + latestSnapshot = snapshot + return snapshot + } + guard isCurrentSession(client: activeClient, generation: generation) else { + throw CancellationError() + } latestShell = shell - archivedThreads = readModel.threads - .filter { $0.archivedAt != nil } - .map(mapThread) + archivedThreads = [] startPolling(activeClient) + scheduleArchivedRefresh(client: activeClient) let snapshot = makeSnapshot( shell: shell, environments: environments, @@ -88,72 +118,97 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { } else { pairedClient = try await runtime.pair(url: endpoint, clientLabel: "T3 Code Swift") } - activeEnvironment = pairedClient.environment - client = pairedClient - loadedThreadIDs.removeAll() - latestDetails.removeAll() - archivedThreads.removeAll() - latestServerConfig = nil + await adoptEnvironment(pairedClient.environment, client: pairedClient) startPolling(pairedClient) } func activateEnvironment(id: String) async throws { - pollingTask?.cancel() - configurationTask?.cancel() let activated = try await runtime.activate(id: id) - activeEnvironment = activated.environment - client = activated - loadedThreadIDs.removeAll() - latestDetails.removeAll() - latestShell = nil - latestServerConfig = nil - archivedThreads.removeAll() - finishTerminalStreams() - terminalIDs.removeAll() - terminalSnapshots.removeAll() + await adoptEnvironment(activated.environment, client: activated) startPolling(activated) } func removeEnvironment(id: String) async throws { if activeEnvironment?.id == id { - pollingTask?.cancel() - configurationTask?.cancel() - pollingTask = nil - configurationTask = nil - client = nil - activeEnvironment = nil - latestShell = nil - latestServerConfig = nil - archivedThreads.removeAll() - loadedThreadIDs.removeAll() - latestDetails.removeAll() - finishTerminalStreams() - terminalIDs.removeAll() - terminalSnapshots.removeAll() + await clearActiveEnvironment(disconnectClient: false) } try await runtime.remove(id: id) } func disconnect() async { + await clearActiveEnvironment() + } + + private func adoptEnvironment( + _ environment: Environment, + client newClient: T3Client + ) async { + let previousClient = client pollingTask?.cancel() + fallbackPollingTask?.cancel() configurationTask?.cancel() + archivedRefreshTask?.cancel() pollingTask = nil + fallbackPollingTask = nil configurationTask = nil - if let client { - await client.disconnect() + archivedRefreshTask = nil + clearEnvironmentState() + activeEnvironment = environment + client = newClient + if let previousClient, previousClient !== newClient { + await previousClient.disconnect() } + } + + private func clearActiveEnvironment(disconnectClient: Bool = true) async { + let previousClient = client + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + archivedRefreshTask?.cancel() + pollingTask = nil + fallbackPollingTask = nil + configurationTask = nil + archivedRefreshTask = nil + clearEnvironmentState() client = nil activeEnvironment = nil + if disconnectClient, let previousClient { + await previousClient.disconnect() + } + } + + private func clearEnvironmentState() { + environmentGeneration &+= 1 + resetDetailRefresh() + attachmentHydrationTasks.values.forEach { $0.task.cancel() } + attachmentHydrationTasks.removeAll() + archivedRefreshTask?.cancel() + archivedRefreshTask = nil latestShell = nil + lastShellEventAt = nil latestServerConfig = nil archivedThreads.removeAll() - loadedThreadIDs.removeAll() + latestSnapshot = nil + activeThreadID = nil latestDetails.removeAll() + attachmentURLs.removeAll() + pendingBootstrapSubmission = nil + pendingTurnSubmissions.removeAll() + approvalThreadIDs.removeAll() + inputThreadIDs.removeAll() finishTerminalStreams() terminalIDs.removeAll() terminalSnapshots.removeAll() } + private func isCurrentSession(client: T3Client, generation: Int) -> Bool { + guard generation == environmentGeneration, let currentClient = self.client else { + return false + } + return currentClient === client + } + func addProject(path: String) async throws { let client = try requireClient() let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) @@ -175,6 +230,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { selection: FeatureSelection? ) async throws -> FeatureThread { let client = try requireClient() + let generation = environmentGeneration let threadID = UUID().uuidString let model = modelSelection(selection, projectID: projectID) let resolvedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -187,6 +243,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { runtimeMode: .fullAccess ) let shell = try await client.shellSnapshot() + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } latestShell = shell if let created = shell.threads.first(where: { $0.id == threadID }) { let mapped = mapThread(created) @@ -212,30 +271,86 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { attachments: [FeatureUploadAttachment] ) async throws -> FeatureThread { let client = try requireClient() - let threadID = UUID().uuidString + let generation = environmentGeneration let model = modelSelection(selection, projectID: projectID) let title = Self.title(from: prompt, hasAttachments: !attachments.isEmpty) let uploads = try makeUploadAttachments(attachments) - - _ = try await client.createThreadAndSend( - threadID: threadID, + let runtime = coreRuntimeMode(runtimeMode) + let interaction = coreInteractionMode(interactionMode) + let signature = BootstrapSubmissionSignature( projectID: projectID, - title: title, - text: prompt, + prompt: prompt, model: model, - runtimeMode: coreRuntimeMode(runtimeMode), - interactionMode: coreInteractionMode(interactionMode), - attachments: uploads + runtimeMode: runtime, + interactionMode: interaction, + attachments: attachments ) + let pending: PendingBootstrapSubmission + if let existing = pendingBootstrapSubmission, + existing.signature == signature { + pending = existing + } else { + pending = PendingBootstrapSubmission( + signature: signature, + threadID: UUID().uuidString, + identity: CommandIdentity() + ) + pendingBootstrapSubmission = pending + } - let shell = try await client.shellSnapshot() - latestShell = shell - await emitSnapshot(shell) - if let created = shell.threads.first(where: { $0.id == threadID }) { - return mapThread(created) + do { + _ = try await client.createThreadAndSend( + threadID: pending.threadID, + projectID: projectID, + title: title, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + attachments: uploads, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + // A connection can disappear after the server accepted the command + // but before its reply reaches us. Bootstrap expansion creates the + // thread before dispatching the stable final turn, so recover an + // interrupted empty thread by sending only that original turn. + guard try await recoverBootstrap( + client: client, + pending: pending, + projectID: projectID, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + attachments: uploads + ) else { + throw error + } + } + + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } + if pendingBootstrapSubmission?.identity == pending.identity { + pendingBootstrapSubmission = nil + } + // 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 shell = try? await client.shellSnapshot() { + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } + latestShell = shell + await emitSnapshot(shell) + if let created = shell.threads.first(where: { $0.id == pending.threadID }) { + return mapThread(created) + } } return FeatureThread( - id: threadID, + id: pending.threadID, projectID: projectID, title: title, providerID: model.instanceId, @@ -246,6 +361,54 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { ) } + private func recoverBootstrap( + client: T3Client, + pending: PendingBootstrapSubmission, + projectID: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode, + attachments: [UploadChatImageAttachment] + ) async throws -> Bool { + guard let snapshot = try? await client.threadSnapshot(id: pending.threadID) else { + return false + } + if snapshot.thread.messages.contains(where: { + $0.id == pending.identity.messageID + }) { + return true + } + guard snapshot.thread.projectId == projectID, + snapshot.thread.deletedAt == nil, + snapshot.thread.messages.isEmpty else { + return false + } + + do { + _ = try await client.sendTurn( + threadID: pending.threadID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: attachments, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + guard await messageWasCommitted( + client: client, + threadID: pending.threadID, + messageID: pending.identity.messageID + ) else { + throw error + } + } + return true + } + func renameThread(id: String, title: String) async throws { let client = try requireClient() _ = try await client.rename(threadID: id, title: title) @@ -274,7 +437,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { let client = try requireClient() _ = try await client.setRuntimeMode(threadID: id, mode: coreRuntimeMode(mode)) try await refresh(client: client) - if loadedThreadIDs.contains(id) { + if activeThreadID == id { try await refreshThread(id: id, client: client) } } @@ -283,7 +446,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { let client = try requireClient() _ = try await client.setInteractionMode(threadID: id, mode: coreInteractionMode(mode)) try await refresh(client: client) - if loadedThreadIDs.contains(id) { + if activeThreadID == id { try await refreshThread(id: id, client: client) } } @@ -291,17 +454,26 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { func deleteThread(id: String) async throws { let client = try requireClient() _ = try await client.delete(threadID: id) - loadedThreadIDs.remove(id) + if activeThreadID == id { + resetDetailRefresh() + activeThreadID = nil + } latestDetails[id] = nil try await refresh(client: client, includeArchived: true) } func loadThread(id: String) async throws -> FeatureThreadDetail { let client = try requireClient() - loadedThreadIDs.insert(id) + let generation = environmentGeneration + resetDetailRefresh() + activeThreadID = id let snapshot = try await client.threadSnapshot(id: id) + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } let detail = mapDetail(snapshot.thread) latestDetails[id] = detail + scheduleAttachmentHydration(in: detail, threadID: id, client: client) return detail } @@ -325,20 +497,79 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { attachments: [FeatureUploadAttachment] ) async throws { let client = try requireClient() + let generation = environmentGeneration guard let shellThread = latestShell?.threads.first(where: { $0.id == threadID }) else { throw NativeFeatureClientError.threadNotFound } - - _ = try await client.sendTurn( - threadID: threadID, + let model = selection.map(coreModelSelection) + let uploads = try makeUploadAttachments(attachments) + let signature = TurnSubmissionSignature( text: text, + model: model, runtimeMode: shellThread.runtimeMode, interactionMode: shellThread.interactionMode, - model: selection.map(coreModelSelection), - attachments: try makeUploadAttachments(attachments) + attachments: attachments ) - try await refreshThread(id: threadID, client: client) - try await refresh(client: client) + let pending: PendingTurnSubmission + if let existing = pendingTurnSubmissions[threadID], + existing.signature == signature { + pending = existing + } else { + pending = PendingTurnSubmission( + signature: signature, + identity: CommandIdentity() + ) + pendingTurnSubmissions[threadID] = pending + } + + do { + _ = try await client.sendTurn( + threadID: threadID, + text: text, + runtimeMode: shellThread.runtimeMode, + interactionMode: shellThread.interactionMode, + model: model, + attachments: uploads, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } + guard await messageWasCommitted( + client: client, + threadID: threadID, + 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 isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } + if pendingTurnSubmissions[threadID]?.identity == pending.identity { + pendingTurnSubmissions[threadID] = nil + } + // Live sync reconciles these snapshots. Refreshes are opportunistic + // after the accepted command so transient reads cannot invite a + // duplicate user turn. + try? await refreshThread(id: threadID, client: client) + try? await refresh(client: client) + } + + private func messageWasCommitted( + client: T3Client, + threadID: String, + messageID: String + ) async -> Bool { + guard let snapshot = try? await client.threadSnapshot(id: threadID) else { + return false + } + return snapshot.thread.messages.contains { $0.id == messageID } } func cancelTurn(threadID: String) async throws { @@ -502,6 +733,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { guard let client else { return AsyncStream { continuation in continuation.finish() } } + let generation = environmentGeneration return AsyncStream { continuation in let subscriptionID = UUID() terminalContinuations[threadID, default: [:]][subscriptionID] = continuation @@ -513,6 +745,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { for try await event in await client.terminalEvents() { guard !Task.isCancelled else { break } guard let self else { break } + guard self.isCurrentSession( + client: client, + generation: generation + ) else { + break + } guard event.threadId == threadID else { continue } if let terminalID = self.terminalIDs[threadID], event.terminalId != nil, @@ -530,6 +768,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { continuation.finish() return } + guard self.isCurrentSession( + client: client, + generation: generation + ) else { + continuation.finish() + return + } var snapshot = self.terminalSnapshots[threadID] ?? FeatureTerminalSnapshot(threadID: threadID) snapshot.state = .failed @@ -553,6 +798,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { func openTerminal(threadID: String, columns: Int, rows: Int) async throws { let client = try requireClient() + let generation = environmentGeneration let context = try workspaceContext(threadID: threadID) let terminalID = terminalIDs[threadID] ?? UUID().uuidString terminalIDs[threadID] = terminalID @@ -564,6 +810,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { columns: columns, rows: rows ) + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } let mapped = NativeWorkspaceMapper.terminal(snapshot) terminalSnapshots[threadID] = mapped publishTerminal(mapped, threadID: threadID) @@ -592,8 +841,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { func closeTerminal(threadID: String) async throws { let client = try requireClient() + let generation = environmentGeneration let terminalID = try requireTerminalID(threadID: threadID) try await client.closeTerminal(threadID: threadID, terminalID: terminalID) + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } terminalIDs[threadID] = nil let snapshot = FeatureTerminalSnapshot(threadID: threadID) terminalSnapshots[threadID] = snapshot @@ -681,20 +934,36 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { private func startPolling(_ activeClient: T3Client) { pollingTask?.cancel() + fallbackPollingTask?.cancel() configurationTask?.cancel() + let generation = environmentGeneration pollingTask = Task { [weak self] in do { await activeClient.connect() - let sequence = self?.latestShell?.snapshotSequence + guard let self, + self.isCurrentSession(client: activeClient, generation: generation) else { + return + } + let sequence = self.latestShell?.snapshotSequence for try await item in await activeClient.shellEvents(after: sequence) { - guard !Task.isCancelled, let self else { break } + guard !Task.isCancelled, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + break + } + self.lastShellEventAt = .now + self.emitConnection(.connected) switch item { case let .snapshot(shell): - await self.consume(shell: shell, client: activeClient) + await self.consume( + shell: shell, + client: activeClient, + refreshActiveThread: true + ) case .projectUpserted, .projectRemoved, .threadUpserted, .threadRemoved: - if let shell = try? await activeClient.shellSnapshot() { - await self.consume(shell: shell, client: activeClient) - } + await self.consume(delta: item, client: activeClient) case .synchronized: break } @@ -702,22 +971,69 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { } catch is CancellationError { return } catch { - guard let self else { return } - self.continuation.yield(.failure("Live sync unavailable. Using HTTP updates.")) - do { - for try await shell in await activeClient.pollShell(every: .seconds(2)) { - guard !Task.isCancelled else { break } - await self.consume(shell: shell, client: activeClient) + // 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.emitConnection( + .reconnecting, + detail: "Live updates paused. Refreshing over HTTP." + ) + } + fallbackPollingTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(3)) + 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 else { return } + await self.consume( + shell: shell, + client: activeClient, + refreshActiveThread: true + ) + } catch is CancellationError { + return + } catch { + self.emitConnection( + .reconnecting, + detail: "Server unreachable. Retrying automatically." + ) } - } catch { - self.continuation.yield(.failure(error.localizedDescription)) } + try? await Task.sleep(for: .seconds(2)) } } configurationTask = Task { [weak self] in do { for try await event in await activeClient.serverConfigEvents() { - guard !Task.isCancelled, let self else { break } + guard !Task.isCancelled, + let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + break + } switch event { case let .snapshot(config): self.latestServerConfig = config @@ -739,42 +1055,210 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { } } - private func consume(shell: OrchestrationShellSnapshot, client: T3Client) async { + private func consume( + shell: OrchestrationShellSnapshot, + client: T3Client, + refreshActiveThread: Bool + ) async { + guard let currentClient = self.client, currentClient === client else { return } latestShell = shell - if let readModel = try? await client.readModel() { - archivedThreads = readModel.threads - .filter { $0.archivedAt != nil } - .map(mapThread) + await emitSnapshot(shell) + if refreshActiveThread, let threadID = activeThreadID { + scheduleDetailRefresh(threadID: threadID, client: client) + } + } + + private func consume(delta: ShellStreamItem, client: T3Client) async { + guard let currentClient = self.client, currentClient === client else { return } + guard let current = latestShell else { + if let shell = try? await client.shellSnapshot() { + await consume(shell: shell, client: client, refreshActiveThread: true) + } + return + } + + let sequence: Int + + switch delta { + case let .projectUpserted(nextSequence, _): + sequence = nextSequence + case let .projectRemoved(nextSequence, _): + sequence = nextSequence + case let .threadUpserted(nextSequence, _): + sequence = nextSequence + case let .threadRemoved(nextSequence, _): + sequence = nextSequence + case .snapshot, .synchronized: + return + } + + // Replayed deltas are expected after reconnect. They must be entirely + // side-effect free, including for cached detail and selection state. + guard sequence > current.snapshotSequence else { return } + + var projects = current.projects + var threads = current.threads + var changedThreadID: String? + var shouldRefreshArchived = false + + switch delta { + case let .projectUpserted(_, project): + if let index = projects.firstIndex(where: { $0.id == project.id }) { + projects[index] = project + } else { + projects.append(project) + } + case let .projectRemoved(_, projectID): + projects.removeAll { $0.id == projectID } + case let .threadUpserted(_, thread): + changedThreadID = thread.id + archivedThreads.removeAll { $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): + changedThreadID = threadID + shouldRefreshArchived = true + threads.removeAll { $0.id == threadID } + latestDetails[threadID] = nil + if activeThreadID == threadID { + resetDetailRefresh() + activeThreadID = nil + } + case .snapshot, .synchronized: + return } + + let shell = OrchestrationShellSnapshot( + snapshotSequence: sequence, + projects: projects, + threads: threads, + updatedAt: current.updatedAt + ) + latestShell = shell await emitSnapshot(shell) - for threadID in loadedThreadIDs { - try? await refreshThread(id: threadID, client: client) + if shouldRefreshArchived { + scheduleArchivedRefresh(client: client) + } + if let changedThreadID, activeThreadID == changedThreadID { + scheduleDetailRefresh(threadID: changedThreadID, client: client) + } + } + + private func scheduleDetailRefresh(threadID: String, client: T3Client) { + guard activeThreadID == threadID else { return } + guard detailRefreshTask == nil else { + detailRefreshPending = true + return + } + detailRefreshPending = false + detailRefreshGeneration &+= 1 + let generation = detailRefreshGeneration + let sessionGeneration = environmentGeneration + detailRefreshTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(160)) + } catch { + self?.finishDetailRefresh(generation: generation, client: client) + return + } + guard let self else { return } + if !Task.isCancelled, + self.activeThreadID == threadID, + self.isCurrentSession(client: client, generation: sessionGeneration) { + try? await self.refreshThread(id: threadID, client: client) + } + self.finishDetailRefresh(generation: generation, client: client) } } + private func finishDetailRefresh(generation: Int, client: T3Client) { + guard detailRefreshGeneration == generation else { return } + detailRefreshTask = nil + let needsTrailingRefresh = detailRefreshPending + detailRefreshPending = false + if needsTrailingRefresh, let threadID = activeThreadID { + scheduleDetailRefresh(threadID: threadID, client: client) + } + } + + private func resetDetailRefresh() { + detailRefreshGeneration &+= 1 + detailRefreshTask?.cancel() + detailRefreshTask = nil + detailRefreshPending = false + } + private func refresh(client: T3Client, includeArchived: Bool = false) async throws { + let generation = environmentGeneration let shell = try await client.shellSnapshot() + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } latestShell = shell if includeArchived { - let readModel = try await client.readModel() - archivedThreads = readModel.threads - .filter { $0.archivedAt != nil } - .map(mapThread) + scheduleArchivedRefresh(client: client) } await emitSnapshot(shell) } + private func scheduleArchivedRefresh(client: T3Client) { + 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.archivedThreads = archivedShell.threads.map(self.mapThread) + if let shell = self.latestShell { + await self.emitSnapshot(shell) + } + } + } + private func refreshThread(id: String, client: T3Client) async throws { + let generation = environmentGeneration + guard let environmentID = activeEnvironment?.id else { + throw CancellationError() + } let snapshot = try await client.threadSnapshot(id: id) + guard isCurrentSession(client: client, generation: generation) else { + throw CancellationError() + } let detail = mapDetail(snapshot.thread) - guard latestDetails[id] != detail else { return } - latestDetails[id] = detail - continuation.yield(.detail(detail)) + if latestDetails[id] != detail { + latestDetails[id] = detail + continuation.yield(.detail(detail)) + } + let hydrationBase = latestDetails[id] ?? detail + let hydrated = await hydratedAttachmentURLs( + in: hydrationBase, + client: client, + environmentID: environmentID, + generation: generation + ) + guard isCurrentSession(client: client, generation: generation), + latestDetails[id] == hydrationBase, + hydrated != hydrationBase else { + return + } + latestDetails[id] = hydrated + continuation.yield(.detail(hydrated)) } private func emitSnapshot(_ shell: OrchestrationShellSnapshot) async { guard let environment = activeEnvironment else { return } + let generation = environmentGeneration let environments = (try? await runtime.environments()) ?? [environment] + guard generation == environmentGeneration, + activeEnvironment?.id == environment.id else { + return + } let snapshot = makeSnapshot( shell: shell, environments: environments, @@ -785,20 +1269,43 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { continuation.yield(.snapshot(snapshot)) } - private func disconnectedSnapshot(environments: [Environment]) -> FeatureSnapshot { + private func disconnectedSnapshot( + environments: [Environment], + detail: String? = nil + ) -> FeatureSnapshot { FeatureSnapshot( - connection: .init(state: .disconnected), + 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 } + continuation.yield( + .connection( + FeatureConnection( + state: state, + environmentName: environment.label, + endpoint: environment.httpBaseURL.absoluteString, + detail: detail + ) + ) + ) + } + private func makeSnapshot( shell: OrchestrationShellSnapshot, environments: [Environment], activeEnvironment: Environment ) -> FeatureSnapshot { let threads = shell.threads.map(mapThread) + archivedThreads + let threadCountByProject = threads.reduce(into: [String: Int]()) { counts, thread in + counts[thread.projectID, default: 0] += 1 + } return FeatureSnapshot( connection: FeatureConnection( state: .connected, @@ -814,7 +1321,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { environmentID: activeEnvironment.id, name: project.title, path: project.workspaceRoot, - threadCount: threads.lazy.filter { $0.projectID == project.id }.count + threadCount: threadCountByProject[project.id, default: 0], + defaultSelection: project.defaultModelSelection.map(mapSelection) ) }, threads: threads, @@ -850,7 +1358,19 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { modelOptions: mapOptionSelections(thread.modelSelection.options), isArchived: thread.archivedAt != nil, isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.latestUserMessageAt, + latestTurn: thread.latestTurn + ), snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), runtimeMode: mapRuntimeMode(thread.runtimeMode), interactionMode: mapInteractionMode(thread.interactionMode) ) @@ -861,7 +1381,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { id: thread.id, projectID: thread.projectId, title: thread.title, - preview: thread.messages.last?.text, + preview: previewText(thread.messages.last?.text), createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), state: mapThreadState( @@ -875,7 +1395,19 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { modelOptions: mapOptionSelections(thread.modelSelection.options), isArchived: thread.archivedAt != nil, isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.messages.last(where: { $0.role == "user" })?.createdAt, + latestTurn: thread.latestTurn + ), snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), runtimeMode: mapRuntimeMode(thread.runtimeMode), interactionMode: mapInteractionMode(thread.interactionMode) ) @@ -896,25 +1428,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { id: $0.id, name: $0.name, mimeType: $0.mimeType, - sizeBytes: $0.sizeBytes + sizeBytes: $0.sizeBytes, + url: cachedAttachmentURL(for: $0.id) ) } ) } - let work = thread.activities.compactMap { activity -> FeatureMessage? in - guard ![ - "approval.requested", - "approval.resolved", - "user-input.requested", - "user-input.resolved", - "context-window.updated", - "checkpoint.captured", - "task.started", - "tool.started", - ].contains(activity.kind), - activity.summary != "Checkpoint captured" else { - return nil - } + let errors = thread.activities.compactMap { activity -> FeatureMessage? in + guard activity.tone == "error" else { return nil } let detail = activity.payload["detail"]?.stringValue let text = detail.map { "\(activity.summary)\n\($0)" } ?? activity.summary return FeatureMessage( @@ -926,12 +1447,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { toolName: activity.kind ) } - let mergedMessages = (messages + work).sorted { $0.createdAt < $1.createdAt } + let mergedMessages = (messages + errors + collapsedWorkLogs(thread.activities)) + .sorted { $0.createdAt < $1.createdAt } let mappedThread = FeatureThread( id: thread.id, projectID: thread.projectId, title: thread.title, - preview: thread.messages.last?.text, + preview: previewText(thread.messages.last?.text), createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), state: mapThreadState( @@ -945,7 +1467,19 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { modelOptions: mapOptionSelections(thread.modelSelection.options), isArchived: thread.archivedAt != nil, isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.messages.last(where: { $0.role == "user" })?.createdAt, + latestTurn: thread.latestTurn + ), snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), runtimeMode: mapRuntimeMode(thread.runtimeMode), interactionMode: mapInteractionMode(thread.interactionMode) ) @@ -957,6 +1491,46 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { ) } + /// 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] + ) -> [FeatureMessage] { + let terminalKinds = Set(["tool.completed", "task.completed", "turn.plan.updated"]) + let completed = activities.filter { + $0.tone != "error" && terminalKinds.contains($0.kind) + } + let groups = Dictionary(grouping: completed) { activity in + activity.turnId ?? "unscoped" + } + + return groups.values.compactMap { unsorted in + let group = unsorted.sorted { $0.createdAt < $1.createdAt } + guard let last = group.last else { return nil } + let visible = group.suffix(40) + var lines: [String] = [] + let hiddenCount = group.count - visible.count + if hiddenCount > 0 { + lines.append("\(hiddenCount) earlier updates hidden") + } + lines.append( + contentsOf: visible.map { activity in + let detail = previewText(activity.payload["detail"]?.stringValue) + return "• \(detail ?? activity.summary)" + } + ) + return FeatureMessage( + id: "work-log-\(last.turnId ?? last.id)", + role: .tool, + text: lines.joined(separator: "\n"), + createdAt: parseDate(last.createdAt), + state: .complete, + toolName: "Work log · \(group.count)" + ) + } + } + private func pendingApprovals(_ thread: OrchestrationThread) -> [FeatureApproval] { var open: [String: FeatureApproval] = [:] for activity in thread.activities.sorted(by: { @@ -1128,6 +1702,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { isAvailable: provider.enabled && provider.installed && provider.status != "disabled" + && provider.status != "error" && provider.auth.status != "unauthenticated" && provider.availability != "unavailable", driver: provider.driver, @@ -1210,6 +1785,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { ) } + 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): @@ -1280,6 +1863,157 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { } } + 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 + } + + private func scheduleAttachmentHydration( + in detail: FeatureThreadDetail, + threadID: String, + client: T3Client + ) { + guard let environmentID = activeEnvironment?.id else { return } + attachmentHydrationTasks[threadID]?.task.cancel() + let generation = environmentGeneration + let workID = UUID() + let task = Task { [weak self] in + guard let self else { return } + let hydrated = await self.hydratedAttachmentURLs( + in: detail, + client: client, + environmentID: environmentID, + generation: generation + ) + guard self.isCurrentSession(client: client, generation: generation), + self.latestDetails[threadID] == detail, + hydrated != detail else { + self.finishAttachmentHydration(threadID: threadID, workID: workID) + return + } + self.latestDetails[threadID] = hydrated + self.continuation.yield(.detail(hydrated)) + self.finishAttachmentHydration(threadID: threadID, workID: workID) + } + attachmentHydrationTasks[threadID] = (workID, task) + } + + private func finishAttachmentHydration(threadID: String, workID: UUID) { + guard attachmentHydrationTasks[threadID]?.id == workID else { return } + attachmentHydrationTasks[threadID] = nil + } + + private func hydratedAttachmentURLs( + in detail: FeatureThreadDetail, + client: T3Client, + environmentID: String, + generation: Int + ) async -> FeatureThreadDetail { + guard isCurrentSession(client: client, generation: generation) else { + return detail + } + let imageIDs = Set( + detail.messages.flatMap(\.attachments) + .filter { $0.mimeType.hasPrefix("image/") } + .map(\.id) + ) + let missingIDs = Array(imageIDs.filter { + cachedAttachmentURL(for: $0, environmentID: environmentID) == nil + }) + + await withTaskGroup(of: (String, ResolvedAssetURL?).self) { group in + var iterator = missingIDs.makeIterator() + for _ in 0.. 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 makeUploadAttachments( _ attachments: [FeatureUploadAttachment] ) throws -> [UploadChatImageAttachment] { @@ -1313,6 +2047,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { return "\(compact.prefix(69).trimmingCharacters(in: .whitespacesAndNewlines))..." } + private func previewText(_ text: String?) -> String? { + guard let text else { return nil } + let compact = text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard !compact.isEmpty else { return nil } + return compact.count > 160 ? "\(compact.prefix(157))..." : compact + } + private func loadSettings() -> FeatureSettings { guard let data = settingsStore.data(forKey: Self.settingsKey), let settings = try? JSONDecoder().decode(FeatureSettings.self, from: data) else { @@ -1322,9 +2063,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { } private func parseDate(_ value: String) -> Date { + parseValidDate(value) ?? .distantPast + } + + private func parseValidDate(_ value: String) -> Date? { Self.fractionalDateFormatter.date(from: value) ?? Self.dateFormatter.date(from: value) - ?? .distantPast } private static let settingsKey = "swift-ios.feature-settings.v1" @@ -1336,6 +2080,60 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging { private static let dateFormatter = ISO8601DateFormatter() } +private struct AttachmentCacheKey: Hashable { + let environmentID: String + let attachmentID: String +} + +private struct CachedAttachmentURL { + let url: URL + let expiresAt: Date +} + +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 attachments: [FeatureUploadAttachment] +} + +private struct PendingBootstrapSubmission { + let signature: BootstrapSubmissionSignature + let threadID: String + let identity: CommandIdentity +} + +private struct TurnSubmissionSignature: Equatable { + let text: String + let model: ModelSelection? + let runtimeMode: RuntimeMode + let interactionMode: InteractionMode + let attachments: [FeatureUploadAttachment] +} + +private struct PendingTurnSubmission { + let signature: TurnSubmissionSignature + let identity: CommandIdentity +} + private enum NativeFeatureClientError: LocalizedError { case notConnected case threadNotFound diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift index 6ca34516886..f0793072841 100644 --- a/apps/swift-ios/Core/Attachments.swift +++ b/apps/swift-ios/Core/Attachments.swift @@ -100,3 +100,8 @@ public struct AssetCreateURLResult: Codable, Equatable, Sendable { /// Unix epoch milliseconds from the server contract. public let expiresAt: Double } + +public struct ResolvedAssetURL: Equatable, Sendable { + public let url: URL + public let expiresAt: Date +} diff --git a/apps/swift-ios/Core/PairingService.swift b/apps/swift-ios/Core/PairingService.swift index 906954663f2..76b37b14cc6 100644 --- a/apps/swift-ios/Core/PairingService.swift +++ b/apps/swift-ios/Core/PairingService.swift @@ -17,20 +17,6 @@ public struct TokenExchangeResult: Decodable, Sendable { } public actor PairingService { - public static let standardScopes = [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - // Startup pairing credentials carry administrative access. Requesting - // these scopes preserves that access for native multi-device - // management; ordinary pairing links remain bounded by the scopes the - // server granted them. - "access:read", - "access:write", - "relay:read", - ] - private let transport: any HTTPTransport private let environmentStore: EnvironmentStore private let credentialStore: any CredentialStore @@ -124,7 +110,6 @@ public actor PairingService { name: "requested_token_type", value: "urn:ietf:params:oauth:token-type:access_token" ), - URLQueryItem(name: "scope", value: Self.standardScopes.joined(separator: " ")), URLQueryItem(name: "client_device_type", value: "mobile"), URLQueryItem(name: "client_os", value: "iOS"), ] diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index 03dd1df7f55..ab2c77a7e57 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -9,12 +9,16 @@ public actor T3Client { environment: Environment, credentialStore: any CredentialStore, httpTransport: any HTTPTransport = URLSessionHTTPTransport(), - webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector() + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector(), + rpcConnectionWaitTimeout: Duration = .seconds(4) ) { self.environment = environment let api = EnvironmentAPI(transport: httpTransport, credentials: credentialStore) self.api = api - self.rpc = WebSocketRPCClient(connector: webSocketConnector) { + self.rpc = WebSocketRPCClient( + connector: webSocketConnector, + connectionWaitTimeout: rpcConnectionWaitTimeout + ) { let ticket = try await api.webSocketTicket(for: environment) var components = URLComponents( url: environment.webSocketBaseURL, @@ -40,6 +44,10 @@ public actor T3Client { await rpc.stop() } + public func liveConnectionActive() async -> Bool { + await rpc.isConnected() + } + public func shellSnapshot() async throws -> OrchestrationShellSnapshot { try await api.shellSnapshot(for: environment) } @@ -48,6 +56,13 @@ public actor T3Client { 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) async throws -> OrchestrationThreadDetailSnapshot { try await api.threadSnapshot(id: id, environment: environment) } @@ -145,7 +160,16 @@ public actor T3Client { @discardableResult public func dispatch(_ command: JSONValue) async throws -> DispatchResult { - try await api.dispatch(command, environment: environment) + 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 @@ -155,7 +179,10 @@ public actor T3Client { runtimeMode: RuntimeMode, interactionMode: InteractionMode = .default, model: ModelSelection? = nil, - attachments: [UploadChatImageAttachment] = [] + attachments: [UploadChatImageAttachment] = [], + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() ) async throws -> DispatchResult { try await dispatch( try OrchestrationCommands.sendTurn( @@ -164,7 +191,10 @@ public actor T3Client { runtimeMode: runtimeMode, interactionMode: interactionMode, model: model, - attachments: attachments + attachments: attachments, + commandID: commandID, + messageID: messageID, + createdAt: createdAt ) ) } @@ -194,8 +224,8 @@ public actor T3Client { ) } - /// Atomically creates a thread and starts its first turn. This is the - /// server-supported path for message-first thread creation. + /// Creates a thread and starts its first turn through the server-supported + /// message-first bootstrap path. @discardableResult public func createThreadAndSend( threadID: String = UUID().uuidString, @@ -207,9 +237,12 @@ public actor T3Client { interactionMode: InteractionMode = .default, branch: String? = nil, worktreePath: String? = nil, - attachments: [UploadChatImageAttachment] = [] + attachments: [UploadChatImageAttachment] = [], + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() ) async throws -> DispatchResult { - try await dispatch( + try await dispatchOverWebSocket( try OrchestrationCommands.createThreadAndSend( threadID: threadID, projectID: projectID, @@ -220,11 +253,22 @@ public actor T3Client { interactionMode: interactionMode, branch: branch, worktreePath: worktreePath, - attachments: attachments + attachments: attachments, + commandID: commandID, + messageID: messageID, + createdAt: createdAt ) ) } + private func dispatchOverWebSocket(_ command: JSONValue) async throws -> DispatchResult { + try await rpc.request( + RPCMethod.dispatchCommand.rawValue, + payload: command, + as: DispatchResult.self + ) + } + @discardableResult public func createProject( projectID: String = UUID().uuidString, @@ -408,6 +452,10 @@ public actor T3Client { } 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, @@ -415,7 +463,10 @@ public actor T3Client { )?.absoluteURL else { throw RPCError.protocolViolation("The server returned an invalid asset URL.") } - return url + return ResolvedAssetURL( + url: url, + expiresAt: Date(timeIntervalSince1970: result.expiresAt / 1_000) + ) } // MARK: VCS and source control @@ -881,12 +932,12 @@ public actor EnvironmentRuntime { throw RPCError.remote("Environment \(id) is not saved.") } try await environmentStore.setActiveEnvironment(id: id) - return client(for: environment) + return await client(for: environment) } public func activeClient() async throws -> T3Client? { guard let environment = try await activeEnvironment() else { return nil } - return client(for: environment) + return await client(for: environment) } @discardableResult @@ -898,7 +949,7 @@ public actor EnvironmentRuntime { ) let environment = try await service.pair(url: url, label: clientLabel) try await environmentStore.setActiveEnvironment(id: environment.id) - return client(for: environment) + return await client(for: environment) } @discardableResult @@ -914,7 +965,7 @@ public actor EnvironmentRuntime { ) let environment = try await service.pair(host: host, code: code, label: clientLabel) try await environmentStore.setActiveEnvironment(id: environment.id) - return client(for: environment) + return await client(for: environment) } public func remove(id: String) async throws { @@ -925,8 +976,14 @@ public actor EnvironmentRuntime { try await environmentStore.remove(id: id) } - private func client(for environment: Environment) -> T3Client { - if let existing = clients[environment.id] { return existing } + private func client(for environment: Environment) async -> T3Client { + if let existing = clients[environment.id] { + if existing.environment == environment { + return existing + } + clients.removeValue(forKey: environment.id) + await existing.disconnect() + } let client = T3Client( environment: environment, credentialStore: credentialStore, @@ -942,6 +999,7 @@ public enum RPCMethod: String, Sendable { case serverProbe = "server.probe" case serverGetConfig = "server.getConfig" case dispatchCommand = "orchestration.dispatchCommand" + case getArchivedShellSnapshot = "orchestration.getArchivedShellSnapshot" case subscribeShell = "orchestration.subscribeShell" case subscribeThread = "orchestration.subscribeThread" case projectsListEntries = "projects.listEntries" @@ -1097,6 +1155,8 @@ public enum OrchestrationCommands { "text": .string(text), "attachments": .array(attachments.map(\.jsonValue)), ]), + "modelSelection": try .encode(model), + "titleSeed": .string(title), "runtimeMode": .string(runtimeMode.rawValue), "interactionMode": .string(interactionMode.rawValue), "bootstrap": .object(["createThread": .object(create)]), diff --git a/apps/swift-ios/Core/WebSocketRPC.swift b/apps/swift-ios/Core/WebSocketRPC.swift index 075223471e1..99f9e5a772d 100644 --- a/apps/swift-ios/Core/WebSocketRPC.swift +++ b/apps/swift-ios/Core/WebSocketRPC.swift @@ -90,12 +90,15 @@ private actor URLSessionWebSocketConnection: WebSocketConnection { } public enum RPCError: LocalizedError, Sendable { + case connectionUnavailable case disconnected 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 let .remote(message): message case let .protocolViolation(message): message @@ -164,6 +167,7 @@ public actor WebSocketRPCClient { private let connector: any WebSocketConnecting private let endpointProvider: EndpointProvider + private let connectionWaitTimeout: Duration private var connection: (any WebSocketConnection)? private var loopTask: Task? private var keepaliveTask: Task? @@ -175,9 +179,11 @@ public actor WebSocketRPCClient { public init( connector: any WebSocketConnecting = URLSessionWebSocketConnector(), + connectionWaitTimeout: Duration = .seconds(4), endpointProvider: @escaping EndpointProvider ) { self.connector = connector + self.connectionWaitTimeout = connectionWaitTimeout self.endpointProvider = endpointProvider } @@ -192,6 +198,10 @@ public actor WebSocketRPCClient { loopTask = Task { await self.connectionLoop() } } + public func isConnected() -> Bool { + connection != nil + } + public func stop() async { desired = false loopTask?.cancel() @@ -281,6 +291,15 @@ public actor WebSocketRPCClient { if connection != nil { Task { await self.sendUnary(id) } } + let timeout = connectionWaitTimeout + Task { [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return + } + await self?.failUnaryIfUnsent(id) + } } } @@ -356,8 +375,7 @@ public actor WebSocketRPCClient { try await sendControl("Ack", requestID: requestID) case "Exit": guard let requestID = response.requestId, let exit = response.exit else { return } - if var request = unary.removeValue(forKey: requestID) { - request.sent = false + if let request = unary.removeValue(forKey: requestID) { if exit._tag == "Success" { request.resume(.success(exit.value ?? .null)) } else { @@ -380,13 +398,18 @@ public actor WebSocketRPCClient { private func sendUnary(_ id: Int) async { guard let connection, var request = unary[id], !request.sent else { return } + // Actor methods are reentrant at the send below. Record that this + // command crossed the socket boundary first so a concurrent + // disconnect fails it instead of replaying an ambiguous mutation. + request.sent = true + unary[id] = request do { try await connection.send(JSONEncoder.t3.encode(request.envelope)) - request.sent = true - unary[id] = request } catch { - request.resume(.failure(error)) - unary.removeValue(forKey: id) + // A response, disconnect, or stop may have completed the request + // while send was suspended. Only its current owner may resume it. + guard let failed = unary.removeValue(forKey: id) else { return } + failed.resume(.failure(error)) } } @@ -441,6 +464,12 @@ public actor WebSocketRPCClient { } } + private func failUnaryIfUnsent(_ id: Int) { + guard let request = unary[id], !request.sent else { return } + unary.removeValue(forKey: id) + request.resume(.failure(RPCError.connectionUnavailable)) + } + private func remoteError(_ exit: RPCResponseEnvelope.Exit) -> RPCError { let value = exit.cause?.first?.error let message = value?["message"]?.stringValue diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 6cac31d62a6..1cecec62834 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -47,6 +47,9 @@ struct FeatureComposerView: View { .padding(.trailing, 7) .padding(.bottom, 7) .accessibilityLabel(isWorking && textIsEmpty ? "Stop agent" : "Send") + .accessibilityIdentifier( + isWorking && textIsEmpty ? "thread-stop" : "message-send" + ) } .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 18)) .overlay { diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift index f60ab53c87a..9f664988792 100644 --- a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -1,3 +1,4 @@ +import ImageIO import PhotosUI import SwiftUI import UniformTypeIdentifiers @@ -37,6 +38,7 @@ struct FeatureImageAttachmentPicker: View { .disabled(!isEnabled || isLoading || attachments.count >= maximumCount) .opacity(isEnabled ? 1 : 0.3) .accessibilityLabel(isLoading ? "Adding images" : "Add images") + .accessibilityIdentifier("image-attachment-picker") .accessibilityHint(isEnabled ? "" : "The selected model does not accept images") .onChange(of: selection) { loadSelection() @@ -67,12 +69,13 @@ struct FeatureImageAttachmentPicker: View { guard let data = try await item.loadTransferable(type: Data.self) else { continue } - let type = item.supportedContentTypes.first ?? .jpeg - let attachment = try FeatureImageProcessor.attachment( - from: data, - sourceType: type, - ordinal: attachments.count + 1 - ) + let ordinal = attachments.count + 1 + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment( + from: data, + ordinal: ordinal + ) + }.value attachments.append(attachment) } catch { errorMessage = error.localizedDescription @@ -107,11 +110,12 @@ struct FeatureAttachmentStrip: View { 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 = UIImage(data: attachment.data) { + if let image { Image(uiImage: image) .resizable() .scaledToFill() @@ -130,12 +134,23 @@ private struct FeatureAttachmentThumbnail: View { .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: 5, y: -5) + .offset(x: 11, y: -11) .accessibilityLabel("Remove \(attachment.filename)") } - .padding(.top, 5) - .padding(.trailing, 5) + .padding(.top, 11) + .padding(.trailing, 11) + .task(id: attachment.id) { + let data = attachment.thumbnailData ?? attachment.data + image = await Task.detached(priority: .utility) { + UIImage(data: data) + }.value + } } } @@ -145,15 +160,25 @@ enum FeatureImageProcessor { static func attachment( from sourceData: Data, - sourceType _: UTType, ordinal: Int ) throws -> FeatureDraftAttachment { - guard let sourceImage = UIImage(data: sourceData) else { + 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 image = resized(sourceImage) - guard let data = image.jpegData(compressionQuality: 0.82) else { + 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 { @@ -162,20 +187,25 @@ enum FeatureImageProcessor { return FeatureDraftAttachment( data: data, + thumbnailData: thumbnailData, filename: "Image \(ordinal).jpg", mimeType: "image/jpeg" ) } - private static func resized(_ image: UIImage) -> UIImage { + private static func thumbnail(from image: UIImage) -> Data? { let longestSide = max(image.size.width, image.size.height) - guard longestSide > maximumDimension else { return image } - let scale = maximumDimension / longestSide - let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) - let renderer = UIGraphicsImageRenderer(size: size) + 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) } } diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 3026ba373d9..b0f0ced7515 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -124,7 +124,7 @@ public struct ThreadDetailView: View { } .task(id: thread.id) { isLoading = true - _ = await model.detail(for: thread.id) + _ = await model.detail(for: thread.id, force: true) selection = currentSelection isLoading = false } @@ -233,9 +233,12 @@ public struct ThreadDetailView: View { .padding(.top, 14) .padding(.bottom, 10) } - .defaultScrollAnchor(.bottom) .scrollDismissesKeyboard(.interactively) - .onChange(of: detail.messages.count) { + .task(id: thread.id) { + await Task.yield() + proxy.scrollTo("timeline-bottom", anchor: .bottom) + } + .onChange(of: detail.messages.last?.id) { withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo("timeline-bottom", anchor: .bottom) } @@ -279,8 +282,17 @@ public struct ThreadDetailView: View { ) ) if !sent { - draft = message - attachments = pendingAttachments + let currentDraft = draft + let restoredMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + if currentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft = message + } else if !restoredMessage.isEmpty { + draft = "\(message)\n\(currentDraft)" + } + let pendingIDs = Set(pendingAttachments.map(\.id)) + attachments = pendingAttachments + attachments.filter { + !pendingIDs.contains($0.id) + } sendFailed = true } isSending = false @@ -414,7 +426,8 @@ struct FeatureMessageView: View { .background(Color.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 15)) } .accessibilityLabel("You") - .accessibilityValue(message.text) + .accessibilityValue(accessibilityValue) + .accessibilityIdentifier("message-\(message.id)") case .assistant: VStack(alignment: .leading, spacing: 7) { HStack(spacing: 6) { @@ -431,6 +444,7 @@ struct FeatureMessageView: View { } } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("message-\(message.id)") case .tool: DisclosureGroup { Text(message.text) @@ -444,17 +458,30 @@ struct FeatureMessageView: View { } .padding(11) .background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("message-\(message.id)") case .system: Text(message.text) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) + .accessibilityIdentifier("message-\(message.id)") } } + + 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 FeatureMessageAttachmentsView: View { let attachments: [FeatureMessageAttachment] + @State private var previewedAttachment: FeatureMessageAttachment? var body: some View { if !attachments.isEmpty { @@ -466,19 +493,62 @@ private struct FeatureMessageAttachmentsView: View { spacing: 7 ) { ForEach(attachments) { attachment in - HStack(spacing: 9) { - Image(systemName: attachment.mimeType.hasPrefix("image/") ? "photo" : "doc") + VStack(alignment: .leading, spacing: 6) { + if attachment.mimeType.hasPrefix("image/") { + Group { + if let url = attachment.url { + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFit() + case .failure: + attachmentPlaceholder( + systemImage: "exclamationmark.triangle" + ) + case .empty: + attachmentPlaceholder(systemImage: "photo") + @unknown default: + attachmentPlaceholder(systemImage: "photo") + } + } + } else { + attachmentPlaceholder(systemImage: "photo") + } + } + .frame(height: 160) + .frame(maxWidth: .infinity) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + HStack(spacing: 9) { + Image( + systemName: attachment.mimeType.hasPrefix("image/") + ? "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(.caption.weight(.medium)) - .lineLimit(1) - Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file)) + .background( + T3Colors.surfaceRaised, + in: RoundedRectangle(cornerRadius: 6) + ) + VStack(alignment: .leading, spacing: 1) { + Text(attachment.name) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text( + ByteCountFormatter.string( + fromByteCount: Int64(attachment.sizeBytes), + countStyle: .file + ) + ) .font(.caption2.monospacedDigit()) .foregroundStyle(T3Colors.textSecondary) + } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -488,9 +558,99 @@ private struct FeatureMessageAttachmentsView: View { .stroke(T3Colors.border, lineWidth: 1) } .accessibilityElement(children: .combine) + .accessibilityLabel( + attachment.mimeType.hasPrefix("image/") + ? "Image attachment" + : "File attachment" + ) + .accessibilityValue(attachmentAccessibilityValue(attachment)) + .accessibilityIdentifier("attachment-\(attachment.id)") + .accessibilityAddTraits( + attachment.mimeType.hasPrefix("image/") && attachment.url != nil + ? .isButton + : [] + ) + .accessibilityHint( + attachment.mimeType.hasPrefix("image/") && attachment.url != nil + ? "Opens full-screen preview" + : "" + ) + .accessibilityAction { + if attachment.mimeType.hasPrefix("image/"), attachment.url != nil { + previewedAttachment = attachment + } + } + .contentShape(Rectangle()) + .onTapGesture { + if attachment.mimeType.hasPrefix("image/"), attachment.url != nil { + previewedAttachment = attachment + } + } + } + } + .fullScreenCover(item: $previewedAttachment) { attachment in + FeatureAttachmentPreview(attachment: attachment) + } + } + } + + private func attachmentPlaceholder(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func attachmentAccessibilityValue( + _ attachment: FeatureMessageAttachment + ) -> String { + let size = ByteCountFormatter.string( + fromByteCount: Int64(attachment.sizeBytes), + countStyle: .file + ) + return "\(attachment.name), \(size)" + } +} + +private struct FeatureAttachmentPreview: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let attachment: FeatureMessageAttachment + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + if let url = attachment.url { + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFit() + case .failure: + ContentUnavailableView( + "Image unavailable", + systemImage: "exclamationmark.triangle" + ) + case .empty: + ProgressView() + @unknown default: + ProgressView() + } + } + .padding(12) } } + .navigationTitle(attachment.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .t3NavigationChrome() } + .preferredColorScheme(.dark) } } @@ -510,7 +670,7 @@ struct StreamingStatusView: View { .font(.footnote.weight(.semibold)) } .padding(.vertical, 6) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) } } diff --git a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift index 20780e3fa34..315802383e8 100644 --- a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -16,6 +16,8 @@ public struct ConnectionOnboardingView: View { @State private var showsPermissionAction = false @State private var showingScanner = false @State private var entryHeading = "Connect manually" + @State private var connectionTask: Task? + @State private var connectionAttemptID: UUID? @FocusState private var focusedField: ConnectionField? public init( @@ -65,6 +67,16 @@ public struct ConnectionOnboardingView: View { ToolbarItem(placement: .cancellationAction) { Button("Close", action: onCancel) } + } else if stage == .checking { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + connectionTask?.cancel() + connectionTask = nil + connectionAttemptID = nil + model.errorMessage = nil + stage = .details + } + } } } .fullScreenCover(isPresented: $showingScanner) { @@ -95,6 +107,12 @@ public struct ConnectionOnboardingView: View { errorMessage = nil } } + .interactiveDismissDisabled(stage == .connecting) + .onDisappear { + connectionTask?.cancel() + connectionTask = nil + connectionAttemptID = nil + } } private var welcomeView: some View { @@ -509,13 +527,17 @@ public struct ConnectionOnboardingView: View { @MainActor private func connect(_ action: ConnectionAction) { + connectionTask?.cancel() + let attemptID = UUID() + connectionAttemptID = attemptID endpoint = action.endpoint errorMessage = nil showsPermissionAction = false stage = .checking - Task { + connectionTask = Task { let readiness = await readinessChecker.check(endpoint: action.endpoint) + guard !Task.isCancelled, connectionAttemptID == attemptID else { return } switch readiness { case .ready: stage = .connecting @@ -539,14 +561,19 @@ public struct ConnectionOnboardingView: View { await model.activateEnvironment(id) didConnect = model.snapshot.connection.state == .connected } + 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 = .details } } diff --git a/apps/swift-ios/Features/Devices/DevicesView.swift b/apps/swift-ios/Features/Devices/DevicesView.swift index 9439296b234..350be5797a4 100644 --- a/apps/swift-ios/Features/Devices/DevicesView.swift +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -23,6 +23,17 @@ public struct DevicesView: View { .font(.footnote) .foregroundStyle(.secondary) } + } 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") diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index a474240ad92..5754a85375a 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -20,7 +20,9 @@ public final class FeatureRootModel { do { snapshot = try await client.initialSnapshot() } catch { - errorMessage = error.localizedDescription + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } } isLoading = false @@ -33,13 +35,16 @@ public final class FeatureRootModel { do { snapshot = try await client.initialSnapshot() } catch { - errorMessage = error.localizedDescription + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } } } public func pair(endpoint: String, token: String?) async -> Bool { await perform { try await client.pair(endpoint: endpoint, token: token) + details.removeAll() snapshot = try await client.initialSnapshot() } } @@ -78,6 +83,7 @@ public final class FeatureRootModel { title: String?, selection: FeatureSelection? ) async -> FeatureThread? { + let environment = currentEnvironmentIdentity var created: FeatureThread? let succeeded = await perform { let thread = try await client.createThread( @@ -85,6 +91,9 @@ public final class FeatureRootModel { title: title, selection: selection ) + guard currentEnvironmentIdentity == environment else { + throw CancellationError() + } upsert(thread) created = thread } @@ -95,8 +104,9 @@ public final class FeatureRootModel { let prompt = request.trimmedPrompt guard !prompt.isEmpty || !request.attachments.isEmpty else { return nil } + let environment = currentEnvironmentIdentity var created: FeatureThread? - let succeeded = await perform { + let succeeded = await perform(reportError: false) { let thread = try await client.createThreadAndSend( projectID: request.projectID, prompt: prompt, @@ -105,6 +115,9 @@ public final class FeatureRootModel { interactionMode: request.interactionMode, attachments: request.attachments.map(\.upload) ) + guard currentEnvironmentIdentity == environment else { + throw CancellationError() + } upsert(thread) created = thread } @@ -112,8 +125,10 @@ public final class FeatureRootModel { } 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 } guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } snapshot.threads[index].title = title details[id]?.thread.title = title @@ -121,8 +136,10 @@ public final class FeatureRootModel { } public func setArchived(_ id: String, archived: Bool) async { + let environment = currentEnvironmentIdentity await perform { try await client.setThreadArchived(id: id, archived: archived) + guard currentEnvironmentIdentity == environment else { return } guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } snapshot.threads[index].isArchived = archived details[id]?.thread.isArchived = archived @@ -130,26 +147,39 @@ public final class FeatureRootModel { } public func setSettled(_ id: String, settled: Bool) async { + let environment = currentEnvironmentIdentity await perform { try await client.setThreadSettled(id: id, settled: settled) + guard currentEnvironmentIdentity == environment else { return } guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } snapshot.threads[index].isSettled = settled + snapshot.threads[index].keepsActive = !settled + snapshot.threads[index].settledAt = settled ? .now : nil details[id]?.thread.isSettled = settled + details[id]?.thread.keepsActive = !settled + details[id]?.thread.settledAt = settled ? .now : nil } } 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 } guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + let snoozedAt = until.map { _ in Date.now } snapshot.threads[index].snoozedUntil = until + snapshot.threads[index].snoozedAt = snoozedAt details[id]?.thread.snoozedUntil = until + details[id]?.thread.snoozedAt = snoozedAt } } public func setRuntimeMode(_ id: String, mode: FeatureRuntimeMode) async { + let environment = currentEnvironmentIdentity await perform { try await client.setRuntimeMode(id: id, mode: mode) + guard currentEnvironmentIdentity == environment else { return } guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } snapshot.threads[index].runtimeMode = mode details[id]?.thread.runtimeMode = mode @@ -157,8 +187,10 @@ public final class FeatureRootModel { } public func setInteractionMode(_ id: String, mode: FeatureInteractionMode) async { + let environment = currentEnvironmentIdentity await perform { try await client.setInteractionMode(id: id, mode: mode) + guard currentEnvironmentIdentity == environment else { return } guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } snapshot.threads[index].interactionMode = mode details[id]?.thread.interactionMode = mode @@ -166,8 +198,10 @@ public final class FeatureRootModel { } public func deleteThread(_ id: String) async { + let environment = currentEnvironmentIdentity await perform { try await client.deleteThread(id: id) + guard currentEnvironmentIdentity == environment else { return } snapshot.threads.removeAll { $0.id == id } details[id] = nil } @@ -177,14 +211,20 @@ public final class FeatureRootModel { if !force, let cached = details[id] { return cached } + let environment = currentEnvironmentIdentity do { let detail = try await client.loadThread(id: id) + guard currentEnvironmentIdentity == environment else { + return details[id] + } details[id] = detail upsert(detail.thread) return detail } catch { - errorMessage = error.localizedDescription - return nil + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return details[id] } } @@ -202,6 +242,7 @@ public final class FeatureRootModel { let trimmed = submission.text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty || !submission.attachments.isEmpty else { return false } + let environment = currentEnvironmentIdentity let optimisticID = "local-\(UUID().uuidString)" let optimistic = FeatureMessage( id: optimisticID, @@ -219,7 +260,7 @@ public final class FeatureRootModel { ) details[submission.threadID]?.messages.append(optimistic) - let sent = await perform { + let sent = await perform(reportError: false) { try await client.sendMessage( threadID: submission.threadID, text: trimmed, @@ -228,6 +269,7 @@ public final class FeatureRootModel { ) } if !sent { + guard currentEnvironmentIdentity == environment else { return false } details[submission.threadID]?.messages.removeAll { $0.id == optimisticID } } return sent @@ -240,8 +282,10 @@ public final class FeatureRootModel { } public func resolveApproval(_ id: String, decision: FeatureApprovalDecision) async { + let environment = currentEnvironmentIdentity await perform { try await client.resolveApproval(id: id, decision: decision) + guard currentEnvironmentIdentity == environment else { return } for key in details.keys { details[key]?.approvals.removeAll { $0.id == id } } @@ -249,8 +293,10 @@ public final class FeatureRootModel { } public func resolveUserInput(_ id: String, answers: [String: String]) async { + let environment = currentEnvironmentIdentity await perform { try await client.resolveUserInput(id: id, answers: answers) + guard currentEnvironmentIdentity == environment else { return } for key in details.keys { details[key]?.userInputs.removeAll { $0.id == id } } @@ -265,14 +311,17 @@ public final class FeatureRootModel { } @discardableResult - private func perform(_ operation: () async throws -> Void) async -> Bool { + 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 !Self.isBenignCancellation(error) { + if reportError, !Self.isBenignCancellation(error) { errorMessage = error.localizedDescription } return false @@ -287,6 +336,17 @@ public final class FeatureRootModel { return message == "cancelled" || message == "canceled" } + private var currentEnvironmentIdentity: String { + let active = snapshot.environments.first(where: \.isActive) + return [ + active?.id, + active?.endpoint, + snapshot.connection.endpoint, + ] + .compactMap { $0 } + .joined(separator: "|") + } + private func apply(_ event: FeatureEvent) { switch event { case let .snapshot(value): diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift index 48adc2bbfe9..70896804c35 100644 --- a/apps/swift-ios/Features/Root/FeatureRootView.swift +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -11,7 +11,7 @@ public struct FeatureRootView: View { Group { if model.isLoading { FeatureLoadingView() - } else if model.snapshot.connection.state == .connected { + } else if model.snapshot.connection.state != .disconnected { WorkspaceView( model: model, submitNewTask: { request in diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index 8c5db5c2ccf..1a800d92fae 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -46,13 +46,22 @@ public struct FeatureProject: Identifiable, Sendable, Equatable, Hashable, Codab public var name: String public var path: String public var threadCount: Int + public var defaultSelection: FeatureSelection? - public init(id: String, environmentID: String, name: String, path: String, threadCount: Int = 0) { + public init( + id: String, + environmentID: String, + name: String, + path: String, + threadCount: Int = 0, + defaultSelection: FeatureSelection? = nil + ) { self.id = id self.environmentID = environmentID self.name = name self.path = path self.threadCount = threadCount + self.defaultSelection = defaultSelection } } @@ -91,7 +100,13 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var modelOptions: [FeatureModelOptionSelection] public var isArchived: Bool public var isSettled: Bool + public var keepsActive: Bool + public var settledAt: Date? + public var lastActivityAt: Date? public var snoozedUntil: Date? + public var snoozedAt: Date? + public var attentionAt: Date? + public var latestTurnCompletedAt: Date? public var runtimeMode: FeatureRuntimeMode public var interactionMode: FeatureInteractionMode @@ -108,7 +123,13 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl modelOptions: [FeatureModelOptionSelection] = [], isArchived: Bool = false, isSettled: Bool = false, + keepsActive: Bool = false, + settledAt: Date? = nil, + lastActivityAt: Date? = nil, snoozedUntil: Date? = nil, + snoozedAt: Date? = nil, + attentionAt: Date? = nil, + latestTurnCompletedAt: Date? = nil, runtimeMode: FeatureRuntimeMode = .fullAccess, interactionMode: FeatureInteractionMode = .standard ) { @@ -124,7 +145,13 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.modelOptions = modelOptions self.isArchived = isArchived self.isSettled = isSettled + self.keepsActive = keepsActive + self.settledAt = settledAt + self.lastActivityAt = lastActivityAt self.snoozedUntil = snoozedUntil + self.snoozedAt = snoozedAt + self.attentionAt = attentionAt + self.latestTurnCompletedAt = latestTurnCompletedAt self.runtimeMode = runtimeMode self.interactionMode = interactionMode } diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 279c55dc384..bd3350825c0 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -3,17 +3,20 @@ import Foundation public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { public let id: UUID public var data: Data + public var thumbnailData: Data? public var filename: String public var mimeType: String public init( id: UUID = UUID(), data: Data, + thumbnailData: Data? = nil, filename: String, mimeType: String ) { self.id = id self.data = data + self.thumbnailData = thumbnailData self.filename = filename self.mimeType = mimeType } @@ -73,6 +76,7 @@ public struct FeatureMessageSubmission: Sendable, Equatable { struct DailyUXSidebarIndex { let active: [FeatureThread] + let snoozed: [FeatureThread] let settled: [FeatureThread] let searchResults: [FeatureThread] @@ -85,11 +89,11 @@ struct DailyUXSidebarIndex { let projectByID = Dictionary(uniqueKeysWithValues: snapshot.projects.map { ($0.id, $0) }) let visible = snapshot.threads.filter { thread in guard !thread.isArchived else { return false } - guard thread.snoozedUntil.map({ $0 > now }) != true else { return false } return projectID == nil || thread.projectID == projectID } + let available = visible.filter { !$0.isEffectivelySnoozed(at: now) } - active = visible + active = available .filter { !$0.isEffectivelySettled(at: now) } .sorted { lhs, rhs in if lhs.createdAt != rhs.createdAt { @@ -98,11 +102,22 @@ struct DailyUXSidebarIndex { return lhs.id < rhs.id } - settled = visible + 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.isEffectivelySettled(at: now) } .sorted { lhs, rhs in - if lhs.updatedAt != rhs.updatedAt { - return lhs.updatedAt > rhs.updatedAt + if lhs.settledSortDate != rhs.settledSortDate { + return lhs.settledSortDate > rhs.settledSortDate } return lhs.id < rhs.id } @@ -113,7 +128,7 @@ struct DailyUXSidebarIndex { return } - searchResults = (active + settled).filter { thread in + searchResults = (active + snoozed + settled).filter { thread in let project = projectByID[thread.projectID] return [ thread.title, @@ -132,15 +147,44 @@ extension FeatureThread { func isEffectivelySettled(at now: Date) -> Bool { switch state { - case .queued, .working, .waitingForApproval, .waitingForInput, .failed: + case .queued, .working, .waitingForApproval, .waitingForInput: return false - case .idle, .completed: + case .idle, .failed, .completed: break } if isSettled { return true } - return now.timeIntervalSince(updatedAt) >= 3 * 24 * 60 * 60 + if keepsActive { + return false + } + guard let lastActivityAt else { + return false + } + return now.timeIntervalSince(lastActivityAt) >= 3 * 24 * 60 * 60 + } + + 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 } } @@ -199,6 +243,16 @@ struct DailyUXModelCatalog { } enum DailyUXModelOptions { + 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] diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 1c87894b549..f687fdeb63d 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -98,6 +98,7 @@ public struct NewThreadView: View { .disabled(!canSubmit) .opacity(canSubmit ? 1 : 0.35) .accessibilityLabel(isSubmitting ? "Starting task" : "Start task") + .accessibilityIdentifier("new-task-submit") } } .padding(.horizontal, 12) @@ -123,14 +124,13 @@ public struct NewThreadView: View { projectID = model.snapshot.projects.first?.id ?? "" } if selection == nil { - selection = DailyUXModelOptions.validated( - model.snapshot.settings.defaultSelection, - in: model.snapshot.providers - ) - ?? DailyUXModelOptions.preferredSelection(in: model.snapshot.providers) + selection = initialSelection } promptFocused = true } + .onChange(of: projectID) { + selection = initialSelection + } .alert("Couldn’t start task", isPresented: $submissionFailed) { Button("OK") {} } message: { @@ -212,6 +212,14 @@ public struct NewThreadView: View { model.snapshot.projects.first { $0.id == projectID } } + private var initialSelection: FeatureSelection? { + DailyUXModelOptions.initialSelection( + projectDefault: selectedProject?.defaultSelection, + appDefault: model.snapshot.settings.defaultSelection, + providers: model.snapshot.providers + ) + } + private var canSubmit: Bool { !isSubmitting && !projectID.isEmpty diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index bdeb1473322..86a161bd065 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -15,6 +15,7 @@ public struct WorkspaceView: View { @State private var showingArchived = false @State private var renamingThread: FeatureThread? @State private var renameTitle = "" + @State private var sidebarClock = Date.now public init( model: FeatureRootModel, @@ -119,6 +120,17 @@ public struct WorkspaceView: View { self.selectedThreadID = nil } } + .task(id: sidebarBoundarySignature) { + while !Task.isCancelled { + sidebarClock = .now + let delay = nextSidebarRefreshDelay(from: sidebarClock) + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + } + } } private var sidebar: some View { @@ -129,6 +141,7 @@ public struct WorkspaceView: View { if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { projectFilter activeSection + snoozedSection settledSection archiveAction } else { @@ -296,13 +309,27 @@ public struct WorkspaceView: View { } label: { ProjectFilterRow( project: model.snapshot.projects.first { $0.id == selectedProjectID }, - totalCount: sidebarIndex.active.count + sidebarIndex.settled.count + totalCount: sidebarIndex.active.count + + sidebarIndex.snoozed.count + + sidebarIndex.settled.count ) } .buttonStyle(.plain) .listRowSeparator(.hidden) } + @ViewBuilder + private var snoozedSection: some View { + if !sidebarIndex.snoozed.isEmpty { + Section("Snoozed") { + ForEach(sidebarIndex.snoozed) { thread in + threadLink(thread, showsProject: selectedProjectID == nil) + .opacity(0.76) + } + } + } + } + @ViewBuilder private var activeSection: some View { Section("Active") { @@ -349,11 +376,23 @@ public struct WorkspaceView: View { Button { showingArchived = true } label: { - Label("Archived", systemImage: "archivebox") - .font(.subheadline) - .foregroundStyle(T3Colors.textSecondary) + HStack { + Label("Archived", systemImage: "archivebox") + .font(.subheadline) + .foregroundStyle(T3Colors.textSecondary) + Spacer() + } + .contentShape(Rectangle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Archived") + .accessibilityAddTraits(.isButton) + .accessibilityAction { + showingArchived = true + } } .buttonStyle(.plain) + .accessibilityLabel("Archived") + .accessibilityIdentifier("archived-threads-button") } @ViewBuilder @@ -403,13 +442,13 @@ public struct WorkspaceView: View { } label: { Label("Archive", systemImage: "archivebox") } - if thread.isSettled { + if thread.isEffectivelySettled(at: .now) { Button { Task { await model.setSettled(thread.id, settled: false) } } label: { Label("Reopen", systemImage: "arrow.counterclockwise") } - } else if !thread.isEffectivelySettled(at: .now) { + } else { Button { Task { await model.setSettled(thread.id, settled: true) } } label: { @@ -417,20 +456,25 @@ public struct WorkspaceView: View { } } Button { + let isSnoozed = thread.isEffectivelySnoozed(at: sidebarClock) Task { await model.setSnoozed( thread.id, - until: thread.snoozedUntil == nil - ? Date().addingTimeInterval(60 * 60) - : nil + until: isSnoozed ? nil : Date().addingTimeInterval(60 * 60) ) } } label: { + let isSnoozed = thread.isEffectivelySnoozed(at: sidebarClock) Label( - thread.snoozedUntil == nil ? "Snooze 1 hour" : "Unsnooze", - systemImage: thread.snoozedUntil == nil ? "clock" : "bell" + isSnoozed ? "Unsnooze" : "Snooze 1 hour", + systemImage: isSnoozed ? "bell" : "clock" ) } + .disabled( + thread.state == .queued + || thread.state == .waitingForApproval + || thread.state == .waitingForInput + ) Button(role: .destructive) { Task { await model.deleteThread(thread.id) } } label: { @@ -443,10 +487,34 @@ public struct WorkspaceView: View { DailyUXSidebarIndex( snapshot: model.snapshot, query: searchText, - projectID: selectedProjectID + projectID: selectedProjectID, + now: sidebarClock ) } + private var sidebarBoundarySignature: String { + model.snapshot.threads.map { + [ + $0.id, + $0.state.rawValue, + $0.lastActivityAt?.timeIntervalSince1970.description ?? "", + $0.snoozedUntil?.timeIntervalSince1970.description ?? "", + $0.snoozedAt?.timeIntervalSince1970.description ?? "", + $0.attentionAt?.timeIntervalSince1970.description ?? "", + ].joined(separator: ":") + } + .joined(separator: "|") + } + + private func nextSidebarRefreshDelay(from now: Date) -> TimeInterval { + let nextSnoozeBoundary = model.snapshot.threads + .compactMap(\.snoozedUntil) + .filter { $0 > now } + .min() + .map { $0.timeIntervalSince(now) } + return max(0.25, min(60, nextSnoozeBoundary ?? 60)) + } + private var connectionColor: Color { switch model.snapshot.connection.state { case .connected: T3Colors.success diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..3da005aaafdae2672f17ef2a0646548700514117 GIT binary patch literal 345819 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�X6IKp+-5lrY#Y@LmhJ=oj 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%0Dbb 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