Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/main/services/github/githubService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/main/services/orchestrator/workerTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,73 @@ 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() : "";
// 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;
const planMarkdown =
reportedPlan && typeof reportedPlan.markdown === "string" ? reportedPlan.markdown.trim() : "";
if (!planMarkdown.length) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This helper treats report_result.plan.markdown as sufficient proof that recovery succeeded:

// apps/desktop/src/main/services/orchestrator/workerTracking.ts
const reportedPlan = lastResultReport && isRecord(lastResultReport.plan) ? lastResultReport.plan : null;
const planMarkdown =
  reportedPlan && typeof reportedPlan.markdown === "string" ? reportedPlan.markdown.trim() : "";
if (!planMarkdown.length) return;

updateWorkerStateFromEventCtx() invokes it immediately after extractAndRegisterArtifacts(), but that function wraps plan-file writes and artifact registration in a top-level artifact_extraction_failed catch. When a recovery attempt returns markdown but fs.writeFileSync / registerArtifact throws, ADE skips persisting .ade/plans/mission-plan.md and still auto-resolves the old planner_plan_missing intervention here, allowing the mission to leave intervention_required without the canonical plan artifact. Gate this resolution on confirmed plan artifact persistence/registration instead of the reported markdown alone.


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() : "";
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
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;
Expand Down Expand Up @@ -1275,6 +1342,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);
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/renderer/components/app/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> {
return globalThis.setTimeout(handler, ms);
}

function cancelPaletteTimeout(id: ReturnType<typeof setTimeout>) {
globalThis.clearTimeout(id);
}

export type CommandPaletteIntent = "default" | "project-browse";

type Command = {
Expand Down Expand Up @@ -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) => {
Expand All @@ -450,7 +458,7 @@ export function CommandPalette({
});
}, 140);
return () => {
window.clearTimeout(timeout);
cancelPaletteTimeout(timeout);
};
}, [detail, detailTarget, mode, open]);

Expand Down
24 changes: 24 additions & 0 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
Expand Down
102 changes: 53 additions & 49 deletions apps/ios/ADE/Views/Components/ADEDesignSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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)"
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 "Not connected. Tap to open settings."
return "Connected"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case .syncing:
return "Syncing with host"
case .connecting:
return "Connecting to host"
case .error:
return "Connection error\(errorSuffix)"
case .disconnected:
return "Disconnected from host"
}
}

Expand All @@ -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()
}
}

Expand Down
10 changes: 8 additions & 2 deletions apps/ios/ADE/Views/Files/FilesDetailComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

var body: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: fileIcon(for: relativePath))
Expand Down Expand Up @@ -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))
Expand Down
20 changes: 0 additions & 20 deletions apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
)
}
}
Loading
Loading