diff --git a/FrameworkTests/AssistantConversationOperationsTests.swift b/FrameworkTests/AssistantConversationOperationsTests.swift index 3b7f52db..3189db83 100644 --- a/FrameworkTests/AssistantConversationOperationsTests.swift +++ b/FrameworkTests/AssistantConversationOperationsTests.swift @@ -35,6 +35,25 @@ struct AssistantConversationOperationsTests { #expect(restored.canEditUserMessage(restored.messages[0].id)) } + @Test func restoreMergesHistoryCreatedWhileTheRepositoryWasLoading() { + let store = AssistantConversationStore() + let live = ConversationModel(messages: [ChatMessage(role: .user, text: "Created while loading")]) + store.insert(live) + let persistedID = UUID() + let persistedMessage = AgentTranscriptMessage(role: .user, text: "Persisted history") + + store.restore([ + StoredAgentConversation( + id: persistedID, + messages: [persistedMessage], + lastRunState: .completed, + metadata: StoredAgentConversationMetadata() + ) + ]) + + #expect(store.conversations.map(\.id) == [persistedID, live.id]) + } + @Test func activeRunCannotBeArchivedOrReordered() async throws { let (repository, conversation, operations) = try await fixture() conversation.handle(.runState(.running)) diff --git a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift index 41ce47ee..3e71fcd4 100644 --- a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift +++ b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift @@ -71,8 +71,8 @@ public final class AssistantConversationStore { } public func restore(_ stored: [StoredAgentConversation]) { - guard conversations.isEmpty else { return } - conversations = stored.map { value in + let existingIDs = Set(conversations.map(\.id)) + let restored = stored.filter { !existingIDs.contains($0.id) }.map { value in let conversation = ConversationModel( id: value.id, messages: value.messages.compactMap { message in @@ -143,6 +143,7 @@ public final class AssistantConversationStore { } return conversation } + conversations = restored + conversations } } diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index ebb5c0b4..ae7b0140 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -4,7 +4,7 @@ public nonisolated enum SettingsKey { public static let appLanguage = "app.language" public static let defaultICloudSyncEnabled = true public static let defaultMetricsSyncEnabled = true - public static let defaultSettingsSyncEnabled = true + public static let defaultSettingsSyncEnabled = false public static let defaultTerminalThemeDark = "Termind Dark" public static let defaultTerminalThemeLight = "Termind Light" public static let defaultVaultSyncEnabled = true diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 80e82b44..61c906f5 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -282,8 +282,9 @@ and upgrades do not re-enable it. Pro gates three independent coordinators: V13 Settings entity names/defaults are frozen; V14 only adds `PersistentTerminalPreferences`; V15 adds the independent Metrics journal/account/ quarantine. Settings persistence atomically commits data and upload intent. Separate -records cover providers/models, MCP clients, skills, roles, model selection, Web -Search, and terminal preferences/custom text. Remote commits refresh models without +records cover providers/models, MCP clients, skills, roles, Web Search, and terminal +preferences/custom text. The most recently selected conversation model remains +device-local. Remote commits refresh models without re-entering local mutation. Active Agent runs keep their starting snapshot. Conversations, approval mode, local MCP listener settings, and unrelated defaults do @@ -330,8 +331,11 @@ tests/discovery use ephemeral snapshots, cancel on editor/config changes, and ca repopulate discarded drafts. Secrets remain in memory until atomic save and roll back on metadata failure. Mobile does not start the local MCP listener. -Each mobile scene owns one navigation/command model and publishes it as a focused -scene value, so hardware-keyboard commands never operate another iPad window. Mobile +Each mobile scene owns one navigation/command model and one Assistant library model, +including its selected conversation and New Chat draft. The app-wide Assistant runtime +and conversation store remain shared so active runs are never duplicated. The scene +model is published as a focused value, so hardware-keyboard commands never operate +another iPad window. Mobile uses the macOS shortcuts for capabilities that exist on both platforms: navigation, connection/creation, terminal Find and Command Generator, Workspace close/switch, and Assistant send/stop. Workspace switching never reconnects the session or claims diff --git a/TFI/App/MobileFeatureRegistry.swift b/TFI/App/MobileFeatureRegistry.swift index 2b7325a3..3ac6649b 100644 --- a/TFI/App/MobileFeatureRegistry.swift +++ b/TFI/App/MobileFeatureRegistry.swift @@ -140,7 +140,7 @@ struct MobileFeatureRegistry { AnyView( MobileAssistantView( app: app, - model: app.assistant, + model: scene.assistant, libraryRoute: Binding( get: { scene.assistantLibraryRoute }, set: { scene.assistantLibraryRoute = $0 } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 835b71f0..91ef5aec 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -12,6 +12,7 @@ import struct TermindDomain.Host @Observable final class MobileSceneModel { let id = UUID() + let assistant: MobileAssistantSceneModel var selectedDestination: MobileDestination? = .vault var workspaceID: MobileWorkspace.ID? var isConnectionPickerPresented = false @@ -24,6 +25,7 @@ final class MobileSceneModel { init(app: MobileAppModel) { self.app = app + assistant = app.assistant.makeSceneModel() } var selectedTab: MobileDestination { @@ -58,7 +60,7 @@ final class MobileSceneModel { } func openNewConversation() { - app.assistant.newConversation() + assistant.newConversation() assistantLibraryRoute = nil selectedDestination = .assistant } diff --git a/TFI/Application/Agent/MobileAssistantModel.swift b/TFI/Application/Agent/MobileAssistantModel.swift index 6670a2b2..a2776fa8 100644 --- a/TFI/Application/Agent/MobileAssistantModel.swift +++ b/TFI/Application/Agent/MobileAssistantModel.swift @@ -5,12 +5,12 @@ import TermindApplication import TermindDomain import TermindRemoteInfrastructure -/// Application-owned runs, with navigation and drafts independent of each run. +/// Application-owned Assistant runs and services. Scene-local navigation lives in +/// `MobileAssistantSceneModel`; only the conversation store is shared here. @MainActor @Observable final class MobileAssistantModel { let store = AssistantConversationStore() - let library: AssistantLibraryModel let speech: SpeechTranscriptionCoordinator let configuration: AIConfigurationModel let skills: SkillLibraryModel @@ -21,14 +21,18 @@ final class MobileAssistantModel { private let connections: AgentConnectionPool private let background: AgentBackgroundCommands private let attachmentLoader: ContextAttachmentLoading + private var loadTask: Task? private var suspensionTask: Task? private var loaded = false private(set) var isSuspending = false var error: String? - var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation } var isRunning: Bool { store.conversations.contains { $0.isStreaming } } + func makeSceneModel() -> MobileAssistantSceneModel { + MobileAssistantSceneModel(runtime: self, library: AssistantLibraryModel(store: store)) + } + init( configuration: AIConfigurationModel, repository: any AgentRunPersisting, hostServices: AgentHostServices, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel, @@ -42,7 +46,6 @@ final class MobileAssistantModel { self.skills = skills self.speech = speech self.attachmentLoader = attachmentLoader - library = AssistantLibraryModel(store: store) operations = AssistantConversationOperations(repository: repository) let resolver = hostServices.resolver let connections = hostServices.connections @@ -60,37 +63,49 @@ final class MobileAssistantModel { skills: catalog, channel: channel, ai: configuration, hosts: resolver, metrics: metrics, connections: connections, authentication: authentication, background: background) }, title: { try await titles.summarize($0) }) - configure(conversation) } func load() async { - guard !loaded else { return } - loaded = true + if loaded { return } + if let loadTask { + await loadTask.value + return + } + let loadTask = Task { [weak self] in + guard let self else { return } + await loadOnce() + } + self.loadTask = loadTask + await loadTask.value + self.loadTask = nil + } + + private func loadOnce() async { do { - library.restore(try await repository.loadConversations()) + store.restore(try await repository.loadConversations()) store.conversations.forEach(configure) + loaded = true } catch { - loaded = false self.error = error.localizedDescription } } - private func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader } + fileprivate func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader } - func select(_ conversation: ConversationModel) { + fileprivate func select(_ conversation: ConversationModel, in library: AssistantLibraryModel) { speech.cancelAll() library.select(conversation.id) configure(conversation) } - func newConversation() { + fileprivate func newConversation(in library: AssistantLibraryModel) { speech.cancelAll() _ = library.resetDraftConversation() - configure(conversation) + configure(library.draftConversation) } - func send() { - let conversation = conversation + fileprivate func send(in library: AssistantLibraryModel) { + let conversation = library.selectedConversation ?? library.draftConversation guard !isSuspending, !conversation.isArchived, !conversation.isHistoryChanging, !conversation.isCompacting, conversation.composer.pendingAttachments.isEmpty, authorize() else { return } @@ -127,7 +142,7 @@ final class MobileAssistantModel { func waitForSuspension() async { await suspensionTask?.value } - func selectModel(_ value: AIModelRef) { + fileprivate func selectModel(_ value: AIModelRef, for conversation: ConversationModel) { guard let resolved = configuration.lookup(value) else { return } conversation.model = value configuration.select(value) @@ -136,10 +151,15 @@ final class MobileAssistantModel { ).clamp(conversation.reasoningEffort) } - func archive(_ conversation: ConversationModel, archived: Bool) async { + fileprivate func archive( + _ conversation: ConversationModel, archived: Bool, in library: AssistantLibraryModel + ) async { await perform { try await operations.setArchived(archived, conversation: conversation) - if archived && conversation.isArchived && self.conversation.id == conversation.id { newConversation() } + let selected = library.selectedConversation ?? library.draftConversation + if archived && conversation.isArchived && selected.id == conversation.id { + newConversation(in: library) + } } } @@ -147,23 +167,28 @@ final class MobileAssistantModel { await perform { try await operations.rename(conversation, title: title) } } - func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async { + fileprivate func move( + _ conversation: ConversationModel, pinned: Bool, before: UUID? = nil, + in library: AssistantLibraryModel + ) async { await perform { try await operations.move(conversation, pinned: pinned, before: before, library: library) } } - func delete(_ conversation: ConversationModel) async { + fileprivate func delete(_ conversation: ConversationModel, from library: AssistantLibraryModel) async { guard !conversation.isStreaming, !conversation.isCompacting else { return } await perform { let result = try await operations.delete(conversation) guard result.deleted else { return } library.delete(conversation.id) if library.selectedConversation == nil { _ = library.resetDraftConversation() } - configure(self.conversation) + configure(library.selectedConversation ?? library.draftConversation) if let cleanupError = result.cleanupError { error = cleanupError } } } - func fork(_ conversation: ConversationModel, at messageID: UUID) async { + fileprivate func fork( + _ conversation: ConversationModel, at messageID: UUID, in library: AssistantLibraryModel + ) async { await perform { if let fork = try await operations.fork( conversation, at: messageID, title: store.nextForkTitle(for: conversation)) @@ -188,16 +213,85 @@ final class MobileAssistantModel { _ = await execution.compact(conversation, instructions: instructions) } - func implement(_ card: AgentPlanCard, in conversation: ConversationModel) { + fileprivate func implement( + _ card: AgentPlanCard, in conversation: ConversationModel, library: AssistantLibraryModel + ) { guard !conversation.isArchived, !conversation.isStreaming else { return } conversation.resolvePlan(card.id, as: .implemented) conversation.isPlanning = false conversation.composer.draft = String(localized: "Implement this plan.") - select(conversation) - send() + select(conversation, in: library) + send(in: library) } private func perform(_ operation: () async throws -> Void) async { do { try await operation() } catch { self.error = error.localizedDescription } } } + +/// Scene-local Assistant navigation over the app-wide conversation store and run services. +/// +/// The runtime owns work that must survive navigation. The library owns only the selected +/// conversation and the New Chat draft, so two iPad windows can navigate independently +/// without duplicating a running conversation. +@MainActor +@Observable +final class MobileAssistantSceneModel { + private let runtime: MobileAssistantModel + let library: AssistantLibraryModel + + init(runtime: MobileAssistantModel, library: AssistantLibraryModel? = nil) { + self.runtime = runtime + self.library = library ?? AssistantLibraryModel(store: runtime.store) + runtime.configure(self.library.draftConversation) + } + + var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation } + var speech: SpeechTranscriptionCoordinator { runtime.speech } + var configuration: AIConfigurationModel { runtime.configuration } + var skills: SkillLibraryModel { runtime.skills } + var isRunning: Bool { runtime.isRunning } + var isSuspending: Bool { runtime.isSuspending } + var error: String? { + get { runtime.error } + set { runtime.error = newValue } + } + + func load() async { await runtime.load() } + func select(_ conversation: ConversationModel) { runtime.select(conversation, in: library) } + func newConversation() { runtime.newConversation(in: library) } + func send() { runtime.send(in: library) } + func selectModel(_ value: AIModelRef) { runtime.selectModel(value, for: conversation) } + + func archive(_ conversation: ConversationModel, archived: Bool) async { + await runtime.archive(conversation, archived: archived, in: library) + } + + func rename(_ conversation: ConversationModel, title: String) async { + await runtime.rename(conversation, title: title) + } + + func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async { + await runtime.move(conversation, pinned: pinned, before: before, in: library) + } + + func delete(_ conversation: ConversationModel) async { + await runtime.delete(conversation, from: library) + } + + func fork(_ conversation: ConversationModel, at messageID: UUID) async { + await runtime.fork(conversation, at: messageID, in: library) + } + + func edit(_ conversation: ConversationModel, messageID: UUID, text: String) async { + await runtime.edit(conversation, messageID: messageID, text: text) + } + + func compact(_ conversation: ConversationModel, instructions: String = "") async { + await runtime.compact(conversation, instructions: instructions) + } + + func implement(_ card: AgentPlanCard, in conversation: ConversationModel) { + runtime.implement(card, in: conversation, library: library) + } +} diff --git a/TFI/Application/Transfers/MobileSFTPSessionModel.swift b/TFI/Application/Transfers/MobileSFTPSessionModel.swift index be70455a..e990147b 100644 --- a/TFI/Application/Transfers/MobileSFTPSessionModel.swift +++ b/TFI/Application/Transfers/MobileSFTPSessionModel.swift @@ -181,41 +181,49 @@ final class MobileSFTPSessionModel { } func rename(_ file: RemoteFile, to name: String) async throws { - guard let fileSystem else { return } + guard let fileSystem, let connectionGeneration else { return } do { try await fileSystem.rename(file, to: name) + guard self.connectionGeneration == connectionGeneration else { return } await reload() } catch { + guard self.connectionGeneration == connectionGeneration else { return } if handlesConnectionError?(error) != true { throw error } } } func setPermissions(_ file: RemoteFile, mode: UInt32) async throws { - guard let fileSystem else { return } + guard let fileSystem, let connectionGeneration else { return } do { try await fileSystem.setPermissions(of: file, to: mode) + guard self.connectionGeneration == connectionGeneration else { return } await reload() } catch { + guard self.connectionGeneration == connectionGeneration else { return } if handlesConnectionError?(error) != true { throw error } } } func createDirectory(_ name: String) async { - guard let fileSystem else { return } + guard let fileSystem, let connectionGeneration else { return } do { try await fileSystem.createDirectory(named: name, in: path) + guard self.connectionGeneration == connectionGeneration else { return } await reload() } catch { + guard self.connectionGeneration == connectionGeneration else { return } record(error) } } func delete(_ file: RemoteFile) async { - guard let fileSystem else { return } + guard let fileSystem, let connectionGeneration else { return } do { try await fileSystem.remove(file) + guard self.connectionGeneration == connectionGeneration else { return } await reload() } catch { + guard self.connectionGeneration == connectionGeneration else { return } record(error) } } diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index 1a7110a4..1e25d33c 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -49,7 +49,7 @@ private struct MobileDismissibleComposerBadge: View { struct MobileAssistantComposer: View { let pro: ProAccessController - let model: MobileAssistantModel + let model: MobileAssistantSceneModel @Bindable var conversation: ConversationModel let focus: FocusState.Binding? let focusRequest: Int? @@ -76,7 +76,7 @@ struct MobileAssistantComposer: View { init( pro: ProAccessController, - model: MobileAssistantModel, + model: MobileAssistantSceneModel, conversation: ConversationModel, focus: FocusState.Binding? = nil, focusRequest: Int? = nil, @@ -653,8 +653,8 @@ struct MobileAssistantComposer: View { throw CocoaError(.fileReadCorruptFile) } try Task.checkCancellation() - let chip = try await ContextAttachmentImporter.importImage( - data: data, label: String(localized: "Photo")) + let chip = try await conversation.attachmentLoader.image( + data, String(localized: "Photo")) try Task.checkCancellation() conversation.finishAttachmentImport(pending.id, with: chip) } catch is CancellationError { conversation.cancelAttachmentImport(pending.id) } catch { diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index d689753f..146476b6 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -459,7 +459,7 @@ private struct MobileAssistantSteeringQueue: View { struct MobileAssistantConversation: View { let app: MobileAppModel - let model: MobileAssistantModel + let model: MobileAssistantSceneModel @Bindable var conversation: ConversationModel let composerFocus: FocusState.Binding let composerFocusRequest: Int? diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index a856d875..20803d89 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -18,7 +18,7 @@ enum MobileAssistantLibraryRoute: String, Identifiable, Hashable { struct MobileAssistantView: View { let app: MobileAppModel - @Bindable var model: MobileAssistantModel + @Bindable var model: MobileAssistantSceneModel @Binding var libraryRoute: MobileAssistantLibraryRoute? @State private var restoreComposerFocusAfterLibrary = false @State private var nextComposerFocusRequest = 0 @@ -30,7 +30,7 @@ struct MobileAssistantView: View { init( app: MobileAppModel, - model: MobileAssistantModel, + model: MobileAssistantSceneModel, libraryRoute: Binding ) { self.app = app @@ -212,7 +212,7 @@ struct MobileAssistantView: View { } struct MobileAssistantLibrary: View { - @Bindable var model: MobileAssistantModel + @Bindable var model: MobileAssistantSceneModel let route: MobileAssistantLibraryRoute let selected: () -> Void let showArchived: () -> Void @@ -221,6 +221,18 @@ struct MobileAssistantLibrary: View { @State private var title = "" @State private var deleting: ConversationModel? + init( + model: MobileAssistantSceneModel, + route: MobileAssistantLibraryRoute, + selected: @escaping () -> Void, + showArchived: @escaping () -> Void + ) { + self.model = model + self.route = route + self.selected = selected + self.showArchived = showArchived + } + private var archived: Bool { route == .archived } var body: some View { diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index a9480016..292c385d 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -85,9 +85,13 @@ final class MobileWorkspace: Identifiable { private(set) var uploadNotice: WorkspaceUploadNotice? private var pendingUpload: MobilePendingUpload? private var uploadPreparationTask: Task? + private var uploadPreparationID: UUID? private var uploadEnqueueTask: Task? + private var uploadEnqueueID: UUID? private var uploadNoticeTask: Task? + private var uploadNoticeID: UUID? private var remoteTransferPreparationTask: Task? + private var remoteTransferPreparationID: UUID? private var pendingDownload: MobilePendingDownload? private var pendingRemoteCopy: MobilePendingRemoteCopy? let terminalSettings: TerminalSettingsModel @@ -967,9 +971,17 @@ final class MobileWorkspace: Identifiable { cancelPendingUpload() let connectionGeneration = generation + let preparationID = UUID() + uploadPreparationID = preparationID uploadNotice = .preparing(destination) uploadPreparationTask = Task { [weak self] in guard let self else { return } + defer { + if uploadPreparationID == preparationID { + uploadPreparationTask = nil + uploadPreparationID = nil + } + } var client: (any SFTPClientProtocol)? var ownsClient = false do { @@ -985,7 +997,8 @@ final class MobileWorkspace: Identifiable { ownsClient = true } try Task.checkCancellation() - guard generation == connectionGeneration, uploadDestination == destination, + guard uploadPreparationID == preparationID, + generation == connectionGeneration, uploadDestination == destination, let client else { throw CancellationError() } let batch = try await SFTPLocalUploadPlanner.prepare( @@ -994,7 +1007,9 @@ final class MobileWorkspace: Identifiable { client: client ) try Task.checkCancellation() - guard generation == connectionGeneration, uploadDestination == destination else { + guard uploadPreparationID == preparationID, + generation == connectionGeneration, uploadDestination == destination + else { throw CancellationError() } guard !batch.isEmpty else { @@ -1008,7 +1023,6 @@ final class MobileWorkspace: Identifiable { configuration: configuration, connectionGeneration: connectionGeneration ) - uploadPreparationTask = nil if batch.conflictCount > 0 { uploadNotice = nil } else { @@ -1016,13 +1030,12 @@ final class MobileWorkspace: Identifiable { } } catch is CancellationError { if ownsClient { await client?.disconnect() } - uploadNotice = nil - uploadPreparationTask = nil + if uploadPreparationID == preparationID { uploadNotice = nil } } catch { if ownsClient { await client?.disconnect() } + guard uploadPreparationID == preparationID else { return } uploadNotice = nil operationError = error.localizedDescription - uploadPreparationTask = nil } } return true @@ -1048,29 +1061,34 @@ final class MobileWorkspace: Identifiable { } cancelPendingUpload() let connectionGeneration = generation + let preparationID = UUID() + remoteTransferPreparationID = preparationID remoteTransferPreparationTask = Task { [weak self] in guard let self else { return } + defer { + if remoteTransferPreparationID == preparationID { + remoteTransferPreparationTask = nil + remoteTransferPreparationID = nil + } + } do { let batch = try await SFTPRemoteCopyPlanner.prepare( payload, destination: sftpSession.path, client: sftp, coordinator: transfers ) try Task.checkCancellation() - guard generation == connectionGeneration else { - remoteTransferPreparationTask = nil - return - } + guard remoteTransferPreparationID == preparationID, + generation == connectionGeneration + else { return } pendingRemoteCopy = MobilePendingRemoteCopy( batch: batch, configuration: configuration, connectionGeneration: connectionGeneration ) - remoteTransferPreparationTask = nil if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) } } catch is CancellationError { - remoteTransferPreparationTask = nil } catch { + guard remoteTransferPreparationID == preparationID else { return } operationError = error.localizedDescription - remoteTransferPreparationTask = nil } } } @@ -1084,28 +1102,33 @@ final class MobileWorkspace: Identifiable { } cancelPendingUpload() let connectionGeneration = generation + let preparationID = UUID() + remoteTransferPreparationID = preparationID remoteTransferPreparationTask = Task { [weak self] in guard let self else { return } + defer { + if remoteTransferPreparationID == preparationID { + remoteTransferPreparationTask = nil + remoteTransferPreparationID = nil + } + } do { let batch = try await SFTPRemoteDownloadPlanner.prepare( [file], destination: destination, client: sftp ) try Task.checkCancellation() - guard generation == connectionGeneration else { - remoteTransferPreparationTask = nil - return - } + guard remoteTransferPreparationID == preparationID, + generation == connectionGeneration + else { return } pendingDownload = MobilePendingDownload( batch: batch, configuration: configuration, connectionGeneration: connectionGeneration ) - remoteTransferPreparationTask = nil if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) } } catch is CancellationError { - remoteTransferPreparationTask = nil } catch { + guard remoteTransferPreparationID == preparationID else { return } operationError = error.localizedDescription - remoteTransferPreparationTask = nil } } } @@ -1154,6 +1177,7 @@ final class MobileWorkspace: Identifiable { } func cancelPendingRemoteTransfer() { + remoteTransferPreparationID = nil remoteTransferPreparationTask?.cancel() remoteTransferPreparationTask = nil pendingDownload = nil @@ -1163,9 +1187,18 @@ final class MobileWorkspace: Identifiable { func enqueuePendingUpload(_ policy: TransferConflictPolicy) { guard let pendingUpload, let transfers else { return } self.pendingUpload = nil + uploadEnqueueID = nil uploadEnqueueTask?.cancel() + let enqueueID = UUID() + uploadEnqueueID = enqueueID uploadEnqueueTask = Task { [weak self] in guard let self else { return } + defer { + if uploadEnqueueID == enqueueID { + uploadEnqueueTask = nil + uploadEnqueueID = nil + } + } do { let result = try await SFTPLocalUploadPlanner.enqueue( pendingUpload.batch, @@ -1185,33 +1218,36 @@ final class MobileWorkspace: Identifiable { } } if pendingUpload.ownsClient { await pendingUpload.client.disconnect() } - guard generation == pendingUpload.connectionGeneration else { return } + guard uploadEnqueueID == enqueueID, + generation == pendingUpload.connectionGeneration + else { return } if result.changedRemoteDirectories, descriptor.kind == .sftp { await sftpSession.openDirectory(sftpSession.path) } showUploadNotice( .queued(pendingUpload.batch.rootCount, pendingUpload.destination) ) - uploadEnqueueTask = nil } catch is CancellationError { if pendingUpload.ownsClient { await pendingUpload.client.disconnect() } - uploadNotice = nil - uploadEnqueueTask = nil + if uploadEnqueueID == enqueueID { uploadNotice = nil } } catch { if pendingUpload.ownsClient { await pendingUpload.client.disconnect() } + guard uploadEnqueueID == enqueueID else { return } uploadNotice = nil operationError = error.localizedDescription - uploadEnqueueTask = nil } } } func cancelPendingUpload() { cancelPendingRemoteTransfer() + uploadPreparationID = nil uploadPreparationTask?.cancel() uploadPreparationTask = nil + uploadEnqueueID = nil uploadEnqueueTask?.cancel() uploadEnqueueTask = nil + uploadNoticeID = nil uploadNoticeTask?.cancel() uploadNoticeTask = nil uploadNotice = nil @@ -1223,13 +1259,19 @@ final class MobileWorkspace: Identifiable { } private func showUploadNotice(_ notice: WorkspaceUploadNotice) { + uploadNoticeID = nil uploadNoticeTask?.cancel() + let noticeID = UUID() + uploadNoticeID = noticeID uploadNotice = notice uploadNoticeTask = Task { [weak self] in try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled, self?.uploadNotice == notice else { return } + guard !Task.isCancelled, self?.uploadNoticeID == noticeID, + self?.uploadNotice == notice + else { return } self?.uploadNotice = nil self?.uploadNoticeTask = nil + self?.uploadNoticeID = nil } } diff --git a/TFI/Platform/MobileBackgroundController.swift b/TFI/Platform/MobileBackgroundController.swift index ba97f28f..5049685b 100644 --- a/TFI/Platform/MobileBackgroundController.swift +++ b/TFI/Platform/MobileBackgroundController.swift @@ -9,19 +9,32 @@ protocol MobileBackgroundTaskManaging: AnyObject { @MainActor final class MobileBackgroundController: MobileBackgroundTaskManaging { private var task: UIBackgroundTaskIdentifier = .invalid + private var expirationID: UUID? func begin(expiration: @escaping @MainActor () -> Void) { finish() + let expirationID = UUID() + self.expirationID = expirationID task = UIApplication.shared.beginBackgroundTask(withName: "Finish remote session work") { [weak self] in Task { @MainActor in + guard self?.expirationID == expirationID else { return } + self?.expirationID = nil expiration() - self?.finish() + self?.endTask() } } - if task == .invalid { expiration() } + if task == .invalid, self.expirationID == expirationID { + self.expirationID = nil + expiration() + } } func finish() { + expirationID = nil + endTask() + } + + private func endTask() { guard task != .invalid else { return } UIApplication.shared.endBackgroundTask(task) task = .invalid diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 713987a3..24383726 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -36,6 +36,28 @@ struct MobileAppTests { #expect(try app.hosts.snapshot().value.hosts == [host]) } + @Test func assistantNavigationAndDraftsAreSceneLocalWhileConversationsAreShared() throws { + let app = try MobileAppModel(inMemory: true) + let first = MobileSceneModel(app: app) + let second = MobileSceneModel(app: app) + first.assistant.conversation.composer.draft = "first draft" + second.assistant.conversation.composer.draft = "second draft" + + let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Shared")]) + first.assistant.library.insertAndSelect(conversation) + + #expect(second.assistant.library.conversation(id: conversation.id) === conversation) + #expect(second.assistant.library.selectedConversation == nil) + #expect(second.assistant.conversation.composer.draft == "second draft") + + second.assistant.select(conversation) + first.assistant.newConversation() + + #expect(second.assistant.conversation === conversation) + #expect(first.assistant.conversation !== conversation) + #expect(first.assistant.conversation.composer.draft.isEmpty) + } + @Test func historyModelsUseTheSharedSwiftDataContainer() throws { let app = try MobileAppModel(inMemory: true) let hostID = UUID() diff --git a/TFITests/MobileAssistantRenderingTests.swift b/TFITests/MobileAssistantRenderingTests.swift index dec77051..c12d8cc6 100644 --- a/TFITests/MobileAssistantRenderingTests.swift +++ b/TFITests/MobileAssistantRenderingTests.swift @@ -13,6 +13,7 @@ struct MobileAssistantRenderingTests { @Test func renderUserImageAlignment() async throws { guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() for index in 0..<7 { let size = @@ -45,13 +46,13 @@ struct MobileAssistantRenderingTests { ) } conversation.appendUserTurn("Compare these images.") - app.assistant.library.insertAndSelect(conversation) + assistant.library.insertAndSelect(conversation) try await capture( "user-images", language: "en", view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) } ) } @@ -112,8 +113,9 @@ struct MobileAssistantRenderingTests { } } let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel.demo(language: try #require(AppLanguage(rawValue: language))) - app.assistant.library.insertAndSelect(conversation) + assistant.library.insertAndSelect(conversation) conversation.composer.draft = switch language { case "zh-Hans": "先检查服务日志。" @@ -125,26 +127,26 @@ struct MobileAssistantRenderingTests { try await capture( "chat", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.draft = "/" try await capture( "slash", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.draft = "/skill " try await capture( "slash-options", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.draft = "" conversation.beginCompaction() try await capture( "compact-running", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.failCompaction( text("The context service is unavailable.", "上下文服务不可用。", "上下文服務目前無法使用。") @@ -152,7 +154,7 @@ struct MobileAssistantRenderingTests { try await capture( "compact-failed", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.endCompaction() conversation.appendUserTurn( @@ -167,14 +169,14 @@ struct MobileAssistantRenderingTests { try await capture( "compact-completed", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.attachmentImportError = text( "The selected attachment could not be imported.", "无法导入所选附件。", "無法匯入所選附件。") try await capture( "attachment-error", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.attachmentImportError = nil conversation.handle( @@ -212,12 +214,12 @@ struct MobileAssistantRenderingTests { try await capture( "question", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) try await capture( "question-dark", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) } .environment(\.locale, Locale(identifier: language)) .preferredColorScheme(.dark)) @@ -228,7 +230,7 @@ struct MobileAssistantRenderingTests { try await capture( "question-custom", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.cancelUserInput() conversation.present( @@ -264,12 +266,12 @@ struct MobileAssistantRenderingTests { try await capture( "question-multiple", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) try await capture( "question-multiple-large", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) } .environment(\.locale, Locale(identifier: language)) .environment(\.dynamicTypeSize, .accessibility2)) @@ -287,12 +289,12 @@ struct MobileAssistantRenderingTests { try await capture( "question-secret", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) try await capture( "question-secret-dark", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) } .environment(\.locale, Locale(identifier: language)) .preferredColorScheme(.dark)) @@ -302,13 +304,13 @@ struct MobileAssistantRenderingTests { try await capture( "archived-chat", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) - app.assistant.newConversation() + assistant.newConversation() try await capture( "empty", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) + MobileAssistantView(app: app, model: assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) } } @@ -317,6 +319,7 @@ struct MobileAssistantRenderingTests { guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } for language in ["en", "zh-Hans", "zh-Hant"] { let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() let pinned = ConversationModel.demo(language: try #require(AppLanguage(rawValue: language))) pinned.isPinned = true let ordinary = ConversationModel() @@ -333,14 +336,14 @@ struct MobileAssistantRenderingTests { case "zh-Hant": "調查資料庫延遲" default: "Investigate database latency" } - app.assistant.library.insertAndSelect(pinned) - app.assistant.library.insertAndSelect(ordinary) - app.assistant.library.insertAndSelect(streaming) + assistant.library.insertAndSelect(pinned) + assistant.library.insertAndSelect(ordinary) + assistant.library.insertAndSelect(streaming) try await capture( "conversations", language: language, view: NavigationStack { MobileAssistantLibrary( - model: app.assistant, route: .conversations, + model: assistant, route: .conversations, selected: {}, showArchived: {} ).navigationTitle("Conversations") }.environment(\.locale, Locale(identifier: language))) @@ -350,7 +353,7 @@ struct MobileAssistantRenderingTests { "archive", language: language, view: NavigationStack { MobileAssistantLibrary( - model: app.assistant, route: .archived, + model: assistant, route: .archived, selected: {}, showArchived: {} ).navigationTitle("Archived") }.environment(\.locale, Locale(identifier: language))) diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index c9f16c25..c2924f6f 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -17,16 +17,18 @@ import UniformTypeIdentifiers struct MobileAssistantTests { @Test func shortUserInputUsesItsNaturalHeightAndLongInputUsesTheCap() throws { let shortApp = try MobileAppModel(inMemory: true) - shortApp.assistant.conversation.present( + let shortAssistant = shortApp.assistant.makeSceneModel() + shortAssistant.conversation.present( AgentUserInputRequest(questions: [ AgentUserQuestion(id: "answer", header: "Answer", prompt: "Continue?", kind: .text) ])) let shortHeight = measuredInteractionHeight( - conversation: shortApp.assistant.conversation, + conversation: shortAssistant.conversation, hosts: shortApp.library ) let longApp = try MobileAppModel(inMemory: true) + let longAssistant = longApp.assistant.makeSceneModel() let options = (0..<16).map { index in AgentUserInputOption( id: "option-\(index)", @@ -34,7 +36,7 @@ struct MobileAssistantTests { detail: "A deliberately detailed explanation that makes this option require another line." ) } - longApp.assistant.conversation.present( + longAssistant.conversation.present( AgentUserInputRequest(questions: [ AgentUserQuestion( id: "choice", @@ -44,7 +46,7 @@ struct MobileAssistantTests { ) ])) let longHeight = measuredInteractionHeight( - conversation: longApp.assistant.conversation, + conversation: longAssistant.conversation, hosts: longApp.library ) @@ -56,7 +58,8 @@ struct MobileAssistantTests { @Test func longApprovalUsesTheSameHeightCap() async throws { let app = try MobileAppModel(inMemory: true) - let conversation = app.assistant.conversation + let assistant = app.assistant.makeSceneModel() + let conversation = assistant.conversation let invocation = AgentToolInvocation( runID: UUID(), call: AgentToolCall( @@ -141,7 +144,7 @@ struct MobileAssistantTests { @Test func changingConversationsPreservesTheirDraftsAndArchiveNavigation() async throws { let app = try MobileAppModel(inMemory: true) - let model = app.assistant + let model = app.assistant.makeSceneModel() let first = ConversationModel(messages: [ChatMessage(role: .user, text: "First")]) let second = ConversationModel(messages: [ChatMessage(role: .user, text: "Second")]) model.library.insertAndSelect(first) @@ -157,24 +160,26 @@ struct MobileAssistantTests { @Test func explicitNewConversationStartsWithAFreshDraft() throws { let app = try MobileAppModel(inMemory: true) - app.assistant.conversation.composer.draft = "discard this explicit new-chat draft" + let assistant = app.assistant.makeSceneModel() + assistant.conversation.composer.draft = "discard this explicit new-chat draft" - app.assistant.newConversation() + assistant.newConversation() - #expect(app.assistant.conversation.isEmpty) - #expect(app.assistant.conversation.composer.draft.isEmpty) + #expect(assistant.conversation.isEmpty) + #expect(assistant.conversation.composer.draft.isEmpty) } @Test func firstNewConversationSendPromotesDraftAndStartsRun() throws { let app = try MobileAppModel(inMemory: true) - let draft = app.assistant.conversation + let assistant = app.assistant.makeSceneModel() + let draft = assistant.conversation draft.composer.draft = "Why is the service unavailable?" - app.assistant.send() + assistant.send() - #expect(app.assistant.library.selectedConversation === draft) - #expect(app.assistant.library.conversations.map(\.id) == [draft.id]) - #expect(app.assistant.library.draftConversation !== draft) + #expect(assistant.library.selectedConversation === draft) + #expect(assistant.library.conversations.map(\.id) == [draft.id]) + #expect(assistant.library.draftConversation !== draft) #expect(draft.composer.draft.isEmpty) #expect(draft.messages.map(\.role) == [.user, .assistant]) #expect(draft.messages.first?.text == "Why is the service unavailable?") @@ -183,13 +188,14 @@ struct MobileAssistantTests { @Test func attachmentOnlyFirstSendPromotesTheConversation() throws { let app = try MobileAppModel(inMemory: true) - let draft = app.assistant.conversation + let assistant = app.assistant.makeSceneModel() + let draft = assistant.conversation draft.attach(ContextChip(kind: .file, label: "service.log", content: "connection refused")) - app.assistant.send() + assistant.send() - #expect(app.assistant.library.selectedConversation === draft) - #expect(app.assistant.library.conversations.map(\.id) == [draft.id]) + #expect(assistant.library.selectedConversation === draft) + #expect(assistant.library.conversations.map(\.id) == [draft.id]) #expect(draft.messages.map(\.role) == [.user, .assistant]) #expect(draft.messages.first?.text.isEmpty == true) #expect(draft.messages.first?.contextChips.map(\.label) == ["service.log"]) @@ -198,12 +204,13 @@ struct MobileAssistantTests { @Test func existingConversationSendStillUsesTheDraftExecutionPath() async throws { let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() conversation.composer.draft = "Opening question" - _ = app.assistant.library.startConversation(conversation) + _ = assistant.library.startConversation(conversation) conversation.composer.draft = "Follow-up question" - app.assistant.send() + assistant.send() #expect(conversation.composer.draft.isEmpty) #expect(conversation.messages.map(\.text).contains("Follow-up question")) @@ -217,9 +224,10 @@ struct MobileAssistantTests { @Test func slashCatalogShowsAlphabetizedCompactPlanAndSkillCommandsOnMobile() throws { let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Inspect")]) - app.assistant.library.insertAndSelect(conversation) - let composer = MobileAssistantComposer(pro: app.pro, model: app.assistant, conversation: conversation) + assistant.library.insertAndSelect(conversation) + let composer = MobileAssistantComposer(pro: app.pro, model: assistant, conversation: conversation) let commands = composer.availableCommands let first = try #require(commands.first) #expect(commands.contains { $0.id == "plan" }) @@ -237,6 +245,7 @@ struct MobileAssistantTests { @Test func selectingAnExplicitModelRemembersItAndClampsEffort() throws { let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() var reasoning = AIModel(id: "reasoning-model") reasoning.abilities.insert(.reasoning) reasoning.reasoningProfile = ReasoningProfile(levels: [ @@ -250,21 +259,21 @@ struct MobileAssistantTests { let provider = ModelProvider(name: "Test", models: [reasoning, plain]) app.ai.upsert(provider) - app.assistant.conversation.reasoningEffort = .low + assistant.conversation.reasoningEffort = .low let reasoningRef = AIModelRef(modelProviderID: provider.id, modelID: reasoning.id) - app.assistant.selectModel(reasoningRef) - #expect(app.assistant.conversation.model == reasoningRef) + assistant.selectModel(reasoningRef) + #expect(assistant.conversation.model == reasoningRef) #expect(app.ai.lastSelectedModel == reasoningRef) - #expect(app.assistant.conversation.reasoningEffort == .high) + #expect(assistant.conversation.reasoningEffort == .high) let plainRef = AIModelRef(modelProviderID: provider.id, modelID: plain.id) - app.assistant.selectModel(plainRef) - #expect(app.assistant.conversation.reasoningEffort == .off) + assistant.selectModel(plainRef) + #expect(assistant.conversation.reasoningEffort == .off) } @Test func assistantDropImporterAcceptsFileURLsAndImageData() async throws { let app = try MobileAppModel(inMemory: true) - let conversation = app.assistant.conversation + let conversation = app.assistant.makeSceneModel().conversation let directory = FileManager.default.temporaryDirectory.appending( path: UUID().uuidString, directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -346,11 +355,12 @@ struct MobileAssistantTests { @Test func backgroundExpirationBlocksNewRunsUntilCleanupCompletes() async throws { let app = try MobileAppModel(inMemory: true) - app.assistant.conversation.composer.draft = "preserve" + let assistant = app.assistant.makeSceneModel() + assistant.conversation.composer.draft = "preserve" app.assistant.expireBackground() #expect(app.assistant.isSuspending) - app.assistant.send() - #expect(app.assistant.conversation.composer.draft == "preserve") + assistant.send() + #expect(assistant.conversation.composer.draft == "preserve") await app.assistant.waitForSuspension() #expect(!app.assistant.isSuspending) #expect(!app.assistant.isRunning) diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift index 3830c631..dff32d0a 100644 --- a/TFITests/MobileShortcutTests.swift +++ b/TFITests/MobileShortcutTests.swift @@ -65,11 +65,11 @@ struct MobileShortcutTests { #expect(scene.editingHost != nil) scene.assistantLibraryRoute = .archived - let previousConversationID = app.assistant.conversation.id + let previousConversationID = scene.assistant.conversation.id scene.openNewConversation() #expect(scene.selectedDestination == .assistant) #expect(scene.assistantLibraryRoute == nil) - #expect(app.assistant.conversation.id != previousConversationID) + #expect(scene.assistant.conversation.id != previousConversationID) } @Test func workspaceCommandsSelectByIndexAndWrapInBothDirections() throws { diff --git a/TFM/Features/SFTP/SFTPSessionModel.swift b/TFM/Features/SFTP/SFTPSessionModel.swift index d3f607e0..31712794 100644 --- a/TFM/Features/SFTP/SFTPSessionModel.swift +++ b/TFM/Features/SFTP/SFTPSessionModel.swift @@ -406,8 +406,20 @@ final class SFTPSessionModel: Identifiable { } } ) - try Task.checkCancellation() - try await self.resolveInitialPathIfNeeded(using: client) + guard !Task.isCancelled, self.connectionAttemptID == attemptID else { + await client.disconnect() + return + } + do { + try await self.resolveInitialPathIfNeeded(using: client) + } catch { + await client.disconnect() + throw error + } + guard !Task.isCancelled, self.connectionAttemptID == attemptID else { + await client.disconnect() + return + } self.connectionJourney.receive(.connected(host.id)) self.client = client self.configuration = self.usesEphemeralCredentials ? nil : configuration @@ -433,8 +445,10 @@ final class SFTPSessionModel: Identifiable { TermindSSHError.missingUsername, TermindSSHError.keyPassphraseRequired { + guard self.connectionAttemptID == attemptID else { return } self.requestAuthentication(host: host, store: store) } catch let error as TermindSSHError where error.shouldRequestAuthentication { + guard self.connectionAttemptID == attemptID else { return } let authenticationHost: Host if let id = error.routeHostID, let jump = store.host(id) { authenticationHost = jump @@ -456,6 +470,7 @@ final class SFTPSessionModel: Identifiable { preserveSubmission: retryNeedsPassphrase && submitted != nil ) } catch { + guard self.connectionAttemptID == attemptID else { return } if self.connection.hasConnectedOnce || reconnecting { self.scheduleReconnect(after: error.localizedDescription, using: store) } else { diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 98a37bc1..8d450997 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -814,7 +814,7 @@ final class SSHSessionModel: Identifiable { }, shellIntegration: { [weak self] event in Task { @MainActor in - guard let self else { return } + guard let self, self.connectionAttemptID == attemptID else { return } self.receiveShellIntegrationEvent(event) } } diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 738bfef6..b6c1fcea 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -230,6 +230,12 @@ struct ArchitectureBoundaryTests { #expect(!root.contains("switch scene.selectedDestination")) #expect(features.contains("mutating func register(")) #expect(features.contains("Duplicate mobile feature registration")) + #expect(features.contains("model: scene.assistant")) + #expect(!features.contains("model: app.assistant")) + + let scene = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileSceneModel.swift"), encoding: .utf8) + #expect(scene.contains("let assistant: MobileAssistantSceneModel")) let descriptor = try String( contentsOf: sourceRoot.appending( diff --git a/TFMTests/SettingsSyncTests.swift b/TFMTests/SettingsSyncTests.swift index b019e661..d1eac478 100644 --- a/TFMTests/SettingsSyncTests.swift +++ b/TFMTests/SettingsSyncTests.swift @@ -301,7 +301,7 @@ struct SettingsSyncTests { defer { withExtendedLifetime(container) {} } let before = try store.load().webSearch let valid = envelope(.webSearch(.init(isEnabled: false, prefersBuiltIn: false))) - var invalid = envelope(.selection(nil)) + var invalid = envelope(.terminal(TerminalPreferences())) invalid.formatVersion = 99 #expect(throws: (any Error).self) { try store.applyRemote([valid, invalid]) } #expect(try store.load().webSearch == before)