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..12aed1dc4 100644 --- a/apps/desktop/src/main/services/orchestrator/workerTracking.ts +++ b/apps/desktop/src/main/services/orchestrator/workerTracking.ts @@ -219,6 +219,73 @@ function resolvePlannerPlanMissingIntervention(args: { }; } +function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: { + ctx: OrchestratorContext; + deps: UpdateWorkerStateDeps; + 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() : ""; + const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : ""; + // 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; + + // 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; + + 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; + // 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({ + 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; @@ -772,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); @@ -1018,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", { @@ -1107,6 +1176,7 @@ export function extractAndRegisterArtifacts( error: error instanceof Error ? error.message : String(error) }); } + return { planArtifactPersisted }; } // ── updateWorkerStateFromEvent ─────────────────────────────────── @@ -1263,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); @@ -1275,6 +1345,14 @@ export function updateWorkerStateFromEventCtx( attempt, step, }); + resolvePlannerPlanMissingInterventionsAfterPlanningSuccess({ + ctx, + deps, + missionId: graph.run.missionId, + attempt, + step, + planArtifactPersisted: artifactExtraction.planArtifactPersisted, + }); } 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 5b19e9cf9..d30409429 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -434,7 +434,7 @@ export function CommandPalette({ const requestId = ++detailRequestRef.current; setDetailLoading(true); setDetailPath(detailTarget); - const timeout = window.setTimeout(() => { + const timeout = globalThis.setTimeout(() => { void window.ade.project .getDetail(detailTarget) .then((result) => { @@ -451,7 +451,7 @@ export function CommandPalette({ }); }, 140); return () => { - window.clearTimeout(timeout); + globalThis.clearTimeout(timeout); }; }, [detail, detailTarget, mode, open]); diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 705268656..0a06ab4c9 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -128,6 +128,31 @@ 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 raw = lastError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let message = + normalized.isEmpty + ? "Fresh data could not be loaded from the host. Cached content may be outdated until you retry or reconnect." + : normalized + 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/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 135d77892..8b61ec8c2 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -421,61 +421,34 @@ struct ADEStatusPill: View { } } -struct ADEConnectionPill: View { +struct ADEConnectionDot: 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 + case .connected: return ADEColor.success + case .syncing: return ADEColor.warning + case .connecting: return ADEColor.warning + case .error, .disconnected: return ADEColor.danger } } - private var label: String { + 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 .disconnected: return "Not connected" - case .error: return "Offline" + case .error: return "Error" + case .disconnected: return "Disconnected" } } - 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.") + private var showsHostSuffix: Bool { + syncService.connectionState == .connected } -} -struct ADEConnectionDot: View { - @EnvironmentObject private var syncService: SyncService - - private var tint: Color { - switch syncService.connectionState { - case .connected, .syncing: return ADEColor.success - case .connecting: return ADEColor.warning - case .error: return ADEColor.danger - case .disconnected: return ADEColor.textMuted - } - } - - private var showsHostName: Bool { - switch syncService.connectionState { - case .connected, .syncing: return true - default: return false - } + private var showsConnectedGlow: Bool { + syncService.connectionState == .connected } private var truncatedHostName: String? { @@ -490,15 +463,32 @@ struct ADEConnectionDot: View { } private var accessibilityLabel: String { + let errorSuffix: String = { + guard syncService.connectionState == .error, + let raw = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return "" + } + let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let clipped = normalized.count > 120 ? String(normalized.prefix(117)) + "…" : normalized + return ". \(clipped)" + }() + switch syncService.connectionState { - case .connected, .syncing: - if let name = syncService.hostName, !name.isEmpty { - return "Connected to \(name). Tap to open settings." + case .connected: + if let name = truncatedHostName { + 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 "Not connected. 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" } } @@ -510,17 +500,31 @@ 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) + .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/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index 393b80833..c34ab55f5 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -2,12 +2,28 @@ 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 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 { HStack(alignment: .center, spacing: 12) { Image(systemName: fileIcon(for: relativePath)) @@ -38,9 +54,9 @@ struct FilesHeaderStrip: View { Text("Read only") .font(.caption2.weight(.medium)) .foregroundStyle(ADEColor.textSecondary) - if !isFilesLive { + 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/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..3e39ddff8 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -8,8 +8,6 @@ struct FilesDetailScreen: View { let workspace: FilesWorkspace let relativePath: String let focusLine: Int? - let isFilesLive: Bool - let needsRepairing: Bool let transitionNamespace: Namespace.ID? let navigateToDirectory: (String) -> Void @@ -94,12 +92,17 @@ 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") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + ADEConnectionDot() } - .accessibilityLabel("Back") } ToolbarItem(placement: .topBarTrailing) { Button { @@ -157,22 +160,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", @@ -188,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/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..852a5a049 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -27,11 +27,17 @@ struct FilesRootScreen: View { NavigationStack(path: $navigationPath) { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { - if let presentation = statusPresentation { - statusNoticeCard(presentation) - .transition(.opacity) + 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", @@ -49,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 { @@ -151,8 +160,6 @@ struct FilesRootScreen: View { showHidden: showHidden, isLive: canUseLiveFileActions, isTabActive: isTabActive, - needsRepairing: needsRepairing, - showDisconnectedNotice: false, openDirectory: { path in openDirectory(path, in: workspace) }, @@ -185,7 +192,6 @@ struct FilesRootScreen: View { showHidden: $showHidden, isLive: canUseLiveFileActions, isTabActive: isTabActive, - needsRepairing: needsRepairing, openDirectory: { path in openDirectory(path, in: workspace) }, @@ -211,8 +217,6 @@ struct FilesRootScreen: View { workspace: workspace, relativePath: relativePath, focusLine: focusLine, - isFilesLive: canUseLiveFileActions, - needsRepairing: needsRepairing, transitionNamespace: transitionNamespace, navigateToDirectory: { path in openDirectory(path, in: workspace) @@ -232,7 +236,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..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) } @@ -55,9 +51,16 @@ struct LanesTabView: View { NavigationStack { ScrollView { LazyVStack(spacing: 14) { - if let statusNotice { - noticeCard(statusNotice) - .transition(.opacity) + 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( 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() diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 8d655dfc5..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 } @@ -218,10 +214,6 @@ struct PRsTabView: View { var body: some View { NavigationStack(path: $path) { List { - if let statusNotice { - statusNotice.prListRow() - } - if let notice = laneContextNotice { notice.prListRow() } @@ -232,6 +224,17 @@ struct PRsTabView: View { .prListRow() } } else { + 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", @@ -294,7 +297,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 +420,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 +821,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..0b887a5ec 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 } @@ -222,12 +218,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) @@ -235,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, @@ -337,7 +339,7 @@ struct WorkRootScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - ADEConnectionPill() + ADEConnectionDot() } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 8) { @@ -397,8 +399,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 95764fc1b..5839a81f4 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: ""), 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: