From 7211bedc88778f43b99e9b77f52214328ff4f4b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 14:44:13 +0000 Subject: [PATCH 1/6] ios: unify host connection status in nav bar - Extend ADEConnectionDot with Connected/Disconnected/Connecting/Error label, red dot when offline, and optional host suffix when live - Remove tab-root and nested ADEConnection banners for offline/cached/sync copy (Work, Lanes, PRs, Files); rely on toolbar indicator instead - Align Work, PRs, and Files root toolbars on ADEConnectionDot; add dot to lane detail, files directory/detail, work session, and new-chat flows - Drop unused lane/files connection notice helpers and trim tests accordingly Co-authored-by: Arul Sharma --- .../Views/Components/ADEDesignSystem.swift | 28 ++- .../Files/FilesDetailScreen+Actions.swift | 20 -- .../ADE/Views/Files/FilesDetailScreen.swift | 30 +-- .../FilesDirectoryContentsView+Actions.swift | 21 -- .../Files/FilesDirectoryContentsView.swift | 6 - .../Views/Files/FilesDirectoryScreen.swift | 6 +- apps/ios/ADE/Views/Files/FilesModels.swift | 56 ----- .../Views/Files/FilesRootScreen+Actions.swift | 63 ------ .../ios/ADE/Views/Files/FilesRootScreen.swift | 11 +- .../Lanes/LaneConnectionPresentation.swift | 206 ------------------ .../ADE/Views/Lanes/LaneDetailScreen.swift | 35 +-- .../ADE/Views/Lanes/LaneRootStateViews.swift | 26 --- apps/ios/ADE/Views/LanesTabView.swift | 4 - apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 76 +------ .../Work/WorkArtifactTerminalViews.swift | 12 - .../ADE/Views/Work/WorkChatSessionView.swift | 12 - .../ADE/Views/Work/WorkNewChatScreen.swift | 3 + apps/ios/ADE/Views/Work/WorkPreviews.swift | 1 - .../Views/Work/WorkRootScreen+Actions.swift | 58 ----- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 11 +- .../Work/WorkSessionDestinationView.swift | 6 +- apps/ios/ADETests/ADETests.swift | 66 ------ 22 files changed, 46 insertions(+), 711 deletions(-) diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 135d77892..17728bd2b 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -466,18 +466,23 @@ struct ADEConnectionDot: View { switch syncService.connectionState { case .connected, .syncing: return ADEColor.success case .connecting: return ADEColor.warning - case .error: return ADEColor.danger - case .disconnected: return ADEColor.textMuted + case .error, .disconnected: return ADEColor.danger } } - private var showsHostName: Bool { + private var statusText: String { switch syncService.connectionState { - case .connected, .syncing: return true - default: return false + case .connected, .syncing: return "Connected" + case .connecting: return "Connecting" + case .error: return "Error" + case .disconnected: return "Disconnected" } } + private var showsConnectedGlow: Bool { + syncService.connectionState == .connected || syncService.connectionState == .syncing + } + private var truncatedHostName: String? { guard let rawName = syncService.hostName else { return nil } let cleaned = rawName @@ -498,7 +503,7 @@ struct ADEConnectionDot: View { return "Connected. Tap to open settings." case .connecting: return "Connecting. Tap to open settings." case .error: return "Connection error. Tap to open settings." - case .disconnected: return "Not connected. Tap to open settings." + case .disconnected: return "Disconnected. Tap to open settings." } } @@ -510,8 +515,15 @@ struct ADEConnectionDot: View { Circle() .fill(tint) .frame(width: 10, height: 10) - .shadow(color: tint.opacity(showsHostName ? 0.5 : 0), radius: showsHostName ? 4 : 0) - if showsHostName, let name = truncatedHostName { + .shadow(color: tint.opacity(showsConnectedGlow ? 0.5 : 0), radius: showsConnectedGlow ? 4 : 0) + Text(statusText) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if showsConnectedGlow, let name = truncatedHostName { + Text("·") + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textMuted) Text(name) .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textSecondary) diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift index 20c119835..4f41120a0 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift @@ -87,24 +87,4 @@ extension FilesDetailScreen { hasLoadedDiff = true } - var disconnectedNotice: ADENoticeCard { - ADENoticeCard( - title: "Read-only while disconnected", - message: needsRepairing - ? "Pair again before trusting cached file previews, metadata, history, or diffs." - : "The last-loaded file preview, metadata, history, and diff stay visible, but refresh waits for the host to reconnect.", - icon: "icloud.slash", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? "Open Settings" : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible(userInitiated: true) - } - } - } - ) - } } diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index da32d8d98..5a911b77c 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -9,7 +9,6 @@ struct FilesDetailScreen: View { let relativePath: String let focusLine: Int? let isFilesLive: Bool - let needsRepairing: Bool let transitionNamespace: Namespace.ID? let navigateToDirectory: (String) -> Void @@ -94,12 +93,15 @@ struct FilesDetailScreen: View { .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .topBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") + HStack(spacing: 10) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + } + .accessibilityLabel("Back") + ADEConnectionDot() } - .accessibilityLabel("Back") } ToolbarItem(placement: .topBarTrailing) { Button { @@ -157,22 +159,6 @@ struct FilesDetailScreen: View { } ) - if !isFilesLive { - FilesCompactBanner( - symbol: "icloud.slash", - tint: ADEColor.warning, - title: needsRepairing ? "Pair again to trust this cached view." : "Disconnected — showing last-loaded content.", - actionTitle: syncService.activeHostProfile == nil ? "Settings" : "Reconnect", - onAction: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { await syncService.reconnectIfPossible(userInitiated: true) } - } - } - ) - } - if let errorMessage { FilesCompactBanner( symbol: "exclamationmark.triangle.fill", diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift index 1c18f8adc..3ed7e30ca 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift @@ -42,27 +42,6 @@ extension FilesDirectoryContentsView { return (workspace.rootPath as NSString).appendingPathComponent(relativePath) } - var disconnectedNotice: ADENoticeCard { - ADENoticeCard( - title: nodes.isEmpty ? "Reconnect to load this folder" : "Showing cached directory", - message: needsRepairing - ? "The previous host trust was cleared. Pair again before trusting refreshed file state." - : "Cached rows stay browseable, but refresh and search wait for the host to reconnect.", - icon: "icloud.slash", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? "Open Settings" : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible(userInitiated: true) - } - } - } - ) - } - struct DirectoryReloadKey: Hashable { let workspaceId: String let parentPath: String diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift index 9556c6f85..fe99493b7 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift @@ -8,8 +8,6 @@ struct FilesDirectoryContentsView: View { let showHidden: Bool let isLive: Bool let isTabActive: Bool - let needsRepairing: Bool - let showDisconnectedNotice: Bool let openDirectory: (String) -> Void let openFile: (String, Int?) -> Void let transitionNamespace: Namespace.ID? @@ -21,10 +19,6 @@ struct FilesDirectoryContentsView: View { var body: some View { LazyVStack(alignment: .leading, spacing: 12) { - if showDisconnectedNotice && !isLive { - disconnectedNotice - } - if let errorMessage { ADENoticeCard( title: "Directory load failed", diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift b/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift index bc5647b0f..aaf433a26 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift @@ -8,7 +8,6 @@ struct FilesDirectoryScreen: View { @Binding var showHidden: Bool let isLive: Bool let isTabActive: Bool - let needsRepairing: Bool let openDirectory: (String) -> Void let openFile: (String, Int?) -> Void let transitionNamespace: Namespace.ID? @@ -31,8 +30,6 @@ struct FilesDirectoryScreen: View { showHidden: showHidden, isLive: isLive, isTabActive: isTabActive, - needsRepairing: needsRepairing, - showDisconnectedNotice: true, openDirectory: openDirectory, openFile: openFile, transitionNamespace: transitionNamespace, @@ -48,6 +45,9 @@ struct FilesDirectoryScreen: View { .adeNavigationGlass() .navigationTitle(parentPath.isEmpty ? "Root" : lastPathComponent(parentPath)) .toolbar { + ToolbarItem(placement: .topBarLeading) { + ADEConnectionDot() + } ToolbarItemGroup(placement: .topBarTrailing) { Button { showHidden.toggle() diff --git a/apps/ios/ADE/Views/Files/FilesModels.swift b/apps/ios/ADE/Views/Files/FilesModels.swift index e33a7d04b..9074c81cd 100644 --- a/apps/ios/ADE/Views/Files/FilesModels.swift +++ b/apps/ios/ADE/Views/Files/FilesModels.swift @@ -16,12 +16,6 @@ enum FilesSearchKind { case textSearch } -struct FilesBrowserStatusPresentation: Equatable { - let title: String - let message: String - let actionTitle: String? -} - struct FilesBreadcrumbItem: Equatable { let label: String let path: String @@ -164,56 +158,6 @@ func resolveFilesWorkspace(for request: FilesNavigationRequest, in workspaces: [ return nil } -func filesBrowserStatusPresentation( - status: SyncDomainStatus, - hasCachedWorkspaces: Bool, - hasActiveHostProfile: Bool, - needsRepairing: Bool -) -> FilesBrowserStatusPresentation? { - switch status.phase { - case .disconnected: - if hasCachedWorkspaces { - return FilesBrowserStatusPresentation( - title: "Showing cached workspaces", - message: needsRepairing - ? "Workspace metadata and cached directory snapshots are still visible, but you need to pair again before trusting the host or refreshing Files." - : "Workspace metadata and cached directory snapshots stay visible on iPhone, but quick open, text search, and refresh need the host to reconnect.", - actionTitle: hasActiveHostProfile ? "Reconnect" : "Open Settings" - ) - } - - return FilesBrowserStatusPresentation( - title: "Host disconnected", - message: hasActiveHostProfile - ? "Reconnect to hydrate workspace roots, browse live directories, and run quick open or text search from Files." - : (needsRepairing - ? "The previous pairing was cleared. Open Settings to pair again before Files can trust or hydrate workspace data." - : "Pair with a host from Settings to hydrate workspace roots before browsing files on iPhone."), - actionTitle: hasActiveHostProfile ? "Reconnect" : "Open Settings" - ) - case .hydrating: - return FilesBrowserStatusPresentation( - title: "Hydrating workspaces", - message: "Files uses lane hydration for workspace roots. Waiting for the latest host lane data before browsing continues.", - actionTitle: nil - ) - case .syncingInitialData: - return FilesBrowserStatusPresentation( - title: "Syncing initial data", - message: "Waiting for the host to finish syncing project and lane metadata before Files loads the workspace browser.", - actionTitle: nil - ) - case .failed: - return FilesBrowserStatusPresentation( - title: "Workspace hydration failed", - message: status.lastError ?? "The lane graph did not hydrate, so Files cannot trust its workspace model yet.", - actionTitle: "Retry" - ) - case .ready: - return nil - } -} - func filesSearchEmptyMessage(kind: FilesSearchKind, isLive: Bool, needsRepairing: Bool, query: String) -> String { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let label: String = switch kind { diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift b/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift index ae2d12bae..d62f4c331 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift @@ -32,15 +32,6 @@ extension FilesRootScreen { filesStatus.phase == .hydrating || filesStatus.phase == .syncingInitialData } - var statusPresentation: FilesBrowserStatusPresentation? { - filesBrowserStatusPresentation( - status: filesStatus, - hasCachedWorkspaces: !workspaces.isEmpty, - hasActiveHostProfile: syncService.activeHostProfile != nil, - needsRepairing: needsRepairing - ) - } - var quickOpenEmptyMessage: String { filesSearchEmptyMessage(kind: .quickOpen, isLive: canUseLiveFileActions, needsRepairing: needsRepairing, query: quickOpenQuery) } @@ -49,60 +40,6 @@ extension FilesRootScreen { filesSearchEmptyMessage(kind: .textSearch, isLive: canUseLiveFileActions, needsRepairing: needsRepairing, query: textSearchQuery) } - @ViewBuilder - func statusNoticeCard(_ presentation: FilesBrowserStatusPresentation) -> some View { - ADENoticeCard( - title: presentation.title, - message: presentation.message, - icon: statusNoticeIcon, - tint: statusNoticeTint, - actionTitle: presentation.actionTitle, - action: presentation.actionTitle == nil ? nil : handleStatusNoticeAction - ) - } - - var statusNoticeIcon: String { - switch filesStatus.phase { - case .disconnected: - return "icloud.slash" - case .hydrating, .syncingInitialData: - return "arrow.trianglehead.2.clockwise.rotate.90" - case .failed: - return "exclamationmark.triangle.fill" - case .ready: - return "folder" - } - } - - var statusNoticeTint: Color { - switch filesStatus.phase { - case .disconnected, .syncingInitialData: - return ADEColor.warning - case .hydrating, .ready: - return ADEColor.accent - case .failed: - return ADEColor.danger - } - } - - func handleStatusNoticeAction() { - switch filesStatus.phase { - case .disconnected: - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible(userInitiated: true) - await reload(refreshRemote: true) - } - } - case .failed: - Task { await reload(refreshRemote: true) } - case .hydrating, .syncingInitialData, .ready: - break - } - } - @MainActor func refreshFromPullGesture() async { await reload(refreshRemote: true) diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index c3158e078..57fbb586f 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -27,11 +27,6 @@ struct FilesRootScreen: View { NavigationStack(path: $navigationPath) { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { - if let presentation = statusPresentation { - statusNoticeCard(presentation) - .transition(.opacity) - } - if let errorMessage, filesStatus.phase == .ready { ADENoticeCard( title: "Files view error", @@ -151,8 +146,6 @@ struct FilesRootScreen: View { showHidden: showHidden, isLive: canUseLiveFileActions, isTabActive: isTabActive, - needsRepairing: needsRepairing, - showDisconnectedNotice: false, openDirectory: { path in openDirectory(path, in: workspace) }, @@ -185,7 +178,6 @@ struct FilesRootScreen: View { showHidden: $showHidden, isLive: canUseLiveFileActions, isTabActive: isTabActive, - needsRepairing: needsRepairing, openDirectory: { path in openDirectory(path, in: workspace) }, @@ -212,7 +204,6 @@ struct FilesRootScreen: View { relativePath: relativePath, focusLine: focusLine, isFilesLive: canUseLiveFileActions, - needsRepairing: needsRepairing, transitionNamespace: transitionNamespace, navigateToDirectory: { path in openDirectory(path, in: workspace) @@ -232,7 +223,7 @@ struct FilesRootScreen: View { } .toolbar { ToolbarItem(placement: .topBarLeading) { - ADEConnectionPill() + ADEConnectionDot() } ToolbarItem(placement: .topBarTrailing) { Button { diff --git a/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift b/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift index 8ff498ebe..b6bced291 100644 --- a/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift +++ b/apps/ios/ADE/Views/Lanes/LaneConnectionPresentation.swift @@ -6,36 +6,6 @@ enum LaneConnectionNoticeAction: Equatable { case retry } -enum LaneConnectionNoticeTintRole: Equatable { - case accent - case warning - case danger - case secondary - - var color: Color { - switch self { - case .accent: - return ADEColor.accent - case .warning: - return ADEColor.warning - case .danger: - return ADEColor.danger - case .secondary: - return ADEColor.textSecondary - } - } -} - -struct LaneConnectionNoticePresentation: Equatable { - let title: String - let message: String - let icon: String - let tintRole: LaneConnectionNoticeTintRole - let actionTitle: String? - let action: LaneConnectionNoticeAction? - let allowsLiveActions: Bool -} - struct LaneEmptyStatePresentation: Equatable { let symbol: String let title: String @@ -56,94 +26,6 @@ func laneAllowsDiffInspection( hasCachedTargets || laneAllowsLiveActions(connectionState: connectionState, laneStatus: laneStatus) } -func laneRootConnectionNotice( - connectionState: RemoteConnectionState, - laneStatus: SyncDomainStatus, - hasCachedLanes: Bool, - hasHostProfile: Bool, - needsRepairing: Bool -) -> LaneConnectionNoticePresentation? { - let allowsLiveActions = laneAllowsLiveActions(connectionState: connectionState, laneStatus: laneStatus) - if allowsLiveActions { - return nil - } - - if laneStatus.phase == .failed { - return LaneConnectionNoticePresentation( - title: "Lane hydration failed", - message: laneStatus.lastError ?? "Lane hydration did not complete.", - icon: "exclamationmark.triangle.fill", - tintRole: .danger, - actionTitle: "Retry", - action: .retry, - allowsLiveActions: false - ) - } - - if connectionState == .connecting { - return LaneConnectionNoticePresentation( - title: hasCachedLanes ? "Reconnecting to lanes" : "Connecting to host", - message: hasCachedLanes - ? "Cached lanes stay visible while ADE reconnects to refresh live state." - : "Looking for the current lane graph from the host.", - icon: "bolt.horizontal.circle", - tintRole: .accent, - actionTitle: nil, - action: nil, - allowsLiveActions: false - ) - } - - if laneStatus.phase == .hydrating { - return LaneConnectionNoticePresentation( - title: hasCachedLanes ? "Refreshing lane graph" : "Hydrating lane graph", - message: hasCachedLanes - ? "Cached lanes remain visible while the latest lane graph loads." - : "Pulling lane snapshots from the host.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tintRole: .accent, - actionTitle: nil, - action: nil, - allowsLiveActions: false - ) - } - - if connectionState == .syncing || laneStatus.phase == .syncingInitialData { - return LaneConnectionNoticePresentation( - title: hasCachedLanes ? "Syncing live lane state" : "Syncing initial data", - message: hasCachedLanes - ? "Cached lanes remain readable while sync catches up. Live actions will unlock when sync finishes." - : "Waiting for the host to finish syncing before lanes load.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tintRole: .warning, - actionTitle: nil, - action: nil, - allowsLiveActions: false - ) - } - - if connectionState == .disconnected || connectionState == .error || laneStatus.phase == .disconnected { - let offlineAction = laneOfflineAction(hasHostProfile: hasHostProfile, needsRepairing: needsRepairing) - return LaneConnectionNoticePresentation( - title: hasCachedLanes ? "Showing cached lanes" : "Host disconnected", - message: hasCachedLanes - ? (needsRepairing - ? "Cached lanes remain visible, but host trust was cleared. Pair again before running live lane actions." - : "Cached lane state stays visible. Reconnect to refresh or run live lane actions.") - : (hasHostProfile - ? "Reconnect to load the current lane graph from the host." - : "Pair with a host to load the current lane graph."), - icon: "bolt.horizontal.circle", - tintRole: .warning, - actionTitle: offlineAction?.title, - action: offlineAction?.action, - allowsLiveActions: false - ) - } - - return nil -} - func laneRootEmptyState( connectionState: RemoteConnectionState, laneStatus: SyncDomainStatus, @@ -175,94 +57,6 @@ func laneRootEmptyState( return nil } -func laneDetailConnectionNotice( - connectionState: RemoteConnectionState, - laneStatus: SyncDomainStatus, - hasCachedDetail: Bool, - hasHostProfile: Bool, - needsRepairing: Bool -) -> LaneConnectionNoticePresentation? { - let allowsLiveActions = laneAllowsLiveActions(connectionState: connectionState, laneStatus: laneStatus) - if allowsLiveActions { - return nil - } - - if laneStatus.phase == .failed { - return LaneConnectionNoticePresentation( - title: "Lane detail failed", - message: laneStatus.lastError ?? "Lane detail did not load.", - icon: "exclamationmark.triangle.fill", - tintRole: .danger, - actionTitle: "Retry", - action: .retry, - allowsLiveActions: false - ) - } - - if connectionState == .connecting { - return LaneConnectionNoticePresentation( - title: hasCachedDetail ? "Reconnecting to lane detail" : "Connecting to lane detail", - message: hasCachedDetail - ? "Cached lane detail stays visible while ADE reconnects to refresh live git state." - : "Waiting for the host to load this lane detail.", - icon: "bolt.horizontal.circle", - tintRole: .accent, - actionTitle: nil, - action: nil, - allowsLiveActions: false - ) - } - - if laneStatus.phase == .hydrating { - return LaneConnectionNoticePresentation( - title: hasCachedDetail ? "Refreshing lane detail" : "Hydrating lane detail", - message: hasCachedDetail - ? "Cached lane detail remains visible while the latest lane state loads." - : "Pulling lane detail, git status, and conflict state from the host.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tintRole: .accent, - actionTitle: nil, - action: nil, - allowsLiveActions: false - ) - } - - if connectionState == .syncing || laneStatus.phase == .syncingInitialData { - return LaneConnectionNoticePresentation( - title: hasCachedDetail ? "Syncing live lane detail" : "Syncing lane detail", - message: hasCachedDetail - ? "Cached lane detail remains visible while sync catches up. Live git actions unlock when sync finishes." - : "Waiting for the host to finish syncing before this lane detail becomes live.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tintRole: .warning, - actionTitle: nil, - action: nil, - allowsLiveActions: false - ) - } - - if connectionState == .disconnected || connectionState == .error || laneStatus.phase == .disconnected { - let offlineAction = laneOfflineAction(hasHostProfile: hasHostProfile, needsRepairing: needsRepairing) - return LaneConnectionNoticePresentation( - title: hasCachedDetail ? "Showing cached lane detail" : "Lane detail offline", - message: hasCachedDetail - ? (needsRepairing - ? "Cached lane context stays visible, but host trust was cleared. Pair again before staging, committing, or resolving conflicts." - : "Cached lane context stays visible. Reconnect before staging, committing, pushing, or resolving conflicts.") - : (hasHostProfile - ? "Reconnect to load git status, conflicts, and stack context for this lane." - : "Pair with a host to load live lane detail on iPhone."), - icon: "icloud.slash", - tintRole: .secondary, - actionTitle: offlineAction?.title, - action: offlineAction?.action, - allowsLiveActions: false - ) - } - - return nil -} - func laneDetailEmptyState( connectionState: RemoteConnectionState, laneStatus: SyncDomainStatus, diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index 054d33584..3fe5a7723 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -93,10 +93,6 @@ struct LaneDetailScreen: View { .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } - if let detailConnectionNotice { - connectionNoticeCard(detailConnectionNotice) - } - rebaseBannerSection detailHeader @@ -115,6 +111,11 @@ struct LaneDetailScreen: View { .scrollBounceBehavior(.basedOnSize) .navigationTitle(detail?.lane.name ?? initialSnapshot.lane.name) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + ADEConnectionDot() + } + } .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "lane-container-\(laneId)", in: transitionNamespace) .task { syncService.announceLaneOpen(laneId: laneId) @@ -280,16 +281,6 @@ struct LaneDetailScreen: View { laneAllowsLiveActions(connectionState: syncService.connectionState, laneStatus: syncService.status(for: .lanes)) } - private var detailConnectionNotice: LaneConnectionNoticePresentation? { - laneDetailConnectionNotice( - connectionState: syncService.connectionState, - laneStatus: syncService.status(for: .lanes), - hasCachedDetail: detail != nil, - hasHostProfile: syncService.activeHostProfile != nil, - needsRepairing: syncService.activeHostProfile == nil && detail != nil - ) - } - private var detailEmptyStatePresentation: LaneEmptyStatePresentation? { laneDetailEmptyState( connectionState: syncService.connectionState, @@ -391,22 +382,6 @@ struct LaneDetailScreen: View { syncService.requestedPrNavigation = PrNavigationRequest(prId: pr.id, laneId: pr.laneId) } - @ViewBuilder - private func connectionNoticeCard(_ presentation: LaneConnectionNoticePresentation) -> some View { - ADENoticeCard( - title: presentation.title, - message: presentation.message, - icon: presentation.icon, - tint: presentation.tintRole.color, - actionTitle: presentation.actionTitle, - action: presentation.action.map { action in - { - handleNoticeAction(action) - } - } - ) - } - @ViewBuilder private func detailEmptyStateCard(_ presentation: LaneEmptyStatePresentation) -> some View { ADEEmptyStateView(symbol: presentation.symbol, title: presentation.title, message: presentation.message) { diff --git a/apps/ios/ADE/Views/Lanes/LaneRootStateViews.swift b/apps/ios/ADE/Views/Lanes/LaneRootStateViews.swift index c98dedd6f..267e636ce 100644 --- a/apps/ios/ADE/Views/Lanes/LaneRootStateViews.swift +++ b/apps/ios/ADE/Views/Lanes/LaneRootStateViews.swift @@ -1,16 +1,6 @@ import SwiftUI extension LanesTabView { - var statusNotice: LaneConnectionNoticePresentation? { - laneRootConnectionNotice( - connectionState: syncService.connectionState, - laneStatus: laneStatus, - hasCachedLanes: !laneSnapshots.isEmpty, - hasHostProfile: syncService.activeHostProfile != nil, - needsRepairing: needsRepairing - ) - } - var emptyStatePresentation: LaneEmptyStatePresentation? { laneRootEmptyState( connectionState: syncService.connectionState, @@ -57,22 +47,6 @@ extension LanesTabView { } } - @ViewBuilder - func noticeCard(_ presentation: LaneConnectionNoticePresentation) -> some View { - ADENoticeCard( - title: presentation.title, - message: presentation.message, - icon: presentation.icon, - tint: presentation.tintRole.color, - actionTitle: presentation.actionTitle, - action: presentation.action.map { action in - { - handleNoticeAction(action) - } - } - ) - } - @ViewBuilder func emptyStateCard(_ presentation: LaneEmptyStatePresentation) -> some View { ADEEmptyStateView(symbol: presentation.symbol, title: presentation.title, message: presentation.message) { diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index d45ac1e38..2d04a87ec 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -55,10 +55,6 @@ struct LanesTabView: View { NavigationStack { ScrollView { LazyVStack(spacing: 14) { - if let statusNotice { - noticeCard(statusNotice) - .transition(.opacity) - } if let errorMessage, laneStatus.phase == .ready { ADENoticeCard( title: "Lane view error", diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 8d655dfc5..74d7f4f8b 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -218,10 +218,6 @@ struct PRsTabView: View { var body: some View { NavigationStack(path: $path) { List { - if let statusNotice { - statusNotice.prListRow() - } - if let notice = laneContextNotice { notice.prListRow() } @@ -294,7 +290,7 @@ struct PRsTabView: View { .searchable(text: $searchText, prompt: selectedRootSurface.wrappedValue == .github ? "Search PRs, branches, authors" : "Search workflow cards") .toolbar { ToolbarItem(placement: .topBarLeading) { - ADEConnectionPill() + ADEConnectionDot() } ToolbarItemGroup(placement: .topBarTrailing) { Button { @@ -417,18 +413,6 @@ struct PRsTabView: View { ) .prListRow() - if githubSnapshot == nil && !isLive && !prs.isEmpty { - ADENoticeCard( - title: "Showing cached ADE PRs", - message: "Reconnect to see repository, external, ADE, and status filters from the desktop GitHub tab.", - icon: "wifi.slash", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - .prListRow() - } - if prsStatus.phase == .ready && filteredPrs.isEmpty && filteredGitHubPrs.isEmpty { ADEEmptyStateView( symbol: searchText.isEmpty ? "arrow.triangle.pull" : "magnifyingglass", @@ -830,64 +814,6 @@ struct PRsTabView: View { UIApplication.shared.open(url) } - private var statusNotice: ADENoticeCard? { - switch prsStatus.phase { - case .disconnected: - return ADENoticeCard( - title: prs.isEmpty ? "Host disconnected" : "Showing cached PRs", - message: prs.isEmpty - ? (syncService.activeHostProfile == nil - ? "Pair with a host to hydrate pull requests, stacks, and workflow state." - : "Reconnect to hydrate pull requests, stacks, and workflow state.") - : (needsRepairing - ? "Cached PR state is still visible, but the previous host trust was cleared. Pair again before trusting review or workflow status." - : "Cached PR state is visible. Reconnect before trusting live merge, review, or queue readiness."), - icon: "arrow.triangle.pull", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? (needsRepairing ? "Pair again" : "Pair with host") : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible(userInitiated: true) - await reload(refreshRemote: true) - } - } - } - ) - case .hydrating: - return ADENoticeCard( - title: "Hydrating pull requests", - message: "Refreshing PR summaries, stack relationships, and cached detail so iPhone does not show partial state.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.accent, - actionTitle: nil, - action: nil - ) - case .syncingInitialData: - return ADENoticeCard( - title: "Syncing initial data", - message: "Waiting for the host to finish syncing project data before PR hydration starts.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .failed: - return ADENoticeCard( - title: "PR hydration failed", - message: prsStatus.lastError ?? "The host PR state did not hydrate cleanly.", - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload(refreshRemote: true) } } - ) - case .ready: - return nil - } - } - // MARK: - Legacy → unified workflow card adapters // // These let the root screen keep rendering queue/integration/rebase state when the mobile diff --git a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift index 36470c69d..7e23a6560 100644 --- a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift +++ b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift @@ -125,7 +125,6 @@ struct WorkArtifactView: View { struct WorkTerminalSessionView: View { @EnvironmentObject var syncService: SyncService let session: TerminalSessionSummary - let disconnectedNotice: Bool let transitionNamespace: Namespace.ID? let onOpenLane: (() -> Void)? @@ -143,17 +142,6 @@ struct WorkTerminalSessionView: View { onOpenLane: onOpenLane ) - if disconnectedNotice { - ADENoticeCard( - title: "Showing cached terminal output", - message: "Reconnect to resume live ANSI rendering for this session.", - icon: "wifi.slash", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } - if terminalDisplay.truncated { ADENoticeCard( title: "Showing recent output", diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 84dca0a55..0e2175340 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -28,7 +28,6 @@ struct WorkChatSessionView: View { @State var timelineRebuildTask: Task? @State var timelineRebuildGeneration = 0 let isLive: Bool - let disconnectedNotice: Bool let transitionNamespace: Namespace.ID? let onOpenLane: (() -> Void)? let onSend: @MainActor (String) async -> Bool @@ -194,17 +193,6 @@ struct WorkChatSessionView: View { } } - if disconnectedNotice { - ADENoticeCard( - title: "Connection lost", - message: "Cached messages stay visible, but sending, streaming, and artifact refresh are paused until the host reconnects.", - icon: "wifi.slash", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } - if let errorMessage { ADENoticeCard( title: "Chat error", diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index e95e29a06..2aac01a0c 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -77,6 +77,9 @@ struct WorkNewChatScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) .toolbar { + ToolbarItem(placement: .topBarLeading) { + ADEConnectionDot() + } ToolbarItem(placement: .topBarTrailing) { if busy { ProgressView().controlSize(.small) diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index db82119ad..0af47535a 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -225,7 +225,6 @@ private enum WorkPreviewData { sending: .constant(false), errorMessage: .constant(nil), isLive: true, - disconnectedNotice: false, transitionNamespace: nil, onOpenLane: {}, onSend: { _ in true }, diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 40a14d955..923b66cc7 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -365,62 +365,4 @@ extension WorkRootScreen { ) } - var statusNotice: ADENoticeCard? { - let hasCachedSessions = !mergedSessions.isEmpty - switch workStatus.phase { - case .disconnected: - return ADENoticeCard( - title: hasCachedSessions ? "Showing cached work" : "Host disconnected", - message: hasCachedSessions - ? (needsRepairing - ? "Cached chats and terminal sessions stay readable, but the previous host trust was cleared. Pair again before trusting active work state." - : "Cached chats and terminal sessions stay readable. Reconnect to stream output, refresh status, or start a new chat.") - : (syncService.activeHostProfile == nil - ? "Pair with a host to create chats, stream tool activity, and fetch proof artifacts." - : "Reconnect to create chats, stream transcripts, and refresh agent activity."), - icon: "terminal", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? (needsRepairing ? "Pair again" : "Pair with host") : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible(userInitiated: true) - await reload(refreshRemote: true) - } - } - } - ) - case .hydrating: - return ADENoticeCard( - title: "Hydrating work sessions", - message: "Pulling host sessions, chat metadata, and proof artifacts so Work matches the desktop chat surface.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.accent, - actionTitle: nil, - action: nil - ) - case .syncingInitialData: - return ADENoticeCard( - title: "Syncing initial data", - message: "Waiting for the host to finish syncing core project data before Work hydrates session state.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .failed: - return ADENoticeCard( - title: "Work hydration failed", - message: workStatus.lastError ?? "The host session list did not hydrate cleanly.", - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload(refreshRemote: true) } } - ) - case .ready: - return nil - } - } } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 0acb04420..30fdd3606 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -222,12 +222,6 @@ struct WorkRootScreen: View { NavigationStack(path: $path) { ScrollViewReader { proxy in List { - if let statusNotice { - statusNotice - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - if isLoadingSkeleton { ForEach(0..<3, id: \.self) { _ in ADECardSkeleton(rows: 3) @@ -337,7 +331,7 @@ struct WorkRootScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - ADEConnectionPill() + ADEConnectionDot() } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 8) { @@ -397,8 +391,7 @@ struct WorkRootScreen: View { initialChatSummary: chatSummaries[route.sessionId], initialTranscript: transcriptCache[route.sessionId], transitionNamespace: routeTransitionNamespace, - isLive: isLive, - disconnectedNotice: !isLive + isLive: isLive ) .environmentObject(syncService) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index c6930b604..1ae3d3f55 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -12,7 +12,6 @@ struct WorkSessionDestinationView: View { let initialTranscript: [WorkChatEnvelope]? let transitionNamespace: Namespace.ID? let isLive: Bool - let disconnectedNotice: Bool @State var session: TerminalSessionSummary? @State var chatSummary: AgentChatSessionSummary? @@ -79,6 +78,9 @@ struct WorkSessionDestinationView: View { .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) .toolbar { + ToolbarItem(placement: .topBarLeading) { + ADEConnectionDot() + } ToolbarItem(placement: .topBarTrailing) { sessionHeaderTrailingControls } @@ -134,7 +136,6 @@ struct WorkSessionDestinationView: View { sending: $sending, errorMessage: $errorMessage, isLive: isLive, - disconnectedNotice: disconnectedNotice, transitionNamespace: transitionNamespace, onOpenLane: openSessionLane, onSend: sendMessage, @@ -157,7 +158,6 @@ struct WorkSessionDestinationView: View { } else { WorkTerminalSessionView( session: session, - disconnectedNotice: disconnectedNotice, transitionNamespace: transitionNamespace, onOpenLane: openSessionLane ) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9d2a9efca..107cdd9d7 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1733,25 +1733,6 @@ final class ADETests: XCTestCase { ) } - func testLaneRootConnectionNoticeKeepsCachedLanesVisibleWhileOffline() { - let notice = laneRootConnectionNotice( - connectionState: .disconnected, - laneStatus: .disconnected, - hasCachedLanes: true, - hasHostProfile: true, - needsRepairing: false - ) - - XCTAssertEqual(notice?.title, "Showing cached lanes") - XCTAssertEqual(notice?.actionTitle, "Reconnect") - XCTAssertEqual(notice?.action, .reconnect) - XCTAssertFalse(notice?.allowsLiveActions ?? true) - XCTAssertEqual( - notice?.message, - "Cached lane state stays visible. Reconnect to refresh or run live lane actions." - ) - } - func testLaneRootEmptyStateGuidesUnpairedUsersWhenNoCacheExists() { let emptyState = laneRootEmptyState( connectionState: .disconnected, @@ -1764,24 +1745,6 @@ final class ADETests: XCTestCase { XCTAssertEqual(emptyState?.action, .openSettings) } - func testLaneDetailNoticeDisablesLiveActionsWhileSyncing() { - let notice = laneDetailConnectionNotice( - connectionState: .syncing, - laneStatus: SyncDomainStatus(phase: .ready, lastError: nil, lastHydratedAt: nil), - hasCachedDetail: true, - hasHostProfile: true, - needsRepairing: false - ) - - XCTAssertEqual(notice?.title, "Syncing live lane detail") - XCTAssertFalse(notice?.allowsLiveActions ?? true) - XCTAssertNil(notice?.action) - XCTAssertEqual( - notice?.message, - "Cached lane detail remains visible while sync catches up. Live git actions unlock when sync finishes." - ) - } - func testLaneDetailEmptyStateSurfacesRetryWhenHydrationFailsWithoutCache() { let emptyState = laneDetailEmptyState( connectionState: .connected, @@ -2231,35 +2194,6 @@ final class ADETests: XCTestCase { XCTAssertEqual(resolveFilesWorkspace(for: request, in: workspaces)?.id, "workspace-lane-2") } - func testFilesBrowserStatusPresentationKeepsCachedWorkspacesExplicitWhileOffline() { - let presentation = filesBrowserStatusPresentation( - status: SyncDomainStatus(phase: .disconnected), - hasCachedWorkspaces: true, - hasActiveHostProfile: true, - needsRepairing: false - ) - - XCTAssertEqual(presentation?.title, "Showing cached workspaces") - XCTAssertEqual(presentation?.actionTitle, "Reconnect") - XCTAssertEqual( - presentation?.message, - "Workspace metadata and cached directory snapshots stay visible on iPhone, but quick open, text search, and refresh need the host to reconnect." - ) - } - - func testFilesBrowserStatusPresentationUsesFailureCopyWhenWorkspaceHydrationFails() { - let presentation = filesBrowserStatusPresentation( - status: SyncDomainStatus(phase: .failed, lastError: "Lane hydration timed out"), - hasCachedWorkspaces: false, - hasActiveHostProfile: false, - needsRepairing: false - ) - - XCTAssertEqual(presentation?.title, "Workspace hydration failed") - XCTAssertEqual(presentation?.message, "Lane hydration timed out") - XCTAssertEqual(presentation?.actionTitle, "Retry") - } - func testFilesSearchEmptyMessageReflectsLiveAndQueryState() { XCTAssertEqual( filesSearchEmptyMessage(kind: .quickOpen, isLive: false, needsRepairing: false, query: ""), From 446540dab7c73fe4423c49ea9b2a8993b63eba52 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 15:00:51 +0000 Subject: [PATCH 2/6] ios: harden connection dot for a11y, syncing, and deep screens - Treat RemoteConnectionState.syncing as its own Syncing label and tint - VoiceOver: action hint, error context from lastError, Large Content Viewer - Dynamic Type: minimumScaleFactor on connection label row; 44pt min hit height - Add ADEConnectionDot to PR detail and lane diff; widen file detail back tap Co-authored-by: Arul Sharma --- .../Views/Components/ADEDesignSystem.swift | 46 +++++++++++++++---- .../ADE/Views/Files/FilesDetailScreen.swift | 2 + apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift | 3 ++ apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 5 ++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 17728bd2b..e541afaf4 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -464,7 +464,8 @@ struct ADEConnectionDot: View { private var tint: Color { switch syncService.connectionState { - case .connected, .syncing: return ADEColor.success + case .connected: return ADEColor.success + case .syncing: return ADEColor.warning case .connecting: return ADEColor.warning case .error, .disconnected: return ADEColor.danger } @@ -472,15 +473,20 @@ struct ADEConnectionDot: View { private var statusText: String { switch syncService.connectionState { - case .connected, .syncing: return "Connected" + case .connected: return "Connected" + case .syncing: return "Syncing" case .connecting: return "Connecting" case .error: return "Error" case .disconnected: return "Disconnected" } } + private var showsHostSuffix: Bool { + syncService.connectionState == .connected + } + private var showsConnectedGlow: Bool { - syncService.connectionState == .connected || syncService.connectionState == .syncing + syncService.connectionState == .connected } private var truncatedHostName: String? { @@ -495,15 +501,28 @@ struct ADEConnectionDot: View { } private var accessibilityLabel: String { + let errorSuffix: String = { + guard syncService.connectionState == .error, let err = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), !err.isEmpty else { + return "" + } + let clipped = err.count > 120 ? String(err.prefix(117)) + "…" : err + return ". \(clipped)" + }() + switch syncService.connectionState { - case .connected, .syncing: + case .connected: if let name = syncService.hostName, !name.isEmpty { - return "Connected to \(name). Tap to open settings." + return "Connected to \(name)" } - return "Connected. Tap to open settings." - case .connecting: return "Connecting. Tap to open settings." - case .error: return "Connection error. Tap to open settings." - case .disconnected: return "Disconnected. Tap to open settings." + return "Connected" + case .syncing: + return "Syncing with host" + case .connecting: + return "Connecting to host" + case .error: + return "Connection error\(errorSuffix)" + case .disconnected: + return "Disconnected from host" } } @@ -520,19 +539,26 @@ struct ADEConnectionDot: View { .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) - if showsConnectedGlow, let name = truncatedHostName { + .minimumScaleFactor(0.75) + if showsHostSuffix, let name = truncatedHostName { Text("·") .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textMuted) + .minimumScaleFactor(0.75) Text(name) .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textSecondary) .lineLimit(1) + .minimumScaleFactor(0.75) } } + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(accessibilityLabel) + .accessibilityHint("Opens settings to pair or reconnect.") + .accessibilityShowsLargeContentViewer() } } diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index 5a911b77c..a7a6ebfac 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -100,6 +100,8 @@ struct FilesDetailScreen: View { Image(systemName: "chevron.left") } .accessibilityLabel("Back") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) ADEConnectionDot() } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift index 79b5a3479..14aad9369 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift @@ -114,6 +114,9 @@ struct LaneDiffScreen: View { .navigationTitle(request.title) .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .topBarLeading) { + ADEConnectionDot() + } ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index d7cea7e3f..0770ab213 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -241,6 +241,11 @@ struct PrDetailView: View { .adeNavigationGlass() .navigationTitle(currentPr.title) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + ADEConnectionDot() + } + } .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "pr-container-\(prId)", in: transitionNamespace) .task(id: syncService.localStateRevision) { await reload() From 45f785100535511b25f3f26aeb4d49b69193595f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 18:08:55 +0000 Subject: [PATCH 3/6] fix: address PR review items and flaky CI (desktop shard 7) iOS: - Show PR view error Retry whenever errorMessage is set (not only when prs ready) - Remove unused WorkRootScreen.needsRepairing - Normalize lastError whitespace for VoiceOver; remove unused ADEConnectionPill - FilesHeaderStrip derives live/offline from SyncService; drop isFilesLive plumbing Desktop: - CommandPalette: use globalThis timers so teardown cannot reference undefined window - githubService tests: clear GITHUB_TOKEN/ADE_GITHUB_TOKEN in beforeEach for CI env - workerTracking: resolve planner_plan_missing interventions when a planning-like step succeeds with non-empty plan markdown (unblocks mission status) Co-authored-by: Arul Sharma --- .../services/github/githubService.test.ts | 3 + .../services/orchestrator/workerTracking.ts | 77 +++++++++++++++++++ .../components/app/CommandPalette.tsx | 12 ++- .../Views/Components/ADEDesignSystem.swift | 46 ++--------- .../Views/Files/FilesDetailComponents.swift | 10 ++- .../ADE/Views/Files/FilesDetailScreen.swift | 2 - .../ios/ADE/Views/Files/FilesRootScreen.swift | 1 - apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 2 +- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 4 - 9 files changed, 105 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 1ba476477..23fd7daf0 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -91,6 +91,9 @@ function jsonResponse( describe("githubService.apiRequest", () => { beforeEach(() => { vi.clearAllMocks(); + // Tests assume no ambient token; CI/agents often inject GITHUB_TOKEN globally. + delete process.env.GITHUB_TOKEN; + delete process.env.ADE_GITHUB_TOKEN; }); it("returns data and response on success (HTTP 200)", async () => { diff --git a/apps/desktop/src/main/services/orchestrator/workerTracking.ts b/apps/desktop/src/main/services/orchestrator/workerTracking.ts index 182c79b66..2fcc55fa5 100644 --- a/apps/desktop/src/main/services/orchestrator/workerTracking.ts +++ b/apps/desktop/src/main/services/orchestrator/workerTracking.ts @@ -219,6 +219,76 @@ function resolvePlannerPlanMissingIntervention(args: { }; } +function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { + ctx: OrchestratorContext; + deps: UpdateWorkerStateDeps; + missionId: string; + attempt: OrchestratorRunGraph["attempts"][number]; + step: OrchestratorRunGraph["steps"][number]; +}): void { + const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {}; + const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; + const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; + const readOnlyExecution = stepMeta.readOnlyExecution === true; + const planningLike = + readOnlyExecution + || phaseKey === "planning" + || stepType === "planning" + || stepType === "analysis"; + if (!planningLike) return; + + const lastResultReport = isRecord(stepMeta.lastResultReport) ? stepMeta.lastResultReport : null; + const reportedPlan = lastResultReport && isRecord(lastResultReport.plan) ? lastResultReport.plan : null; + const planMarkdown = + reportedPlan && typeof reportedPlan.markdown === "string" ? reportedPlan.markdown.trim() : ""; + if (!planMarkdown.length) return; + + const mission = args.ctx.missionService.get(args.missionId); + if (!mission) return; + + const resolvedAt = nowIso(); + for (const intervention of mission.interventions) { + if (intervention.status !== "open" || intervention.interventionType !== "failed_step") continue; + const meta = isRecord(intervention.metadata) ? intervention.metadata : {}; + const reasonCode = typeof meta.reasonCode === "string" ? meta.reasonCode.trim() : ""; + if (reasonCode !== "planner_plan_missing") continue; + const interventionRunId = typeof meta.runId === "string" ? meta.runId.trim() : ""; + if (interventionRunId.length > 0 && interventionRunId !== args.attempt.runId) continue; + + try { + args.ctx.missionService.resolveIntervention({ + missionId: args.missionId, + interventionId: intervention.id, + status: "resolved", + note: `Auto-resolved after planner returned report_result.plan for step "${stepTitleForMessage(args.step)}".`, + }); + args.deps.recordRuntimeEvent({ + runId: args.attempt.runId, + stepId: args.step.id, + attemptId: args.attempt.id, + sessionId: args.attempt.executorSessionId, + eventType: "intervention_resolved", + eventKey: `intervention_resolved:${intervention.id}:planner_plan_recovered`, + payload: { + interventionId: intervention.id, + reason: "planner_plan_recovered", + recoveredByStepId: args.step.id, + recoveredByStepKey: args.step.stepKey, + resolvedAt, + }, + }); + } catch (error) { + args.ctx.logger.debug("ai_orchestrator.planner_plan_missing_resolve_failed", { + missionId: args.missionId, + runId: args.attempt.runId, + stepId: args.step.id, + interventionId: intervention.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + function resolveRecoveredFailedStepInterventions(args: { ctx: OrchestratorContext; deps: UpdateWorkerStateDeps; @@ -1275,6 +1345,13 @@ export function updateWorkerStateFromEventCtx( attempt, step, }); + resolvePlannerPlanMissingInterventionsAfterPlanningSuccess({ + ctx, + deps, + missionId: graph.run.missionId, + attempt, + step, + }); } if (step && ctx.aiIntegrationService) { const runtimeProfile = ctx.runRuntimeProfiles.get(attempt.runId) ?? resolveActiveRuntimeProfile(ctx, graph.run.missionId); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index a2a0c178b..0df638199 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -21,6 +21,14 @@ import { fadeScale } from "../../lib/motion"; import { useAppStore } from "../../state/appStore"; import { cn } from "../ui/cn"; +function schedulePaletteTimeout(handler: () => void, ms: number): ReturnType { + return globalThis.setTimeout(handler, ms); +} + +function cancelPaletteTimeout(id: ReturnType) { + globalThis.clearTimeout(id); +} + export type CommandPaletteIntent = "default" | "project-browse"; type Command = { @@ -433,7 +441,7 @@ export function CommandPalette({ const requestId = ++detailRequestRef.current; setDetailLoading(true); setDetailPath(detailTarget); - const timeout = window.setTimeout(() => { + const timeout = schedulePaletteTimeout(() => { void window.ade.project .getDetail(detailTarget) .then((result) => { @@ -450,7 +458,7 @@ export function CommandPalette({ }); }, 140); return () => { - window.clearTimeout(timeout); + cancelPaletteTimeout(timeout); }; }, [detail, detailTarget, mode, open]); diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index e541afaf4..162af66b6 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -421,44 +421,6 @@ struct ADEStatusPill: View { } } -struct ADEConnectionPill: View { - @EnvironmentObject private var syncService: SyncService - - private var tint: Color { - switch syncService.connectionState { - case .connected, .syncing: return ADEColor.success - case .connecting: return ADEColor.accent - case .disconnected, .error: return ADEColor.danger - } - } - - private var label: String { - switch syncService.connectionState { - case .connected, .syncing: return "Connected" - case .connecting: return "Connecting" - case .disconnected: return "Not connected" - case .error: return "Offline" - } - } - - var body: some View { - Button { - syncService.settingsPresented = true - } label: { - HStack(spacing: 6) { - Circle() - .fill(tint) - .frame(width: 8, height: 8) - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - } - } - .buttonStyle(.plain) - .accessibilityLabel("Connection: \(label). Tap to open settings.") - } -} - struct ADEConnectionDot: View { @EnvironmentObject private var syncService: SyncService @@ -502,10 +464,14 @@ struct ADEConnectionDot: View { private var accessibilityLabel: String { let errorSuffix: String = { - guard syncService.connectionState == .error, let err = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), !err.isEmpty else { + guard syncService.connectionState == .error, + let raw = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return "" } - let clipped = err.count > 120 ? String(err.prefix(117)) + "…" : err + let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let clipped = normalized.count > 120 ? String(normalized.prefix(117)) + "…" : normalized return ". \(clipped)" }() diff --git a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index 393b80833..d37d26805 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -2,12 +2,18 @@ import SwiftUI import UIKit struct FilesHeaderStrip: View { + @EnvironmentObject private var syncService: SyncService + let relativePath: String let language: FilesLanguage let fileSize: Int - let isFilesLive: Bool let transitionNamespace: Namespace.ID? + private var filesBrowserIsLive: Bool { + syncService.status(for: .files).phase == .ready + && (syncService.connectionState == .connected || syncService.connectionState == .syncing) + } + var body: some View { HStack(alignment: .center, spacing: 12) { Image(systemName: fileIcon(for: relativePath)) @@ -38,7 +44,7 @@ struct FilesHeaderStrip: View { Text("Read only") .font(.caption2.weight(.medium)) .foregroundStyle(ADEColor.textSecondary) - if !isFilesLive { + if !filesBrowserIsLive { Text("·").foregroundStyle(ADEColor.textMuted) Text("Offline") .font(.caption2.weight(.semibold)) diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index a7a6ebfac..3e39ddff8 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -8,7 +8,6 @@ struct FilesDetailScreen: View { let workspace: FilesWorkspace let relativePath: String let focusLine: Int? - let isFilesLive: Bool let transitionNamespace: Namespace.ID? let navigateToDirectory: (String) -> Void @@ -176,7 +175,6 @@ struct FilesDetailScreen: View { relativePath: relativePath, language: language, fileSize: blob.size, - isFilesLive: isFilesLive, transitionNamespace: transitionNamespace ) diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index 57fbb586f..cf5e55a8a 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -203,7 +203,6 @@ struct FilesRootScreen: View { workspace: workspace, relativePath: relativePath, focusLine: focusLine, - isFilesLive: canUseLiveFileActions, transitionNamespace: transitionNamespace, navigateToDirectory: { path in openDirectory(path, in: workspace) diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 74d7f4f8b..1e01147cb 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -228,7 +228,7 @@ struct PRsTabView: View { .prListRow() } } else { - if let errorMessage, prsStatus.phase == .ready { + if let errorMessage { ADENoticeCard( title: "PR view error", message: errorMessage, diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 30fdd3606..15b2d484f 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -58,10 +58,6 @@ struct WorkRootScreen: View { workStatus.phase == .ready && (syncService.connectionState == .connected || syncService.connectionState == .syncing) } - var needsRepairing: Bool { - syncService.activeHostProfile == nil && !mergedSessions.isEmpty - } - var isLoadingSkeleton: Bool { workStatus.phase == .hydrating || workStatus.phase == .syncingInitialData } From 54d05d248a459fb479da55827caf43052150ce0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 00:37:57 +0000 Subject: [PATCH 4/6] fix: address Capy/Greptile review on hydration errors and planner resolution Restore PR tab error card gating to .ready while showing domain .failed notices when cached rows remain. Add the same hydration failure card for Lanes, Work, and Files. Narrow planner_plan_missing auto-resolve to true planning steps so read-only implementation work cannot clear the intervention. Co-authored-by: Arul Sharma --- .../services/orchestrator/workerTracking.ts | 11 ++++----- apps/ios/ADE/Models/RemoteModels.swift | 24 +++++++++++++++++++ .../ios/ADE/Views/Files/FilesRootScreen.swift | 11 +++++++++ apps/ios/ADE/Views/LanesTabView.swift | 11 +++++++++ apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 13 +++++++++- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 12 ++++++++++ 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/orchestrator/workerTracking.ts b/apps/desktop/src/main/services/orchestrator/workerTracking.ts index 2fcc55fa5..8b6c9b912 100644 --- a/apps/desktop/src/main/services/orchestrator/workerTracking.ts +++ b/apps/desktop/src/main/services/orchestrator/workerTracking.ts @@ -229,13 +229,10 @@ function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {}; const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; - const readOnlyExecution = stepMeta.readOnlyExecution === true; - const planningLike = - readOnlyExecution - || phaseKey === "planning" - || stepType === "planning" - || stepType === "analysis"; - if (!planningLike) return; + // Match `extractAndRegisterArtifacts` planning detection: read-only implementation steps must not + // auto-resolve `planner_plan_missing` unless ADE would persist the canonical plan artifact. + const isPlanningStep = stepType === "planning" || stepType === "analysis" || phaseKey === "planning"; + if (!isPlanningStep) return; const lastResultReport = isRecord(stepMeta.lastResultReport) ? stepMeta.lastResultReport : null; const reportedPlan = lastResultReport && isRecord(lastResultReport.plan) ? lastResultReport.plan : null; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 98c35d2b5..33916575a 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -128,6 +128,30 @@ struct SyncDomainStatus: Equatable { static let disconnected = SyncDomainStatus(phase: .disconnected) } +extension SyncDomainStatus { + /// Inline notice when the domain is in `.failed` but cached rows may still render (no empty-state card). + func inlineHydrationFailureNotice(for domain: SyncDomain) -> (title: String, message: String)? { + guard phase == .failed else { return nil } + let trimmed = lastError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let message = + trimmed.isEmpty + ? "Fresh data could not be loaded from the host. Cached content may be outdated until you retry or reconnect." + : trimmed + let title: String + switch domain { + case .lanes: + title = "Lane hydration failed" + case .files: + title = "Files hydration failed" + case .work: + title = "Work hydration failed" + case .prs: + title = "PR hydration failed" + } + return (title, message) + } +} + struct LaneStatus: Codable, Equatable { var dirty: Bool var ahead: Int diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index cf5e55a8a..7aa2305f2 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -27,6 +27,17 @@ struct FilesRootScreen: View { NavigationStack(path: $navigationPath) { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { + if let hydrationNotice = filesStatus.inlineHydrationFailureNotice(for: .files) { + ADENoticeCard( + title: hydrationNotice.title, + message: hydrationNotice.message, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: true) } } + ) + .transition(.opacity) + } if let errorMessage, filesStatus.phase == .ready { ADENoticeCard( title: "Files view error", diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index 2d04a87ec..17434376e 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -55,6 +55,17 @@ struct LanesTabView: View { NavigationStack { ScrollView { LazyVStack(spacing: 14) { + if let hydrationNotice = laneStatus.inlineHydrationFailureNotice(for: .lanes) { + ADENoticeCard( + title: hydrationNotice.title, + message: hydrationNotice.message, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: true) } } + ) + .transition(.opacity) + } if let errorMessage, laneStatus.phase == .ready { ADENoticeCard( title: "Lane view error", diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 1e01147cb..15c535522 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -228,7 +228,18 @@ struct PRsTabView: View { .prListRow() } } else { - if let errorMessage { + if let hydrationNotice = prsStatus.inlineHydrationFailureNotice(for: .prs) { + ADENoticeCard( + title: hydrationNotice.title, + message: hydrationNotice.message, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: true) } } + ) + .prListRow() + } + if let errorMessage, prsStatus.phase == .ready { ADENoticeCard( title: "PR view error", message: errorMessage, diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 15b2d484f..0b887a5ec 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -225,6 +225,18 @@ struct WorkRootScreen: View { .listRowSeparator(.hidden) } } else { + if let hydrationNotice = workStatus.inlineHydrationFailureNotice(for: .work) { + ADENoticeCard( + title: hydrationNotice.title, + message: hydrationNotice.message, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: true) } } + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } WorkFiltersSection( searchText: $searchText, selectedLaneId: $selectedLaneId, From 91fd90c6aace472de1e3013fcdec856c6792c7e9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:01:09 -0400 Subject: [PATCH 5/6] fix: address remaining PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize host-error whitespace on iOS inline notices so multiline host errors read cleanly in the UI and VoiceOver. Use the truncated host name in the connection-dot accessibility label to match the visible text. FilesHeaderStrip now distinguishes Syncing/Connecting from Offline so the header matches the toolbar dot during normal sync. FilesRootScreen renders a disconnected/no-workspaces fallback instead of a blank body when the Files domain is disconnected with no cache. LaneDiffScreen dropped the leading connection dot that conflicted with the sheet's Done button (cancellationAction). Orchestrator: extractAndRegisterArtifacts now reports whether the canonical plan was persisted, and resolvePlannerPlanMissingInterventions only clears the intervention when the plan file was actually written. Cross-run recovery is also allowed — a successful planning attempt on any run of the mission can clear a stale planner_plan_missing. Co-authored-by: Cursor Agent --- .../services/orchestrator/workerTracking.ts | 24 +++++++++++-------- apps/ios/ADE/Models/RemoteModels.swift | 7 +++--- .../Views/Components/ADEDesignSystem.swift | 2 +- .../Views/Files/FilesDetailComponents.swift | 20 ++++++++++++---- .../ios/ADE/Views/Files/FilesRootScreen.swift | 11 +++++---- apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift | 3 --- 6 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/services/orchestrator/workerTracking.ts b/apps/desktop/src/main/services/orchestrator/workerTracking.ts index 8b6c9b912..12aed1dc4 100644 --- a/apps/desktop/src/main/services/orchestrator/workerTracking.ts +++ b/apps/desktop/src/main/services/orchestrator/workerTracking.ts @@ -225,6 +225,7 @@ function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { missionId: string; attempt: OrchestratorRunGraph["attempts"][number]; step: OrchestratorRunGraph["steps"][number]; + planArtifactPersisted: boolean; }): void { const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {}; const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : ""; @@ -234,11 +235,10 @@ function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { const isPlanningStep = stepType === "planning" || stepType === "analysis" || phaseKey === "planning"; if (!isPlanningStep) return; - const lastResultReport = isRecord(stepMeta.lastResultReport) ? stepMeta.lastResultReport : null; - const reportedPlan = lastResultReport && isRecord(lastResultReport.plan) ? lastResultReport.plan : null; - const planMarkdown = - reportedPlan && typeof reportedPlan.markdown === "string" ? reportedPlan.markdown.trim() : ""; - if (!planMarkdown.length) return; + // Only resolve when the canonical plan artifact was actually written and registered in this attempt. + // `report_result.plan.markdown` alone is insufficient: fs.writeFileSync or registerArtifact may have + // failed inside extractAndRegisterArtifacts, and we'd otherwise clear the intervention without a plan. + if (!args.planArtifactPersisted) return; const mission = args.ctx.missionService.get(args.missionId); if (!mission) return; @@ -249,8 +249,8 @@ function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { const meta = isRecord(intervention.metadata) ? intervention.metadata : {}; const reasonCode = typeof meta.reasonCode === "string" ? meta.reasonCode.trim() : ""; if (reasonCode !== "planner_plan_missing") continue; - const interventionRunId = typeof meta.runId === "string" ? meta.runId.trim() : ""; - if (interventionRunId.length > 0 && interventionRunId !== args.attempt.runId) continue; + // Allow cross-run recovery: any successful planning attempt on this mission can clear a + // stale planner_plan_missing intervention, regardless of which run originally recorded it. try { args.ctx.missionService.resolveIntervention({ @@ -839,11 +839,12 @@ export function extractAndRegisterArtifacts( graph: OrchestratorRunGraph; attempt: OrchestratorRunGraph["attempts"][number]; } -): void { +): { planArtifactPersisted: boolean } { + let planArtifactPersisted = false; try { const { graph, attempt } = args; const envelope = attempt.resultEnvelope; - if (!envelope) return; + if (!envelope) return { planArtifactPersisted }; const outputs = isRecord(envelope.outputs) ? envelope.outputs : {}; const step = graph.steps.find((s) => s.id === attempt.stepId); @@ -1085,6 +1086,7 @@ export function extractAndRegisterArtifacts( absolutePath: absolutePlanPath, }, }); + planArtifactPersisted = true; } else { const missingPlanDetail = "Planning worker completed without returning a usable plan payload in report_result.plan.markdown."; ctx.logger.warn("ai_orchestrator.plan_artifact_missing", { @@ -1174,6 +1176,7 @@ export function extractAndRegisterArtifacts( error: error instanceof Error ? error.message : String(error) }); } + return { planArtifactPersisted }; } // ── updateWorkerStateFromEvent ─────────────────────────────────── @@ -1330,7 +1333,7 @@ export function updateWorkerStateFromEventCtx( } // Extract and register artifacts from the worker result envelope. - extractAndRegisterArtifacts(ctx, { graph, attempt }); + const artifactExtraction = extractAndRegisterArtifacts(ctx, { graph, attempt }); // Evaluation loop: evaluate step based on active runtime profile. const step = graph.steps.find((s) => s.id === attempt.stepId); @@ -1348,6 +1351,7 @@ export function updateWorkerStateFromEventCtx( missionId: graph.run.missionId, attempt, step, + planArtifactPersisted: artifactExtraction.planArtifactPersisted, }); } if (step && ctx.aiIntegrationService) { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index f7e9dc5df..0a06ab4c9 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -132,11 +132,12 @@ extension SyncDomainStatus { /// Inline notice when the domain is in `.failed` but cached rows may still render (no empty-state card). func inlineHydrationFailureNotice(for domain: SyncDomain) -> (title: String, message: String)? { guard phase == .failed else { return nil } - let trimmed = lastError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let raw = lastError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ") let message = - trimmed.isEmpty + normalized.isEmpty ? "Fresh data could not be loaded from the host. Cached content may be outdated until you retry or reconnect." - : trimmed + : normalized let title: String switch domain { case .lanes: diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 162af66b6..8b61ec8c2 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -477,7 +477,7 @@ struct ADEConnectionDot: View { switch syncService.connectionState { case .connected: - if let name = syncService.hostName, !name.isEmpty { + if let name = truncatedHostName { return "Connected to \(name)" } return "Connected" diff --git a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index d37d26805..c34ab55f5 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -9,9 +9,19 @@ struct FilesHeaderStrip: View { let fileSize: Int let transitionNamespace: Namespace.ID? - private var filesBrowserIsLive: Bool { - syncService.status(for: .files).phase == .ready - && (syncService.connectionState == .connected || syncService.connectionState == .syncing) + private var filesBrowserStatusSuffix: String? { + let phase = syncService.status(for: .files).phase + let connection = syncService.connectionState + if phase == .ready && (connection == .connected || connection == .syncing) { + return nil + } + if phase == .hydrating || phase == .syncingInitialData || connection == .syncing { + return "Syncing" + } + if connection == .connecting { + return "Connecting" + } + return "Offline" } var body: some View { @@ -44,9 +54,9 @@ struct FilesHeaderStrip: View { Text("Read only") .font(.caption2.weight(.medium)) .foregroundStyle(ADEColor.textSecondary) - if !filesBrowserIsLive { + if let filesBrowserStatusSuffix { Text("·").foregroundStyle(ADEColor.textMuted) - Text("Offline") + Text(filesBrowserStatusSuffix) .font(.caption2.weight(.semibold)) .foregroundStyle(ADEColor.warning) } diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index 7aa2305f2..852a5a049 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -55,11 +55,14 @@ struct FilesRootScreen: View { ADECardSkeleton(rows: 4) } - if filesStatus.phase == .ready && workspaces.isEmpty { + if workspaces.isEmpty && !isLoadingSkeleton { + let isDisconnected = filesStatus.phase == .disconnected || syncService.activeHostProfile == nil ADEEmptyStateView( - symbol: "folder.badge.questionmark", - title: "No workspaces available", - message: "This host does not currently expose any lane-backed workspaces for the mobile Files browser." + symbol: isDisconnected ? "wifi.slash" : "folder.badge.questionmark", + title: isDisconnected ? "Files unavailable" : "No workspaces available", + message: isDisconnected + ? "Files need a connected host. Reconnect or pair a host in Settings to browse workspaces." + : "This host does not currently expose any lane-backed workspaces for the mobile Files browser." ) { Button(syncService.activeHostProfile == nil ? "Open Settings" : "Refresh Files") { if syncService.activeHostProfile == nil { diff --git a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift index 14aad9369..79b5a3479 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift @@ -114,9 +114,6 @@ struct LaneDiffScreen: View { .navigationTitle(request.title) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { - ADEConnectionDot() - } ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } From a1fe2fc572cd6f1071703cb145e4a3bccdf9897c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:17:33 -0400 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20finalize=20=E2=80=94=20simplify,?= =?UTF-8?q?=20drop=20unused=20props,=20update=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandPalette: inline the two globalThis.setTimeout/clearTimeout wrappers at their sole call site. iOS: drop unused `needsRepairing` properties from LanesTabView and PrsRootScreen now that repair affordances live in Settings and the connection dot. Docs: document the unified iOS connection-status indicator and the new planner_plan_missing recovery contract (planArtifactPersisted gate + cross-run resolve). Co-authored-by: Cursor Agent --- .../components/app/CommandPalette.tsx | 12 +----- apps/ios/ADE/Views/LanesTabView.swift | 4 -- apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 4 -- docs/features/missions/orchestration.md | 31 ++++++++++++++ .../sync-and-multi-device/ios-companion.md | 40 +++++++++++++++++++ 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index f464a157f..d30409429 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -21,14 +21,6 @@ import { fadeScale } from "../../lib/motion"; import { useAppStore } from "../../state/appStore"; import { cn } from "../ui/cn"; -function schedulePaletteTimeout(handler: () => void, ms: number): ReturnType { - return globalThis.setTimeout(handler, ms); -} - -function cancelPaletteTimeout(id: ReturnType) { - globalThis.clearTimeout(id); -} - export type CommandPaletteIntent = "default" | "project-browse"; type Command = { @@ -442,7 +434,7 @@ export function CommandPalette({ const requestId = ++detailRequestRef.current; setDetailLoading(true); setDetailPath(detailTarget); - const timeout = schedulePaletteTimeout(() => { + const timeout = globalThis.setTimeout(() => { void window.ade.project .getDetail(detailTarget) .then((result) => { @@ -459,7 +451,7 @@ export function CommandPalette({ }); }, 140); return () => { - cancelPaletteTimeout(timeout); + globalThis.clearTimeout(timeout); }; }, [detail, detailTarget, mode, open]); diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index 17434376e..1d894fda2 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -39,10 +39,6 @@ struct LanesTabView: View { syncService.status(for: .lanes) } - var needsRepairing: Bool { - syncService.activeHostProfile == nil && !laneSnapshots.isEmpty - } - var canRunLiveActions: Bool { laneAllowsLiveActions(connectionState: syncService.connectionState, laneStatus: laneStatus) } diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 15c535522..58851bc55 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -42,10 +42,6 @@ struct PRsTabView: View { prsStatus.phase == .ready && (syncService.connectionState == .connected || syncService.connectionState == .syncing) } - private var needsRepairing: Bool { - syncService.activeHostProfile == nil && !prs.isEmpty - } - private var isLoadingSkeleton: Bool { prsStatus.phase == .hydrating || prsStatus.phase == .syncingInitialData } diff --git a/docs/features/missions/orchestration.md b/docs/features/missions/orchestration.md index 754e62b0b..b266666e5 100644 --- a/docs/features/missions/orchestration.md +++ b/docs/features/missions/orchestration.md @@ -22,6 +22,7 @@ All in `apps/desktop/src/main/services/orchestrator/`. - `metaReasoner.ts` — higher-level reasoning helpers for coordinator decisions. - `metricsAndUsage.ts` — token and cost accounting; `estimateTokenCost`. - `recoveryService.ts` — tracked session state, recovery iteration policy (`DEFAULT_RECOVERY_LOOP_POLICY`). +- `workerTracking.ts` — worker session tracking, per-attempt artifact extraction (`extractAndRegisterArtifacts`), planning-phase plan-artifact persistence gate, and `planner_plan_missing` intervention auto-resolution on successful re-planning. - `stepPolicyResolver.ts` — `ResolvedOrchestratorRuntimeConfig`, step-level policy merging, autopilot config, file-claim scope (`doFileClaimsOverlap`, `doesFileClaimMatchPath`), repo-relative path normalization. - `baseOrchestratorAdapter.ts` — `buildFullPrompt` (the worker prompt builder), shell escaping, inline decoding. - `providerOrchestratorAdapter.ts` — provider-specific launchers (Claude CLI, Codex CLI, MCP), `resolveAdeMcpServerLaunch`, `cleanupMcpConfigFile`. @@ -142,6 +143,36 @@ Every worker spawn creates a `DelegationContract`: `extractDelegationContract` / `updateDelegationContract` / `derivePlanningStartupStateFromContract` keep the contract in sync during runtime. `extractActiveDelegationContracts` surfaces the currently active contracts for the coordinator's "what's running" view. +## Planning artifact persistence and intervention recovery + +`workerTracking.ts` owns the post-attempt artifact pass for every +worker completion. `extractAndRegisterArtifacts(ctx, { graph, attempt })` +walks the attempt's `resultEnvelope`, writes the canonical plan +markdown under `.ade/missions//plan.md`, registers it via +`registerArtifact`, and returns `{ planArtifactPersisted }`. The flag +is `true` only when the plan markdown was actually written **and** +the artifact row was registered in this attempt — `report_result.plan.markdown` +alone is insufficient, because the underlying `fs.writeFileSync` or +`registerArtifact` may have failed silently. + +A `planner_plan_missing` intervention (`interventionType: "failed_step"`, +`reasonCode: "planner_plan_missing"`) is opened when the planner +completes without a usable plan. `resolvePlannerPlanMissingInterventionsAfterPlanningSuccess` +is the matching auto-resolver: on any successful planning-phase +attempt (`stepType === "planning" | "analysis"` or +`phaseKey === "planning"`), if and only if `planArtifactPersisted` is +`true`, it resolves every open `planner_plan_missing` intervention on +the mission and emits a runtime event +(`eventType: "intervention_resolved"`, +`eventKey: "intervention_resolved::planner_plan_recovered"`). + +The resolver is intentionally cross-run: any later successful +planning attempt can clear a stale intervention that was recorded by +a previous run. Without the `planArtifactPersisted` gate the resolver +could clear the intervention when the plan was never actually +written, so the persistence check is load-bearing — do not relax it +to just checking `report_result.plan`. + ## Runtime event routing `runtimeEventRouter.routeEventToCoordinator()` classifies an incoming event (worker output, CLI signal, test result, gate report) and decides whether to: diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 563274251..088735770 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -67,6 +67,46 @@ tab grew from one ~3,000-line file to ~30 focused files. Deployment target: iOS 26+. iPhone and iPad (adaptive layouts planned for Phase 7). +### Connection status UI + +Host connection status is surfaced through a single shared component, +`ADEConnectionDot` (in `Views/Components/ADEDesignSystem.swift`). It +renders a colored dot, a state label (Connected / Syncing / Connecting +/ Disconnected / Error), and the truncated host name when connected, +and acts as a 44pt button that opens Settings. Tint mapping: + +| Connection state | Color | +|---|---| +| `connected` | success (green) | +| `syncing` | warning (amber) | +| `connecting` | warning (amber) | +| `disconnected` | danger (red) | +| `error` | danger (red) | + +The dot is placed in the top-leading `ToolbarItem` of every top-level +tab (Lanes, Files, Work, PRs) and every deep screen +(`LaneDetailScreen`, `PrDetailView`, `WorkSessionDestinationView`, +`WorkNewChatScreen`, `FilesDirectoryScreen`; `FilesDetailScreen` +hosts it alongside its back-button affordance). It replaces the +older `ADEConnectionPill` and the per-tab "connection notice" banner +cards — controllers no longer ship duplicate offline / reconnect / +hydrating cards inside each screen body. + +Accessibility: the dot exposes `accessibilityLabel` that includes the +host name when connected and trims the last error message for the +error state, an `accessibilityHint` of "Opens settings to pair or +reconnect", and `accessibilityShowsLargeContentViewer()` so VoiceOver +and Large Content can reach it. + +The one remaining inline banner per tab is the hydration-failure +notice built from `SyncDomainStatus.inlineHydrationFailureNotice(for:)` +on `RemoteModels.swift`. It surfaces only when a domain is in +`.failed` phase (so cached rows may still render underneath) and +offers a single "Retry" action that calls `reload(refreshRemote: true)`. +The read-only header strip in `FilesHeaderStrip` also appends a +compact "Syncing" / "Connecting" / "Offline" suffix derived directly +from `SyncService.connectionState` and `status(for: .files).phase`. + ## Architectural pattern The implementation is deliberately small: