Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/swift-ios/Features/Workspace/DailyUXModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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 }
Expand All @@ -160,6 +225,30 @@ enum DailyUXCreationContext {
}
}

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
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

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),
Expand Down
188 changes: 144 additions & 44 deletions apps/swift-ios/Features/Workspace/NewThreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public struct NewThreadView: View {
@State private var immediateDraftSaveTasks: [String: Task<Void, Never>] = [:]
@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
Expand Down Expand Up @@ -63,7 +64,6 @@ public struct NewThreadView: View {
topBar
if creationProjects.isEmpty {
noProjects
.padding(.top, 82)
} else {
hero
.padding(.top, 82)
Expand Down Expand Up @@ -313,28 +313,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? {
Expand Down Expand Up @@ -459,6 +508,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)
}
Expand Down Expand Up @@ -1113,48 +1166,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 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)
Expand All @@ -1169,6 +1250,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)
Expand Down Expand Up @@ -1213,6 +1309,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 "" }

Expand Down
Loading
Loading