From 72ffbadad3ecb0184c4091fe86c531f5d081eaa6 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 24 Aug 2026 03:23:43 +1000 Subject: [PATCH 1/7] fix(swift-ios): explain unreachable task environments --- .../Features/Workspace/DailyUXModels.swift | 18 +++ .../Features/Workspace/NewThreadView.swift | 64 +++++++++ .../Features/Workspace/WorkspaceView.swift | 22 ++-- .../FeatureTests/DailyUXNewTaskTests.swift | 124 ++++++++++++++++++ 4 files changed, 220 insertions(+), 8 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index d07ded659eb7..27951452aa33 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -194,6 +194,24 @@ enum DailyUXCreationContext { } } + static func unreachableEnvironments(in snapshot: FeatureSnapshot) -> [FeatureEnvironment] { + unreachableEnvironments(in: snapshot.environments) + } + + static func unreachableEnvironments( + in environments: [FeatureEnvironment] + ) -> [FeatureEnvironment] { + environments.filter { environment in + guard environment.isEnabled else { return false } + return environment.connectionState == .disconnected + || environment.connectionState == .reconnecting + } + } + + static func shouldOpenNewTask(in snapshot: FeatureSnapshot) -> Bool { + !projects(in: snapshot).isEmpty || !unreachableEnvironments(in: snapshot).isEmpty + } + 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..bc416e6d960d 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -397,6 +397,32 @@ public struct NewThreadView: View { 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("Try again") { + Task { await model.reload() } + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityHint("Refresh environment status") + .accessibilityIdentifier("new-task-unreachable-retry") + } Button("Add project") { dismiss() Task { @MainActor in @@ -536,6 +562,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) } @@ -1360,6 +1390,36 @@ private struct NewTaskProjectPicker: View { } } } + + if !unreachableEnvironments.isEmpty { + Section("Unavailable environments") { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(unreachableEnvironments.prefix(3))) { + environment in + Label( + "\(environment.name) is unreachable", + systemImage: "network.slash" + ) + .accessibilityLabel( + "\(environment.name) is unreachable" + ) + } + + if unreachableEnvironments.count > 3 { + Text( + "And \(unreachableEnvironments.count - 3) more" + ) + .foregroundStyle(T3Colors.textTertiary) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .accessibilityElement(children: .contain) + .accessibilityIdentifier( + "new-task-unreachable-environments-notice" + ) + } + } } .listStyle(.plain) .scrollContentBackground(.hidden) @@ -1424,6 +1484,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..3dd7445c8e13 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,11 @@ public struct WorkspaceView: View { } private func openNewTaskOrProjectCreation(initialProjectID: String?) { - if creationProjects.isEmpty { - showingAddProject = true - } else { + if DailyUXCreationContext.shouldOpenNewTask(in: model.snapshot) { newTaskInitialProjectID = initialProjectID showingNewTask = true + } else { + showingAddProject = true } } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index a8fff20bf14c..c6cbf3e41c09 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -1228,6 +1228,130 @@ struct DailyUXNewTaskTests { #expect(sections.others.isEmpty) } + @Test + func newTaskAvailabilityOnlyTreatsEnabledDisconnectedOrReconnectingEnvironmentsAsUnreachable() { + 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 + ), + ] + + #expect( + DailyUXCreationContext.unreachableEnvironments(in: environments).map(\.id) + == ["disconnected", "reconnecting"] + ) + } + + @Test + func newTaskRouteOpensForUnreachableEnvironmentsWithoutProjects() { + let snapshot = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio", + connectionState: .disconnected + ), + ], + projects: [], + threads: [] + ) + + #expect(DailyUXCreationContext.shouldOpenNewTask(in: snapshot)) + } + + @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.shouldOpenNewTask(in: snapshot)) + } + + @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.shouldOpenNewTask(in: snapshot)) + #expect( + DailyUXCreationContext.unreachableEnvironments(in: snapshot).map(\.name) + == ["Studio Mac"] + ) + } + private func rankedProject( _ id: String, name: String, From be6f873d67bbda8bb9ae5880a9dc96bed828d17b Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 24 Aug 2026 04:06:11 +1000 Subject: [PATCH 2/7] fix(swift-ios): harden unreachable environment recovery --- .../Features/Workspace/DailyUXModels.swift | 72 +++++- .../Features/Workspace/NewThreadView.swift | 224 ++++++++++-------- .../Features/Workspace/WorkspaceView.swift | 5 +- .../FeatureTests/DailyUXNewTaskTests.swift | 77 +++++- 4 files changed, 277 insertions(+), 101 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 27951452aa33..0e813cb7e370 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -183,6 +183,71 @@ 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 } @@ -208,8 +273,11 @@ enum DailyUXCreationContext { } } - static func shouldOpenNewTask(in snapshot: FeatureSnapshot) -> Bool { - !projects(in: snapshot).isEmpty || !unreachableEnvironments(in: snapshot).isEmpty + 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] { diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index bc416e6d960d..6871dd2282e6 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) @@ -390,54 +390,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) - 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)" - ) + 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") } - .font(T3Typography.supporting) - .foregroundStyle(T3Colors.warning) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - - Button("Try again") { - Task { await model.reload() } + Button("Add project") { + dismiss() + Task { @MainActor in + await Task.yield() + onCreateProject() + } } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) .controlSize(.large) - .accessibilityHint("Refresh environment status") - .accessibilityIdentifier("new-task-unreachable-retry") - } - Button("Add project") { - dismiss() - Task { @MainActor in - await Task.yield() - onCreateProject() - } + .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? { @@ -1354,78 +1377,76 @@ private struct NewTaskProjectPicker: View { 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 !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 !unreachableEnvironments.isEmpty { - Section("Unavailable environments") { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(unreachableEnvironments.prefix(3))) { - environment in - Label( - "\(environment.name) is unreachable", - systemImage: "network.slash" - ) - .accessibilityLabel( - "\(environment.name) is unreachable" - ) - } - - if unreachableEnvironments.count > 3 { - Text( - "And \(unreachableEnvironments.count - 3) more" - ) - .foregroundStyle(T3Colors.textTertiary) - } - } - .font(T3Typography.supporting) - .foregroundStyle(T3Colors.warning) - .accessibilityElement(children: .contain) - .accessibilityIdentifier( - "new-task-unreachable-environments-notice" + 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" + ) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .scrollDismissesKeyboard(.interactively) } } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) .background(T3Colors.background) .navigationTitle("Project") .navigationBarTitleDisplayMode(.inline) @@ -1440,6 +1461,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) diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 3dd7445c8e13..ccc2c7c9b697 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -649,10 +649,11 @@ public struct WorkspaceView: View { } private func openNewTaskOrProjectCreation(initialProjectID: String?) { - if DailyUXCreationContext.shouldOpenNewTask(in: model.snapshot) { + switch DailyUXCreationContext.newTaskDestination(in: model.snapshot) { + case .newTask: newTaskInitialProjectID = initialProjectID showingNewTask = true - } else { + case .addProject: showingAddProject = true } } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index c6cbf3e41c09..146b2762932b 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -1290,7 +1290,7 @@ struct DailyUXNewTaskTests { threads: [] ) - #expect(DailyUXCreationContext.shouldOpenNewTask(in: snapshot)) + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .newTask) } @Test @@ -1315,7 +1315,7 @@ struct DailyUXNewTaskTests { threads: [] ) - #expect(!DailyUXCreationContext.shouldOpenNewTask(in: snapshot)) + #expect(DailyUXCreationContext.newTaskDestination(in: snapshot) == .addProject) } @Test @@ -1345,13 +1345,84 @@ struct DailyUXNewTaskTests { ) #expect(DailyUXCreationContext.projects(in: snapshot).map(\.id) == [project.id]) - #expect(DailyUXCreationContext.shouldOpenNewTask(in: snapshot)) + #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") + #expect(retry.begin()) + #expect(retry.isInProgress) + #expect(retry.buttonTitle == "Trying again…") + #expect(!retry.begin()) + + retry.finish() + + #expect(!retry.isInProgress) + #expect(retry.begin()) + } + + @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, From 3ecc0a6115ffa7c31d10056201f94067e2697186 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 24 Aug 2026 17:12:54 +1000 Subject: [PATCH 3/7] test(swift-ios): evaluate retry mutations before assertions --- .../Tests/FeatureTests/DailyUXNewTaskTests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index 146b2762932b..949daaefd496 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -1358,15 +1358,18 @@ struct DailyUXNewTaskTests { #expect(!retry.isInProgress) #expect(retry.buttonTitle == "Try again") - #expect(retry.begin()) + let didBegin = retry.begin() + #expect(didBegin) #expect(retry.isInProgress) #expect(retry.buttonTitle == "Trying again…") - #expect(!retry.begin()) + let duplicateBegin = retry.begin() + #expect(!duplicateBegin) retry.finish() #expect(!retry.isInProgress) - #expect(retry.begin()) + let didRestart = retry.begin() + #expect(didRestart) } @Test From 25353c87e9f35bca926890145173d6e55c30c6d3 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 21:06:52 +1000 Subject: [PATCH 4/7] fix(swift-ios): stop calling reconnecting environments unreachable Reconnecting is a transient state whose HTTP fallback still serves work, so announcing those environments as unreachable in the picker, empty state, and compose hint contradicted the sidebar and connection hub, which present reconnecting separately from offline. Only disconnected environments are unreachable now. Co-Authored-By: Claude --- apps/swift-ios/Features/Workspace/DailyUXModels.swift | 5 ++++- .../swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 0e813cb7e370..a2cb12cca104 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -263,13 +263,16 @@ enum DailyUXCreationContext { 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 - || environment.connectionState == .reconnecting } } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index 949daaefd496..146c594d6ab9 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -1229,7 +1229,7 @@ struct DailyUXNewTaskTests { } @Test - func newTaskAvailabilityOnlyTreatsEnabledDisconnectedOrReconnectingEnvironmentsAsUnreachable() { + func newTaskAvailabilityOnlyTreatsEnabledDisconnectedEnvironmentsAsUnreachable() { let environments = [ FeatureEnvironment( id: "disconnected", @@ -1269,9 +1269,12 @@ struct DailyUXNewTaskTests { ), ] + // Reconnecting stays excluded: HTTP fallback still serves work while + // the socket re-establishes, matching the sidebar's reconnecting + // treatment. #expect( DailyUXCreationContext.unreachableEnvironments(in: environments).map(\.id) - == ["disconnected", "reconnecting"] + == ["disconnected"] ) } From 7d93ff0995f6badbdf97514cad32c6978ab8ebd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:24:20 +1000 Subject: [PATCH 5/7] fix(swift-ios): block tasks for unreachable environments --- .../Features/Workspace/DailyUXModels.swift | 6 +++++- .../Features/Workspace/NewThreadView.swift | 12 ++++++++--- .../FeatureTests/DailyUXNewTaskTests.swift | 20 ++++++++++++++++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index a2cb12cca104..e94eb85937c0 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -252,13 +252,17 @@ enum DailyUXCreationContext { static func projects(in snapshot: FeatureSnapshot) -> [FeatureProject] { guard !snapshot.environments.isEmpty else { return snapshot.projects } let availableEnvironmentIDs = Set( - snapshot.environments.filter(\.isEnabled).map(\.id) + snapshot.environments.filter { canCreateTask(in: $0) }.map(\.id) ) return snapshot.projects.filter { availableEnvironmentIDs.contains($0.environmentID) } } + static func canCreateTask(in environment: FeatureEnvironment) -> Bool { + environment.isEnabled && environment.connectionState != .disconnected + } + static func unreachableEnvironments(in snapshot: FeatureSnapshot) -> [FeatureEnvironment] { unreachableEnvironments(in: snapshot.environments) } diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 6871dd2282e6..a7d82de18f72 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -718,10 +718,16 @@ 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 project = model.snapshot.projects.first(where: { $0.id == projectID }), + let environment = model.snapshot.environments.first(where: { + $0.id == project.environmentID + }) { + guard environment.isEnabled else { return "Environment is off." } + guard DailyUXCreationContext.canCreateTask(in: environment) else { + return "Environment is unreachable." + } } + 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." } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index 146c594d6ab9..66816489f812 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -618,7 +618,7 @@ struct DailyUXNewTaskTests { #expect( DailyUXCreationContext.projects(in: snapshot).map(\.id) - == ["active-project", "passive-project", "offline-project"] + == ["active-project", "passive-project"] ) let passiveProviders = DailyUXCreationContext.providers( for: passiveProject, @@ -1276,6 +1276,24 @@ struct DailyUXNewTaskTests { 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) + == ["reconnecting", "connecting", "connected", "unknown"] + ) } @Test From 0f8465a3d98740fdaa36ee8c9255fad4aa831d7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:32:04 +1000 Subject: [PATCH 6/7] test(swift-ios): cover stale project availability --- .../Features/Workspace/DailyUXModels.swift | 12 ++++ .../Features/Workspace/NewThreadView.swift | 13 ++-- .../FeatureTests/DailyUXNewTaskTests.swift | 65 ++++++++++++++++++- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index e94eb85937c0..c627af1edcb4 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -263,6 +263,18 @@ enum DailyUXCreationContext { environment.isEnabled && environment.connectionState != .disconnected } + 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 } + guard environment.isEnabled else { return "Environment is off." } + return canCreateTask(in: environment) ? nil : "Environment is unreachable." + } + static func unreachableEnvironments(in snapshot: FeatureSnapshot) -> [FeatureEnvironment] { unreachableEnvironments(in: snapshot.environments) } diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index a7d82de18f72..b8cd0ce4c240 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -718,14 +718,11 @@ public struct NewThreadView: View { } private var submissionValidationMessage: String? { - if let project = model.snapshot.projects.first(where: { $0.id == projectID }), - let environment = model.snapshot.environments.first(where: { - $0.id == project.environmentID - }) { - guard environment.isEnabled else { return "Environment is off." } - guard DailyUXCreationContext.canCreateTask(in: environment) else { - return "Environment is unreachable." - } + 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." } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index 66816489f812..44e7eda9200b 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -1229,7 +1229,7 @@ struct DailyUXNewTaskTests { } @Test - func newTaskAvailabilityOnlyTreatsEnabledDisconnectedEnvironmentsAsUnreachable() { + func newTaskAvailabilityOnlyTreatsEnabledDisconnectedEnvironmentsAsUnreachable() throws { let environments = [ FeatureEnvironment( id: "disconnected", @@ -1294,6 +1294,69 @@ struct DailyUXNewTaskTests { DailyUXCreationContext.projects(in: snapshot).map(\.environmentID) == ["reconnecting", "connecting", "connected", "unknown"] ) + #expect( + DailyUXCreationContext.projectEnvironmentValidationMessage( + projectID: "disconnected-project", + in: snapshot + ) == "Environment is unreachable." + ) + #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 From 7d2b1ae65885526f51f92040c23781022e2da896 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 15:16:21 -0700 Subject: [PATCH 7/7] fix(swift-ios): keep offline task projects and drafts available --- .../Features/Workspace/DailyUXModels.swift | 11 ++- .../Features/Workspace/NewThreadView.swift | 9 +++ .../FeatureTests/DailyUXNewTaskTests.swift | 10 ++- .../FeatureTests/FeatureRootModelTests.swift | 72 +++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index c627af1edcb4..9a89f1d1728b 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -251,18 +251,16 @@ struct NewTaskProjectPickerPresentation: Equatable { 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 { canCreateTask(in: $0) }.map(\.id) + snapshot.environments.filter(\.isEnabled).map(\.id) ) return snapshot.projects.filter { availableEnvironmentIDs.contains($0.environmentID) } } - static func canCreateTask(in environment: FeatureEnvironment) -> Bool { - environment.isEnabled && environment.connectionState != .disconnected - } - static func projectEnvironmentValidationMessage( projectID: String, in snapshot: FeatureSnapshot @@ -271,8 +269,7 @@ enum DailyUXCreationContext { let environment = snapshot.environments.first(where: { $0.id == project.environmentID }) else { return nil } - guard environment.isEnabled else { return "Environment is off." } - return canCreateTask(in: environment) ? nil : "Environment is unreachable." + return environment.isEnabled ? nil : "Environment is off." } static func unreachableEnvironments(in snapshot: FeatureSnapshot) -> [FeatureEnvironment] { diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index b8cd0ce4c240..e5a9fb872a51 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -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 @@ -1374,6 +1376,8 @@ 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 = "" @@ -1444,6 +1448,11 @@ private struct NewTaskProjectPicker: View { .accessibilityIdentifier( "new-task-unreachable-environments-notice" ) + + Button(retryState.buttonTitle, action: onRetry) + .disabled(retryState.isInProgress) + .accessibilityHint("Refresh environment status") + .accessibilityIdentifier("new-task-project-picker-retry") } } } diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index 44e7eda9200b..1adcc159b668 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -618,7 +618,7 @@ struct DailyUXNewTaskTests { #expect( DailyUXCreationContext.projects(in: snapshot).map(\.id) - == ["active-project", "passive-project"] + == ["active-project", "passive-project", "offline-project"] ) let passiveProviders = DailyUXCreationContext.providers( for: passiveProject, @@ -1269,9 +1269,7 @@ struct DailyUXNewTaskTests { ), ] - // Reconnecting stays excluded: HTTP fallback still serves work while - // the socket re-establishes, matching the sidebar's reconnecting - // treatment. + // A reconnecting environment can still serve work through HTTP. #expect( DailyUXCreationContext.unreachableEnvironments(in: environments).map(\.id) == ["disconnected"] @@ -1292,13 +1290,13 @@ struct DailyUXNewTaskTests { #expect( DailyUXCreationContext.projects(in: snapshot).map(\.environmentID) - == ["reconnecting", "connecting", "connected", "unknown"] + == ["disconnected", "reconnecting", "connecting", "connected", "unknown"] ) #expect( DailyUXCreationContext.projectEnvironmentValidationMessage( projectID: "disconnected-project", in: snapshot - ) == "Environment is unreachable." + ) == nil ) #expect( DailyUXCreationContext.projectEnvironmentValidationMessage( 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