Fix mobile state and connection races
This commit is contained in:
1 parent
a737283621
commit
8dcc934506
21 files changed
+389
-138
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var suspensionTask: Task<Void, Never>?
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Bool>.Binding?
|
||||
let focusRequest: Int?
|
||||
@@ -76,7 +76,7 @@ struct MobileAssistantComposer: View {
|
||||
|
||||
init(
|
||||
pro: ProAccessController,
|
||||
model: MobileAssistantModel,
|
||||
model: MobileAssistantSceneModel,
|
||||
conversation: ConversationModel,
|
||||
focus: FocusState<Bool>.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 {
|
||||
|
||||
@@ -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<Bool>.Binding
|
||||
let composerFocusRequest: Int?
|
||||
|
||||
@@ -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<MobileAssistantLibraryRoute?>
|
||||
) {
|
||||
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 {
|
||||
|
||||
@@ -85,9 +85,13 @@ final class MobileWorkspace: Identifiable {
|
||||
private(set) var uploadNotice: WorkspaceUploadNotice?
|
||||
private var pendingUpload: MobilePendingUpload?
|
||||
private var uploadPreparationTask: Task<Void, Never>?
|
||||
private var uploadPreparationID: UUID?
|
||||
private var uploadEnqueueTask: Task<Void, Never>?
|
||||
private var uploadEnqueueID: UUID?
|
||||
private var uploadNoticeTask: Task<Void, Never>?
|
||||
private var uploadNoticeID: UUID?
|
||||
private var remoteTransferPreparationTask: Task<Void, Never>?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user