diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index d07ded659eb7..9a89f1d1728b 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -183,9 +183,76 @@ enum DailyUXSnoozePresets { } } +enum DailyUXCreationDestination: Equatable { + case newTask + case addProject +} + +struct NewTaskRetryState: Equatable { + private(set) var isInProgress = false + + var buttonTitle: String { + isInProgress ? "Trying again…" : "Try again" + } + + mutating func begin() -> Bool { + guard !isInProgress else { return false } + isInProgress = true + return true + } + + mutating func finish() { + isInProgress = false + } +} + +struct NewTaskProjectPickerPresentation: Equatable { + enum ProjectContent: Equatable { + case projects + case noProjects + case noMatches + } + + static let visibleEnvironmentLimit = 3 + + let projectContent: ProjectContent + let unavailableEnvironments: [FeatureEnvironment] + + init( + groups: [DailyUXProjectGroup], + filteredGroups: [DailyUXProjectGroup], + unavailableEnvironments: [FeatureEnvironment] + ) { + if groups.isEmpty { + projectContent = .noProjects + } else if filteredGroups.isEmpty { + projectContent = .noMatches + } else { + projectContent = .projects + } + self.unavailableEnvironments = unavailableEnvironments + } + + var visibleUnavailableEnvironments: [FeatureEnvironment] { + Array(unavailableEnvironments.prefix(Self.visibleEnvironmentLimit)) + } + + var additionalUnavailableEnvironmentCount: Int { + max(0, unavailableEnvironments.count - Self.visibleEnvironmentLimit) + } + + var unavailableAccessibilityLabel: String { + (["Unavailable environments"] + unavailableEnvironments.map { + "\($0.name) is unreachable" + }).joined(separator: ". ") + } +} + enum DailyUXCreationContext { static func projects(in snapshot: FeatureSnapshot) -> [FeatureProject] { guard !snapshot.environments.isEmpty else { return snapshot.projects } + // Cached projects can queue tasks offline. A connection change must not + // remove the selected project or its draft while the user is typing. let availableEnvironmentIDs = Set( snapshot.environments.filter(\.isEnabled).map(\.id) ) @@ -194,6 +261,41 @@ enum DailyUXCreationContext { } } + static func projectEnvironmentValidationMessage( + projectID: String, + in snapshot: FeatureSnapshot + ) -> String? { + guard let project = snapshot.projects.first(where: { $0.id == projectID }), + let environment = snapshot.environments.first(where: { + $0.id == project.environmentID + }) else { return nil } + return environment.isEnabled ? nil : "Environment is off." + } + + static func unreachableEnvironments(in snapshot: FeatureSnapshot) -> [FeatureEnvironment] { + unreachableEnvironments(in: snapshot.environments) + } + + /// Enabled environments a new task cannot reach. `.reconnecting` is a + /// transient state whose HTTP fallback still serves work, so the sidebar + /// and connection hub present it separately; only `.disconnected` is + /// unreachable here. + static func unreachableEnvironments( + in environments: [FeatureEnvironment] + ) -> [FeatureEnvironment] { + environments.filter { environment in + guard environment.isEnabled else { return false } + return environment.connectionState == .disconnected + } + } + + static func newTaskDestination(in snapshot: FeatureSnapshot) -> DailyUXCreationDestination { + if !projects(in: snapshot).isEmpty || !unreachableEnvironments(in: snapshot).isEmpty { + return .newTask + } + return .addProject + } + static func projectGroups(in snapshot: FeatureSnapshot) -> [DailyUXProjectGroup] { return DailyUXProjectGrouping.groups( projects: projects(in: snapshot), diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 39273200f864..e5a9fb872a51 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -36,6 +36,7 @@ public struct NewThreadView: View { @State private var immediateDraftSaveTasks: [String: Task] = [:] @State private var submittedSuccessfully = false @State private var restoresPromptAfterPickerDismissal = false + @State private var unreachableRetry = NewTaskRetryState() // Plain state, not `FocusState`; see the note on `composerFocused` in // ThreadDetailView. @State private var promptFocused = false @@ -64,7 +65,6 @@ public struct NewThreadView: View { topBar if creationProjects.isEmpty { noProjects - .padding(.top, 82) } else if !usesCompactProjectContext { hero .padding(.top, 82) @@ -206,6 +206,8 @@ public struct NewThreadView: View { environments: model.snapshot.environments, recentGroupIDs: recentProjectGroupIDs, selectionID: selectedProjectGroup?.id, + retryState: unreachableRetry, + onRetry: retryUnreachableEnvironments, onSelect: { group in if selectProjectGroup(group) { activePicker = nil @@ -390,28 +392,77 @@ public struct NewThreadView: View { } private var noProjects: some View { - VStack(spacing: 14) { - Image(systemName: "folder.badge.plus") - .font(.system(size: 28, weight: .regular)) - .foregroundStyle(T3Colors.textSecondary) - Text("No projects") - .font(T3Typography.threadHeading1.weight(.regular)) - .foregroundStyle(T3Colors.textPrimary) - Button("Add project") { - dismiss() - Task { @MainActor in - await Task.yield() - onCreateProject() + ScrollView { + VStack(spacing: 14) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(T3Colors.textSecondary) + Text("No projects") + .font(T3Typography.threadHeading1.weight(.regular)) + .foregroundStyle(T3Colors.textPrimary) + if !unreachableEnvironments.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(unreachableEnvironments) { environment in + Label( + "\(environment.name) is unreachable", + systemImage: "network.slash" + ) + .accessibilityLabel("\(environment.name) is unreachable") + .accessibilityIdentifier( + "new-task-unreachable-environment-\(environment.id)" + ) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + + Button(action: retryUnreachableEnvironments) { + HStack(spacing: 8) { + if unreachableRetry.isInProgress { + ProgressView() + .controlSize(.small) + } + Text(unreachableRetry.buttonTitle) + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(unreachableRetry.isInProgress) + .accessibilityLabel(unreachableRetry.buttonTitle) + .accessibilityHint("Refresh environment status") + .accessibilityIdentifier("new-task-unreachable-retry") + } + Button("Add project") { + dismiss() + Task { @MainActor in + await Task.yield() + onCreateProject() + } } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(T3Colors.primaryAction) + .foregroundStyle(T3Colors.primaryActionForeground) + .padding(.top, 6) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(T3Colors.primaryAction) - .foregroundStyle(T3Colors.primaryActionForeground) - .padding(.top, 6) + .padding(.top, 82) + .padding(.bottom, 28) + .padding(.horizontal, 28) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @MainActor + private func retryUnreachableEnvironments() { + guard unreachableRetry.begin() else { return } + Task { @MainActor in + defer { unreachableRetry.finish() } + await model.reload() } - .padding(.horizontal, 28) - .frame(maxWidth: .infinity) } private var selectedProject: FeatureProject? { @@ -536,6 +587,10 @@ public struct NewThreadView: View { DailyUXCreationContext.projects(in: model.snapshot) } + private var unreachableEnvironments: [FeatureEnvironment] { + DailyUXCreationContext.unreachableEnvironments(in: model.snapshot) + } + private var creationProjectIDs: [String] { creationProjectGroups.flatMap(\.projects).map(\.id) } @@ -665,10 +720,13 @@ public struct NewThreadView: View { } private var submissionValidationMessage: String? { - guard selectedProject != nil else { return "Choose a project." } - if let selectedEnvironment { - guard selectedEnvironment.isEnabled else { return "Environment is off." } + if let environmentMessage = DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: projectID, + in: model.snapshot + ) { + return environmentMessage } + guard selectedProject != nil else { return "Choose a project." } guard restoredDraftProjectID == projectID else { return "Project is loading." } guard concreteSelection != nil else { guard !creationProviders.isEmpty else { return "No providers available." } @@ -1318,54 +1376,89 @@ private struct NewTaskProjectPicker: View { let environments: [FeatureEnvironment] let recentGroupIDs: [String] let selectionID: String? + let retryState: NewTaskRetryState + let onRetry: () -> Void let onSelect: (DailyUXProjectGroup) -> Void @State private var query = "" var body: some View { NavigationStack { - Group { - if groups.isEmpty { - ContentUnavailableView( - "No projects", - systemImage: "folder" - ) - } else if filteredGroups.isEmpty { - ContentUnavailableView( + let presentation = NewTaskProjectPickerPresentation( + groups: groups, + filteredGroups: filteredGroups, + unavailableEnvironments: unreachableEnvironments + ) + List { + switch presentation.projectContent { + case .noProjects: + projectUnavailableRow("No projects", systemImage: "folder") + case .noMatches: + projectUnavailableRow( "No matching projects", systemImage: "magnifyingglass" ) - } else { + case .projects: let sections = DailyUXProjectPickerSections( groups: filteredGroups, recentGroupIDs: recentGroupIDs ) - List { - if sections.recents.isEmpty { - ForEach(sections.others) { group in + if sections.recents.isEmpty { + ForEach(sections.others) { group in + projectRow(group) + } + } else { + Section("Recent") { + ForEach(sections.recents) { group in projectRow(group) } - } else { - Section("Recent") { - ForEach(sections.recents) { group in + } + + if !sections.others.isEmpty { + Section("Other projects") { + ForEach(sections.others) { group in projectRow(group) } } + } + } + } + + if !presentation.unavailableEnvironments.isEmpty { + Section("Unavailable environments") { + VStack(alignment: .leading, spacing: 8) { + ForEach(presentation.visibleUnavailableEnvironments) { environment in + Label( + "\(environment.name) is unreachable", + systemImage: "network.slash" + ) + } - if !sections.others.isEmpty { - Section("Other projects") { - ForEach(sections.others) { group in - projectRow(group) - } - } + if presentation.additionalUnavailableEnvironmentCount > 0 { + Text( + "And \(presentation.additionalUnavailableEnvironmentCount) more" + ) + .foregroundStyle(T3Colors.textTertiary) } } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .accessibilityElement(children: .ignore) + .accessibilityLabel(presentation.unavailableAccessibilityLabel) + .accessibilityIdentifier( + "new-task-unreachable-environments-notice" + ) + + Button(retryState.buttonTitle, action: onRetry) + .disabled(retryState.isInProgress) + .accessibilityHint("Refresh environment status") + .accessibilityIdentifier("new-task-project-picker-retry") } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .scrollDismissesKeyboard(.interactively) } } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) .background(T3Colors.background) .navigationTitle("Project") .navigationBarTitleDisplayMode(.inline) @@ -1380,6 +1473,21 @@ private struct NewTaskProjectPicker: View { .presentationBackground(T3Colors.background) } + private func projectUnavailableRow(_ title: String, systemImage: String) -> some View { + ContentUnavailableView { + Label { + Text(title) + } icon: { + Image(systemName: systemImage) + } + } description: { + EmptyView() + } + .frame(maxWidth: .infinity, minHeight: 220) + .listRowSeparator(.hidden) + .listRowBackground(T3Colors.background) + } + private func projectRow(_ group: DailyUXProjectGroup) -> some View { Button { onSelect(group) @@ -1424,6 +1532,10 @@ private struct NewTaskProjectPicker: View { ) } + private var unreachableEnvironments: [FeatureEnvironment] { + DailyUXCreationContext.unreachableEnvironments(in: environments) + } + private func projectLocation(_ group: DailyUXProjectGroup) -> String { guard let firstProject = group.projects.first else { return "" } diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 307f65ab3063..ccc2c7c9b697 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -507,14 +507,20 @@ public struct WorkspaceView: View { } .buttonStyle(.plain) .accessibilityLabel("New task") - .accessibilityHint( - creationProjects.isEmpty - ? "Create a project to start a task" - : "Compose a message and start a thread" - ) + .accessibilityHint(newTaskAccessibilityHint) .accessibilityIdentifier("sidebar-new-task-button") } + private var newTaskAccessibilityHint: String { + if !creationProjects.isEmpty { + return "Compose a message and start a thread" + } + if !DailyUXCreationContext.unreachableEnvironments(in: model.snapshot).isEmpty { + return "Review unreachable environments and try again" + } + return "Create a project to start a task" + } + private var projectFilter: some View { HStack(spacing: 0) { Menu { @@ -643,11 +649,12 @@ public struct WorkspaceView: View { } private func openNewTaskOrProjectCreation(initialProjectID: String?) { - if creationProjects.isEmpty { - showingAddProject = true - } else { + switch DailyUXCreationContext.newTaskDestination(in: model.snapshot) { + case .newTask: newTaskInitialProjectID = initialProjectID showingNewTask = true + case .addProject: + showingAddProject = true } } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index a8fff20bf14c..1adcc159b668 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -1228,6 +1228,286 @@ struct DailyUXNewTaskTests { #expect(sections.others.isEmpty) } + @Test + func newTaskAvailabilityOnlyTreatsEnabledDisconnectedEnvironmentsAsUnreachable() throws { + let environments = [ + FeatureEnvironment( + id: "disconnected", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + FeatureEnvironment( + id: "reconnecting", + name: "Travel Mac", + endpoint: "http://travel", + connectionState: .reconnecting + ), + FeatureEnvironment( + id: "connecting", + name: "New Mac", + endpoint: "http://new", + connectionState: .connecting + ), + FeatureEnvironment( + id: "connected", + name: "Desk Mac", + endpoint: "http://desk", + connectionState: .connected + ), + FeatureEnvironment( + id: "unknown", + name: "Unknown Mac", + endpoint: "http://unknown" + ), + FeatureEnvironment( + id: "disabled", + name: "Disabled Mac", + endpoint: "http://disabled", + isEnabled: false, + connectionState: .disconnected + ), + ] + + // A reconnecting environment can still serve work through HTTP. + #expect( + DailyUXCreationContext.unreachableEnvironments(in: environments).map(\.id) + == ["disconnected"] + ) + + let projects = environments.map { environment in + rankedProject( + "\(environment.id)-project", + name: environment.name, + environmentID: environment.id + ) + } + let snapshot = rankedSnapshot( + environments: environments, + projects: projects, + threads: [] + ) + + #expect( + DailyUXCreationContext.projects(in: snapshot).map(\.environmentID) + == ["disconnected", "reconnecting", "connecting", "connected", "unknown"] + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "disconnected-project", + in: snapshot + ) == nil + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "reconnecting-project", + in: snapshot + ) == nil + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "connected-project", + in: snapshot + ) == nil + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "disabled-project", + in: snapshot + ) == "Environment is off." + ) + + let disconnectedProject = try #require( + projects.first { $0.environmentID == "disconnected" } + ) + let recoveredSnapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "disconnected", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .reconnecting + ), + ], + projects: [disconnectedProject], + threads: [] + ) + + #expect( + DailyUXCreationContext.projects(in: recoveredSnapshot).map(\.id) + == [disconnectedProject.id] + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: disconnectedProject.id, + in: recoveredSnapshot + ) == nil + ) + + let legacySnapshot = rankedSnapshot( + environments: [], + projects: [disconnectedProject], + threads: [] + ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: disconnectedProject.id, + in: legacySnapshot + ) == nil + ) + } + + @Test + func newTaskRouteOpensForUnreachableEnvironmentsWithoutProjects() { + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ], + projects: [], + threads: [] + ) + + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .newTask) + } + + @Test + func newTaskRouteStillUsesProjectCreationWhenNothingIsReachableOrKnownUnreachable() { + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "connecting", + name: "New Mac", + endpoint: "http://new", + connectionState: .connecting + ), + FeatureEnvironment( + id: "disabled", + name: "Disabled Mac", + endpoint: "http://disabled", + isEnabled: false, + connectionState: .disconnected + ), + ], + projects: [], + threads: [] + ) + + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .addProject) + } + + @Test + func newTaskRouteKeepsReachableProjectsWhenUnreachableEnvironmentsCoexist() { + let project = rankedProject( + "reachable-project", + name: "Reachable", + environmentID: "connected" + ) + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "connected", + name: "Desk Mac", + endpoint: "http://desk", + connectionState: .connected + ), + FeatureEnvironment( + id: "unreachable", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ], + projects: [project], + threads: [] + ) + + #expect(DailyUXCreationContext.projects(in: snapshot).map(\.id) == [project.id]) + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .newTask) + #expect( + DailyUXCreationContext.unreachableEnvironments(in: snapshot).map(\.name) + == ["Studio Mac"] + ) + } + + @Test + func unreachableRetryIsSingleFlightAndPresentsProgress() { + var retry = NewTaskRetryState() + + #expect(!retry.isInProgress) + #expect(retry.buttonTitle == "Try again") + let didBegin = retry.begin() + #expect(didBegin) + #expect(retry.isInProgress) + #expect(retry.buttonTitle == "Trying again…") + let duplicateBegin = retry.begin() + #expect(!duplicateBegin) + + retry.finish() + + #expect(!retry.isInProgress) + let didRestart = retry.begin() + #expect(didRestart) + } + + @Test + func zeroSearchMatchesKeepTheUnavailableEnvironmentNotice() { + let project = rankedProject("reachable", name: "Reachable") + let groups = DailyUXProjectGrouping.groups(projects: [project]) + let unavailable = [ + FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ] + let filtered = NewTaskProjectPickerSearch.matching( + groups, + query: "no result", + environments: unavailable + ) + + let presentation = NewTaskProjectPickerPresentation( + groups: groups, + filteredGroups: filtered, + unavailableEnvironments: unavailable + ) + + #expect(presentation.projectContent == .noMatches) + #expect(presentation.unavailableEnvironments.map(\.name) == ["Studio Mac"]) + } + + @Test + func boundedUnavailableNoticeExposesEveryEnvironmentNameToAccessibility() { + let unavailable = (1 ... 5).map { index in + FeatureEnvironment( + id: "environment-\(index)", + name: "Environment \(index)", + endpoint: "http://environment-\(index)", + connectionState: .disconnected + ) + } + let presentation = NewTaskProjectPickerPresentation( + groups: [], + filteredGroups: [], + unavailableEnvironments: unavailable + ) + + #expect( + presentation.visibleUnavailableEnvironments.map(\.name) + == ["Environment 1", "Environment 2", "Environment 3"] + ) + #expect(presentation.additionalUnavailableEnvironmentCount == 2) + for environment in unavailable { + #expect(presentation.unavailableAccessibilityLabel.contains(environment.name)) + } + } + private func rankedProject( _ id: String, name: String, diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift index d2124b81f8fb..efe44a3eb9b1 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -337,6 +337,78 @@ struct FeatureRootModelTests { #expect(try await store.submissions() == [submission]) } + @Test + func offlineQueuedTaskKeepsItsProjectAvailableInNewTask() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-offline-picker-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let project = FeatureProject( + id: "project-1", environmentID: "environment-1", name: "Native", path: "/native", + repositoryIdentity: .init(canonicalKey: "github.com/example/native") + ) + let otherProject = FeatureProject( + id: "project-2", environmentID: "environment-2", name: "Native", path: "/other/native", + repositoryIdentity: .init(canonicalKey: "github.com/example/native") + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", name: "Studio", endpoint: "https://studio.example", + isActive: true, connectionState: .connected + ), + .init( + id: "environment-2", name: "Laptop", endpoint: "https://laptop.example", + connectionState: .connected + ), + ], + projects: [project, otherProject] + ) + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + let selected = try #require(DailyUXCreationContext.initialProject( + in: model.snapshot, requestedProjectID: project.id + )) + let draftKey = FeatureComposerDraftStore.newTaskKey(project: selected, in: model.snapshot) + let projectGroups = DailyUXCreationContext.projectGroups(in: model.snapshot) + + client.snapshot.connection.state = .disconnected + client.snapshot.environments[0].connectionState = .disconnected + client.startTaskError = URLError(.notConnectedToInternet) + await model.reload() + let retained = try #require(DailyUXCreationContext.projects(in: model.snapshot).first { + $0.id == selected.id + }) + + #expect(DailyUXCreationContext.projectGroups(in: model.snapshot) == projectGroups) + #expect(DailyUXCreationContext.initialProject( + in: model.snapshot, requestedProjectID: selected.id + )?.id == project.id) + #expect(FeatureComposerDraftStore.newTaskKey(project: retained, in: model.snapshot) == draftKey) + #expect(DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: selected.id, in: model.snapshot + ) == nil) + + let thread = try #require(await model.startTask(NewTaskRequest( + projectID: project.id, + prompt: "Keep this task until the computer reconnects", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard + ))) + let queued = try await store.submissions() + + #expect(queued.count == 1) + #expect(queued.first?.threadID == thread.id) + #expect(queued.first?.creation?.projectID == project.id) + #expect( + DailyUXCreationContext.projects(in: model.snapshot).contains { $0.id == project.id }, + "New Task must retain the project that its durable outbox can queue while offline." + ) + } + @Test func cancellingAnOfflineTaskRemovesItsDurableSubmission() async throws { let directory = FileManager.default.temporaryDirectory